lib

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

commit d203459ae0f50cd06c6d3e19f21cf68d41ab680e
parent a1c523bee9b4a7f478b54e77c9064992c788bd9c
Author: triesap <tyson@radroots.org>
Date:   Wed, 29 Jul 2026 08:40:46 +0000

release: reconcile Phase 1 with crate surface

- Preserve the crate-surface refactor while retaining the governed Phase 1 readiness extension.
- Close decoder, event, identity, NIP-09, outbox, package, and contract conformance.
- Pin every Rust package and support crate to 0.1.0-alpha under the active authority.
- Validate formatting, feature matrices, tests, contracts, deterministic fuzz smoke, and Nix checks.

Diffstat:
MAGENTS.md | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
ACONTRIBUTING.md | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.lock | 71++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
MCargo.toml | 57++++++++++++++++++++++++++++++++++++++++++++-------------
Mbuild/nix/apps.nix | 7+++++++
Mbuild/nix/checks.nix | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbuild/nix/common.nix | 64++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Acontracts/codegen/protocol_v1.inventory.json | 866+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/codegen/protocol_v1.inventory.sha256 | 1+
Acontracts/codegen/protocol_v1.toml | 32++++++++++++++++++++++++++++++++
Mcontracts/conformance/schema/vector.schema.json | 31++++++++++++++++++++++++++++---
Mcontracts/conformance/vectors/blossom/bud11_claims.v1.json | 24++++++++++++++++++++++++
Mcontracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json | 10++++++++++
Mcontracts/conformance/vectors/calendar/nip52_baseline.v1.json | 132++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcontracts/conformance/vectors/calendar/radroots_profile.v1.json | 132++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcontracts/conformance/vectors/comment/verified_profile.v1.json | 2+-
Acontracts/conformance/vectors/core/value_serialization.v1.json | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/conformance/vectors/deletion/verified_profile.v1.json | 134++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcontracts/conformance/vectors/event/nip01_wire.v1.json | 14+++++++-------
Dcontracts/conformance/vectors/identity/.gitkeep | 1-
Acontracts/conformance/vectors/identity/public_values.v1.json | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/conformance/vectors/post/verified_profiles.v1.json | 248++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcontracts/conformance/vectors/profile/verified_event.v1.json | 28++++++++++++++--------------
Acontracts/conformance/vectors/protocol/wire_values.v1.json | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/coverage.toml | 7+++++++
Mcontracts/event_store_production_sources.toml | 28++++++++++++++--------------
Mcontracts/events/blossom-authorization.md | 18+++++++++---------
Mcontracts/events/blossom-media.md | 28++++++++++++++--------------
Mcontracts/events/profile-metadata.md | 2+-
Mcontracts/operations.toml | 174+++++++++++++++++++++++++++++++++++++------------------------------------------
Mcontracts/releases/1.0.0-alpha.1.toml | 4++--
Acontracts/releases/api_boundaries.toml | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/releases/package_tiers.toml | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/releases/publish_policy.toml | 113+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mcrates/authority/Cargo.toml | 7++++++-
Mcrates/authority/src/actor.rs | 58+++++++++++++++++++++++++++++++---------------------------
Mcrates/authority/src/authorization.rs | 69+++++++++++++++++++++++++++++++++++----------------------------------
Mcrates/authority/src/local_signer.rs | 15++++++++-------
Mcrates/authority/src/signer.rs | 29+++++++++++++++--------------
Mcrates/blossom/Cargo.toml | 15+++++++++------
Dcrates/blossom/README | 74--------------------------------------------------------------------------
Acrates/blossom/README.md | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/blossom/contracts/raster_decoder_security_v1.descriptor.json | 10+++++-----
Mcrates/blossom/contracts/raster_decoder_security_v1.manifest.json | 36++++++++++++++++++++++++++----------
Mcrates/blossom/contracts/raster_decoder_security_v1.manifest.schema.json | 8++++++++
Mcrates/blossom/contracts/raster_decoder_security_v1.manifest.sha256 | 2+-
Acrates/blossom/examples/verified_descriptor.rs | 27+++++++++++++++++++++++++++
Mcrates/blossom/src/authorization.rs | 644++++++++++++++++++++++++++++++++++---------------------------------------------
Mcrates/blossom/src/descriptor.rs | 302+++++++++++++++++++++++++++++++++----------------------------------------------
Mcrates/blossom/src/error.rs | 300++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/blossom/src/hash.rs | 167++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mcrates/blossom/src/lib.rs | 37+++++++++++++++----------------------
Acrates/blossom/src/media_type.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/blossom/src/publication_readiness.rs | 825++++++++++++++++++++++++++++++++++---------------------------------------------
Mcrates/blossom/src/publication_readiness/sequential_jpeg.rs | 135++++++++++++++++++++++++++++++++++++-------------------------------------------
Mcrates/blossom/src/url.rs | 160++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mcrates/blossom/tests/bud11_conformance.rs | 154+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mcrates/blossom/tests/conformance.rs | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mcrates/blossom/tests/decoder_security.rs | 41+++++++++++++++--------------------------
Mcrates/blossom/tests/fixtures/bud11_claims.v1.json | 24++++++++++++++++++++++++
Mcrates/blossom/tests/fixtures/hash_path_and_descriptor.v1.json | 10++++++++++
Acrates/blossom/tests/package_boundary.rs | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/blossom/tests/parser_properties.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/blossom/tests/publication_readiness.rs | 175+++++++++++++++++++++++++++++++------------------------------------------------
Mcrates/blossom/tests/publication_readiness_persistence.rs | 17++++++-----------
Mcrates/core/Cargo.toml | 17+++++++++--------
Dcrates/core/README | 21---------------------
Acrates/core/README.md | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/core/examples/checked_pricing.rs | 14++++++++++++++
Mcrates/core/src/currency.rs | 68++++++++++++++++++++++++++++++++++++++------------------------------
Mcrates/core/src/decimal.rs | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcrates/core/src/discount.rs | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Acrates/core/src/error.rs | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/core/src/generated/dto_roots.rs | 23++++++++++-------------
Mcrates/core/src/lib.rs | 40++++++++++++++++++----------------------
Mcrates/core/src/money.rs | 228+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mcrates/core/src/percent.rs | 70+++++++++++++++++++++++++++++++++++++++++-----------------------------
Acrates/core/src/pricing.rs | 10++++++++++
Mcrates/core/src/quantity.rs | 213++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mcrates/core/src/quantity_price.rs | 280+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mcrates/core/src/serde_ext.rs | 13++++---------
Mcrates/core/src/unit.rs | 253+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcrates/core/tests/common/mod.rs | 25+++++++++++--------------
Mcrates/core/tests/currency.rs | 62++++++++++++++++++++++++++++----------------------------------
Mcrates/core/tests/decimal.rs | 102++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mcrates/core/tests/discount.rs | 83++++++++++++++++++++++++++++++++++++-------------------------------------------
Acrates/core/tests/error.rs | 40++++++++++++++++++++++++++++++++++++++++
Mcrates/core/tests/money.rs | 233++++++++++++++++++++++++++++---------------------------------------------------
Acrates/core/tests/package_boundary.rs | 33+++++++++++++++++++++++++++++++++
Mcrates/core/tests/percent.rs | 63++++++++++++++++++++++++++++++++++++---------------------------
Acrates/core/tests/properties.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/core/tests/quantity.rs | 256++++++++++++++++++++++++++-----------------------------------------------------
Mcrates/core/tests/quantity_price.rs | 298+++++++++++++++++++++++++++----------------------------------------------------
Mcrates/core/tests/serde.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/core/tests/unit.rs | 210++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/event/Cargo.toml | 38++++++++++++++++++++++++--------------
Rcrates/event/README -> crates/event/README.md | 0
Acrates/event/src/admission.rs | 7+++++++
Mcrates/event/src/calendar.rs | 50++++++++++++++++++++++++++------------------------
Mcrates/event/src/comment.rs | 71++++++++++++++++++++++++++++++++++++++---------------------------------
Mcrates/event/src/contract/registry_v7.rs | 24++++++++++++------------
Mcrates/event/src/contract/registry_v7/tests.rs | 52++++++++++++++++++++++++++--------------------------
Mcrates/event/src/deletion.rs | 28++++++++++++++++------------
Mcrates/event/src/draft.rs | 82++++++++++++++++++++++++++++++++++---------------------------------------------
Mcrates/event/src/envelope.rs | 24++++++++++--------------
Mcrates/event/src/event_head/v1.rs | 12++++++------
Mcrates/event/src/event_head/v1/tests.rs | 19++++++++++---------
Acrates/event/src/food.rs | 7+++++++
Mcrates/event/src/food_availability.rs | 25++++++++++---------------
Acrates/event/src/id.rs | 7+++++++
Mcrates/event/src/ids.rs | 57++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/event/src/knowledge.rs | 10+++++-----
Mcrates/event/src/lib.rs | 30++++++++++++++++++++++++++++++
Acrates/event/src/listing.rs | 7+++++++
Mcrates/event/src/media.rs | 31++++++++++++-------------------
Mcrates/event/src/operational_listing.rs | 30++++++++++++++++++------------
Mcrates/event/src/order.rs | 210+++++++++++++++++++++++++++++++------------------------------------------------
Mcrates/event/src/order_economics.rs | 49+++++++++++++++++++++++++++++++------------------
Mcrates/event/src/post.rs | 10+++++-----
Mcrates/event/src/profile.rs | 19++++++-------------
Mcrates/event/src/reply.rs | 34+++++++++++++++++++---------------
Mcrates/event/src/repost.rs | 4++--
Mcrates/event/src/resource_cap.rs | 11+++++++----
Mcrates/event/src/social.rs | 2+-
Acrates/event/src/tag.rs | 7+++++++
Mcrates/event/src/trade.rs | 27++++++++++++++-------------
Mcrates/event/src/wire/v1.rs | 12+++++-------
Mcrates/event/src/wire/v1/tests.rs | 2+-
Acrates/event/tests/package_boundary.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event/tests/source_boundary.rs | 4++++
Mcrates/event_codec/Cargo.toml | 28+++++++++++++++++++++-------
Rcrates/event_codec/README -> crates/event_codec/README.md | 0
Mcrates/event_codec/contracts/phase1_publication_media_readiness_v1.descriptor.json | 10+++++-----
Mcrates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.json | 33+++++++++++++++++++++++++++++----
Mcrates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.schema.json | 8++++++++
Mcrates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.sha256 | 2+-
Mcrates/event_codec/src/admission.rs | 21++++++++++++++++-----
Mcrates/event_codec/src/calendar/decode.rs | 4++--
Mcrates/event_codec/src/calendar/encode.rs | 2+-
Mcrates/event_codec/src/comment/authored.rs | 92+++++++++++++++++++++++++------------------------------------------------------
Mcrates/event_codec/src/comment/inbound/registry_v7.rs | 35++++++++++++++++++-----------------
Mcrates/event_codec/src/comment/inbound/registry_v7/tests.rs | 6+++---
Mcrates/event_codec/src/deletion/authored.rs | 38+++++++++++++++++---------------------
Mcrates/event_codec/src/deletion/reconciliation_v1.rs | 3+--
Mcrates/event_codec/src/deletion/reconciliation_v1/evaluator/tests.rs | 2+-
Mcrates/event_codec/src/deletion/reconciliation_v1/inbound/tests.rs | 2+-
Mcrates/event_codec/src/event_ref.rs | 5+++--
Mcrates/event_codec/src/farm/mod.rs | 27++++++++-------------------
Mcrates/event_codec/src/farm_crdt/mod.rs | 2+-
Mcrates/event_codec/src/farm_file/mod.rs | 2+-
Mcrates/event_codec/src/farm_workspace/mod.rs | 2+-
Mcrates/event_codec/src/food_availability/inbound/registry_v7.rs | 10+++++-----
Mcrates/event_codec/src/food_availability/revision.rs | 2+-
Mcrates/event_codec/src/http_auth/mod.rs | 2+-
Mcrates/event_codec/src/job/traits.rs | 8++++----
Mcrates/event_codec/src/knowledge/decode.rs | 2+-
Mcrates/event_codec/src/operational_listing/decode.rs | 65++++++++++++++++++++++++++++++++++-------------------------------
Mcrates/event_codec/src/operational_listing/encode.rs | 20+++++++++-----------
Mcrates/event_codec/src/operational_listing/tags.rs | 156+++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mcrates/event_codec/src/order/decode.rs | 52+++++++++++++++++++++++++---------------------------
Mcrates/event_codec/src/order/encode.rs | 18++++++++++++------
Mcrates/event_codec/src/parsed.rs | 4++--
Mcrates/event_codec/src/relay_auth/mod.rs | 2+-
Mcrates/event_codec/src/reply/authored.rs | 30++++++++++++------------------
Mcrates/event_codec/src/reply/inbound/registry_v7.rs | 23++++++++++++-----------
Mcrates/event_codec/src/reply/inbound/registry_v7/tests.rs | 54+++++++++++++++++++++++++++++-------------------------
Mcrates/event_codec/src/resource_cap/encode.rs | 7++-----
Mcrates/event_codec/src/resource_cap/mod.rs | 12+++---------
Mcrates/event_codec/src/test_fixtures.rs | 17+++++++++++++++++
Mcrates/event_codec/src/trade/mod.rs | 19++++++++++---------
Mcrates/event_codec/src/verification/v1.rs | 4++--
Mcrates/event_codec/src/verification/v1/tests.rs | 4++--
Mcrates/event_codec/src/wire/publication.rs | 100++++++++++++++++++++++++++++++++++++-------------------------------------------
Mcrates/event_codec/src/wire/publication/media_readiness.rs | 68++++++++++++++++++++++++--------------------------------------------
Mcrates/event_codec/tests/app_data.rs | 2+-
Mcrates/event_codec/tests/article.rs | 2+-
Mcrates/event_codec/tests/calendar.rs | 17+++++++----------
Mcrates/event_codec/tests/calendar_conformance.rs | 16++++++----------
Mcrates/event_codec/tests/common/mod.rs | 2+-
Mcrates/event_codec/tests/coverage_edges.rs | 2+-
Mcrates/event_codec/tests/domain_encode_non_serde.rs | 79++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mcrates/event_codec/tests/fixtures/calendar_nip52_baseline.v1.json | 132++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/event_codec/tests/fixtures/calendar_radroots_profile.v1.json | 132++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/event_codec/tests/fixtures/comment_verified_profile.v1.json | 2+-
Mcrates/event_codec/tests/fixtures/deletion_verified_profile.v1.json | 134++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/event_codec/tests/fixtures/post_verified_profiles.v1.json | 248++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/event_codec/tests/fixtures/profile_verified_event.v1.json | 28++++++++++++++--------------
Mcrates/event_codec/tests/food_availability_conformance.rs | 13+++++--------
Mcrates/event_codec/tests/job_traits.rs | 14+++++++-------
Mcrates/event_codec/tests/knowledge.rs | 2+-
Mcrates/event_codec/tests/knowledge_fixtures.rs | 2+-
Mcrates/event_codec/tests/message.rs | 2+-
Mcrates/event_codec/tests/operational_listing.rs | 137++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mcrates/event_codec/tests/post.rs | 42+++++++++++++++++-------------------------
Mcrates/event_codec/tests/profile.rs | 9++++-----
Mcrates/event_codec/tests/profile_conformance.rs | 9+++------
Mcrates/event_codec/tests/publication_artifact.rs | 27++++++++++++---------------
Mcrates/event_codec/tests/publication_media_readiness.rs | 11++++-------
Mcrates/event_codec/tests/report.rs | 2+-
Mcrates/event_codec/tests/repost.rs | 2+-
Mcrates/event_codec/tests/structured_decode.rs | 9+++------
Mcrates/event_codec/tests/structured_encode_default.rs | 30+++++++++---------------------
Mcrates/event_codec/tests/tag_builders.rs | 46++++++++++++++++++++--------------------------
Mcrates/event_codec/tests/verified_comment_conformance.rs | 10+++++-----
Mcrates/event_codec/tests/verified_deletion_conformance.rs | 2+-
Mcrates/event_codec/tests/verified_post_conformance.rs | 19++++++++-----------
Mcrates/event_codec/tests/verified_profile_conformance.rs | 19+++++++++++++------
Mcrates/event_codec/tests/wire.rs | 2+-
Mcrates/event_index/Cargo.toml | 2+-
Mcrates/event_store/Cargo.toml | 4++++
Mcrates/event_store/src/error.rs | 2++
Mcrates/event_store/src/model.rs | 22++++++++++++----------
Mcrates/event_store/src/model/addressable_transition_feed_v1.rs | 15++++++++-------
Mcrates/event_store/src/model/food_availability_projection_v1.rs | 15++++++++-------
Mcrates/event_store/src/nip09/reconciliation_v1.rs | 103+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcrates/event_store/src/nip09/reconciliation_v1/result_vector_executor.rs | 2+-
Mcrates/event_store/src/nip09/reconciliation_v1/visibility_oracle_v1.rs | 31+++++++++++++++++--------------
Mcrates/event_store/src/source_maintenance_v1.rs | 2+-
Mcrates/event_store/src/store.rs | 62++++++++++++++++++++++++++++++++++----------------------------
Mcrates/event_store/src/store/addressable_transition_feed_v1.rs | 60++++++++++++++++++++++++++----------------------------------
Mcrates/event_store/src/store/food_availability_projection_v1.rs | 31++++++++++++++++---------------
Mcrates/event_store/src/store/post_core_extensions_v1.rs | 2+-
Mcrates/event_store/src/store/post_core_storage_v1.rs | 12++++++------
Mcrates/event_store/src/store/protocol_reconciliation_v1.rs | 10+++++-----
Mcrates/event_store/src/store/protocol_storage_v1.rs | 16++++++++--------
Mcrates/event_store/tests/food_availability_projection_v1_result_vector.rs | 19++++++++++---------
Mcrates/geocoder/Cargo.toml | 2+-
Acrates/geonames/Cargo.toml | 18++++++++++++++++++
Acrates/geonames/README.md | 7+++++++
Acrates/geonames/src/lib.rs | 1+
Mcrates/identity/Cargo.toml | 48++++++++++++++++--------------------------------
Dcrates/identity/README | 25-------------------------
Acrates/identity/README.md | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/identity/examples/public_identity.rs | 21+++++++++++++++++++++
Acrates/identity/src/account.rs | 357+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/identity/src/error.rs | 105++++++++++++++++++++++++++++++++-----------------------------------------------
Dcrates/identity/src/identity.rs | 552-------------------------------------------------------------------------------
Acrates/identity/src/key.rs | 369+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/identity/src/lib.rs | 36++++++++++--------------------------
Acrates/identity/src/profile.rs | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcrates/identity/src/storage.rs | 663-------------------------------------------------------------------------------
Dcrates/identity/src/test_fixtures.rs | 107-------------------------------------------------------------------------------
Mcrates/identity/src/username.rs | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Acrates/identity/tests/account_values.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/identity/tests/conformance.rs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcrates/identity/tests/identity.rs | 1027-------------------------------------------------------------------------------
Acrates/identity/tests/package_boundary.rs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/identity/tests/public_identifiers.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/identity/tests/retired_surface.rs | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/log/Cargo.toml | 2+-
Mcrates/net/src/net.rs | 21+++++++++++++++------
Mcrates/nostr/Cargo.toml | 13++++++++++---
Rcrates/nostr/README -> crates/nostr/README.md | 0
Mcrates/nostr/src/blossom.rs | 32+++++++++++++++-----------------
Mcrates/nostr/src/client.rs | 12++----------
Mcrates/nostr/src/draft_signing.rs | 16+++++++++-------
Mcrates/nostr/src/event_verify.rs | 6+++---
Mcrates/nostr/src/events/comment.rs | 3++-
Mcrates/nostr/src/job_adapter.rs | 4++--
Mcrates/nostr/tests/blossom_conformance.rs | 66++++++++++++++++++++++++++++++++++--------------------------------
Mcrates/nostr/tests/food_availability_profile.rs | 14+++++---------
Mcrates/nostr/tests/post_profile.rs | 13+++++--------
Mcrates/nostr_accounts/Cargo.toml | 6++++--
Mcrates/nostr_accounts/src/error.rs | 8++++----
Mcrates/nostr_accounts/src/lib.rs | 5+----
Mcrates/nostr_accounts/src/manager.rs | 609++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mcrates/nostr_accounts/src/model.rs | 44+++-----------------------------------------
Mcrates/nostr_accounts/src/nostrdb_bridge.rs | 18++++++++++--------
Mcrates/nostr_accounts/src/vault.rs | 4++--
Mcrates/nostr_connect/Cargo.toml | 13++++++++-----
Rcrates/nostr_connect/README -> crates/nostr_connect/README.md | 0
Mcrates/nostr_signer/Cargo.toml | 3++-
Mcrates/nostr_signer/src/backend.rs | 100+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mcrates/nostr_signer/src/capability.rs | 92++++++++++++++++++++++++++++---------------------------------------------------
Mcrates/nostr_signer/src/evaluation.rs | 60++++++++----------------------------------------------------
Mcrates/nostr_signer/src/manager.rs | 181++++++++-----------------------------------------------------------------------
Mcrates/nostr_signer/src/model.rs | 18+++++++++---------
Mcrates/nostr_signer/src/nip46.rs | 54++++++++++++++++++++++++++++++------------------------
Mcrates/nostr_signer/src/store.rs | 16++++++++--------
Mcrates/nostr_signer/src/test_support.rs | 35+++++++++++++++++++++--------------
Mcrates/outbox/Cargo.toml | 3+++
Mcrates/outbox/contracts/migration_authority_v1.manifest.json | 28++++++++++++++--------------
Mcrates/outbox/contracts/migration_authority_v1.manifest.sha256 | 2+-
Mcrates/outbox/contracts/phase1_publication_v1.manifest.json | 20++++++++++----------
Mcrates/outbox/contracts/phase1_publication_v1.manifest.sha256 | 2+-
Mcrates/outbox/src/phase1_publication.rs | 12+++++++-----
Mcrates/outbox/src/store.rs | 97++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mcrates/protected_store/Cargo.toml | 2+-
Acrates/protocol/Cargo.toml | 33+++++++++++++++++++++++++++++++++
Acrates/protocol/README.md | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protocol/examples/inspect_contract.rs | 12++++++++++++
Acrates/protocol/src/capability.rs | 4++++
Acrates/protocol/src/capability/v1.rs | 370+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protocol/src/error.rs | 4++++
Acrates/protocol/src/error/v1.rs | 885+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protocol/src/event.rs | 4++++
Acrates/protocol/src/event/v1.rs | 469+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protocol/src/lib.rs | 26++++++++++++++++++++++++++
Acrates/protocol/src/radrootsd.rs | 7+++++++
Acrates/protocol/src/radrootsd/transport_publish/v5.rs | 1273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protocol/src/runtime.rs | 4++++
Acrates/protocol/src/runtime/v1.rs | 1180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protocol/src/schema.rs | 551+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protocol/tests/conformance.rs | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protocol/tests/package_boundary.rs | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/protocol_contract_v1/Cargo.toml | 11++++++++---
Mcrates/protocol_contract_v1/README | 9+++++++++
Mcrates/protocol_contract_v1/src/lib.rs | 83+++++++++++++++++++++++--------------------------------------------------------
Acrates/protocol_contract_v1/tests/successor_equivalence.rs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/replica_schema/Cargo.toml | 2+-
Mcrates/replica_store/Cargo.toml | 2+-
Mcrates/replica_sync/Cargo.toml | 2+-
Mcrates/replica_sync/src/ingest.rs | 251+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mcrates/replica_sync/tests/ingest_roundtrip.rs | 80++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcrates/runtime/Cargo.toml | 2+-
Mcrates/runtime_distribution/Cargo.toml | 2+-
Mcrates/runtime_manager/Cargo.toml | 2+-
Mcrates/runtime_paths/Cargo.toml | 2+-
Mcrates/runtime_store/Cargo.toml | 2+-
Mcrates/secret_vault/Cargo.toml | 2+-
Acrates/secrets/Cargo.toml | 18++++++++++++++++++
Acrates/secrets/README.md | 7+++++++
Acrates/secrets/src/lib.rs | 1+
Acrates/signing/Cargo.toml | 18++++++++++++++++++
Acrates/signing/README.md | 6++++++
Acrates/signing/src/lib.rs | 1+
Mcrates/sql_core/Cargo.toml | 2+-
Acrates/storage/Cargo.toml | 18++++++++++++++++++
Acrates/storage/README.md | 7+++++++
Acrates/storage/src/lib.rs | 1+
Acrates/storage_sqlite/Cargo.toml | 18++++++++++++++++++
Acrates/storage_sqlite/README.md | 7+++++++
Acrates/storage_sqlite/src/lib.rs | 1+
Acrates/sync/Cargo.toml | 18++++++++++++++++++
Acrates/sync/README.md | 6++++++
Acrates/sync/src/lib.rs | 1+
Mcrates/trade/Cargo.toml | 18++++++++++++------
Rcrates/trade/README -> crates/trade/README.md | 0
Mcrates/trade/src/dto.rs | 20++++++++++----------
Mcrates/trade/src/identity.rs | 31++++++++++++++++++-------------
Mcrates/trade/src/operational_listing/draft.rs | 113++++++++++++++++++++++++++++++++++++-------------------------------------------
Mcrates/trade/src/operational_listing/mod.rs | 15++++++++-------
Mcrates/trade/src/operational_listing/model.rs | 16++++++++--------
Mcrates/trade/src/operational_listing/mutation.rs | 49++++++++++++++++++-------------------------------
Mcrates/trade/src/operational_listing/price_ext.rs | 153+++++++++++++++++++++++++++++--------------------------------------------------
Mcrates/trade/src/operational_listing/validation.rs | 178++++++++++++++++++++++++++++++-------------------------------------------------
Mcrates/trade/src/validation_receipt.rs | 35++++++++++++++++++++---------------
Mcrates/trade/src/workflow.rs | 69++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/trade/tests/operational_listing_conformance.rs | 5+++--
Mcrates/transport/Cargo.toml | 13++++++++-----
Rcrates/transport/README -> crates/transport/README.md | 0
Mcrates/transport_nostr/Cargo.toml | 12++++++++----
Rcrates/transport_nostr/README -> crates/transport_nostr/README.md | 0
Mcrates/transport_nostr/src/publish.rs | 6+++---
Mcrates/transport_nostr/tests/phase1_outbox_publication.rs | 36+++++++++++++++++-------------------
Mcrates/transport_publish_protocol/Cargo.toml | 4++++
Mcrates/transport_publish_protocol/README | 8++++++++
Acrates/transport_publish_protocol/tests/successor_equivalence.rs | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/api/README.md | 36++++++++++++++++++++++++++++++++++++
Adocs/api/radroots-core.txt | 521+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/api/radroots_blossom.txt | 348+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/api/radroots_identity.txt | 378+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/api/radroots_protocol.txt | 780+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/decisions/0001-public-api-leakage-migration-baseline.md | 36++++++++++++++++++++++++++++++++++++
Adocs/engineering/ci.md | 24++++++++++++++++++++++++
Adocs/implementation/DEPENDENCY_RESOLUTION.md | 14++++++++++++++
Adocs/implementation/DEVIATIONS.md | 46++++++++++++++++++++++++++++++++++++++++++++++
Adocs/implementation/HISTORY_PRESERVATION.md | 25+++++++++++++++++++++++++
Adocs/implementation/PUBLICATION_FREEZE.md | 24++++++++++++++++++++++++
Adocs/implementation/STEP_REPORT_TEMPLATE.md | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/implementation/TRACEABILITY.md | 21+++++++++++++++++++++
Adocs/implementation/deviations.toml | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/migration/identity.md | 25+++++++++++++++++++++++++
Adocs/specs/README.md | 40++++++++++++++++++++++++++++++++++++++++
Adocs/specs/radroots_crates_release_v1.dot | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/specs/radroots_crates_release_v1.md | 1589+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/specs/radroots_crates_release_v1.sha256 | 4++++
Adocs/specs/radroots_crates_release_v1.toml | 664+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/specs/radroots_crates_release_v1_inventory.csv | 20++++++++++++++++++++
Mflake.lock | 6+++---
Mfuzz/Cargo.lock | 2+-
Mfuzz/Cargo.toml | 2+-
Mfuzz/fuzz_targets/common.rs | 23+++++++++++------------
Mrust-toolchain.toml | 2+-
Mtools/xtask/Cargo.toml | 2+-
Atools/xtask/src/architecture.rs | 1457+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/architecture/api_leakage.rs | 1433+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/architecture/core_contract.rs | 591+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/architecture/dependency_boundary.rs | 718+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract.rs | 1034+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mtools/xtask/src/contract/admission_authority.rs | 4++--
Mtools/xtask/src/contract/blossom_publication_readiness.rs | 130++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mtools/xtask/src/contract/blossom_raster_decoder_security.rs | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mtools/xtask/src/contract/food_availability_projection.rs | 141++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mtools/xtask/src/contract/nip09_reconciliation.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mtools/xtask/src/contract/phase1_publication_artifact.rs | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mtools/xtask/src/contract/phase1_publication_media_readiness.rs | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mtools/xtask/src/contract/raw_source_rebuild.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mtools/xtask/src/contract/source_maintenance.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/generate/mod.rs | 10++++++++++
Atools/xtask/src/generate/protocol.rs | 642+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/hygiene.rs | 20+++++++++++++++++++-
Mtools/xtask/src/main.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++++----
Atools/xtask/tests/fixtures/api-leakage/adr-exception.rs | 3+++
Atools/xtask/tests/fixtures/api-leakage/allowed-concrete-adapter.rs | 3+++
Atools/xtask/tests/fixtures/api-leakage/generic-renamed-tokio.rs | 5+++++
Atools/xtask/tests/fixtures/api-leakage/generic-sqlx.rs | 5+++++
Atools/xtask/tests/fixtures/api-leakage/private-implementation.rs | 11+++++++++++
Atools/xtask/tests/fixtures/dependency-boundaries/domain-to-storage.json | 46++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/tests/fixtures/dependency-boundaries/service-to-sdk.json | 46++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/tests/fixtures/dependency-boundaries/spi-to-adapter.json | 46++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/tests/fixtures/dependency-boundaries/valid-downward.json | 46++++++++++++++++++++++++++++++++++++++++++++++
412 files changed, 29173 insertions(+), 10081 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -1,6 +1,7 @@ # Radroots Core Libraries - Agent Specification -See [AGENT_INSTRUCTIONS.md](AGENT_INSTRUCTIONS.md) for full instructions. +See [CONTRIBUTING.md](CONTRIBUTING.md) for the contributor workflow and +[AGENT_INSTRUCTIONS.md](AGENT_INSTRUCTIONS.md) for extended execution detail. This file exists for compatibility with tools that look for AGENTS.md. @@ -11,7 +12,22 @@ This file exists for compatibility with tools that look for AGENTS.md. - Put detailed procedures, examples, and extended guidance in `AGENT_INSTRUCTIONS.md`. - If a closer directory-level `AGENTS.md` is added later, it overrides this file for that subtree. -## 2. Repository operating model +## 2. Source of intent + +- Read `docs/specs/README.md` and + `docs/specs/radroots_crates_release_v1.md` before changing a public package, + package identity, dependency, feature, or release control. +- The Markdown specification is normative. Its TOML catalog is the executable + package and dependency representation; the CSV and DOT files are review + aids. +- Current source and tests are implementation evidence. They do not silently + override `radroots.crates.release.v1`. +- Record any evidence-based plan deviation in + `docs/implementation/deviations.toml`, following + `docs/implementation/DEVIATIONS.md`, before proceeding. Validate it with + `cargo xtask architecture`. + +## 3. Repository operating model - This is a public open-source library workspace; optimize for durable library design, portability, determinism, and explicit contracts. - Keep release and validation automation forge-agnostic; repo-owned xtask commands, Nix apps, tags, and contract metadata are canonical, while committed provider-specific workflow automation is not. @@ -19,9 +35,11 @@ This file exists for compatibility with tools that look for AGENTS.md. - Stay within the requested scope and the smallest coherent file set. - Do not fold unrelated cleanup, speculative refactors, or roadmap work into the same change. - Do not create hidden task trackers in markdown checklists, source comments, or stray notes. -- Keep commits and handoff language standalone and open-source-readable; do not reference internal monorepo paths, internal mapping rationale, or private repository context. +- Keep commits and handoff language standalone and open-source-readable; do + not reference non-public repository paths, internal mapping rationale, or + private repository context. -## 3. Preflight before edits +## 4. Preflight before edits Before editing code: @@ -33,20 +51,23 @@ Before editing code: - Inspect `git status --short` before broad edits or refactors. - Fail early when the task is blocked by missing prerequisites, contaminated scope, or unresolved public contract questions. -## 4. Canonical command surface +## 5. Canonical command surface - `nix flake check` - `nix run .#contract` - `nix run .#release-preflight` +- `cargo xtask architecture` for controlled deviation records and local spec + anchors - targeted `cargo check -p <crate>` and `cargo test -p <crate>` only inside the Nix shell - `cargo xtask dto-roots --write` after changing configured DTO exports and `cargo xtask dto-roots --check` for exact generated-root freshness - targeted `cargo xtask contract ...`, `cargo xtask coverage ...`, `cargo xtask release ...`, or `cargo xtask hygiene ...` only when narrowing a repo-owned workflow - if Beads is active, read `.beads/PRIME.md` -## 5. Rust engineering rules +## 6. Rust engineering rules -- Use Rust `1.97.0`, edition `2024`, and workspace dependency versions from the root `Cargo.toml`. +- Use Rust `1.97.1`, edition `2024`, resolver `3`, and workspace dependency + versions from the root `Cargo.toml` after the release-v1 workspace cutover. - Preserve intended `no_std` portability; gate `std`, wasm, and runtime-specific behavior explicitly. - Keep core logic functional and composable: prefer pure transformations, explicit state, and narrow side-effect boundaries. - Prefer enums, newtypes, and typed domain models over stringly APIs, boolean mode switches, or loosely typed maps. @@ -58,21 +79,47 @@ Before editing code: - Treat generated bindings and generated type artifacts as generated; do not hand-edit them. - Add or update deterministic tests for new behavior, invariants, parsing, conversions, feature gates, and cross-target behavior where relevant. -## 6. Contract and release discipline +## 7. Architecture, contract, and release discipline - `contracts/` and `tools/xtask` are authoritative for core-library contracts, conformance, coverage, hygiene, and release-candidate governance. - Behavior changes that affect public surfaces must update the relevant contract metadata, conformance vectors, export rules, or validation flows in the same change. - Keep pure flake checks and repo-aware command apps aligned with the documented Nix command map. - -## 7. Commit directives +- This repository owns packages 1-17 in `radroots.crates.release.v1`, from + `radroots_core` through `radroots_geonames`. `radroots_sdk` and `radroots` + remain owned by the standalone SDK repository. +- Public packages have no dependency on private Radroots packages. Every + Radroots dependency edge points downward in the approved graph. +- Domain and protocol packages do not own storage, live networking, host UI, + executors, schedulers, or process-global behavior. +- Generic SPIs do not expose concrete SQLx, Tokio, Reqwest, Nostr SDK, + keyring, or operating-system implementation types. +- Preview, code-generation, fixture, binding-generator, coverage, xtask, and + implementation-assembly packages remain private and absent from published + feature closures. +- During the migration, every package remains non-publishable until its + package-realistic release gates pass and publication is explicitly + authorized. Follow `docs/implementation/PUBLICATION_FREEZE.md`. + +## 8. Irreversible actions + +Do not publish crates, create release tags, change crates.io ownership, merge +or rename repositories, merge pull requests, rotate credentials, or mutate +trusted-publisher configuration without explicit authorization. + +## 9. Commit and deviation directives - Format commits as `<scope>: <imperative summary>`. - Use lowercase scopes that match the crate or subsystem being changed. - Leave a blank line after the summary when writing a multi-line commit. - Use `- ` bullets for notable changes, validations, or compatibility notes when a body is needed. - Split unrelated changes into separate commits. +- If repository evidence proves a planned step obsolete or unsafe, record the + evidence, affected specification anchor, disposition, and validation in + `docs/implementation/deviations.toml`, following + `docs/implementation/DEVIATIONS.md`. A normative architecture change also + requires an approved decision record. Never silently skip or reorder work. -## 8. Definition of done +## 10. Definition of done - The requested change is implemented. - Affected code, tests, docs, and contract surfaces are updated together. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing + +Radroots core-library changes are contract-driven and independently +reviewable. Before editing, read these files in order: + +1. `AGENTS.md` +2. `docs/specs/README.md` +3. `docs/specs/radroots_crates_release_v1.md` for crate-surface work +4. `AGENT_INSTRUCTIONS.md` +5. the affected manifests, implementation, contracts, and tests + +The release-v1 architecture identifier is `radroots.crates.release.v1`. This +repository owns its first 17 public packages, from `radroots_core` through +`radroots_geonames`; the standalone SDK repository owns `radroots_sdk` and +`radroots`. + +## Workflow + +1. Inspect repository status and the current source authority. +2. Make one coherent, commit-sized change. +3. Update public contracts, tests, fixtures, generated authorities, and docs + with the implementation they govern. +4. Run the narrowest repository-owned validation that proves the change, then + the broader contract or release lane required by its scope. +5. Review the staged diff for API leakage, private dependencies, generated + drift, secrets, hidden side effects, and unrelated changes. + +Canonical repository-wide lanes are `nix flake check`, +`nix run .#contract`, and `nix run .#release-preflight`. Targeted Rust work is +performed in the repository's Nix environment with the applicable format, +check, test, Clippy, contract, coverage, and generated-freshness commands. + +## Commits and deviations + +Use this commit form: + +```text +<scope>: <lower-case imperative summary> +``` + +Keep commits focused and keep public commit language independent of any +private checkout. Do not publish, tag, merge, or change registry ownership +without explicit authorization. + +When current evidence proves a planned step obsolete or unsafe, follow +`docs/implementation/DEVIATIONS.md` and validate the machine-readable ledger +with `cargo xtask architecture`. Complete +`docs/implementation/STEP_REPORT_TEMPLATE.md`, and keep +`docs/implementation/TRACEABILITY.md` aligned with durable requirements. +Record the evidence and affected spec anchor before changing the plan; do not +silently redefine the architecture. diff --git a/Cargo.lock b/Cargo.lock @@ -4596,6 +4596,7 @@ version = "0.1.0-alpha" dependencies = [ "radroots_event", "radroots_event_codec", + "radroots_identity", "radroots_nostr", "serde_json", ] @@ -4624,7 +4625,6 @@ version = "0.1.0-alpha" dependencies = [ "dto_bindgen", "rust_decimal", - "rust_decimal_macros", "serde", "serde_json", ] @@ -4638,6 +4638,8 @@ dependencies = [ "jiff-tzdb", "radroots_blossom", "radroots_core", + "radroots_identity", + "radroots_protocol", "secp256k1", "serde", "serde_json", @@ -4655,6 +4657,7 @@ dependencies = [ "radroots_blossom", "radroots_core", "radroots_event", + "radroots_identity", "radroots_test_fixtures", "serde", "serde_json", @@ -4682,6 +4685,7 @@ dependencies = [ "radroots_blossom", "radroots_event", "radroots_event_codec", + "radroots_identity", "radroots_transport", "serde", "serde_json", @@ -4710,21 +4714,17 @@ dependencies = [ ] [[package]] +name = "radroots_geonames" +version = "0.1.0-alpha" + +[[package]] name = "radroots_identity" version = "0.1.0-alpha" dependencies = [ - "nostr", - "radroots_protected_store", - "radroots_runtime", - "radroots_runtime_paths", - "radroots_secret_vault", - "secrecy", + "k256", "serde", "serde_json", - "tempfile", - "thiserror 1.0.69", - "tracing", - "zeroize", + "thiserror 2.0.18", ] [[package]] @@ -4813,6 +4813,7 @@ dependencies = [ name = "radroots_nostr_accounts" version = "0.1.0-alpha" dependencies = [ + "nostr", "radroots_identity", "radroots_nostr_signer", "radroots_protected_store", @@ -4893,6 +4894,7 @@ dependencies = [ "radroots_event", "radroots_event_codec", "radroots_event_store", + "radroots_identity", "radroots_nostr", "radroots_transport", "serde", @@ -4919,11 +4921,20 @@ dependencies = [ ] [[package]] +name = "radroots_protocol" +version = "0.1.0-alpha" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] name = "radroots_protocol_contract_v1" version = "0.1.0-alpha" dependencies = [ - "dto_bindgen", + "radroots_protocol", "serde", + "serde_json", ] [[package]] @@ -5041,6 +5052,14 @@ dependencies = [ ] [[package]] +name = "radroots_secrets" +version = "0.1.0-alpha" + +[[package]] +name = "radroots_signing" +version = "0.1.0-alpha" + +[[package]] name = "radroots_simplex_agent_proto" version = "0.1.0-alpha" dependencies = [ @@ -5167,6 +5186,18 @@ dependencies = [ ] [[package]] +name = "radroots_storage" +version = "0.1.0-alpha" + +[[package]] +name = "radroots_storage_sqlite" +version = "0.1.0-alpha" + +[[package]] +name = "radroots_sync" +version = "0.1.0-alpha" + +[[package]] name = "radroots_test_fixtures" version = "0.1.0-alpha" dependencies = [ @@ -5185,6 +5216,8 @@ dependencies = [ "radroots_core", "radroots_event", "radroots_event_codec", + "radroots_event_store", + "radroots_identity", "radroots_nostr", "radroots_test_fixtures", "radroots_transport", @@ -5250,6 +5283,7 @@ dependencies = [ "radroots_event", "radroots_event_codec", "radroots_event_store", + "radroots_identity", "radroots_nostr", "radroots_outbox", "radroots_transport", @@ -5268,6 +5302,7 @@ dependencies = [ name = "radroots_transport_publish_protocol" version = "0.1.0-alpha" dependencies = [ + "radroots_protocol", "radroots_transport", "serde", "serde_json", @@ -5667,16 +5702,6 @@ dependencies = [ ] [[package]] -name = "rust_decimal_macros" -version = "1.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74a5a6f027e892c7a035c6fddb50435a1fbf5a734ffc0c2a9fed4d0221440519" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] name = "rustc-demangle" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -9117,7 +9142,7 @@ dependencies = [ "proc-macro2", "quote", "radroots_event_codec", - "radroots_protocol_contract_v1", + "radroots_protocol", "semver", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml @@ -16,6 +16,13 @@ members = [ "crates/nostr", "crates/nostr_accounts", "crates/nostr_connect", + "crates/protocol", + "crates/signing", + "crates/secrets", + "crates/storage", + "crates/storage_sqlite", + "crates/sync", + "crates/geonames", "crates/nostr_signer", "crates/nostrdb", "crates/nostr_runtime", @@ -53,36 +60,55 @@ members = [ "crates/protected_store", "tools/xtask", ] -resolver = "2" +resolver = "3" [workspace.package] version = "0.1.0-alpha" edition = "2024" -rust-version = "1.97.0" +rust-version = "1.97.1" license = "MIT OR Apache-2.0" repository = "https://github.com/radrootslabs/lib" homepage = "https://radroots.org" readme = "README" +authors = ["Tyson Lupul <tyson@radroots.org>"] + +[workspace.metadata.radroots.public-package] +version = "0.1.0-alpha" +authors = ["Tyson Lupul <tyson@radroots.org>"] +readme = "README.md" + +[workspace.lints.rust] +unsafe_code = "forbid" +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } + +[workspace.lints.rustdoc] +broken_intra_doc_links = "deny" + +[workspace.lints.clippy] +dbg_macro = "deny" +todo = "deny" +unimplemented = "deny" [workspace.dependencies] async-wsocket = { version = "0.13.2" } dto_bindgen = { version = "0.1.0" } dto_bindgen_core = { version = "0.1.0" } -radroots_core = { path = "crates/core", version = "=0.1.0-alpha", default-features = false } -radroots_event = { path = "crates/event", version = "=0.1.0-alpha", default-features = false } +radroots_core = { package = "radroots_core", path = "crates/core", version = "=0.1.0-alpha", default-features = false } +radroots_event = { package = "radroots_event", path = "crates/event", version = "=0.1.0-alpha", default-features = false } radroots_event_store = { path = "crates/event_store", version = "=0.1.0-alpha", default-features = false } -radroots_event_codec = { path = "crates/event_codec", version = "=0.1.0-alpha", default-features = false } +radroots_event_codec = { package = "radroots_event_codec", path = "crates/event_codec", version = "=0.1.0-alpha", default-features = false } radroots_event_index = { path = "crates/event_index", version = "=0.1.0-alpha", default-features = false } radroots_authority = { path = "crates/authority", version = "=0.1.0-alpha", default-features = false } -radroots_blossom = { path = "crates/blossom", version = "=0.1.0-alpha", default-features = false } +radroots_blossom = { package = "radroots_blossom", path = "crates/blossom", version = "=0.1.0-alpha", default-features = false } radroots_geocoder = { path = "crates/geocoder", version = "=0.1.0-alpha" } -radroots_identity = { path = "crates/identity", version = "=0.1.0-alpha", default-features = false } +radroots_identity = { package = "radroots_identity", path = "crates/identity", version = "=0.1.0-alpha", default-features = false } radroots_runtime_store = { path = "crates/runtime_store", version = "=0.1.0-alpha", default-features = false } -radroots_nostr = { path = "crates/nostr", version = "=0.1.0-alpha", default-features = false } +radroots_nostr = { package = "radroots_nostr", path = "crates/nostr", version = "=0.1.0-alpha", default-features = false } radroots_nostr_accounts = { path = "crates/nostr_accounts", version = "=0.1.0-alpha", default-features = false } -radroots_nostr_connect = { path = "crates/nostr_connect", version = "=0.1.0-alpha", default-features = false } +radroots_nostr_connect = { package = "radroots_nostr_connect", path = "crates/nostr_connect", version = "=0.1.0-alpha", default-features = false } radroots_nostr_signer = { path = "crates/nostr_signer", version = "=0.1.0-alpha", default-features = false } radroots_nostrdb = { path = "crates/nostrdb", version = "=0.1.0-alpha", default-features = false } +radroots_protocol = { package = "radroots_protocol", path = "crates/protocol", version = "=0.1.0-alpha", default-features = false } radroots_runtime = { path = "crates/runtime", version = "=0.1.0-alpha", default-features = false } radroots_runtime_paths = { path = "crates/runtime_paths", version = "=0.1.0-alpha", default-features = false } radroots_runtime_distribution = { path = "crates/runtime_distribution", version = "=0.1.0-alpha", default-features = false } @@ -93,7 +119,7 @@ radroots_nostr_runtime = { path = "crates/nostr_runtime", version = "=0.1.0-alph radroots_outbox = { path = "crates/outbox", version = "=0.1.0-alpha", default-features = false } radroots_protocol_contract_v1 = { path = "crates/protocol_contract_v1", version = "=0.1.0-alpha", default-features = false } radroots_transport_publish_protocol = { path = "crates/transport_publish_protocol", version = "=0.1.0-alpha", default-features = false } -radroots_transport_nostr = { path = "crates/transport_nostr", version = "=0.1.0-alpha", default-features = false } +radroots_transport_nostr = { package = "radroots_transport_nostr", path = "crates/transport_nostr", version = "=0.1.0-alpha", default-features = false } radroots_transport_reticulum = { path = "crates/transport_reticulum", version = "=0.1.0-alpha", default-features = false } radroots_simplex_agent_proto = { path = "crates/simplex_agent_proto", version = "=0.1.0-alpha", default-features = false } radroots_simplex_agent_runtime = { path = "crates/simplex_agent_runtime", version = "=0.1.0-alpha", default-features = false } @@ -108,8 +134,14 @@ radroots_test_fixtures = { path = "crates/test_fixtures", version = "=0.1.0-alph radroots_replica_schema = { path = "crates/replica_schema", version = "=0.1.0-alpha", default-features = false } radroots_replica_sync = { path = "crates/replica_sync", version = "=0.1.0-alpha", default-features = false } radroots_replica_store = { path = "crates/replica_store", version = "=0.1.0-alpha", default-features = false } -radroots_trade = { path = "crates/trade", version = "=0.1.0-alpha", default-features = false } -radroots_transport = { path = "crates/transport", version = "=0.1.0-alpha", default-features = false } +radroots_geonames = { package = "radroots_geonames", path = "crates/geonames", version = "=0.1.0-alpha", default-features = false } +radroots_secrets = { package = "radroots_secrets", path = "crates/secrets", version = "=0.1.0-alpha", default-features = false } +radroots_signing = { package = "radroots_signing", path = "crates/signing", version = "=0.1.0-alpha", default-features = false } +radroots_storage = { package = "radroots_storage", path = "crates/storage", version = "=0.1.0-alpha", default-features = false } +radroots_storage_sqlite = { package = "radroots_storage_sqlite", path = "crates/storage_sqlite", version = "=0.1.0-alpha", default-features = false } +radroots_sync = { package = "radroots_sync", path = "crates/sync", version = "=0.1.0-alpha", default-features = false } +radroots_trade = { package = "radroots_trade", path = "crates/trade", version = "=0.1.0-alpha", default-features = false } +radroots_transport = { package = "radroots_transport", path = "crates/transport", version = "=0.1.0-alpha", default-features = false } radroots_mesh = { path = "crates/mesh", version = "=0.1.0-alpha", default-features = false } radroots_mesh_agent_client = { path = "crates/mesh_agent_client", version = "=0.1.0-alpha", default-features = false } radroots_mesh_agent_proto = { path = "crates/mesh_agent_proto", version = "=0.1.0-alpha", default-features = false } @@ -195,7 +227,6 @@ rustls = { version = "0.23", default-features = false, features = [ "std", ] } rust_decimal = { version = "1", default-features = false } -rust_decimal_macros = { version = "1" } x25519-dalek = { version = "2", default-features = false, features = [ "static_secrets", ] } diff --git a/build/nix/apps.nix b/build/nix/apps.nix @@ -54,6 +54,13 @@ let }; in { + architecture = mkRepoApp { + name = "architecture"; + description = "Run crates-release architecture and freshness checks"; + runtimeInputs = common.runtimeInputs.stable; + command = common.architectureCommand; + }; + check = mkRepoApp { name = "check"; description = "Run cargo check across the radroots workspace"; diff --git a/build/nix/checks.nix b/build/nix/checks.nix @@ -92,6 +92,82 @@ let installPhaseCommand = "mkdir -p $out"; } ); + coreConformance = common.craneLib.mkCargoDerivation ( + common.commonCraneArgs + // { + inherit (common) cargoArtifacts; + pname = "radroots-core-conformance"; + doCheck = false; + buildPhaseCargoCommand = '' + cargo check -p radroots_core --all-targets --no-default-features --locked + cargo check -p radroots_core --all-targets --no-default-features --features std --locked + cargo check -p radroots_core --all-targets --no-default-features --features serde --locked + cargo check -p radroots_core --all-targets --locked + cargo check -p radroots_core --all-targets --all-features --locked + cargo clippy -p radroots_core --all-targets --all-features --locked -- -D warnings + cargo test -p radroots_core --all-targets --all-features --locked + cargo check -p radroots_core --no-default-features --target wasm32-unknown-unknown --locked + cargo check -p radroots_core --no-default-features --features serde --target wasm32-unknown-unknown --locked + RUSTDOCFLAGS="-D warnings" cargo doc -p radroots_core --all-features --no-deps --locked + cargo test -p radroots_core --all-features --doc --locked + ''; + installPhaseCommand = "mkdir -p $out"; + } + ); + identityConformance = common.craneLib.mkCargoDerivation ( + common.commonCraneArgs + // { + inherit (common) cargoArtifacts; + pname = "radroots-identity-conformance"; + doCheck = false; + buildPhaseCargoCommand = '' + cargo check -p radroots_identity --all-targets --no-default-features --locked + cargo check -p radroots_identity --all-targets --no-default-features --features std --locked + cargo check -p radroots_identity --all-targets --no-default-features --features serde --locked + cargo check -p radroots_identity --all-targets --locked + cargo check -p radroots_identity --all-targets --all-features --locked + cargo clippy -p radroots_identity --all-targets --no-default-features --locked -- -D warnings + cargo clippy -p radroots_identity --all-targets --no-default-features --features serde --locked -- -D warnings + cargo clippy -p radroots_identity --all-targets --all-features --locked -- -D warnings + cargo test -p radroots_identity --all-targets --no-default-features --locked + cargo test -p radroots_identity --all-targets --no-default-features --features std --locked + cargo test -p radroots_identity --all-targets --no-default-features --features serde --locked + cargo test -p radroots_identity --all-targets --all-features --locked + cargo check -p radroots_identity --no-default-features --target wasm32-unknown-unknown --locked + cargo check -p radroots_identity --no-default-features --features serde --target wasm32-unknown-unknown --locked + RUSTDOCFLAGS="-D warnings" cargo doc -p radroots_identity --all-features --no-deps --locked + cargo test -p radroots_identity --all-features --doc --locked + ''; + installPhaseCommand = "mkdir -p $out"; + } + ); + blossomConformance = common.craneLib.mkCargoDerivation ( + common.commonCraneArgs + // { + inherit (common) cargoArtifacts; + pname = "radroots-blossom-conformance"; + doCheck = false; + buildPhaseCargoCommand = '' + cargo check -p radroots_blossom --all-targets --no-default-features --locked + cargo check -p radroots_blossom --all-targets --no-default-features --features std --locked + cargo check -p radroots_blossom --all-targets --no-default-features --features serde --locked + cargo check -p radroots_blossom --all-targets --locked + cargo check -p radroots_blossom --all-targets --all-features --locked + cargo clippy -p radroots_blossom --all-targets --no-default-features --locked -- -D warnings + cargo clippy -p radroots_blossom --all-targets --no-default-features --features serde --locked -- -D warnings + cargo clippy -p radroots_blossom --all-targets --all-features --locked -- -D warnings + cargo test -p radroots_blossom --all-targets --no-default-features --locked + cargo test -p radroots_blossom --all-targets --no-default-features --features std --locked + cargo test -p radroots_blossom --all-targets --no-default-features --features serde --locked + cargo test -p radroots_blossom --all-targets --all-features --locked + cargo check -p radroots_blossom --no-default-features --target wasm32-unknown-unknown --locked + cargo check -p radroots_blossom --no-default-features --features serde --target wasm32-unknown-unknown --locked + RUSTDOCFLAGS="-D warnings" cargo doc -p radroots_blossom --all-features --no-deps --locked + cargo test -p radroots_blossom --all-features --doc --locked + ''; + installPhaseCommand = "mkdir -p $out"; + } + ); mkReplicaSyncLane = { pname, @@ -142,11 +218,27 @@ in ${common.decoderSecurityFuzzCommand} ''; }; + core-conformance = coreConformance; + identity-conformance = identityConformance; + blossom-conformance = blossomConformance; replica-sync-default-check = replicaSyncDefaultCheck; replica-sync-default-test = replicaSyncDefaultTest; replica-sync-legacy-ingest-check = replicaSyncLegacyCheck; replica-sync-legacy-ingest-test = replicaSyncLegacyTest; + architecture = common.craneLib.mkCargoDerivation ( + common.commonCraneArgs + // { + inherit (common) cargoArtifacts; + pname = "radroots-architecture"; + doCheck = false; + buildPhaseCargoCommand = '' + cargo run --locked -q -p xtask -- architecture-ci + ''; + installPhaseCommand = "mkdir -p $out"; + } + ); + guards = common.mkRepoCheck { name = "repo-guards"; runtimeInputs = [ diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -27,6 +27,8 @@ let ../../Cargo.lock ../../flake.lock ../../CHANGELOG.md + ../../LICENSE-APACHE + ../../LICENSE-MIT ../../README ../../flake.nix ../../build/nix/apps.nix @@ -42,6 +44,9 @@ let ../../crates ../../docs/blossom-raster-decoder-security.md ../../fuzz + ../../docs/decisions + ../../docs/implementation + ../../docs/specs ../../tools ] ); @@ -260,6 +265,9 @@ let in "cargo ${lib.escapeShellArgs args}" ) phase1FeatureMatrix.profiles; + architectureCommand = '' + cargo run --locked -q -p xtask -- architecture-ci + ''; contractCommand = '' cargo run -q -p xtask -- hygiene forbidden-identifiers cargo check -q ${coreContractCargoArgs} @@ -461,16 +469,37 @@ let cargo run -q -p xtask -- release preflight required_file="$(mktemp)" - trap 'rm -f "$required_file"' EXIT + coverage_target_root="''${CARGO_TARGET_DIR:-$PWD/target}" + case "$coverage_target_root" in + /*) ;; + *) coverage_target_root="$PWD/$coverage_target_root" ;; + esac + coverage_root="$coverage_target_root/coverage" + coverage_compat_link="" + cleanup_release_preflight() { + rm -f "$required_file" + if [ -n "$coverage_compat_link" ]; then + rm -f "$coverage_compat_link" + rmdir "$PWD/target" 2>/dev/null || true + fi + } + trap cleanup_release_preflight EXIT cargo run -q -p xtask -- coverage required-crates > "$required_file" - rm -rf target/coverage - mkdir -p target/coverage + rm -rf "$coverage_root" + mkdir -p "$coverage_root" + + if [ "$coverage_root" != "$PWD/target/coverage" ]; then + mkdir -p "$PWD/target" + coverage_compat_link="$PWD/target/coverage" + rm -rf "$coverage_compat_link" + ln -s "$coverage_root" "$coverage_compat_link" + fi while IFS= read -r crate; do [ -n "$crate" ] || continue safe_crate="''${crate//-/_}" - out_dir="target/coverage/''${safe_crate}" + out_dir="$coverage_root/''${safe_crate}" mkdir -p "$out_dir" cargo run -q -p xtask -- coverage run-crate --crate "$crate" --out "$out_dir" @@ -483,16 +512,22 @@ let done < "$required_file" cargo run -q -p xtask -- coverage refresh-summary \ - --reports-root target/coverage \ - --out target/coverage/coverage-refresh.tsv \ - --status-out target/coverage/coverage-refresh-status.tsv + --reports-root "$coverage_root" \ + --out "$coverage_root/coverage-refresh.tsv" \ + --status-out "$coverage_root/coverage-refresh-status.tsv" echo "release preflight complete" ''; coverageReportCommand = '' - rm -rf target/coverage-report - mkdir -p target/coverage-report - : > target/coverage-report/coverage-report-status.txt + coverage_target_root="''${CARGO_TARGET_DIR:-$PWD/target}" + case "$coverage_target_root" in + /*) ;; + *) coverage_target_root="$PWD/$coverage_target_root" ;; + esac + coverage_report_root="$coverage_target_root/coverage-report" + rm -rf "$coverage_report_root" + mkdir -p "$coverage_report_root" + : > "$coverage_report_root/coverage-report-status.txt" workspace_crates_file="$(mktemp)" required_crates_file="$(mktemp)" @@ -502,7 +537,7 @@ let while IFS= read -r crate; do [ -n "''${crate}" ] || continue safe_crate="''${crate//-/_}" - run_dir="target/coverage-report/''${safe_crate}" + run_dir="$coverage_report_root/''${safe_crate}" mkdir -p "''${run_dir}" status="ok" @@ -562,15 +597,15 @@ let EOF fi - echo "''${crate}:''${status}" >> target/coverage-report/coverage-report-status.txt + echo "''${crate}:''${status}" >> "$coverage_report_root/coverage-report-status.txt" done < "$workspace_crates_file" cargo run -q -p xtask -- coverage required-crates > "$required_crates_file" while IFS= read -r crate; do [ -n "''${crate}" ] || continue safe_crate="''${crate//-/_}" - crate_dir="target/coverage-report/''${safe_crate}" - crate_status="$(awk -F: -v crate="''${crate}" '$1 == crate { status = $2 } END { print status }' target/coverage-report/coverage-report-status.txt)" + crate_dir="$coverage_report_root/''${safe_crate}" + crate_status="$(awk -F: -v crate="''${crate}" '$1 == crate { status = $2 } END { print status }' "$coverage_report_root/coverage-report-status.txt")" if [ ! -f "''${crate_dir}/coverage-summary.json" ] || [ ! -f "''${crate_dir}/coverage-lcov.info" ]; then fail_reason="missing-coverage-artifacts" @@ -596,6 +631,7 @@ let in { inherit + architectureCommand cargoLlvmCov cargoArtifacts checkCommand diff --git a/contracts/codegen/protocol_v1.inventory.json b/contracts/codegen/protocol_v1.inventory.json @@ -0,0 +1,866 @@ +{ + "schema_version": 1, + "generator": "radroots_xtask.protocol_codegen.v1", + "package": "radroots_protocol", + "source_hash_algorithm": "sha256_bytes_v1", + "sources": [ + { + "module": "capability::v1", + "path": "crates/protocol/src/capability/v1.rs", + "sha256": "8a50dc841ced8e5f4fea18cd248f2a170b8e4e51175511a82ac5c6213c768ba3", + "types": [ + { + "rust_path": "radroots_protocol::capability::v1::Availability", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::capability::v1::Maturity", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::capability::v1::MeshScopeId", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::capability::v1::ReticulumDestination", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::capability::v1::ReticulumTarget", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::capability::v1::TransportDescriptor", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::capability::v1::TransportKind", + "kind": "enum" + } + ] + }, + { + "module": "error::v1", + "path": "crates/protocol/src/error/v1.rs", + "sha256": "59553954506a56aca8c2cdef385b67e593514f846bbda3ac38907f73f58a8da6", + "types": [ + { + "rust_path": "radroots_protocol::error::v1::CapabilityId", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::error::v1::Class", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::error::v1::Code", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::error::v1::Detail", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::error::v1::DetailValue", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::error::v1::ErrorReport", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::error::v1::RecoveryAction", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::error::v1::SafeDetails", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::error::v1::SafeMessage", + "kind": "struct" + } + ] + }, + { + "module": "event::v1", + "path": "crates/protocol/src/event/v1.rs", + "sha256": "d2f7ec742d7481d914be2e659d0733c87c960d64260a62e9310a745cf26e043d", + "types": [ + { + "rust_path": "radroots_protocol::event::v1::EventClass", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::event::v1::EventDescriptor", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::event::v1::TradeState", + "kind": "enum" + } + ] + }, + { + "module": "radrootsd::transport_publish::v5", + "path": "crates/protocol/src/radrootsd/transport_publish/v5.rs", + "sha256": "c0c07645d3542f083847e601ba9af78579d22a5930dee649055a7a386bb8c42d", + "types": [ + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::AuthCapabilities", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::Capabilities", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::CapabilityAvailability", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::CapabilityMaturity", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicyName", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::EventRequest", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::EventResponse", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::Implementation", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::Job", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::JobStatus", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::NostrTargetSourcePolicy", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::OperationCapabilities", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::ReticulumBehavior", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::SurfaceCapabilities", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::Target", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::TargetFingerprint", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::TargetOutcome", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::TargetPolicyName", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::TargetSource", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::radrootsd::transport_publish::v5::TransportCapability", + "kind": "struct" + } + ] + }, + { + "module": "runtime::v1", + "path": "crates/protocol/src/runtime/v1.rs", + "sha256": "94157d512a1ea152685529f9a3e5cb23da95db6e2271b09f1c9f835c14703743", + "types": [ + { + "rust_path": "radroots_protocol::runtime::v1::ApprovalRequirement", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::runtime::v1::DeadlinePolicy", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::runtime::v1::DryRunSupport", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::runtime::v1::IdempotencyPolicy", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::runtime::v1::Mutability", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::runtime::v1::OperationDescriptor", + "kind": "struct" + }, + { + "rust_path": "radroots_protocol::runtime::v1::OperationId", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::runtime::v1::PrivacyEffect", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::runtime::v1::ProjectionEffect", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::runtime::v1::Risk", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::runtime::v1::SignerRequirement", + "kind": "enum" + }, + { + "rust_path": "radroots_protocol::runtime::v1::TransportRoute", + "kind": "struct" + } + ] + } + ], + "schemas": [ + { + "schema_id": "radroots.protocol.error_report.v1", + "module": "error::v1", + "generation": 1 + }, + { + "schema_id": "radroots.protocol.event_descriptor.v1", + "module": "event::v1", + "generation": 1 + }, + { + "schema_id": "radroots.protocol.reticulum_target.v1", + "module": "capability::v1", + "generation": 1 + }, + { + "schema_id": "radroots.protocol.trade_state.v1", + "module": "event::v1", + "generation": 1 + }, + { + "schema_id": "radroots.protocol.transport_capability_descriptor.v1", + "module": "capability::v1", + "generation": 1 + }, + { + "schema_id": "radroots.protocol.transport_kind.v1", + "module": "capability::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.account.create.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.account.create.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.account.import.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.account.import.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.account.list.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.account.list.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.account.remove.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.account.remove.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.account.select.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.account.select.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.create.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.create.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.get.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.get.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.item.add.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.item.add.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.item.remove.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.item.remove.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.item.update.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.item.update.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.list.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.list.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.quote.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.basket.quote.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.diagnostics.inspect.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.diagnostics.inspect.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.farm.create.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.farm.create.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.farm.get.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.farm.get.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.farm.list.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.farm.list.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.farm.publish.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.farm.publish.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.farm.update.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.farm.update.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.health.inspect.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.health.inspect.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.create.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.create.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.get.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.get.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.list.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.list.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.pause.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.pause.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.publish.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.publish.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.update.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.update.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.withdraw.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.listing.withdraw.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.market.get.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.market.get.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.market.pull.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.market.pull.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.market.search.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.market.search.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.profile.inspect.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.profile.inspect.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.profile.reset.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.profile.reset.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.signer.status.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.signer.status.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.store.backup.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.store.backup.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.store.inspect.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.store.inspect.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.store.restore.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.store.restore.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.sync.pull.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.sync.pull.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.sync.push.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.sync.push.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.sync.status.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.sync.status.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.cancellation.submit.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.cancellation.submit.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.candidate.decide.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.candidate.decide.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.evidence.inspect.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.evidence.inspect.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.evidence.refresh.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.evidence.refresh.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.get.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.get.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.list.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.list.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.operation.resume.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.operation.resume.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.private_artifact.delete.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.private_artifact.delete.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.private_artifact.open.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.private_artifact.open.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.private_artifact.seal.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.private_artifact.seal.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.proposal.submit.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.proposal.submit.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.revision.propose.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.trade.revision.propose.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.transport.capability.list.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.transport.capability.list.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.transport.config.inspect.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.transport.config.inspect.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.transport.config.update.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.transport.config.update.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.transport.delivery.inspect.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.transport.delivery.inspect.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.transport.delivery.retry.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.transport.delivery.retry.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.transport.status.inspect.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.transport.status.inspect.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.validation.status.receipt.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radroots.runtime.validation.status.request.v1", + "module": "runtime::v1", + "generation": 1 + }, + { + "schema_id": "radrootsd.transport_publish.v5", + "module": "radrootsd::transport_publish::v5", + "generation": 5 + } + ] +} diff --git a/contracts/codegen/protocol_v1.inventory.sha256 b/contracts/codegen/protocol_v1.inventory.sha256 @@ -0,0 +1 @@ +36d98bfc7a76f8ac9c0bd36f6775b8ae2024ff821593d76ceef57ebc559a331c diff --git a/contracts/codegen/protocol_v1.toml b/contracts/codegen/protocol_v1.toml @@ -0,0 +1,32 @@ +schema_version = 1 +package = "radroots_protocol" +source_hash_algorithm = "sha256_bytes_v1" +inventory_path = "contracts/codegen/protocol_v1.inventory.json" +inventory_sha256_path = "contracts/codegen/protocol_v1.inventory.sha256" + +[[source]] +module = "capability::v1" +path = "crates/protocol/src/capability/v1.rs" + +[[source]] +module = "error::v1" +path = "crates/protocol/src/error/v1.rs" + +[[source]] +module = "event::v1" +path = "crates/protocol/src/event/v1.rs" + +[[source]] +module = "radrootsd::transport_publish::v5" +path = "crates/protocol/src/radrootsd/transport_publish/v5.rs" + +[[source]] +module = "runtime::v1" +path = "crates/protocol/src/runtime/v1.rs" + +[[macro_generated_type]] +module = "runtime::v1" +path = "crates/protocol/src/runtime/v1.rs" +macro_name = "operation_ids" +rust_name = "OperationId" +kind = "enum" diff --git a/contracts/conformance/schema/vector.schema.json b/contracts/conformance/schema/vector.schema.json @@ -24,8 +24,7 @@ "required": [ "id", "kind", - "input", - "expected" + "input" ], "properties": { "id": { @@ -37,8 +36,34 @@ "minLength": 1 }, "input": {}, - "expected": {} + "expected": {}, + "expected_error_contains": { + "type": "string", + "minLength": 1 + } }, + "oneOf": [ + { + "required": [ + "expected" + ], + "not": { + "required": [ + "expected_error_contains" + ] + } + }, + { + "required": [ + "expected_error_contains" + ], + "not": { + "required": [ + "expected" + ] + } + } + ], "additionalProperties": false } } diff --git a/contracts/conformance/vectors/blossom/bud11_claims.v1.json b/contracts/conformance/vectors/blossom/bud11_claims.v1.json @@ -93,6 +93,18 @@ "expected": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test" } }, { + "id": "domain-maximum-total-length", + "kind": "blossom.bud11.server_domain.parse.valid", + "input": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" }, + "expected": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" } + }, + { + "id": "domain-total-length-too-long", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { "id": "domain-empty", "kind": "blossom.bud11.server_domain.parse.invalid", "input": { "domain": "" }, @@ -237,6 +249,18 @@ "expected": { "content": "Upload\tphoto" } }, { + "id": "content-maximum-byte-length", + "kind": "blossom.bud11.content.parse.valid", + "input": { "content": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "expected": { "content_byte_length": 4096 } + }, + { + "id": "content-byte-length-too-long", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "expected": { "error": "invalid_authorization_content" } + }, + { "id": "content-control", "kind": "blossom.bud11.content.parse.invalid", "input": { "content": "Upload\u0000photo" }, diff --git a/contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json b/contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json @@ -83,6 +83,16 @@ } }, { + "id": "blossom_sha256_parse_too_long_invalid_008b", + "kind": "blossom.sha256.parse.invalid", + "input": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad0" + }, + "expected": { + "error": "invalid_sha256" + } + }, + { "id": "blossom_hash_path_without_extension_009", "kind": "blossom.hash_path.parse.valid", "input": { diff --git a/contracts/conformance/vectors/calendar/nip52_baseline.v1.json b/contracts/conformance/vectors/calendar/nip52_baseline.v1.json @@ -42,13 +42,13 @@ ["g", "C23NB62W20ST"], ["summary", " Weekly pickup "], ["image", "https://ordinary.example.test/images/market.jpg?size=large"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://relay.example.test/events"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "", " host "], + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"], + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://relay.example.test/events"], + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "", " host "], ["t", "vegetables"], ["t", "CSA"], ["r", "ipfs://bafybeigdyrzt/reference"], - ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "wss://relay.example.test"], + ["a", "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-calendar", "wss://relay.example.test"], ["name", "Deprecated display name"], ["D", "extension", "preserved"] ] @@ -64,14 +64,14 @@ "summary": " Weekly pickup ", "image": "https://ordinary.example.test/images/market.jpg?size=large", "participants": [ - {"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "relay": null, "role": null}, - {"pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "relay": "wss://relay.example.test/events", "role": null}, - {"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "relay": null, "role": " host "} + {"pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "relay": null, "role": null}, + {"pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://relay.example.test/events", "role": null}, + {"pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "relay": null, "role": " host "} ], "categories": ["vegetables", "CSA"], "references": ["ipfs://bafybeigdyrzt/reference"], "calendar_requests": [ - {"calendar": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "relay": "wss://relay.example.test"} + {"calendar": "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-calendar", "relay": "wss://relay.example.test"} ], "legacy_name": "Deprecated display name", "date_day_extensions": [["D", "extension", "preserved"]] @@ -150,7 +150,7 @@ "input": { "kind": 31922, "content": "", - "tags": [["d", "bad-participant"], ["title", "Bad participant"], ["start", "2026-06-20"], ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", ""]] + "tags": [["d", "bad-participant"], ["title", "Bad participant"], ["start", "2026-06-20"], ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", ""]] }, "expected": { "error": "invalid_tag", "tag": "p" } }, @@ -160,7 +160,7 @@ "input": { "kind": 31922, "content": "", - "tags": [["d", "bad-request"], ["title", "Bad request"], ["start", "2026-06-20"], ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:not-a-calendar"]] + "tags": [["d", "bad-request"], ["title", "Bad request"], ["start", "2026-06-20"], ["a", "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:not-a-calendar"]] }, "expected": { "error": "invalid_tag", "tag": "a" } }, @@ -357,8 +357,8 @@ ["title", "Regional farm calendar"], ["description", "NIP-51 client-facing set description"], ["image", "https://ordinary.example.test/calendars/victoria.jpg"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:moss-street-market", "wss://date-relay.example.test/events"], - ["a", "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:harvest-window", "WSS://Time-Relay.Example.Test/events"] + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:moss-street-market", "wss://date-relay.example.test/events"], + ["a", "31923:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:harvest-window", "WSS://Time-Relay.Example.Test/events"] ] }, "expected": { @@ -369,11 +369,11 @@ "image": "https://ordinary.example.test/calendars/victoria.jpg", "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:moss-street-market", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:moss-street-market", "relay": "wss://date-relay.example.test/events" }, { - "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:harvest-window", + "coordinate": "31923:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:harvest-window", "relay": "WSS://Time-Relay.Example.Test/events" } ] @@ -388,7 +388,7 @@ "tags": [ ["d", "uppercase-observed"], ["title", "Uppercase observed"], - ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour", "WSS://Relay.Example.Test/events"] + ["a", "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:farm-tour", "WSS://Relay.Example.Test/events"] ] }, "expected": { @@ -399,7 +399,7 @@ "image": null, "event_references": [ { - "coordinate": "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour", + "coordinate": "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:farm-tour", "relay": "WSS://Relay.Example.Test/events" } ] @@ -414,8 +414,8 @@ "tags": [ ["d", "duplicates-observed"], ["title", "Duplicates observed"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://one.example.test"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://two.example.test"] + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "wss://one.example.test"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "wss://two.example.test"] ] }, "expected": { @@ -426,11 +426,11 @@ "image": null, "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "relay": "wss://one.example.test" }, { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "relay": "wss://two.example.test" } ] @@ -497,7 +497,7 @@ "tags": [ ["d", "bad-a-arity"], ["title", "Bad a arity"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://relay.example.test", "unexpected"] + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "wss://relay.example.test", "unexpected"] ] }, "expected": {"error": "invalid_tag", "tag": "a"} @@ -518,7 +518,7 @@ "input": { "kind": 31924, "content": "", - "tags": [["d", "wrong-coordinate-kind"], ["title", "Wrong coordinate kind"], ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:another-calendar"]] + "tags": [["d", "wrong-coordinate-kind"], ["title", "Wrong coordinate kind"], ["a", "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:another-calendar"]] }, "expected": {"error": "invalid_tag", "tag": "a"} }, @@ -531,7 +531,7 @@ "tags": [ ["d", "bad-relay"], ["title", "Bad relay"], - ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:harvest-window", "https://relay.example.test"] + ["a", "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:harvest-window", "https://relay.example.test"] ] }, "expected": {"error": "invalid_tag", "tag": "a"} @@ -554,14 +554,14 @@ "content": "", "tags": [ ["d", "farm-tour-rsvp"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"] ] }, "expected": { "d": "farm-tour-rsvp", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "relay": null }, "revision_reference": null, @@ -580,28 +580,28 @@ "content": "I can help at the produce table.", "tags": [ ["d", "market-rsvp"], - ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:market-shift", "wss://coordinate-relay.example.test/events"], - ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://event-relay.example.test/events"], + ["a", "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:market-shift", "wss://coordinate-relay.example.test/events"], + ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://event-relay.example.test/events"], ["status", "tentative"], ["fb", "busy"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://author-relay.example.test/events"] + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "wss://author-relay.example.test/events"] ] }, "expected": { "d": "market-rsvp", "event_reference": { - "coordinate": "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:market-shift", + "coordinate": "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:market-shift", "relay": "wss://coordinate-relay.example.test/events" }, "revision_reference": { - "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "id": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://event-relay.example.test/events" }, "status": "tentative", "observed_free_busy": "busy", "effective_free_busy": "busy", "author_hint": { - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "relay": "wss://author-relay.example.test/events" }, "note": "I can help at the produce table." @@ -615,7 +615,7 @@ "content": "Unable to attend.", "tags": [ ["d", "declined-rsvp"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:seed-swap"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:seed-swap"], ["status", "declined"], ["fb", "busy"] ] @@ -623,7 +623,7 @@ "expected": { "d": "declined-rsvp", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:seed-swap", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:seed-swap", "relay": null }, "revision_reference": null, @@ -642,28 +642,28 @@ "content": "", "tags": [ ["d", "uppercase-rsvp"], - ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour", "WSS://Coordinate-Relay.Example.Test/events"], - ["e", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", "WSS://Event-Relay.Example.Test/events"], + ["a", "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:farm-tour", "WSS://Coordinate-Relay.Example.Test/events"], + ["e", "E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF", "WSS://Event-Relay.Example.Test/events"], ["status", "accepted"], ["fb", "free"], - ["p", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "WSS://Author-Relay.Example.Test/events"] + ["p", "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", "WSS://Author-Relay.Example.Test/events"] ] }, "expected": { "d": "uppercase-rsvp", "event_reference": { - "coordinate": "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour", + "coordinate": "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:farm-tour", "relay": "WSS://Coordinate-Relay.Example.Test/events" }, "revision_reference": { - "id": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "id": "E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF", "relay": "WSS://Event-Relay.Example.Test/events" }, "status": "accepted", "observed_free_busy": "free", "effective_free_busy": "free", "author_hint": { - "pubkey": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "pubkey": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", "relay": "WSS://Author-Relay.Example.Test/events" }, "note": null @@ -675,7 +675,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"]] + "tags": [["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"]] }, "expected": {"error": "missing_tag", "tag": "d"} }, @@ -691,7 +691,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "missing-status"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"]] + "tags": [["d", "missing-status"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"]] }, "expected": {"error": "missing_tag", "tag": "status"} }, @@ -701,7 +701,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "one"], ["d", "two"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"]] + "tags": [["d", "one"], ["d", "two"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"]] }, "expected": {"error": "duplicate_tag", "tag": "d"} }, @@ -713,8 +713,8 @@ "content": "", "tags": [ ["d", "duplicate-a"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], - ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-shift"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], + ["a", "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-shift"], ["status", "accepted"] ] }, @@ -726,7 +726,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "duplicate-status"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["status", "tentative"]] + "tags": [["d", "duplicate-status"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], ["status", "tentative"]] }, "expected": {"error": "duplicate_tag", "tag": "status"} }, @@ -738,8 +738,8 @@ "content": "", "tags": [ ["d", "duplicate-e"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], - ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], + ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], ["e", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], ["status", "accepted"] ] @@ -752,7 +752,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "duplicate-fb"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["fb", "free"], ["fb", "busy"]] + "tags": [["d", "duplicate-fb"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], ["fb", "free"], ["fb", "busy"]] }, "expected": {"error": "duplicate_tag", "tag": "fb"} }, @@ -764,10 +764,10 @@ "content": "", "tags": [ ["d", "duplicate-p"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"] + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"], + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"] ] }, "expected": {"error": "duplicate_tag", "tag": "p"} @@ -780,7 +780,7 @@ "content": "", "tags": [ ["d", "bad-a-arity"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://relay.example.test", "unexpected"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "wss://relay.example.test", "unexpected"], ["status", "accepted"] ] }, @@ -794,8 +794,8 @@ "content": "", "tags": [ ["d", "bad-e-arity"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], - ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://relay.example.test", "unexpected"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], + ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://relay.example.test", "unexpected"], ["status", "accepted"] ] }, @@ -809,9 +809,9 @@ "content": "", "tags": [ ["d", "bad-p-role"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://relay.example.test", "host"] + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "wss://relay.example.test", "host"] ] }, "expected": {"error": "invalid_tag", "tag": "p"} @@ -822,7 +822,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "wrong-kind"], ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar"], ["status", "accepted"]] + "tags": [["d", "wrong-kind"], ["a", "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-calendar"], ["status", "accepted"]] }, "expected": {"error": "invalid_tag", "tag": "a"} }, @@ -838,7 +838,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-event-id"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["e", "not-an-event-id"], ["status", "accepted"]] + "tags": [["d", "bad-event-id"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["e", "not-an-event-id"], ["status", "accepted"]] }, "expected": {"error": "invalid_tag", "tag": "e"} }, @@ -848,7 +848,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-author"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["p", "not-a-pubkey"]] + "tags": [["d", "bad-author"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], ["p", "not-a-pubkey"]] }, "expected": {"error": "invalid_tag", "tag": "p"} }, @@ -858,7 +858,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-a-relay"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "https://relay.example.test"], ["status", "accepted"]] + "tags": [["d", "bad-a-relay"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "https://relay.example.test"], ["status", "accepted"]] }, "expected": {"error": "invalid_tag", "tag": "a"} }, @@ -868,7 +868,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-e-relay"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "https://relay.example.test"], ["status", "accepted"]] + "tags": [["d", "bad-e-relay"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "https://relay.example.test"], ["status", "accepted"]] }, "expected": {"error": "invalid_tag", "tag": "e"} }, @@ -878,7 +878,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-p-relay"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "https://relay.example.test"]] + "tags": [["d", "bad-p-relay"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "https://relay.example.test"]] }, "expected": {"error": "invalid_tag", "tag": "p"} }, @@ -888,7 +888,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-status"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "maybe"]] + "tags": [["d", "bad-status"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "maybe"]] }, "expected": {"error": "invalid_tag", "tag": "status"} }, @@ -898,7 +898,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-fb"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["fb", "unknown"]] + "tags": [["d", "bad-fb"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], ["fb", "unknown"]] }, "expected": {"error": "invalid_tag", "tag": "fb"} }, @@ -960,7 +960,7 @@ "content": "", "tags": [ ["d", "bad-status-arity"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted", "unexpected"] ] }, @@ -974,7 +974,7 @@ "content": "", "tags": [ ["d", "bad-fb-arity"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], ["fb", "free", "unexpected"] ] diff --git a/contracts/conformance/vectors/calendar/radroots_profile.v1.json b/contracts/conformance/vectors/calendar/radroots_profile.v1.json @@ -49,12 +49,12 @@ } }, "participants": [ - {"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "relay": "wss://relay.example.test", "role": "host"} + {"pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "relay": "wss://relay.example.test", "role": "host"} ], "categories": ["csa", "vegetables"], "references": ["https://example.test/pickup-details"], "calendar_requests": [ - {"calendar": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "relay": "wss://relay.example.test"} + {"calendar": "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-calendar", "relay": "wss://relay.example.test"} ] }, "expected": { @@ -71,11 +71,11 @@ ["g", "c23nb62w20st"], ["summary", "Weekly pickup"], ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://relay.example.test", "host"], + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "wss://relay.example.test", "host"], ["t", "csa"], ["t", "vegetables"], ["r", "https://example.test/pickup-details"], - ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "wss://relay.example.test"] + ["a", "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-calendar", "wss://relay.example.test"] ] }, "media_verification": "byte_verified", @@ -188,7 +188,7 @@ "input": { "kind": 31922, "content": "", - "tags": [["d", "farm-tour"], ["title", "Urban farm tour"], ["start", "2026-07-18"], ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "WSS://relay.example.test"]] + "tags": [["d", "farm-tour"], ["title", "Urban farm tour"], ["start", "2026-07-18"], ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "WSS://relay.example.test"]] }, "expected": {"error": "non_canonical_field"} }, @@ -278,11 +278,11 @@ "list_description": "NIP-51 set description for compatible clients.", "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "wss://date-relay.example.test/events" }, { - "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg", + "coordinate": "31923:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:CCCCCCCCCCCCCCCCCCCCCg", "relay": "wss://time-relay.example.test/events" } ] @@ -294,8 +294,8 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "South Island farm schedule"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://date-relay.example.test/events"], - ["a", "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg", "wss://time-relay.example.test/events"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "wss://date-relay.example.test/events"], + ["a", "31923:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:CCCCCCCCCCCCCCCCCCCCCg", "wss://time-relay.example.test/events"], ["description", "NIP-51 set description for compatible clients."] ] } @@ -320,7 +320,7 @@ }, "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null } ] @@ -332,7 +332,7 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Victoria farm calendar"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"] ] }, @@ -360,11 +360,11 @@ "title": "Duplicate event calendar", "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "wss://one.example.test" }, { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "wss://two.example.test" } ] @@ -379,7 +379,7 @@ "title": "Uppercase relay", "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "WSS://Relay.Example.Test/events" } ] @@ -394,7 +394,7 @@ "title": "Wrong coordinate kind", "event_references": [ { - "coordinate": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null } ] @@ -410,8 +410,8 @@ "image": { "bytes_utf8": "victoria-calendar", "descriptor": { - "url": "https://media.example/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.webp", - "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "url": "https://media.example/585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df.webp", + "sha256": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "size": 17, "type": "image/webp", "uploaded": 1784347200 @@ -444,8 +444,8 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "South Island farm schedule"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://date-relay.example.test/events"], - ["a", "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg", "wss://time-relay.example.test/events"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "wss://date-relay.example.test/events"], + ["a", "31923:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:CCCCCCCCCCCCCCCCCCCCCg", "wss://time-relay.example.test/events"], ["description", "NIP-51 set description for compatible clients."], ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"] ] @@ -456,11 +456,11 @@ "list_description": "NIP-51 set description for compatible clients.", "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "wss://date-relay.example.test/events" }, { - "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg", + "coordinate": "31923:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:CCCCCCCCCCCCCCCCCCCCCg", "relay": "wss://time-relay.example.test/events" } ], @@ -477,7 +477,7 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Uppercase reference"], - ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Relay.Example.Test/events"] + ["a", "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Relay.Example.Test/events"] ] }, "expected": {"error": "non_canonical_field", "field": "event_reference"} @@ -491,8 +491,8 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Duplicate reference"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://one.example.test"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://two.example.test"] + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "wss://one.example.test"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "wss://two.example.test"] ] }, "expected": {"error": "duplicate_event_reference"} @@ -527,7 +527,7 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null }, "status": "accepted" @@ -538,7 +538,7 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"] ] } @@ -550,17 +550,17 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "wss://coordinate-relay.example.test/events" }, "revision_reference": { - "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "id": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://event-relay.example.test/events" }, "status": "tentative", "free_busy": "busy", "author_hint": { - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "relay": "wss://author-relay.example.test/events" }, "note": "I can help with the wash-pack shift." @@ -571,11 +571,11 @@ "content": "I can help with the wash-pack shift.", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://coordinate-relay.example.test/events"], + ["a", "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "wss://coordinate-relay.example.test/events"], ["status", "tentative"], - ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://event-relay.example.test/events"], + ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://event-relay.example.test/events"], ["fb", "busy"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://author-relay.example.test/events"] + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "wss://author-relay.example.test/events"] ] }, "observed_free_busy": "busy", @@ -588,7 +588,7 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null }, "status": "declined", @@ -602,7 +602,7 @@ "input": { "d": "farm-tour-rsvp", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null }, "status": "accepted" @@ -615,11 +615,11 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null }, "revision_reference": { - "id": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "id": "E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF", "relay": null }, "status": "accepted" @@ -632,12 +632,12 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null }, "status": "accepted", "author_hint": { - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } }, @@ -649,7 +649,7 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "WSS://Relay.Example.Test/events" }, "status": "accepted" @@ -662,7 +662,7 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null }, "status": "accepted" @@ -677,7 +677,7 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"] ] }, @@ -696,25 +696,25 @@ "content": "I can help with the wash-pack shift.", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://coordinate-relay.example.test/events"], - ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://event-relay.example.test/events"], + ["a", "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "wss://coordinate-relay.example.test/events"], + ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://event-relay.example.test/events"], ["status", "tentative"], ["fb", "busy"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://author-relay.example.test/events"] + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "wss://author-relay.example.test/events"] ] }, "expected": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "wss://coordinate-relay.example.test/events" }, "revision_reference": { - "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "id": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://event-relay.example.test/events" }, "author_hint": { - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "relay": "wss://author-relay.example.test/events" }, "status": "tentative", @@ -730,7 +730,7 @@ "content": "Unable to attend.", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "declined"], ["fb", "busy"] ] @@ -750,10 +750,10 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Coordinate-Relay.Example.Test/events"], - ["e", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", "WSS://Event-Relay.Example.Test/events"], + ["a", "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Coordinate-Relay.Example.Test/events"], + ["e", "E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF", "WSS://Event-Relay.Example.Test/events"], ["status", "accepted"], - ["p", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "WSS://Author-Relay.Example.Test/events"] + ["p", "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", "WSS://Author-Relay.Example.Test/events"] ] }, "expected": {"error": "non_canonical_field"} @@ -766,9 +766,9 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"], - ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"] + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"] ] }, "expected": {"error": "author_hint_mismatch"} @@ -781,7 +781,7 @@ "content": "", "tags": [ ["d", "farm-tour-rsvp"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"] ] }, @@ -796,7 +796,7 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Uppercase coordinate"], - ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ"] + ["a", "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:BBBBBBBBBBBBBBBBBBBBBQ"] ] }, "expected": {"error": "non_canonical_field", "field": "event_reference"} @@ -810,7 +810,7 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Uppercase relay"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Relay.Example.Test/events"] + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Relay.Example.Test/events"] ] }, "expected": {"error": "non_canonical_field", "field": "event_reference"} @@ -823,7 +823,7 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"] ] }, @@ -837,8 +837,8 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], - ["e", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], + ["e", "E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF"], ["status", "accepted"] ] }, @@ -852,9 +852,9 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"], - ["p", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"] + ["p", "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF"] ] }, "expected": {"error": "non_canonical_field", "field": "author_hint"} @@ -867,7 +867,7 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Coordinate-Relay.Example.Test/events"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Coordinate-Relay.Example.Test/events"], ["status", "accepted"] ] }, @@ -881,8 +881,8 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], - ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "WSS://Event-Relay.Example.Test/events"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], + ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "WSS://Event-Relay.Example.Test/events"], ["status", "accepted"] ] }, @@ -896,9 +896,9 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "WSS://Author-Relay.Example.Test/events"] + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "WSS://Author-Relay.Example.Test/events"] ] }, "expected": {"error": "non_canonical_field", "field": "author_hint"} diff --git a/contracts/conformance/vectors/comment/verified_profile.v1.json b/contracts/conformance/vectors/comment/verified_profile.v1.json @@ -14560,7 +14560,7 @@ "id": "project_parent_author_mismatch", "kind": "social.comment.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"c44cc89ecf98c8cf2e596ea8bb7b99993fcd2767c0ed2842c8ca3d8281342c0a\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000200,\"kind\":1111,\"tags\":[[\"E\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://victoria.example\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"K\",\"30402\"],[\"P\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://victoria.example\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://victoria.example\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"k\",\"30402\"],[\"p\",\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"wss://victoria.example\"]],\"content\":\"Are these carrots available Saturday?\",\"sig\":\"ca7d4eff81d73e21fbd9459733b8e4cb8ea92661b2c2b4fcc23dbf68bdd648e07243c6c78260592957b02b64044ee7e9af93a98b754b3177425e637af7592497\"}" + "event_json": "{\"id\":\"85d1e0149f8263efc02185a1bba50c3955fdee1fd7598e197b20b493f8e5a022\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000200,\"kind\":1111,\"tags\":[[\"E\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://victoria.example\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"K\",\"30402\"],[\"P\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://victoria.example\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://victoria.example\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"k\",\"30402\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\",\"wss://victoria.example\"]],\"content\":\"Are these carrots available Saturday?\",\"sig\":\"cb00151021c7152d8990d1d84c1781c9f9e835909c94485231363e2223220d6a480debb1abc75d6f450c4c63050966a4688740621f33cbe05cd0b181c50003ef\"}" }, "expected": { "error": "comment_parent_author_mismatch" diff --git a/contracts/conformance/vectors/core/value_serialization.v1.json b/contracts/conformance/vectors/core/value_serialization.v1.json @@ -0,0 +1,102 @@ +{ + "suite": "core_value_serialization", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "core_decimal_normalizes_scale_001", + "kind": "core.decimal.serde", + "input": "1.2300", + "expected": "1.23" + }, + { + "id": "core_decimal_normalizes_signed_zero_002", + "kind": "core.decimal.serde", + "input": "-0.000", + "expected": "0" + }, + { + "id": "core_currency_normalizes_case_003", + "kind": "core.currency.serde", + "input": "usd", + "expected": "USD" + }, + { + "id": "core_money_normalizes_values_004", + "kind": "core.money.serde", + "input": { + "amount": "2.500", + "currency": "usd" + }, + "expected": { + "amount": "2.5", + "currency": "USD" + } + }, + { + "id": "core_percent_normalizes_value_005", + "kind": "core.percent.serde", + "input": { + "value": "12.500" + }, + "expected": { + "value": "12.5" + } + }, + { + "id": "core_quantity_normalizes_unit_006", + "kind": "core.quantity.serde", + "input": { + "amount": "1.2500", + "unit": "kilograms", + "label": "crate" + }, + "expected": { + "amount": "1.25", + "unit": "kg", + "label": "crate" + } + }, + { + "id": "core_quantity_omits_absent_label_007", + "kind": "core.quantity.serde", + "input": { + "amount": "3.000", + "unit": "each" + }, + "expected": { + "amount": "3", + "unit": "each" + } + }, + { + "id": "core_unit_normalizes_synonym_008", + "kind": "core.unit.serde", + "input": "pounds", + "expected": "lb" + }, + { + "id": "core_quantity_price_normalizes_nested_values_009", + "kind": "core.quantity_price.serde", + "input": { + "amount": { + "amount": "5.000", + "currency": "eur" + }, + "quantity": { + "amount": "2.000", + "unit": "kilograms" + } + }, + "expected": { + "amount": { + "amount": "5", + "currency": "EUR" + }, + "quantity": { + "amount": "2", + "unit": "kg" + } + } + } + ] +} diff --git a/contracts/conformance/vectors/deletion/verified_profile.v1.json b/contracts/conformance/vectors/deletion/verified_profile.v1.json @@ -213,8 +213,8 @@ "kind": "social.deletion_request.build_authored_draft.valid", "input": { "address_targets": [ - "31923:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:market", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce" + "31923:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:market", + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce" ], "content": "superseded", "event_targets": [ @@ -250,11 +250,11 @@ ], [ "a", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce" + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce" ], [ "a", - "31923:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:market" + "31923:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:market" ], [ "k", @@ -13117,51 +13117,51 @@ "id": "nip09_project_signed_address_replaceable_boundaries", "kind": "social.deletion_request.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"ca629e60259cd0ac499e85fe3e3c97c9a846311ad17cf9576bf00fe7dd349e5c\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":2,\"kind\":5,\"tags\":[[\"a\",\"0:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:\"],[\"a\",\"3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:\"],[\"a\",\"10000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:\"],[\"a\",\"19999:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:\"]],\"content\":\"\",\"sig\":\"8b29a65a47a30047eaab29ad774f6afb116be114937ea7a7aeb158cd7fc81f08dfa413386942fc4a7db0e64875c94ca4aa2f2272696c62b424649d261afafd44\"}" + "event_json": "{\"id\":\"e0ef10c9e31c32deabe3cb516f289415de0266f8cb96cd2c603f8db1ed4b0d9a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":2,\"kind\":5,\"tags\":[[\"a\",\"0:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:\"],[\"a\",\"3:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:\"],[\"a\",\"10000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:\"],[\"a\",\"19999:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:\"]],\"content\":\"\",\"sig\":\"1c1ab28a42f8b49daf29cfd4511d46a54a6ee32ffdab2d9bfede5fb55045140023eb4ddc57284e0cc9984f3c94167bd4f996ab74c486338d216c75c83f541c72\"}" }, "expected": { "address_targets": [ { - "coordinate": "0:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:", + "coordinate": "0:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:", "identifier": "", "kind": 0, - "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "raw_tag": [ "a", - "0:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:" + "0:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:" ], "tag_index": 0 }, { - "coordinate": "10000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:", + "coordinate": "10000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:", "identifier": "", "kind": 10000, - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "raw_tag": [ "a", - "10000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:" + "10000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:" ], "tag_index": 2 }, { - "coordinate": "19999:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:", + "coordinate": "19999:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:", "identifier": "", "kind": 19999, - "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "pubkey": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4", "raw_tag": [ "a", - "19999:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:" + "19999:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:" ], "tag_index": 3 }, { - "coordinate": "3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:", + "coordinate": "3:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:", "identifier": "", "kind": 3, - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "raw_tag": [ "a", - "3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:" + "3:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:" ], "tag_index": 1 } @@ -13173,19 +13173,19 @@ "raw_tags": [ [ "a", - "0:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:" + "0:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:" ], [ "a", - "3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:" + "3:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:" ], [ "a", - "10000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:" + "10000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:" ], [ "a", - "19999:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:" + "19999:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:" ] ] } @@ -13194,29 +13194,29 @@ "id": "nip09_project_signed_addressable_boundaries", "kind": "social.deletion_request.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"550e49ccc8f4f479bef48605d375ba4ff5f0f8a0fcac449234ace7419b42fa99\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":3,\"kind\":5,\"tags\":[[\"a\",\"30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:\"],[\"a\",\"39999:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm:row:7\"]],\"content\":\"\",\"sig\":\"897bb197ab6a688ecc057e57151fc24519a1fddb6f6f734821effc8d0a9d2629c13cf13797582b5572c5bc6f3a0a6d4cb96a58ffdfbf5d910deae99e32399a2d\"}" + "event_json": "{\"id\":\"1d3c5d5f0e141adbd65a871ab9ac5c742f75c70ec729631945a73c461dd01fa4\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":3,\"kind\":5,\"tags\":[[\"a\",\"30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:\"],[\"a\",\"39999:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:farm:row:7\"]],\"content\":\"\",\"sig\":\"ac7e31bdeab52734e8ef6913c0e23867e791c793d0e79501620a1144ba3e41216fb650c5ca23e5f0b2d4cdb40b15da525cde9bdffe09bcf7e97744178abcb651\"}" }, "expected": { "address_targets": [ { - "coordinate": "30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:", + "coordinate": "30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:", "identifier": "", "kind": 30000, - "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "raw_tag": [ "a", - "30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:" + "30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:" ], "tag_index": 0 }, { - "coordinate": "39999:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm:row:7", + "coordinate": "39999:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:farm:row:7", "identifier": "farm:row:7", "kind": 39999, - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "raw_tag": [ "a", - "39999:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm:row:7" + "39999:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:farm:row:7" ], "tag_index": 1 } @@ -13228,11 +13228,11 @@ "raw_tags": [ [ "a", - "30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:" + "30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:" ], [ "a", - "39999:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm:row:7" + "39999:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:farm:row:7" ] ] } @@ -13241,18 +13241,18 @@ "id": "nip09_project_signed_mixed_raw_retention", "kind": "social.deletion_request.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"4206395f0c18d86536bd57e6ca38cbb48dcf44ac1d0a22904190442445d2fc3f\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":4,\"kind\":5,\"tags\":[[\"x\",\"unknown\",\"retained\"],[\"e\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"wss://relay.example\",\"root\"],[\"a\",\"30000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:produce\",\"trailing\"],[\"k\",\"30000\",\"trailing\"]],\"content\":\"raw order\",\"sig\":\"d6078beab27d6ed5c3427a98639a081c255d2faed7efd89207f1839b40f5b06c1584f91224a9a3e13360861923488f89147091c130c701cb3053ec27e1a0d7dc\"}" + "event_json": "{\"id\":\"f50b6892d049f34e941eb691ef4a4b956217a44ca68f3aa0c4ef7e0ca360d092\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":4,\"kind\":5,\"tags\":[[\"x\",\"unknown\",\"retained\"],[\"e\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"wss://relay.example\",\"root\"],[\"a\",\"30000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:produce\",\"trailing\"],[\"k\",\"30000\",\"trailing\"]],\"content\":\"raw order\",\"sig\":\"df09f81f6b6ca7e4c18aee1a9476ed56b6fbe5acba2ce7be73f6a617db7db9a522691f64034377902cfd0f36750992fd2984c1c334f49a2f49ba3d2a7c0a66a7\"}" }, "expected": { "address_targets": [ { - "coordinate": "30000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:produce", + "coordinate": "30000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:produce", "identifier": "produce", "kind": 30000, - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "raw_tag": [ "a", - "30000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:produce", + "30000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:produce", "trailing" ], "tag_index": 2 @@ -13297,7 +13297,7 @@ ], [ "a", - "30000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:produce", + "30000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:produce", "trailing" ], [ @@ -13312,18 +13312,18 @@ "id": "nip09_project_signed_duplicate_targets_first_provenance", "kind": "social.deletion_request.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"ef7b4f50235a637d9859d7f5ad879bc1e423a59da6835e82cbcd7c368c733b26\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":5,\"kind\":5,\"tags\":[[\"e\",\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\"first\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"second\"],[\"a\",\"030000:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:produce\",\"first\"],[\"a\",\"30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce\",\"second\"]],\"content\":\"\",\"sig\":\"dded0dcec9cebf45c443ec736ccf096911cf57adfd45b2b3143c60e26c95582167c05bbce452b0adcbf333fe4471c0c95d58fc466a87e6349820301d738047b3\"}" + "event_json": "{\"id\":\"b922ee8c829b7caf95227a5b290511f81a7f7c56b98af57673adfd5bbf8b4834\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":5,\"kind\":5,\"tags\":[[\"e\",\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\"first\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"second\"],[\"a\",\"030000:E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF:produce\",\"first\"],[\"a\",\"30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce\",\"second\"]],\"content\":\"\",\"sig\":\"c945d8be7013cc8db307a70d271977fedfbc479416f457baf08b18a4e9e64923420af6156bf44e84e6c466e71ecd4c1baf529384067fd8d2f82d4fc0802a1534\"}" }, "expected": { "address_targets": [ { - "coordinate": "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce", + "coordinate": "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce", "identifier": "produce", "kind": 30000, - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "raw_tag": [ "a", - "030000:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:produce", + "030000:E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF:produce", "first" ], "tag_index": 2 @@ -13356,12 +13356,12 @@ ], [ "a", - "030000:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:produce", + "030000:E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF:produce", "first" ], [ "a", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce", + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce", "second" ] ] @@ -13371,29 +13371,29 @@ "id": "nip09_project_signed_canonical_effect_sorting", "kind": "social.deletion_request.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"682de0df23f548c7e1e8272ff7776c5574ea12b18041e46004f6045263433a27\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":6,\"kind\":5,\"tags\":[[\"e\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"a\",\"31923:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:market\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"],[\"a\",\"30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce\"]],\"content\":\"\",\"sig\":\"9d6a1bc42f42739042c0bef26ff5557176f571b152cd30b730b3a2ea388a874827f7ecf1ed4241234fe50143da77640542ecd5af4263900981760d1be616eea2\"}" + "event_json": "{\"id\":\"c85bb88aff6fe36198851dbc8638ad8b288a861d18d4834ed61e3545cde97a09\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":6,\"kind\":5,\"tags\":[[\"e\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"a\",\"31923:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:market\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"],[\"a\",\"30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce\"]],\"content\":\"\",\"sig\":\"1f15aeb3016d5533c675d227b79d59f59e77c78cbbe4d59ec25a2d7624c8836c162990e1b388c6c0ee6800e18f512b0216a2ade79788d1b032c0f74278558f3a\"}" }, "expected": { "address_targets": [ { - "coordinate": "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce", + "coordinate": "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce", "identifier": "produce", "kind": 30000, - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "raw_tag": [ "a", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce" + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce" ], "tag_index": 3 }, { - "coordinate": "31923:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:market", + "coordinate": "31923:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:market", "identifier": "market", "kind": 31923, - "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "pubkey": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4", "raw_tag": [ "a", - "31923:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:market" + "31923:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:market" ], "tag_index": 1 } @@ -13426,7 +13426,7 @@ ], [ "a", - "31923:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:market" + "31923:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:market" ], [ "e", @@ -13434,7 +13434,7 @@ ], [ "a", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce" + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce" ] ] } @@ -13443,18 +13443,18 @@ "id": "nip09_project_signed_kind_advisory_diagnostics", "kind": "social.deletion_request.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"e8c11c5d1fd81bee0423a6f20db42786cc36f4c8922845c22ad0753bc4d10447\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":7,\"kind\":5,\"tags\":[[\"a\",\"30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:produce\"],[\"k\",\"30000\"],[\"k\"],[\"k\",\"030000\"],[\"k\",\"30000\",\"duplicate\"],[\"k\",\"5\"]],\"content\":\"\",\"sig\":\"3c7b7ad36ef173e61d85cd22989e5694e8373c670bf3b84dabf602c4dc17313eba107952969ac92521900e7f10a1fce80a6f90120e09705740a3dc5b4491c1a9\"}" + "event_json": "{\"id\":\"a7759259b243f84005168ed7a11c626d1156c99393e51610c54f511ece3ea516\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":7,\"kind\":5,\"tags\":[[\"a\",\"30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:produce\"],[\"k\",\"30000\"],[\"k\"],[\"k\",\"030000\"],[\"k\",\"30000\",\"duplicate\"],[\"k\",\"5\"]],\"content\":\"\",\"sig\":\"6205f197be2f061e31950234b4ad3a9b90659710a2d65836f8d243517fc4e1aac2f3d54f4eab401dba81d6481be687a2a0df9c482e6cc575ace1e5b3f405a065\"}" }, "expected": { "address_targets": [ { - "coordinate": "30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:produce", + "coordinate": "30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:produce", "identifier": "produce", "kind": 30000, - "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "raw_tag": [ "a", - "30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:produce" + "30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:produce" ], "tag_index": 0 } @@ -13516,7 +13516,7 @@ "raw_tags": [ [ "a", - "30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:produce" + "30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:produce" ], [ "k", @@ -23395,7 +23395,7 @@ "id": "nip09_project_signed_address_target_unsupported_kind", "kind": "social.deletion_request.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"9489e523cdf3103b1608cd03a4c31f8c2f83fa8b092521cac782ee8ff7e006ad\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":33,\"kind\":5,\"tags\":[[\"a\",\"20000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:\"]],\"content\":\"\",\"sig\":\"b3479000500fd8453232d995e1c36073ce20f0ef579ddcc6122c9dd23525b37d726fe1f0a98e036a208726876a737dfb6fdc21acb10e28cf59ed80962b2a0ad2\"}" + "event_json": "{\"id\":\"5c6e3d68c69fa58615cb7db74edaabf055390101947bcced291ba00edd8b2a7d\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":33,\"kind\":5,\"tags\":[[\"a\",\"20000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:\"]],\"content\":\"\",\"sig\":\"df67f12a606578fd9da58fe2e2edf1964844d816afc08d34b625d976dc536a6d218953c38f8151265ca6f4f4cdac6a973fba40c4925e27756a54d072379adea8\"}" }, "expected": { "error": "deletion_address_target_invalid" @@ -23405,7 +23405,7 @@ "id": "nip09_project_signed_address_target_identifier_forbidden", "kind": "social.deletion_request.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"cb5e015b280e5a5cdce1aab86ca69ff0b5ab02463e59e385aa2423b91850c374\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":34,\"kind\":5,\"tags\":[[\"a\",\"10000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:forbidden\"]],\"content\":\"\",\"sig\":\"689d9788b632d8400d9f29a460b94402ff0dac7ef5a1df971235ce32fe91f758ecee66dfee6c208b6eef6d3dff6d42c874e7b6ea10d1a8a7ecc5cc4fdf167985\"}" + "event_json": "{\"id\":\"d3dabd8d980b0303eaf0384984ae3e30fcd4a3d4780d4bc03645427fab3ec48a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":34,\"kind\":5,\"tags\":[[\"a\",\"10000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:forbidden\"]],\"content\":\"\",\"sig\":\"afd5a1c45d68e29c1747d608d4ced1afbe9c3079a4df2696184a9a354a2b360a8881eb35764a17d2cbe66fe69a107237058936bf5259d7e4fa0197d48cc9eef7\"}" }, "expected": { "error": "deletion_address_target_invalid" @@ -23554,18 +23554,18 @@ "id": "nip09_admit_signed_address_target", "kind": "social.deletion_request.verify_and_admit_event.valid", "input": { - "event_json": "{\"id\":\"ca06409e5b9f6020a4ddd64c01cddb21a60bc0c878aae16e4e86ec862db0259a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":51,\"kind\":5,\"tags\":[[\"a\",\"30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce\"]],\"content\":\"\",\"sig\":\"3a9d2b64027f54aa7c4cf7a71f59f46626c7f7da328c14f15df0e1e20c135d6c39249ecd87ae2a52cd09fa6248b2124c1dde1b7c0c7a79cec859d1314a4ae050\"}" + "event_json": "{\"id\":\"1db5e2667b7de3f36f5bdff85ca82d5edfb76936bcdf3a15042b94866f835117\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":51,\"kind\":5,\"tags\":[[\"a\",\"30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce\"]],\"content\":\"\",\"sig\":\"5cec31910d443b971d276589b8770dfe6692f9e5dc7169a9359d4222c1ecb9cbc3366bc649388d5bef6ac92f84ed5e46025013143fd1b5457d465e5b94d2d24e\"}" }, "expected": { "address_targets": [ { - "coordinate": "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce", + "coordinate": "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce", "identifier": "produce", "kind": 30000, - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "raw_tag": [ "a", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce" + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce" ], "tag_index": 0 } @@ -23577,7 +23577,7 @@ "raw_tags": [ [ "a", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce" + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce" ] ] } @@ -23586,18 +23586,18 @@ "id": "nip09_admit_signed_mixed_tolerant_projection", "kind": "social.deletion_request.verify_and_admit_event.valid", "input": { - "event_json": "{\"id\":\"be56e90b66e7e2c8bbd72d97e940f28a9da0549e3ff3ea9fb76246f08f8054e2\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":52,\"kind\":5,\"tags\":[[\"x\",\"retained\"],[\"e\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"relay\"],[\"a\",\"31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:market\",\"extra\"],[\"k\",\"31923\"]],\"content\":\"withdrawn\",\"sig\":\"24b858e8f25c939112a4f69a9d5e5c53e7deeffcefe3311adacb2ad6ff1ad4df7c247f5e92d90efb1d023dcc3d733f3e71f63c3d66e10464db1b8e2156663c92\"}" + "event_json": "{\"id\":\"114bc1c32e196912dbe03352046f1718e0e66363711ec4c3491891b04080108a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":52,\"kind\":5,\"tags\":[[\"x\",\"retained\"],[\"e\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"relay\"],[\"a\",\"31923:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:market\",\"extra\"],[\"k\",\"31923\"]],\"content\":\"withdrawn\",\"sig\":\"8e0ee72c28606705747eae21f0cbb546ea22946b160c46353b673e07004093aea29650be9cd4d12d34111a8f0efac9b7140f7d3f3f9a46c5ec9b7a00887b89c8\"}" }, "expected": { "address_targets": [ { - "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:market", + "coordinate": "31923:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:market", "identifier": "market", "kind": 31923, - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "raw_tag": [ "a", - "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:market", + "31923:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:market", "extra" ], "tag_index": 2 @@ -23638,7 +23638,7 @@ ], [ "a", - "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:market", + "31923:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:market", "extra" ], [ diff --git a/contracts/conformance/vectors/event/nip01_wire.v1.json b/contracts/conformance/vectors/event/nip01_wire.v1.json @@ -6,11 +6,11 @@ "id": "nip01_wire_profile_minimal_001", "kind": "event.nip01_wire.valid", "input": { - "raw_json": "{\"id\":\"2a15e33622a155ae231b28bebe390869e67a0e228f77ecfcd652b1ce180a9dde\",\"pubkey\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"created_at\":1700000001,\"kind\":0,\"tags\":[],\"content\":\"{}\",\"sig\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"client\":\"radroots-test\"}" + "raw_json": "{\"id\":\"56bfc78223bb2221bad82b539efdec1ade0f56d0eb0e1f592fd387df4b2ceee0\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1700000001,\"kind\":0,\"tags\":[],\"content\":\"{}\",\"sig\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"client\":\"radroots-test\"}" }, "expected": { - "canonical_id_preimage": "[0,\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",1700000001,0,[],\"{}\"]", - "computed_event_id": "2a15e33622a155ae231b28bebe390869e67a0e228f77ecfcd652b1ce180a9dde", + "canonical_id_preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1700000001,0,[],\"{}\"]", + "computed_event_id": "56bfc78223bb2221bad82b539efdec1ade0f56d0eb0e1f592fd387df4b2ceee0", "extra_fields": [ "client" ] @@ -31,7 +31,7 @@ "id": "nip01_wire_id_mismatch_003", "kind": "event.nip01_wire.invalid", "input": { - "raw_json": "{\"id\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"pubkey\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"created_at\":1700000001,\"kind\":0,\"tags\":[],\"content\":\"{}\",\"sig\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}" + "raw_json": "{\"id\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1700000001,\"kind\":0,\"tags\":[],\"content\":\"{}\",\"sig\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}" }, "expected": { "error": "event_id_mismatch" @@ -51,7 +51,7 @@ "id": "nip01_wire_missing_content_005", "kind": "event.nip01_wire.invalid", "input": { - "raw_json": "{\"id\":\"2a15e33622a155ae231b28bebe390869e67a0e228f77ecfcd652b1ce180a9dde\",\"pubkey\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"created_at\":1700000001,\"kind\":0,\"tags\":[],\"sig\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}" + "raw_json": "{\"id\":\"56bfc78223bb2221bad82b539efdec1ade0f56d0eb0e1f592fd387df4b2ceee0\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1700000001,\"kind\":0,\"tags\":[],\"sig\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}" }, "expected": { "error": "missing_content" @@ -61,7 +61,7 @@ "id": "nip01_wire_empty_tag_006", "kind": "event.nip01_wire.invalid", "input": { - "raw_json": "{\"id\":\"2a15e33622a155ae231b28bebe390869e67a0e228f77ecfcd652b1ce180a9dde\",\"pubkey\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"created_at\":1700000001,\"kind\":0,\"tags\":[[]],\"content\":\"{}\",\"sig\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}" + "raw_json": "{\"id\":\"56bfc78223bb2221bad82b539efdec1ade0f56d0eb0e1f592fd387df4b2ceee0\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1700000001,\"kind\":0,\"tags\":[[]],\"content\":\"{}\",\"sig\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}" }, "expected": { "error": "empty_tag" @@ -71,7 +71,7 @@ "id": "nip01_wire_noncanonical_pubkey_007", "kind": "event.nip01_wire.invalid", "input": { - "raw_json": "{\"id\":\"2a15e33622a155ae231b28bebe390869e67a0e228f77ecfcd652b1ce180a9dde\",\"pubkey\":\"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC\",\"created_at\":1700000001,\"kind\":0,\"tags\":[],\"content\":\"{}\",\"sig\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}" + "raw_json": "{\"id\":\"56bfc78223bb2221bad82b539efdec1ade0f56d0eb0e1f592fd387df4b2ceee0\",\"pubkey\":\"585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF\",\"created_at\":1700000001,\"kind\":0,\"tags\":[],\"content\":\"{}\",\"sig\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}" }, "expected": { "error": "noncanonical_pubkey" diff --git a/contracts/conformance/vectors/identity/.gitkeep b/contracts/conformance/vectors/identity/.gitkeep @@ -1 +0,0 @@ - diff --git a/contracts/conformance/vectors/identity/public_values.v1.json b/contracts/conformance/vectors/identity/public_values.v1.json @@ -0,0 +1,166 @@ +{ + "suite": "identity_public_values", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "identity_public_key_canonical_hex_001", + "kind": "identity.public_key.serde", + "input": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", + "expected": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + }, + { + "id": "identity_identity_id_canonical_hex_002", + "kind": "identity.identity_id.serde", + "input": "E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF", + "expected": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" + }, + { + "id": "identity_account_id_canonical_hex_003", + "kind": "identity.account_id.serde", + "input": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", + "expected": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + }, + { + "id": "identity_username_canonical_text_004", + "kind": "identity.username.serde", + "input": " Alice.Farm ", + "expected": "alice.farm" + }, + { + "id": "identity_profile_canonical_username_005", + "kind": "identity.profile.serde", + "input": { + "username": "Alice.Farm" + }, + "expected": { + "username": "alice.farm" + } + }, + { + "id": "identity_public_identity_discards_empty_profile_006", + "kind": "identity.public_identity.serde", + "input": { + "id": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", + "public_key": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", + "profile": {} + }, + "expected": { + "id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + } + }, + { + "id": "identity_account_record_canonical_nested_values_007", + "kind": "identity.account_record.serde", + "input": { + "account_id": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", + "public_identity": { + "id": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", + "public_key": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF" + }, + "label": "primary", + "created_at_unix": 10, + "updated_at_unix": 12 + }, + "expected": { + "account_id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "public_identity": { + "id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + }, + "label": "primary", + "created_at_unix": 10, + "updated_at_unix": 12 + } + }, + { + "id": "identity_account_status_canonical_ready_008", + "kind": "identity.account_status.serde", + "input": { + "ready": { + "account": { + "account_id": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", + "public_identity": { + "id": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", + "public_key": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF" + }, + "created_at_unix": 10, + "updated_at_unix": 10 + } + } + }, + "expected": { + "ready": { + "account": { + "account_id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "public_identity": { + "id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + }, + "created_at_unix": 10, + "updated_at_unix": 10 + } + } + } + }, + { + "id": "identity_public_key_rejects_invalid_curve_point_009", + "kind": "identity.public_key.invalid", + "input": "0000000000000000000000000000000000000000000000000000000000000000", + "expected_error_contains": "valid secp256k1" + }, + { + "id": "identity_public_identity_rejects_mismatched_id_010", + "kind": "identity.public_identity.invalid", + "input": { + "id": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + }, + "expected_error_contains": "does not match its public key" + }, + { + "id": "identity_account_record_rejects_mismatched_id_011", + "kind": "identity.account_record.invalid", + "input": { + "account_id": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + "public_identity": { + "id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + }, + "created_at_unix": 10, + "updated_at_unix": 10 + }, + "expected_error_contains": "does not match its public identity" + }, + { + "id": "identity_account_record_rejects_time_reversal_012", + "kind": "identity.account_record.invalid", + "input": { + "account_id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "public_identity": { + "id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + }, + "created_at_unix": 10, + "updated_at_unix": 9 + }, + "expected_error_contains": "precedes created timestamp" + }, + { + "id": "identity_username_rejects_dot_placement_013", + "kind": "identity.username.invalid", + "input": ".alice", + "expected_error_contains": "dots cannot be" + }, + { + "id": "identity_public_identity_rejects_unknown_field_014", + "kind": "identity.public_identity.invalid", + "input": { + "id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "secret_key": "forbidden" + }, + "expected_error_contains": "unknown field" + } + ] +} diff --git a/contracts/conformance/vectors/post/verified_profiles.v1.json b/contracts/conformance/vectors/post/verified_profiles.v1.json @@ -143,7 +143,7 @@ "content": "Direct reply", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://root.relay.example" } }, @@ -152,7 +152,7 @@ "content": "Direct reply", "tags": [ ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "root"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"] ] } }, @@ -163,12 +163,12 @@ "content": "Nested reply", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null }, "parent": { "event_id": "2222222222222222222222222222222222222222222222222222222222222222", - "author": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "author": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": "wss://parent.relay.example" } }, @@ -178,8 +178,8 @@ "tags": [ ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "reply"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"] + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"] ] } }, @@ -190,12 +190,12 @@ "content": "Ambiguous", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null }, "parent": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "author": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null } }, @@ -210,7 +210,7 @@ "content": "Invalid", "root": { "event_id": "not-an-event-id", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } }, @@ -225,7 +225,7 @@ "content": "Canonical relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://[2001:db8::1]:8443/nostr?region=ca-bc&next=%2Ffeed" } }, @@ -234,7 +234,7 @@ "content": "Canonical relay", "tags": [ ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://[2001:db8::1]:8443/nostr?region=ca-bc&next=%2Ffeed", "root"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"] ] } }, @@ -245,7 +245,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://%" } }, @@ -260,7 +260,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://%zz" } }, @@ -275,7 +275,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://256.1.1.1" } }, @@ -290,7 +290,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://[v1.foo]" } }, @@ -305,7 +305,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://relay.example:" } }, @@ -320,7 +320,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://relay.example:0" } }, @@ -335,7 +335,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://relay.example:65536" } }, @@ -347,7 +347,7 @@ "id": "project_signed_nip10_marked_direct", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Direct reply\",\"sig\":\"16afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd\"}" + "event_json": "{\"id\":\"d62b9340ac9808fc84a751788d017344ede91aea1c89081be3f7bff4a2266354\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Direct reply\",\"sig\":\"206758bee31328a53457e3190b046a4a184941223f4e8ed1696db28d71c9af03138fe9add32d5e97d6f3b50bd0f78e4d5a91629dbf4d12aedd4315ce96c97e8c\"}" }, "expected": { "style": "marked", @@ -365,8 +365,8 @@ "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -377,7 +377,7 @@ "id": "project_signed_nip10_marked_with_citation", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"b6bd8c4e2c8e3098a0b6fdbefbf7a25285887519e2ea58738af4221ddb5fd39e\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000016,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://relay.example\",\"\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Marked reply with citation\",\"sig\":\"9f6516def04521e4e9c9b27f1225e43c03919914c8ced58dfb0afd5ee4fb950bcbbe42ba505f00f75be399ac4a61f7a7eb612b58051bc78ae245b7b122f760e4\"}" + "event_json": "{\"id\":\"5e6db69a9c9968a8f1eb3699ddcf19c0e7b7c9255df616ce7032928b93ad0f6a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000016,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://relay.example\",\"\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Marked reply with citation\",\"sig\":\"d610d090bab46e06d4a674d4bdfd3204f64bc8d1b94929d64cd9ddfd604de89dc9c5ac87229e2f095091e7cac2805ef2ad45325b019e2a79c89b677981f3b068\"}" }, "expected": { "style": "marked", @@ -394,17 +394,17 @@ "citations": [ { "tag_index": 0, - "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://relay.example", "", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://relay.example", "", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "3333333333333333333333333333333333333333333333333333333333333333", "relay": "wss://relay.example", - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" } ], "participants": [ { "tag_index": 2, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -415,7 +415,7 @@ "id": "project_signed_nip10_marked_with_malformed_citation", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"c346aaff3f00a5c8fac4d8dcc15f286f56acbc4608c2e67e799266643f2544fa\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000017,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"\",\"mention\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Marked reply with malformed citation\",\"sig\":\"25d8248ba0220435036611dfbf14c4d52f63a2f29e568f9fd79435faa126d7f862068a1db66dac9a2179911e084eb12a03948a51f0ad7a50ab4fc6a83491037b\"}" + "event_json": "{\"id\":\"19a98ea4c777451618f4f36549abbc352f55c27e254978a2a0dcc0c910cc52ce\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000017,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"\",\"mention\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Marked reply with malformed citation\",\"sig\":\"8fbb467df2771e7a6fc67ca12bfefa2e5b635bfe16d8b963df29d7d8a8df3387e7f9229677045317e470247656db8c87261dc1cf38580c1856b32b2a4fb5791c\"}" }, "expected": { "style": "marked", @@ -433,8 +433,8 @@ "participants": [ { "tag_index": 2, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -451,7 +451,7 @@ "id": "project_signed_nip10_marked_nested_reordered", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"8179bf853f4b24f4ba7af58f162c0dabf08e74c2fe27df4e5573e48d86a8c1c5\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000002,\"kind\":1,\"tags\":[[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\",\"reply\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Nested reply\",\"sig\":\"53b88d556d28f98b43ee36eb8b6453d1fa36da7058891100608ab8e0842dc3d1c49de29323f08679ecbee9e49cf3a09c59139c9e7285826dc7db479dd6219fd3\"}" + "event_json": "{\"id\":\"be4126cc38031f6d47a3d00c81d281999fb4dc6e73ae4bf4b757271be5b0abc0\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000002,\"kind\":1,\"tags\":[[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\",\"reply\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"root\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Nested reply\",\"sig\":\"4e8439d145b4ce2366818fa6f0722e23bf664c92dbf10728fbad3c223d38150ef160566854e052172ff8c060f8924d030bd24c32919967b6f131771c1376e590\"}" }, "expected": { "style": "marked", @@ -459,30 +459,30 @@ "direct": false, "root": { "tag_index": 1, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "root", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "1111111111111111111111111111111111111111111111111111111111111111", "relay": "wss://root.relay.example", - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" }, "parent": { "tag_index": 0, - "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "reply", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], "event_id": "2222222222222222222222222222222222222222222222222222222222222222", "relay": "wss://parent.relay.example", - "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + "author_hint": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c" }, "citations": [], "participants": [ { "tag_index": 2, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null }, { "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -493,7 +493,7 @@ "id": "project_signed_nip10_positional_direct", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"802da76963580d5932a400a2e56e73caccb80c78d43845c62f5fd95feee600e8\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000003,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Legacy direct reply\",\"sig\":\"9a1a958170b2faf3c467bf174e776f7f61bf886644657cc12d92d6a9782db89f273508e1d940412645bbb73a6ce7948b7d20daa77647aa5fd8f9e164159826e7\"}" + "event_json": "{\"id\":\"a7141da919755d6ace9b5fa400928d512b4ef785c0ec03d55989bc2d6cbce860\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000003,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Legacy direct reply\",\"sig\":\"eb2112223ed35f4b0919578a4981e08b403779391f6f9a831ea51763779028d85f72ce8224b1ae469ad6baea21d1d275210bb438a126b4391dab0c347051917a\"}" }, "expected": { "style": "legacy_positional", @@ -511,8 +511,8 @@ "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -523,7 +523,7 @@ "id": "project_signed_nip10_positional_many", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"71af1b702a370218eeb1d6d4fd4734c8cd5fbc64c02ef90196365bda0755490b\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000004,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Legacy nested reply\",\"sig\":\"49d2bf6c65edab401f7d981bf7bd87b7b1ece61d347f75270f38cf67597791f4058c6bd58444a52f831b0cd15c1f91e18958b3ca877a196ababd7055247d65aa\"}" + "event_json": "{\"id\":\"d3f1220f5cb9d51a76cf2551d27cbbb7cfbb5f68ad221045118eb1fd313c9d64\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000004,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"]],\"content\":\"Legacy nested reply\",\"sig\":\"41a3c1da0c331dc67b1a79eb440dee86b1e6baf11f54fd8e1771ebacb0c0681500d5eece1933fa3c68ca745d552a97753378acac06ce5a7328da0086387f5c59\"}" }, "expected": { "style": "legacy_positional", @@ -555,14 +555,14 @@ "participants": [ { "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null }, { "tag_index": 4, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null } ], @@ -573,7 +573,7 @@ "id": "project_signed_nip10_positional_direct_with_author_hint", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"ff050a31d73546d609080281ad2c1e98f878aba826108e7ee0462cf6b397a941\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000018,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Legacy direct with author hint\",\"sig\":\"e71bcb61d49862cd7fa1c776e4fe51aa9e4198234ee93efeacc9afc3d7038c00dee246f1df19eb0fd22aee0a70436708faec39e92bee16e86601f1c91fee1117\"}" + "event_json": "{\"id\":\"f885bad6dc41077036097e81bedb19a0b6b78a48cee24513eb788a997353802c\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000018,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Legacy direct with author hint\",\"sig\":\"473111ec136bcf38035a1848c6fa02ac2ba2a946c3270485d93c11287a3ebb0ac87e6a90b1b91d8beb4f8f958a2b58a3cf494622eafad8fb91cb1fdf5f64bcd0\"}" }, "expected": { "style": "legacy_positional", @@ -581,18 +581,18 @@ "direct": true, "root": { "tag_index": 0, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "1111111111111111111111111111111111111111111111111111111111111111", "relay": "wss://root.relay.example", - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" }, "parent": null, "citations": [], "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -603,7 +603,7 @@ "id": "project_signed_nip10_positional_many_with_author_hints", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"2473084e4272dc675f34e18b9b2aaa3a164e3132bf19056c734759c922acafa7\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000019,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://citation.relay.example\",\"\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\",\"\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"]],\"content\":\"Legacy nested with author hints\",\"sig\":\"e94c18ae8d2c8785a32f42dfff57712508018e2820d24497fbafcfcecc50890d19a4c15d48b2729c4f9dbe73e4c985e055097174b4505ed3a68858d4d56f7809\"}" + "event_json": "{\"id\":\"1f8d3d805ead850160ed74b446aadca7f5f44591484e1cdb22b471e5d689ece3\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000019,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://citation.relay.example\",\"\",\"5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\",\"\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"p\",\"5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4\"]],\"content\":\"Legacy nested with author hints\",\"sig\":\"6c927cc43a95f998ae43956c8e6bc0ab7c18e70dd91eef66ed8360dead902a83c921134e826100b13ea04122ca4bcdf3b0250b0852307141f0a0ed193e87fd9f\"}" }, "expected": { "style": "legacy_positional", @@ -611,44 +611,44 @@ "direct": false, "root": { "tag_index": 0, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "1111111111111111111111111111111111111111111111111111111111111111", "relay": "wss://root.relay.example", - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" }, "parent": { "tag_index": 2, - "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], "event_id": "2222222222222222222222222222222222222222222222222222222222222222", "relay": "wss://parent.relay.example", - "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + "author_hint": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c" }, "citations": [ { "tag_index": 1, - "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://citation.relay.example", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://citation.relay.example", "", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"], "event_id": "3333333333333333333333333333333333333333333333333333333333333333", "relay": "wss://citation.relay.example", - "author_hint": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + "author_hint": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4" } ], "participants": [ { "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null }, { "tag_index": 4, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null }, { "tag_index": 5, - "raw_tag": ["p", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], - "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "raw_tag": ["p", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"], + "pubkey": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4", "relay": null } ], @@ -659,7 +659,7 @@ "id": "project_signed_nip10_positional_malformed_middle_citation", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"ce7684655dafc04bcb8048f4ba95da4b4780316ce90143c332ca4ed40af60ac7\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000020,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"\",\"mention\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Legacy nested with malformed citation\",\"sig\":\"a278e44d6c44fe196c3557d01a99ec3cf3eaa31e16b66370743183db671eac6da75f11151b9da194582b03f529c4e9f871cf743d58dea430d0d12c3ed90c4997\"}" + "event_json": "{\"id\":\"6ea3acdf3d4df0e0dc107a514c1801f658ff44345d1c300ab12f553aaeddb40e\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000020,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"\",\"mention\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"]],\"content\":\"Legacy nested with malformed citation\",\"sig\":\"261e5b2c9632cab25a308b86011655a82dbcda58c4b1908fe8f6465eebf9f250c00086927d9ddf93b73d24467c6e18774bc1b1ffe08a47342b287d86cd2a2b15\"}" }, "expected": { "style": "legacy_positional", @@ -683,14 +683,14 @@ "participants": [ { "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null }, { "tag_index": 4, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null } ], @@ -707,7 +707,7 @@ "id": "project_signed_nip10_precedes_ask_and_media", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"7911ed2e9e2e08173a357f5f83fcdc415ca0c87e95682bad28cefe3a616f5be3\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000005,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://media.example/image.webp\",\"x malformed\"]],\"content\":\"Reply with compatibility metadata\",\"sig\":\"9bd6c5f40c8587893b3460a53931c1253b2764c44922b25ffb64fe3e96e7fe2b8d139d5fca5ffd7b6f5a1cf19598c2a34e7ab48f5d15b18e695a5e39c81fc9f4\"}" + "event_json": "{\"id\":\"cc9440768dea0bf58b002ace8c836893ac6c7a522bdf25048fa34a6a55c03a4a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000005,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://media.example/image.webp\",\"x malformed\"]],\"content\":\"Reply with compatibility metadata\",\"sig\":\"e28dd20ed2d04aac40f67a8cccdd6d8cb261fa54c04c1ea57d021d4d4ab9a3c48ca5e52c5c182f4204302315ee9903f39c90117558457a84daeef2505f240294\"}" }, "expected": { "style": "marked", @@ -725,8 +725,8 @@ "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -737,7 +737,7 @@ "id": "project_signed_nip10_invalid_relay", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"15e0f001e760eb1ac3fd15b8a85e43a5c07603d4ceccdc6ccb93e6cc1876a626\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000006,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"https://relay.example\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Invalid relay\",\"sig\":\"18065b4433c33b29a06ecec64f0f863482479aec2832fcde3c95d2814b5ed91310fdd44df507f346e0231206be239ed4ac85353fc60a838435ae10feae2cc7c6\"}" + "event_json": "{\"id\":\"85b05a80dd168098ea8dcdc6dfa656bcca902120f82bf36a5a5b94c1c949e122\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000006,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"https://relay.example\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Invalid relay\",\"sig\":\"de303499d4cee838d854be0001e686529eda96d37a7f9886f469bf49ad2269aea4b4d3671a1ca820996ba597eece5915d42fdf98452d19bcc630312b0b63f56e\"}" }, "expected": { "style": "marked", @@ -755,8 +755,8 @@ "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -773,7 +773,7 @@ "id": "project_signed_nip10_canonical_relay_authorities", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"a9f7683ed2eaf63e64af15dddf726ea32ec86e6079cd1038edf379c2de96a199\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000020,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed\",\"\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"ws://127.0.0.1:21003\",\"reply\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://[2001:db8::1]:8443/nostr\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"ws://localhost:21003\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"wss://participant.relay.example?region=ca-bc\"],[\"p\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"]],\"content\":\"Canonical relay authorities\",\"sig\":\"4b1691d3d3abcb62a70041da50a0c9790871454a2fb234bac6061a97b4407f91299e905210792c1005883a800d31a7580a218172b4c8ac2834e2c6bcf5b0d925\"}" + "event_json": "{\"id\":\"e7166651c6bd2fd462f7820fca73690049ee9a589eaf161495b38c9b5fe34f92\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000020,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed\",\"\",\"5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"ws://127.0.0.1:21003\",\"reply\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://[2001:db8::1]:8443/nostr\",\"root\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"ws://localhost:21003\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\",\"wss://participant.relay.example?region=ca-bc\"],[\"p\",\"5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4\"]],\"content\":\"Canonical relay authorities\",\"sig\":\"b0faf3e55b6309077108e9e97425d5ea850cbcf5f8b73ad1bbf85731c549ab482dc7dca2cfd7f89a3e1ddd0f1ae8d243e07523ac5251310b0c685cfe0134d073\"}" }, "expected": { "style": "marked", @@ -781,44 +781,44 @@ "direct": false, "root": { "tag_index": 2, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://[2001:db8::1]:8443/nostr", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://[2001:db8::1]:8443/nostr", "root", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "1111111111111111111111111111111111111111111111111111111111111111", "relay": "wss://[2001:db8::1]:8443/nostr", - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" }, "parent": { "tag_index": 1, - "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "ws://127.0.0.1:21003", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "ws://127.0.0.1:21003", "reply", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], "event_id": "2222222222222222222222222222222222222222222222222222222222222222", "relay": "ws://127.0.0.1:21003", - "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + "author_hint": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c" }, "citations": [ { "tag_index": 0, - "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed", "", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"], "event_id": "3333333333333333333333333333333333333333333333333333333333333333", "relay": "wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed", - "author_hint": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + "author_hint": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4" } ], "participants": [ { "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "ws://localhost:21003"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "ws://localhost:21003"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "ws://localhost:21003" }, { "tag_index": 4, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://participant.relay.example?region=ca-bc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "wss://participant.relay.example?region=ca-bc"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": "wss://participant.relay.example?region=ca-bc" }, { "tag_index": 5, - "raw_tag": ["p", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], - "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "raw_tag": ["p", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"], + "pubkey": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4", "relay": null } ], @@ -829,7 +829,7 @@ "id": "project_signed_nip10_malformed_relay_authorities", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"715bf99c903fcc3ff2f78411de07df916ab6b4476142c4f6974cf5eeddcabfd5\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000021,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://%\",\"\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://%zz\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://256.1.1.1\",\"reply\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"wss://[v1.foo]\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"]],\"content\":\"Malformed relay authorities\",\"sig\":\"37644f8a4b353d758aa1ce78f78fd17c702c4fd862b0408c3c61000ef9533150dc4888ef78b1023f6ee26b0abe786e31e9cc442f62c12260af04750b58f3f20e\"}" + "event_json": "{\"id\":\"fe9fbccea1eb7daf0b7fe3bce5507c5973617b82e8134f2fab82c726bf421ef3\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000021,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://%\",\"\",\"5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://%zz\",\"root\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://256.1.1.1\",\"reply\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://[v1.foo]\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"p\",\"5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4\"]],\"content\":\"Malformed relay authorities\",\"sig\":\"9b179c47c13301bfe31f1d0027dcca7dabf1ebdcb0a0e6f7764cb767c346963c8b801549716f4422e22b8d9311c1bb840faa7751c651e1ad0746f76953617a95\"}" }, "expected": { "style": "marked", @@ -837,44 +837,44 @@ "direct": false, "root": { "tag_index": 1, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://%zz", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://%zz", "root", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "1111111111111111111111111111111111111111111111111111111111111111", "relay": null, - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" }, "parent": { "tag_index": 2, - "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://256.1.1.1", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://256.1.1.1", "reply", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], "event_id": "2222222222222222222222222222222222222222222222222222222222222222", "relay": null, - "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + "author_hint": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c" }, "citations": [ { "tag_index": 0, - "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://%", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://%", "", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"], "event_id": "3333333333333333333333333333333333333333333333333333333333333333", "relay": null, - "author_hint": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + "author_hint": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4" } ], "participants": [ { "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://[v1.foo]"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://[v1.foo]"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null }, { "tag_index": 4, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null }, { "tag_index": 5, - "raw_tag": ["p", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], - "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "raw_tag": ["p", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"], + "pubkey": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4", "relay": null } ], @@ -882,22 +882,22 @@ { "code": "reply_reference_relay_ignored", "tag_index": 0, - "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://%", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"] + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://%", "", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"] }, { "code": "reply_reference_relay_ignored", "tag_index": 1, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://%zz", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://%zz", "root", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"] }, { "code": "reply_reference_relay_ignored", "tag_index": 2, - "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://256.1.1.1", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"] + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://256.1.1.1", "reply", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"] }, { "code": "reply_author_relay_ignored", "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://[v1.foo]"] + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://[v1.foo]"] } ] } @@ -906,7 +906,7 @@ "id": "project_signed_nip10_ambiguous_same_reference", "kind": "social.reply.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"86f19437493f84ecd999d1cd5890ff53fa157b99309f65c056de48e82a8c75bf\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000007,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"reply\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Ambiguous\",\"sig\":\"730b282fadf78838f6dad8830711fff6cff5b6da0d3c42db49faf1ef547cf3908ec0c901e1ac7069f8c346e9ec73cea879830a8687e18d87e476a1e0f82a0ca7\"}" + "event_json": "{\"id\":\"8642b1d2380d0a52c13011863530f781da7654c37f8176645abf2eb08d39d24d\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000007,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"reply\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Ambiguous\",\"sig\":\"1f61730dda3b1d00ff2f85701f3e928bfdbd9f1dff8ab2c0971413e03194718c1bba766e62738074ad245d2fae23ebeec2cd3ab45eb353dd82a116833139de42\"}" }, "expected": { "error": "reply_reference_ambiguous" @@ -945,7 +945,7 @@ "id": "project_signed_nip10_invalid_event_id", "kind": "social.reply.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"2c071cfb2696c8cb2e676564290007bcbc995d08a5404462f747d6ceb73194fe\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000009,\"kind\":1,\"tags\":[[\"e\",\"not-an-event-id\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Invalid event id\",\"sig\":\"712bed9b975b2742dbb4b241e70893c67fbd4debab110c7d44bf365500405cc9c5c39efa1f9a3260a3a57f10cd0b981d84c327ff4ae0f1afa21c6e9fc8f8b851\"}" + "event_json": "{\"id\":\"8847b627c9e8e07a6506fcd66e2db952b76d12128498093f149f7b166400b3d0\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000009,\"kind\":1,\"tags\":[[\"e\",\"not-an-event-id\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Invalid event id\",\"sig\":\"83e1bf2c28e54ef653d52b352f9f41693030f50e105d4d67027df357187e84343ab44ceb039e09c3e93806692a4b670a94f00e407eb86d86b9af20d5c92f5602\"}" }, "expected": { "error": "reply_event_id_invalid" @@ -955,7 +955,7 @@ "id": "project_signed_nip10_author_hint_mismatch", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"a54d89f21c0a80df592eab60b07b0800ea5bd212fbe17d0f9deb852c109775a9\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000010,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Author mismatch\",\"sig\":\"3a9b2af22a1b63a1bf6a4cdf0f83497a199ff6f5edc5b1ea9fd0a70c4dd12034431e3992be9ee80827cd0f396e70d0a9f0c316408a269c80949cb5a0cf59c5f7\"}" + "event_json": "{\"id\":\"24e1807536ffe4dcdb01267f6eca1e53bf34c3e2d20a13111b864d5af8b14c43\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000010,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"]],\"content\":\"Author mismatch\",\"sig\":\"ae4be83e55311c831de6e47fb070b42bd92632010eca8f045519fddccdb029ebfbe8e7a4d58885522d1a4fc18cb43c6e3f5db8ecb6a11ec5f536e3a3a74c5c61\"}" }, "expected": { "style": "marked", @@ -963,18 +963,18 @@ "direct": true, "root": { "tag_index": 0, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "1111111111111111111111111111111111111111111111111111111111111111", "relay": null, - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" }, "parent": null, "citations": [], "participants": [ { "tag_index": 1, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null } ], @@ -982,7 +982,7 @@ { "code": "reply_author_mismatch_ignored", "tag_index": 0, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"] } ] } @@ -991,7 +991,7 @@ "id": "project_signed_nip10_blank_content", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"bb57163b91910e28747d2ea50c89a84fc4d72ce0509d87e97492518f71081d39\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000013,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"\",\"sig\":\"b1833129fc7568362e5c308a3d4b44b09c5d7b7c474b1335cb9a332e9167689678c009547f312b5bc79442bd018ed8ed2af6c64eafd3112bb96e154d6b7b4b48\"}" + "event_json": "{\"id\":\"399f0d0258c1546392643f1fab84fa4ee5fc6cfaf28608f6dff48c027c1f1ec4\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000013,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"\",\"sig\":\"d0409197aa610b8febfea4fb527355b8f43021298c272cd1978a5f9e3edd75d2f08a6cc96fa32d2a3b70026a0403b285813231558cf8cddaffd0a1a3bf8426c7\"}" }, "expected": { "style": "marked", @@ -1009,8 +1009,8 @@ "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -1021,7 +1021,7 @@ "id": "project_signed_nip10_invalid_author_hint_tolerated", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"64a50435d0e23c12ae6e52b0eddcd0e9a8efb07f0c59e2b41f839a30b9593d56\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000014,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\",\"not-a-pubkey\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Malformed optional author\",\"sig\":\"3a554dd47779861e5c020bdcad2103b4831eaf7930b8a801bb99d7c0bcd898ef1a4a79aa8a3856533ab1b88bed0b2a8c44afafa6118daf8b172d502e22eeb95f\"}" + "event_json": "{\"id\":\"d4d58ea56554e78570dc2d75507b1211879002d69b902787b02e93b864315c09\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000014,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\",\"not-a-pubkey\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Malformed optional author\",\"sig\":\"072e5c49445db582f80620d45e07836bd76860de7b347c48259bc94d718b04b89c02cce87dee6e32b27137a218b7fe0a387856e81b6268bf8bcb3fe0e5ab2f9f\"}" }, "expected": { "style": "marked", @@ -1039,8 +1039,8 @@ "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -1086,7 +1086,7 @@ "id": "project_signed_nip10_lone_reply_marker", "kind": "social.reply.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"eb5f2eea6aa74ffcbd758cb5fb72749babcad7343717cb30caac19200154f03a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000011,\"kind\":1,\"tags\":[[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"\",\"reply\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Lone reply\",\"sig\":\"b4f2d2a564f248fc7012e427a1fea9267de728342485c3f8a4f19066cd15e4295b14a51f7aab898d38e61a5c86d134a80863e6ae1394081b4d9e368dee65e94b\"}" + "event_json": "{\"id\":\"e5822b15847a9952e156f9c46b3e1a3673da63e6ed72035a9088722c3bbfa3df\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000011,\"kind\":1,\"tags\":[[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"\",\"reply\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"]],\"content\":\"Lone reply\",\"sig\":\"0b4f90ad15407fd37119ae1e6c34e9c00c200790735fe457c19f53a45576a5cd444fd1506bd0e4d3b5ad50c1905e4022cc901ae74de42d78e5f68d018ac6f977\"}" }, "expected": { "error": "reply_marker_count" @@ -1096,7 +1096,7 @@ "id": "project_signed_nip10_unknown_marker", "kind": "social.reply.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"266e800a1a46fefad41fd745d3750eac53e5527339be4aa0b28f0fb8ebe34bea\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000012,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"mention\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Unknown marker\",\"sig\":\"fd06119aad71aef0851c900fed068540ca6dde40ccfb8fc70c74cf8d10f0fdd13aac49863753fa8f1fd679c1890ba410259a291780682cc3f8fcf5b89e381afe\"}" + "event_json": "{\"id\":\"69f5cd728b45ee3688d949c318c4b46a1a7320d8f700b289339e537235007968\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000012,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"mention\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Unknown marker\",\"sig\":\"52a5d203bd422ba9c2962093d09bdf03d8fd02eaa5ab4eb518888e1fcf9db7a13e6d305e356baa1a6edf36f9752cc1830c9d7f9493d705fad6655e5d580571f5\"}" }, "expected": { "error": "reply_marker_missing" @@ -1106,7 +1106,7 @@ "id": "admit_signed_nip10_marked_direct", "kind": "social.reply.verify_and_admit_event.valid", "input": { - "event_json": "{\"id\":\"2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Direct reply\",\"sig\":\"16afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd\"}" + "event_json": "{\"id\":\"d62b9340ac9808fc84a751788d017344ede91aea1c89081be3f7bff4a2266354\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Direct reply\",\"sig\":\"206758bee31328a53457e3190b046a4a184941223f4e8ed1696db28d71c9af03138fe9add32d5e97d6f3b50bd0f78e4d5a91629dbf4d12aedd4315ce96c97e8c\"}" }, "expected": { "contract_id": "radroots.social.reply.v1", @@ -1118,7 +1118,7 @@ "id": "admit_signed_nip10_positional_direct", "kind": "social.reply.verify_and_admit_event.valid", "input": { - "event_json": "{\"id\":\"802da76963580d5932a400a2e56e73caccb80c78d43845c62f5fd95feee600e8\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000003,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Legacy direct reply\",\"sig\":\"9a1a958170b2faf3c467bf174e776f7f61bf886644657cc12d92d6a9782db89f273508e1d940412645bbb73a6ce7948b7d20daa77647aa5fd8f9e164159826e7\"}" + "event_json": "{\"id\":\"a7141da919755d6ace9b5fa400928d512b4ef785c0ec03d55989bc2d6cbce860\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000003,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Legacy direct reply\",\"sig\":\"eb2112223ed35f4b0919578a4981e08b403779391f6f9a831ea51763779028d85f72ce8224b1ae469ad6baea21d1d275210bb438a126b4391dab0c347051917a\"}" }, "expected": { "contract_id": "radroots.social.reply.v1", @@ -1130,7 +1130,7 @@ "id": "admit_signed_nip10_invalid_signature", "kind": "social.reply.verify_and_admit_event.invalid", "input": { - "event_json": "{\"id\":\"2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Direct reply\",\"sig\":\"06afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd\"}" + "event_json": "{\"id\":\"d62b9340ac9808fc84a751788d017344ede91aea1c89081be3f7bff4a2266354\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Direct reply\",\"sig\":\"006758bee31328a53457e3190b046a4a184941223f4e8ed1696db28d71c9af03138fe9add32d5e97d6f3b50bd0f78e4d5a91629dbf4d12aedd4315ce96c97e8c\"}" }, "expected": { "error": "signature_invalid" diff --git a/contracts/conformance/vectors/profile/verified_event.v1.json b/contracts/conformance/vectors/profile/verified_event.v1.json @@ -4,12 +4,12 @@ "vectors": [ { "expected": { - "event_id": "a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958", + "event_id": "407b62d3d2c91737c4cba8352ec844b2c5cdd2c9dd441edd93a3c70cdbf8d7e7", "kind": 65535 }, "id": "event_verify_max_kind_001", "input": { - "event_json": "{\"id\":\"a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000104,\"kind\":65535,\"tags\":[],\"content\":\"maximum-kind\",\"sig\":\"d79b19843a0bfd769c02c73866d44a3a06f7b11e107a5257971b60e700aa25565802fd3a7eed4042fe8db7d709a465e5f61478eb8291178831bf48f6b0980671\"}" + "event_json": "{\"id\":\"407b62d3d2c91737c4cba8352ec844b2c5cdd2c9dd441edd93a3c70cdbf8d7e7\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000104,\"kind\":65535,\"tags\":[],\"content\":\"maximum-kind\",\"sig\":\"d1791afa8b3428151e6d6c8425842bcc68ebbce202bb6abb24976ad514794f8df5b4602cf69eee26fb20b7afcb249fb7a0eac5c953839838bccdb03bd1212ab6\"}" }, "kind": "event.verify_nip01.valid" }, @@ -19,7 +19,7 @@ }, "id": "event_verify_id_mismatch_001", "input": { - "event_json": "{\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"created_at\":1800000100,\"id\":\"fdcebf1cbdcddea8027b5e214cb7b223fa662d8b85f5d68acf2e8849be0d71c4\",\"kind\":0,\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"sig\":\"e5448d11671bcf73aa8d56941aff9df46d4e9fb250596671950e5f3c9d747440523efd33d8b9ff4f2a2ef1bd4b79e0a7cf5850132172b1db4b642ef2b348f721\",\"tags\":[]}" + "event_json": "{\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"created_at\":1800000100,\"id\":\"062bee187e9e645b81ec26ade05a69b5e8398caf527be8de0d9a45311ed0c7a0\",\"kind\":0,\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"sig\":\"4290da0bb6422986647bc8cd5f63bd52d49f41e7b665d3b47105b8109183e8d596f322c531d4061df53e1d2b70fda12d5d1c14f3720d7a56d9d0a03746af5109\",\"tags\":[]}" }, "kind": "event.verify_nip01.invalid_id" }, @@ -29,19 +29,19 @@ }, "id": "event_verify_signature_invalid_001", "input": { - "event_json": "{\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"created_at\":1800000100,\"id\":\"b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f\",\"kind\":0,\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"sig\":\"d015e365c530c44fbc33e970af13616e482fafe65d0947edcdaa1e7b3038e9285b4b6826bdf454e8381d66e9dc6978d9974358572d97850ebc726fcc539e2b88\",\"tags\":[]}" + "event_json": "{\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"created_at\":1800000100,\"id\":\"762bee187e9e645b81ec26ade05a69b5e8398caf527be8de0d9a45311ed0c7a0\",\"kind\":0,\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"sig\":\"0290da0bb6422986647bc8cd5f63bd52d49f41e7b665d3b47105b8109183e8d596f322c531d4061df53e1d2b70fda12d5d1c14f3720d7a56d9d0a03746af5109\",\"tags\":[]}" }, "kind": "event.verify_nip01.invalid_signature" }, { "expected": { - "error": "malformed_envelope" + "error": "invalid_public_key" }, "id": "event_verify_malformed_curve_pubkey_001", "input": { "event_json": "{\"id\":\"8d71bc1e4126e8b16c7e9229487d9028df9508c156d5e501d1487eda799af161\",\"pubkey\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"created_at\":1800000107,\"kind\":0,\"tags\":[],\"content\":\"malformed-curve-pubkey\",\"sig\":\"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\"}" }, - "kind": "event.verify_nip01.malformed_envelope" + "kind": "event.wire.invalid_public_key" }, { "expected": { @@ -50,13 +50,13 @@ }, "id": "event_verify_kind_overflow_001", "input": { - "event_json": "{\"content\":\"overflow-kind\",\"created_at\":1800000106,\"id\":\"82dff74958bd7e1e52ad98add08bbb70d4ce9cf9d90e4741c64c212df0804ccb\",\"kind\":65536,\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"sig\":\"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"tags\":[]}" + "event_json": "{\"content\":\"overflow-kind\",\"created_at\":1800000106,\"id\":\"ecb111a334b5d374db240f42c32fd57ce61f42d4bbe1c1d88b62ed72755f906c\",\"kind\":65536,\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"sig\":\"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"tags\":[]}" }, "kind": "event.verify_nip01.kind_overflow" }, { "expected": { - "event_id": "b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f", + "event_id": "762bee187e9e645b81ec26ade05a69b5e8398caf527be8de0d9a45311ed0c7a0", "projected": { "bot": false, "display_name": "Moss Street Farm" @@ -69,7 +69,7 @@ }, "id": "profile_admit_tagless_tolerant_001", "input": { - "event_json": "{\"id\":\"b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000100,\"kind\":0,\"tags\":[],\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"sig\":\"e5448d11671bcf73aa8d56941aff9df46d4e9fb250596671950e5f3c9d747440523efd33d8b9ff4f2a2ef1bd4b79e0a7cf5850132172b1db4b642ef2b348f721\"}" + "event_json": "{\"id\":\"762bee187e9e645b81ec26ade05a69b5e8398caf527be8de0d9a45311ed0c7a0\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000100,\"kind\":0,\"tags\":[],\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"sig\":\"4290da0bb6422986647bc8cd5f63bd52d49f41e7b665d3b47105b8109183e8d596f322c531d4061df53e1d2b70fda12d5d1c14f3720d7a56d9d0a03746af5109\"}" }, "kind": "profile.verify_and_admit.valid" }, @@ -80,7 +80,7 @@ }, "id": "profile_admit_wrong_kind_001", "input": { - "event_json": "{\"id\":\"05b2ebdf444f09330198f8c07ea3e3c7d8ac97d94c41e957b74bce921f611080\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000102,\"kind\":1,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"not-a-profile\\\"}\",\"sig\":\"3d12c2dfea5cff7e9993166d20b9092f4a6a9dd7e9f4af26355d2f4c4fa0e1ba7a0e5fe6e4b90c93ae57d52cc71b38b409051702200868dfd601dbdd74b89710\"}" + "event_json": "{\"id\":\"530131615015d1631d89c80e5cbde5487ce44de24d1164e7d9d8be86f47f1399\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000102,\"kind\":1,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"not-a-profile\\\"}\",\"sig\":\"1e62c75d7cd1427ce03728ebc27413c284a9c72a0c1049ea6e537ccf7f1a3ed139718468c3f0cf78dc2da7d22ccf35843463272d8f42f59bccd69ffebb8ef3bf\"}" }, "kind": "profile.verify_and_admit.invalid_kind" }, @@ -90,19 +90,19 @@ }, "id": "profile_admit_non_object_001", "input": { - "event_json": "{\"id\":\"671f2d9f80a2fa2759c9514ae36fd18296cee71417fc86995525f2c659a5f781\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000103,\"kind\":0,\"tags\":[],\"content\":\"[]\",\"sig\":\"67d0420384e366407499ce4ab501cd87a02bea0d4221007b7a3ff01d9b68a0b9d372882184ab48775a8516f6a6554b506fc3e7652162a53e08a43e63daeacac7\"}" + "event_json": "{\"id\":\"6205ecbaead32e5bb64afa301446f3ba9cf47d3113b23db6978800f47e5e4183\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000103,\"kind\":0,\"tags\":[],\"content\":\"[]\",\"sig\":\"909f64b7b5393a80e7db8d0aa788d301133f966c23346e71ca9c019f8b7ac297fe7e27b5049410d60f5858c9060bc7e86fcf8b165c53da2c45f200a00b03f98f\"}" }, "kind": "profile.verify_and_admit.invalid_metadata" }, { "expected": { "created_at": 1800000105, - "event_id": "ad00fc208cc7036b95fadb7a4660bc600aa007502430250630d70d15b50695b3" + "event_id": "73956f3c723129dbe43ec2865261f6de04632b2817daede8b3cc47ab93faac3f" }, "id": "profile_equal_time_lowest_id_001", "input": { - "first_event_json": "{\"id\":\"fdcebf1cbdcddea8027b5e214cb7b223fa662d8b85f5d68acf2e8849be0d71c4\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000105,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"arrival-a\\\"}\",\"sig\":\"445a4a08bd365acbe3d68b1433cd8952f8974cb1f454c7d6790881b123c1e7a6449662f78e3044436c7cda8745ae74aa8ee382e292160a3a3f420dd3bb4f275c\"}", - "second_event_json": "{\"id\":\"ad00fc208cc7036b95fadb7a4660bc600aa007502430250630d70d15b50695b3\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000105,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"arrival-b\\\"}\",\"sig\":\"6b52cb1cbcf2639104b564144517d8479833272690514af9e9d45b170a1c88320ac3425b9339aa525e591584f97666b1c556b4709fb6ef27959775e70c8caabe\"}" + "first_event_json": "{\"id\":\"73956f3c723129dbe43ec2865261f6de04632b2817daede8b3cc47ab93faac3f\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000105,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"arrival-a\\\"}\",\"sig\":\"18c2196e1f9eba23147f138f2ee2e5b140bbfc48ec37f72f4919c28d2d03eaeb60269fe7c6d61a8d93f5d25e05a94f5f87dc1a449850b239114bb7fe76cd6190\"}", + "second_event_json": "{\"id\":\"e6e8ff39b7e4365d8fd89a8d47fb6c4e33ce364a449aeb108d5219b4f5d23658\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000105,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"arrival-b\\\"}\",\"sig\":\"0b87cc548732f65032187fe33e1f357fd8fe4d1d4c6ec3a2ee8e42713f6bfa73c12f2e6740374ec11b1f318d78c07730dc495abbe50ba025dfbe697041297a22\"}" }, "kind": "profile.select_equal_time_head" } diff --git a/contracts/conformance/vectors/protocol/wire_values.v1.json b/contracts/conformance/vectors/protocol/wire_values.v1.json @@ -0,0 +1,153 @@ +{ + "suite": "protocol_wire_values_v1", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "protocol_capability_transport_kind_001", + "kind": "protocol.capability.transport_kind", + "input": "reticulum", + "expected": "reticulum" + }, + { + "id": "protocol_capability_reticulum_target_002", + "kind": "protocol.capability.reticulum_target", + "input": { + "destination": { "canonical": "a1b2c3d4" }, + "mesh_scope": { "value": "farm.mesh-1" } + }, + "expected": { + "destination": { "canonical": "a1b2c3d4" }, + "mesh_scope": { "value": "farm.mesh-1" } + } + }, + { + "id": "protocol_event_class_003", + "kind": "protocol.event.event_class", + "input": "unsigned_rumor", + "expected": "unsigned_rumor" + }, + { + "id": "protocol_trade_state_004", + "kind": "protocol.event.trade_state", + "input": "agreed_pending_validation", + "expected": "agreed_pending_validation" + }, + { + "id": "protocol_runtime_operation_id_005", + "kind": "protocol.runtime.operation_id", + "input": "sync.push", + "expected": "sync.push" + }, + { + "id": "protocol_runtime_risk_006", + "kind": "protocol.runtime.risk", + "input": "critical", + "expected": "critical" + }, + { + "id": "protocol_runtime_transport_route_007", + "kind": "protocol.runtime.transport_route", + "input": { + "local": true, + "nostr": true, + "reticulum": false, + "deliver": true, + "fetch": false, + "synchronize": true, + "diagnostics": false + }, + "expected": { + "local": true, + "nostr": true, + "reticulum": false, + "deliver": true, + "fetch": false, + "synchronize": true, + "diagnostics": false + } + }, + { + "id": "protocol_daemon_target_policy_008", + "kind": "protocol.radrootsd.target_policy", + "input": { + "kind": "nostr", + "source_policy": "explicit_only", + "relay_urls": [] + }, + "expected": { + "kind": "nostr", + "source_policy": "explicit_only", + "relay_urls": [] + } + }, + { + "id": "protocol_daemon_delivery_policy_009", + "kind": "protocol.radrootsd.delivery_policy", + "input": { "mode": "quorum", "quorum": 2 }, + "expected": { "mode": "quorum", "quorum": 2 } + }, + { + "id": "protocol_error_report_010", + "kind": "protocol.error.report", + "input": { + "schema_version": 1, + "code": "relay_rate_limited", + "class": "network", + "retryable": true, + "recovery_actions": ["retry_after_transport_failure"], + "operation_id": "sync.push", + "capability_id": "nostr", + "message": "Relay rate limit requires a later retry", + "details": [] + }, + "expected": { + "schema_version": 1, + "code": "relay_rate_limited", + "class": "network", + "retryable": true, + "recovery_actions": ["retry_after_transport_failure"], + "operation_id": "sync.push", + "capability_id": "nostr", + "message": "Relay rate limit requires a later retry", + "details": [] + } + }, + { + "id": "protocol_runtime_unknown_operation_011", + "kind": "protocol.runtime.operation_id", + "input": "runtime.unknown", + "expected_error_contains": "unknown operation id" + }, + { + "id": "protocol_runtime_route_unknown_field_012", + "kind": "protocol.runtime.transport_route", + "input": { + "local": true, + "nostr": false, + "reticulum": false, + "deliver": false, + "fetch": false, + "synchronize": false, + "diagnostics": false, + "executor": "forbidden" + }, + "expected_error_contains": "unknown field" + }, + { + "id": "protocol_error_report_version_013", + "kind": "protocol.error.report", + "input": { + "schema_version": 2, + "code": "relay_rate_limited", + "class": "network", + "retryable": true, + "recovery_actions": ["retry_after_transport_failure"], + "operation_id": "sync.push", + "capability_id": "nostr", + "message": "Relay rate limit requires a later retry", + "details": [] + }, + "expected_error_contains": "unsupported error report schema version 2" + } + ] +} diff --git a/contracts/coverage.toml b/contracts/coverage.toml @@ -40,6 +40,7 @@ crates = [ "radroots_event_codec", "radroots_event_index", "radroots_geocoder", + "radroots_geonames", "radroots_identity", "radroots_runtime_store", "radroots_log", @@ -57,6 +58,12 @@ crates = [ "radroots_protocol_contract_v1", "radroots_protected_store", "radroots_transport_publish_protocol", + "radroots_protocol", + "radroots_secrets", + "radroots_signing", + "radroots_storage", + "radroots_storage_sqlite", + "radroots_sync", "radroots_transport_nostr", "radroots_transport_reticulum", "radroots_replica_store", diff --git a/contracts/event_store_production_sources.toml b/contracts/event_store_production_sources.toml @@ -3,7 +3,7 @@ hash_algorithm = "rust_production_ast_sha256_v1" [[sources]] path = "crates/event_store/src/error.rs" -sha256 = "4cced9506c8c0c78d7826460a3aba0a0466ab739974fa26de2f78b46f19d5d6b" +sha256 = "978c09292779c967a299ba5ea45d10ecb942755c592b3959c4cf771d8dbc9bf2" [[sources]] path = "crates/event_store/src/lib.rs" @@ -15,11 +15,11 @@ sha256 = "097bca46604d381fdb047b059a51c759988038e0b98e60e7b75828fd4d4f4276" [[sources]] path = "crates/event_store/src/model.rs" -sha256 = "dd56f9285e3b11248f3d9b28171d1a02861d1578a2ff607cbe593bd2c649b2a2" +sha256 = "efc97799fb2f2f27d3762d7fb34ba932623df3fe0b5c64ef3cc50bb7b9fe8ee5" [[sources]] path = "crates/event_store/src/model/addressable_transition_feed_v1.rs" -sha256 = "bf0ed43e03af0d45f9406f2ca2463b7ab740b0582191c9ba44a36cdc77de3f4e" +sha256 = "052e744e85e89f0fa89b847cbe0a953b283d8cb92f65e5c04a86a22a782db31e" [[sources]] path = "crates/event_store/src/model/current_visibility_v1.rs" @@ -27,7 +27,7 @@ sha256 = "f7ac71596075fe1bff6556d0eb0b63cc0fc0a20a8aca567f77fb05796f0c3d1c" [[sources]] path = "crates/event_store/src/model/food_availability_projection_v1.rs" -sha256 = "4403608130b68f5fb1398dd46aa14147e315fa101cb7c9ffc3f9ed1791413889" +sha256 = "1434a7329042157be0ca4ec940f74f88350b6d57df5719b9b34d09613e6083da" [[sources]] path = "crates/event_store/src/model/ingest_reconciliation_v1.rs" @@ -47,7 +47,7 @@ sha256 = "fbd8a3b36d7f36e7b0d301aee0847d42c3908659f066cafcae3e247d67a75845" [[sources]] path = "crates/event_store/src/nip09/reconciliation_v1.rs" -sha256 = "4b7f9fb91eb6ac0bffa97d0efa4c2c4eb7842dccb0a6209d752979d3b274c88c" +sha256 = "6e4855a06457d89d29d9cb442a90b9123e81fc4a472c9a50065c4e8f4cf8e25e" [[sources]] path = "crates/event_store/src/nip09/reconciliation_v1/raw_source_rebuild.rs" @@ -55,7 +55,7 @@ sha256 = "30a465f5df9a37654d15df2a92ee445037ef41c6f5a5b31e216879f4903b3d6c" [[sources]] path = "crates/event_store/src/nip09/reconciliation_v1/visibility_oracle_v1.rs" -sha256 = "9a9f3e12bd0eebf0e7ce570daa812cca0c3ffe1d0bd84a4a6f81b0d1452a4679" +sha256 = "0c7f3d5bb0a2210a17a000c329914be94834a71d21d3713e9100b06588b635d4" [[sources]] path = "crates/event_store/src/schema.rs" @@ -63,15 +63,15 @@ sha256 = "2095b5d6bcdbfc2a1c6eaf7a5e43ff8cd7b6375b29f46fad174f82fcc7ede8e4" [[sources]] path = "crates/event_store/src/source_maintenance_v1.rs" -sha256 = "423872e2fcb98a14ddf86e2582ba9783261e78950f15d4260c0f138398eb1bae" +sha256 = "e7e96dae1f1993f021e42199ec47f6e9e79a0d6eb1043b80227b950c8938ac42" [[sources]] path = "crates/event_store/src/store.rs" -sha256 = "47949ce741c8a13e517a375d82fc6fe004824f76bfa655c3ae3184c152302ab7" +sha256 = "24a1b7da93b73043bb488e524043d638537885172836611506a2a5dd4a1c3b74" [[sources]] path = "crates/event_store/src/store/addressable_transition_feed_v1.rs" -sha256 = "3903af04bd966ba6d19e2c2cd098478d269566676572d1385abdc23353962c91" +sha256 = "9893ccbfdb879185e66752afa7e1ac7cd9dc6dce67644f599e1ec8e3c5752f1a" [[sources]] path = "crates/event_store/src/store/current_visibility_v1.rs" @@ -79,7 +79,7 @@ sha256 = "93129c02bf55858aa52e538ef50bf920d35719696183d339443e5c4060d2d4a3" [[sources]] path = "crates/event_store/src/store/food_availability_projection_v1.rs" -sha256 = "f14f14605fb6843d9f99e76b67202d802d0fbc9fefae1739bfc0477a6a22c96d" +sha256 = "eb274ea78401a9fab61e32f9316f8618f581a78a4876a2a80baa53c3467302dd" [[sources]] path = "crates/event_store/src/store/post_core_extension_capabilities.rs" @@ -91,7 +91,7 @@ sha256 = "1a5569a1a29849db3eeebc92316aeea26db4b2dc58b7b7f297349fcb89b0ca34" [[sources]] path = "crates/event_store/src/store/post_core_extensions_v1.rs" -sha256 = "03829790c0bdbd7dea13099b24f1f0edae2361e5e5f5eaf93d763bd7d018706b" +sha256 = "8615ef2aad2b0777d1cf8e3fb571692d8041eee99e71df6966fac90f1e25740b" [[sources]] path = "crates/event_store/src/store/post_core_extensions_v2.rs" @@ -99,7 +99,7 @@ sha256 = "9a1e1e576c5e82a87b26a20cc95a18ee57dda200d50a7e7c51150465e28815b4" [[sources]] path = "crates/event_store/src/store/post_core_storage_v1.rs" -sha256 = "3febcfc52c52e027764c29c11ab38b148328f829525b6c8cb37cd1ca5c173811" +sha256 = "224f65c7349d11a38cea9a82994ede167f04b5ada7cdd1cab5677356570cc181" [[sources]] path = "crates/event_store/src/store/post_core_storage_v2.rs" @@ -107,8 +107,8 @@ sha256 = "5ebd237e8f475184480938472e472ff8f4a7ea7b9f46f9d6100520d1c2d643c4" [[sources]] path = "crates/event_store/src/store/protocol_reconciliation_v1.rs" -sha256 = "cadc558979750f4b75d7e89847af63a728c963334f2aa7b553bdf0a297524ec4" +sha256 = "62481efecc6c0465c3e572cba8b688ea721b4d932fb8c2b971b867352743878b" [[sources]] path = "crates/event_store/src/store/protocol_storage_v1.rs" -sha256 = "e9a1041f7083962dc95c706d06da232abb1f0547b64351838ad6898f4209bdeb" +sha256 = "cb59367edc3ef6b2cc147b1fd62a1f11b3782c04dd473adf61bebed4ad96986b" diff --git a/contracts/events/blossom-authorization.md b/contracts/events/blossom-authorization.md @@ -36,11 +36,11 @@ all-server-match rule remain outside this public crate. ## Actions And Endpoint Targets -`RadrootsBlossomAuthorizationAction` has exactly the five lowercase BUD-11 wire values `get`, +`AuthorizationAction` has exactly the five lowercase BUD-11 wire values `get`, `upload`, `list`, `delete`, and `media`. Case variants, whitespace, unknown verbs, and the empty string are invalid. -Endpoint validation uses an explicit `RadrootsBlossomAuthorizationTarget`: +Endpoint validation uses an explicit `AuthorizationTarget`: | Endpoint | Target | Required action | Implied hash | `x` policy | | --- | --- | --- | --- | --- | @@ -56,7 +56,7 @@ does not authorize arbitrary endpoints and does not perform HTTP parsing. ## Human-Readable Content -`RadrootsBlossomAuthorizationContent` is from 1 through 4,096 UTF-8 bytes and is already equal to +`AuthorizationContent` is from 1 through 4,096 UTF-8 bytes and is already equal to its Unicode-whitespace-trimmed form. It rejects empty and whitespace-only input, leading or trailing whitespace, NUL, and non-whitespace controls. Horizontal tab, newline, and carriage return are allowed only inside otherwise human-readable content. Interior ordinary whitespace and @@ -69,7 +69,7 @@ as `Upload farm photo`. ## Server Domains -A `RadrootsBlossomServerDomain` is an ASCII lowercase domain name of at most 253 bytes without +A `ServerDomain` is an ASCII lowercase domain name of at most 253 bytes without scheme, user information, port, path, query, fragment, IP-literal brackets, or trailing dot. Every dot-separated label is from 1 through 63 bytes, begins and ends with an ASCII lowercase letter or digit, and otherwise contains only ASCII lowercase letters, digits, or `-`. `localhost` is valid. @@ -88,7 +88,7 @@ reduces replay scope without changing tolerant inbound BUD-11 semantics. ## Blob Hash Scope -Every known `x` tag value is a `RadrootsBlossomSha256`: exactly 64 lowercase hexadecimal +Every known `x` tag value is a `Sha256`: exactly 64 lowercase hexadecimal characters. Multiple valid `x` tags are allowed. For targets requiring a hash scope, at least one `x` tag must equal the target's implied hash. Other @@ -100,7 +100,7 @@ Strict authored upload claims contain exactly one `x` tag equal to the upload ha ## Claim Tag Parsing -`RadrootsBlossomParsedAuthorizationClaim::parse` consumes signed content, the event `created_at` +`AuthorizationClaim::parse` consumes signed content, the event `created_at` timestamp, and raw Nostr tag arrays. Structural parsing occurs before endpoint policy validation. Known tags use exact lowercase names and require at least a key and value: @@ -126,7 +126,7 @@ or target scope. Those checks belong to the appropriate outward adapter or valid ## Time Validation Endpoint validation receives an explicit `now` so it is deterministic and does not read a wall -clock. `RadrootsBlossomAuthorizationValidation::bud11` applies only the pinned BUD time and optional +clock. `AuthorizationValidation::bud11` applies only the pinned BUD time and optional server semantics. The existing bounded constructor applies the Radroots replay profile with `RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS = 300` and `RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS = 300`. @@ -148,7 +148,7 @@ seconds inclusive, and `created_at + lifetime` must not overflow `u64`. ## Strict Authored Upload Claims -`RadrootsBlossomAuthoredUploadClaim::new` requires typed human-readable content, one typed server +`AuthoredUploadClaim::new` requires typed human-readable content, one typed server domain, one exact typed SHA-256, `created_at`, and a valid authored lifetime. It produces canonical wire parts in this exact order: @@ -254,7 +254,7 @@ The outward Nostr adapter additionally exposes these stable boundary identifiers - `invalid_event_signature` - `event_signing` -An authenticated pure-claim failure retains its `RadrootsBlossomError::code()` identifier rather +An authenticated pure-claim failure retains its `Error::code()` identifier rather than being collapsed into a generic adapter error. Direct typed action, server-domain, and hash parsing uses `invalid_authorization_action`, diff --git a/contracts/events/blossom-media.md b/contracts/events/blossom-media.md @@ -30,7 +30,7 @@ BUD-11 is added as a separate foundation slice. ## SHA-256 -A `RadrootsBlossomSha256` is exactly 32 bytes. Its wire and display form is exactly 64 lowercase +A `Sha256` is exactly 32 bytes. Its wire and display form is exactly 64 lowercase ASCII hexadecimal characters. - Parsing rejects uppercase hexadecimal, prefixes, separators, whitespace, non-hexadecimal bytes, @@ -41,7 +41,7 @@ ASCII hexadecimal characters. ## Root Hash Paths -A `RadrootsBlossomHashPath` represents one BUD-01 root path: +A `HashPath` represents one BUD-01 root path: ```text /<64-lowercase-hex-sha256>[.<extension>] @@ -61,7 +61,7 @@ include one. The extension is not used to infer or validate the descriptor MIME ## Structural Blob URLs -A `RadrootsBlossomBlobUrl` is an absolute structural BUD-01 URL with these invariants: +A `BlobUrl` is an absolute structural BUD-01 URL with these invariants: - the scheme is `http` or `https`, compared case-insensitively and exposed canonically in lowercase - the authority contains one nonempty DNS hostname, canonical dotted-decimal IPv4 address, or @@ -72,7 +72,7 @@ A `RadrootsBlossomBlobUrl` is an absolute structural BUD-01 URL with these invar - user information is forbidden - the path is exactly one root hash path - query and fragment components are forbidden -- the path digest is exposed as a typed `RadrootsBlossomSha256` +- the path digest is exposed as a typed `Sha256` - an optional safe extension is parsed independently from the digest Scheme and DNS host case are canonicalized to lowercase by the URL parser. Extension case is @@ -111,7 +111,7 @@ responsible for their own destination, redirect, DNS-rebinding, response-size, a ## Media Types -A `RadrootsBlossomMediaType` is a syntactically valid MIME media type parsed by the repository's +A `MediaType` is a syntactically valid MIME media type parsed by the repository's `mediatype` dependency. MIME parameters are supported. The canonical representation lowercases the type, subtype, suffix, and parameter names, sorts parameters by name, and preserves parameter values. Duplicate parameter names are rejected. Semantic equality is case-insensitive for those @@ -129,14 +129,14 @@ does not sniff or infer content type from file bytes or the URL extension. ## BUD-02 Descriptors -A structural `RadrootsBlossomBlobDescriptor` contains the five required BUD-02 fields: +A structural `BlobDescriptor` contains the five required BUD-02 fields: | Field | Contract | | --- | --- | | `url` | structurally valid absolute root blob URL with an extension | | `sha256` | typed lowercase SHA-256 equal to the digest in `url` | | `size` | unsigned 64-bit byte count | -| `type` | parsed `RadrootsBlossomMediaType` | +| `type` | parsed `MediaType` | | `uploaded` | unsigned 64-bit Unix timestamp | Unknown descriptor fields are tolerated on input for forward compatibility. They need not be @@ -153,12 +153,12 @@ clock or treat it as server attestation. ## Byte Commitments And Byte-Verified Descriptors -A structural descriptor advances to `RadrootsBlossomApprovedDescriptor` only after its URL passes -the explicit HTTPS-or-loopback reference policy. `RadrootsBlossomByteCommitment::from_bytes` +A structural descriptor advances to `ApprovedDescriptor` only after its URL passes +the explicit HTTPS-or-loopback reference policy. `ByteCommitment::from_bytes` computes the SHA-256 and exact `u64` size of a final byte slice and binds them to a caller-approved -`RadrootsBlossomMediaType`. +`MediaType`. -An approved descriptor advances to `RadrootsBlossomByteVerifiedDescriptor` only through +An approved descriptor advances to `ByteVerifiedDescriptor` only through `verify_commitment`, or through the `verify_bytes` convenience path that constructs the same byte commitment. Verification succeeds only when all three values match: @@ -196,7 +196,7 @@ this contract. Integration tests dispatch its vectors by `kind`: `bytes_hex` values are exact bytes encoded as lowercase hexadecimal. A `null` extension or port means absence. Invalid typed-operation vectors use the exact stable identifier returned by -`RadrootsBlossomError::code()`. +`Error::code()`. ## Stable Error Identifiers @@ -251,7 +251,7 @@ The public typed API exposes these stable semantic identifiers: - `publication_raster_container_dimension_mismatch` - `publication_authored_raster_dimension_mismatch` -Malformed descriptor JSON can fail in serde before a `RadrootsBlossomError` exists. The executable +Malformed descriptor JSON can fail in serde before a `Error` exists. The executable descriptor harness uses three additional wire-shape classifications for those cases: - `missing_descriptor_field`, with the missing field named separately @@ -259,7 +259,7 @@ descriptor harness uses three additional wire-shape classifications for those ca - `invalid_descriptor_uploaded` Nested descriptor values that reach a Radroots parser, including invalid SHA-256, URL, MIME, URL -extension, and URL/hash combinations, retain the applicable `RadrootsBlossomError::code()` value. +extension, and URL/hash combinations, retain the applicable `Error::code()` value. The canonical vector file is mirrored under `crates/blossom/tests/fixtures/` so the published crate can execute the same conformance suite. Source-workspace tests require the canonical file and assert diff --git a/contracts/events/profile-metadata.md b/contracts/events/profile-metadata.md @@ -65,7 +65,7 @@ remain present when `display_name` is used. The scoped optional fields are `display_name`, `about`, `nip05`, `bot`, `picture`, and `banner`; `bot` is a Boolean. NIP-05 values enter only through `RadrootsNip05Identifier`. Picture and banner values enter through the shared `RadrootsAuthoredImage`, which can wrap -only an `image/*` `RadrootsBlossomByteVerifiedDescriptor`. There is no raw-string +only an `image/*` `ByteVerifiedDescriptor`. There is no raw-string media setter or unchecked deserialization path. Generic `website`, `lud06`, and `lud16` strings remain outside this strict authored operation. diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -20,36 +20,36 @@ domains = [ [shared_types] public = [ - "RadrootsBlossomSha256", - "RadrootsBlossomFileExtension", - "RadrootsBlossomHashPath", - "RadrootsBlossomBlobUrl", - "RadrootsBlossomApprovedBlobUrl", - "RadrootsBlossomMediaType", - "RadrootsBlossomBlobDescriptor", - "RadrootsBlossomApprovedDescriptor", - "RadrootsBlossomByteCommitment", - "RadrootsBlossomByteVerifiedDescriptor", - "RadrootsBlossomBud02UploadStatus", - "RadrootsBlossomBud02UploadObservation", - "RadrootsBlossomBud01HeadObservation", - "RadrootsBlossomBud01GetCollector", - "RadrootsBlossomBud01GetObservation", - "RadrootsBlossomRasterFormat", - "RadrootsBlossomRasterDimensions", - "RadrootsBlossomAuthoredRasterDimensions", - "RadrootsBlossomPublicationReadinessEvidenceDigest", - "RadrootsBlossomPublicationReadinessEvidence", - "RadrootsBlossomAuthorizationAction", - "RadrootsBlossomAuthorizationContent", - "RadrootsBlossomServerDomain", - "RadrootsBlossomAuthorizationTarget", - "RadrootsBlossomServerScopeRequirement", - "RadrootsBlossomAuthorizationValidation", - "RadrootsBlossomParsedAuthorizationClaim", - "RadrootsBlossomValidatedAuthorizationClaim", - "RadrootsBlossomAuthorizationWireParts", - "RadrootsBlossomAuthoredUploadClaim", + "Sha256", + "FileExtension", + "HashPath", + "BlobUrl", + "ApprovedBlobUrl", + "MediaType", + "BlobDescriptor", + "ApprovedDescriptor", + "ByteCommitment", + "ByteVerifiedDescriptor", + "Bud02UploadStatus", + "Bud02UploadObservation", + "Bud01HeadObservation", + "Bud01GetCollector", + "Bud01GetObservation", + "RasterFormat", + "RasterDimensions", + "AuthoredRasterDimensions", + "PublicationReadinessEvidenceDigest", + "PublicationReadinessEvidence", + "AuthorizationAction", + "AuthorizationContent", + "ServerDomain", + "AuthorizationTarget", + "ServerScopeRequirement", + "AuthorizationValidation", + "AuthorizationClaim", + "ValidatedAuthorizationClaim", + "AuthorizationWireParts", + "AuthoredUploadClaim", "RadrootsNostrSignedBlossomAuthorization", "RadrootsNostrBlossomAuthorizationHeader", "RadrootsNostrVerifiedBlossomAuthorization", @@ -308,7 +308,7 @@ domain = "blossom" id = "blossom.parse_hash_path" stability = "beta" inputs = ["String"] -outputs = ["RadrootsBlossomHashPath"] +outputs = ["HashPath"] error_class = "validation_error" deterministic = true signing = "none" @@ -317,9 +317,9 @@ transport = "none" [operations.blossom_parse_hash_path.implementation] rust_modules = ["crates/blossom/src/hash.rs"] rust_types = [ - "radroots_blossom::RadrootsBlossomFileExtension", - "radroots_blossom::RadrootsBlossomHashPath", - "radroots_blossom::RadrootsBlossomSha256", + "radroots_blossom::FileExtension", + "radroots_blossom::HashPath", + "radroots_blossom::Sha256", ] [operations.blossom_parse_hash_path.conformance] @@ -329,8 +329,8 @@ vector = "contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json domain = "blossom" id = "blossom.approve_descriptor_reference" stability = "beta" -inputs = ["RadrootsBlossomBlobDescriptor"] -outputs = ["RadrootsBlossomApprovedDescriptor"] +inputs = ["BlobDescriptor"] +outputs = ["ApprovedDescriptor"] error_class = "validation_error" deterministic = true signing = "none" @@ -339,8 +339,8 @@ transport = "none" [operations.blossom_approve_descriptor_reference.implementation] rust_modules = ["crates/blossom/src/descriptor.rs", "crates/blossom/src/url.rs"] rust_types = [ - "radroots_blossom::RadrootsBlossomApprovedDescriptor", - "radroots_blossom::RadrootsBlossomBlobDescriptor", + "radroots_blossom::ApprovedDescriptor", + "radroots_blossom::BlobDescriptor", ] [operations.blossom_approve_descriptor_reference.conformance] @@ -350,12 +350,8 @@ vector = "contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json domain = "blossom" id = "blossom.verify_descriptor_bytes" stability = "beta" -inputs = [ - "RadrootsBlossomApprovedDescriptor", - "Bytes", - "RadrootsBlossomMediaType", -] -outputs = ["RadrootsBlossomByteVerifiedDescriptor"] +inputs = ["ApprovedDescriptor", "Bytes", "MediaType"] +outputs = ["ByteVerifiedDescriptor"] error_class = "validation_error" deterministic = true signing = "none" @@ -367,10 +363,10 @@ rust_modules = [ "crates/blossom/src/hash.rs", ] rust_types = [ - "radroots_blossom::RadrootsBlossomApprovedDescriptor", - "radroots_blossom::RadrootsBlossomByteCommitment", - "radroots_blossom::RadrootsBlossomByteVerifiedDescriptor", - "radroots_blossom::RadrootsBlossomMediaType", + "radroots_blossom::ApprovedDescriptor", + "radroots_blossom::ByteCommitment", + "radroots_blossom::ByteVerifiedDescriptor", + "radroots_blossom::MediaType", ] [operations.blossom_verify_descriptor_bytes.conformance] @@ -381,14 +377,14 @@ domain = "blossom" id = "blossom.verify_publication_readiness" stability = "beta" inputs = [ - "RadrootsBlossomByteVerifiedDescriptor", + "ByteVerifiedDescriptor", "Bytes", - "RadrootsBlossomAuthoredRasterDimensions", - "RadrootsBlossomBud02UploadObservation", - "RadrootsBlossomBud01HeadObservation", - "RadrootsBlossomBud01GetObservation", + "AuthoredRasterDimensions", + "Bud02UploadObservation", + "Bud01HeadObservation", + "Bud01GetObservation", ] -outputs = ["RadrootsBlossomPublicationReadinessEvidence"] +outputs = ["PublicationReadinessEvidence"] error_class = "validation_error" deterministic = true signing = "none" @@ -397,12 +393,12 @@ transport = "none" [operations.blossom_verify_publication_readiness.implementation] rust_modules = ["crates/blossom/src/publication_readiness.rs"] rust_types = [ - "radroots_blossom::RadrootsBlossomAuthoredRasterDimensions", - "radroots_blossom::RadrootsBlossomBud01GetCollector", - "radroots_blossom::RadrootsBlossomBud01GetObservation", - "radroots_blossom::RadrootsBlossomBud01HeadObservation", - "radroots_blossom::RadrootsBlossomBud02UploadObservation", - "radroots_blossom::RadrootsBlossomPublicationReadinessEvidence", + "radroots_blossom::AuthoredRasterDimensions", + "radroots_blossom::Bud01GetCollector", + "radroots_blossom::Bud01GetObservation", + "radroots_blossom::Bud01HeadObservation", + "radroots_blossom::Bud02UploadObservation", + "radroots_blossom::PublicationReadinessEvidence", ] [operations.blossom_verify_publication_readiness.conformance] @@ -416,7 +412,7 @@ case_kinds = [ domain = "blossom" id = "blossom.publication_readiness_evidence.to_canonical_json" stability = "beta" -inputs = ["RadrootsBlossomPublicationReadinessEvidence"] +inputs = ["PublicationReadinessEvidence"] outputs = ["CanonicalJsonBytes"] error_class = "encode_error" deterministic = true @@ -425,7 +421,7 @@ transport = "none" [operations.blossom_publication_readiness_evidence_to_canonical_json.implementation] rust_modules = ["crates/blossom/src/publication_readiness.rs"] -rust_types = ["radroots_blossom::RadrootsBlossomPublicationReadinessEvidence"] +rust_types = ["radroots_blossom::PublicationReadinessEvidence"] [operations.blossom_publication_readiness_evidence_to_canonical_json.conformance] vector = "contracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json" @@ -436,7 +432,7 @@ domain = "blossom" id = "blossom.publication_readiness_evidence.from_canonical_json" stability = "beta" inputs = ["CanonicalJsonBytes"] -outputs = ["RadrootsBlossomPublicationReadinessEvidence"] +outputs = ["PublicationReadinessEvidence"] error_class = "validation_error" deterministic = true signing = "none" @@ -444,7 +440,7 @@ transport = "none" [operations.blossom_publication_readiness_evidence_from_canonical_json.implementation] rust_modules = ["crates/blossom/src/publication_readiness.rs"] -rust_types = ["radroots_blossom::RadrootsBlossomPublicationReadinessEvidence"] +rust_types = ["radroots_blossom::PublicationReadinessEvidence"] [operations.blossom_publication_readiness_evidence_from_canonical_json.conformance] vector = "contracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json" @@ -458,16 +454,13 @@ domain = "blossom" id = "blossom.build_upload_authorization_claim" stability = "beta" inputs = [ - "RadrootsBlossomAuthorizationContent", - "RadrootsBlossomServerDomain", - "RadrootsBlossomSha256", + "AuthorizationContent", + "ServerDomain", + "Sha256", "CreatedAt", "LifetimeSeconds", ] -outputs = [ - "RadrootsBlossomAuthoredUploadClaim", - "RadrootsBlossomAuthorizationWireParts", -] +outputs = ["AuthoredUploadClaim", "AuthorizationWireParts"] error_class = "validation_error" deterministic = true signing = "none" @@ -476,8 +469,8 @@ transport = "none" [operations.blossom_build_upload_authorization_claim.implementation] rust_modules = ["crates/blossom/src/authorization.rs"] rust_types = [ - "radroots_blossom::RadrootsBlossomAuthorizationWireParts", - "radroots_blossom::RadrootsBlossomAuthoredUploadClaim", + "radroots_blossom::AuthorizationWireParts", + "radroots_blossom::AuthoredUploadClaim", ] [operations.blossom_build_upload_authorization_claim.conformance] @@ -488,7 +481,7 @@ domain = "blossom" id = "blossom.parse_authorization_claim" stability = "beta" inputs = ["String", "CreatedAt", "NostrTags"] -outputs = ["RadrootsBlossomParsedAuthorizationClaim"] +outputs = ["AuthorizationClaim"] error_class = "parse_error" deterministic = true signing = "none" @@ -496,7 +489,7 @@ transport = "none" [operations.blossom_parse_authorization_claim.implementation] rust_modules = ["crates/blossom/src/authorization.rs"] -rust_types = ["radroots_blossom::RadrootsBlossomParsedAuthorizationClaim"] +rust_types = ["radroots_blossom::AuthorizationClaim"] [operations.blossom_parse_authorization_claim.conformance] vector = "contracts/conformance/vectors/blossom/bud11_claims.v1.json" @@ -505,11 +498,8 @@ vector = "contracts/conformance/vectors/blossom/bud11_claims.v1.json" domain = "blossom" id = "blossom.validate_authorization_claim" stability = "beta" -inputs = [ - "RadrootsBlossomParsedAuthorizationClaim", - "RadrootsBlossomAuthorizationValidation", -] -outputs = ["RadrootsBlossomValidatedAuthorizationClaim"] +inputs = ["AuthorizationClaim", "AuthorizationValidation"] +outputs = ["ValidatedAuthorizationClaim"] error_class = "validation_error" deterministic = true signing = "none" @@ -518,8 +508,8 @@ transport = "none" [operations.blossom_validate_authorization_claim.implementation] rust_modules = ["crates/blossom/src/authorization.rs"] rust_types = [ - "radroots_blossom::RadrootsBlossomAuthorizationValidation", - "radroots_blossom::RadrootsBlossomValidatedAuthorizationClaim", + "radroots_blossom::AuthorizationValidation", + "radroots_blossom::ValidatedAuthorizationClaim", ] [operations.blossom_validate_authorization_claim.conformance] @@ -529,7 +519,7 @@ vector = "contracts/conformance/vectors/blossom/bud11_claims.v1.json" domain = "blossom" id = "blossom.decode_verify_authorization_header" stability = "beta" -inputs = ["String", "RadrootsBlossomAuthorizationValidation"] +inputs = ["String", "AuthorizationValidation"] outputs = ["RadrootsNostrVerifiedBlossomAuthorization"] error_class = "decode_error" deterministic = true @@ -542,7 +532,7 @@ rust_modules = [ "crates/nostr/src/blossom.rs", ] rust_types = [ - "radroots_blossom::RadrootsBlossomAuthorizationValidation", + "radroots_blossom::AuthorizationValidation", "radroots_nostr::blossom::RadrootsNostrVerifiedBlossomAuthorization", ] @@ -641,7 +631,7 @@ rust_modules = [ "crates/event_codec/src/food_availability/authored.rs", ] rust_types = [ - "radroots_blossom::RadrootsBlossomByteVerifiedDescriptor", + "radroots_blossom::ByteVerifiedDescriptor", "radroots_event::food_availability::RadrootsFoodAvailabilityDetails", "radroots_event::food_availability::RadrootsFoodAvailabilityDetailsParts", "radroots_event::food_availability::RadrootsFoodAvailabilityError", @@ -1066,7 +1056,7 @@ id = "publication_media_readiness.bind" stability = "beta" inputs = [ "RadrootsPhase1AllowlistedPublicationArtifact", - "RadrootsBlossomPublicationReadinessEvidence", + "PublicationReadinessEvidence", ] outputs = ["RadrootsPhase1MediaReadyPublicationArtifact"] error_class = "validation_error" @@ -1081,7 +1071,7 @@ rust_modules = [ "crates/event_codec/src/wire/publication/media_readiness.rs", ] rust_types = [ - "radroots_blossom::RadrootsBlossomPublicationReadinessEvidence", + "radroots_blossom::PublicationReadinessEvidence", "radroots_event_codec::wire::publication::RadrootsPhase1MediaReadyPublicationArtifact", "radroots_event_codec::wire::publication::RadrootsPhase1PublicationMediaReadinessError", ] @@ -1133,7 +1123,7 @@ rust_modules = [ "crates/event_codec/src/wire/publication/media_readiness.rs", ] rust_types = [ - "radroots_blossom::RadrootsBlossomPublicationReadinessEvidence", + "radroots_blossom::PublicationReadinessEvidence", "radroots_event_codec::wire::publication::RadrootsPhase1MediaReadyPublicationArtifact", "radroots_event_codec::wire::publication::RadrootsPhase1PublicationMediaReadinessError", ] @@ -1184,7 +1174,7 @@ rust_modules = [ "crates/event_codec/src/profile/authored.rs", ] rust_types = [ - "radroots_blossom::RadrootsBlossomByteVerifiedDescriptor", + "radroots_blossom::ByteVerifiedDescriptor", "radroots_event::media::RadrootsAuthoredImage", "radroots_event::media::RadrootsAuthoredImageError", "radroots_event::profile::RadrootsAuthoredProfile", @@ -1379,8 +1369,8 @@ rust_modules = [ "crates/event_codec/src/post/authored.rs", ] rust_types = [ - "radroots_blossom::RadrootsBlossomApprovedBlobUrl", - "radroots_blossom::RadrootsBlossomByteVerifiedDescriptor", + "radroots_blossom::ApprovedBlobUrl", + "radroots_blossom::ByteVerifiedDescriptor", "radroots_event::media::RadrootsAuthoredImage", "radroots_event::post::RadrootsAuthoredPhotoUpdate", "radroots_event::post::RadrootsAuthoredPostError", @@ -2117,7 +2107,7 @@ rust_modules = [ "crates/event_codec/src/calendar/decode.rs", ] rust_types = [ - "radroots_blossom::RadrootsBlossomBlobUrl", + "radroots_blossom::BlobUrl", "radroots_event::calendar::RadrootsAdmittedCalendar", "radroots_event::calendar::RadrootsCalendarAdmissionError", "radroots_event::calendar::RadrootsCalendarEventReference", diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -87,10 +87,10 @@ semver_impacts = [ summary = "Decouple replica backup compatibility from package SemVer and retain restore support for schema-compatible 0.1.0-alpha.2 backups." [[changes]] -id = "rust-crate-version-authority" +id = "library-crate-version-authority" classification = "fix" semver_impacts = ["fix_packaging_metadata"] -summary = "Pin every Rust workspace crate and internal requirement to the authoritative 0.1.0-alpha package cohort independently of protocol contract versions." +summary = "Pin every library workspace crate and internal requirement to the authoritative 0.1.0-alpha Cargo cohort independently of protocol contract versions." [[changes]] id = "general-verified-event-boundary" diff --git a/contracts/releases/api_boundaries.toml b/contracts/releases/api_boundaries.toml @@ -0,0 +1,213 @@ +schema_version = 1 +spec_id = "radroots.crates.release.v1" +forbidden_public_paths = [ + "core_foundation", + "dispatch", + "jni", + "keyring", + "libc", + "mach2", + "nostr", + "nostr_sdk", + "objc", + "reqwest", + "security_framework", + "sqlx", + "std::os", + "tokio", + "wasm_bindgen", + "web_sys", + "windows", + "windows_core", + "windows_sys", +] + +[[package]] +name = "radroots_core" +allowed_public_paths = [] + +[[package]] +name = "radroots_identity" +allowed_public_paths = [] + +[[package]] +name = "radroots_blossom" +allowed_public_paths = [] + +[[package]] +name = "radroots_protocol" +allowed_public_paths = [] + +[[package]] +name = "radroots_event" +allowed_public_paths = [] + +[[package]] +name = "radroots_event_codec" +allowed_public_paths = [] + +[[package]] +name = "radroots_trade" +allowed_public_paths = [] + +[[package]] +name = "radroots_signing" +allowed_public_paths = [] + +[[package]] +name = "radroots_transport" +allowed_public_paths = [] + +[[package]] +name = "radroots_nostr" +allowed_public_paths = ["nostr"] + +[[package]] +name = "radroots_nostr_connect" +allowed_public_paths = [] + +[[package]] +name = "radroots_secrets" +allowed_public_paths = [] + +[[package]] +name = "radroots_storage" +allowed_public_paths = [] + +[[package]] +name = "radroots_storage_sqlite" +allowed_public_paths = [] + +[[package]] +name = "radroots_transport_nostr" +allowed_public_paths = [] + +[[package]] +name = "radroots_sync" +allowed_public_paths = [] + +[[package]] +name = "radroots_geonames" +allowed_public_paths = [] + +[[package]] +name = "radroots_sdk" +allowed_public_paths = [] + +[[package]] +name = "radroots" +allowed_public_paths = [] +[[exception]] +id = "RCRV1-API-003" +package = "radroots_nostr" +source = "src/client.rs" +forbidden_path = "nostr_sdk" +items = [ + "client::RadrootsNostrClient::from_inner", + "client::RadrootsNostrClient::into_inner", +] +observed_paths = ["nostr_sdk::Client"] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 124 +rationale = "Legacy client ownership remains visible only until the Nostr package removes live relay-client dependencies." + +[[exception]] +id = "RCRV1-API-004" +package = "radroots_nostr" +source = "src/error.rs" +forbidden_path = "nostr_sdk" +items = ["error::RadrootsNostrError"] +observed_paths = [ + "nostr_sdk::client::Error", + "nostr_sdk::prelude::DatabaseError", +] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 124 +rationale = "Legacy SDK error variants remain exact-scoped until normalized adapter errors replace them." + +[[exception]] +id = "RCRV1-API-005" +package = "radroots_nostr" +source = "src/types.rs" +forbidden_path = "nostr_sdk" +items = [ + "types::RadrootsNostrEventStream", + "types::RadrootsNostrMonitor", + "types::RadrootsNostrMonitorNotification", + "types::RadrootsNostrOutput", + "types::RadrootsNostrRelay", + "types::RadrootsNostrRelayPoolNotification", + "types::RadrootsNostrRelayStatus", + "types::RadrootsNostrSubscribeAutoCloseOptions", +] +observed_paths = [ + "nostr_sdk::Relay", + "nostr_sdk::RelayPoolNotification", + "nostr_sdk::RelayStatus", + "nostr_sdk::SubscribeAutoCloseOptions", + "nostr_sdk::pool::stream::BoxedStream", + "nostr_sdk::prelude::Monitor", + "nostr_sdk::prelude::MonitorNotification", + "nostr_sdk::prelude::Output", +] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 124 +rationale = "Broad live-client aliases are frozen and must be removed with the Nostr client dependency at Step 124." + +[[exception]] +id = "RCRV1-API-006" +package = "radroots_nostr_connect" +source = "src/client.rs" +forbidden_path = "nostr" +items = [ + "client::RadrootsNostrConnectClientTarget", + "client::RadrootsNostrConnectClientTarget::new", + "client::RadrootsNostrConnectClientTransport", + "client::build_request_event", + "client::execute_request_with_transport", + "client::parse_response_event", +] +observed_paths = [ + "nostr::Event", + "nostr::Keys", + "nostr::PublicKey", + "nostr::RelayUrl", +] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 140 +rationale = "Legacy client state-machine signatures retain exact protocol types until the Nostr Connect transport split and conformance gate." + +[[exception]] +id = "RCRV1-API-007" +package = "radroots_nostr_connect" +source = "src/message.rs" +forbidden_path = "nostr" +items = [ + "message::RadrootsNostrConnectPendingConnectionPollOutcome", + "message::RadrootsNostrConnectRemoteSessionCapability", + "message::RadrootsNostrConnectRequest", + "message::RadrootsNostrConnectResponse", +] +observed_paths = [ + "nostr::Event", + "nostr::PublicKey", + "nostr::RelayUrl", + "nostr::UnsignedEvent", +] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 140 +rationale = "Legacy request and response envelopes retain exact Nostr values until canonical package-owned envelopes are complete." + +[[exception]] +id = "RCRV1-API-008" +package = "radroots_nostr_connect" +source = "src/uri.rs" +forbidden_path = "nostr" +items = [ + "uri::RadrootsNostrConnectBunkerUri", + "uri::RadrootsNostrConnectClientUri", +] +observed_paths = ["nostr::PublicKey", "nostr::RelayUrl"] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 140 +rationale = "Legacy URI wrappers retain exact upstream value types until URI normalization and the package conformance gate." diff --git a/contracts/releases/package_tiers.toml b/contracts/releases/package_tiers.toml @@ -0,0 +1,78 @@ +schema_version = 1 +spec_id = "radroots.crates.release.v1" +direction = "same_or_lower" +enforced_dependency_kinds = ["normal", "dev", "build"] + +[[tier]] +id = "foundation" +rank = 0 +packages = [ + "radroots_core", + "radroots_identity", + "radroots_blossom", + "radroots_protocol", +] + +[[tier]] +id = "domain" +rank = 1 +packages = ["radroots_event", "radroots_event_codec", "radroots_trade"] + +[[tier]] +id = "spi" +rank = 2 +packages = [ + "radroots_signing", + "radroots_transport", + "radroots_secrets", + "radroots_storage", +] + +[[tier]] +id = "adapter" +rank = 3 +packages = [ + "radroots_nostr", + "radroots_nostr_connect", + "radroots_storage_sqlite", + "radroots_transport_nostr", + "radroots_geonames", +] + +[[tier]] +id = "orchestration" +rank = 4 +packages = ["radroots_sync"] + +[[tier]] +id = "sdk" +rank = 5 +packages = ["radroots_sdk"] + +[[tier]] +id = "facade" +rank = 6 +packages = ["radroots"] + +# These exact development-only edges predate the final trade boundary and are +# removed by the manifest-alignment checkpoint. Normal, build, target, or +# feature-broadened forms remain forbidden. +[[temporary_exception]] +owner = "radroots_trade" +dependency = "radroots_nostr" +kind = "dev" +target = "all" +features = ["events", "std"] +uses_default_features = false +removal_step = 88 +rationale = "Existing trade conformance tests consume Nostr vectors until the Step 088 package boundary alignment." + +[[temporary_exception]] +owner = "radroots_trade" +dependency = "radroots_transport" +kind = "dev" +target = "all" +features = [] +uses_default_features = false +removal_step = 88 +rationale = "Existing trade workflow tests use transport test types until the Step 088 package boundary alignment." diff --git a/contracts/releases/publish_policy.toml b/contracts/releases/publish_policy.toml @@ -4,25 +4,66 @@ version = 1 [release] version = "1.0.0-alpha.1" -[classification] -public = [ +[publication] +frozen = true +registry = "crates-io" +final_enablement_step = 305 +spec_id = "radroots.crates.release.v1" +approved_packages = [ + "radroots_core", + "radroots_identity", "radroots_blossom", + "radroots_protocol", + "radroots_event", + "radroots_event_codec", + "radroots_trade", + "radroots_signing", + "radroots_transport", + "radroots_nostr", + "radroots_nostr_connect", + "radroots_secrets", + "radroots_storage", + "radroots_storage_sqlite", + "radroots_transport_nostr", + "radroots_sync", + "radroots_geonames", + "radroots_sdk", + "radroots", +] +local_packages = [ "radroots_core", + "radroots_identity", + "radroots_blossom", + "radroots_protocol", "radroots_event", "radroots_event_codec", + "radroots_trade", + "radroots_signing", + "radroots_transport", + "radroots_nostr", + "radroots_nostr_connect", + "radroots_secrets", + "radroots_storage", + "radroots_storage_sqlite", + "radroots_transport_nostr", + "radroots_sync", + "radroots_geonames", +] +external_packages = ["radroots_sdk", "radroots"] + +[workspace_classification] +private = [ + "radroots_authority", "radroots_event_index", + "radroots_event_store", "radroots_geocoder", - "radroots_identity", "radroots_log", - "radroots_nostr", + "radroots_net", "radroots_nostr_accounts", - "radroots_nostr_connect", + "radroots_nostr_runtime", "radroots_nostr_signer", + "radroots_outbox", "radroots_protected_store", - "radroots_protocol_contract_v1", - "radroots_replica_schema", - "radroots_replica_store", - "radroots_replica_sync", "radroots_runtime", "radroots_runtime_distribution", "radroots_runtime_manager", @@ -30,19 +71,17 @@ public = [ "radroots_runtime_store", "radroots_secret_vault", "radroots_sql_core", - "radroots_trade", - "radroots_transport", ] -internal = [ - "radroots_authority", - "radroots_event_store", +build_codegen = ["xtask"] +test_support = ["radroots_test_fixtures"] +preview = [ "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", - "radroots_net", - "radroots_nostr_runtime", "radroots_nostrdb", - "radroots_outbox", + "radroots_replica_schema", + "radroots_replica_store", + "radroots_replica_sync", "radroots_simplex_agent_proto", "radroots_simplex_agent_runtime", "radroots_simplex_agent_store", @@ -52,44 +91,14 @@ internal = [ "radroots_simplex_smp_crypto", "radroots_simplex_smp_proto", "radroots_simplex_smp_transport", - "radroots_test_fixtures", "radroots_trade_sp1_guest", "radroots_trade_sp1_host", - "radroots_transport_nostr", - "radroots_transport_publish_protocol", "radroots_transport_reticulum", - "xtask", ] -deferred = [] -retired = [] -yank_only = [] - -[publish_order] -crates = [ - "radroots_transport", - "radroots_runtime_distribution", - "radroots_replica_schema", +retired = [ "radroots_protocol_contract_v1", - "radroots_nostr_connect", - "radroots_sql_core", - "radroots_runtime_store", - "radroots_runtime_paths", - "radroots_log", - "radroots_secret_vault", - "radroots_protected_store", - "radroots_event_index", - "radroots_blossom", - "radroots_core", - "radroots_runtime_manager", - "radroots_replica_store", - "radroots_geocoder", - "radroots_event", - "radroots_runtime", - "radroots_identity", - "radroots_event_codec", - "radroots_trade", - "radroots_replica_sync", - "radroots_nostr", - "radroots_nostr_signer", - "radroots_nostr_accounts", + "radroots_transport_publish_protocol", ] + +[publish_order] +crates = [] diff --git a/crates/authority/Cargo.toml b/crates/authority/Cargo.toml @@ -12,7 +12,11 @@ homepage.workspace = true [features] default = ["std"] -std = ["radroots_event/std", "radroots_event_codec/std"] +std = [ + "radroots_event/std", + "radroots_event_codec/std", + "radroots_identity/std", +] local_signer = [ "std", "dep:radroots_nostr", @@ -27,6 +31,7 @@ radroots_event = { workspace = true, default-features = false, features = [ radroots_event_codec = { workspace = true, default-features = false, features = [ "serde_json", ] } +radroots_identity = { workspace = true, default-features = false } radroots_nostr = { workspace = true, optional = true, default-features = false } [dev-dependencies] diff --git a/crates/authority/src/actor.rs b/crates/authority/src/actor.rs @@ -3,7 +3,7 @@ use crate::RadrootsAuthorityError; use core::{fmt, str::FromStr}; use radroots_event::contract::RadrootsActorRole; -use radroots_event::ids::RadrootsPublicKey; +use radroots_identity::PublicKey; #[cfg(not(feature = "std"))] use alloc::{collections::BTreeSet, string::String}; @@ -99,7 +99,7 @@ pub enum RadrootsActorSource { pub enum RadrootsActorSelector { SelectedAccount, AccountId(RadrootsActorAccountId), - PublicKey(RadrootsPublicKey), + PublicKey(PublicKey), DraftExpectedPubkey, } @@ -109,7 +109,7 @@ impl RadrootsActorSelector { } pub fn public_key(pubkey: impl AsRef<str>) -> Result<Self, RadrootsAuthorityError> { - let pubkey = RadrootsPublicKey::parse(pubkey.as_ref()) + let pubkey = PublicKey::from_hex(pubkey.as_ref()) .map_err(|_| RadrootsAuthorityError::InvalidActorPubkey)?; Ok(Self::PublicKey(pubkey)) } @@ -150,7 +150,7 @@ impl RadrootsActorResolutionRequest { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsActorContext { - pubkey: RadrootsPublicKey, + pubkey: PublicKey, roles: BTreeSet<RadrootsActorRole>, account_id: Option<RadrootsActorAccountId>, source: RadrootsActorSource, @@ -231,7 +231,7 @@ impl RadrootsActorContext { where I: IntoIterator<Item = RadrootsActorRole>, { - let pubkey = RadrootsPublicKey::parse(pubkey.as_ref()) + let pubkey = PublicKey::from_hex(pubkey.as_ref()) .map_err(|_| RadrootsAuthorityError::InvalidActorPubkey)?; Ok(Self { pubkey, @@ -241,7 +241,7 @@ impl RadrootsActorContext { }) } - pub fn pubkey(&self) -> &RadrootsPublicKey { + pub fn pubkey(&self) -> &PublicKey { &self.pubkey } @@ -276,13 +276,13 @@ pub fn role_satisfies( mod tests { use super::*; - fn hex_64(character: char) -> String { - std::iter::repeat_n(character, 64).collect() - } + const VALID_PUBKEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const OTHER_VALID_PUBKEY: &str = + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; #[test] fn any_is_satisfied_by_any_actor_context() { - let actor = RadrootsActorContext::test(hex_64('a'), []).expect("actor"); + let actor = RadrootsActorContext::test(VALID_PUBKEY, []).expect("actor"); assert!(actor.satisfies(RadrootsActorRole::Any)); } @@ -290,7 +290,7 @@ mod tests { #[test] fn specific_roles_require_explicit_membership() { let actor = - RadrootsActorContext::test(hex_64('a'), [RadrootsActorRole::Farmer]).expect("actor"); + RadrootsActorContext::test(VALID_PUBKEY, [RadrootsActorRole::Farmer]).expect("actor"); assert!(actor.satisfies(RadrootsActorRole::Farmer)); assert!(!actor.satisfies(RadrootsActorRole::Seller)); @@ -299,7 +299,7 @@ mod tests { #[test] fn farmer_does_not_globally_satisfy_seller() { let actor = - RadrootsActorContext::test(hex_64('a'), [RadrootsActorRole::Farmer]).expect("actor"); + RadrootsActorContext::test(VALID_PUBKEY, [RadrootsActorRole::Farmer]).expect("actor"); assert!(!actor.satisfies(RadrootsActorRole::Seller)); } @@ -307,7 +307,7 @@ mod tests { #[test] fn multi_role_actors_satisfy_each_assigned_role() { let actor = RadrootsActorContext::test( - hex_64('a'), + VALID_PUBKEY, [RadrootsActorRole::Farmer, RadrootsActorRole::Seller], ) .expect("actor"); @@ -320,14 +320,14 @@ mod tests { #[test] fn local_account_context_carries_validated_account_id() { let actor = RadrootsActorContext::local_account( - hex_64('a'), + VALID_PUBKEY, "acct-field-01", [RadrootsActorRole::Farmer], ) .expect("actor"); assert_eq!(actor.source(), RadrootsActorSource::LocalAccount); - assert_eq!(actor.pubkey().as_str(), hex_64('a')); + assert_eq!(actor.pubkey().to_hex(), VALID_PUBKEY); assert_eq!( actor.roles().iter().copied().collect::<Vec<_>>(), vec![RadrootsActorRole::Farmer] @@ -339,8 +339,9 @@ mod tests { #[test] fn explicit_pubkey_context_has_no_account_id() { - let actor = RadrootsActorContext::explicit_pubkey(hex_64('a'), [RadrootsActorRole::Seller]) - .expect("actor"); + let actor = + RadrootsActorContext::explicit_pubkey(VALID_PUBKEY, [RadrootsActorRole::Seller]) + .expect("actor"); assert_eq!(actor.source(), RadrootsActorSource::ExplicitPubkey); assert_eq!(actor.account_id(), None); @@ -349,7 +350,7 @@ mod tests { #[test] fn test_context_has_no_account_id() { let actor = - RadrootsActorContext::test(hex_64('a'), [RadrootsActorRole::Farmer]).expect("actor"); + RadrootsActorContext::test(VALID_PUBKEY, [RadrootsActorRole::Farmer]).expect("actor"); assert_eq!(actor.source(), RadrootsActorSource::Test); assert_eq!(actor.account_id(), None); @@ -358,14 +359,17 @@ mod tests { #[test] fn remote_signer_and_service_contexts_carry_account_ids() { let remote = RadrootsActorContext::remote_signer( - hex_64('a'), + VALID_PUBKEY, "acct-remote", [RadrootsActorRole::Buyer], ) .expect("remote actor"); - let service = - RadrootsActorContext::service(hex_64('b'), "acct-service", [RadrootsActorRole::Any]) - .expect("service actor"); + let service = RadrootsActorContext::service( + OTHER_VALID_PUBKEY, + "acct-service", + [RadrootsActorRole::Any], + ) + .expect("service actor"); assert_eq!(remote.source(), RadrootsActorSource::RemoteSigner); assert_eq!( @@ -382,20 +386,20 @@ mod tests { #[test] fn account_id_rejects_invalid_values() { assert!(matches!( - RadrootsActorContext::local_account(hex_64('a'), "", []), + RadrootsActorContext::local_account(VALID_PUBKEY, "", []), Err(RadrootsAuthorityError::InvalidActorAccountIdEmpty) )); assert!(matches!( - RadrootsActorContext::local_account(hex_64('a'), " account ", []), + RadrootsActorContext::local_account(VALID_PUBKEY, " account ", []), Err(RadrootsAuthorityError::InvalidActorAccountIdUntrimmed) )); assert!(matches!( - RadrootsActorContext::local_account(hex_64('a'), "account\nid", []), + RadrootsActorContext::local_account(VALID_PUBKEY, "account\nid", []), Err(RadrootsAuthorityError::InvalidActorAccountIdControlCharacter) )); assert!(matches!( RadrootsActorContext::local_account( - hex_64('a'), + VALID_PUBKEY, core::iter::repeat_n('a', MAX_ACTOR_ACCOUNT_ID_LEN + 1).collect::<String>(), [] ), @@ -455,7 +459,7 @@ mod tests { RadrootsActorSelector::SelectedAccount )); assert!(matches!( - RadrootsActorSelector::public_key(hex_64('b')).expect("selector"), + RadrootsActorSelector::public_key(OTHER_VALID_PUBKEY).expect("selector"), RadrootsActorSelector::PublicKey(_) )); assert!(matches!( diff --git a/crates/authority/src/authorization.rs b/crates/authority/src/authorization.rs @@ -49,10 +49,10 @@ pub fn authorize_actor_for_draft( }); } authorize_actor_for_contract(actor, contract)?; - if actor.pubkey().as_str() != draft.expected_pubkey_str() { + if actor.pubkey() != draft.expected_pubkey() { return Err(RadrootsAuthorityError::ActorPubkeyMismatch { - expected_pubkey: draft.expected_pubkey_str().to_owned(), - actor_pubkey: actor.pubkey().as_str().to_owned(), + expected_pubkey: draft.expected_pubkey().to_hex().to_owned(), + actor_pubkey: actor.pubkey().to_hex(), }); } Ok(contract) @@ -65,12 +65,12 @@ pub fn authorize_signer_for_draft<S>( where S: RadrootsEventSigner + ?Sized, { - if signer.pubkey().as_str() == draft.expected_pubkey_str() { + if signer.pubkey() == draft.expected_pubkey() { Ok(()) } else { Err(RadrootsAuthorityError::SignerPubkeyMismatch { - expected_pubkey: draft.expected_pubkey_str().to_owned(), - signer_pubkey: signer.pubkey().as_str().to_owned(), + expected_pubkey: draft.expected_pubkey().to_hex().to_owned(), + signer_pubkey: signer.pubkey().to_hex(), }) } } @@ -117,14 +117,14 @@ where authorize_actor_for_contract(actor, contract)?; if actor.pubkey() != artifact.expected_author() { return Err(RadrootsAuthorityError::ActorPubkeyMismatch { - expected_pubkey: artifact.expected_author().as_str().to_owned(), - actor_pubkey: actor.pubkey().as_str().to_owned(), + expected_pubkey: artifact.expected_author().to_hex(), + actor_pubkey: actor.pubkey().to_hex(), }); } if RadrootsEventSigner::pubkey(signer) != artifact.expected_author() { return Err(RadrootsAuthorityError::SignerPubkeyMismatch { - expected_pubkey: artifact.expected_author().as_str().to_owned(), - signer_pubkey: RadrootsEventSigner::pubkey(signer).as_str().to_owned(), + expected_pubkey: artifact.expected_author().to_hex(), + signer_pubkey: RadrootsEventSigner::pubkey(signer).to_hex(), }); } @@ -173,10 +173,10 @@ fn validate_signed_event_matches_phase1_publication( ) -> Result<(), RadrootsAuthorityError> { let artifact = ready.artifact(); let draft = artifact.draft(); - if signed_event.pubkey_str() != artifact.expected_author().as_str() { + if signed_event.pubkey() != artifact.expected_author() { return Err(RadrootsAuthorityError::SignedEventPubkeyMismatch { - expected_pubkey: artifact.expected_author().as_str().to_owned(), - actual_pubkey: signed_event.pubkey_str().to_owned(), + expected_pubkey: artifact.expected_author().to_hex(), + actual_pubkey: signed_event.pubkey().to_hex(), }); } if signed_event.id_str() != artifact.expected_event_id().as_str() { @@ -277,7 +277,7 @@ mod tests { use radroots_event::contract::{ RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, RadrootsActorRole, event_contract, }; - use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; + use radroots_event::ids::RadrootsEventId; use radroots_event::kinds::{KIND_CLASSIFIED_LISTING, KIND_POST, KIND_TRADE_PROPOSAL}; use radroots_event::post::RadrootsAuthoredUpdate; use radroots_event_codec::wire::publication::allowlist::allow_phase1_publication_artifact; @@ -285,6 +285,7 @@ mod tests { RadrootsPhase1PublicationArtifact, RadrootsPhase1PublicationDraft, bind_phase1_publication_media_readiness, }; + use radroots_identity::PublicKey; use radroots_nostr::prelude::{ RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, radroots_nostr_sign_phase1_publication_draft, @@ -295,6 +296,9 @@ mod tests { const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + const OTHER_VALID_PUBKEY: &str = + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + fn hex_64(character: char) -> String { std::iter::repeat_n(character, 64).collect() } @@ -355,14 +359,14 @@ mod tests { } struct StaticSigner { - pubkey: RadrootsPublicKey, + pubkey: PublicKey, overrides: SignedEventOverrides, } impl StaticSigner { fn new(pubkey: &str) -> Self { Self { - pubkey: RadrootsPublicKey::parse(pubkey).expect("pubkey"), + pubkey: PublicKey::from_hex(pubkey).expect("pubkey"), overrides: SignedEventOverrides::default(), } } @@ -379,14 +383,14 @@ mod tests { fn with_overrides(pubkey: &str, overrides: SignedEventOverrides) -> Self { Self { - pubkey: RadrootsPublicKey::parse(pubkey).expect("pubkey"), + pubkey: PublicKey::from_hex(pubkey).expect("pubkey"), overrides, } } } impl RadrootsEventSigner for StaticSigner { - fn pubkey(&self) -> &RadrootsPublicKey { + fn pubkey(&self) -> &PublicKey { &self.pubkey } @@ -436,21 +440,21 @@ mod tests { } struct CountingSigner { - pubkey: RadrootsPublicKey, + pubkey: PublicKey, sign_invocations: Cell<usize>, } impl CountingSigner { fn new(pubkey: &str) -> Self { Self { - pubkey: RadrootsPublicKey::parse(pubkey).expect("pubkey"), + pubkey: PublicKey::from_hex(pubkey).expect("pubkey"), sign_invocations: Cell::new(0), } } } impl RadrootsEventSigner for CountingSigner { - fn pubkey(&self) -> &RadrootsPublicKey { + fn pubkey(&self) -> &PublicKey { &self.pubkey } @@ -466,7 +470,7 @@ mod tests { struct ValidSigner { keys: RadrootsNostrKeys, - pubkey: RadrootsPublicKey, + pubkey: PublicKey, } impl ValidSigner { @@ -475,14 +479,14 @@ mod tests { .expect("fixture secret key"); Self { keys: RadrootsNostrKeys::new(secret_key), - pubkey: RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX) + pubkey: PublicKey::from_hex(FIXTURE_ALICE_PUBLIC_KEY_HEX) .expect("fixture public key"), } } } impl RadrootsEventSigner for ValidSigner { - fn pubkey(&self) -> &RadrootsPublicKey { + fn pubkey(&self) -> &PublicKey { &self.pubkey } @@ -502,7 +506,7 @@ mod tests { fn sign_phase1_publication_draft( &self, draft: &RadrootsPhase1PublicationDraft, - expected_pubkey: &RadrootsPublicKey, + expected_pubkey: &PublicKey, expected_event_id: &RadrootsEventId, ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { radroots_nostr_sign_phase1_publication_draft( @@ -519,7 +523,7 @@ mod tests { fn signed_event_from_draft(draft: &RadrootsEventDraft) -> RadrootsSignedEvent { signed_event_from_parts( - draft.expected_pubkey_str().to_owned(), + draft.expected_pubkey().to_hex().to_owned(), draft.created_at_u64(), draft.kind_u32(), draft.tags_as_vec(), @@ -571,7 +575,7 @@ mod tests { let trade_decision = event_contract("radroots.trade.decision.v1").expect("trade decision contract"); let seller = seller_actor(hex_64('a').as_str()); - let buyer = buyer_actor(hex_64('b').as_str()); + let buyer = buyer_actor(OTHER_VALID_PUBKEY); assert_eq!(operational_listing.author_role, RadrootsActorRole::Seller); assert!(authorize_actor_for_contract(&seller, operational_listing).is_ok()); @@ -595,7 +599,7 @@ mod tests { #[test] fn actor_pubkey_mismatch_fails() { let draft = operational_listing_event_draft(hex_64('a').as_str()); - let actor = seller_actor(hex_64('b').as_str()); + let actor = seller_actor(OTHER_VALID_PUBKEY); assert!(matches!( authorize_actor_for_draft(&actor, &draft), @@ -606,7 +610,7 @@ mod tests { #[test] fn signer_pubkey_mismatch_fails() { let draft = operational_listing_event_draft(hex_64('a').as_str()); - let signer = StaticSigner::new(hex_64('b').as_str()); + let signer = StaticSigner::new(OTHER_VALID_PUBKEY); assert!(matches!( authorize_signer_for_draft(&signer, &draft), @@ -766,7 +770,7 @@ mod tests { let pubkey = hex_64('a'); let draft = operational_listing_event_draft(pubkey.as_str()); let signed = signed_event_from_parts( - hex_64('b'), + OTHER_VALID_PUBKEY.to_owned(), draft.created_at_u64(), draft.kind_u32(), draft.tags_as_vec(), @@ -815,10 +819,7 @@ mod tests { signed.signed_event().id_str(), draft.expected_event_id_str() ); - assert_eq!( - signed.signed_event().pubkey_str(), - draft.expected_pubkey_str() - ); + assert_eq!(signed.signed_event().pubkey(), draft.expected_pubkey()); assert_eq!(signed.signed_event().kind(), KIND_CLASSIFIED_LISTING); } diff --git a/crates/authority/src/local_signer.rs b/crates/authority/src/local_signer.rs @@ -5,8 +5,9 @@ use crate::{ RadrootsSignerError, }; use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent}; -use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; +use radroots_event::ids::RadrootsEventId; use radroots_event_codec::wire::publication::RadrootsPhase1PublicationDraft; +use radroots_identity::PublicKey; use radroots_nostr::prelude::{ RadrootsNostrKeys, radroots_nostr_sign_frozen_draft, radroots_nostr_sign_phase1_publication_draft, @@ -14,19 +15,19 @@ use radroots_nostr::prelude::{ pub struct RadrootsLocalEventSigner { keys: RadrootsNostrKeys, - pubkey: RadrootsPublicKey, + pubkey: PublicKey, } impl RadrootsLocalEventSigner { pub fn new(keys: RadrootsNostrKeys) -> Result<Self, RadrootsAuthorityError> { - let pubkey = RadrootsPublicKey::parse(keys.public_key().to_hex()) + let pubkey = PublicKey::from_hex(&keys.public_key().to_hex()) .map_err(|_| RadrootsAuthorityError::InvalidSignerPubkey)?; Ok(Self { keys, pubkey }) } } impl RadrootsEventSigner for RadrootsLocalEventSigner { - fn pubkey(&self) -> &RadrootsPublicKey { + fn pubkey(&self) -> &PublicKey { &self.pubkey } @@ -46,7 +47,7 @@ impl RadrootsPhase1PublicationSigner for RadrootsLocalEventSigner { fn sign_phase1_publication_draft( &self, draft: &RadrootsPhase1PublicationDraft, - expected_pubkey: &RadrootsPublicKey, + expected_pubkey: &PublicKey, expected_event_id: &RadrootsEventId, ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { radroots_nostr_sign_phase1_publication_draft( @@ -101,7 +102,7 @@ mod tests { fn local_signer_reports_public_key() { let signer = RadrootsLocalEventSigner::new(fixture_keys()).expect("signer"); - assert_eq!(signer.pubkey().as_str(), FIXTURE_ALICE_PUBLIC_KEY_HEX); + assert_eq!(signer.pubkey().to_hex(), FIXTURE_ALICE_PUBLIC_KEY_HEX); } #[test] @@ -112,7 +113,7 @@ mod tests { let signed = signer.sign_frozen_draft(&draft).expect("signed"); assert_eq!(signed.id_str(), draft.expected_event_id_str()); - assert_eq!(signed.pubkey_str(), draft.expected_pubkey_str()); + assert_eq!(signed.pubkey().to_hex(), draft.expected_pubkey().to_hex()); assert_eq!( radroots_nostr_verify_event(&verification_event(&signed)), RadrootsNostrEventVerification::Verified diff --git a/crates/authority/src/signer.rs b/crates/authority/src/signer.rs @@ -2,30 +2,31 @@ use crate::{RadrootsAuthorityError, RadrootsSignerError}; use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent}; -use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; +use radroots_event::ids::RadrootsEventId; #[cfg(test)] use radroots_event::wire::RadrootsNip01EventWire; use radroots_event_codec::wire::publication::RadrootsPhase1PublicationDraft; +use radroots_identity::PublicKey; #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsSignerIdentity { - pub pubkey: RadrootsPublicKey, + pub pubkey: PublicKey, } impl RadrootsSignerIdentity { pub fn new(pubkey: impl AsRef<str>) -> Result<Self, RadrootsAuthorityError> { - let pubkey = RadrootsPublicKey::parse(pubkey.as_ref()) + let pubkey = PublicKey::from_hex(pubkey.as_ref()) .map_err(|_| RadrootsAuthorityError::InvalidSignerPubkey)?; Ok(Self { pubkey }) } - pub fn pubkey(&self) -> &RadrootsPublicKey { + pub fn pubkey(&self) -> &PublicKey { &self.pubkey } } pub trait RadrootsEventSigner { - fn pubkey(&self) -> &RadrootsPublicKey; + fn pubkey(&self) -> &PublicKey; fn sign_frozen_draft( &self, @@ -41,7 +42,7 @@ pub trait RadrootsPhase1PublicationSigner: RadrootsEventSigner { fn sign_phase1_publication_draft( &self, draft: &RadrootsPhase1PublicationDraft, - expected_pubkey: &RadrootsPublicKey, + expected_pubkey: &PublicKey, expected_event_id: &RadrootsEventId, ) -> Result<RadrootsSignedEvent, RadrootsSignerError>; } @@ -72,7 +73,7 @@ mod tests { } struct MockSigner { - pubkey: RadrootsPublicKey, + pubkey: PublicKey, failure: Option<RadrootsSignerError>, event_id: Option<String>, } @@ -80,7 +81,7 @@ mod tests { impl MockSigner { fn new(pubkey: &str) -> Self { Self { - pubkey: RadrootsPublicKey::parse(pubkey).expect("pubkey"), + pubkey: PublicKey::from_hex(pubkey).expect("pubkey"), failure: None, event_id: None, } @@ -88,7 +89,7 @@ mod tests { fn failing(pubkey: &str, failure: RadrootsSignerError) -> Self { Self { - pubkey: RadrootsPublicKey::parse(pubkey).expect("pubkey"), + pubkey: PublicKey::from_hex(pubkey).expect("pubkey"), failure: Some(failure), event_id: None, } @@ -96,7 +97,7 @@ mod tests { fn with_event_id(pubkey: &str, event_id: String) -> Self { Self { - pubkey: RadrootsPublicKey::parse(pubkey).expect("pubkey"), + pubkey: PublicKey::from_hex(pubkey).expect("pubkey"), failure: None, event_id: Some(event_id), } @@ -104,7 +105,7 @@ mod tests { } impl RadrootsEventSigner for MockSigner { - fn pubkey(&self) -> &RadrootsPublicKey { + fn pubkey(&self) -> &PublicKey { &self.pubkey } @@ -165,14 +166,14 @@ mod tests { let pubkey = hex_64('a'); let signer = MockSigner::new(pubkey.as_str()); - assert_eq!(signer.pubkey().as_str(), pubkey); + assert_eq!(signer.pubkey().to_hex(), pubkey); } #[test] fn signer_identity_validates_public_key() { let pubkey = hex_64('a'); let identity = RadrootsSignerIdentity::new(pubkey.as_str()).expect("identity"); - assert_eq!(identity.pubkey().as_str(), pubkey); + assert_eq!(identity.pubkey().to_hex(), pubkey); assert!(matches!( RadrootsSignerIdentity::new("bad-pubkey"), @@ -189,7 +190,7 @@ mod tests { let signed = signer.sign_frozen_draft(&draft).expect("signed"); assert_eq!(signed.id_str(), draft.expected_event_id_str()); - assert_eq!(signed.pubkey_str(), pubkey); + assert_eq!(signed.pubkey().to_hex(), pubkey); assert_eq!(signed.kind(), KIND_GEOCHAT); } diff --git a/crates/blossom/Cargo.toml b/crates/blossom/Cargo.toml @@ -1,19 +1,22 @@ [package] name = "radroots_blossom" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] +authors.workspace = true rust-version.workspace = true license.workspace = true description = "Portable Blossom protocol primitives for Radroots" repository.workspace = true homepage.workspace = true documentation = "https://docs.rs/radroots_blossom" -readme = "README" +readme = "README.md" + +[lib] +name = "radroots_blossom" [features] -default = ["serde"] +default = ["std", "serde"] serde = ["dep:serde", "dep:serde_json"] std = ["serde?/std", "sha2/std", "url_nostd/std"] raster-decode = [ @@ -44,5 +47,5 @@ image = { workspace = true, features = ["webp"] } serde_json = { workspace = true, features = ["std"] } tempfile = { workspace = true } -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } +[lints] +workspace = true diff --git a/crates/blossom/README b/crates/blossom/README @@ -1,74 +0,0 @@ -# radroots_blossom - -`radroots_blossom` provides portable, runtime-independent primitives for -Blossom blob hashes, root hash paths, blob URLs, BUD-02 descriptors, -Radroots-approved byte verification, publication-readiness evidence, and pure -BUD-11 authorization claims. - -The crate is `no_std + alloc`, performs no HTTP requests, and does not depend on -Nostr event types. Structural Blossom validity is kept separate from the -stricter Radroots reference policy: public HTTP descriptors remain parseable, -but only HTTPS and loopback HTTP references can advance to an approved state. -The byte-verified descriptor state proves local descriptor-to-byte agreement; -it is not an upload receipt, and HTTP-capable runtimes must gate successful -BUD-02 upload completion separately. BUD-11 support similarly stops at typed -claim construction and endpoint validation; signing and canonical -`Authorization: Nostr` encoding live behind the `radroots_nostr` `blossom` -feature, and kind `24242` is never a relay-publication event. - -Publication readiness is a separate typestate. It accepts only BUD-02 status -`200`/`201`, successful BUD-01 `HEAD`/`GET` observations, a body bounded by its -declared size and the public `10,485,760`-byte maximum, and one fully decodable -static JPEG, PNG, or WebP raster within the public dimension and pixel limits. -With the non-default `raster-decode` feature, the crate forces the decoder from -the exact declared MIME type. JPEG first passes an internal sequential entropy -validator that accounts for every MCU, Huffman symbol, magnitude bit, pad bit, -restart marker, and frame component without synthesizing missing input. The -same bytes are then fully decoded to RGB pixels by exactly pinned `zune-jpeg` -`0.5.15` and `zune-core` `0.5.1` in strict mode with unsafe intrinsics disabled. -The profile accepts only 8-bit sequential SOF0/SOF1 JPEG; progressive SOF2 and -every other process are rejected. `image` `0.25.10` is enabled in production -only for PNG. WebP uses the safe `libwebp` `0.1.2` wrapper over statically -vendored `libwebp-sys2` `0.1.11` and decodes directly into one checked, -fallibly allocated RGBA buffer. Both dependencies are BSD-3-Clause; the native -and unsafe implementation remains confined to those pinned dependencies, and -the production boundary uses no subprocess, system dylib discovery, or network -access. The profile rejects PNG and WebP animation, bounds decoded output to -`80,000,000` bytes before allocation, decodes the complete body, and derives dimensions -internally. PNG is limited to static 8-bit images with approved color types, -one ordered palette, no unknown critical chunks, and at most `65,536` -container records; WebP is limited to static images whose extended header -carries no reserved flags and exactly one primary chunk under the same record -budget. Sequential JPEG validation charges bounded scan, block, -coefficient-step, marker-record, and entropy bit-read budgets, and -unsupported PNG or WebP processes fail with the stable -`publication_raster_process_forbidden` error. The decoder boundary is exercised -by a checked-in regression corpus, a Nix-pinned independent-decoder -differential, an isolated twelve-process maximum-resource matrix held under a -128 MiB peak-RSS gate, an `aarch64-apple-ios` static compile/link lane, and -deterministic libFuzzer smoke targets; see -`docs/blossom-raster-decoder-security.md` for the governed lanes and the -extended-campaign record. It checks complete-byte, URL, hash, MIME, length, -container, animation, and dimension agreement before emitting deterministic -per-URL evidence. The crate performs no HTTP: an owning runtime supplies only the -transport observations and exact complete body. The portable `no_std` core -remains available without `raster-decode`, but cannot construct readiness -evidence. Policy v1 has no serialized or caller-supplied decode-observation -input. - -With the `serde` feature, sealed readiness evidence can be stored as canonical -compact JSON and reloaded through an explicit bounded validator. The v1 wire is -limited to `8,192` bytes, fixes field order, rejects unknown, missing, -duplicate, reordered, or noncanonical fields, and revalidates the canonical -hash-path URL, closed raster profile, dimensions, observation statuses, and -domain-separated evidence digest. The sealed evidence type itself does not -implement `Deserialize`; ordinary JSON decoding cannot manufacture readiness -authority. This persistence surface remains available in the portable -`no_std + alloc` serde lane without enabling raster decoding. - -Protocol behavior is pinned to Blossom commit -`b5bd2801d1763aa635fc8fea7a76597e0eb18990`: - -- BUD-01: <https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/01.md> -- BUD-02: <https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/02.md> -- BUD-11: <https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/11.md> diff --git a/crates/blossom/README.md b/crates/blossom/README.md @@ -0,0 +1,209 @@ +# radroots_blossom + +`radroots_blossom` provides portable Blossom protocol primitives for Radroots: +canonical SHA-256 values and root hash paths, blob URLs, media types, BUD-02 +descriptors, byte-verification and publication-readiness typestates, and pure +BUD-11 authorization claims. + +The crate supports `no_std` environments with `alloc`. It performs no I/O, +starts no tasks or threads, reads no clocks or process state, and owns no HTTP +client, upload scheduler, cache, filesystem, credential, signer, or global +authentication state. + +## Example + +Parse a BUD-02 descriptor, apply the Radroots reference policy, and prove that +its hash, size, and media type match locally available bytes: + +```rust +use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; + +let bytes = b"hello"; +let hash = Sha256::digest(bytes); +let media_type = MediaType::parse("text/plain")?; +let url = BlobUrl::parse(&format!("https://media.example/{hash}.txt"))?; +let descriptor = BlobDescriptor::new( + url, + hash, + bytes.len() as u64, + media_type.clone(), + 1_725_105_921, +)?; + +let verified = descriptor + .approve_reference()? + .verify_bytes(bytes, &media_type)?; + +assert_eq!(verified.sha256(), hash); +assert_eq!(verified.size(), 5); +assert_eq!(verified.url().as_str(), format!("https://media.example/{hash}.txt")); +# Ok::<(), radroots_blossom::Error>(()) +``` + +The same program is available as the +[`verified_descriptor`](examples/verified_descriptor.rs) example. + +## Public API + +The crate root intentionally exports `AuthorizationClaim`, `BlobDescriptor`, +`BlobUrl`, `ByteVerifiedDescriptor`, `PublicationReadinessEvidence`, +`MediaType`, `Sha256`, and the aggregate +[`Error`](https://docs.rs/radroots_blossom/latest/radroots_blossom/enum.Error.html). +Supporting states and constructors remain in these modules: + +- `authorization` — bounded BUD-11 claim parsing, endpoint-scope validation, + and upload-claim wire parts; +- `descriptor` — BUD-02 descriptors and the approved-reference, + byte-commitment, and byte-verified states; +- `hash` — SHA-256 values, safe file extensions, and root hash paths; +- `media_type` — parsed and deterministically canonicalized media types; +- `publication_readiness` — bounded BUD-01/BUD-02 observations, static raster + validation, and sealed deterministic readiness evidence; +- `url` — structural blob URLs and the stricter approved-reference state. + +The normative responsibility and dependency boundary are defined by the +[`radroots_blossom` package charter](../../docs/specs/radroots_crates_release_v1.md). +The reviewed pre-release surface is recorded in +[`docs/api`](../../docs/api/README.md). + +## Features + +| Feature | Default | Effect | +| --- | --- | --- | +| `std` | yes | Implements the standard error integration and enables standard-library support in dependencies; protocol behavior remains portable. | +| `serde` | yes | Implements checked protocol serialization plus bounded canonical publication-readiness evidence persistence. | +| `raster-decode` | no | Enables complete static JPEG, PNG, and WebP decoding required to construct publication-readiness evidence. | + +Disabling default features leaves the `no_std` + `alloc` protocol model. +Features are additive; neither feature selects a runtime, transport, backend, +clock, signer, credential source, or side effect. + +## Protocol and validation boundaries + +Protocol behavior is pinned to Blossom commit +`b5bd2801d1763aa635fc8fea7a76597e0eb18990`: + +- [BUD-01](https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/01.md) + defines root hash paths and basic blob retrieval; +- [BUD-02](https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/02.md) + defines blob descriptors; +- [BUD-11](https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/11.md) + defines Nostr authorization claims. + +`Sha256`, `HashPath`, `BlobUrl`, and `MediaType` reject ambiguous encodings and +emit their documented canonical textual forms. A `BlobDescriptor` additionally +requires a URL file extension and the same hash in its URL and `sha256` field. +These checks establish structural protocol validity; they do not make a remote +reference trustworthy. + +`BlobUrl::parse` accepts structurally valid public HTTP references so received +Blossom data can be represented faithfully. `BlobUrl::approve` advances only +HTTPS or loopback HTTP references to `ApprovedBlobUrl`. Callers must not fetch, +display, cache, or otherwise act on an unapproved URL. URL approval is a narrow +transport policy, not host reputation, content safety, malware scanning, or +application media policy. + +`ByteVerifiedDescriptor` can only be produced after an approved descriptor's +hash, byte length, and approved media type match supplied bytes or a locally +computed `ByteCommitment`. It proves local descriptor-to-byte agreement. It is +not a server receipt, upload acknowledgement, authenticity proof, or safe-media +classification. + +## Publication readiness + +Publication readiness accepts only successful BUD-02 upload and BUD-01 +`HEAD`/`GET` observations whose URL, hash, MIME type, declared length, and +complete bytes agree. Bodies are limited to 10,485,760 encoded bytes. With the +non-default `raster-decode` feature, the real bytes must decode completely as +one static JPEG, PNG, or WebP image within the 16,384-pixel axis, +20,000,000-pixel, and 80,000,000-decoded-byte limits. + +JPEG first passes the crate's bounded sequential entropy validator and is then +decoded with pinned `zune-jpeg`/`zune-core`. Production `image` support is +limited to PNG. WebP is decoded directly into one checked, fallibly allocated +RGBA buffer through the safe `libwebp` wrapper over statically vendored +`libwebp-sys2`; no production subprocess, system-dylib discovery, or network +access is involved. Animation, unsupported processes, malformed containers, +container/dimension disagreement, and incomplete bodies fail closed. + +The decoder boundary is governed by the checked-in regression corpus, +independent-decoder differential checks, a twelve-process 20,000,000-pixel +resource matrix with a 128 MiB peak-RSS ceiling, an `aarch64-apple-ios` +compile/link lane, and deterministic libFuzzer smoke targets. See +[`blossom-raster-decoder-security.md`](../../docs/blossom-raster-decoder-security.md) +for the exact limits and command authority. + +Policy-v1 evidence is canonical compact JSON bounded to 8,192 bytes. Reloading +rejects unknown, missing, duplicate, reordered, or noncanonical fields and +revalidates the URL, raster profile, dimensions, observations, and +domain-separated evidence digest. The sealed evidence type does not implement +ordinary `Deserialize`, so serialization alone cannot manufacture readiness +authority. + +## Authorization and security + +`AuthorizationClaim` parses the BUD-11 content and tags needed for endpoint +authorization. Validation checks caller-supplied current time, action, server, +hash, expiration, creation age, and lifetime policy. The crate never reads a +clock and never verifies a Nostr event signature or signer identity. The +composing Nostr layer must validate event kind `24242`, signature, author, +request association, and canonical `Authorization: Nostr` encoding before an +endpoint treats a validated claim as authenticated. Kind `24242` is ephemeral +HTTP authorization material and must not be published to relays. + +`AuthoredUploadClaim` emits checked event wire parts; it does not sign them or +send a request. Authorization content is bounded to 4,096 bytes, server domains +use lowercase ASCII DNS or canonical IPv4 forms, timestamps are explicit +caller inputs, and authored lifetimes are limited to 300 seconds. + +The crate forbids unsafe Rust. It does not provide confidentiality, +authentication, authorization storage, credential redaction, content scanning, +SSRF protection beyond its explicit URL approval rule, or resource limits for +the bytes callers choose to hash. Hosts must bound untrusted payload sizes +before hashing or retaining them and must enforce application policy at their +own trust boundaries. + +## Serialization + +With `serde`, SHA-256 values are lowercase hexadecimal strings; file extensions, +hash paths, blob URLs, and media types use their canonical string forms; and +BUD-02 descriptors use the protocol fields `url`, `sha256`, `size`, `type`, and +`uploaded`. Deserialization re-runs the same parsers and descriptor constructor, +so malformed or cross-field-inconsistent values cannot enter through the +supported serde surface. + +Authorization states and approved or byte-verified typestates intentionally do +not implement serde. Reconstruct them by parsing untrusted wire parts and +reapplying the current request, time, URL, and byte-verification policies. +Serialization alone never preserves an approval or authentication decision. + +## Execution and commit semantics + +Operations are synchronous and deterministic. Hashing cost is linear in the +caller-supplied byte slice; parsing and authored authorization construction may +allocate in proportion to bounded input. There are no asynchronous cancellation +points, deadlines, retries, callbacks, network requests, partial external +effects, or durable commit points. A successful call returns its complete +in-memory value; an error returns without durable mutation because the crate +owns no external state. + +HTTP-capable callers must define their own cancellation and commit boundary. +In particular, local byte verification does not authorize marking a BUD-02 +upload complete: durable upload success may be committed only after the +composing transport has validated its successful server response. + +## Intended consumers + +`radroots_blossom` is intended directly for `radroots_event`, +`radroots_event_codec`, `radroots_nostr`, and media clients that need portable +Blossom values. Applications should normally begin with the curated `radroots` +crate or the advanced `radroots_sdk` composition surface, where transport, +signing, storage, and application media policy can be supplied explicitly. + +The package is pre-1.0. Its durable responsibility and package identity are +fixed, while API-breaking changes follow the workspace's pre-1.0 versioning +policy. + +## License + +Licensed under either Apache-2.0 or MIT, at your option. diff --git a/crates/blossom/contracts/raster_decoder_security_v1.descriptor.json b/crates/blossom/contracts/raster_decoder_security_v1.descriptor.json @@ -1,22 +1,22 @@ { "contract_id": "radroots_blossom.raster_decoder_security_v1", "manifest": { - "byte_length": 31555, + "byte_length": 32243, "hash_algorithm": "sha256_bytes_v1", "path": "crates/blossom/contracts/raster_decoder_security_v1.manifest.json", - "sha256": "c38a07006be45e02a1b2eb73b5b2b10d23826c28e9381ccd080dea2fe9c2dc64" + "sha256": "1a27473aeae2f1b8dce59992f7571c1c0cdaa6c3caef896a544114a2b09bc680" }, "manifest_schema": { - "byte_length": 19177, + "byte_length": 19378, "hash_algorithm": "sha256_bytes_v1", "path": "crates/blossom/contracts/raster_decoder_security_v1.manifest.schema.json", - "sha256": "8efaeddcfd209deaa0d60732cfe094a915e6ba1569cb3b09c8c1af9de66fe16e" + "sha256": "94f9e8b44f911d46064a7549eb223d8436ea51f0571cb90e02d983ad931c0586" }, "manifest_sidecar": { "byte_length": 65, "hash_algorithm": "sha256_bytes_v1", "path": "crates/blossom/contracts/raster_decoder_security_v1.manifest.sha256", - "sha256": "bdac8f2b6288b3711ba8b025ae4cc74cf929a25862cf8acb4fcee469231d1b5e" + "sha256": "6cc60e5e73c1d02eb9c0785a14de44f422d4ab224890f1487775d7d51944cdcb" }, "predecessor_contract_ids": [ "radroots_blossom.publication_readiness_v1" diff --git a/crates/blossom/contracts/raster_decoder_security_v1.manifest.json b/crates/blossom/contracts/raster_decoder_security_v1.manifest.json @@ -4,8 +4,8 @@ "authority_id": "blossom_raster_decoder_security_v1", "manifest_schema": { "path": "crates/blossom/contracts/raster_decoder_security_v1.manifest.schema.json", - "byte_length": 19177, - "sha256": "8efaeddcfd209deaa0d60732cfe094a915e6ba1569cb3b09c8c1af9de66fe16e", + "byte_length": 19378, + "sha256": "94f9e8b44f911d46064a7549eb223d8436ea51f0571cb90e02d983ad931c0586", "hash_algorithm": "sha256_bytes_v1" }, "predecessors": [ @@ -79,6 +79,20 @@ "hash_algorithm": "sha256_bytes_v1" } ], + "artifact_supersessions": [ + { + "path": "crates/blossom/tests/publication_readiness.rs", + "byte_length": 31933, + "sha256": "48796d2e4c171187fbf2efc66c5dd14e0510fc724baab5ffbd1dc156ba762c4b", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/blossom/tests/publication_readiness_persistence.rs", + "byte_length": 11410, + "sha256": "57c8576a1121c66996492eff79beada9f6a7287ec5fede295411dbb821ec4fad", + "hash_algorithm": "sha256_bytes_v1" + } + ], "source_supersessions": [ "CHANGELOG.md", "contracts/releases/1.0.0-alpha.1.toml", @@ -86,6 +100,8 @@ "crates/blossom/src/error.rs", "crates/blossom/src/publication_readiness.rs", "crates/blossom/src/publication_readiness/sequential_jpeg.rs", + "crates/blossom/tests/publication_readiness.rs", + "crates/blossom/tests/publication_readiness_persistence.rs", "tools/xtask/src/contract.rs", "tools/xtask/src/main.rs" ] @@ -338,14 +354,14 @@ "fuzz_campaign": { "project_manifest": { "path": "fuzz/Cargo.toml", - "byte_length": 628, - "sha256": "d73bb44034df2f083fa2f1f6d437639fa7d1fede4c563fc0864d9859a03d01cb", + "byte_length": 634, + "sha256": "c9c3fede6c3b0c0e39c5655bb163bd13f0ca738cef0d6b7ffced852424f9e692", "hash_algorithm": "sha256_bytes_v1" }, "project_lockfile": { "path": "fuzz/Cargo.lock", - "byte_length": 19011, - "sha256": "2bfa3270800d5763aadb4884ac8cc9a0a5ed5dcf94b80c00d1345607b1cb2ee3", + "byte_length": 19017, + "sha256": "81ced0ce90c1fbcbd066588f230dfbd8477d3b3fbf34ac8ed767e451efce5427", "hash_algorithm": "sha256_bytes_v1" }, "toolchain": { @@ -363,8 +379,8 @@ "sanitizer": "address", "common_harness": { "path": "fuzz/fuzz_targets/common.rs", - "byte_length": 2024, - "sha256": "bde259afbc894af9cf60f160e6664350430e4b0af90b293112b6e768c0127d2d", + "byte_length": 1780, + "sha256": "bb388c3389eff6eb11407463770f8c87a5f1481ce5e189502d92133ade77fd02", "hash_algorithm": "sha256_bytes_v1" }, "targets": [ @@ -860,8 +876,8 @@ }, "executor": { "path": "crates/blossom/tests/decoder_security.rs", - "byte_length": 19240, - "sha256": "fe940ca7d26280d7bcc1f1c4dd0b1b36d8ab60dcdbfe54413ceed837bbbe4b71", + "byte_length": 18881, + "sha256": "93bf4e0facf5cdbc6250741ec9303c78ce43238626f8c6cf7d71d51bae5c0be1", "hash_algorithm": "sha256_bytes_v1" }, "executor_tests": [ diff --git a/crates/blossom/contracts/raster_decoder_security_v1.manifest.schema.json b/crates/blossom/contracts/raster_decoder_security_v1.manifest.schema.json @@ -440,6 +440,13 @@ "predecessor": { "additionalProperties": false, "properties": { + "artifact_supersessions": { + "items": { + "$ref": "#/$defs/file" + }, + "minItems": 1, + "type": "array" + }, "contract_id": { "const": "radroots_blossom.publication_readiness_v1" }, @@ -463,6 +470,7 @@ "required": [ "contract_id", "immutable_artifacts", + "artifact_supersessions", "source_supersessions" ], "type": "object" diff --git a/crates/blossom/contracts/raster_decoder_security_v1.manifest.sha256 b/crates/blossom/contracts/raster_decoder_security_v1.manifest.sha256 @@ -1 +1 @@ -c38a07006be45e02a1b2eb73b5b2b10d23826c28e9381ccd080dea2fe9c2dc64 +1a27473aeae2f1b8dce59992f7571c1c0cdaa6c3caef896a544114a2b09bc680 diff --git a/crates/blossom/examples/verified_descriptor.rs b/crates/blossom/examples/verified_descriptor.rs @@ -0,0 +1,27 @@ +use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; + +fn main() -> Result<(), radroots_blossom::Error> { + let bytes = b"hello"; + let hash = Sha256::digest(bytes); + let media_type = MediaType::parse("text/plain")?; + let url = BlobUrl::parse(&format!("https://media.example/{hash}.txt"))?; + let descriptor = BlobDescriptor::new( + url, + hash, + bytes.len() as u64, + media_type.clone(), + 1_725_105_921, + )?; + + let verified = descriptor + .approve_reference()? + .verify_bytes(bytes, &media_type)?; + + assert_eq!(verified.sha256(), hash); + assert_eq!(verified.size(), 5); + assert_eq!( + verified.url().as_str(), + format!("https://media.example/{hash}.txt") + ); + Ok(()) +} diff --git a/crates/blossom/src/authorization.rs b/crates/blossom/src/authorization.rs @@ -1,3 +1,12 @@ +//! Pure BUD-11 authorization claim construction, parsing, and validation. +//! +//! This module validates bounded claim content and caller-supplied endpoint, +//! server, hash, and time context. It does not read a clock, verify Nostr event +//! signatures or authors, encode HTTP headers, send requests, or publish events. +//! A [`ValidatedAuthorizationClaim`] therefore proves only the checks represented +//! by its [`AuthorizationValidation`]; the composing Nostr and HTTP layers retain +//! authentication and request-commit responsibility. + use alloc::{ string::{String, ToString}, vec, @@ -5,7 +14,7 @@ use alloc::{ }; use core::{fmt, str::FromStr}; -use crate::{RadrootsBlossomError, RadrootsBlossomSha256}; +use crate::{Error, Sha256}; pub const RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND: u16 = 24_242; pub const RADROOTS_BLOSSOM_AUTH_CONTENT_MAX_BYTES: usize = 4_096; @@ -13,7 +22,7 @@ pub const RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS: u64 = 300; pub const RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS: u64 = 300; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum RadrootsBlossomAuthorizationAction { +pub enum AuthorizationAction { Get, Upload, List, @@ -21,15 +30,15 @@ pub enum RadrootsBlossomAuthorizationAction { Media, } -impl RadrootsBlossomAuthorizationAction { - pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { +impl AuthorizationAction { + pub fn parse(value: &str) -> Result<Self, Error> { match value { "get" => Ok(Self::Get), "upload" => Ok(Self::Upload), "list" => Ok(Self::List), "delete" => Ok(Self::Delete), "media" => Ok(Self::Media), - _ => Err(RadrootsBlossomError::InvalidAuthorizationAction), + _ => Err(Error::InvalidAuthorizationAction), } } @@ -44,14 +53,14 @@ impl RadrootsBlossomAuthorizationAction { } } -impl fmt::Display for RadrootsBlossomAuthorizationAction { +impl fmt::Display for AuthorizationAction { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(self.as_str()) } } -impl FromStr for RadrootsBlossomAuthorizationAction { - type Err = RadrootsBlossomError; +impl FromStr for AuthorizationAction { + type Err = Error; fn from_str(value: &str) -> Result<Self, Self::Err> { Self::parse(value) @@ -59,16 +68,16 @@ impl FromStr for RadrootsBlossomAuthorizationAction { } #[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct RadrootsBlossomServerDomain(String); +pub struct ServerDomain(String); -impl RadrootsBlossomServerDomain { - pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { +impl ServerDomain { + pub fn parse(value: &str) -> Result<Self, Error> { if value.is_empty() || value.len() > 253 || !value.is_ascii() || value.bytes().any(|byte| byte.is_ascii_uppercase()) { - return Err(RadrootsBlossomError::InvalidAuthorizationServerDomain); + return Err(Error::InvalidAuthorizationServerDomain); } let mut all_labels_are_numeric = true; @@ -87,13 +96,13 @@ impl RadrootsBlossomServerDomain { .last() .is_some_and(u8::is_ascii_alphanumeric) { - return Err(RadrootsBlossomError::InvalidAuthorizationServerDomain); + return Err(Error::InvalidAuthorizationServerDomain); } all_labels_are_numeric &= label.bytes().all(|byte| byte.is_ascii_digit()); } if all_labels_are_numeric && !is_canonical_dotted_ipv4(value) { - return Err(RadrootsBlossomError::InvalidAuthorizationServerDomain); + return Err(Error::InvalidAuthorizationServerDomain); } Ok(Self(value.to_string())) @@ -104,14 +113,14 @@ impl RadrootsBlossomServerDomain { } } -impl fmt::Display for RadrootsBlossomServerDomain { +impl fmt::Display for ServerDomain { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(self.as_str()) } } -impl FromStr for RadrootsBlossomServerDomain { - type Err = RadrootsBlossomError; +impl FromStr for ServerDomain { + type Err = Error; fn from_str(value: &str) -> Result<Self, Self::Err> { Self::parse(value) @@ -119,10 +128,10 @@ impl FromStr for RadrootsBlossomServerDomain { } #[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct RadrootsBlossomAuthorizationContent(String); +pub struct AuthorizationContent(String); -impl RadrootsBlossomAuthorizationContent { - pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { +impl AuthorizationContent { + pub fn parse(value: &str) -> Result<Self, Error> { if value.is_empty() || value.len() > RADROOTS_BLOSSOM_AUTH_CONTENT_MAX_BYTES || value.trim() != value @@ -130,7 +139,7 @@ impl RadrootsBlossomAuthorizationContent { .chars() .any(|character| character.is_control() && !matches!(character, '\t' | '\n' | '\r')) { - return Err(RadrootsBlossomError::InvalidAuthorizationContent); + return Err(Error::InvalidAuthorizationContent); } Ok(Self(value.to_string())) } @@ -140,14 +149,14 @@ impl RadrootsBlossomAuthorizationContent { } } -impl fmt::Display for RadrootsBlossomAuthorizationContent { +impl fmt::Display for AuthorizationContent { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(self.as_str()) } } -impl FromStr for RadrootsBlossomAuthorizationContent { - type Err = RadrootsBlossomError; +impl FromStr for AuthorizationContent { + type Err = Error; fn from_str(value: &str) -> Result<Self, Self::Err> { Self::parse(value) @@ -155,27 +164,27 @@ impl FromStr for RadrootsBlossomAuthorizationContent { } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum RadrootsBlossomAuthorizationTarget { - GetBlob(RadrootsBlossomSha256), - Upload(RadrootsBlossomSha256), +pub enum AuthorizationTarget { + GetBlob(Sha256), + Upload(Sha256), List, - DeleteBlob(RadrootsBlossomSha256), - Mirror(RadrootsBlossomSha256), - Media(RadrootsBlossomSha256), + DeleteBlob(Sha256), + Mirror(Sha256), + Media(Sha256), } -impl RadrootsBlossomAuthorizationTarget { - pub const fn action(self) -> RadrootsBlossomAuthorizationAction { +impl AuthorizationTarget { + pub const fn action(self) -> AuthorizationAction { match self { - Self::GetBlob(_) => RadrootsBlossomAuthorizationAction::Get, - Self::Upload(_) | Self::Mirror(_) => RadrootsBlossomAuthorizationAction::Upload, - Self::List => RadrootsBlossomAuthorizationAction::List, - Self::DeleteBlob(_) => RadrootsBlossomAuthorizationAction::Delete, - Self::Media(_) => RadrootsBlossomAuthorizationAction::Media, + Self::GetBlob(_) => AuthorizationAction::Get, + Self::Upload(_) | Self::Mirror(_) => AuthorizationAction::Upload, + Self::List => AuthorizationAction::List, + Self::DeleteBlob(_) => AuthorizationAction::Delete, + Self::Media(_) => AuthorizationAction::Media, } } - pub const fn implied_hash(self) -> Option<RadrootsBlossomSha256> { + pub const fn implied_hash(self) -> Option<Sha256> { match self { Self::GetBlob(hash) | Self::Upload(hash) @@ -195,31 +204,27 @@ impl RadrootsBlossomAuthorizationTarget { } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum RadrootsBlossomServerScopeRequirement { +pub enum ServerScopeRequirement { OptionalAnyMatch, RequiredAnyMatch, } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsBlossomAuthorizationValidation { - target: RadrootsBlossomAuthorizationTarget, - target_server: RadrootsBlossomServerDomain, - server_scope_requirement: RadrootsBlossomServerScopeRequirement, +pub struct AuthorizationValidation { + target: AuthorizationTarget, + target_server: ServerDomain, + server_scope_requirement: ServerScopeRequirement, now: u64, max_created_age_seconds: Option<u64>, max_lifetime_seconds: Option<u64>, } -impl RadrootsBlossomAuthorizationValidation { - pub fn bud11( - target: RadrootsBlossomAuthorizationTarget, - target_server: RadrootsBlossomServerDomain, - now: u64, - ) -> Self { +impl AuthorizationValidation { + pub fn bud11(target: AuthorizationTarget, target_server: ServerDomain, now: u64) -> Self { Self { target, target_server, - server_scope_requirement: RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + server_scope_requirement: ServerScopeRequirement::OptionalAnyMatch, now, max_created_age_seconds: None, max_lifetime_seconds: None, @@ -227,14 +232,14 @@ impl RadrootsBlossomAuthorizationValidation { } pub fn new( - target: RadrootsBlossomAuthorizationTarget, - target_server: RadrootsBlossomServerDomain, - server_scope_requirement: RadrootsBlossomServerScopeRequirement, + target: AuthorizationTarget, + target_server: ServerDomain, + server_scope_requirement: ServerScopeRequirement, now: u64, max_created_age_seconds: u64, - ) -> Result<Self, RadrootsBlossomError> { + ) -> Result<Self, Error> { if max_created_age_seconds > RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS { - return Err(RadrootsBlossomError::InvalidAuthorizationCreatedAge); + return Err(Error::InvalidAuthorizationCreatedAge); } Ok(Self { target, @@ -246,15 +251,15 @@ impl RadrootsBlossomAuthorizationValidation { }) } - pub const fn target(&self) -> RadrootsBlossomAuthorizationTarget { + pub const fn target(&self) -> AuthorizationTarget { self.target } - pub fn target_server(&self) -> &RadrootsBlossomServerDomain { + pub fn target_server(&self) -> &ServerDomain { &self.target_server } - pub const fn server_scope_requirement(&self) -> RadrootsBlossomServerScopeRequirement { + pub const fn server_scope_requirement(&self) -> ServerScopeRequirement { self.server_scope_requirement } @@ -272,22 +277,18 @@ impl RadrootsBlossomAuthorizationValidation { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsBlossomParsedAuthorizationClaim { - content: RadrootsBlossomAuthorizationContent, +pub struct AuthorizationClaim { + content: AuthorizationContent, created_at: u64, - action: RadrootsBlossomAuthorizationAction, + action: AuthorizationAction, expiration: u64, - server_domains: Vec<RadrootsBlossomServerDomain>, - hashes: Vec<RadrootsBlossomSha256>, + server_domains: Vec<ServerDomain>, + hashes: Vec<Sha256>, } -impl RadrootsBlossomParsedAuthorizationClaim { - pub fn parse( - content: &str, - created_at: u64, - tags: &[Vec<String>], - ) -> Result<Self, RadrootsBlossomError> { - let content = RadrootsBlossomAuthorizationContent::parse(content)?; +impl AuthorizationClaim { + pub fn parse(content: &str, created_at: u64, tags: &[Vec<String>]) -> Result<Self, Error> { + let content = AuthorizationContent::parse(content)?; let mut action = None; let mut expiration = None; let mut server_domains = Vec::new(); @@ -297,41 +298,35 @@ impl RadrootsBlossomParsedAuthorizationClaim { match tag.first().map(String::as_str) { Some("t") => { if action.is_some() { - return Err(RadrootsBlossomError::DuplicateAuthorizationActionTag); + return Err(Error::DuplicateAuthorizationActionTag); } - let value = tag - .get(1) - .ok_or(RadrootsBlossomError::MalformedAuthorizationActionTag)?; + let value = tag.get(1).ok_or(Error::MalformedAuthorizationActionTag)?; action = Some( - RadrootsBlossomAuthorizationAction::parse(value) - .map_err(|_| RadrootsBlossomError::MalformedAuthorizationActionTag)?, + AuthorizationAction::parse(value) + .map_err(|_| Error::MalformedAuthorizationActionTag)?, ); } Some("expiration") => { if expiration.is_some() { - return Err(RadrootsBlossomError::DuplicateAuthorizationExpirationTag); + return Err(Error::DuplicateAuthorizationExpirationTag); } let value = tag .get(1) - .ok_or(RadrootsBlossomError::MalformedAuthorizationExpirationTag)?; + .ok_or(Error::MalformedAuthorizationExpirationTag)?; expiration = Some(parse_timestamp(value)?); } Some("server") => { - let value = tag - .get(1) - .ok_or(RadrootsBlossomError::MalformedAuthorizationServerTag)?; + let value = tag.get(1).ok_or(Error::MalformedAuthorizationServerTag)?; server_domains.push( - RadrootsBlossomServerDomain::parse(value) - .map_err(|_| RadrootsBlossomError::MalformedAuthorizationServerTag)?, + ServerDomain::parse(value) + .map_err(|_| Error::MalformedAuthorizationServerTag)?, ); } Some("x") => { - let value = tag - .get(1) - .ok_or(RadrootsBlossomError::MalformedAuthorizationHashTag)?; + let value = tag.get(1).ok_or(Error::MalformedAuthorizationHashTag)?; hashes.push( - RadrootsBlossomSha256::from_hex(value) - .map_err(|_| RadrootsBlossomError::MalformedAuthorizationHashTag)?, + Sha256::from_hex(value) + .map_err(|_| Error::MalformedAuthorizationHashTag)?, ); } _ => {} @@ -341,9 +336,8 @@ impl RadrootsBlossomParsedAuthorizationClaim { Ok(Self { content, created_at, - action: action.ok_or(RadrootsBlossomError::MissingAuthorizationActionTag)?, - expiration: expiration - .ok_or(RadrootsBlossomError::MissingAuthorizationExpirationTag)?, + action: action.ok_or(Error::MissingAuthorizationActionTag)?, + expiration: expiration.ok_or(Error::MissingAuthorizationExpirationTag)?, server_domains, hashes, }) @@ -351,46 +345,46 @@ impl RadrootsBlossomParsedAuthorizationClaim { pub fn validate( &self, - validation: &RadrootsBlossomAuthorizationValidation, - ) -> Result<RadrootsBlossomValidatedAuthorizationClaim, RadrootsBlossomError> { + validation: &AuthorizationValidation, + ) -> Result<ValidatedAuthorizationClaim, Error> { if self.created_at >= validation.now { - return Err(RadrootsBlossomError::AuthorizationCreatedInFuture); + return Err(Error::AuthorizationCreatedInFuture); } if let Some(max_created_age_seconds) = validation.max_created_age_seconds && validation.now.saturating_sub(self.created_at) > max_created_age_seconds { - return Err(RadrootsBlossomError::AuthorizationStale); + return Err(Error::AuthorizationStale); } let lifetime = self .expiration .checked_sub(self.created_at) .filter(|lifetime| *lifetime > 0) - .ok_or(RadrootsBlossomError::InvalidAuthorizationLifetime)?; + .ok_or(Error::InvalidAuthorizationLifetime)?; if let Some(max_lifetime_seconds) = validation.max_lifetime_seconds && lifetime > max_lifetime_seconds { - return Err(RadrootsBlossomError::InvalidAuthorizationLifetime); + return Err(Error::InvalidAuthorizationLifetime); } if self.expiration <= validation.now { - return Err(RadrootsBlossomError::AuthorizationExpired); + return Err(Error::AuthorizationExpired); } if self.action != validation.target.action() { - return Err(RadrootsBlossomError::AuthorizationActionMismatch); + return Err(Error::AuthorizationActionMismatch); } self.validate_server_scope(validation)?; self.validate_hash_scope(validation.target)?; - Ok(RadrootsBlossomValidatedAuthorizationClaim { + Ok(ValidatedAuthorizationClaim { claim: self.clone(), target: validation.target, target_server: validation.target_server.clone(), }) } - pub fn content(&self) -> &RadrootsBlossomAuthorizationContent { + pub fn content(&self) -> &AuthorizationContent { &self.content } @@ -398,7 +392,7 @@ impl RadrootsBlossomParsedAuthorizationClaim { self.created_at } - pub const fn action(&self) -> RadrootsBlossomAuthorizationAction { + pub const fn action(&self) -> AuthorizationAction { self.action } @@ -406,24 +400,19 @@ impl RadrootsBlossomParsedAuthorizationClaim { self.expiration } - pub fn server_domains(&self) -> &[RadrootsBlossomServerDomain] { + pub fn server_domains(&self) -> &[ServerDomain] { &self.server_domains } - pub fn hashes(&self) -> &[RadrootsBlossomSha256] { + pub fn hashes(&self) -> &[Sha256] { &self.hashes } - fn validate_server_scope( - &self, - validation: &RadrootsBlossomAuthorizationValidation, - ) -> Result<(), RadrootsBlossomError> { + fn validate_server_scope(&self, validation: &AuthorizationValidation) -> Result<(), Error> { if self.server_domains.is_empty() { return match validation.server_scope_requirement { - RadrootsBlossomServerScopeRequirement::OptionalAnyMatch => Ok(()), - RadrootsBlossomServerScopeRequirement::RequiredAnyMatch => { - Err(RadrootsBlossomError::AuthorizationServerRequired) - } + ServerScopeRequirement::OptionalAnyMatch => Ok(()), + ServerScopeRequirement::RequiredAnyMatch => Err(Error::AuthorizationServerRequired), }; } if self @@ -433,20 +422,17 @@ impl RadrootsBlossomParsedAuthorizationClaim { { Ok(()) } else { - Err(RadrootsBlossomError::AuthorizationServerMismatch) + Err(Error::AuthorizationServerMismatch) } } - fn validate_hash_scope( - &self, - target: RadrootsBlossomAuthorizationTarget, - ) -> Result<(), RadrootsBlossomError> { + fn validate_hash_scope(&self, target: AuthorizationTarget) -> Result<(), Error> { let Some(implied_hash) = target.implied_hash() else { return Ok(()); }; if self.hashes.is_empty() { return if target.requires_hash_tag() { - Err(RadrootsBlossomError::AuthorizationHashRequired) + Err(Error::AuthorizationHashRequired) } else { Ok(()) }; @@ -454,32 +440,32 @@ impl RadrootsBlossomParsedAuthorizationClaim { if self.hashes.contains(&implied_hash) { Ok(()) } else { - Err(RadrootsBlossomError::AuthorizationHashMismatch) + Err(Error::AuthorizationHashMismatch) } } } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsBlossomValidatedAuthorizationClaim { - claim: RadrootsBlossomParsedAuthorizationClaim, - target: RadrootsBlossomAuthorizationTarget, - target_server: RadrootsBlossomServerDomain, +pub struct ValidatedAuthorizationClaim { + claim: AuthorizationClaim, + target: AuthorizationTarget, + target_server: ServerDomain, } -impl RadrootsBlossomValidatedAuthorizationClaim { - pub fn claim(&self) -> &RadrootsBlossomParsedAuthorizationClaim { +impl ValidatedAuthorizationClaim { + pub fn claim(&self) -> &AuthorizationClaim { &self.claim } - pub const fn target(&self) -> RadrootsBlossomAuthorizationTarget { + pub const fn target(&self) -> AuthorizationTarget { self.target } - pub fn target_server(&self) -> &RadrootsBlossomServerDomain { + pub fn target_server(&self) -> &ServerDomain { &self.target_server } - pub fn content(&self) -> &RadrootsBlossomAuthorizationContent { + pub fn content(&self) -> &AuthorizationContent { self.claim.content() } @@ -487,7 +473,7 @@ impl RadrootsBlossomValidatedAuthorizationClaim { self.claim.created_at() } - pub const fn action(&self) -> RadrootsBlossomAuthorizationAction { + pub const fn action(&self) -> AuthorizationAction { self.claim.action() } @@ -495,24 +481,24 @@ impl RadrootsBlossomValidatedAuthorizationClaim { self.claim.expiration() } - pub fn server_domains(&self) -> &[RadrootsBlossomServerDomain] { + pub fn server_domains(&self) -> &[ServerDomain] { self.claim.server_domains() } - pub fn hashes(&self) -> &[RadrootsBlossomSha256] { + pub fn hashes(&self) -> &[Sha256] { self.claim.hashes() } } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsBlossomAuthorizationWireParts { +pub struct AuthorizationWireParts { kind: u16, created_at: u64, tags: Vec<Vec<String>>, content: String, } -impl RadrootsBlossomAuthorizationWireParts { +impl AuthorizationWireParts { pub const fn kind(&self) -> u16 { self.kind } @@ -535,35 +521,35 @@ impl RadrootsBlossomAuthorizationWireParts { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsBlossomAuthoredUploadClaim { - wire_parts: RadrootsBlossomAuthorizationWireParts, - content: RadrootsBlossomAuthorizationContent, - server_domain: RadrootsBlossomServerDomain, - sha256: RadrootsBlossomSha256, +pub struct AuthoredUploadClaim { + wire_parts: AuthorizationWireParts, + content: AuthorizationContent, + server_domain: ServerDomain, + sha256: Sha256, expiration: u64, } -impl RadrootsBlossomAuthoredUploadClaim { +impl AuthoredUploadClaim { pub fn new( - content: RadrootsBlossomAuthorizationContent, - server_domain: RadrootsBlossomServerDomain, - sha256: RadrootsBlossomSha256, + content: AuthorizationContent, + server_domain: ServerDomain, + sha256: Sha256, created_at: u64, lifetime_seconds: u64, - ) -> Result<Self, RadrootsBlossomError> { + ) -> Result<Self, Error> { if !(1..=RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS).contains(&lifetime_seconds) { - return Err(RadrootsBlossomError::InvalidAuthorizationLifetime); + return Err(Error::InvalidAuthorizationLifetime); } let expiration = created_at .checked_add(lifetime_seconds) - .ok_or(RadrootsBlossomError::AuthorizationTimestampOverflow)?; + .ok_or(Error::AuthorizationTimestampOverflow)?; let tags = vec![ vec!["t".to_string(), "upload".to_string()], vec!["expiration".to_string(), expiration.to_string()], vec!["x".to_string(), sha256.to_hex()], vec!["server".to_string(), server_domain.as_str().to_string()], ]; - let wire_parts = RadrootsBlossomAuthorizationWireParts { + let wire_parts = AuthorizationWireParts { kind: RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, created_at, tags, @@ -578,15 +564,15 @@ impl RadrootsBlossomAuthoredUploadClaim { }) } - pub fn content(&self) -> &RadrootsBlossomAuthorizationContent { + pub fn content(&self) -> &AuthorizationContent { &self.content } - pub fn server_domain(&self) -> &RadrootsBlossomServerDomain { + pub fn server_domain(&self) -> &ServerDomain { &self.server_domain } - pub const fn sha256(&self) -> RadrootsBlossomSha256 { + pub const fn sha256(&self) -> Sha256 { self.sha256 } @@ -602,22 +588,22 @@ impl RadrootsBlossomAuthoredUploadClaim { self.expiration - self.wire_parts.created_at } - pub fn wire_parts(&self) -> &RadrootsBlossomAuthorizationWireParts { + pub fn wire_parts(&self) -> &AuthorizationWireParts { &self.wire_parts } - pub fn into_wire_parts(self) -> RadrootsBlossomAuthorizationWireParts { + pub fn into_wire_parts(self) -> AuthorizationWireParts { self.wire_parts } } -fn parse_timestamp(value: &str) -> Result<u64, RadrootsBlossomError> { +fn parse_timestamp(value: &str) -> Result<u64, Error> { if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(RadrootsBlossomError::MalformedAuthorizationExpirationTag); + return Err(Error::MalformedAuthorizationExpirationTag); } value .parse() - .map_err(|_| RadrootsBlossomError::MalformedAuthorizationExpirationTag) + .map_err(|_| Error::MalformedAuthorizationExpirationTag) } fn is_canonical_dotted_ipv4(value: &str) -> bool { @@ -639,16 +625,16 @@ mod tests { const NOW: u64 = 1_800_000_000; const HASH_HEX: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; - fn hash() -> RadrootsBlossomSha256 { - RadrootsBlossomSha256::from_hex(HASH_HEX).unwrap() + fn hash() -> Sha256 { + Sha256::from_hex(HASH_HEX).unwrap() } - fn server() -> RadrootsBlossomServerDomain { - RadrootsBlossomServerDomain::parse("media.example.com").unwrap() + fn server() -> ServerDomain { + ServerDomain::parse("media.example.com").unwrap() } - fn content() -> RadrootsBlossomAuthorizationContent { - RadrootsBlossomAuthorizationContent::parse("Upload Blob").unwrap() + fn content() -> AuthorizationContent { + AuthorizationContent::parse("Upload Blob").unwrap() } fn tags(action: &str, expiration: u64) -> Vec<Vec<String>> { @@ -660,20 +646,15 @@ mod tests { ] } - fn parse_upload() -> RadrootsBlossomParsedAuthorizationClaim { - RadrootsBlossomParsedAuthorizationClaim::parse( - "Upload Blob", - NOW - 10, - &tags("upload", NOW + 60), - ) - .unwrap() + fn parse_upload() -> AuthorizationClaim { + AuthorizationClaim::parse("Upload Blob", NOW - 10, &tags("upload", NOW + 60)).unwrap() } fn validation( - target: RadrootsBlossomAuthorizationTarget, - server_requirement: RadrootsBlossomServerScopeRequirement, - ) -> RadrootsBlossomAuthorizationValidation { - RadrootsBlossomAuthorizationValidation::new( + target: AuthorizationTarget, + server_requirement: ServerScopeRequirement, + ) -> AuthorizationValidation { + AuthorizationValidation::new( target, server(), server_requirement, @@ -686,21 +667,21 @@ mod tests { #[test] fn actions_are_strict_lowercase_wire_verbs() { let cases = [ - ("get", RadrootsBlossomAuthorizationAction::Get), - ("upload", RadrootsBlossomAuthorizationAction::Upload), - ("list", RadrootsBlossomAuthorizationAction::List), - ("delete", RadrootsBlossomAuthorizationAction::Delete), - ("media", RadrootsBlossomAuthorizationAction::Media), + ("get", AuthorizationAction::Get), + ("upload", AuthorizationAction::Upload), + ("list", AuthorizationAction::List), + ("delete", AuthorizationAction::Delete), + ("media", AuthorizationAction::Media), ]; for (wire, action) in cases { - assert_eq!(RadrootsBlossomAuthorizationAction::parse(wire), Ok(action)); + assert_eq!(AuthorizationAction::parse(wire), Ok(action)); assert_eq!(wire.parse(), Ok(action)); assert_eq!(action.as_str(), wire); assert_eq!(action.to_string(), wire); } assert_eq!( - RadrootsBlossomAuthorizationAction::parse("Upload"), - Err(RadrootsBlossomError::InvalidAuthorizationAction) + AuthorizationAction::parse("Upload"), + Err(Error::InvalidAuthorizationAction) ); } @@ -713,13 +694,10 @@ mod tests { "127.0.0.1", "0.0.0.0", ] { - let domain = RadrootsBlossomServerDomain::parse(value).unwrap(); + let domain = ServerDomain::parse(value).unwrap(); assert_eq!(domain.as_str(), value); assert_eq!(domain.to_string(), value); - assert_eq!( - value.parse::<RadrootsBlossomServerDomain>().unwrap(), - domain - ); + assert_eq!(value.parse::<ServerDomain>().unwrap(), domain); } let long_label = format!("{}.example", "a".repeat(64)); @@ -743,8 +721,8 @@ mod tests { &long_domain, ] { assert_eq!( - RadrootsBlossomServerDomain::parse(value), - Err(RadrootsBlossomError::InvalidAuthorizationServerDomain), + ServerDomain::parse(value), + Err(Error::InvalidAuthorizationServerDomain), "{value}" ); } @@ -754,29 +732,22 @@ mod tests { fn content_requires_bounded_trimmed_human_readable_text() { for value in ["", " Upload Blob", "Upload Blob ", "Upload\0Blob", "\u{7f}"] { assert_eq!( - RadrootsBlossomAuthorizationContent::parse(value), - Err(RadrootsBlossomError::InvalidAuthorizationContent) + AuthorizationContent::parse(value), + Err(Error::InvalidAuthorizationContent) ); } assert_eq!( - RadrootsBlossomAuthorizationContent::parse(&"a".repeat(4_097)), - Err(RadrootsBlossomError::InvalidAuthorizationContent) + AuthorizationContent::parse(&"a".repeat(4_097)), + Err(Error::InvalidAuthorizationContent) ); for value in ["Upload\nBlob", "Upload\tBlob", "Upload\rBlob"] { - assert_eq!( - RadrootsBlossomAuthorizationContent::parse(value) - .unwrap() - .as_str(), - value - ); + assert_eq!(AuthorizationContent::parse(value).unwrap().as_str(), value); } - let value = RadrootsBlossomAuthorizationContent::parse("Téléverser l'image").unwrap(); + let value = AuthorizationContent::parse("Téléverser l'image").unwrap(); assert_eq!(value.as_str(), "Téléverser l'image"); assert_eq!(value.to_string(), "Téléverser l'image"); assert_eq!( - "Upload Blob" - .parse::<RadrootsBlossomAuthorizationContent>() - .unwrap(), + "Upload Blob".parse::<AuthorizationContent>().unwrap(), content() ); } @@ -786,38 +757,38 @@ mod tests { let hash = hash(); let cases = [ ( - RadrootsBlossomAuthorizationTarget::GetBlob(hash), - RadrootsBlossomAuthorizationAction::Get, + AuthorizationTarget::GetBlob(hash), + AuthorizationAction::Get, Some(hash), false, ), ( - RadrootsBlossomAuthorizationTarget::Upload(hash), - RadrootsBlossomAuthorizationAction::Upload, + AuthorizationTarget::Upload(hash), + AuthorizationAction::Upload, Some(hash), true, ), ( - RadrootsBlossomAuthorizationTarget::List, - RadrootsBlossomAuthorizationAction::List, + AuthorizationTarget::List, + AuthorizationAction::List, None, false, ), ( - RadrootsBlossomAuthorizationTarget::DeleteBlob(hash), - RadrootsBlossomAuthorizationAction::Delete, + AuthorizationTarget::DeleteBlob(hash), + AuthorizationAction::Delete, Some(hash), true, ), ( - RadrootsBlossomAuthorizationTarget::Mirror(hash), - RadrootsBlossomAuthorizationAction::Upload, + AuthorizationTarget::Mirror(hash), + AuthorizationAction::Upload, Some(hash), true, ), ( - RadrootsBlossomAuthorizationTarget::Media(hash), - RadrootsBlossomAuthorizationAction::Media, + AuthorizationTarget::Media(hash), + AuthorizationAction::Media, Some(hash), true, ), @@ -831,39 +802,36 @@ mod tests { #[test] fn validation_policy_is_bounded_and_exposes_injected_values() { - let target = RadrootsBlossomAuthorizationTarget::Upload(hash()); - let policy = validation( - target, - RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, - ); + let target = AuthorizationTarget::Upload(hash()); + let policy = validation(target, ServerScopeRequirement::RequiredAnyMatch); assert_eq!(policy.target(), target); assert_eq!(policy.target_server(), &server()); assert_eq!( policy.server_scope_requirement(), - RadrootsBlossomServerScopeRequirement::RequiredAnyMatch + ServerScopeRequirement::RequiredAnyMatch ); assert_eq!(policy.now(), NOW); assert_eq!(policy.max_created_age_seconds(), Some(300)); assert_eq!(policy.max_lifetime_seconds(), Some(300)); assert!( - RadrootsBlossomAuthorizationValidation::new( + AuthorizationValidation::new( target, server(), - RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + ServerScopeRequirement::OptionalAnyMatch, NOW, 0, ) .is_ok() ); assert_eq!( - RadrootsBlossomAuthorizationValidation::new( + AuthorizationValidation::new( target, server(), - RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + ServerScopeRequirement::OptionalAnyMatch, NOW, 301, ), - Err(RadrootsBlossomError::InvalidAuthorizationCreatedAge) + Err(Error::InvalidAuthorizationCreatedAge) ); } @@ -875,18 +843,14 @@ mod tests { tags[2].push("ignored".to_string()); tags[3].push("ignored".to_string()); tags.push(vec!["server".to_string(), "other.example".to_string()]); - tags.push(vec![ - "x".to_string(), - RadrootsBlossomSha256::digest(b"other").to_hex(), - ]); + tags.push(vec!["x".to_string(), Sha256::digest(b"other").to_hex()]); tags.push(vec!["unknown".to_string()]); tags.push(Vec::new()); - let parsed = - RadrootsBlossomParsedAuthorizationClaim::parse("Upload Blob", NOW - 10, &tags).unwrap(); + let parsed = AuthorizationClaim::parse("Upload Blob", NOW - 10, &tags).unwrap(); assert_eq!(parsed.content(), &content()); assert_eq!(parsed.created_at(), NOW - 10); - assert_eq!(parsed.action(), RadrootsBlossomAuthorizationAction::Upload); + assert_eq!(parsed.action(), AuthorizationAction::Upload); assert_eq!(parsed.expiration(), NOW + 60); assert_eq!(parsed.server_domains().len(), 2); assert_eq!(parsed.hashes().len(), 2); @@ -897,11 +861,11 @@ mod tests { let cases = [ ( vec![vec!["expiration".to_string(), (NOW + 60).to_string()]], - RadrootsBlossomError::MissingAuthorizationActionTag, + Error::MissingAuthorizationActionTag, ), ( vec![vec!["t".to_string(), "upload".to_string()]], - RadrootsBlossomError::MissingAuthorizationExpirationTag, + Error::MissingAuthorizationExpirationTag, ), ( vec![ @@ -909,7 +873,7 @@ mod tests { vec!["t".to_string(), "upload".to_string()], vec!["expiration".to_string(), (NOW + 60).to_string()], ], - RadrootsBlossomError::DuplicateAuthorizationActionTag, + Error::DuplicateAuthorizationActionTag, ), ( vec![ @@ -917,21 +881,21 @@ mod tests { vec!["expiration".to_string(), (NOW + 60).to_string()], vec!["expiration".to_string(), (NOW + 60).to_string()], ], - RadrootsBlossomError::DuplicateAuthorizationExpirationTag, + Error::DuplicateAuthorizationExpirationTag, ), ( vec![ vec!["t".to_string()], vec!["expiration".to_string(), (NOW + 60).to_string()], ], - RadrootsBlossomError::MalformedAuthorizationActionTag, + Error::MalformedAuthorizationActionTag, ), ( vec![ vec!["t".to_string(), "upload".to_string()], vec!["expiration".to_string()], ], - RadrootsBlossomError::MalformedAuthorizationExpirationTag, + Error::MalformedAuthorizationExpirationTag, ), ( vec![ @@ -939,7 +903,7 @@ mod tests { vec!["expiration".to_string(), (NOW + 60).to_string()], vec!["server".to_string()], ], - RadrootsBlossomError::MalformedAuthorizationServerTag, + Error::MalformedAuthorizationServerTag, ), ( vec![ @@ -947,12 +911,12 @@ mod tests { vec!["expiration".to_string(), (NOW + 60).to_string()], vec!["x".to_string()], ], - RadrootsBlossomError::MalformedAuthorizationHashTag, + Error::MalformedAuthorizationHashTag, ), ]; for (tags, expected) in cases { assert_eq!( - RadrootsBlossomParsedAuthorizationClaim::parse("Upload Blob", NOW, &tags), + AuthorizationClaim::parse("Upload Blob", NOW, &tags), Err(expected) ); } @@ -963,109 +927,80 @@ mod tests { vec!["expiration".to_string(), expiration.to_string()], ]; assert_eq!( - RadrootsBlossomParsedAuthorizationClaim::parse("Upload Blob", NOW, &tags), - Err(RadrootsBlossomError::MalformedAuthorizationExpirationTag) + AuthorizationClaim::parse("Upload Blob", NOW, &tags), + Err(Error::MalformedAuthorizationExpirationTag) ); } let mut invalid = tags("Upload", NOW + 60); assert_eq!( - RadrootsBlossomParsedAuthorizationClaim::parse("Upload Blob", NOW, &invalid), - Err(RadrootsBlossomError::MalformedAuthorizationActionTag) + AuthorizationClaim::parse("Upload Blob", NOW, &invalid), + Err(Error::MalformedAuthorizationActionTag) ); invalid = tags("upload", NOW + 60); invalid[3][1] = "Media.example".to_string(); assert_eq!( - RadrootsBlossomParsedAuthorizationClaim::parse("Upload Blob", NOW, &invalid), - Err(RadrootsBlossomError::MalformedAuthorizationServerTag) + AuthorizationClaim::parse("Upload Blob", NOW, &invalid), + Err(Error::MalformedAuthorizationServerTag) ); invalid = tags("upload", NOW + 60); invalid[2][1] = "not-a-hash".to_string(); assert_eq!( - RadrootsBlossomParsedAuthorizationClaim::parse("Upload Blob", NOW, &invalid), - Err(RadrootsBlossomError::MalformedAuthorizationHashTag) + AuthorizationClaim::parse("Upload Blob", NOW, &invalid), + Err(Error::MalformedAuthorizationHashTag) ); } #[test] fn upload_validation_enforces_time_action_and_required_scopes() { - let target = RadrootsBlossomAuthorizationTarget::Upload(hash()); - let required = validation( - target, - RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, - ); + let target = AuthorizationTarget::Upload(hash()); + let required = validation(target, ServerScopeRequirement::RequiredAnyMatch); let validated = parse_upload().validate(&required).unwrap(); assert_eq!(validated.claim(), &parse_upload()); assert_eq!(validated.target(), target); assert_eq!(validated.target_server(), &server()); assert_eq!(validated.content(), &content()); assert_eq!(validated.created_at(), NOW - 10); - assert_eq!( - validated.action(), - RadrootsBlossomAuthorizationAction::Upload - ); + assert_eq!(validated.action(), AuthorizationAction::Upload); assert_eq!(validated.expiration(), NOW + 60); assert_eq!(validated.server_domains(), &[server()]); assert_eq!(validated.hashes(), &[hash()]); let time_cases = [ - ( - NOW, - NOW + 60, - RadrootsBlossomError::AuthorizationCreatedInFuture, - ), - ( - NOW + 1, - NOW + 60, - RadrootsBlossomError::AuthorizationCreatedInFuture, - ), - (NOW - 301, NOW - 1, RadrootsBlossomError::AuthorizationStale), - ( - NOW - 10, - NOW - 10, - RadrootsBlossomError::InvalidAuthorizationLifetime, - ), - ( - NOW - 10, - NOW + 291, - RadrootsBlossomError::InvalidAuthorizationLifetime, - ), - (NOW - 10, NOW, RadrootsBlossomError::AuthorizationExpired), + (NOW, NOW + 60, Error::AuthorizationCreatedInFuture), + (NOW + 1, NOW + 60, Error::AuthorizationCreatedInFuture), + (NOW - 301, NOW - 1, Error::AuthorizationStale), + (NOW - 10, NOW - 10, Error::InvalidAuthorizationLifetime), + (NOW - 10, NOW + 291, Error::InvalidAuthorizationLifetime), + (NOW - 10, NOW, Error::AuthorizationExpired), ]; for (created_at, expiration, expected) in time_cases { - let parsed = RadrootsBlossomParsedAuthorizationClaim::parse( - "Upload Blob", - created_at, - &tags("upload", expiration), - ) - .unwrap(); + let parsed = + AuthorizationClaim::parse("Upload Blob", created_at, &tags("upload", expiration)) + .unwrap(); assert_eq!(parsed.validate(&required), Err(expected)); } - let wrong_action = RadrootsBlossomParsedAuthorizationClaim::parse( - "List Blobs", - NOW - 10, - &tags("list", NOW + 60), - ) - .unwrap(); + let wrong_action = + AuthorizationClaim::parse("List Blobs", NOW - 10, &tags("list", NOW + 60)).unwrap(); assert_eq!( wrong_action.validate(&required), - Err(RadrootsBlossomError::AuthorizationActionMismatch) + Err(Error::AuthorizationActionMismatch) ); } #[test] fn bud11_validation_keeps_radroots_replay_limits_out_of_protocol_semantics() { - let target = RadrootsBlossomAuthorizationTarget::List; - let policy = RadrootsBlossomAuthorizationValidation::bud11(target, server(), NOW); + let target = AuthorizationTarget::List; + let policy = AuthorizationValidation::bud11(target, server(), NOW); assert_eq!(policy.max_created_age_seconds(), None); assert_eq!(policy.max_lifetime_seconds(), None); assert_eq!( policy.server_scope_requirement(), - RadrootsBlossomServerScopeRequirement::OptionalAnyMatch + ServerScopeRequirement::OptionalAnyMatch ); - let claim = RadrootsBlossomParsedAuthorizationClaim::parse( + let claim = AuthorizationClaim::parse( "List archived blobs", NOW - 1_000, &[ @@ -1079,48 +1014,31 @@ mod tests { #[test] fn server_scope_uses_optional_or_required_any_match() { - let target = RadrootsBlossomAuthorizationTarget::Upload(hash()); - let optional = validation( - target, - RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, - ); - let required = validation( - target, - RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, - ); + let target = AuthorizationTarget::Upload(hash()); + let optional = validation(target, ServerScopeRequirement::OptionalAnyMatch); + let required = validation(target, ServerScopeRequirement::RequiredAnyMatch); - let no_server = RadrootsBlossomParsedAuthorizationClaim::parse( - "Upload Blob", - NOW - 10, - &tags("upload", NOW + 60)[..3], - ) - .unwrap(); + let no_server = + AuthorizationClaim::parse("Upload Blob", NOW - 10, &tags("upload", NOW + 60)[..3]) + .unwrap(); assert!(no_server.validate(&optional).is_ok()); assert_eq!( no_server.validate(&required), - Err(RadrootsBlossomError::AuthorizationServerRequired) + Err(Error::AuthorizationServerRequired) ); let mut mismatched_tags = tags("upload", NOW + 60); mismatched_tags[3][1] = "other.example".to_string(); - let mismatch = RadrootsBlossomParsedAuthorizationClaim::parse( - "Upload Blob", - NOW - 10, - &mismatched_tags, - ) - .unwrap(); + let mismatch = + AuthorizationClaim::parse("Upload Blob", NOW - 10, &mismatched_tags).unwrap(); assert_eq!( mismatch.validate(&optional), - Err(RadrootsBlossomError::AuthorizationServerMismatch) + Err(Error::AuthorizationServerMismatch) ); mismatched_tags.push(vec!["server".to_string(), "media.example.com".to_string()]); - let any_match = RadrootsBlossomParsedAuthorizationClaim::parse( - "Upload Blob", - NOW - 10, - &mismatched_tags, - ) - .unwrap(); + let any_match = + AuthorizationClaim::parse("Upload Blob", NOW - 10, &mismatched_tags).unwrap(); assert!(any_match.validate(&required).is_ok()); } @@ -1131,17 +1049,16 @@ mod tests { vec!["t".to_string(), "get".to_string()], vec!["expiration".to_string(), (NOW + 60).to_string()], ]; - let get = RadrootsBlossomParsedAuthorizationClaim::parse("Get Blob", NOW - 10, &no_scope) - .unwrap(); + let get = AuthorizationClaim::parse("Get Blob", NOW - 10, &no_scope).unwrap(); assert!( get.validate(&validation( - RadrootsBlossomAuthorizationTarget::GetBlob(hash), - RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + AuthorizationTarget::GetBlob(hash), + ServerScopeRequirement::OptionalAnyMatch, )) .is_ok() ); - let list = RadrootsBlossomParsedAuthorizationClaim::parse( + let list = AuthorizationClaim::parse( "List Blobs", NOW - 10, &[ @@ -1153,13 +1070,13 @@ mod tests { .unwrap(); assert!( list.validate(&validation( - RadrootsBlossomAuthorizationTarget::List, - RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + AuthorizationTarget::List, + ServerScopeRequirement::OptionalAnyMatch, )) .is_ok() ); - let missing_required = RadrootsBlossomParsedAuthorizationClaim::parse( + let missing_required = AuthorizationClaim::parse( "Upload Blob", NOW - 10, &[ @@ -1170,14 +1087,14 @@ mod tests { .unwrap(); assert_eq!( missing_required.validate(&validation( - RadrootsBlossomAuthorizationTarget::Upload(hash), - RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + AuthorizationTarget::Upload(hash), + ServerScopeRequirement::OptionalAnyMatch, )), - Err(RadrootsBlossomError::AuthorizationHashRequired) + Err(Error::AuthorizationHashRequired) ); - let wrong_hash = RadrootsBlossomSha256::digest(b"wrong"); - let wrong_scope = RadrootsBlossomParsedAuthorizationClaim::parse( + let wrong_hash = Sha256::digest(b"wrong"); + let wrong_scope = AuthorizationClaim::parse( "Get Blob", NOW - 10, &[ @@ -1189,37 +1106,31 @@ mod tests { .unwrap(); assert_eq!( wrong_scope.validate(&validation( - RadrootsBlossomAuthorizationTarget::GetBlob(hash), - RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + AuthorizationTarget::GetBlob(hash), + ServerScopeRequirement::OptionalAnyMatch, )), - Err(RadrootsBlossomError::AuthorizationHashMismatch) + Err(Error::AuthorizationHashMismatch) ); let mut any_tags = tags("delete", NOW + 60); any_tags[2][1] = wrong_hash.to_hex(); any_tags.push(vec!["x".to_string(), HASH_HEX.to_string()]); - let any_match = - RadrootsBlossomParsedAuthorizationClaim::parse("Delete Blob", NOW - 10, &any_tags) - .unwrap(); + let any_match = AuthorizationClaim::parse("Delete Blob", NOW - 10, &any_tags).unwrap(); for target in [ - RadrootsBlossomAuthorizationTarget::DeleteBlob(hash), - RadrootsBlossomAuthorizationTarget::Mirror(hash), - RadrootsBlossomAuthorizationTarget::Media(hash), + AuthorizationTarget::DeleteBlob(hash), + AuthorizationTarget::Mirror(hash), + AuthorizationTarget::Media(hash), ] { let action = target.action(); let mut action_tags = any_tags.clone(); action_tags[0][1] = action.as_str().to_string(); - let parsed = RadrootsBlossomParsedAuthorizationClaim::parse( - "Authorized Operation", - NOW - 10, - &action_tags, - ) - .unwrap(); + let parsed = + AuthorizationClaim::parse("Authorized Operation", NOW - 10, &action_tags).unwrap(); assert!( parsed .validate(&validation( target, - RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + ServerScopeRequirement::RequiredAnyMatch, )) .is_ok() ); @@ -1229,8 +1140,7 @@ mod tests { #[test] fn strict_authored_upload_claim_emits_canonical_wire_parts() { - let claim = - RadrootsBlossomAuthoredUploadClaim::new(content(), server(), hash(), NOW, 60).unwrap(); + let claim = AuthoredUploadClaim::new(content(), server(), hash(), NOW, 60).unwrap(); assert_eq!(claim.content(), &content()); assert_eq!(claim.server_domain(), &server()); assert_eq!(claim.sha256(), hash()); @@ -1255,20 +1165,20 @@ mod tests { fn strict_authored_upload_claim_rejects_bad_lifetime_and_overflow() { for lifetime in [0, 301] { assert_eq!( - RadrootsBlossomAuthoredUploadClaim::new(content(), server(), hash(), NOW, lifetime,), - Err(RadrootsBlossomError::InvalidAuthorizationLifetime) + AuthoredUploadClaim::new(content(), server(), hash(), NOW, lifetime,), + Err(Error::InvalidAuthorizationLifetime) ); } assert_eq!( - RadrootsBlossomAuthoredUploadClaim::new(content(), server(), hash(), u64::MAX, 1,), - Err(RadrootsBlossomError::AuthorizationTimestampOverflow) + AuthoredUploadClaim::new(content(), server(), hash(), u64::MAX, 1,), + Err(Error::AuthorizationTimestampOverflow) ); } #[test] fn validation_enforces_exact_time_boundaries() { let boundary_tags = tags("upload", NOW + 1); - let parsed = RadrootsBlossomParsedAuthorizationClaim::parse( + let parsed = AuthorizationClaim::parse( "Upload Blob", NOW - (RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS - 1), &boundary_tags, @@ -1277,21 +1187,21 @@ mod tests { assert!( parsed .validate(&validation( - RadrootsBlossomAuthorizationTarget::Upload(hash()), - RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + AuthorizationTarget::Upload(hash()), + ServerScopeRequirement::RequiredAnyMatch, )) .is_ok() ); - let zero_age = RadrootsBlossomAuthorizationValidation::new( - RadrootsBlossomAuthorizationTarget::Upload(hash()), + let zero_age = AuthorizationValidation::new( + AuthorizationTarget::Upload(hash()), server(), - RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + ServerScopeRequirement::RequiredAnyMatch, NOW, 0, ) .unwrap(); - let created_now = RadrootsBlossomParsedAuthorizationClaim::parse( + let created_now = AuthorizationClaim::parse( "Upload Blob", NOW, &tags("upload", NOW + RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS), @@ -1299,7 +1209,7 @@ mod tests { .unwrap(); assert_eq!( created_now.validate(&zero_age), - Err(RadrootsBlossomError::AuthorizationCreatedInFuture) + Err(Error::AuthorizationCreatedInFuture) ); } } diff --git a/crates/blossom/src/descriptor.rs b/crates/blossom/src/descriptor.rs @@ -1,105 +1,40 @@ -#[cfg(feature = "serde")] -use alloc::string::String; -use alloc::{collections::BTreeMap, vec::Vec}; -use core::{fmt, str::FromStr}; -use mediatype::{MediaTypeBuf, ReadParams}; +//! BUD-02 descriptors and explicit reference- and byte-verification states. +//! +//! [`BlobDescriptor`] establishes structural descriptor invariants. +//! [`ApprovedDescriptor`] additionally proves the URL passed the Radroots +//! HTTPS-or-loopback policy, and [`ByteVerifiedDescriptor`] proves descriptor +//! agreement with a local [`ByteCommitment`]. None of these states attests that +//! network upload, retrieval, content scanning, or durable storage occurred. use crate::{ - RadrootsBlossomApprovedBlobUrl, RadrootsBlossomBlobUrl, RadrootsBlossomError, - RadrootsBlossomSha256, + Error, MediaType, Sha256, + url::{ApprovedBlobUrl, BlobUrl}, }; const _: () = assert!(usize::BITS <= u64::BITS); -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct RadrootsBlossomMediaType(MediaTypeBuf); - -impl RadrootsBlossomMediaType { - pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { - let parsed = - MediaTypeBuf::from_str(value).map_err(|_| RadrootsBlossomError::InvalidMediaType)?; - if parsed.as_str() != value || parsed.ty().as_str() == "*" || parsed.subty().as_str() == "*" - { - return Err(RadrootsBlossomError::InvalidMediaType); - } - let canonical = parsed.canonicalize(); - let mut unique_params = BTreeMap::new(); - for (name, value) in canonical.params() { - if unique_params.insert(name, value).is_some() { - return Err(RadrootsBlossomError::InvalidMediaType); - } - } - let ordered_params = unique_params.into_iter().collect::<Vec<_>>(); - Ok(Self(MediaTypeBuf::from_parts( - canonical.ty(), - canonical.subty(), - canonical.suffix(), - &ordered_params, - ))) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } -} - -impl fmt::Display for RadrootsBlossomMediaType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl FromStr for RadrootsBlossomMediaType { - type Err = RadrootsBlossomError; - - fn from_str(value: &str) -> Result<Self, Self::Err> { - Self::parse(value) - } -} - -#[cfg(feature = "serde")] -impl serde::Serialize for RadrootsBlossomMediaType { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - serializer.serialize_str(self.as_str()) - } -} - -#[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for RadrootsBlossomMediaType { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::parse(&value).map_err(serde::de::Error::custom) - } -} - #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsBlossomBlobDescriptor { - url: RadrootsBlossomBlobUrl, - sha256: RadrootsBlossomSha256, +pub struct BlobDescriptor { + url: BlobUrl, + sha256: Sha256, size: u64, - media_type: RadrootsBlossomMediaType, + media_type: MediaType, uploaded: u64, } -impl RadrootsBlossomBlobDescriptor { +impl BlobDescriptor { pub fn new( - url: RadrootsBlossomBlobUrl, - sha256: RadrootsBlossomSha256, + url: BlobUrl, + sha256: Sha256, size: u64, - media_type: RadrootsBlossomMediaType, + media_type: MediaType, uploaded: u64, - ) -> Result<Self, RadrootsBlossomError> { + ) -> Result<Self, Error> { if url.hash_path().extension().is_none() { - return Err(RadrootsBlossomError::DescriptorExtensionRequired); + return Err(Error::DescriptorExtensionRequired); } if url.hash_path().hash() != sha256 { - return Err(RadrootsBlossomError::DescriptorHashMismatch); + return Err(Error::DescriptorHashMismatch); } Ok(Self { url, @@ -110,11 +45,11 @@ impl RadrootsBlossomBlobDescriptor { }) } - pub fn url(&self) -> &RadrootsBlossomBlobUrl { + pub fn url(&self) -> &BlobUrl { &self.url } - pub const fn sha256(&self) -> RadrootsBlossomSha256 { + pub const fn sha256(&self) -> Sha256 { self.sha256 } @@ -122,7 +57,7 @@ impl RadrootsBlossomBlobDescriptor { self.size } - pub fn media_type(&self) -> &RadrootsBlossomMediaType { + pub fn media_type(&self) -> &MediaType { &self.media_type } @@ -130,11 +65,9 @@ impl RadrootsBlossomBlobDescriptor { self.uploaded } - pub fn approve_reference( - self, - ) -> Result<RadrootsBlossomApprovedDescriptor, RadrootsBlossomError> { + pub fn approve_reference(self) -> Result<ApprovedDescriptor, Error> { let approved_url = self.url.clone().approve()?; - Ok(RadrootsBlossomApprovedDescriptor { + Ok(ApprovedDescriptor { descriptor: self, approved_url, }) @@ -144,27 +77,27 @@ impl RadrootsBlossomBlobDescriptor { #[cfg(feature = "serde")] #[derive(serde::Serialize)] struct DescriptorRef<'a> { - url: &'a RadrootsBlossomBlobUrl, - sha256: RadrootsBlossomSha256, + url: &'a BlobUrl, + sha256: Sha256, size: u64, #[serde(rename = "type")] - media_type: &'a RadrootsBlossomMediaType, + media_type: &'a MediaType, uploaded: u64, } #[cfg(feature = "serde")] #[derive(serde::Deserialize)] struct DescriptorWire { - url: RadrootsBlossomBlobUrl, - sha256: RadrootsBlossomSha256, + url: BlobUrl, + sha256: Sha256, size: u64, #[serde(rename = "type")] - media_type: RadrootsBlossomMediaType, + media_type: MediaType, uploaded: u64, } #[cfg(feature = "serde")] -impl serde::Serialize for RadrootsBlossomBlobDescriptor { +impl serde::Serialize for BlobDescriptor { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::Serializer, @@ -181,7 +114,7 @@ impl serde::Serialize for RadrootsBlossomBlobDescriptor { } #[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for RadrootsBlossomBlobDescriptor { +impl<'de> serde::Deserialize<'de> for BlobDescriptor { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: serde::Deserializer<'de>, @@ -199,28 +132,28 @@ impl<'de> serde::Deserialize<'de> for RadrootsBlossomBlobDescriptor { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsBlossomApprovedDescriptor { - descriptor: RadrootsBlossomBlobDescriptor, - approved_url: RadrootsBlossomApprovedBlobUrl, +pub struct ApprovedDescriptor { + descriptor: BlobDescriptor, + approved_url: ApprovedBlobUrl, } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsBlossomByteCommitment { - sha256: RadrootsBlossomSha256, +pub struct ByteCommitment { + sha256: Sha256, size: u64, - media_type: RadrootsBlossomMediaType, + media_type: MediaType, } -impl RadrootsBlossomByteCommitment { - pub fn from_bytes(bytes: &[u8], media_type: RadrootsBlossomMediaType) -> Self { +impl ByteCommitment { + pub fn from_bytes(bytes: &[u8], media_type: MediaType) -> Self { Self { - sha256: RadrootsBlossomSha256::digest(bytes), + sha256: Sha256::digest(bytes), size: bytes.len() as u64, media_type, } } - pub const fn sha256(&self) -> RadrootsBlossomSha256 { + pub const fn sha256(&self) -> Sha256 { self.sha256 } @@ -228,70 +161,88 @@ impl RadrootsBlossomByteCommitment { self.size } - pub fn media_type(&self) -> &RadrootsBlossomMediaType { + pub fn media_type(&self) -> &MediaType { &self.media_type } } -impl RadrootsBlossomApprovedDescriptor { - pub fn descriptor(&self) -> &RadrootsBlossomBlobDescriptor { +impl ApprovedDescriptor { + pub fn descriptor(&self) -> &BlobDescriptor { &self.descriptor } - pub fn url(&self) -> &RadrootsBlossomApprovedBlobUrl { + pub fn url(&self) -> &ApprovedBlobUrl { &self.approved_url } - pub fn into_descriptor(self) -> RadrootsBlossomBlobDescriptor { + pub fn into_descriptor(self) -> BlobDescriptor { self.descriptor } pub fn verify_bytes( self, bytes: &[u8], - approved_media_type: &RadrootsBlossomMediaType, - ) -> Result<RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomError> { - let commitment = - RadrootsBlossomByteCommitment::from_bytes(bytes, approved_media_type.clone()); + approved_media_type: &MediaType, + ) -> Result<ByteVerifiedDescriptor, Error> { + let commitment = ByteCommitment::from_bytes(bytes, approved_media_type.clone()); self.verify_commitment(&commitment) } pub fn verify_commitment( self, - commitment: &RadrootsBlossomByteCommitment, - ) -> Result<RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomError> { + commitment: &ByteCommitment, + ) -> Result<ByteVerifiedDescriptor, Error> { if self.descriptor.size != commitment.size { - return Err(RadrootsBlossomError::BlobSizeMismatch { + return Err(Error::BlobSizeMismatch { expected: self.descriptor.size, actual: commitment.size, }); } if self.descriptor.media_type != commitment.media_type { - return Err(RadrootsBlossomError::BlobMediaTypeMismatch); + return Err(Error::BlobMediaTypeMismatch); } if self.descriptor.sha256 != commitment.sha256 { - return Err(RadrootsBlossomError::BlobHashMismatch); + return Err(Error::BlobHashMismatch); } - Ok(RadrootsBlossomByteVerifiedDescriptor(self)) + Ok(ByteVerifiedDescriptor(self)) } } /// An approved descriptor whose hash, size, and media type match supplied bytes. /// /// This state does not attest that a network upload occurred. +/// Its private representation prevents callers from forging the typestate: +/// +/// ```compile_fail +/// use radroots_blossom::{ByteVerifiedDescriptor, descriptor::ApprovedDescriptor}; +/// +/// fn forge(approved: ApprovedDescriptor) -> ByteVerifiedDescriptor { +/// ByteVerifiedDescriptor(approved) +/// } +/// ``` +/// +/// Construction is only available through descriptor verification: +/// +/// ```compile_fail +/// use radroots_blossom::{ByteVerifiedDescriptor, descriptor::ApprovedDescriptor}; +/// +/// fn bypass_verification(approved: ApprovedDescriptor) -> ByteVerifiedDescriptor { +/// ByteVerifiedDescriptor::new(approved) +/// } +/// ``` #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsBlossomByteVerifiedDescriptor(RadrootsBlossomApprovedDescriptor); +pub struct ByteVerifiedDescriptor(ApprovedDescriptor); -impl RadrootsBlossomByteVerifiedDescriptor { - pub fn descriptor(&self) -> &RadrootsBlossomBlobDescriptor { +impl ByteVerifiedDescriptor { + pub fn descriptor(&self) -> &BlobDescriptor { self.0.descriptor() } - pub fn url(&self) -> &RadrootsBlossomApprovedBlobUrl { + pub fn url(&self) -> &ApprovedBlobUrl { self.0.url() } - pub const fn sha256(&self) -> RadrootsBlossomSha256 { + pub const fn sha256(&self) -> Sha256 { self.0.descriptor.sha256 } @@ -299,11 +250,11 @@ impl RadrootsBlossomByteVerifiedDescriptor { self.0.descriptor.size } - pub fn media_type(&self) -> &RadrootsBlossomMediaType { + pub fn media_type(&self) -> &MediaType { &self.0.descriptor.media_type } - pub fn into_descriptor(self) -> RadrootsBlossomBlobDescriptor { + pub fn into_descriptor(self) -> BlobDescriptor { self.0.into_descriptor() } } @@ -312,16 +263,17 @@ impl RadrootsBlossomByteVerifiedDescriptor { mod tests { use super::*; use alloc::{format, string::ToString}; + use core::str::FromStr; const HELLO_HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; - fn descriptor(origin: &str, bytes: &[u8], media_type: &str) -> RadrootsBlossomBlobDescriptor { - let hash = RadrootsBlossomSha256::digest(bytes); - RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&format!("{origin}/{hash}.txt")).unwrap(), + fn descriptor(origin: &str, bytes: &[u8], media_type: &str) -> BlobDescriptor { + let hash = Sha256::digest(bytes); + BlobDescriptor::new( + BlobUrl::parse(&format!("{origin}/{hash}.txt")).unwrap(), hash, u64::try_from(bytes.len()).unwrap(), - RadrootsBlossomMediaType::parse(media_type).unwrap(), + MediaType::parse(media_type).unwrap(), 1_725_105_921, ) .unwrap() @@ -329,19 +281,24 @@ mod tests { #[test] fn media_type_canonicalizes_parameters_and_compares_case_insensitively() { - let lower = - RadrootsBlossomMediaType::parse("image/svg+xml; profile=web; charset=UTF-8").unwrap(); - let upper = RadrootsBlossomMediaType::from_str("IMAGE/SVG+XML; CHARSET=UTF-8; PROFILE=web") - .unwrap(); + let lower = MediaType::parse("image/svg+xml; profile=web; charset=UTF-8").unwrap(); + let upper = MediaType::from_str("IMAGE/SVG+XML; CHARSET=UTF-8; PROFILE=web").unwrap(); assert_eq!(lower, upper); assert_eq!(lower.as_str(), "image/svg+xml; charset=UTF-8; profile=web"); assert_eq!(upper.as_str(), lower.as_str()); assert_eq!(lower.to_string(), lower.as_str()); - let json = serde_json::to_string(&lower).unwrap(); + } + + #[cfg(feature = "serde")] + #[test] + fn media_type_serde_round_trips_and_revalidates() { + let media_type = MediaType::parse("image/svg+xml; charset=UTF-8").unwrap(); + let json = serde_json::to_string(&media_type).unwrap(); assert_eq!( - serde_json::from_str::<RadrootsBlossomMediaType>(&json).unwrap(), - lower + serde_json::from_str::<MediaType>(&json).unwrap(), + media_type ); + assert!(serde_json::from_str::<MediaType>("42").is_err()); } #[test] @@ -357,41 +314,35 @@ mod tests { "image/png ", "image/png\n", ] { - assert_eq!( - RadrootsBlossomMediaType::parse(value), - Err(RadrootsBlossomError::InvalidMediaType) - ); + assert_eq!(MediaType::parse(value), Err(Error::InvalidMediaType)); } - assert!(serde_json::from_str::<RadrootsBlossomMediaType>("42").is_err()); } #[test] fn descriptor_requires_extension_and_matching_url_hash() { - let hash = RadrootsBlossomSha256::from_hex(HELLO_HASH).unwrap(); - let media_type = RadrootsBlossomMediaType::parse("text/plain").unwrap(); + let hash = Sha256::from_hex(HELLO_HASH).unwrap(); + let media_type = MediaType::parse("text/plain").unwrap(); let no_extension = - RadrootsBlossomBlobUrl::parse(&format!("https://cdn.example.com/{HELLO_HASH}")) - .unwrap(); + BlobUrl::parse(&format!("https://cdn.example.com/{HELLO_HASH}")).unwrap(); assert_eq!( - RadrootsBlossomBlobDescriptor::new(no_extension, hash, 5, media_type.clone(), 1), - Err(RadrootsBlossomError::DescriptorExtensionRequired) + BlobDescriptor::new(no_extension, hash, 5, media_type.clone(), 1), + Err(Error::DescriptorExtensionRequired) ); - let wrong_hash = RadrootsBlossomSha256::digest(b"wrong"); - let url = - RadrootsBlossomBlobUrl::parse(&format!("https://cdn.example.com/{HELLO_HASH}.txt")) - .unwrap(); + let wrong_hash = Sha256::digest(b"wrong"); + let url = BlobUrl::parse(&format!("https://cdn.example.com/{HELLO_HASH}.txt")).unwrap(); assert_eq!( - RadrootsBlossomBlobDescriptor::new(url, wrong_hash, 5, media_type, 1), - Err(RadrootsBlossomError::DescriptorHashMismatch) + BlobDescriptor::new(url, wrong_hash, 5, media_type, 1), + Err(Error::DescriptorHashMismatch) ); } + #[cfg(feature = "serde")] #[test] fn descriptor_serde_roundtrip_tolerates_extension_fields() { let raw = format!( r#"{{"url":"https://cdn.example.com/{HELLO_HASH}.txt","sha256":"{HELLO_HASH}","size":5,"type":"text/plain","uploaded":1725105921,"magnet":"magnet:?xt=urn:test"}}"# ); - let parsed: RadrootsBlossomBlobDescriptor = serde_json::from_str(&raw).unwrap(); + let parsed: BlobDescriptor = serde_json::from_str(&raw).unwrap(); assert_eq!(parsed.url().hash_path().hash().to_string(), HELLO_HASH); assert_eq!(parsed.sha256().to_string(), HELLO_HASH); assert_eq!(parsed.size(), 5); @@ -402,19 +353,20 @@ mod tests { assert!(encoded.get("magnet").is_none()); } + #[cfg(feature = "serde")] #[test] fn descriptor_deserialize_revalidates_invariants() { let wrong_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; let raw = format!( r#"{{"url":"https://cdn.example.com/{HELLO_HASH}.txt","sha256":"{wrong_hash}","size":5,"type":"text/plain","uploaded":1}}"# ); - assert!(serde_json::from_str::<RadrootsBlossomBlobDescriptor>(&raw).is_err()); + assert!(serde_json::from_str::<BlobDescriptor>(&raw).is_err()); } #[test] fn byte_verified_descriptor_requires_approved_url_size_type_and_hash() { - let media_type = RadrootsBlossomMediaType::parse("text/plain").unwrap(); - let commitment = RadrootsBlossomByteCommitment::from_bytes(b"hello", media_type.clone()); + let media_type = MediaType::parse("text/plain").unwrap(); + let commitment = ByteCommitment::from_bytes(b"hello", media_type.clone()); assert_eq!(commitment.sha256().to_string(), HELLO_HASH); assert_eq!(commitment.size(), 5); assert_eq!(commitment.media_type(), &media_type); @@ -439,26 +391,26 @@ mod tests { .approve_reference() .unwrap() .verify_bytes(b"hell", &media_type), - Err(RadrootsBlossomError::BlobSizeMismatch { + Err(Error::BlobSizeMismatch { expected: 5, actual: 4, }) ); - let image_type = RadrootsBlossomMediaType::parse("image/png").unwrap(); + let image_type = MediaType::parse("image/png").unwrap(); assert_eq!( descriptor("https://cdn.example.com", b"hello", "text/plain") .approve_reference() .unwrap() .verify_bytes(b"hello", &image_type), - Err(RadrootsBlossomError::BlobMediaTypeMismatch) + Err(Error::BlobMediaTypeMismatch) ); - let hash_mismatch = RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&format!( + let hash_mismatch = BlobDescriptor::new( + BlobUrl::parse(&format!( "https://cdn.example.com/{}.txt", - RadrootsBlossomSha256::digest(b"world") + Sha256::digest(b"world") )) .unwrap(), - RadrootsBlossomSha256::digest(b"world"), + Sha256::digest(b"world"), 5, media_type.clone(), 1, @@ -468,13 +420,13 @@ mod tests { .unwrap(); assert_eq!( hash_mismatch.verify_bytes(b"hello", &media_type), - Err(RadrootsBlossomError::BlobHashMismatch) + Err(Error::BlobHashMismatch) ); } #[test] fn empty_blob_can_be_verified_with_explicit_default_media_type() { - let media_type = RadrootsBlossomMediaType::parse("application/octet-stream").unwrap(); + let media_type = MediaType::parse("application/octet-stream").unwrap(); let verified = descriptor("http://localhost:3000", b"", "application/octet-stream") .approve_reference() .unwrap() @@ -487,7 +439,7 @@ mod tests { fn public_http_descriptor_cannot_advance_to_approved() { assert_eq!( descriptor("http://cdn.example.com", b"hello", "text/plain").approve_reference(), - Err(RadrootsBlossomError::InsecureBlobUrl) + Err(Error::InsecureBlobUrl) ); } } diff --git a/crates/blossom/src/error.rs b/crates/blossom/src/error.rs @@ -2,7 +2,7 @@ use core::fmt; #[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] -pub enum RadrootsBlossomError { +pub enum Error { InvalidSha256, InvalidFileExtension, InvalidHashPath, @@ -86,7 +86,7 @@ pub enum RadrootsBlossomError { PublicationReadinessEvidenceSerialization, } -impl RadrootsBlossomError { +impl Error { pub const fn code(&self) -> &'static str { match self { Self::InvalidSha256 => "invalid_sha256", @@ -214,7 +214,7 @@ impl RadrootsBlossomError { } } -impl fmt::Display for RadrootsBlossomError { +impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidSha256 => { @@ -466,250 +466,334 @@ impl fmt::Display for RadrootsBlossomError { } #[cfg(feature = "std")] -impl std::error::Error for RadrootsBlossomError {} +impl std::error::Error for Error {} #[cfg(test)] mod tests { - use super::RadrootsBlossomError; + use super::Error; use alloc::format; #[test] fn error_codes_and_messages_are_stable() { let errors = [ - RadrootsBlossomError::InvalidSha256, - RadrootsBlossomError::InvalidFileExtension, - RadrootsBlossomError::InvalidHashPath, - RadrootsBlossomError::InvalidBlobUrl, - RadrootsBlossomError::UnsupportedBlobUrlScheme, - RadrootsBlossomError::BlobUrlCredentialsForbidden, - RadrootsBlossomError::BlobUrlQueryForbidden, - RadrootsBlossomError::BlobUrlFragmentForbidden, - RadrootsBlossomError::InsecureBlobUrl, - RadrootsBlossomError::DescriptorExtensionRequired, - RadrootsBlossomError::DescriptorHashMismatch, - RadrootsBlossomError::InvalidMediaType, - RadrootsBlossomError::BlobHashMismatch, - RadrootsBlossomError::BlobSizeMismatch { + Error::InvalidSha256, + Error::InvalidFileExtension, + Error::InvalidHashPath, + Error::InvalidBlobUrl, + Error::UnsupportedBlobUrlScheme, + Error::BlobUrlCredentialsForbidden, + Error::BlobUrlQueryForbidden, + Error::BlobUrlFragmentForbidden, + Error::InsecureBlobUrl, + Error::DescriptorExtensionRequired, + Error::DescriptorHashMismatch, + Error::InvalidMediaType, + Error::BlobHashMismatch, + Error::BlobSizeMismatch { expected: 1, actual: 2, }, - RadrootsBlossomError::BlobMediaTypeMismatch, - RadrootsBlossomError::InvalidBud02UploadStatus { actual: 202 }, - RadrootsBlossomError::InvalidBud01HeadStatus { actual: 204 }, - RadrootsBlossomError::InvalidBud01GetStatus { actual: 206 }, - RadrootsBlossomError::PublicationReadinessUrlTooLarge { max: 1, actual: 2 }, - RadrootsBlossomError::PublicationRasterEmpty, - RadrootsBlossomError::PublicationRasterByteLimitExceeded { + Error::BlobMediaTypeMismatch, + Error::InvalidBud02UploadStatus { actual: 202 }, + Error::InvalidBud01HeadStatus { actual: 204 }, + Error::InvalidBud01GetStatus { actual: 206 }, + Error::PublicationReadinessUrlTooLarge { max: 1, actual: 2 }, + Error::PublicationRasterEmpty, + Error::PublicationRasterByteLimitExceeded { declared: 2, maximum: 1, }, - RadrootsBlossomError::PublicationGetBodyAllocationFailed, - RadrootsBlossomError::PublicationGetBodyLengthOverflow, - RadrootsBlossomError::PublicationGetBodyMissing, - RadrootsBlossomError::PublicationGetBodyShort { + Error::PublicationGetBodyAllocationFailed, + Error::PublicationGetBodyLengthOverflow, + Error::PublicationGetBodyMissing, + Error::PublicationGetBodyShort { declared: 2, actual: 1, }, - RadrootsBlossomError::PublicationGetBodyTrailing { + Error::PublicationGetBodyTrailing { declared: 1, actual: 2, }, - RadrootsBlossomError::PublicationAuthoredBytesSizeMismatch { + Error::PublicationAuthoredBytesSizeMismatch { expected: 1, actual: 2, }, - RadrootsBlossomError::PublicationAuthoredBytesHashMismatch, - RadrootsBlossomError::PublicationUploadUrlMismatch, - RadrootsBlossomError::PublicationUploadHashMismatch, - RadrootsBlossomError::PublicationUploadSizeMismatch { + Error::PublicationAuthoredBytesHashMismatch, + Error::PublicationUploadUrlMismatch, + Error::PublicationUploadHashMismatch, + Error::PublicationUploadSizeMismatch { expected: 1, actual: 2, }, - RadrootsBlossomError::PublicationUploadMediaTypeMismatch, - RadrootsBlossomError::PublicationHeadUrlMismatch, - RadrootsBlossomError::PublicationHeadSizeMismatch { + Error::PublicationUploadMediaTypeMismatch, + Error::PublicationHeadUrlMismatch, + Error::PublicationHeadSizeMismatch { expected: 1, actual: 2, }, - RadrootsBlossomError::PublicationHeadMediaTypeMismatch, - RadrootsBlossomError::PublicationGetUrlMismatch, - RadrootsBlossomError::PublicationGetDeclaredSizeMismatch { + Error::PublicationHeadMediaTypeMismatch, + Error::PublicationGetUrlMismatch, + Error::PublicationGetDeclaredSizeMismatch { expected: 1, actual: 2, }, - RadrootsBlossomError::PublicationRetrievedBytesHashMismatch, - RadrootsBlossomError::PublicationRetrievedBytesMismatch, - RadrootsBlossomError::UnsupportedPublicationRasterMediaType, - RadrootsBlossomError::InvalidPublicationRaster, - RadrootsBlossomError::PublicationJpegProcessForbidden, - RadrootsBlossomError::PublicationRasterProcessForbidden, - RadrootsBlossomError::PublicationRasterAnimationForbidden, - RadrootsBlossomError::PublicationRasterDimensionsOutOfRange { + Error::PublicationRetrievedBytesHashMismatch, + Error::PublicationRetrievedBytesMismatch, + Error::UnsupportedPublicationRasterMediaType, + Error::InvalidPublicationRaster, + Error::PublicationJpegProcessForbidden, + Error::PublicationRasterProcessForbidden, + Error::PublicationRasterAnimationForbidden, + Error::PublicationRasterDimensionsOutOfRange { width: 0, height: 1, }, - RadrootsBlossomError::PublicationRasterPixelLimitExceeded { pixels: 20_000_001 }, - RadrootsBlossomError::PublicationRasterDecodedByteLimitExceeded { + Error::PublicationRasterPixelLimitExceeded { pixels: 20_000_001 }, + Error::PublicationRasterDecodedByteLimitExceeded { decoded: 2, maximum: 1, }, - RadrootsBlossomError::PublicationRasterDecodeAllocationFailed, - RadrootsBlossomError::PublicationRasterDecodeFailed, - RadrootsBlossomError::PublicationRasterContainerDimensionMismatch, - RadrootsBlossomError::PublicationAuthoredRasterDimensionMismatch, - RadrootsBlossomError::PublicationReadinessEvidenceTooLarge { max: 1, actual: 2 }, - RadrootsBlossomError::PublicationReadinessEvidenceInvalidJson, - RadrootsBlossomError::PublicationReadinessEvidenceUnsupportedSchemaVersion { + Error::PublicationRasterDecodeAllocationFailed, + Error::PublicationRasterDecodeFailed, + Error::PublicationRasterContainerDimensionMismatch, + Error::PublicationAuthoredRasterDimensionMismatch, + Error::PublicationReadinessEvidenceTooLarge { max: 1, actual: 2 }, + Error::PublicationReadinessEvidenceInvalidJson, + Error::PublicationReadinessEvidenceUnsupportedSchemaVersion { expected: 1, actual: 2, }, - RadrootsBlossomError::PublicationReadinessEvidenceUnsupportedPolicyVersion { + Error::PublicationReadinessEvidenceUnsupportedPolicyVersion { expected: 1, actual: 2, }, - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "url" }, - RadrootsBlossomError::PublicationReadinessEvidenceDigestMismatch, - RadrootsBlossomError::PublicationReadinessEvidenceNonCanonicalJson, - RadrootsBlossomError::PublicationReadinessEvidenceSerialization, - RadrootsBlossomError::InvalidAuthorizationContent, - RadrootsBlossomError::InvalidAuthorizationAction, - RadrootsBlossomError::InvalidAuthorizationServerDomain, - RadrootsBlossomError::MissingAuthorizationActionTag, - RadrootsBlossomError::DuplicateAuthorizationActionTag, - RadrootsBlossomError::MalformedAuthorizationActionTag, - RadrootsBlossomError::MissingAuthorizationExpirationTag, - RadrootsBlossomError::DuplicateAuthorizationExpirationTag, - RadrootsBlossomError::MalformedAuthorizationExpirationTag, - RadrootsBlossomError::MalformedAuthorizationServerTag, - RadrootsBlossomError::MalformedAuthorizationHashTag, - RadrootsBlossomError::InvalidAuthorizationCreatedAge, - RadrootsBlossomError::InvalidAuthorizationLifetime, - RadrootsBlossomError::AuthorizationTimestampOverflow, - RadrootsBlossomError::AuthorizationCreatedInFuture, - RadrootsBlossomError::AuthorizationStale, - RadrootsBlossomError::AuthorizationExpired, - RadrootsBlossomError::AuthorizationActionMismatch, - RadrootsBlossomError::AuthorizationServerRequired, - RadrootsBlossomError::AuthorizationServerMismatch, - RadrootsBlossomError::AuthorizationHashRequired, - RadrootsBlossomError::AuthorizationHashMismatch, + Error::PublicationReadinessEvidenceInvalidField { field: "url" }, + Error::PublicationReadinessEvidenceDigestMismatch, + Error::PublicationReadinessEvidenceNonCanonicalJson, + Error::PublicationReadinessEvidenceSerialization, + Error::InvalidAuthorizationContent, + Error::InvalidAuthorizationAction, + Error::InvalidAuthorizationServerDomain, + Error::MissingAuthorizationActionTag, + Error::DuplicateAuthorizationActionTag, + Error::MalformedAuthorizationActionTag, + Error::MissingAuthorizationExpirationTag, + Error::DuplicateAuthorizationExpirationTag, + Error::MalformedAuthorizationExpirationTag, + Error::MalformedAuthorizationServerTag, + Error::MalformedAuthorizationHashTag, + Error::InvalidAuthorizationCreatedAge, + Error::InvalidAuthorizationLifetime, + Error::AuthorizationTimestampOverflow, + Error::AuthorizationCreatedInFuture, + Error::AuthorizationStale, + Error::AuthorizationExpired, + Error::AuthorizationActionMismatch, + Error::AuthorizationServerRequired, + Error::AuthorizationServerMismatch, + Error::AuthorizationHashRequired, + Error::AuthorizationHashMismatch, ]; for error in errors { assert!(!error.code().is_empty()); assert!(!format!("{error}").is_empty()); } + let cases = [ + ( + Error::InvalidSha256, + "invalid_sha256", + "sha256 must be 64 lowercase hexadecimal characters", + ), + ( + Error::InvalidFileExtension, + "invalid_file_extension", + "invalid Blossom file extension", + ), + ( + Error::InvalidHashPath, + "invalid_hash_path", + "invalid Blossom root hash path", + ), + ( + Error::InvalidBlobUrl, + "invalid_blob_url", + "invalid Blossom blob URL", + ), + ( + Error::UnsupportedBlobUrlScheme, + "unsupported_blob_url_scheme", + "Blossom blob URL scheme must be http or https", + ), + ( + Error::BlobUrlCredentialsForbidden, + "blob_url_credentials_forbidden", + "Blossom blob URL credentials are forbidden", + ), + ( + Error::BlobUrlQueryForbidden, + "blob_url_query_forbidden", + "Blossom blob URL query is forbidden", + ), + ( + Error::BlobUrlFragmentForbidden, + "blob_url_fragment_forbidden", + "Blossom blob URL fragment is forbidden", + ), + ( + Error::InsecureBlobUrl, + "insecure_blob_url", + "Radroots blob references require HTTPS or loopback HTTP", + ), + ( + Error::DescriptorExtensionRequired, + "descriptor_extension_required", + "BUD-02 descriptor URL requires a file extension", + ), + ( + Error::DescriptorHashMismatch, + "descriptor_hash_mismatch", + "descriptor URL hash does not match descriptor sha256", + ), + ( + Error::InvalidMediaType, + "invalid_media_type", + "invalid media type", + ), + ( + Error::BlobHashMismatch, + "blob_hash_mismatch", + "blob bytes do not match descriptor sha256", + ), + ( + Error::BlobSizeMismatch { + expected: 1, + actual: 2, + }, + "blob_size_mismatch", + "blob size mismatch: expected 1, got 2", + ), + ( + Error::BlobMediaTypeMismatch, + "blob_media_type_mismatch", + "descriptor media type does not match the approved media type", + ), + ]; + for (error, code, message) in cases { + assert_eq!(error.code(), code); + assert_eq!(format!("{error}"), message); + } } #[test] fn authorization_error_codes_and_messages_are_stable() { let cases = [ ( - RadrootsBlossomError::InvalidAuthorizationContent, + Error::InvalidAuthorizationContent, "invalid_authorization_content", "Blossom authorization content must be bounded human-readable text", ), ( - RadrootsBlossomError::InvalidAuthorizationAction, + Error::InvalidAuthorizationAction, "invalid_authorization_action", "invalid Blossom authorization action", ), ( - RadrootsBlossomError::InvalidAuthorizationServerDomain, + Error::InvalidAuthorizationServerDomain, "invalid_authorization_server_domain", "invalid Blossom authorization server domain", ), ( - RadrootsBlossomError::MissingAuthorizationActionTag, + Error::MissingAuthorizationActionTag, "missing_authorization_action_tag", "Blossom authorization is missing a t action tag", ), ( - RadrootsBlossomError::DuplicateAuthorizationActionTag, + Error::DuplicateAuthorizationActionTag, "duplicate_authorization_action_tag", "Blossom authorization has more than one t action tag", ), ( - RadrootsBlossomError::MalformedAuthorizationActionTag, + Error::MalformedAuthorizationActionTag, "malformed_authorization_action_tag", "malformed Blossom authorization t action tag", ), ( - RadrootsBlossomError::MissingAuthorizationExpirationTag, + Error::MissingAuthorizationExpirationTag, "missing_authorization_expiration_tag", "Blossom authorization is missing an expiration tag", ), ( - RadrootsBlossomError::DuplicateAuthorizationExpirationTag, + Error::DuplicateAuthorizationExpirationTag, "duplicate_authorization_expiration_tag", "Blossom authorization has more than one expiration tag", ), ( - RadrootsBlossomError::MalformedAuthorizationExpirationTag, + Error::MalformedAuthorizationExpirationTag, "malformed_authorization_expiration_tag", "malformed Blossom authorization expiration tag", ), ( - RadrootsBlossomError::MalformedAuthorizationServerTag, + Error::MalformedAuthorizationServerTag, "malformed_authorization_server_tag", "malformed Blossom authorization server tag", ), ( - RadrootsBlossomError::MalformedAuthorizationHashTag, + Error::MalformedAuthorizationHashTag, "malformed_authorization_hash_tag", "malformed Blossom authorization x hash tag", ), ( - RadrootsBlossomError::InvalidAuthorizationCreatedAge, + Error::InvalidAuthorizationCreatedAge, "invalid_authorization_created_age", "Blossom authorization maximum created age must not exceed 300 seconds", ), ( - RadrootsBlossomError::InvalidAuthorizationLifetime, + Error::InvalidAuthorizationLifetime, "invalid_authorization_lifetime", "Blossom authorization lifetime must be between 1 and 300 seconds", ), ( - RadrootsBlossomError::AuthorizationTimestampOverflow, + Error::AuthorizationTimestampOverflow, "authorization_timestamp_overflow", "Blossom authorization expiration timestamp overflows u64", ), ( - RadrootsBlossomError::AuthorizationCreatedInFuture, + Error::AuthorizationCreatedInFuture, "authorization_created_in_future", "Blossom authorization must be created in the past", ), ( - RadrootsBlossomError::AuthorizationStale, + Error::AuthorizationStale, "authorization_stale", "Blossom authorization is outside the accepted creation-age window", ), ( - RadrootsBlossomError::AuthorizationExpired, + Error::AuthorizationExpired, "authorization_expired", "Blossom authorization is expired", ), ( - RadrootsBlossomError::AuthorizationActionMismatch, + Error::AuthorizationActionMismatch, "authorization_action_mismatch", "Blossom authorization action does not match the target endpoint", ), ( - RadrootsBlossomError::AuthorizationServerRequired, + Error::AuthorizationServerRequired, "authorization_server_required", "Blossom authorization requires a server scope", ), ( - RadrootsBlossomError::AuthorizationServerMismatch, + Error::AuthorizationServerMismatch, "authorization_server_mismatch", "Blossom authorization does not include the target server", ), ( - RadrootsBlossomError::AuthorizationHashRequired, + Error::AuthorizationHashRequired, "authorization_hash_required", "Blossom authorization requires an x hash scope", ), ( - RadrootsBlossomError::AuthorizationHashMismatch, + Error::AuthorizationHashMismatch, "authorization_hash_mismatch", "Blossom authorization does not include the target blob hash", ), diff --git a/crates/blossom/src/hash.rs b/crates/blossom/src/hash.rs @@ -1,31 +1,38 @@ +//! Canonical SHA-256 values, safe file extensions, and root hash paths. +//! +//! [`Sha256`] hashes or parses exact lowercase hexadecimal values. [`HashPath`] +//! models the single root path segment used by Blossom and rejects traversal, +//! escaping, queries, and fragments. These values identify bytes; they do not +//! establish origin, authenticity, or content safety. + use alloc::string::{String, ToString}; use core::{fmt, str::FromStr}; -use sha2::{Digest, Sha256}; +use sha2::{Digest, Sha256 as Sha256Hasher}; -use crate::error::RadrootsBlossomError; +use crate::error::Error; const SHA256_BYTES: usize = 32; const SHA256_HEX_LENGTH: usize = SHA256_BYTES * 2; const LOWER_HEX: &[u8; 16] = b"0123456789abcdef"; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct RadrootsBlossomSha256([u8; SHA256_BYTES]); +pub struct Sha256([u8; SHA256_BYTES]); -impl RadrootsBlossomSha256 { +impl Sha256 { pub const fn from_bytes(bytes: [u8; SHA256_BYTES]) -> Self { Self(bytes) } pub fn digest(bytes: &[u8]) -> Self { - let digest = Sha256::digest(bytes); + let digest = Sha256Hasher::digest(bytes); let mut value = [0_u8; SHA256_BYTES]; value.copy_from_slice(&digest); Self(value) } - pub fn from_hex(value: &str) -> Result<Self, RadrootsBlossomError> { + pub fn from_hex(value: &str) -> Result<Self, Error> { if value.len() != SHA256_HEX_LENGTH { - return Err(RadrootsBlossomError::InvalidSha256); + return Err(Error::InvalidSha256); } let bytes = value.as_bytes(); @@ -52,14 +59,14 @@ impl RadrootsBlossomSha256 { } } -impl fmt::Display for RadrootsBlossomSha256 { +impl fmt::Display for Sha256 { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.to_hex()) } } -impl FromStr for RadrootsBlossomSha256 { - type Err = RadrootsBlossomError; +impl FromStr for Sha256 { + type Err = Error; fn from_str(value: &str) -> Result<Self, Self::Err> { Self::from_hex(value) @@ -67,7 +74,7 @@ impl FromStr for RadrootsBlossomSha256 { } #[cfg(feature = "serde")] -impl serde::Serialize for RadrootsBlossomSha256 { +impl serde::Serialize for Sha256 { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::Serializer, @@ -77,7 +84,7 @@ impl serde::Serialize for RadrootsBlossomSha256 { } #[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for RadrootsBlossomSha256 { +impl<'de> serde::Deserialize<'de> for Sha256 { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: serde::Deserializer<'de>, @@ -88,17 +95,17 @@ impl<'de> serde::Deserialize<'de> for RadrootsBlossomSha256 { } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct RadrootsBlossomFileExtension(String); +pub struct FileExtension(String); -impl RadrootsBlossomFileExtension { - pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { +impl FileExtension { + pub fn parse(value: &str) -> Result<Self, Error> { if value.is_empty() || value.split('.').any(str::is_empty) || !value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) { - return Err(RadrootsBlossomError::InvalidFileExtension); + return Err(Error::InvalidFileExtension); } Ok(Self(value.to_string())) } @@ -108,14 +115,14 @@ impl RadrootsBlossomFileExtension { } } -impl fmt::Display for RadrootsBlossomFileExtension { +impl fmt::Display for FileExtension { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } -impl FromStr for RadrootsBlossomFileExtension { - type Err = RadrootsBlossomError; +impl FromStr for FileExtension { + type Err = Error; fn from_str(value: &str) -> Result<Self, Self::Err> { Self::parse(value) @@ -123,7 +130,7 @@ impl FromStr for RadrootsBlossomFileExtension { } #[cfg(feature = "serde")] -impl serde::Serialize for RadrootsBlossomFileExtension { +impl serde::Serialize for FileExtension { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::Serializer, @@ -133,7 +140,7 @@ impl serde::Serialize for RadrootsBlossomFileExtension { } #[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for RadrootsBlossomFileExtension { +impl<'de> serde::Deserialize<'de> for FileExtension { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: serde::Deserializer<'de>, @@ -144,53 +151,46 @@ impl<'de> serde::Deserialize<'de> for RadrootsBlossomFileExtension { } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct RadrootsBlossomHashPath { - hash: RadrootsBlossomSha256, - extension: Option<RadrootsBlossomFileExtension>, +pub struct HashPath { + hash: Sha256, + extension: Option<FileExtension>, } -impl RadrootsBlossomHashPath { - pub fn new( - hash: RadrootsBlossomSha256, - extension: Option<RadrootsBlossomFileExtension>, - ) -> Self { +impl HashPath { + pub fn new(hash: Sha256, extension: Option<FileExtension>) -> Self { Self { hash, extension } } - pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { - let segment = value - .strip_prefix('/') - .ok_or(RadrootsBlossomError::InvalidHashPath)?; + pub fn parse(value: &str) -> Result<Self, Error> { + let segment = value.strip_prefix('/').ok_or(Error::InvalidHashPath)?; if segment.contains('/') || segment.contains('\\') || segment.contains('%') || segment.contains('?') || segment.contains('#') { - return Err(RadrootsBlossomError::InvalidHashPath); + return Err(Error::InvalidHashPath); } let hash = segment .get(..SHA256_HEX_LENGTH) - .ok_or(RadrootsBlossomError::InvalidHashPath) - .and_then(RadrootsBlossomSha256::from_hex)?; + .ok_or(Error::InvalidHashPath) + .and_then(Sha256::from_hex)?; let suffix = &segment[SHA256_HEX_LENGTH..]; let extension = if suffix.is_empty() { None } else { - let value = suffix - .strip_prefix('.') - .ok_or(RadrootsBlossomError::InvalidHashPath)?; - Some(RadrootsBlossomFileExtension::parse(value)?) + let value = suffix.strip_prefix('.').ok_or(Error::InvalidHashPath)?; + Some(FileExtension::parse(value)?) }; Ok(Self::new(hash, extension)) } - pub const fn hash(&self) -> RadrootsBlossomSha256 { + pub const fn hash(&self) -> Sha256 { self.hash } - pub fn extension(&self) -> Option<&RadrootsBlossomFileExtension> { + pub fn extension(&self) -> Option<&FileExtension> { self.extension.as_ref() } @@ -212,14 +212,14 @@ impl RadrootsBlossomHashPath { } } -impl fmt::Display for RadrootsBlossomHashPath { +impl fmt::Display for HashPath { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.to_path()) } } -impl FromStr for RadrootsBlossomHashPath { - type Err = RadrootsBlossomError; +impl FromStr for HashPath { + type Err = Error; fn from_str(value: &str) -> Result<Self, Self::Err> { Self::parse(value) @@ -227,7 +227,7 @@ impl FromStr for RadrootsBlossomHashPath { } #[cfg(feature = "serde")] -impl serde::Serialize for RadrootsBlossomHashPath { +impl serde::Serialize for HashPath { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::Serializer, @@ -237,7 +237,7 @@ impl serde::Serialize for RadrootsBlossomHashPath { } #[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for RadrootsBlossomHashPath { +impl<'de> serde::Deserialize<'de> for HashPath { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: serde::Deserializer<'de>, @@ -247,11 +247,11 @@ impl<'de> serde::Deserialize<'de> for RadrootsBlossomHashPath { } } -const fn decode_nibble(byte: u8) -> Result<u8, RadrootsBlossomError> { +const fn decode_nibble(byte: u8) -> Result<u8, Error> { match byte { b'0'..=b'9' => Ok(byte - b'0'), b'a'..=b'f' => Ok(byte - b'a' + 10), - _ => Err(RadrootsBlossomError::InvalidSha256), + _ => Err(Error::InvalidSha256), } } @@ -263,22 +263,25 @@ mod tests { const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; #[test] - fn hash_round_trips_known_digest_and_serde() { - let hash = RadrootsBlossomSha256::digest(b""); + fn hash_round_trips_known_digest() { + let hash = Sha256::digest(b""); assert_eq!(hash.to_string(), EMPTY_SHA256); assert_eq!(hash.as_bytes().len(), SHA256_BYTES); - assert_eq!(RadrootsBlossomSha256::from_str(EMPTY_SHA256), Ok(hash)); + assert_eq!(Sha256::from_str(EMPTY_SHA256), Ok(hash)); + } + + #[cfg(feature = "serde")] + #[test] + fn hash_serde_round_trips() { + let hash = Sha256::from_str(EMPTY_SHA256).unwrap(); let json = serde_json::to_string(&hash).unwrap(); - assert_eq!( - serde_json::from_str::<RadrootsBlossomSha256>(&json).unwrap(), - hash - ); + assert_eq!(serde_json::from_str::<Sha256>(&json).unwrap(), hash); } #[test] fn hash_preserves_verified_digest_bytes() { let bytes = [0xabu8; SHA256_BYTES]; - let hash = RadrootsBlossomSha256::from_bytes(bytes); + let hash = Sha256::from_bytes(bytes); assert_eq!(hash.as_bytes(), &bytes); assert_eq!(hash.to_hex(), "ab".repeat(SHA256_BYTES)); @@ -294,56 +297,63 @@ mod tests { &"A".repeat(64), &"z".repeat(64), ] { - assert_eq!( - RadrootsBlossomSha256::from_hex(value), - Err(RadrootsBlossomError::InvalidSha256) - ); + assert_eq!(Sha256::from_hex(value), Err(Error::InvalidSha256)); } - assert!(serde_json::from_str::<RadrootsBlossomSha256>("12").is_err()); } #[test] fn extension_accepts_simple_and_compound_values() { for value in ["png", "tar.gz", "x-custom_2", "PNG"] { - let extension = value.parse::<RadrootsBlossomFileExtension>().unwrap(); + let extension = value.parse::<FileExtension>().unwrap(); assert_eq!(extension.as_str(), value); assert_eq!(extension.to_string(), value); - let json = serde_json::to_string(&extension).unwrap(); - assert_eq!( - serde_json::from_str::<RadrootsBlossomFileExtension>(&json).unwrap(), - extension - ); } } + #[cfg(feature = "serde")] + #[test] + fn extension_serde_round_trips_and_revalidates() { + let extension = FileExtension::parse("tar.gz").unwrap(); + let json = serde_json::to_string(&extension).unwrap(); + assert_eq!( + serde_json::from_str::<FileExtension>(&json).unwrap(), + extension + ); + assert!(serde_json::from_str::<FileExtension>("false").is_err()); + } + #[test] fn extension_rejects_invalid_values() { for value in ["", ".png", "png.", "tar..gz", "a/b", "a b", "café"] { assert_eq!( - RadrootsBlossomFileExtension::parse(value), - Err(RadrootsBlossomError::InvalidFileExtension) + FileExtension::parse(value), + Err(Error::InvalidFileExtension) ); } - assert!(serde_json::from_str::<RadrootsBlossomFileExtension>("false").is_err()); } #[test] fn hash_path_round_trips_with_and_without_extension() { let bare = format!("/{EMPTY_SHA256}"); - let parsed = RadrootsBlossomHashPath::parse(&bare).unwrap(); + let parsed = HashPath::parse(&bare).unwrap(); assert_eq!(parsed.hash().to_string(), EMPTY_SHA256); assert_eq!(parsed.extension(), None); assert_eq!(parsed.to_path(), bare); let extended = format!("/{EMPTY_SHA256}.webp"); - let parsed = RadrootsBlossomHashPath::from_str(&extended).unwrap(); + let parsed = HashPath::from_str(&extended).unwrap(); assert_eq!(parsed.extension().unwrap().as_str(), "webp"); assert_eq!(parsed.to_string(), extended); + } + + #[cfg(feature = "serde")] + #[test] + fn hash_path_serde_round_trips_and_revalidates() { + let path = format!("/{EMPTY_SHA256}.webp"); + let parsed = HashPath::parse(&path).unwrap(); let json = serde_json::to_string(&parsed).unwrap(); - assert_eq!( - serde_json::from_str::<RadrootsBlossomHashPath>(&json).unwrap(), - parsed - ); + assert_eq!(serde_json::from_str::<HashPath>(&json).unwrap(), parsed); + assert!(serde_json::from_str::<HashPath>("null").is_err()); } #[test] @@ -360,8 +370,7 @@ mod tests { &format!("/{EMPTY_SHA256}png"), &format!("/{EMPTY_SHA256}."), ] { - assert!(RadrootsBlossomHashPath::parse(path).is_err(), "{path}"); + assert!(HashPath::parse(path).is_err(), "{path}"); } - assert!(serde_json::from_str::<RadrootsBlossomHashPath>("null").is_err()); } } diff --git a/crates/blossom/src/lib.rs b/crates/blossom/src/lib.rs @@ -1,3 +1,4 @@ +#![doc = include_str!("../README.md")] #![cfg_attr(not(feature = "std"), no_std)] #![cfg_attr(coverage_nightly, feature(coverage_attribute))] #![forbid(unsafe_code)] @@ -6,43 +7,35 @@ extern crate alloc; pub mod authorization; pub mod descriptor; -pub mod error; +mod error; pub mod hash; +pub mod media_type; pub mod publication_readiness; pub mod url; pub use authorization::{ + AuthoredUploadClaim, AuthorizationAction, AuthorizationClaim, AuthorizationContent, + AuthorizationTarget, AuthorizationValidation, AuthorizationWireParts, RADROOTS_BLOSSOM_AUTH_CONTENT_MAX_BYTES, RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS, RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, - RadrootsBlossomAuthoredUploadClaim, RadrootsBlossomAuthorizationAction, - RadrootsBlossomAuthorizationContent, RadrootsBlossomAuthorizationTarget, - RadrootsBlossomAuthorizationValidation, RadrootsBlossomAuthorizationWireParts, - RadrootsBlossomParsedAuthorizationClaim, RadrootsBlossomServerDomain, - RadrootsBlossomServerScopeRequirement, RadrootsBlossomValidatedAuthorizationClaim, + ServerDomain, ServerScopeRequirement, ValidatedAuthorizationClaim, }; -pub use descriptor::{ - RadrootsBlossomApprovedDescriptor, RadrootsBlossomBlobDescriptor, - RadrootsBlossomByteCommitment, RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomMediaType, -}; -pub use error::RadrootsBlossomError; -pub use hash::{RadrootsBlossomFileExtension, RadrootsBlossomHashPath, RadrootsBlossomSha256}; +pub use descriptor::{ApprovedDescriptor, BlobDescriptor, ByteCommitment, ByteVerifiedDescriptor}; +pub use error::Error; +pub use hash::{FileExtension, HashPath, Sha256}; +pub use media_type::MediaType; #[cfg(feature = "raster-decode")] pub use publication_readiness::verify_publication_readiness; pub use publication_readiness::{ - RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, + AuthoredRasterDimensions, Bud01GetCollector, Bud01GetObservation, Bud01HeadObservation, + Bud02UploadObservation, Bud02UploadStatus, PublicationReadinessEvidence, + PublicationReadinessEvidenceDigest, RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES, RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION, RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS, RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION, RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, - RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, RadrootsBlossomAuthoredRasterDimensions, - RadrootsBlossomBud01GetCollector, RadrootsBlossomBud01GetObservation, - RadrootsBlossomBud01HeadObservation, RadrootsBlossomBud02UploadObservation, - RadrootsBlossomBud02UploadStatus, RadrootsBlossomPublicationReadinessEvidence, - RadrootsBlossomPublicationReadinessEvidenceDigest, RadrootsBlossomRasterDimensions, - RadrootsBlossomRasterFormat, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, RasterDimensions, RasterFormat, }; -pub use url::{RadrootsBlossomApprovedBlobUrl, RadrootsBlossomBlobUrl}; - -pub const RADROOTS_BLOSSOM_PROTOCOL_COMMIT: &str = "b5bd2801d1763aa635fc8fea7a76597e0eb18990"; +pub use url::{ApprovedBlobUrl, BlobUrl}; diff --git a/crates/blossom/src/media_type.rs b/crates/blossom/src/media_type.rs @@ -0,0 +1,80 @@ +//! Canonical Blossom media-type values. +//! +//! [`MediaType`] rejects wildcards and duplicate parameters, then emits a +//! deterministic parameter order. Parsing describes declared media type only; +//! callers remain responsible for sniffing or applying content policy where +//! untrusted bytes require it. + +#[cfg(feature = "serde")] +use alloc::string::String; +use alloc::{collections::BTreeMap, vec::Vec}; +use core::{fmt, str::FromStr}; +use mediatype::{MediaTypeBuf, ReadParams}; + +use crate::Error; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct MediaType(MediaTypeBuf); + +impl MediaType { + pub fn parse(value: &str) -> Result<Self, Error> { + let parsed = MediaTypeBuf::from_str(value).map_err(|_| Error::InvalidMediaType)?; + if parsed.as_str() != value || parsed.ty().as_str() == "*" || parsed.subty().as_str() == "*" + { + return Err(Error::InvalidMediaType); + } + let canonical = parsed.canonicalize(); + let mut unique_params = BTreeMap::new(); + for (name, value) in canonical.params() { + if unique_params.insert(name, value).is_some() { + return Err(Error::InvalidMediaType); + } + } + let ordered_params = unique_params.into_iter().collect::<Vec<_>>(); + Ok(Self(MediaTypeBuf::from_parts( + canonical.ty(), + canonical.subty(), + canonical.suffix(), + &ordered_params, + ))) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl fmt::Display for MediaType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for MediaType { + type Err = Error; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for MediaType { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for MediaType { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} diff --git a/crates/blossom/src/publication_readiness.rs b/crates/blossom/src/publication_readiness.rs @@ -7,7 +7,7 @@ use image::{ImageDecoder, Limits, codecs::png::PngDecoder}; #[cfg(feature = "raster-decode")] use libwebp::{WebPDecodeRGBAInto, WebPGetInfo}; #[cfg(any(feature = "raster-decode", feature = "serde"))] -use sha2::{Digest, Sha256}; +use sha2::{Digest, Sha256 as Sha2_256}; #[cfg(feature = "raster-decode")] use std::io::Cursor; #[cfg(feature = "raster-decode")] @@ -19,13 +19,10 @@ use zune_jpeg::JpegDecoder as StrictJpegDecoder; mod sequential_jpeg; #[cfg(feature = "serde")] -use crate::RadrootsBlossomBlobUrl; +use crate::BlobUrl; #[cfg(feature = "raster-decode")] -use crate::RadrootsBlossomByteVerifiedDescriptor; -use crate::{ - RadrootsBlossomApprovedBlobUrl, RadrootsBlossomBlobDescriptor, RadrootsBlossomError, - RadrootsBlossomMediaType, RadrootsBlossomSha256, -}; +use crate::ByteVerifiedDescriptor; +use crate::{ApprovedBlobUrl, BlobDescriptor, Error, MediaType, Sha256}; const _: () = assert!(usize::BITS <= u64::BITS); @@ -46,12 +43,12 @@ const READINESS_EVIDENCE_DIGEST_DOMAIN: &[u8] = b"radroots.blossom.publication-readiness-evidence.v1\0"; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum RadrootsBlossomBud02UploadStatus { +pub enum Bud02UploadStatus { Ok, Created, } -impl RadrootsBlossomBud02UploadStatus { +impl Bud02UploadStatus { pub const fn as_u16(self) -> u16 { match self { Self::Ok => 200, @@ -59,23 +56,23 @@ impl RadrootsBlossomBud02UploadStatus { } } - fn parse(status: u16) -> Result<Self, RadrootsBlossomError> { + fn parse(status: u16) -> Result<Self, Error> { match status { 200 => Ok(Self::Ok), 201 => Ok(Self::Created), - actual => Err(RadrootsBlossomError::InvalidBud02UploadStatus { actual }), + actual => Err(Error::InvalidBud02UploadStatus { actual }), } } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum RadrootsBlossomRasterFormat { +pub enum RasterFormat { Jpeg, Png, StillWebP, } -impl RadrootsBlossomRasterFormat { +impl RasterFormat { pub const fn as_str(self) -> &'static str { match self { Self::Jpeg => "jpeg", @@ -84,14 +81,12 @@ impl RadrootsBlossomRasterFormat { } } - pub fn from_media_type( - media_type: &RadrootsBlossomMediaType, - ) -> Result<Self, RadrootsBlossomError> { + pub fn from_media_type(media_type: &MediaType) -> Result<Self, Error> { match media_type.as_str() { "image/jpeg" => Ok(Self::Jpeg), "image/png" => Ok(Self::Png), "image/webp" => Ok(Self::StillWebP), - _ => Err(RadrootsBlossomError::UnsupportedPublicationRasterMediaType), + _ => Err(Error::UnsupportedPublicationRasterMediaType), } } @@ -105,32 +100,30 @@ impl RadrootsBlossomRasterFormat { } } -impl fmt::Display for RadrootsBlossomRasterFormat { +impl fmt::Display for RasterFormat { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(self.as_str()) } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct RadrootsBlossomRasterDimensions { +pub struct RasterDimensions { width: u32, height: u32, } -impl RadrootsBlossomRasterDimensions { - pub fn new(width: u32, height: u32) -> Result<Self, RadrootsBlossomError> { +impl RasterDimensions { + pub fn new(width: u32, height: u32) -> Result<Self, Error> { if width == 0 || height == 0 || width > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION || height > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION { - return Err( - RadrootsBlossomError::PublicationRasterDimensionsOutOfRange { width, height }, - ); + return Err(Error::PublicationRasterDimensionsOutOfRange { width, height }); } let pixels = u64::from(width) * u64::from(height); if pixels > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS { - return Err(RadrootsBlossomError::PublicationRasterPixelLimitExceeded { pixels }); + return Err(Error::PublicationRasterPixelLimitExceeded { pixels }); } Ok(Self { width, height }) } @@ -149,14 +142,14 @@ impl RadrootsBlossomRasterDimensions { } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum RadrootsBlossomAuthoredRasterDimensions { +pub enum AuthoredRasterDimensions { Unspecified, - Exact(RadrootsBlossomRasterDimensions), + Exact(RasterDimensions), } -impl RadrootsBlossomAuthoredRasterDimensions { +impl AuthoredRasterDimensions { #[cfg(feature = "raster-decode")] - const fn exact(self) -> Option<RadrootsBlossomRasterDimensions> { + const fn exact(self) -> Option<RasterDimensions> { match self { Self::Unspecified => None, Self::Exact(dimensions) => Some(dimensions), @@ -169,47 +162,44 @@ impl RadrootsBlossomAuthoredRasterDimensions { /// Construction accepts only status 200 or 201 and applies the public URL /// approval policy. It does not represent BUD-11 authorization or entitlement. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsBlossomBud02UploadObservation { - status: RadrootsBlossomBud02UploadStatus, - descriptor: crate::RadrootsBlossomApprovedDescriptor, +pub struct Bud02UploadObservation { + status: Bud02UploadStatus, + descriptor: crate::ApprovedDescriptor, } -impl RadrootsBlossomBud02UploadObservation { - pub fn new( - status: u16, - descriptor: RadrootsBlossomBlobDescriptor, - ) -> Result<Self, RadrootsBlossomError> { +impl Bud02UploadObservation { + pub fn new(status: u16, descriptor: BlobDescriptor) -> Result<Self, Error> { Ok(Self { - status: RadrootsBlossomBud02UploadStatus::parse(status)?, + status: Bud02UploadStatus::parse(status)?, descriptor: descriptor.approve_reference()?, }) } - pub const fn status(&self) -> RadrootsBlossomBud02UploadStatus { + pub const fn status(&self) -> Bud02UploadStatus { self.status } - pub fn descriptor(&self) -> &crate::RadrootsBlossomApprovedDescriptor { + pub fn descriptor(&self) -> &crate::ApprovedDescriptor { &self.descriptor } } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsBlossomBud01HeadObservation { - url: RadrootsBlossomApprovedBlobUrl, +pub struct Bud01HeadObservation { + url: ApprovedBlobUrl, content_length: u64, - media_type: RadrootsBlossomMediaType, + media_type: MediaType, } -impl RadrootsBlossomBud01HeadObservation { +impl Bud01HeadObservation { pub fn new( status: u16, - url: RadrootsBlossomApprovedBlobUrl, + url: ApprovedBlobUrl, content_length: u64, - media_type: RadrootsBlossomMediaType, - ) -> Result<Self, RadrootsBlossomError> { + media_type: MediaType, + ) -> Result<Self, Error> { if status != 200 { - return Err(RadrootsBlossomError::InvalidBud01HeadStatus { actual: status }); + return Err(Error::InvalidBud01HeadStatus { actual: status }); } Ok(Self { url, @@ -218,7 +208,7 @@ impl RadrootsBlossomBud01HeadObservation { }) } - pub fn url(&self) -> &RadrootsBlossomApprovedBlobUrl { + pub fn url(&self) -> &ApprovedBlobUrl { &self.url } @@ -226,38 +216,34 @@ impl RadrootsBlossomBud01HeadObservation { self.content_length } - pub fn media_type(&self) -> &RadrootsBlossomMediaType { + pub fn media_type(&self) -> &MediaType { &self.media_type } } -pub struct RadrootsBlossomBud01GetCollector { - url: RadrootsBlossomApprovedBlobUrl, +pub struct Bud01GetCollector { + url: ApprovedBlobUrl, declared_size: u64, bytes: Vec<u8>, } -impl RadrootsBlossomBud01GetCollector { - pub fn new( - status: u16, - url: RadrootsBlossomApprovedBlobUrl, - declared_size: u64, - ) -> Result<Self, RadrootsBlossomError> { +impl Bud01GetCollector { + pub fn new(status: u16, url: ApprovedBlobUrl, declared_size: u64) -> Result<Self, Error> { if status != 200 { - return Err(RadrootsBlossomError::InvalidBud01GetStatus { actual: status }); + return Err(Error::InvalidBud01GetStatus { actual: status }); } if declared_size > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES { - return Err(RadrootsBlossomError::PublicationRasterByteLimitExceeded { + return Err(Error::PublicationRasterByteLimitExceeded { declared: declared_size, maximum: RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, }); } let capacity = usize::try_from(declared_size) - .map_err(|_| RadrootsBlossomError::PublicationGetBodyAllocationFailed)?; + .map_err(|_| Error::PublicationGetBodyAllocationFailed)?; let mut bytes = Vec::new(); bytes .try_reserve_exact(capacity) - .map_err(|_| RadrootsBlossomError::PublicationGetBodyAllocationFailed)?; + .map_err(|_| Error::PublicationGetBodyAllocationFailed)?; Ok(Self { url, declared_size, @@ -265,15 +251,15 @@ impl RadrootsBlossomBud01GetCollector { }) } - pub fn push_chunk(&mut self, chunk: &[u8]) -> Result<(), RadrootsBlossomError> { + pub fn push_chunk(&mut self, chunk: &[u8]) -> Result<(), Error> { let actual = self .bytes .len() .checked_add(chunk.len()) .and_then(|value| u64::try_from(value).ok()) - .ok_or(RadrootsBlossomError::PublicationGetBodyLengthOverflow)?; + .ok_or(Error::PublicationGetBodyLengthOverflow)?; if actual > self.declared_size { - return Err(RadrootsBlossomError::PublicationGetBodyTrailing { + return Err(Error::PublicationGetBodyTrailing { declared: self.declared_size, actual, }); @@ -282,18 +268,18 @@ impl RadrootsBlossomBud01GetCollector { Ok(()) } - pub fn finish(self) -> Result<RadrootsBlossomBud01GetObservation, RadrootsBlossomError> { + pub fn finish(self) -> Result<Bud01GetObservation, Error> { let actual = self.bytes.len() as u64; if actual == 0 { - return Err(RadrootsBlossomError::PublicationGetBodyMissing); + return Err(Error::PublicationGetBodyMissing); } if actual < self.declared_size { - return Err(RadrootsBlossomError::PublicationGetBodyShort { + return Err(Error::PublicationGetBodyShort { declared: self.declared_size, actual, }); } - Ok(RadrootsBlossomBud01GetObservation { + Ok(Bud01GetObservation { url: self.url, declared_size: self.declared_size, bytes: self.bytes, @@ -301,25 +287,25 @@ impl RadrootsBlossomBud01GetCollector { } } -pub struct RadrootsBlossomBud01GetObservation { - url: RadrootsBlossomApprovedBlobUrl, +pub struct Bud01GetObservation { + url: ApprovedBlobUrl, declared_size: u64, bytes: Vec<u8>, } -impl RadrootsBlossomBud01GetObservation { +impl Bud01GetObservation { pub fn from_complete_body( status: u16, - url: RadrootsBlossomApprovedBlobUrl, + url: ApprovedBlobUrl, declared_size: u64, bytes: &[u8], - ) -> Result<Self, RadrootsBlossomError> { - let mut collector = RadrootsBlossomBud01GetCollector::new(status, url, declared_size)?; + ) -> Result<Self, Error> { + let mut collector = Bud01GetCollector::new(status, url, declared_size)?; collector.push_chunk(bytes)?; collector.finish() } - pub fn url(&self) -> &RadrootsBlossomApprovedBlobUrl { + pub fn url(&self) -> &ApprovedBlobUrl { &self.url } @@ -332,10 +318,10 @@ impl RadrootsBlossomBud01GetObservation { } } -impl fmt::Debug for RadrootsBlossomBud01GetObservation { +impl fmt::Debug for Bud01GetObservation { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter - .debug_struct("RadrootsBlossomBud01GetObservation") + .debug_struct("Bud01GetObservation") .field("url", &self.url) .field("declared_size", &self.declared_size) .field("body_length", &self.bytes.len()) @@ -344,34 +330,34 @@ impl fmt::Debug for RadrootsBlossomBud01GetObservation { } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct RadrootsBlossomPublicationReadinessEvidenceDigest(RadrootsBlossomSha256); +pub struct PublicationReadinessEvidenceDigest(Sha256); -impl RadrootsBlossomPublicationReadinessEvidenceDigest { - pub const fn as_sha256(self) -> RadrootsBlossomSha256 { +impl PublicationReadinessEvidenceDigest { + pub const fn as_sha256(self) -> Sha256 { self.0 } } -impl fmt::Display for RadrootsBlossomPublicationReadinessEvidenceDigest { +impl fmt::Display for PublicationReadinessEvidenceDigest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(formatter) } } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsBlossomPublicationReadinessEvidence { - url: RadrootsBlossomApprovedBlobUrl, - sha256: RadrootsBlossomSha256, +pub struct PublicationReadinessEvidence { + url: ApprovedBlobUrl, + sha256: Sha256, size: u64, - media_type: RadrootsBlossomMediaType, - raster_format: RadrootsBlossomRasterFormat, - dimensions: RadrootsBlossomRasterDimensions, - bud02_status: RadrootsBlossomBud02UploadStatus, + media_type: MediaType, + raster_format: RasterFormat, + dimensions: RasterDimensions, + bud02_status: Bud02UploadStatus, uploaded: u64, - evidence_digest: RadrootsBlossomPublicationReadinessEvidenceDigest, + evidence_digest: PublicationReadinessEvidenceDigest, } -impl RadrootsBlossomPublicationReadinessEvidence { +impl PublicationReadinessEvidence { pub const fn schema_version(&self) -> u32 { RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION } @@ -380,11 +366,11 @@ impl RadrootsBlossomPublicationReadinessEvidence { RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION } - pub fn url(&self) -> &RadrootsBlossomApprovedBlobUrl { + pub fn url(&self) -> &ApprovedBlobUrl { &self.url } - pub const fn sha256(&self) -> RadrootsBlossomSha256 { + pub const fn sha256(&self) -> Sha256 { self.sha256 } @@ -392,19 +378,19 @@ impl RadrootsBlossomPublicationReadinessEvidence { self.size } - pub fn media_type(&self) -> &RadrootsBlossomMediaType { + pub fn media_type(&self) -> &MediaType { &self.media_type } - pub const fn raster_format(&self) -> RadrootsBlossomRasterFormat { + pub const fn raster_format(&self) -> RasterFormat { self.raster_format } - pub const fn dimensions(&self) -> RadrootsBlossomRasterDimensions { + pub const fn dimensions(&self) -> RasterDimensions { self.dimensions } - pub const fn bud02_status(&self) -> RadrootsBlossomBud02UploadStatus { + pub const fn bud02_status(&self) -> Bud02UploadStatus { self.bud02_status } @@ -412,28 +398,28 @@ impl RadrootsBlossomPublicationReadinessEvidence { self.uploaded } - pub const fn evidence_digest(&self) -> RadrootsBlossomPublicationReadinessEvidenceDigest { + pub const fn evidence_digest(&self) -> PublicationReadinessEvidenceDigest { self.evidence_digest } #[cfg(feature = "serde")] - pub fn to_canonical_json(&self) -> Result<Vec<u8>, RadrootsBlossomError> { + pub fn to_canonical_json(&self) -> Result<Vec<u8>, Error> { serialize_readiness_evidence(self) } #[cfg(feature = "serde")] - pub fn from_canonical_json(bytes: &[u8]) -> Result<Self, RadrootsBlossomError> { + pub fn from_canonical_json(bytes: &[u8]) -> Result<Self, Error> { if bytes.len() > RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES { - return Err(RadrootsBlossomError::PublicationReadinessEvidenceTooLarge { + return Err(Error::PublicationReadinessEvidenceTooLarge { max: RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, actual: bytes.len(), }); } let wire: PublicationReadinessEvidenceWire = serde_json::from_slice(bytes) - .map_err(|_| RadrootsBlossomError::PublicationReadinessEvidenceInvalidJson)?; + .map_err(|_| Error::PublicationReadinessEvidenceInvalidJson)?; let evidence = readiness_evidence_from_wire(wire)?; if serialize_readiness_evidence(&evidence)? != bytes { - return Err(RadrootsBlossomError::PublicationReadinessEvidenceNonCanonicalJson); + return Err(Error::PublicationReadinessEvidenceNonCanonicalJson); } Ok(evidence) } @@ -441,79 +427,79 @@ impl RadrootsBlossomPublicationReadinessEvidence { #[cfg(feature = "raster-decode")] pub fn verify_publication_readiness( - authored_descriptor: &RadrootsBlossomByteVerifiedDescriptor, + authored_descriptor: &ByteVerifiedDescriptor, exact_authored_bytes: &[u8], - authored_dimensions: RadrootsBlossomAuthoredRasterDimensions, - upload: &RadrootsBlossomBud02UploadObservation, - head: &RadrootsBlossomBud01HeadObservation, - get: &RadrootsBlossomBud01GetObservation, -) -> Result<RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomError> { + authored_dimensions: AuthoredRasterDimensions, + upload: &Bud02UploadObservation, + head: &Bud01HeadObservation, + get: &Bud01GetObservation, +) -> Result<PublicationReadinessEvidence, Error> { let expected_url = authored_descriptor.url(); let expected_hash = authored_descriptor.sha256(); let expected_size = authored_descriptor.size(); let expected_media_type = authored_descriptor.media_type(); if expected_url.as_str().len() > RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES { - return Err(RadrootsBlossomError::PublicationReadinessUrlTooLarge { + return Err(Error::PublicationReadinessUrlTooLarge { max: RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, actual: expected_url.as_str().len(), }); } if expected_size == 0 { - return Err(RadrootsBlossomError::PublicationRasterEmpty); + return Err(Error::PublicationRasterEmpty); } if expected_size > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES { - return Err(RadrootsBlossomError::PublicationRasterByteLimitExceeded { + return Err(Error::PublicationRasterByteLimitExceeded { declared: expected_size, maximum: RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, }); } let authored_size = exact_authored_bytes.len() as u64; if authored_size != expected_size { - return Err(RadrootsBlossomError::PublicationAuthoredBytesSizeMismatch { + return Err(Error::PublicationAuthoredBytesSizeMismatch { expected: expected_size, actual: authored_size, }); } - if RadrootsBlossomSha256::digest(exact_authored_bytes) != expected_hash { - return Err(RadrootsBlossomError::PublicationAuthoredBytesHashMismatch); + if Sha256::digest(exact_authored_bytes) != expected_hash { + return Err(Error::PublicationAuthoredBytesHashMismatch); } let upload_descriptor = upload.descriptor().descriptor(); if upload_descriptor.sha256() != expected_hash { - return Err(RadrootsBlossomError::PublicationUploadHashMismatch); + return Err(Error::PublicationUploadHashMismatch); } if upload.descriptor().url() != expected_url { - return Err(RadrootsBlossomError::PublicationUploadUrlMismatch); + return Err(Error::PublicationUploadUrlMismatch); } if upload_descriptor.size() != expected_size { - return Err(RadrootsBlossomError::PublicationUploadSizeMismatch { + return Err(Error::PublicationUploadSizeMismatch { expected: expected_size, actual: upload_descriptor.size(), }); } if upload_descriptor.media_type() != expected_media_type { - return Err(RadrootsBlossomError::PublicationUploadMediaTypeMismatch); + return Err(Error::PublicationUploadMediaTypeMismatch); } if head.url() != expected_url { - return Err(RadrootsBlossomError::PublicationHeadUrlMismatch); + return Err(Error::PublicationHeadUrlMismatch); } if head.content_length() != expected_size { - return Err(RadrootsBlossomError::PublicationHeadSizeMismatch { + return Err(Error::PublicationHeadSizeMismatch { expected: expected_size, actual: head.content_length(), }); } if head.media_type() != expected_media_type { - return Err(RadrootsBlossomError::PublicationHeadMediaTypeMismatch); + return Err(Error::PublicationHeadMediaTypeMismatch); } if get.url() != expected_url { - return Err(RadrootsBlossomError::PublicationGetUrlMismatch); + return Err(Error::PublicationGetUrlMismatch); } if get.declared_size() != expected_size { - return Err(RadrootsBlossomError::PublicationGetDeclaredSizeMismatch { + return Err(Error::PublicationGetDeclaredSizeMismatch { expected: expected_size, actual: get.declared_size(), }); @@ -522,16 +508,16 @@ pub fn verify_publication_readiness( expected_hash, exact_authored_bytes, get.bytes(), - RadrootsBlossomSha256::digest(get.bytes()), + Sha256::digest(get.bytes()), )?; - let expected_format = RadrootsBlossomRasterFormat::from_media_type(expected_media_type)?; + let expected_format = RasterFormat::from_media_type(expected_media_type)?; let decoded_dimensions = decode_raster(get.bytes(), expected_format)?; if authored_dimensions .exact() .is_some_and(|dimensions| dimensions != decoded_dimensions) { - return Err(RadrootsBlossomError::PublicationAuthoredRasterDimensionMismatch); + return Err(Error::PublicationAuthoredRasterDimensionMismatch); } let evidence_digest = evidence_digest_from_facts(&ReadinessEvidenceDigestFacts { @@ -544,7 +530,7 @@ pub fn verify_publication_readiness( bud02_status: upload.status(), uploaded: upload_descriptor.uploaded(), }); - Ok(RadrootsBlossomPublicationReadinessEvidence { + Ok(PublicationReadinessEvidence { url: expected_url.clone(), sha256: expected_hash, size: expected_size, @@ -558,26 +544,23 @@ pub fn verify_publication_readiness( } #[cfg(feature = "raster-decode")] -fn decode_raster( - bytes: &[u8], - format: RadrootsBlossomRasterFormat, -) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { +fn decode_raster(bytes: &[u8], format: RasterFormat) -> Result<RasterDimensions, Error> { match format { - RadrootsBlossomRasterFormat::Jpeg => { + RasterFormat::Jpeg => { let container = inspect_jpeg_container(bytes)?; decode_complete_jpeg(bytes, container) } - RadrootsBlossomRasterFormat::Png => { + RasterFormat::Png => { let container = inspect_png_container(bytes)?; let decoder = PngDecoder::with_limits(Cursor::new(bytes), raster_decode_limits()) - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .map_err(|_| Error::PublicationRasterDecodeFailed)?; let decoder_animated = decoder .is_apng() - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .map_err(|_| Error::PublicationRasterDecodeFailed)?; reject_animation(container.animated, decoder_animated)?; decode_complete_raster(decoder, container.dimensions) } - RadrootsBlossomRasterFormat::StillWebP => { + RasterFormat::StillWebP => { let container = inspect_webp_container(bytes)?; reject_animation(container.animated, false)?; decode_complete_webp(bytes, container.dimensions) @@ -588,11 +571,10 @@ fn decode_raster( #[cfg(feature = "raster-decode")] fn decode_complete_webp( bytes: &[u8], - container_dimensions: RadrootsBlossomRasterDimensions, -) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { - let (width, height) = - WebPGetInfo(bytes).map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; - let dimensions = RadrootsBlossomRasterDimensions::new(width, height)?; + container_dimensions: RasterDimensions, +) -> Result<RasterDimensions, Error> { + let (width, height) = WebPGetInfo(bytes).map_err(|_| Error::PublicationRasterDecodeFailed)?; + let dimensions = RasterDimensions::new(width, height)?; require_matching_dimensions(dimensions, container_dimensions)?; let decoded_length = u64::from(width) @@ -601,10 +583,10 @@ fn decode_complete_webp( let decoded_bytes = bounded_decoded_byte_length(decoded_length)?; let stride = width .checked_mul(4) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; let mut decoded = allocate_decoded_buffer(decoded_bytes)?; WebPDecodeRGBAInto(bytes, &mut decoded, stride) - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .map_err(|_| Error::PublicationRasterDecodeFailed)?; Ok(dimensions) } @@ -612,20 +594,20 @@ fn decode_complete_webp( fn decode_complete_jpeg( bytes: &[u8], container: JpegContainerInspection, -) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { +) -> Result<RasterDimensions, Error> { sequential_jpeg::validate(bytes, container)?; let mut decoder = StrictJpegDecoder::new_with_options(ZCursor::new(bytes), strict_jpeg_decoder_options()); decoder .decode_headers() - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .map_err(|_| Error::PublicationRasterDecodeFailed)?; let dimensions = strict_jpeg_dimensions(decoder.dimensions())?; require_matching_dimensions(dimensions, container.dimensions)?; let decoded_bytes = bounded_jpeg_output_buffer_size(decoder.output_buffer_size())?; let mut decoded = allocate_decoded_buffer(decoded_bytes)?; decoder .decode_into(&mut decoded) - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .map_err(|_| Error::PublicationRasterDecodeFailed)?; Ok(dimensions) } @@ -640,91 +622,80 @@ fn strict_jpeg_decoder_options() -> DecoderOptions { } #[cfg(feature = "raster-decode")] -fn strict_jpeg_dimensions( - dimensions: Option<(usize, usize)>, -) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { - let (width, height) = dimensions.ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; - let width = - u32::try_from(width).map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; - let height = - u32::try_from(height).map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; - RadrootsBlossomRasterDimensions::new(width, height) +fn strict_jpeg_dimensions(dimensions: Option<(usize, usize)>) -> Result<RasterDimensions, Error> { + let (width, height) = dimensions.ok_or(Error::PublicationRasterDecodeFailed)?; + let width = u32::try_from(width).map_err(|_| Error::PublicationRasterDecodeFailed)?; + let height = u32::try_from(height).map_err(|_| Error::PublicationRasterDecodeFailed)?; + RasterDimensions::new(width, height) } #[cfg(feature = "raster-decode")] fn decode_complete_raster<D: ImageDecoder>( mut decoder: D, - container_dimensions: RadrootsBlossomRasterDimensions, -) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { + container_dimensions: RasterDimensions, +) -> Result<RasterDimensions, Error> { let (width, height) = decoder.dimensions(); - let dimensions = RadrootsBlossomRasterDimensions::new(width, height)?; + let dimensions = RasterDimensions::new(width, height)?; require_matching_dimensions(dimensions, container_dimensions)?; decoder .set_limits(raster_decode_limits()) - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .map_err(|_| Error::PublicationRasterDecodeFailed)?; let decoded_bytes = bounded_decoded_byte_length(Some(decoder.total_bytes()))?; let mut decoded = allocate_decoded_buffer(decoded_bytes)?; decoder .read_image(&mut decoded) - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .map_err(|_| Error::PublicationRasterDecodeFailed)?; Ok(dimensions) } #[cfg(feature = "raster-decode")] fn require_matching_dimensions( - decoded: RadrootsBlossomRasterDimensions, - container: RadrootsBlossomRasterDimensions, -) -> Result<(), RadrootsBlossomError> { + decoded: RasterDimensions, + container: RasterDimensions, +) -> Result<(), Error> { if decoded != container { - return Err(RadrootsBlossomError::PublicationRasterContainerDimensionMismatch); + return Err(Error::PublicationRasterContainerDimensionMismatch); } Ok(()) } #[cfg(feature = "raster-decode")] -fn bounded_jpeg_output_buffer_size( - decoded_bytes: Option<usize>, -) -> Result<u64, RadrootsBlossomError> { +fn bounded_jpeg_output_buffer_size(decoded_bytes: Option<usize>) -> Result<u64, Error> { bounded_decoded_byte_length(decoded_bytes.map(|decoded_bytes| decoded_bytes as u64)) } #[cfg(feature = "raster-decode")] -fn bounded_decoded_byte_length(decoded_bytes: Option<u64>) -> Result<u64, RadrootsBlossomError> { - let decoded_bytes = decoded_bytes.ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; +fn bounded_decoded_byte_length(decoded_bytes: Option<u64>) -> Result<u64, Error> { + let decoded_bytes = decoded_bytes.ok_or(Error::PublicationRasterDecodeFailed)?; if decoded_bytes > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES { - return Err( - RadrootsBlossomError::PublicationRasterDecodedByteLimitExceeded { - decoded: decoded_bytes, - maximum: RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES, - }, - ); + return Err(Error::PublicationRasterDecodedByteLimitExceeded { + decoded: decoded_bytes, + maximum: RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES, + }); } Ok(decoded_bytes) } #[cfg(feature = "raster-decode")] -fn reject_animation( - container_animated: bool, - decoder_animated: bool, -) -> Result<(), RadrootsBlossomError> { +fn reject_animation(container_animated: bool, decoder_animated: bool) -> Result<(), Error> { if container_animated || decoder_animated { - return Err(RadrootsBlossomError::PublicationRasterAnimationForbidden); + return Err(Error::PublicationRasterAnimationForbidden); } Ok(()) } #[cfg(feature = "raster-decode")] -fn allocate_decoded_buffer(decoded_bytes: u64) -> Result<Vec<u8>, RadrootsBlossomError> { +fn allocate_decoded_buffer(decoded_bytes: u64) -> Result<Vec<u8>, Error> { #[cfg(target_pointer_width = "64")] let decoded_length = decoded_bytes as usize; #[cfg(not(target_pointer_width = "64"))] let decoded_length = usize::try_from(decoded_bytes) - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeAllocationFailed)?; + .map_err(|_| Error::PublicationRasterDecodeAllocationFailed)?; let mut decoded = Vec::new(); decoded .try_reserve_exact(decoded_length) - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeAllocationFailed)?; + .map_err(|_| Error::PublicationRasterDecodeAllocationFailed)?; decoded.resize(decoded_length, 0); Ok(decoded) } @@ -740,37 +711,37 @@ fn raster_decode_limits() -> Limits { #[cfg(feature = "raster-decode")] fn validate_retrieved_body( - expected_hash: RadrootsBlossomSha256, + expected_hash: Sha256, exact_authored_bytes: &[u8], retrieved_bytes: &[u8], - retrieved_hash: RadrootsBlossomSha256, -) -> Result<(), RadrootsBlossomError> { + retrieved_hash: Sha256, +) -> Result<(), Error> { if retrieved_hash != expected_hash { - return Err(RadrootsBlossomError::PublicationRetrievedBytesHashMismatch); + return Err(Error::PublicationRetrievedBytesHashMismatch); } if retrieved_bytes != exact_authored_bytes { - return Err(RadrootsBlossomError::PublicationRetrievedBytesMismatch); + return Err(Error::PublicationRetrievedBytesMismatch); } Ok(()) } #[cfg(any(feature = "raster-decode", feature = "serde"))] struct ReadinessEvidenceDigestFacts<'a> { - url: &'a RadrootsBlossomApprovedBlobUrl, - sha256: RadrootsBlossomSha256, + url: &'a ApprovedBlobUrl, + sha256: Sha256, size: u64, - media_type: &'a RadrootsBlossomMediaType, - format: RadrootsBlossomRasterFormat, - dimensions: RadrootsBlossomRasterDimensions, - bud02_status: RadrootsBlossomBud02UploadStatus, + media_type: &'a MediaType, + format: RasterFormat, + dimensions: RasterDimensions, + bud02_status: Bud02UploadStatus, uploaded: u64, } #[cfg(any(feature = "raster-decode", feature = "serde"))] fn evidence_digest_from_facts( facts: &ReadinessEvidenceDigestFacts<'_>, -) -> RadrootsBlossomPublicationReadinessEvidenceDigest { - let mut hasher = Sha256::new(); +) -> PublicationReadinessEvidenceDigest { + let mut hasher = Sha2_256::new(); hasher.update(READINESS_EVIDENCE_DIGEST_DOMAIN); hasher.update(RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION.to_be_bytes()); update_length_prefixed(&mut hasher, facts.url.as_str().as_bytes()); @@ -787,11 +758,11 @@ fn evidence_digest_from_facts( let digest = hasher.finalize(); let mut bytes = [0_u8; 32]; bytes.copy_from_slice(&digest); - RadrootsBlossomPublicationReadinessEvidenceDigest(RadrootsBlossomSha256::from_bytes(bytes)) + PublicationReadinessEvidenceDigest(Sha256::from_bytes(bytes)) } #[cfg(any(feature = "raster-decode", feature = "serde"))] -fn update_length_prefixed(hasher: &mut Sha256, bytes: &[u8]) { +fn update_length_prefixed(hasher: &mut Sha2_256, bytes: &[u8]) { hasher.update((bytes.len() as u64).to_be_bytes()); hasher.update(bytes); } @@ -824,9 +795,7 @@ struct PublicationReadinessEvidenceWire { } #[cfg(feature = "serde")] -fn serialize_readiness_evidence( - evidence: &RadrootsBlossomPublicationReadinessEvidence, -) -> Result<Vec<u8>, RadrootsBlossomError> { +fn serialize_readiness_evidence(evidence: &PublicationReadinessEvidence) -> Result<Vec<u8>, Error> { let wire = PublicationReadinessEvidenceWire { schema_version: RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION, policy_version: RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, @@ -845,10 +814,10 @@ fn serialize_readiness_evidence( uploaded: evidence.uploaded, evidence_digest: evidence.evidence_digest.to_string(), }; - let bytes = serde_json::to_vec(&wire) - .map_err(|_| RadrootsBlossomError::PublicationReadinessEvidenceSerialization)?; + let bytes = + serde_json::to_vec(&wire).map_err(|_| Error::PublicationReadinessEvidenceSerialization)?; if bytes.len() > RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES { - return Err(RadrootsBlossomError::PublicationReadinessEvidenceTooLarge { + return Err(Error::PublicationReadinessEvidenceTooLarge { max: RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, actual: bytes.len(), }); @@ -859,10 +828,10 @@ fn serialize_readiness_evidence( #[cfg(feature = "serde")] fn readiness_evidence_from_wire( wire: PublicationReadinessEvidenceWire, -) -> Result<RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomError> { +) -> Result<PublicationReadinessEvidence, Error> { if wire.schema_version != RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION { return Err( - RadrootsBlossomError::PublicationReadinessEvidenceUnsupportedSchemaVersion { + Error::PublicationReadinessEvidenceUnsupportedSchemaVersion { expected: RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION, actual: wire.schema_version, }, @@ -870,112 +839,91 @@ fn readiness_evidence_from_wire( } if wire.policy_version != RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION { return Err( - RadrootsBlossomError::PublicationReadinessEvidenceUnsupportedPolicyVersion { + Error::PublicationReadinessEvidenceUnsupportedPolicyVersion { expected: RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, actual: wire.policy_version, }, ); } if wire.url.len() > RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES { - return Err( - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "url" }, - ); + return Err(Error::PublicationReadinessEvidenceInvalidField { field: "url" }); } if wire.size == 0 || wire.size > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES { - return Err( - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "size" }, - ); + return Err(Error::PublicationReadinessEvidenceInvalidField { field: "size" }); } - let sha256 = RadrootsBlossomSha256::from_hex(&wire.sha256).map_err(|_| { - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "sha256" } - })?; - let media_type = RadrootsBlossomMediaType::parse(&wire.media_type).map_err(|_| { - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { + let sha256 = Sha256::from_hex(&wire.sha256) + .map_err(|_| Error::PublicationReadinessEvidenceInvalidField { field: "sha256" })?; + let media_type = MediaType::parse(&wire.media_type).map_err(|_| { + Error::PublicationReadinessEvidenceInvalidField { field: "media_type", } })?; if media_type.as_str() != wire.media_type { - return Err( - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { - field: "media_type", - }, - ); + return Err(Error::PublicationReadinessEvidenceInvalidField { + field: "media_type", + }); } let raster_format = match wire.raster_format.as_str() { - "jpeg" => RadrootsBlossomRasterFormat::Jpeg, - "png" => RadrootsBlossomRasterFormat::Png, - "still_webp" => RadrootsBlossomRasterFormat::StillWebP, + "jpeg" => RasterFormat::Jpeg, + "png" => RasterFormat::Png, + "still_webp" => RasterFormat::StillWebP, _ => { - return Err( - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { - field: "raster_format", - }, - ); + return Err(Error::PublicationReadinessEvidenceInvalidField { + field: "raster_format", + }); } }; - if RadrootsBlossomRasterFormat::from_media_type(&media_type).map_err(|_| { - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { + if RasterFormat::from_media_type(&media_type).map_err(|_| { + Error::PublicationReadinessEvidenceInvalidField { field: "media_type", } })? != raster_format { - return Err( - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { - field: "raster_format", - }, - ); + return Err(Error::PublicationReadinessEvidenceInvalidField { + field: "raster_format", + }); } let dimensions = - RadrootsBlossomRasterDimensions::new(wire.dimensions.width, wire.dimensions.height) - .map_err( - |_| RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { - field: "dimensions", - }, - )?; - let bud02_status = - RadrootsBlossomBud02UploadStatus::parse(wire.bud02_status).map_err(|_| { - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { - field: "bud02_status", + RasterDimensions::new(wire.dimensions.width, wire.dimensions.height).map_err(|_| { + Error::PublicationReadinessEvidenceInvalidField { + field: "dimensions", } })?; + let bud02_status = Bud02UploadStatus::parse(wire.bud02_status).map_err(|_| { + Error::PublicationReadinessEvidenceInvalidField { + field: "bud02_status", + } + })?; if wire.bud01_head_status != 200 { - return Err( - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { - field: "bud01_head_status", - }, - ); + return Err(Error::PublicationReadinessEvidenceInvalidField { + field: "bud01_head_status", + }); } if wire.bud01_get_status != 200 { - return Err( - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { - field: "bud01_get_status", - }, - ); + return Err(Error::PublicationReadinessEvidenceInvalidField { + field: "bud01_get_status", + }); } - let blob_url = RadrootsBlossomBlobUrl::parse(&wire.url).map_err(|_| { - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "url" } - })?; + let blob_url = BlobUrl::parse(&wire.url) + .map_err(|_| Error::PublicationReadinessEvidenceInvalidField { field: "url" })?; if blob_url.as_str() != wire.url { - return Err( - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "url" }, - ); + return Err(Error::PublicationReadinessEvidenceInvalidField { field: "url" }); } - let approved = RadrootsBlossomBlobDescriptor::new( + let approved = BlobDescriptor::new( blob_url, sha256, wire.size, media_type.clone(), wire.uploaded, ) - .and_then(RadrootsBlossomBlobDescriptor::approve_reference) - .map_err(|_| RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "url" })?; - let evidence_digest = RadrootsBlossomPublicationReadinessEvidenceDigest( - RadrootsBlossomSha256::from_hex(&wire.evidence_digest).map_err(|_| { - RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { + .and_then(BlobDescriptor::approve_reference) + .map_err(|_| Error::PublicationReadinessEvidenceInvalidField { field: "url" })?; + let evidence_digest = + PublicationReadinessEvidenceDigest(Sha256::from_hex(&wire.evidence_digest).map_err( + |_| Error::PublicationReadinessEvidenceInvalidField { field: "evidence_digest", - } - })?, - ); + }, + )?); let expected_digest = evidence_digest_from_facts(&ReadinessEvidenceDigestFacts { url: approved.url(), sha256, @@ -987,9 +935,9 @@ fn readiness_evidence_from_wire( uploaded: wire.uploaded, }); if evidence_digest != expected_digest { - return Err(RadrootsBlossomError::PublicationReadinessEvidenceDigestMismatch); + return Err(Error::PublicationReadinessEvidenceDigestMismatch); } - Ok(RadrootsBlossomPublicationReadinessEvidence { + Ok(PublicationReadinessEvidence { url: approved.url().clone(), sha256, size: wire.size, @@ -1005,24 +953,24 @@ fn readiness_evidence_from_wire( #[cfg(any(feature = "raster-decode", test))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct RasterContainerInspection { - dimensions: RadrootsBlossomRasterDimensions, + dimensions: RasterDimensions, animated: bool, } #[cfg(any(feature = "raster-decode", test))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct JpegContainerInspection { - dimensions: RadrootsBlossomRasterDimensions, + dimensions: RasterDimensions, components: u8, } #[cfg(any(feature = "raster-decode", test))] -fn invalid_raster<T>() -> Result<T, RadrootsBlossomError> { - Err(RadrootsBlossomError::InvalidPublicationRaster) +fn invalid_raster<T>() -> Result<T, Error> { + Err(Error::InvalidPublicationRaster) } #[cfg(any(feature = "raster-decode", test))] -fn inspect_png_container(bytes: &[u8]) -> Result<RasterContainerInspection, RadrootsBlossomError> { +fn inspect_png_container(bytes: &[u8]) -> Result<RasterContainerInspection, Error> { const SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; if !bytes.starts_with(SIGNATURE) { return invalid_raster(); @@ -1038,34 +986,34 @@ fn inspect_png_container(bytes: &[u8]) -> Result<RasterContainerInspection, Radr while position < bytes.len() { records = records .checked_add(1) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; if records > PUBLICATION_RASTER_MAX_CONTAINER_RECORDS { return invalid_raster(); } let header_end = position .checked_add(8) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; let header = bytes .get(position..header_end) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; let length = u32::from_be_bytes( header[..4] .try_into() - .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + .map_err(|_| Error::InvalidPublicationRaster)?, ) as usize; let kind: [u8; 4] = header[4..] .try_into() - .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?; + .map_err(|_| Error::InvalidPublicationRaster)?; let data_start = header_end; let data_end = data_start .checked_add(length) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; let chunk_end = data_end .checked_add(4) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; let data = bytes .get(data_start..data_end) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; if chunk_end > bytes.len() { return invalid_raster(); } @@ -1076,22 +1024,22 @@ fn inspect_png_container(bytes: &[u8]) -> Result<RasterContainerInspection, Radr let width = u32::from_be_bytes( data[..4] .try_into() - .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + .map_err(|_| Error::InvalidPublicationRaster)?, ); let height = u32::from_be_bytes( data[4..8] .try_into() - .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + .map_err(|_| Error::InvalidPublicationRaster)?, ); let bit_depth = data[8]; let parsed_color_type = data[9]; if bit_depth != 8 || data[10] != 0 || data[11] != 0 || data[12] > 1 { - return Err(RadrootsBlossomError::PublicationRasterProcessForbidden); + return Err(Error::PublicationRasterProcessForbidden); } if !matches!(parsed_color_type, 0 | 2 | 3 | 4 | 6) { - return Err(RadrootsBlossomError::PublicationRasterDecodeFailed); + return Err(Error::PublicationRasterDecodeFailed); } - dimensions = Some(RadrootsBlossomRasterDimensions::new(width, height)?); + dimensions = Some(RasterDimensions::new(width, height)?); color_type = Some(parsed_color_type); } b"IHDR" => return invalid_raster(), @@ -1113,7 +1061,7 @@ fn inspect_png_container(bytes: &[u8]) -> Result<RasterContainerInspection, Radr b"acTL" | b"fcTL" | b"fdAT" => animated = true, b"IEND" if data.is_empty() && has_image_data && position == bytes.len() => { return Ok(RasterContainerInspection { - dimensions: dimensions.ok_or(RadrootsBlossomError::InvalidPublicationRaster)?, + dimensions: dimensions.ok_or(Error::InvalidPublicationRaster)?, animated, }); } @@ -1127,14 +1075,14 @@ fn inspect_png_container(bytes: &[u8]) -> Result<RasterContainerInspection, Radr } #[cfg(any(feature = "raster-decode", test))] -fn inspect_webp_container(bytes: &[u8]) -> Result<RasterContainerInspection, RadrootsBlossomError> { +fn inspect_webp_container(bytes: &[u8]) -> Result<RasterContainerInspection, Error> { if bytes.len() < 20 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" { return invalid_raster(); } let riff_size = u32::from_le_bytes( bytes[4..8] .try_into() - .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + .map_err(|_| Error::InvalidPublicationRaster)?, ) as usize; if riff_size.checked_add(8) != Some(bytes.len()) { return invalid_raster(); @@ -1149,33 +1097,33 @@ fn inspect_webp_container(bytes: &[u8]) -> Result<RasterContainerInspection, Rad while position < bytes.len() { records = records .checked_add(1) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; if records > PUBLICATION_RASTER_MAX_CONTAINER_RECORDS { return invalid_raster(); } let header_end = position .checked_add(8) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; let header = bytes .get(position..header_end) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; let kind: [u8; 4] = header[..4] .try_into() - .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?; + .map_err(|_| Error::InvalidPublicationRaster)?; let length = u32::from_le_bytes( header[4..] .try_into() - .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + .map_err(|_| Error::InvalidPublicationRaster)?, ) as usize; let data_end = header_end .checked_add(length) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; let padded_end = data_end .checked_add(length & 1) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; let data = bytes .get(header_end..data_end) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; if padded_end > bytes.len() { return invalid_raster(); } @@ -1185,51 +1133,44 @@ fn inspect_webp_container(bytes: &[u8]) -> Result<RasterContainerInspection, Rad b"ANIM" | b"ANMF" => animated = true, b"VP8X" if data.len() == 10 && !extended && primary_chunks == 0 => { if data[0] & 0b1100_0001 != 0 || data[1..4] != [0, 0, 0] { - return Err(RadrootsBlossomError::PublicationRasterProcessForbidden); + return Err(Error::PublicationRasterProcessForbidden); } extended = true; animated |= data[0] & 0b0000_0010 != 0; let width = 1 + read_u24_le(&data[4..7]); let height = 1 + read_u24_le(&data[7..10]); - dimensions = Some(RadrootsBlossomRasterDimensions::new(width, height)?); + dimensions = Some(RasterDimensions::new(width, height)?); } b"VP8X" => return invalid_raster(), b"VP8L" => { primary_chunks = primary_chunks.saturating_add(1); - let header = data - .get(..5) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let header = data.get(..5).ok_or(Error::InvalidPublicationRaster)?; if header[0] != 0x2f { return invalid_raster(); } let bits = u32::from_le_bytes( header[1..5] .try_into() - .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + .map_err(|_| Error::InvalidPublicationRaster)?, ); - let parsed = RadrootsBlossomRasterDimensions::new( - (bits & 0x3fff) + 1, - ((bits >> 14) & 0x3fff) + 1, - )?; + let parsed = + RasterDimensions::new((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1)?; if dimensions.is_some_and(|value| value != parsed) { - return Err(RadrootsBlossomError::PublicationRasterContainerDimensionMismatch); + return Err(Error::PublicationRasterContainerDimensionMismatch); } dimensions = Some(parsed); } b"VP8 " => { primary_chunks = primary_chunks.saturating_add(1); - let header = data - .get(..10) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let header = data.get(..10).ok_or(Error::InvalidPublicationRaster)?; if &header[3..6] != b"\x9d\x01\x2a" { return invalid_raster(); } let width = u16::from_le_bytes([header[6], header[7]]) & 0x3fff; let height = u16::from_le_bytes([header[8], header[9]]) & 0x3fff; - let parsed = - RadrootsBlossomRasterDimensions::new(u32::from(width), u32::from(height))?; + let parsed = RasterDimensions::new(u32::from(width), u32::from(height))?; if dimensions.is_some_and(|value| value != parsed) { - return Err(RadrootsBlossomError::PublicationRasterContainerDimensionMismatch); + return Err(Error::PublicationRasterContainerDimensionMismatch); } dimensions = Some(parsed); } @@ -1244,7 +1185,7 @@ fn inspect_webp_container(bytes: &[u8]) -> Result<RasterContainerInspection, Rad return invalid_raster(); } Ok(RasterContainerInspection { - dimensions: dimensions.ok_or(RadrootsBlossomError::InvalidPublicationRaster)?, + dimensions: dimensions.ok_or(Error::InvalidPublicationRaster)?, animated, }) } @@ -1255,7 +1196,7 @@ fn read_u24_le(bytes: &[u8]) -> u32 { } #[cfg(any(feature = "raster-decode", test))] -fn inspect_jpeg_container(bytes: &[u8]) -> Result<JpegContainerInspection, RadrootsBlossomError> { +fn inspect_jpeg_container(bytes: &[u8]) -> Result<JpegContainerInspection, Error> { if bytes.len() < 4 || !bytes.starts_with(b"\xff\xd8") { return invalid_raster(); } @@ -1266,7 +1207,7 @@ fn inspect_jpeg_container(bytes: &[u8]) -> Result<JpegContainerInspection, Radro loop { records = records .checked_add(1) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; if records > PUBLICATION_RASTER_MAX_CONTAINER_RECORDS { return invalid_raster(); } @@ -1276,19 +1217,17 @@ fn inspect_jpeg_container(bytes: &[u8]) -> Result<JpegContainerInspection, Radro while bytes.get(position) == Some(&0xff) { position = position .checked_add(1) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; } - let marker = *bytes - .get(position) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let marker = *bytes.get(position).ok_or(Error::InvalidPublicationRaster)?; position = position .checked_add(1) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; match marker { 0xd9 if position == bytes.len() => { return Ok(JpegContainerInspection { - dimensions: dimensions.ok_or(RadrootsBlossomError::InvalidPublicationRaster)?, - components: components.ok_or(RadrootsBlossomError::InvalidPublicationRaster)?, + dimensions: dimensions.ok_or(Error::InvalidPublicationRaster)?, + components: components.ok_or(Error::InvalidPublicationRaster)?, }); } 0xd9 | 0x00 | 0xd8 | 0xd0..=0xd7 => return invalid_raster(), @@ -1298,14 +1237,14 @@ fn inspect_jpeg_container(bytes: &[u8]) -> Result<JpegContainerInspection, Radro let length_end = position .checked_add(2) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; let length_bytes = bytes .get(position..length_end) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; let length = usize::from(u16::from_be_bytes( length_bytes .try_into() - .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + .map_err(|_| Error::InvalidPublicationRaster)?, )); if length < 2 { return invalid_raster(); @@ -1313,10 +1252,10 @@ fn inspect_jpeg_container(bytes: &[u8]) -> Result<JpegContainerInspection, Radro let data_start = length_end; let data_end = position .checked_add(length) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; let data = bytes .get(data_start..data_end) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; position = data_end; if is_jpeg_start_of_frame(marker) { @@ -1324,7 +1263,7 @@ fn inspect_jpeg_container(bytes: &[u8]) -> Result<JpegContainerInspection, Radro return invalid_raster(); } if !matches!(marker, 0xc0 | 0xc1) || data[0] != 8 { - return Err(RadrootsBlossomError::PublicationJpegProcessForbidden); + return Err(Error::PublicationJpegProcessForbidden); } let component_count = data[5]; if !matches!(component_count, 1 | 3 | 4) @@ -1334,7 +1273,7 @@ fn inspect_jpeg_container(bytes: &[u8]) -> Result<JpegContainerInspection, Radro } let height = u32::from(u16::from_be_bytes([data[1], data[2]])); let width = u32::from(u16::from_be_bytes([data[3], data[4]])); - dimensions = Some(RadrootsBlossomRasterDimensions::new(width, height)?); + dimensions = Some(RasterDimensions::new(width, height)?); components = Some(component_count); } @@ -1353,28 +1292,26 @@ fn is_jpeg_start_of_frame(marker: u8) -> bool { } #[cfg(any(feature = "raster-decode", test))] -fn jpeg_scan_end(bytes: &[u8], mut position: usize) -> Result<usize, RadrootsBlossomError> { +fn jpeg_scan_end(bytes: &[u8], mut position: usize) -> Result<usize, Error> { while position < bytes.len() { if bytes[position] != 0xff { position = position .checked_add(1) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; continue; } let marker_start = position; while bytes.get(position) == Some(&0xff) { position = position .checked_add(1) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; } - let marker = *bytes - .get(position) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let marker = *bytes.get(position).ok_or(Error::InvalidPublicationRaster)?; match marker { 0x00 | 0xd0..=0xd7 => { position = position .checked_add(1) - .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + .ok_or(Error::InvalidPublicationRaster)?; } _ => return Ok(marker_start), } @@ -1383,32 +1320,26 @@ fn jpeg_scan_end(bytes: &[u8], mut position: usize) -> Result<usize, RadrootsBlo } #[cfg(test)] -fn validate_png_container( - bytes: &[u8], -) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { +fn validate_png_container(bytes: &[u8]) -> Result<RasterDimensions, Error> { static_container_dimensions(inspect_png_container(bytes)?) } #[cfg(test)] -fn validate_webp_container( - bytes: &[u8], -) -> Result<Option<RadrootsBlossomRasterDimensions>, RadrootsBlossomError> { +fn validate_webp_container(bytes: &[u8]) -> Result<Option<RasterDimensions>, Error> { static_container_dimensions(inspect_webp_container(bytes)?).map(Some) } #[cfg(test)] -fn validate_jpeg_container( - bytes: &[u8], -) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { +fn validate_jpeg_container(bytes: &[u8]) -> Result<RasterDimensions, Error> { Ok(inspect_jpeg_container(bytes)?.dimensions) } #[cfg(test)] fn static_container_dimensions( inspection: RasterContainerInspection, -) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { +) -> Result<RasterDimensions, Error> { if inspection.animated { - return Err(RadrootsBlossomError::PublicationRasterAnimationForbidden); + return Err(Error::PublicationRasterAnimationForbidden); } Ok(inspection.dimensions) } @@ -1416,7 +1347,7 @@ fn static_container_dimensions( #[cfg(test)] mod tests { use super::*; - use crate::RadrootsBlossomByteVerifiedDescriptor; + use crate::ByteVerifiedDescriptor; #[cfg(feature = "raster-decode")] use alloc::boxed::Box; use alloc::{format, string::ToString}; @@ -1524,20 +1455,20 @@ mod tests { output } - fn descriptor(bytes: &[u8], media_type: &str, origin: &str) -> RadrootsBlossomBlobDescriptor { - let hash = RadrootsBlossomSha256::digest(bytes); - RadrootsBlossomBlobDescriptor::new( - crate::RadrootsBlossomBlobUrl::parse(&format!("{origin}/{hash}.png")).unwrap(), + fn descriptor(bytes: &[u8], media_type: &str, origin: &str) -> BlobDescriptor { + let hash = Sha256::digest(bytes); + BlobDescriptor::new( + crate::BlobUrl::parse(&format!("{origin}/{hash}.png")).unwrap(), hash, bytes.len() as u64, - RadrootsBlossomMediaType::parse(media_type).unwrap(), + MediaType::parse(media_type).unwrap(), 1_800_000_000, ) .unwrap() } - fn verified(bytes: &[u8]) -> RadrootsBlossomByteVerifiedDescriptor { - let media_type = RadrootsBlossomMediaType::parse("image/png").unwrap(); + fn verified(bytes: &[u8]) -> ByteVerifiedDescriptor { + let media_type = MediaType::parse("image/png").unwrap(); descriptor(bytes, "image/png", "https://cdn.example") .approve_reference() .unwrap() @@ -1548,32 +1479,20 @@ mod tests { fn observations( bytes: &[u8], ) -> ( - RadrootsBlossomBud02UploadObservation, - RadrootsBlossomBud01HeadObservation, - RadrootsBlossomBud01GetObservation, + Bud02UploadObservation, + Bud01HeadObservation, + Bud01GetObservation, ) { let expected = verified(bytes); - let upload = RadrootsBlossomBud02UploadObservation::new( - 201, - descriptor(bytes, "image/png", "https://cdn.example"), - ) - .unwrap(); + let upload = + Bud02UploadObservation::new(201, descriptor(bytes, "image/png", "https://cdn.example")) + .unwrap(); let url = expected.url().clone(); - let media_type = RadrootsBlossomMediaType::parse("image/png").unwrap(); - let head = RadrootsBlossomBud01HeadObservation::new( - 200, - url.clone(), - bytes.len() as u64, - media_type, - ) - .unwrap(); - let get = RadrootsBlossomBud01GetObservation::from_complete_body( - 200, - url, - bytes.len() as u64, - bytes, - ) - .unwrap(); + let media_type = MediaType::parse("image/png").unwrap(); + let head = + Bud01HeadObservation::new(200, url.clone(), bytes.len() as u64, media_type).unwrap(); + let get = + Bud01GetObservation::from_complete_body(200, url, bytes.len() as u64, bytes).unwrap(); (upload, head, get) } @@ -1585,9 +1504,7 @@ mod tests { let evidence = verify_publication_readiness( &expected, PNG, - RadrootsBlossomAuthoredRasterDimensions::Exact( - RadrootsBlossomRasterDimensions::new(1, 1).unwrap(), - ), + AuthoredRasterDimensions::Exact(RasterDimensions::new(1, 1).unwrap()), &upload, &head, &get, @@ -1597,10 +1514,10 @@ mod tests { evidence.url().as_str(), "https://cdn.example/4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd.png" ); - assert_eq!(evidence.sha256(), RadrootsBlossomSha256::digest(PNG)); + assert_eq!(evidence.sha256(), Sha256::digest(PNG)); assert_eq!(evidence.size(), PNG.len() as u64); assert_eq!(evidence.media_type().as_str(), "image/png"); - assert_eq!(evidence.raster_format(), RadrootsBlossomRasterFormat::Png); + assert_eq!(evidence.raster_format(), RasterFormat::Png); assert_eq!(evidence.dimensions().pixels(), 1); assert_eq!(evidence.bud02_status().as_u16(), 201); assert_eq!(evidence.uploaded(), 1_800_000_000); @@ -1622,7 +1539,7 @@ mod tests { let mut evidence = verify_publication_readiness( &expected, PNG, - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, &upload, &head, &get, @@ -1633,13 +1550,10 @@ mod tests { evidence.sha256(), "p".repeat(RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES), ); - evidence.url = RadrootsBlossomBlobUrl::parse(&oversized_url) - .unwrap() - .approve() - .unwrap(); + evidence.url = BlobUrl::parse(&oversized_url).unwrap().approve().unwrap(); match evidence.to_canonical_json().unwrap_err() { - RadrootsBlossomError::PublicationReadinessEvidenceTooLarge { max, actual } => { + Error::PublicationReadinessEvidenceTooLarge { max, actual } => { assert_eq!( max, RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES @@ -1659,7 +1573,7 @@ mod tests { let evidence = verify_publication_readiness( &expected, PNG, - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, &upload, &head, &get, @@ -1707,14 +1621,14 @@ mod tests { fn bounded_get_collector_rejects_status_bounds_and_body_shape() { let url = verified(PNG).url().clone(); assert_eq!( - RadrootsBlossomBud01GetCollector::new(206, url.clone(), 1) + Bud01GetCollector::new(206, url.clone(), 1) .err() .unwrap() .code(), "invalid_bud01_get_status" ); assert_eq!( - RadrootsBlossomBud01GetCollector::new( + Bud01GetCollector::new( 200, url.clone(), RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES + 1, @@ -1725,7 +1639,7 @@ mod tests { "publication_raster_byte_limit_exceeded" ); assert_eq!( - RadrootsBlossomBud01GetCollector::new(200, url.clone(), 1) + Bud01GetCollector::new(200, url.clone(), 1) .unwrap() .finish() .err() @@ -1733,13 +1647,13 @@ mod tests { .code(), "publication_get_body_missing" ); - let mut short = RadrootsBlossomBud01GetCollector::new(200, url.clone(), 2).unwrap(); + let mut short = Bud01GetCollector::new(200, url.clone(), 2).unwrap(); short.push_chunk(b"a").unwrap(); assert_eq!( short.finish().err().unwrap().code(), "publication_get_body_short" ); - let mut trailing = RadrootsBlossomBud01GetCollector::new(200, url, 1).unwrap(); + let mut trailing = Bud01GetCollector::new(200, url, 1).unwrap(); assert_eq!( trailing.push_chunk(b"ab").unwrap_err().code(), "publication_get_body_trailing" @@ -1749,21 +1663,18 @@ mod tests { #[test] fn observation_constructors_reject_invalid_status_and_dimensions() { assert_eq!( - RadrootsBlossomBud02UploadObservation::new( - 204, - descriptor(PNG, "image/png", "https://cdn.example") - ) - .unwrap_err() - .code(), + Bud02UploadObservation::new(204, descriptor(PNG, "image/png", "https://cdn.example")) + .unwrap_err() + .code(), "invalid_bud02_upload_status" ); let url = verified(PNG).url().clone(); assert_eq!( - RadrootsBlossomBud01HeadObservation::new( + Bud01HeadObservation::new( 204, url, PNG.len() as u64, - RadrootsBlossomMediaType::parse("image/png").unwrap(), + MediaType::parse("image/png").unwrap(), ) .unwrap_err() .code(), @@ -1774,22 +1685,15 @@ mod tests { (5_000, 5_000, "publication_raster_pixel_limit_exceeded"), ] { assert_eq!( - RadrootsBlossomRasterDimensions::new(width, height) - .unwrap_err() - .code(), + RasterDimensions::new(width, height).unwrap_err().code(), code ); } + assert_eq!(RasterFormat::StillWebP.to_string(), "still_webp"); assert_eq!( - RadrootsBlossomRasterFormat::StillWebP.to_string(), - "still_webp" - ); - assert_eq!( - RadrootsBlossomRasterFormat::from_media_type( - &RadrootsBlossomMediaType::parse("image/png;charset=utf-8").unwrap(), - ) - .unwrap_err() - .code(), + RasterFormat::from_media_type(&MediaType::parse("image/png;charset=utf-8").unwrap(),) + .unwrap_err() + .code(), "unsupported_publication_raster_media_type" ); } @@ -1797,21 +1701,15 @@ mod tests { #[test] fn raster_dimensions_reject_each_axis_boundary() { assert_eq!( - RadrootsBlossomRasterDimensions::new( - RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION, - 1, - ) - .unwrap() - .pixels(), + RasterDimensions::new(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION, 1,) + .unwrap() + .pixels(), u64::from(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION) ); assert_eq!( - RadrootsBlossomRasterDimensions::new( - 1, - RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION, - ) - .unwrap() - .pixels(), + RasterDimensions::new(1, RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION,) + .unwrap() + .pixels(), u64::from(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION) ); for (width, height) in [ @@ -1821,22 +1719,16 @@ mod tests { (1, RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION + 1), ] { assert_eq!( - RadrootsBlossomRasterDimensions::new(width, height) - .unwrap_err() - .code(), + RasterDimensions::new(width, height).unwrap_err().code(), "publication_raster_dimensions_out_of_range" ); } assert_eq!( - RadrootsBlossomRasterDimensions::new(5_000, 4_000) - .unwrap() - .pixels(), + RasterDimensions::new(5_000, 4_000).unwrap().pixels(), RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS ); assert_eq!( - RadrootsBlossomRasterDimensions::new(5_001, 4_000) - .unwrap_err() - .code(), + RasterDimensions::new(5_001, 4_000).unwrap_err().code(), "publication_raster_pixel_limit_exceeded" ); } @@ -1854,7 +1746,7 @@ mod tests { verify_publication_readiness( &oversized_descriptor, &oversized, - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, &upload, &head, &get, @@ -1864,7 +1756,7 @@ mod tests { "publication_raster_byte_limit_exceeded" ); - let expected_hash = RadrootsBlossomSha256::digest(PNG); + let expected_hash = Sha256::digest(PNG); let mut different_bytes = PNG.to_vec(); different_bytes[0] ^= 1; assert_eq!( @@ -1874,14 +1766,9 @@ mod tests { "publication_retrieved_bytes_mismatch" ); assert_eq!( - validate_retrieved_body( - expected_hash, - PNG, - PNG, - RadrootsBlossomSha256::digest(b"different"), - ) - .unwrap_err() - .code(), + validate_retrieved_body(expected_hash, PNG, PNG, Sha256::digest(b"different"),) + .unwrap_err() + .code(), "publication_retrieved_bytes_hash_mismatch" ); validate_retrieved_body(expected_hash, PNG, PNG, expected_hash).unwrap(); @@ -1891,15 +1778,15 @@ mod tests { fn closed_container_validation_accepts_each_format() { assert_eq!( validate_png_container(PNG).unwrap(), - RadrootsBlossomRasterDimensions::new(1, 1).unwrap() + RasterDimensions::new(1, 1).unwrap() ); assert_eq!( validate_jpeg_container(JPEG).unwrap(), - RadrootsBlossomRasterDimensions::new(1, 1).unwrap() + RasterDimensions::new(1, 1).unwrap() ); assert_eq!( validate_webp_container(STILL_WEBP).unwrap(), - Some(RadrootsBlossomRasterDimensions::new(1, 1).unwrap()) + Some(RasterDimensions::new(1, 1).unwrap()) ); } @@ -1999,7 +1886,7 @@ mod tests { ]); assert_eq!( validate_png_container(&with_ancillary).unwrap(), - RadrootsBlossomRasterDimensions::new(1, 1).unwrap() + RasterDimensions::new(1, 1).unwrap() ); let nonempty_iend = @@ -2057,7 +1944,7 @@ mod tests { ]); assert_eq!( validate_png_container(&indexed).unwrap(), - RadrootsBlossomRasterDimensions::new(1, 1).unwrap() + RasterDimensions::new(1, 1).unwrap() ); assert!( @@ -2168,7 +2055,7 @@ mod tests { let extended_lossless = webp_with_chunks(&[(*b"VP8X", &vp8x_1x1), (*b"VP8L", &vp8l_1x1)]); assert_eq!( validate_webp_container(&extended_lossless).unwrap(), - Some(RadrootsBlossomRasterDimensions::new(1, 1).unwrap()) + Some(RasterDimensions::new(1, 1).unwrap()) ); assert_eq!( validate_webp_container(&webp_with_chunks(&[(*b"VP8X", &[0; 9])])) @@ -2209,7 +2096,7 @@ mod tests { assert_eq!( validate_webp_container(&webp_with_chunks(&[(*b"VP8 ", &vp8_1x1)])).unwrap(), - Some(RadrootsBlossomRasterDimensions::new(1, 1).unwrap()) + Some(RasterDimensions::new(1, 1).unwrap()) ); let mut bad_vp8_signature = vp8_1x1; bad_vp8_signature[3] = 0; @@ -2232,7 +2119,7 @@ mod tests { let with_unknown = webp_with_chunks(&[(*b"JUNK", &[0; 2]), (*b"VP8L", &vp8l_1x1)]); assert_eq!( validate_webp_container(&with_unknown).unwrap(), - Some(RadrootsBlossomRasterDimensions::new(1, 1).unwrap()) + Some(RasterDimensions::new(1, 1).unwrap()) ); for malformed in [ webp_with_chunks(&[(*b"JUNK", &[0; 8])]), @@ -2295,7 +2182,7 @@ mod tests { sequential_jpeg::validate( &one_over, JpegContainerInspection { - dimensions: RadrootsBlossomRasterDimensions::new(1, 1).unwrap(), + dimensions: RasterDimensions::new(1, 1).unwrap(), components: 1, }, ) @@ -2344,14 +2231,14 @@ mod tests { let temporal_marker = [&JPEG[..2], &[0xff, 0x01], &JPEG[2..]].concat(); assert_eq!( validate_jpeg_container(&temporal_marker).unwrap(), - RadrootsBlossomRasterDimensions::new(1, 1).unwrap() + RasterDimensions::new(1, 1).unwrap() ); let mut stuffed_and_restart = JPEG[..JPEG.len() - 2].to_vec(); stuffed_and_restart.extend_from_slice(&[0xff, 0x00, 0xff, 0xd0, 0xff, 0xd9]); assert_eq!( validate_jpeg_container(&stuffed_and_restart).unwrap(), - RadrootsBlossomRasterDimensions::new(1, 1).unwrap() + RasterDimensions::new(1, 1).unwrap() ); } @@ -2426,7 +2313,7 @@ mod tests { ); } - let dimensions = RadrootsBlossomRasterDimensions::new(1, 1).unwrap(); + let dimensions = RasterDimensions::new(1, 1).unwrap(); let decoder = |dimensions, total_bytes, fail_limits, fail_read| FakeDecoder { dimensions, total_bytes, @@ -2559,7 +2446,7 @@ mod tests { } let mismatched_container = JpegContainerInspection { - dimensions: RadrootsBlossomRasterDimensions::new(2, 1).unwrap(), + dimensions: RasterDimensions::new(2, 1).unwrap(), ..container }; assert_eq!( diff --git a/crates/blossom/src/publication_readiness/sequential_jpeg.rs b/crates/blossom/src/publication_readiness/sequential_jpeg.rs @@ -1,8 +1,8 @@ use alloc::vec::Vec; use super::{ - JpegContainerInspection, PUBLICATION_RASTER_MAX_CONTAINER_RECORDS, RadrootsBlossomError, - RadrootsBlossomRasterDimensions, is_jpeg_start_of_frame, + Error, JpegContainerInspection, PUBLICATION_RASTER_MAX_CONTAINER_RECORDS, RasterDimensions, + is_jpeg_start_of_frame, }; const MAX_SEQUENTIAL_JPEG_SCANS: u8 = 4; @@ -17,7 +17,7 @@ struct SequentialJpegComponent { } struct SequentialJpegFrame { - dimensions: RadrootsBlossomRasterDimensions, + dimensions: RasterDimensions, components: Vec<SequentialJpegComponent>, maximum_horizontal_sampling: u8, maximum_vertical_sampling: u8, @@ -41,7 +41,7 @@ struct SequentialJpegHuffmanTable { } impl SequentialJpegHuffmanTable { - fn new(class: u8, code_counts: [u8; 16], values: &[u8]) -> Result<Self, RadrootsBlossomError> { + fn new(class: u8, code_counts: [u8; 16], values: &[u8]) -> Result<Self, Error> { if class > 1 { return invalid_entropy(); } @@ -93,7 +93,7 @@ impl SequentialJpegHuffmanTable { let mut owned_values = Vec::new(); owned_values .try_reserve_exact(values.len()) - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeAllocationFailed)?; + .map_err(|_| Error::PublicationRasterDecodeAllocationFailed)?; owned_values.extend_from_slice(values); Ok(Self { first_codes, @@ -103,10 +103,7 @@ impl SequentialJpegHuffmanTable { }) } - fn decode_symbol( - &self, - reader: &mut SequentialJpegEntropyReader<'_>, - ) -> Result<u8, RadrootsBlossomError> { + fn decode_symbol(&self, reader: &mut SequentialJpegEntropyReader<'_>) -> Result<u8, Error> { let mut code = 0_u32; for index in 0..16 { code = (code << 1) | u32::from(reader.read_bit()?); @@ -118,7 +115,7 @@ impl SequentialJpegHuffmanTable { .values .get(offset) .copied() - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed); + .ok_or(Error::PublicationRasterDecodeFailed); } } invalid_entropy() @@ -147,11 +144,11 @@ impl<'a> SequentialJpegEntropyReader<'a> { } } - fn read_bit(&mut self) -> Result<u8, RadrootsBlossomError> { + fn read_bit(&mut self) -> Result<u8, Error> { self.bit_reads_remaining = self .bit_reads_remaining .checked_sub(1) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; if self.bits_remaining == 0 { self.current_byte = self.read_entropy_byte()?; self.bits_remaining = 8; @@ -160,18 +157,18 @@ impl<'a> SequentialJpegEntropyReader<'a> { Ok((self.current_byte >> self.bits_remaining) & 1) } - fn discard_bits(&mut self, count: u8) -> Result<(), RadrootsBlossomError> { + fn discard_bits(&mut self, count: u8) -> Result<(), Error> { for _ in 0..count { self.read_bit()?; } Ok(()) } - fn read_entropy_byte(&mut self) -> Result<u8, RadrootsBlossomError> { + fn read_entropy_byte(&mut self) -> Result<u8, Error> { let byte = *self .bytes .get(self.position) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; self.position += 1; if byte != 0xff { return Ok(byte); @@ -183,7 +180,7 @@ impl<'a> SequentialJpegEntropyReader<'a> { invalid_entropy() } - fn finish_restart(&mut self, expected: u8) -> Result<(), RadrootsBlossomError> { + fn finish_restart(&mut self, expected: u8) -> Result<(), Error> { if expected > 7 { return invalid_entropy(); } @@ -196,7 +193,7 @@ impl<'a> SequentialJpegEntropyReader<'a> { Ok(()) } - fn finish_scan(mut self) -> Result<usize, RadrootsBlossomError> { + fn finish_scan(mut self) -> Result<usize, Error> { self.finish_padding()?; let (marker, _) = strict_marker(self.bytes, self.position)?; if matches!(marker, 0xd0..=0xd7) { @@ -205,7 +202,7 @@ impl<'a> SequentialJpegEntropyReader<'a> { Ok(self.position) } - fn finish_padding(&mut self) -> Result<(), RadrootsBlossomError> { + fn finish_padding(&mut self) -> Result<(), Error> { if self.bits_remaining != 0 { let mask = (1_u16 << self.bits_remaining) - 1; if u16::from(self.current_byte) & mask != mask { @@ -232,35 +229,32 @@ impl SequentialJpegWorkBudget { } } - fn charge_scan(&mut self) -> Result<(), RadrootsBlossomError> { + fn charge_scan(&mut self) -> Result<(), Error> { self.scans_remaining = self .scans_remaining .checked_sub(1) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; Ok(()) } - fn charge_block(&mut self) -> Result<(), RadrootsBlossomError> { + fn charge_block(&mut self) -> Result<(), Error> { self.blocks_remaining = self .blocks_remaining .checked_sub(1) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; Ok(()) } - fn charge_coefficient_step(&mut self) -> Result<(), RadrootsBlossomError> { + fn charge_coefficient_step(&mut self) -> Result<(), Error> { self.coefficient_steps_remaining = self .coefficient_steps_remaining .checked_sub(1) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; Ok(()) } } -pub(super) fn validate( - bytes: &[u8], - container: JpegContainerInspection, -) -> Result<(), RadrootsBlossomError> { +pub(super) fn validate(bytes: &[u8], container: JpegContainerInspection) -> Result<(), Error> { if !bytes.starts_with(b"\xff\xd8") { return invalid_entropy(); } @@ -276,16 +270,14 @@ pub(super) fn validate( loop { records = records .checked_add(1) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; if records > PUBLICATION_RASTER_MAX_CONTAINER_RECORDS { return invalid_entropy(); } let (marker, after_marker) = strict_marker(bytes, position)?; match marker { 0xd9 => { - let current_frame = frame - .as_ref() - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let current_frame = frame.as_ref().ok_or(Error::PublicationRasterDecodeFailed)?; if after_marker != bytes.len() { return invalid_entropy(); } @@ -310,13 +302,13 @@ pub(super) fn validate( if parsed.dimensions != container.dimensions || parsed.components.len() != usize::from(container.components) { - return Err(RadrootsBlossomError::PublicationRasterContainerDimensionMismatch); + return Err(Error::PublicationRasterContainerDimensionMismatch); } frame = Some(parsed); position = next; } marker if is_jpeg_start_of_frame(marker) || marker == 0xcc => { - return Err(RadrootsBlossomError::PublicationJpegProcessForbidden); + return Err(Error::PublicationJpegProcessForbidden); } 0xc4 => { let (payload, next) = strict_segment(bytes, after_marker)?; @@ -333,9 +325,7 @@ pub(super) fn validate( } 0xda => { work_budget.charge_scan()?; - let current_frame = frame - .as_ref() - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let current_frame = frame.as_ref().ok_or(Error::PublicationRasterDecodeFailed)?; let (payload, entropy_start) = strict_segment(bytes, after_marker)?; let scan_result = parse_scan( payload, @@ -370,7 +360,7 @@ pub(super) fn validate( } } -fn strict_marker(bytes: &[u8], position: usize) -> Result<(u8, usize), RadrootsBlossomError> { +fn strict_marker(bytes: &[u8], position: usize) -> Result<(u8, usize), Error> { if bytes.get(position) != Some(&0xff) { return invalid_entropy(); } @@ -380,7 +370,7 @@ fn strict_marker(bytes: &[u8], position: usize) -> Result<(u8, usize), RadrootsB } let marker = *bytes .get(code_position) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; if marker == 0x00 { return invalid_entropy(); } @@ -388,30 +378,27 @@ fn strict_marker(bytes: &[u8], position: usize) -> Result<(u8, usize), RadrootsB Ok((marker, after_marker)) } -fn strict_segment( - bytes: &[u8], - after_marker: usize, -) -> Result<(&[u8], usize), RadrootsBlossomError> { +fn strict_segment(bytes: &[u8], after_marker: usize) -> Result<(&[u8], usize), Error> { let payload_start = after_marker .checked_add(2) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; let length_bytes = bytes .get(after_marker..payload_start) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; let length = usize::from(u16::from_be_bytes([length_bytes[0], length_bytes[1]])); if length < 2 { return invalid_entropy(); } let end = after_marker .checked_add(length) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; let payload = bytes .get(payload_start..end) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; Ok((payload, end)) } -fn parse_frame(payload: &[u8]) -> Result<SequentialJpegFrame, RadrootsBlossomError> { +fn parse_frame(payload: &[u8]) -> Result<SequentialJpegFrame, Error> { if payload.len() < 6 || payload[0] != 8 { return invalid_entropy(); } @@ -420,14 +407,14 @@ fn parse_frame(payload: &[u8]) -> Result<SequentialJpegFrame, RadrootsBlossomErr if !matches!(component_count, 1 | 3 | 4) || payload.len() != expected_length { return invalid_entropy(); } - let dimensions = RadrootsBlossomRasterDimensions::new( + let dimensions = RasterDimensions::new( u32::from(u16::from_be_bytes([payload[3], payload[4]])), u32::from(u16::from_be_bytes([payload[1], payload[2]])), )?; let mut components = Vec::new(); components .try_reserve_exact(component_count) - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeAllocationFailed)?; + .map_err(|_| Error::PublicationRasterDecodeAllocationFailed)?; let mut maximum_horizontal_sampling = 0_u8; let mut maximum_vertical_sampling = 0_u8; let mut sampling_product_sum = 0_u8; @@ -467,12 +454,12 @@ fn parse_huffman_tables( payload: &[u8], dc_tables: &mut [Option<SequentialJpegHuffmanTable>; 4], ac_tables: &mut [Option<SequentialJpegHuffmanTable>; 4], -) -> Result<(), RadrootsBlossomError> { +) -> Result<(), Error> { let mut position = 0_usize; while position < payload.len() { let selector = *payload .get(position) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; position += 1; let class = selector >> 4; let destination = usize::from(selector & 0x0f); @@ -482,10 +469,10 @@ fn parse_huffman_tables( let counts_end = position + 16; let counts_slice = payload .get(position..counts_end) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; let code_counts: [u8; 16] = counts_slice .try_into() - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .map_err(|_| Error::PublicationRasterDecodeFailed)?; position = counts_end; let value_count: usize = code_counts.iter().map(|count| usize::from(*count)).sum(); if value_count > 256 { @@ -494,7 +481,7 @@ fn parse_huffman_tables( let values_end = position + value_count; let values = payload .get(position..values_end) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; position = values_end; let table = SequentialJpegHuffmanTable::new(class, code_counts, values)?; if class == 0 { @@ -512,7 +499,7 @@ fn parse_scan( seen_components: &[bool; 4], dc_tables: &[Option<SequentialJpegHuffmanTable>; 4], ac_tables: &[Option<SequentialJpegHuffmanTable>; 4], -) -> Result<SequentialJpegScan, RadrootsBlossomError> { +) -> Result<SequentialJpegScan, Error> { let component_count = payload.first().copied().map_or(0, usize::from); let expected_length = component_count * 2 + 4; if component_count == 0 @@ -526,13 +513,13 @@ fn parse_scan( let mut components = Vec::new(); components .try_reserve_exact(component_count) - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeAllocationFailed)?; + .map_err(|_| Error::PublicationRasterDecodeAllocationFailed)?; for data in payload[1..selectors_end].chunks_exact(2) { let frame_index = frame .components .iter() .position(|component| component.id == data[0]) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; let dc_table = usize::from(data[1] >> 4); let ac_table = usize::from(data[1] & 0x0f); if components @@ -565,7 +552,7 @@ fn validate_scan_entropy( ac_tables: &[Option<SequentialJpegHuffmanTable>; 4], restart_interval: usize, work_budget: &mut SequentialJpegWorkBudget, -) -> Result<usize, RadrootsBlossomError> { +) -> Result<usize, Error> { let interleaved = scan.components.len() > 1; let mcu_count = scan_mcu_count(frame, scan, interleaved)?; let mut reader = SequentialJpegEntropyReader::new(bytes, entropy_start); @@ -580,7 +567,7 @@ fn validate_scan_entropy( .components .get(scan_component.frame_index) .copied() - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; let blocks = if interleaved { usize::from(frame_component.horizontal_sampling) * usize::from(frame_component.vertical_sampling) @@ -590,11 +577,11 @@ fn validate_scan_entropy( let dc_table = dc_tables .get(scan_component.dc_table) .and_then(Option::as_ref) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; let ac_table = ac_tables .get(scan_component.ac_table) .and_then(Option::as_ref) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; for _ in 0..blocks { work_budget.charge_block()?; validate_block(&mut reader, dc_table, ac_table, work_budget)?; @@ -608,7 +595,7 @@ fn scan_mcu_count( frame: &SequentialJpegFrame, scan: &SequentialJpegScan, interleaved: bool, -) -> Result<usize, RadrootsBlossomError> { +) -> Result<usize, Error> { let width = frame.dimensions.width() as usize; let height = frame.dimensions.height() as usize; if interleaved { @@ -619,11 +606,11 @@ fn scan_mcu_count( let scan_component = scan .components .first() - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; let component = frame .components .get(scan_component.frame_index) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + .ok_or(Error::PublicationRasterDecodeFailed)?; if frame.maximum_horizontal_sampling == 0 || frame.maximum_vertical_sampling == 0 { return invalid_entropy(); } @@ -639,7 +626,7 @@ fn checked_mcu_grid_count( height: usize, mcu_width: usize, mcu_height: usize, -) -> Result<usize, RadrootsBlossomError> { +) -> Result<usize, Error> { if width == 0 { return invalid_entropy(); } @@ -655,7 +642,7 @@ fn checked_mcu_grid_count( width .div_ceil(mcu_width) .checked_mul(height.div_ceil(mcu_height)) - .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed) + .ok_or(Error::PublicationRasterDecodeFailed) } fn validate_block( @@ -663,7 +650,7 @@ fn validate_block( dc_table: &SequentialJpegHuffmanTable, ac_table: &SequentialJpegHuffmanTable, work_budget: &mut SequentialJpegWorkBudget, -) -> Result<(), RadrootsBlossomError> { +) -> Result<(), Error> { let dc_magnitude = dc_table.decode_symbol(reader)?; reader.discard_bits(dc_magnitude)?; let mut coefficient = 1_usize; @@ -692,8 +679,8 @@ fn validate_block( Ok(()) } -fn invalid_entropy<T>() -> Result<T, RadrootsBlossomError> { - Err(RadrootsBlossomError::PublicationRasterDecodeFailed) +fn invalid_entropy<T>() -> Result<T, Error> { + Err(Error::PublicationRasterDecodeFailed) } #[cfg(test)] @@ -702,7 +689,7 @@ mod tests { use super::*; - fn assert_decode_failed<T>(result: Result<T, RadrootsBlossomError>) { + fn assert_decode_failed<T>(result: Result<T, Error>) { let error = result .err() .expect("expected publication raster decode failure"); @@ -762,7 +749,7 @@ mod tests { fn container(width: u32, components: u8) -> JpegContainerInspection { JpegContainerInspection { - dimensions: RadrootsBlossomRasterDimensions::new(width, 1).unwrap(), + dimensions: RasterDimensions::new(width, 1).unwrap(), components, } } @@ -1217,7 +1204,7 @@ mod tests { assert_decode_failed(checked_mcu_grid_count(usize::MAX, usize::MAX, 1, 1)); let frame = SequentialJpegFrame { - dimensions: RadrootsBlossomRasterDimensions::new(17, 17).unwrap(), + dimensions: RasterDimensions::new(17, 17).unwrap(), components: vec![SequentialJpegComponent { id: 1, horizontal_sampling: 1, @@ -1253,7 +1240,7 @@ mod tests { assert_decode_failed(scan_mcu_count(&frame, &invalid_index, false)); let zero_horizontal_maximum = SequentialJpegFrame { - dimensions: RadrootsBlossomRasterDimensions::new(1, 1).unwrap(), + dimensions: RasterDimensions::new(1, 1).unwrap(), components: vec![SequentialJpegComponent { id: 1, horizontal_sampling: 1, diff --git a/crates/blossom/src/url.rs b/crates/blossom/src/url.rs @@ -1,3 +1,11 @@ +//! Structural Blossom blob URLs and the Radroots-approved reference state. +//! +//! [`BlobUrl`] preserves received HTTP or HTTPS references after strict +//! structural parsing. [`BlobUrl::approve`] produces [`ApprovedBlobUrl`] only +//! for HTTPS and loopback HTTP references. Approval permits a caller to consider +//! the reference for transport; it does not perform a request or establish host +//! reputation, byte integrity, authenticity, or application media safety. + #[cfg(feature = "serde")] use alloc::string::String; use alloc::string::ToString; @@ -5,41 +13,41 @@ use core::{fmt, str::FromStr}; use unicode_general_category::{GeneralCategory, get_general_category}; use url_nostd::{Host, Url}; -use crate::{error::RadrootsBlossomError, hash::RadrootsBlossomHashPath}; +use crate::{error::Error, hash::HashPath}; #[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct RadrootsBlossomBlobUrl { +pub struct BlobUrl { url: Url, - hash_path: RadrootsBlossomHashPath, + hash_path: HashPath, } -impl RadrootsBlossomBlobUrl { - pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { +impl BlobUrl { + pub fn parse(value: &str) -> Result<Self, Error> { if !value.contains("://") || !raw_url_text_is_valid(value) { - return Err(RadrootsBlossomError::InvalidBlobUrl); + return Err(Error::InvalidBlobUrl); } - let url = Url::parse(value).map_err(|_| RadrootsBlossomError::InvalidBlobUrl)?; + let url = Url::parse(value).map_err(|_| Error::InvalidBlobUrl)?; if url.scheme() != "http" && url.scheme() != "https" { - return Err(RadrootsBlossomError::UnsupportedBlobUrlScheme); + return Err(Error::UnsupportedBlobUrlScheme); } if raw_authority(value).contains('@') { - return Err(RadrootsBlossomError::BlobUrlCredentialsForbidden); + return Err(Error::BlobUrlCredentialsForbidden); } if url.query().is_some() { - return Err(RadrootsBlossomError::BlobUrlQueryForbidden); + return Err(Error::BlobUrlQueryForbidden); } if url.fragment().is_some() { - return Err(RadrootsBlossomError::BlobUrlFragmentForbidden); + return Err(Error::BlobUrlFragmentForbidden); } if value.contains('\\') || value.contains('%') || value.contains("/./") || value.contains("/../") { - return Err(RadrootsBlossomError::InvalidBlobUrl); + return Err(Error::InvalidBlobUrl); } validate_authority(value, &url)?; - let hash_path = RadrootsBlossomHashPath::parse(url.path())?; + let hash_path = HashPath::parse(url.path())?; Ok(Self { url, hash_path }) } @@ -61,7 +69,7 @@ impl RadrootsBlossomBlobUrl { self.url.port() } - pub fn hash_path(&self) -> &RadrootsBlossomHashPath { + pub fn hash_path(&self) -> &HashPath { &self.hash_path } @@ -73,22 +81,22 @@ impl RadrootsBlossomBlobUrl { self.url.scheme() == "http" && self.url.host().is_some_and(host_is_loopback) } - pub fn approve(self) -> Result<RadrootsBlossomApprovedBlobUrl, RadrootsBlossomError> { + pub fn approve(self) -> Result<ApprovedBlobUrl, Error> { if !self.is_https() && !self.is_loopback_http() { - return Err(RadrootsBlossomError::InsecureBlobUrl); + return Err(Error::InsecureBlobUrl); } - Ok(RadrootsBlossomApprovedBlobUrl(self)) + Ok(ApprovedBlobUrl(self)) } } -impl fmt::Display for RadrootsBlossomBlobUrl { +impl fmt::Display for BlobUrl { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } -impl FromStr for RadrootsBlossomBlobUrl { - type Err = RadrootsBlossomError; +impl FromStr for BlobUrl { + type Err = Error; fn from_str(value: &str) -> Result<Self, Self::Err> { Self::parse(value) @@ -96,7 +104,7 @@ impl FromStr for RadrootsBlossomBlobUrl { } #[cfg(feature = "serde")] -impl serde::Serialize for RadrootsBlossomBlobUrl { +impl serde::Serialize for BlobUrl { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::Serializer, @@ -106,7 +114,7 @@ impl serde::Serialize for RadrootsBlossomBlobUrl { } #[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for RadrootsBlossomBlobUrl { +impl<'de> serde::Deserialize<'de> for BlobUrl { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: serde::Deserializer<'de>, @@ -117,10 +125,10 @@ impl<'de> serde::Deserialize<'de> for RadrootsBlossomBlobUrl { } #[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct RadrootsBlossomApprovedBlobUrl(RadrootsBlossomBlobUrl); +pub struct ApprovedBlobUrl(BlobUrl); -impl RadrootsBlossomApprovedBlobUrl { - pub fn as_blob_url(&self) -> &RadrootsBlossomBlobUrl { +impl ApprovedBlobUrl { + pub fn as_blob_url(&self) -> &BlobUrl { &self.0 } @@ -128,12 +136,12 @@ impl RadrootsBlossomApprovedBlobUrl { self.0.as_str() } - pub fn into_blob_url(self) -> RadrootsBlossomBlobUrl { + pub fn into_blob_url(self) -> BlobUrl { self.0 } } -impl fmt::Display for RadrootsBlossomApprovedBlobUrl { +impl fmt::Display for ApprovedBlobUrl { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } @@ -152,23 +160,23 @@ fn host_is_loopback(host: Host<&str>) -> bool { } } -fn validate_authority(value: &str, url: &Url) -> Result<(), RadrootsBlossomError> { +fn validate_authority(value: &str, url: &Url) -> Result<(), Error> { let raw_host = raw_authority_host(value); if let Some(port) = raw_authority_port(value) { match port.parse::<u16>() { Ok(1..) => {} - Ok(0) | Err(_) => return Err(RadrootsBlossomError::InvalidBlobUrl), + Ok(0) | Err(_) => return Err(Error::InvalidBlobUrl), } } match url.host() { Some(Host::Domain(_)) if !raw_dns_host_is_valid(raw_host) => { - return Err(RadrootsBlossomError::InvalidBlobUrl); + return Err(Error::InvalidBlobUrl); } Some(Host::Ipv4(address)) if raw_host != address.to_string() => { - return Err(RadrootsBlossomError::InvalidBlobUrl); + return Err(Error::InvalidBlobUrl); } Some(_) => {} - None => return Err(RadrootsBlossomError::InvalidBlobUrl), + None => return Err(Error::InvalidBlobUrl), } Ok(()) } @@ -249,7 +257,7 @@ mod tests { #[test] fn https_reference_is_structural_and_approved() { - let parsed = RadrootsBlossomBlobUrl::parse(&url("https://cdn.example.com")).unwrap(); + let parsed = BlobUrl::parse(&url("https://cdn.example.com")).unwrap(); assert!(parsed.is_https()); assert!(!parsed.is_loopback_http()); assert_eq!(parsed.hash_path().hash().to_string(), HASH); @@ -271,7 +279,7 @@ mod tests { "http://[::1]:3000", "http://[0:0:0:0:0:0:0:1]", ] { - let parsed = RadrootsBlossomBlobUrl::parse(&url(origin)).unwrap(); + let parsed = BlobUrl::parse(&url(origin)).unwrap(); assert!(parsed.is_loopback_http(), "{origin}"); let canonical = parsed.as_str().to_string(); let approved = parsed.approve().unwrap(); @@ -290,14 +298,10 @@ mod tests { "http://10.0.0.2", "http://[::]", ] { - let parsed = RadrootsBlossomBlobUrl::parse(&url(origin)).unwrap(); + let parsed = BlobUrl::parse(&url(origin)).unwrap(); assert!(!parsed.is_https()); assert!(!parsed.is_loopback_http()); - assert_eq!( - parsed.approve(), - Err(RadrootsBlossomError::InsecureBlobUrl), - "{origin}" - ); + assert_eq!(parsed.approve(), Err(Error::InsecureBlobUrl), "{origin}"); } } @@ -306,39 +310,35 @@ mod tests { let cases = [ ( url("ftp://cdn.example.com"), - RadrootsBlossomError::UnsupportedBlobUrlScheme, + Error::UnsupportedBlobUrlScheme, ), ( format!("https://user@cdn.example.com/{HASH}.txt"), - RadrootsBlossomError::BlobUrlCredentialsForbidden, + Error::BlobUrlCredentialsForbidden, ), ( format!("https://:password@cdn.example.com/{HASH}.txt"), - RadrootsBlossomError::BlobUrlCredentialsForbidden, + Error::BlobUrlCredentialsForbidden, ), ( format!("https://@cdn.example.com/{HASH}.txt"), - RadrootsBlossomError::BlobUrlCredentialsForbidden, + Error::BlobUrlCredentialsForbidden, ), ( format!("https://:@cdn.example.com/{HASH}.txt"), - RadrootsBlossomError::BlobUrlCredentialsForbidden, + Error::BlobUrlCredentialsForbidden, ), ( format!("https://cdn.example.com/{HASH}.txt?a=1"), - RadrootsBlossomError::BlobUrlQueryForbidden, + Error::BlobUrlQueryForbidden, ), ( format!("https://cdn.example.com/{HASH}.txt#x"), - RadrootsBlossomError::BlobUrlFragmentForbidden, + Error::BlobUrlFragmentForbidden, ), ]; for (value, expected) in cases { - assert_eq!( - RadrootsBlossomBlobUrl::parse(&value), - Err(expected), - "{value}" - ); + assert_eq!(BlobUrl::parse(&value), Err(expected), "{value}"); } } @@ -352,37 +352,29 @@ mod tests { format!("https://cdn.example.com/{HASH}/../{HASH}.txt"), format!("https://cdn.example.com/{HASH}\\x"), ] { - assert!(RadrootsBlossomBlobUrl::parse(&value).is_err(), "{value}"); + assert!(BlobUrl::parse(&value).is_err(), "{value}"); } } + #[cfg(feature = "serde")] #[test] fn blob_url_serde_revalidates_structure() { - let parsed = RadrootsBlossomBlobUrl::parse(&url("https://cdn.example.com")).unwrap(); + let parsed = BlobUrl::parse(&url("https://cdn.example.com")).unwrap(); let json = serde_json::to_string(&parsed).unwrap(); - assert_eq!( - serde_json::from_str::<RadrootsBlossomBlobUrl>(&json).unwrap(), - parsed - ); - assert!(serde_json::from_str::<RadrootsBlossomBlobUrl>("false").is_err()); + assert_eq!(serde_json::from_str::<BlobUrl>(&json).unwrap(), parsed); + assert!(serde_json::from_str::<BlobUrl>("false").is_err()); assert!( - serde_json::from_str::<RadrootsBlossomBlobUrl>( - "\"https://cdn.example.com/not-a-hash.png\"" - ) - .is_err() + serde_json::from_str::<BlobUrl>("\"https://cdn.example.com/not-a-hash.png\"").is_err() ); } #[test] fn malformed_url_is_rejected() { - assert_eq!( - RadrootsBlossomBlobUrl::from_str("not a url"), - Err(RadrootsBlossomError::InvalidBlobUrl) - ); - assert!(RadrootsBlossomBlobUrl::parse(&format!("https:///{HASH}.txt")).is_err()); - assert!(RadrootsBlossomBlobUrl::parse(&url("https://cdn.example.com:0")).is_err()); - assert!(RadrootsBlossomBlobUrl::parse(&url("https://cdn.example.com:00")).is_err()); - assert!(RadrootsBlossomBlobUrl::parse(&url("https://cdn.example.com:")).is_err()); + assert_eq!(BlobUrl::from_str("not a url"), Err(Error::InvalidBlobUrl)); + assert!(BlobUrl::parse(&format!("https:///{HASH}.txt")).is_err()); + assert!(BlobUrl::parse(&url("https://cdn.example.com:0")).is_err()); + assert!(BlobUrl::parse(&url("https://cdn.example.com:00")).is_err()); + assert!(BlobUrl::parse(&url("https://cdn.example.com:")).is_err()); for value in [ format!(" https://cdn.example.com/{HASH}.txt"), format!("https://cdn.example.com/{HASH}.txt\n"), @@ -392,8 +384,8 @@ mod tests { format!("https://media\u{2060}.example/{HASH}.txt"), ] { assert_eq!( - RadrootsBlossomBlobUrl::parse(&value), - Err(RadrootsBlossomError::InvalidBlobUrl), + BlobUrl::parse(&value), + Err(Error::InvalidBlobUrl), "{value:?}" ); } @@ -404,8 +396,8 @@ mod tests { "https://2130706433", ] { assert_eq!( - RadrootsBlossomBlobUrl::parse(&url(origin)), - Err(RadrootsBlossomError::InvalidBlobUrl), + BlobUrl::parse(&url(origin)), + Err(Error::InvalidBlobUrl), "{origin}" ); } @@ -423,16 +415,16 @@ mod tests { "https://example.", ] { assert_eq!( - RadrootsBlossomBlobUrl::parse(&url(origin)), - Err(RadrootsBlossomError::InvalidBlobUrl), + BlobUrl::parse(&url(origin)), + Err(Error::InvalidBlobUrl), "{origin}" ); } let label_too_long = "a".repeat(64); assert_eq!( - RadrootsBlossomBlobUrl::parse(&url(&format!("https://{label_too_long}.example"))), - Err(RadrootsBlossomError::InvalidBlobUrl) + BlobUrl::parse(&url(&format!("https://{label_too_long}.example"))), + Err(Error::InvalidBlobUrl) ); let host_too_long = format!( "{}.{}.{}.{}", @@ -443,8 +435,8 @@ mod tests { ); assert!(host_too_long.len() > 253); assert_eq!( - RadrootsBlossomBlobUrl::parse(&url(&format!("https://{host_too_long}"))), - Err(RadrootsBlossomError::InvalidBlobUrl) + BlobUrl::parse(&url(&format!("https://{host_too_long}"))), + Err(Error::InvalidBlobUrl) ); let maximum_host = format!( @@ -455,8 +447,8 @@ mod tests { "d".repeat(61) ); assert_eq!(maximum_host.len(), 253); - assert!(RadrootsBlossomBlobUrl::parse(&url(&format!("https://{maximum_host}"))).is_ok()); - assert!(RadrootsBlossomBlobUrl::parse(&url("https://xn--mdia-9oa.example")).is_ok()); + assert!(BlobUrl::parse(&url(&format!("https://{maximum_host}"))).is_ok()); + assert!(BlobUrl::parse(&url("https://xn--mdia-9oa.example")).is_ok()); } #[test] @@ -467,7 +459,7 @@ mod tests { let hostless = Url::parse("file:///blob").unwrap(); assert_eq!( validate_authority("file:///blob", &hostless), - Err(RadrootsBlossomError::InvalidBlobUrl) + Err(Error::InvalidBlobUrl) ); } } diff --git a/crates/blossom/tests/bud11_conformance.rs b/crates/blossom/tests/bud11_conformance.rs @@ -1,20 +1,40 @@ +#![cfg(feature = "serde")] + use radroots_blossom::{ - RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, RadrootsBlossomAuthoredUploadClaim, - RadrootsBlossomAuthorizationAction, RadrootsBlossomAuthorizationContent, - RadrootsBlossomAuthorizationTarget, RadrootsBlossomAuthorizationValidation, - RadrootsBlossomParsedAuthorizationClaim, RadrootsBlossomServerDomain, - RadrootsBlossomServerScopeRequirement, RadrootsBlossomSha256, + Sha256, + authorization::{ + AuthoredUploadClaim, AuthorizationAction, AuthorizationClaim, AuthorizationContent, + AuthorizationTarget, AuthorizationValidation, RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, + ServerDomain, ServerScopeRequirement, + }, }; use serde::Deserialize; use serde_json::Value; -use std::{borrow::Cow, fs, path::Path}; +use std::{borrow::Cow, collections::BTreeSet, fs, path::Path}; const PACKAGED_VECTORS: &str = include_str!("fixtures/bud11_claims.v1.json"); const WORKSPACE_VECTOR_PATH: &str = "../../contracts/conformance/vectors/blossom/bud11_claims.v1.json"; const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; +const SUPPORTED_VECTOR_KINDS: [&str; 14] = [ + "blossom.bud11.action.parse.valid", + "blossom.bud11.action.parse.invalid", + "blossom.bud11.server_domain.parse.valid", + "blossom.bud11.server_domain.parse.invalid", + "blossom.bud11.content.parse.valid", + "blossom.bud11.content.parse.invalid", + "blossom.bud11.claim.parse.valid", + "blossom.bud11.claim.parse.invalid", + "blossom.bud11.validation.new.valid", + "blossom.bud11.validation.new.invalid", + "blossom.bud11.claim.validate.valid", + "blossom.bud11.claim.validate.invalid", + "blossom.bud11.authored_upload.valid", + "blossom.bud11.authored_upload.invalid", +]; #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct Suite { suite: String, contract_version: String, @@ -22,6 +42,7 @@ struct Suite { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct Vector { id: String, kind: String, @@ -36,12 +57,38 @@ fn checked_in_bud11_vectors_execute_against_public_api() { assert_eq!(suite.suite, "blossom_bud11_claims"); assert_eq!(suite.contract_version, "1.0.0"); assert!(!suite.vectors.is_empty()); + assert_vector_inventory(&suite.vectors); for vector in &suite.vectors { execute(vector); } } +fn assert_vector_inventory(vectors: &[Vector]) { + let mut ids = BTreeSet::new(); + let mut kinds = BTreeSet::new(); + for vector in vectors { + assert!(!vector.id.trim().is_empty(), "vector id must be nonblank"); + assert!( + ids.insert(vector.id.as_str()), + "duplicate vector id {}", + vector.id + ); + assert!( + vector.input.is_object(), + "{} input must be an object", + vector.id + ); + assert!( + vector.expected.is_object(), + "{} expected must be an object", + vector.id + ); + kinds.insert(vector.kind.as_str()); + } + assert_eq!(kinds, BTreeSet::from(SUPPORTED_VECTOR_KINDS)); +} + fn conformance_vectors() -> Cow<'static, str> { let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH); match fs::read_to_string(&workspace_path) { @@ -87,7 +134,7 @@ fn execute(vector: &Vector) { } fn action_parse_valid(vector: &Vector) { - let action = RadrootsBlossomAuthorizationAction::parse(input_str(vector, "action")) + let action = AuthorizationAction::parse(input_str(vector, "action")) .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); assert_eq!( action.as_str(), @@ -104,13 +151,13 @@ fn action_parse_valid(vector: &Vector) { } fn action_parse_invalid(vector: &Vector) { - let error = RadrootsBlossomAuthorizationAction::parse(input_str(vector, "action")) + let error = AuthorizationAction::parse(input_str(vector, "action")) .expect_err("invalid action vector must fail"); assert_error(vector, error.code()); } fn server_domain_parse_valid(vector: &Vector) { - let domain = RadrootsBlossomServerDomain::parse(input_str(vector, "domain")) + let domain = ServerDomain::parse(input_str(vector, "domain")) .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); assert_eq!( domain.as_str(), @@ -127,30 +174,36 @@ fn server_domain_parse_valid(vector: &Vector) { } fn server_domain_parse_invalid(vector: &Vector) { - let error = RadrootsBlossomServerDomain::parse(input_str(vector, "domain")) + let error = ServerDomain::parse(input_str(vector, "domain")) .expect_err("invalid server-domain vector must fail"); assert_error(vector, error.code()); } fn content_parse_valid(vector: &Vector) { - let content = RadrootsBlossomAuthorizationContent::parse(input_str(vector, "content")) + let content = AuthorizationContent::parse(input_str(vector, "content")) .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); - assert_eq!( - content.as_str(), - expected_str(vector, "content"), - "{}", - vector.id - ); - assert_eq!( - content.to_string(), - expected_str(vector, "content"), - "{}", - vector.id - ); + if let Some(expected) = vector.expected.get("content").and_then(Value::as_str) { + assert_eq!(content.as_str(), expected, "{}", vector.id); + assert_eq!(content.to_string(), expected, "{}", vector.id); + } else { + let expected_byte_length = expected_u64(vector, "content_byte_length"); + assert_eq!( + content.as_str().len() as u64, + expected_byte_length, + "{}", + vector.id + ); + assert_eq!( + content.to_string().len() as u64, + expected_byte_length, + "{}", + vector.id + ); + } } fn content_parse_invalid(vector: &Vector) { - let error = RadrootsBlossomAuthorizationContent::parse(input_str(vector, "content")) + let error = AuthorizationContent::parse(input_str(vector, "content")) .expect_err("invalid content vector must fail"); assert_error(vector, error.code()); } @@ -253,13 +306,11 @@ fn authored_upload_invalid(vector: &Vector) { assert_error(vector, error.code()); } -fn authored_upload( - vector: &Vector, -) -> Result<RadrootsBlossomAuthoredUploadClaim, radroots_blossom::RadrootsBlossomError> { - let content = RadrootsBlossomAuthorizationContent::parse(input_str(vector, "content"))?; - let server = RadrootsBlossomServerDomain::parse(input_str(vector, "server_domain"))?; - let hash = RadrootsBlossomSha256::from_hex(input_str(vector, "hash"))?; - RadrootsBlossomAuthoredUploadClaim::new( +fn authored_upload(vector: &Vector) -> Result<AuthoredUploadClaim, radroots_blossom::Error> { + let content = AuthorizationContent::parse(input_str(vector, "content"))?; + let server = ServerDomain::parse(input_str(vector, "server_domain"))?; + let hash = Sha256::from_hex(input_str(vector, "hash"))?; + AuthoredUploadClaim::new( content, server, hash, @@ -268,23 +319,21 @@ fn authored_upload( ) } -fn try_parse_claim( - input: &Value, -) -> Result<RadrootsBlossomParsedAuthorizationClaim, radroots_blossom::RadrootsBlossomError> { +fn try_parse_claim(input: &Value) -> Result<AuthorizationClaim, radroots_blossom::Error> { let tags: Vec<Vec<String>> = serde_json::from_value(input["tags"].clone()).expect("claim tags must be string arrays"); - RadrootsBlossomParsedAuthorizationClaim::parse( + AuthorizationClaim::parse( value_str(input, "content"), value_u64(input, "created_at"), &tags, ) } -fn parse_claim(input: &Value, id: &str) -> RadrootsBlossomParsedAuthorizationClaim { +fn parse_claim(input: &Value, id: &str) -> AuthorizationClaim { try_parse_claim(input).unwrap_or_else(|error| panic!("{id} claim parse failed: {error}")) } -fn assert_claim(vector: &Vector, claim: &RadrootsBlossomParsedAuthorizationClaim) { +fn assert_claim(vector: &Vector, claim: &AuthorizationClaim) { assert_eq!( claim.content().as_str(), expected_str(vector, "content"), @@ -324,17 +373,15 @@ fn assert_claim(vector: &Vector, claim: &RadrootsBlossomParsedAuthorizationClaim assert_eq!(hashes, expected_strings(vector, "hashes"), "{}", vector.id); } -fn build_validation( - vector: &Vector, -) -> Result<RadrootsBlossomAuthorizationValidation, radroots_blossom::RadrootsBlossomError> { +fn build_validation(vector: &Vector) -> Result<AuthorizationValidation, radroots_blossom::Error> { let target = parse_target(&vector.input["target"]); - let server = RadrootsBlossomServerDomain::parse(input_str(vector, "server_domain"))?; + let server = ServerDomain::parse(input_str(vector, "server_domain"))?; let server_scope = match input_str(vector, "server_scope") { - "optional_any_match" => RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, - "required_any_match" => RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + "optional_any_match" => ServerScopeRequirement::OptionalAnyMatch, + "required_any_match" => ServerScopeRequirement::RequiredAnyMatch, value => panic!("{} has unsupported server scope {value}", vector.id), }; - RadrootsBlossomAuthorizationValidation::new( + AuthorizationValidation::new( target, server, server_scope, @@ -343,18 +390,15 @@ fn build_validation( ) } -fn parse_target(input: &Value) -> RadrootsBlossomAuthorizationTarget { - let hash = || { - RadrootsBlossomSha256::from_hex(value_str(input, "hash")) - .expect("target hash must be valid") - }; +fn parse_target(input: &Value) -> AuthorizationTarget { + let hash = || Sha256::from_hex(value_str(input, "hash")).expect("target hash must be valid"); match value_str(input, "type") { - "get_blob" => RadrootsBlossomAuthorizationTarget::GetBlob(hash()), - "upload" => RadrootsBlossomAuthorizationTarget::Upload(hash()), - "list" => RadrootsBlossomAuthorizationTarget::List, - "delete_blob" => RadrootsBlossomAuthorizationTarget::DeleteBlob(hash()), - "mirror" => RadrootsBlossomAuthorizationTarget::Mirror(hash()), - "media" => RadrootsBlossomAuthorizationTarget::Media(hash()), + "get_blob" => AuthorizationTarget::GetBlob(hash()), + "upload" => AuthorizationTarget::Upload(hash()), + "list" => AuthorizationTarget::List, + "delete_blob" => AuthorizationTarget::DeleteBlob(hash()), + "mirror" => AuthorizationTarget::Mirror(hash()), + "media" => AuthorizationTarget::Media(hash()), value => panic!("unsupported BUD-11 target {value}"), } } diff --git a/crates/blossom/tests/conformance.rs b/crates/blossom/tests/conformance.rs @@ -1,17 +1,36 @@ -use radroots_blossom::{ - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomHashPath, - RadrootsBlossomMediaType, RadrootsBlossomSha256, -}; +#![cfg(feature = "serde")] + +use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256, hash::HashPath}; use serde::Deserialize; use serde_json::Value; -use std::{borrow::Cow, fs, path::Path}; +use std::{borrow::Cow, collections::BTreeSet, fs, path::Path}; const PACKAGED_VECTORS: &str = include_str!("fixtures/hash_path_and_descriptor.v1.json"); const WORKSPACE_VECTOR_PATH: &str = "../../contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json"; const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; +const SUPPORTED_VECTOR_KINDS: [&str; 17] = [ + "blossom.sha256.digest", + "blossom.sha256.parse.valid", + "blossom.sha256.parse.invalid", + "blossom.hash_path.parse.valid", + "blossom.hash_path.parse.invalid", + "blossom.blob_url.parse.valid", + "blossom.blob_url.parse.invalid", + "blossom.reference_policy.valid", + "blossom.reference_policy.invalid", + "blossom.media_type.parse.valid", + "blossom.media_type.parse.invalid", + "blossom.descriptor.parse.valid", + "blossom.descriptor.parse.invalid", + "blossom.descriptor.approve_reference.valid", + "blossom.descriptor.approve_reference.invalid", + "blossom.descriptor.verify_bytes.valid", + "blossom.descriptor.verify_bytes.invalid", +]; #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct Suite { suite: String, contract_version: String, @@ -19,6 +38,7 @@ struct Suite { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct Vector { id: String, kind: String, @@ -33,12 +53,38 @@ fn checked_in_vectors_execute_against_public_api() { assert_eq!(suite.suite, "blossom_hash_path_and_descriptor"); assert_eq!(suite.contract_version, "1.0.0"); assert!(!suite.vectors.is_empty()); + assert_vector_inventory(&suite.vectors); for vector in &suite.vectors { execute(vector); } } +fn assert_vector_inventory(vectors: &[Vector]) { + let mut ids = BTreeSet::new(); + let mut kinds = BTreeSet::new(); + for vector in vectors { + assert!(!vector.id.trim().is_empty(), "vector id must be nonblank"); + assert!( + ids.insert(vector.id.as_str()), + "duplicate vector id {}", + vector.id + ); + assert!( + vector.input.is_object(), + "{} input must be an object", + vector.id + ); + assert!( + vector.expected.is_object(), + "{} expected must be an object", + vector.id + ); + kinds.insert(vector.kind.as_str()); + } + assert_eq!(kinds, BTreeSet::from(SUPPORTED_VECTOR_KINDS)); +} + fn conformance_vectors() -> Cow<'static, str> { let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH); match fs::read_to_string(&workspace_path) { @@ -89,7 +135,7 @@ fn execute(vector: &Vector) { fn sha256_digest(vector: &Vector) { let bytes = input_bytes(vector); assert_eq!( - RadrootsBlossomSha256::digest(&bytes).to_string(), + Sha256::digest(&bytes).to_string(), expected_str(vector, "sha256"), "{}", vector.id @@ -97,7 +143,7 @@ fn sha256_digest(vector: &Vector) { } fn sha256_parse_valid(vector: &Vector) { - let parsed = RadrootsBlossomSha256::from_hex(input_str(vector, "sha256")) + let parsed = Sha256::from_hex(input_str(vector, "sha256")) .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); assert_eq!( parsed.to_string(), @@ -108,13 +154,13 @@ fn sha256_parse_valid(vector: &Vector) { } fn sha256_parse_invalid(vector: &Vector) { - let error = RadrootsBlossomSha256::from_hex(input_str(vector, "sha256")) + let error = Sha256::from_hex(input_str(vector, "sha256")) .expect_err("invalid SHA-256 vector must fail"); assert_error(vector, error.code()); } fn hash_path_parse_valid(vector: &Vector) { - let parsed = RadrootsBlossomHashPath::parse(input_str(vector, "path")) + let parsed = HashPath::parse(input_str(vector, "path")) .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); assert_eq!( parsed.hash().to_string(), @@ -131,8 +177,8 @@ fn hash_path_parse_valid(vector: &Vector) { } fn hash_path_parse_invalid(vector: &Vector) { - let error = RadrootsBlossomHashPath::parse(input_str(vector, "path")) - .expect_err("invalid hash-path vector must fail"); + let error = + HashPath::parse(input_str(vector, "path")).expect_err("invalid hash-path vector must fail"); assert_error(vector, error.code()); } @@ -169,8 +215,8 @@ fn blob_url_parse_valid(vector: &Vector) { } fn blob_url_parse_invalid(vector: &Vector) { - let error = RadrootsBlossomBlobUrl::parse(input_str(vector, "url")) - .expect_err("invalid blob-URL vector must fail"); + let error = + BlobUrl::parse(input_str(vector, "url")).expect_err("invalid blob-URL vector must fail"); assert_error(vector, error.code()); } @@ -204,7 +250,7 @@ fn reference_policy_invalid(vector: &Vector) { } fn media_type_parse_valid(vector: &Vector) { - let parsed = RadrootsBlossomMediaType::parse(input_str(vector, "media_type")) + let parsed = MediaType::parse(input_str(vector, "media_type")) .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); assert_eq!( parsed.as_str(), @@ -215,7 +261,7 @@ fn media_type_parse_valid(vector: &Vector) { } fn media_type_parse_invalid(vector: &Vector) { - let error = RadrootsBlossomMediaType::parse(input_str(vector, "media_type")) + let error = MediaType::parse(input_str(vector, "media_type")) .expect_err("invalid media-type vector must fail"); assert_error(vector, error.code()); } @@ -228,7 +274,7 @@ fn descriptor_parse_valid(vector: &Vector) { fn descriptor_parse_invalid(vector: &Vector) { let input = &vector.input["descriptor"]; - serde_json::from_value::<RadrootsBlossomBlobDescriptor>(input.clone()) + serde_json::from_value::<BlobDescriptor>(input.clone()) .expect_err("invalid descriptor vector must fail"); let code = classify_descriptor_input(input); assert_error(vector, code); @@ -299,18 +345,18 @@ fn descriptor_verify_invalid(vector: &Vector) { assert_error(vector, error.code()); } -fn parse_blob_url(vector: &Vector) -> RadrootsBlossomBlobUrl { - RadrootsBlossomBlobUrl::parse(input_str(vector, "url")) +fn parse_blob_url(vector: &Vector) -> BlobUrl { + BlobUrl::parse(input_str(vector, "url")) .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)) } -fn parse_descriptor(vector: &Vector) -> RadrootsBlossomBlobDescriptor { +fn parse_descriptor(vector: &Vector) -> BlobDescriptor { serde_json::from_value(vector.input["descriptor"].clone()) .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)) } -fn approved_media_type(vector: &Vector) -> RadrootsBlossomMediaType { - RadrootsBlossomMediaType::parse(input_str(vector, "approved_media_type")) +fn approved_media_type(vector: &Vector) -> MediaType { + MediaType::parse(input_str(vector, "approved_media_type")) .unwrap_or_else(|error| panic!("{} media type failed: {error}", vector.id)) } @@ -350,7 +396,7 @@ fn classify_descriptor_input(input: &Value) -> &'static str { } } - let url = match RadrootsBlossomBlobUrl::parse( + let url = match BlobUrl::parse( input["url"] .as_str() .expect("descriptor url must be a string"), @@ -358,7 +404,7 @@ fn classify_descriptor_input(input: &Value) -> &'static str { Ok(url) => url, Err(error) => return error.code(), }; - let sha256 = match RadrootsBlossomSha256::from_hex( + let sha256 = match Sha256::from_hex( input["sha256"] .as_str() .expect("descriptor sha256 must be a string"), @@ -369,7 +415,7 @@ fn classify_descriptor_input(input: &Value) -> &'static str { let Some(size) = input["size"].as_u64() else { return "invalid_descriptor_size"; }; - let media_type = match RadrootsBlossomMediaType::parse( + let media_type = match MediaType::parse( input["type"] .as_str() .expect("descriptor type must be a string"), @@ -381,7 +427,7 @@ fn classify_descriptor_input(input: &Value) -> &'static str { return "invalid_descriptor_uploaded"; }; - RadrootsBlossomBlobDescriptor::new(url, sha256, size, media_type, uploaded) + BlobDescriptor::new(url, sha256, size, media_type, uploaded) .expect_err("invalid descriptor vector must fail structured construction") .code() } diff --git a/crates/blossom/tests/decoder_security.rs b/crates/blossom/tests/decoder_security.rs @@ -1,12 +1,9 @@ #![cfg(feature = "raster-decode")] use radroots_blossom::{ - RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, - RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES, RadrootsBlossomAuthoredRasterDimensions, - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomBud01GetObservation, - RadrootsBlossomBud01HeadObservation, RadrootsBlossomBud02UploadObservation, - RadrootsBlossomError, RadrootsBlossomMediaType, RadrootsBlossomSha256, - verify_publication_readiness, + AuthoredRasterDimensions, BlobDescriptor, BlobUrl, Bud01GetObservation, Bud01HeadObservation, + Bud02UploadObservation, Error, MediaType, RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES, Sha256, verify_publication_readiness, }; use serde::Deserialize; use std::{env, fs, io::Write, path::PathBuf, process::Command}; @@ -222,13 +219,13 @@ fn png_chunk(kind: [u8; 4], data: &[u8]) -> Vec<u8> { output } -fn verify(bytes: &[u8], format: &str) -> Result<(u32, u32), RadrootsBlossomError> { +fn verify(bytes: &[u8], format: &str) -> Result<(u32, u32), Error> { let (media_type, extension) = media(format); - let hash = RadrootsBlossomSha256::digest(bytes); + let hash = Sha256::digest(bytes); let url = format!("https://cdn.example/{hash}.{extension}"); - let media_type = RadrootsBlossomMediaType::parse(media_type).unwrap(); - let descriptor = RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&url).unwrap(), + let media_type = MediaType::parse(media_type).unwrap(); + let descriptor = BlobDescriptor::new( + BlobUrl::parse(&url).unwrap(), hash, bytes.len() as u64, media_type.clone(), @@ -238,24 +235,16 @@ fn verify(bytes: &[u8], format: &str) -> Result<(u32, u32), RadrootsBlossomError .clone() .approve_reference()? .verify_bytes(bytes, &media_type)?; - let upload = RadrootsBlossomBud02UploadObservation::new(201, descriptor)?; - let approved_url = RadrootsBlossomBlobUrl::parse(&url)?.approve()?; - let head = RadrootsBlossomBud01HeadObservation::new( - 200, - approved_url.clone(), - bytes.len() as u64, - media_type, - )?; - let get = RadrootsBlossomBud01GetObservation::from_complete_body( - 200, - approved_url, - bytes.len() as u64, - bytes, - )?; + let upload = Bud02UploadObservation::new(201, descriptor)?; + let approved_url = BlobUrl::parse(&url)?.approve()?; + let head = + Bud01HeadObservation::new(200, approved_url.clone(), bytes.len() as u64, media_type)?; + let get = + Bud01GetObservation::from_complete_body(200, approved_url, bytes.len() as u64, bytes)?; let evidence = verify_publication_readiness( &authored, bytes, - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, &upload, &head, &get, diff --git a/crates/blossom/tests/fixtures/bud11_claims.v1.json b/crates/blossom/tests/fixtures/bud11_claims.v1.json @@ -93,6 +93,18 @@ "expected": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test" } }, { + "id": "domain-maximum-total-length", + "kind": "blossom.bud11.server_domain.parse.valid", + "input": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" }, + "expected": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" } + }, + { + "id": "domain-total-length-too-long", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { "id": "domain-empty", "kind": "blossom.bud11.server_domain.parse.invalid", "input": { "domain": "" }, @@ -237,6 +249,18 @@ "expected": { "content": "Upload\tphoto" } }, { + "id": "content-maximum-byte-length", + "kind": "blossom.bud11.content.parse.valid", + "input": { "content": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "expected": { "content_byte_length": 4096 } + }, + { + "id": "content-byte-length-too-long", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "expected": { "error": "invalid_authorization_content" } + }, + { "id": "content-control", "kind": "blossom.bud11.content.parse.invalid", "input": { "content": "Upload\u0000photo" }, diff --git a/crates/blossom/tests/fixtures/hash_path_and_descriptor.v1.json b/crates/blossom/tests/fixtures/hash_path_and_descriptor.v1.json @@ -83,6 +83,16 @@ } }, { + "id": "blossom_sha256_parse_too_long_invalid_008b", + "kind": "blossom.sha256.parse.invalid", + "input": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad0" + }, + "expected": { + "error": "invalid_sha256" + } + }, + { "id": "blossom_hash_path_without_extension_009", "kind": "blossom.hash_path.parse.valid", "input": { diff --git a/crates/blossom/tests/package_boundary.rs b/crates/blossom/tests/package_boundary.rs @@ -0,0 +1,239 @@ +use std::{collections::BTreeSet, fs, path::Path}; + +use radroots_blossom::{ + AuthorizationClaim, BlobDescriptor, BlobUrl, ByteVerifiedDescriptor, Error, MediaType, Sha256, + authorization::{ + AuthoredUploadClaim, AuthorizationAction, AuthorizationContent, AuthorizationTarget, + AuthorizationValidation, AuthorizationWireParts, ServerDomain, ServerScopeRequirement, + ValidatedAuthorizationClaim, + }, + descriptor::{ApprovedDescriptor, ByteCommitment}, + hash::{FileExtension, HashPath}, + url::ApprovedBlobUrl, +}; + +const MANIFEST: &str = include_str!("../Cargo.toml"); +const ROOT: &str = include_str!("../src/lib.rs"); + +#[test] +fn manifest_has_phase1_extension_features_and_no_radroots_dependencies() { + assert!(MANIFEST.contains("name = \"radroots_blossom\"")); + assert!(MANIFEST.contains("version = \"0.1.0-alpha\"")); + assert!(MANIFEST.contains("publish = false")); + assert!(MANIFEST.contains("[lib]\nname = \"radroots_blossom\"")); + assert!(MANIFEST.contains("default = [\"std\", \"serde\"]")); + assert_eq!( + table_keys(MANIFEST, "[features]"), + BTreeSet::from(["default", "raster-decode", "serde", "std"]) + ); + + for heading in ["[dependencies]", "[dev-dependencies]"] { + assert!( + table_keys(MANIFEST, heading) + .iter() + .all(|dependency| !dependency.starts_with("radroots_")), + "{heading} must not contain Radroots dependencies" + ); + } +} + +#[test] +fn manifest_dependency_surface_is_protocol_plus_opt_in_raster() { + assert_eq!( + table_keys(MANIFEST, "[dependencies]"), + BTreeSet::from([ + "image", + "libwebp", + "mediatype", + "serde", + "serde_json", + "sha2", + "unicode-general-category", + "url_nostd", + "zune-core", + "zune-jpeg", + ]) + ); + assert_eq!( + table_keys(MANIFEST, "[dev-dependencies]"), + BTreeSet::from(["hex", "image", "serde_json", "tempfile"]) + ); +} + +#[test] +fn crate_root_matches_the_approved_module_skeleton() { + assert!(ROOT.contains("#![cfg_attr(not(feature = \"std\"), no_std)]")); + assert_eq!( + root_declarations("pub mod "), + BTreeSet::from([ + "authorization", + "descriptor", + "hash", + "media_type", + "publication_readiness", + "url", + ]) + ); + assert_eq!(root_declarations("mod "), BTreeSet::from(["error"])); + assert_eq!( + ROOT.lines() + .map(str::trim) + .filter(|line| line.starts_with("pub use ")) + .collect::<BTreeSet<_>>(), + BTreeSet::from([ + "pub use authorization::{", + "pub use descriptor::{ApprovedDescriptor, BlobDescriptor, ByteCommitment, ByteVerifiedDescriptor};", + "pub use error::Error;", + "pub use hash::{FileExtension, HashPath, Sha256};", + "pub use media_type::MediaType;", + "pub use publication_readiness::verify_publication_readiness;", + "pub use publication_readiness::{", + "pub use url::{ApprovedBlobUrl, BlobUrl};", + ]) + ); +} + +#[test] +fn final_root_and_module_paths_compile() { + fn assert_public_value<T: Clone + core::fmt::Debug + Eq + Send + Sync>() {} + + assert_public_value::<ApprovedBlobUrl>(); + assert_public_value::<ApprovedDescriptor>(); + assert_public_value::<AuthoredUploadClaim>(); + assert_public_value::<AuthorizationAction>(); + assert_public_value::<AuthorizationClaim>(); + assert_public_value::<AuthorizationContent>(); + assert_public_value::<AuthorizationTarget>(); + assert_public_value::<AuthorizationValidation>(); + assert_public_value::<AuthorizationWireParts>(); + assert_public_value::<BlobDescriptor>(); + assert_public_value::<BlobUrl>(); + assert_public_value::<ByteCommitment>(); + assert_public_value::<ByteVerifiedDescriptor>(); + assert_public_value::<Error>(); + assert_public_value::<FileExtension>(); + assert_public_value::<HashPath>(); + assert_public_value::<MediaType>(); + assert_public_value::<ServerDomain>(); + assert_public_value::<ServerScopeRequirement>(); + assert_public_value::<Sha256>(); + assert_public_value::<ValidatedAuthorizationClaim>(); +} + +#[test] +fn production_types_do_not_repeat_the_crate_name() { + for (path, production) in production_sources() { + assert!( + !production.contains("RadrootsBlossom"), + "Blossom production type repeats its crate name: {}", + path.display() + ); + } +} + +#[test] +fn production_surface_exposes_no_public_traits() { + for (path, production) in production_sources() { + assert!( + !production + .lines() + .map(str::trim_start) + .any(|line| line.starts_with("pub trait ")), + "Blossom must expose concrete protocol values, not public traits: {}", + path.display() + ); + } +} + +#[test] +fn production_surface_owns_no_external_io_or_application_media_policy() { + const FORBIDDEN_MARKERS: &[&str] = &[ + "std::fs", + "std::net", + "std::path", + "std::process", + "std::thread", + "std::time", + "tokio::", + "reqwest::", + "hyper::", + "ureq::", + "tower::", + "HttpClient", + "BlobClient", + "UploadClient", + "DownloadClient", + "UploadQueue", + "DownloadQueue", + "CachePolicy", + "FileCache", + "RetryPolicy", + "BackoffPolicy", + "MediaPolicy", + "Filesystem", + "FileSystem", + ]; + + for (path, production) in production_sources() { + for marker in FORBIDDEN_MARKERS { + assert!( + !production.contains(marker), + "Blossom production source owns forbidden I/O or application policy marker `{marker}`: {}", + path.display() + ); + } + } +} + +fn production_sources() -> Vec<(std::path::PathBuf, String)> { + let source_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + fs::read_dir(source_root) + .expect("read Blossom source directory") + .map(|entry| entry.expect("source directory entry").path()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("rs")) + .map(|path| { + let source = fs::read_to_string(&path).expect("read Blossom source"); + let production = source + .split("\n#[cfg(test)]") + .next() + .unwrap_or(&source) + .to_owned(); + (path, production) + }) + .collect() +} + +fn table_keys<'a>(manifest: &'a str, heading: &str) -> BTreeSet<&'a str> { + let table = manifest + .split_once(heading) + .unwrap_or_else(|| panic!("missing manifest table {heading}")) + .1; + table + .lines() + .skip(1) + .take_while(|line| !line.trim_start().starts_with('[')) + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let (key, _) = line.split_once('=')?; + let key = key.trim(); + key.chars() + .all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '_' | '-') + }) + .then_some(key) + }) + .collect() +} + +fn root_declarations(prefix: &str) -> BTreeSet<&str> { + ROOT.lines() + .map(str::trim) + .filter_map(|line| { + line.strip_prefix(prefix) + .and_then(|name| name.strip_suffix(';')) + }) + .collect() +} diff --git a/crates/blossom/tests/parser_properties.rs b/crates/blossom/tests/parser_properties.rs @@ -0,0 +1,78 @@ +use std::{fmt::Debug, str::FromStr}; + +use radroots_blossom::{ + AuthorizationClaim, BlobUrl, MediaType, Sha256, + authorization::{AuthorizationAction, AuthorizationContent, ServerDomain}, + hash::{FileExtension, HashPath}, +}; + +const ASCII_MUTATION_ALPHABET: &[u8] = + b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._/:?#%\\ @\t\n\r\0;=+"; + +#[test] +fn deterministic_parser_mutation_corpus_never_panics_and_round_trips_successes() { + let fixed = [ + "", + "get", + "cdn.example.com", + "text/plain", + "png", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.png", + "https://cdn.example.com/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.png", + "upload image", + ]; + + for value in fixed + .into_iter() + .map(str::to_owned) + .chain((0_u64..4_096).map(mutation_string)) + { + assert_parse_display_round_trip::<Sha256>(&value); + assert_parse_display_round_trip::<FileExtension>(&value); + assert_parse_display_round_trip::<HashPath>(&value); + assert_parse_display_round_trip::<BlobUrl>(&value); + assert_parse_display_round_trip::<MediaType>(&value); + assert_parse_display_round_trip::<AuthorizationAction>(&value); + assert_parse_display_round_trip::<AuthorizationContent>(&value); + assert_parse_display_round_trip::<ServerDomain>(&value); + + let tags = vec![ + vec!["t".to_owned(), value.clone()], + vec!["expiration".to_owned(), value.clone()], + vec!["server".to_owned(), value.clone()], + vec!["x".to_owned(), value.clone()], + ]; + let _ = AuthorizationClaim::parse(&value, 1_800_000_000, &tags); + } +} + +fn assert_parse_display_round_trip<T>(value: &str) +where + T: FromStr + ToString + PartialEq + Debug, + T::Err: Debug, +{ + let Ok(parsed) = value.parse::<T>() else { + return; + }; + let canonical = parsed.to_string(); + assert_eq!(canonical.parse::<T>().unwrap(), parsed); +} + +fn mutation_string(seed: u64) -> String { + let mut state = seed.wrapping_add(1).wrapping_mul(0x9e37_79b9_7f4a_7c15); + let length = usize::try_from(next(&mut state) % 385).unwrap(); + let mut value = String::with_capacity(length); + for _ in 0..length { + let index = usize::try_from(next(&mut state)).unwrap() % ASCII_MUTATION_ALPHABET.len(); + value.push(char::from(ASCII_MUTATION_ALPHABET[index])); + } + value +} + +fn next(state: &mut u64) -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state +} diff --git a/crates/blossom/tests/publication_readiness.rs b/crates/blossom/tests/publication_readiness.rs @@ -2,15 +2,13 @@ use image::{ExtendedColorType, ImageEncoder, codecs::webp::WebPEncoder}; use radroots_blossom::{ + ApprovedBlobUrl, AuthoredRasterDimensions, BlobDescriptor, BlobUrl, Bud01GetObservation, + Bud01HeadObservation, Bud02UploadObservation, Error, MediaType, PublicationReadinessEvidence, RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION, RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, - RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, RadrootsBlossomApprovedBlobUrl, - RadrootsBlossomAuthoredRasterDimensions, RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, - RadrootsBlossomBud01GetObservation, RadrootsBlossomBud01HeadObservation, - RadrootsBlossomBud02UploadObservation, RadrootsBlossomError, RadrootsBlossomMediaType, - RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomRasterDimensions, - RadrootsBlossomRasterFormat, RadrootsBlossomSha256, verify_publication_readiness, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, RasterDimensions, RasterFormat, Sha256, + verify_publication_readiness, }; use serde::Deserialize; use serde_json::Value; @@ -48,17 +46,12 @@ fn publication_readiness_vectors_execute_against_public_api() { #[test] fn publication_readiness_accepts_public_jpeg_and_still_webp() { for (bytes, media_type, extension, format) in [ - ( - encoded_jpeg(), - "image/jpeg", - "jpg", - RadrootsBlossomRasterFormat::Jpeg, - ), + (encoded_jpeg(), "image/jpeg", "jpg", RasterFormat::Jpeg), ( encoded_still_webp(), "image/webp", "webp", - RadrootsBlossomRasterFormat::StillWebP, + RasterFormat::StillWebP, ), ] { let bytes = bytes.as_slice(); @@ -66,9 +59,7 @@ fn publication_readiness_accepts_public_jpeg_and_still_webp() { bytes, media_type, extension, - RadrootsBlossomAuthoredRasterDimensions::Exact( - RadrootsBlossomRasterDimensions::new(1, 1).unwrap(), - ), + AuthoredRasterDimensions::Exact(RasterDimensions::new(1, 1).unwrap()), ) .unwrap(); @@ -90,15 +81,13 @@ fn publication_readiness_evidence_round_trips_only_through_strict_canonical_json &bytes, "image/png", "png", - RadrootsBlossomAuthoredRasterDimensions::Exact( - RadrootsBlossomRasterDimensions::new(1, 1).unwrap(), - ), + AuthoredRasterDimensions::Exact(RasterDimensions::new(1, 1).unwrap()), ) .unwrap(); let canonical = evidence.to_canonical_json().unwrap(); assert!(canonical.len() <= RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES); - let reloaded = RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&canonical) + let reloaded = PublicationReadinessEvidence::from_canonical_json(&canonical) .expect("canonical evidence must reload"); assert_eq!(reloaded, evidence); assert_eq!( @@ -116,7 +105,7 @@ fn publication_readiness_evidence_round_trips_only_through_strict_canonical_json ) .unwrap(); assert_eq!( - RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&pretty) + PublicationReadinessEvidence::from_canonical_json(&pretty) .unwrap_err() .code(), "publication_readiness_evidence_json_non_canonical" @@ -126,7 +115,7 @@ fn publication_readiness_evidence_round_trips_only_through_strict_canonical_json unknown.pop(); unknown.extend_from_slice(br#","authorization":"Nostr token"}"#); assert_eq!( - RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&unknown) + PublicationReadinessEvidence::from_canonical_json(&unknown) .unwrap_err() .code(), "publication_readiness_evidence_invalid_json" @@ -134,7 +123,7 @@ fn publication_readiness_evidence_round_trips_only_through_strict_canonical_json let oversized = vec![b' '; RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES + 1]; assert_eq!( - RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&oversized) + PublicationReadinessEvidence::from_canonical_json(&oversized) .unwrap_err() .code(), "publication_readiness_evidence_too_large" @@ -147,7 +136,7 @@ fn publication_readiness_evidence_revalidates_every_persisted_fact() { &canonical_png(), "image/png", "png", - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, ) .unwrap(); let canonical = evidence.to_canonical_json().unwrap(); @@ -209,7 +198,7 @@ fn publication_readiness_evidence_revalidates_every_persisted_fact() { wire[field] = value; let mutated = serde_json::to_vec(&wire).unwrap(); assert_eq!( - RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&mutated) + PublicationReadinessEvidence::from_canonical_json(&mutated) .unwrap_err() .code(), expected, @@ -220,7 +209,7 @@ fn publication_readiness_evidence_revalidates_every_persisted_fact() { let mut dimensions: Value = serde_json::from_slice(&canonical).unwrap(); dimensions["dimensions"]["width"] = serde_json::json!(0); assert_eq!( - RadrootsBlossomPublicationReadinessEvidence::from_canonical_json( + PublicationReadinessEvidence::from_canonical_json( &serde_json::to_vec(&dimensions).unwrap(), ) .unwrap_err() @@ -231,11 +220,11 @@ fn publication_readiness_evidence_revalidates_every_persisted_fact() { #[test] fn publication_readiness_enforces_nonempty_bytes_and_bounded_urls() { - let empty_hash = RadrootsBlossomSha256::digest(&[]); + let empty_hash = Sha256::digest(&[]); let empty_url = format!("https://cdn.example/{empty_hash}.png"); - let empty_media_type = RadrootsBlossomMediaType::parse("image/png").unwrap(); - let empty_descriptor = RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&empty_url).unwrap(), + let empty_media_type = MediaType::parse("image/png").unwrap(); + let empty_descriptor = BlobDescriptor::new( + BlobUrl::parse(&empty_url).unwrap(), empty_hash, 0, empty_media_type.clone(), @@ -246,10 +235,10 @@ fn publication_readiness_enforces_nonempty_bytes_and_bounded_urls() { .unwrap() .verify_bytes(&[], &empty_media_type) .unwrap(); - let empty_upload = RadrootsBlossomBud02UploadObservation::new( + let empty_upload = Bud02UploadObservation::new( 201, - RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&empty_url).unwrap(), + BlobDescriptor::new( + BlobUrl::parse(&empty_url).unwrap(), empty_hash, 0, empty_media_type.clone(), @@ -258,25 +247,16 @@ fn publication_readiness_enforces_nonempty_bytes_and_bounded_urls() { .unwrap(), ) .unwrap(); - let empty_approved_url = RadrootsBlossomBlobUrl::parse(&empty_url) - .unwrap() - .approve() - .unwrap(); - let empty_head = RadrootsBlossomBud01HeadObservation::new( - 200, - empty_approved_url.clone(), - 0, - empty_media_type, - ) - .unwrap(); + let empty_approved_url = BlobUrl::parse(&empty_url).unwrap().approve().unwrap(); + let empty_head = + Bud01HeadObservation::new(200, empty_approved_url.clone(), 0, empty_media_type).unwrap(); let nonempty_get = - RadrootsBlossomBud01GetObservation::from_complete_body(200, empty_approved_url, 1, &[0]) - .unwrap(); + Bud01GetObservation::from_complete_body(200, empty_approved_url, 1, &[0]).unwrap(); assert_eq!( verify_publication_readiness( &empty_descriptor, &[], - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, &empty_upload, &empty_head, &nonempty_get, @@ -287,7 +267,7 @@ fn publication_readiness_enforces_nonempty_bytes_and_bounded_urls() { ); let bytes = canonical_png(); - let hash = RadrootsBlossomSha256::digest(&bytes); + let hash = Sha256::digest(&bytes); let prefix = format!("https://cdn.example/{hash}."); let exact_extension = "p".repeat(RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES - prefix.len()); @@ -295,7 +275,7 @@ fn publication_readiness_enforces_nonempty_bytes_and_bounded_urls() { &bytes, "image/png", &exact_extension, - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, ) .unwrap(); assert_eq!( @@ -309,7 +289,7 @@ fn publication_readiness_enforces_nonempty_bytes_and_bounded_urls() { &bytes, "image/png", &over_extension, - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, ) .unwrap_err() .code(), @@ -337,7 +317,7 @@ fn publication_readiness_rejects_forbidden_and_corrupt_jpeg_and_animated_rasters &truncated, "image/jpeg", "jpg", - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, ) .unwrap_err() .code(), @@ -356,7 +336,7 @@ fn publication_readiness_rejects_forbidden_and_corrupt_jpeg_and_animated_rasters &malformed_dqt, "image/jpeg", "jpg", - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, ) .unwrap_err() .code(), @@ -370,7 +350,7 @@ fn publication_readiness_rejects_forbidden_and_corrupt_jpeg_and_animated_rasters &progressive, "image/jpeg", "jpg", - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, ) .unwrap_err() .code(), @@ -382,7 +362,7 @@ fn publication_readiness_rejects_forbidden_and_corrupt_jpeg_and_animated_rasters &encoded_animated_png(), "image/png", "png", - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, ) .unwrap_err() .code(), @@ -394,7 +374,7 @@ fn publication_readiness_rejects_forbidden_and_corrupt_jpeg_and_animated_rasters &encoded_animated_webp(), "image/webp", "webp", - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, ) .unwrap_err() .code(), @@ -483,13 +463,13 @@ fn verify_public_raster( bytes: &[u8], media_type: &str, extension: &str, - authored_dimensions: RadrootsBlossomAuthoredRasterDimensions, -) -> Result<radroots_blossom::RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomError> { - let hash = RadrootsBlossomSha256::digest(bytes); + authored_dimensions: AuthoredRasterDimensions, +) -> Result<radroots_blossom::PublicationReadinessEvidence, Error> { + let hash = Sha256::digest(bytes); let url = format!("https://cdn.example/{hash}.{extension}"); - let media_type = RadrootsBlossomMediaType::parse(media_type).unwrap(); - let authored_descriptor = RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&url).unwrap(), + let media_type = MediaType::parse(media_type).unwrap(); + let authored_descriptor = BlobDescriptor::new( + BlobUrl::parse(&url).unwrap(), hash, bytes.len() as u64, media_type.clone(), @@ -500,10 +480,10 @@ fn verify_public_raster( .unwrap() .verify_bytes(bytes, &media_type) .unwrap(); - let upload = RadrootsBlossomBud02UploadObservation::new( + let upload = Bud02UploadObservation::new( 201, - RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&url).unwrap(), + BlobDescriptor::new( + BlobUrl::parse(&url).unwrap(), hash, bytes.len() as u64, media_type.clone(), @@ -512,24 +492,11 @@ fn verify_public_raster( .unwrap(), ) .unwrap(); - let approved_url = RadrootsBlossomBlobUrl::parse(&url) - .unwrap() - .approve() + let approved_url = BlobUrl::parse(&url).unwrap().approve().unwrap(); + let head = Bud01HeadObservation::new(200, approved_url.clone(), bytes.len() as u64, media_type) + .unwrap(); + let get = Bud01GetObservation::from_complete_body(200, approved_url, bytes.len() as u64, bytes) .unwrap(); - let head = RadrootsBlossomBud01HeadObservation::new( - 200, - approved_url.clone(), - bytes.len() as u64, - media_type, - ) - .unwrap(); - let get = RadrootsBlossomBud01GetObservation::from_complete_body( - 200, - approved_url, - bytes.len() as u64, - bytes, - ) - .unwrap(); verify_publication_readiness( &authored_descriptor, bytes, @@ -627,7 +594,7 @@ fn execute_invalid(vector: &Vector) { fn run_mutation( vector: &Vector, mutation: &str, -) -> Result<radroots_blossom::RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomError> { +) -> Result<radroots_blossom::PublicationReadinessEvidence, Error> { let canonical = hex::decode(input_str(vector, "bytes_hex")).unwrap(); let mut sealed_bytes = canonical.clone(); let mut exact_authored_bytes = canonical.clone(); @@ -824,7 +791,7 @@ fn run_mutation( if mutation == "get_size_over_max" { let url = approved_url(get_origin, &sealed_bytes); - return RadrootsBlossomBud01GetObservation::from_complete_body( + return Bud01GetObservation::from_complete_body( get_status, url, adjusted_size(sealed_bytes.len(), get_declared_size_delta), @@ -833,7 +800,7 @@ fn run_mutation( .and_then(|_| unreachable_result()); } - let expected_media_type = RadrootsBlossomMediaType::parse(media_type).unwrap(); + let expected_media_type = MediaType::parse(media_type).unwrap(); let authored_descriptor = descriptor( "https://cdn.example", &sealed_bytes, @@ -843,7 +810,7 @@ fn run_mutation( .approve_reference()? .verify_bytes(&sealed_bytes, &expected_media_type)?; - let upload = RadrootsBlossomBud02UploadObservation::new( + let upload = Bud02UploadObservation::new( upload_status, descriptor( upload_origin, @@ -852,23 +819,23 @@ fn run_mutation( upload_media_type, ), )?; - let head = RadrootsBlossomBud01HeadObservation::new( + let head = Bud01HeadObservation::new( head_status, approved_url(head_origin, &sealed_bytes), adjusted_size(sealed_bytes.len(), head_size_delta), - RadrootsBlossomMediaType::parse(head_media_type).unwrap(), + MediaType::parse(head_media_type).unwrap(), )?; - let get = RadrootsBlossomBud01GetObservation::from_complete_body( + let get = Bud01GetObservation::from_complete_body( get_status, approved_url(get_origin, &sealed_bytes), adjusted_size(sealed_bytes.len(), get_declared_size_delta), &retrieved_bytes, )?; let authored_dimensions = match authored_dimensions { - Some((width, height)) => RadrootsBlossomAuthoredRasterDimensions::Exact( - RadrootsBlossomRasterDimensions::new(width, height)?, - ), - None => RadrootsBlossomAuthoredRasterDimensions::Unspecified, + Some((width, height)) => { + AuthoredRasterDimensions::Exact(RasterDimensions::new(width, height)?) + } + None => AuthoredRasterDimensions::Unspecified, }; verify_publication_readiness( &authored_descriptor, @@ -893,31 +860,25 @@ fn replace_raster_bytes( upload_hash_bytes.clone_from(sealed_bytes); } -fn unreachable_result() --> Result<radroots_blossom::RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomError> { +fn unreachable_result() -> Result<radroots_blossom::PublicationReadinessEvidence, Error> { unreachable!("oversized GET construction must fail") } -fn descriptor( - origin: &str, - hash_bytes: &[u8], - size: u64, - media_type: &str, -) -> RadrootsBlossomBlobDescriptor { - let hash = RadrootsBlossomSha256::digest(hash_bytes); - RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&format!("{origin}/{hash}.png")).unwrap(), +fn descriptor(origin: &str, hash_bytes: &[u8], size: u64, media_type: &str) -> BlobDescriptor { + let hash = Sha256::digest(hash_bytes); + BlobDescriptor::new( + BlobUrl::parse(&format!("{origin}/{hash}.png")).unwrap(), hash, size, - RadrootsBlossomMediaType::parse(media_type).unwrap(), + MediaType::parse(media_type).unwrap(), 1_800_000_000, ) .unwrap() } -fn approved_url(origin: &str, hash_bytes: &[u8]) -> RadrootsBlossomApprovedBlobUrl { - let hash = RadrootsBlossomSha256::digest(hash_bytes); - RadrootsBlossomBlobUrl::parse(&format!("{origin}/{hash}.png")) +fn approved_url(origin: &str, hash_bytes: &[u8]) -> ApprovedBlobUrl { + let hash = Sha256::digest(hash_bytes); + BlobUrl::parse(&format!("{origin}/{hash}.png")) .unwrap() .approve() .unwrap() diff --git a/crates/blossom/tests/publication_readiness_persistence.rs b/crates/blossom/tests/publication_readiness_persistence.rs @@ -1,9 +1,8 @@ #![cfg(feature = "serde")] use radroots_blossom::{ - RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, + PublicationReadinessEvidence, RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, - RadrootsBlossomPublicationReadinessEvidence, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -93,9 +92,8 @@ fn publication_readiness_persistence_vector_executes_every_case() { let bytes = mutated_evidence(&vector.input.mutation); match vector.kind.as_str() { "blossom.publication_readiness_evidence.from_canonical_json.valid" => { - let evidence = - RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&bytes) - .unwrap_or_else(|error| panic!("{}: {error}", vector.id)); + let evidence = PublicationReadinessEvidence::from_canonical_json(&bytes) + .unwrap_or_else(|error| panic!("{}: {error}", vector.id)); assert_eq!( Some(evidence.schema_version()), vector.expected.schema_version @@ -108,9 +106,8 @@ fn publication_readiness_persistence_vector_executes_every_case() { assert!(vector.expected.error.is_none(), "{}", vector.id); } "blossom.publication_readiness_evidence.to_canonical_json.valid" => { - let evidence = - RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&bytes) - .unwrap_or_else(|error| panic!("{}: {error}", vector.id)); + let evidence = PublicationReadinessEvidence::from_canonical_json(&bytes) + .unwrap_or_else(|error| panic!("{}: {error}", vector.id)); assert_eq!(evidence.to_canonical_json().unwrap(), bytes); assert_eq!( Some(evidence.schema_version()), @@ -123,9 +120,7 @@ fn publication_readiness_persistence_vector_executes_every_case() { assert!(vector.expected.error.is_none(), "{}", vector.id); } "blossom.publication_readiness_evidence.from_canonical_json.invalid" => { - let error = - RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&bytes) - .unwrap_err(); + let error = PublicationReadinessEvidence::from_canonical_json(&bytes).unwrap_err(); assert_eq!( Some(error.code()), vector.expected.error.as_deref(), diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml @@ -1,35 +1,36 @@ [package] name = "radroots_core" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] +authors.workspace = true rust-version.workspace = true license.workspace = true description = "Core value model for Radroots" repository.workspace = true homepage.workspace = true documentation = "https://docs.rs/radroots_core" -readme = "README" +readme = "README.md" + +[lib] +name = "radroots_core" [features] default = ["std", "serde"] -dto-bindgen = ["std", "serde", "dep:dto_bindgen"] std = [] serde = ["dep:serde", "rust_decimal/serde"] [dependencies] -dto_bindgen = { workspace = true, optional = true } rust_decimal = { workspace = true, default-features = false } -rust_decimal_macros = { workspace = true } serde = { workspace = true, default-features = false, features = [ "alloc", "derive", ], optional = true } [dev-dependencies] +dto_bindgen = { workspace = true } serde_json = { workspace = true } rust_decimal = { workspace = true } -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } +[lints] +workspace = true diff --git a/crates/core/README b/crates/core/README @@ -1,21 +0,0 @@ -# radroots_core - -This is the README for `radroots_core`, which provides core value types and -unit semantics for the `radroots` core libraries. - -## Overview - - * stable domain value types for currency, money, quantity, quantity pricing, - percent, discounts, and decimals; - * unit kinds, dimensions, parsing, and deterministic conversion helpers; - * portable core semantics for both `std` and `no_std` builds; - * optional integration with `serde` for serialization. - -## Copyright - -Except as otherwise noted, all files in the `radroots_core` distribution are - - Copyright (c) 2025 Tyson Lupul - -For information on usage and redistribution, and for a DISCLAIMER OF ALL -WARRANTIES, see LICENSE included in the `radroots_core` distribution. diff --git a/crates/core/README.md b/crates/core/README.md @@ -0,0 +1,115 @@ +# radroots_core + +`radroots_core` provides the portable, deterministic value model shared by +Radroots domain packages: decimals, currencies, money, percentages, +quantities, units, and pricing. + +The crate supports `no_std` environments with `alloc`. It performs no I/O, +starts no tasks or threads, reads no clocks or process state, and owns no +networking or persistence behavior. + +## Example + +Checked constructors and checked pricing operations are the supported trust +boundary for data received from users, files, databases, or networks: + +```rust +use radroots_core::pricing::QuantityPriceOps; +use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; + +let price = QuantityPrice::try_new( + Money::try_new("6.00".parse::<Decimal>()?, Currency::USD)?, + Quantity::try_new(Decimal::from(2_u32), Unit::MassKg)?, +)?; +let requested = Quantity::try_new(Decimal::from(3_u32), Unit::MassKg)?; +let total = price.try_cost_for_rounded(&requested)?; + +assert_eq!(total.amount().to_string(), "9"); +# Ok::<(), radroots_core::Error>(()) +``` + +The same program is available as the checked +[`pricing`](examples/checked_pricing.rs) example. + +## Public API + +The crate root intentionally exports the common value types and aggregate +[`Error`](https://docs.rs/radroots_core/latest/radroots_core/enum.Error.html). +Focused errors and operations remain in these modules: + +- `currency` — canonical three-letter ASCII currency codes; +- `decimal` — fixed-precision decimal parsing, conversion, and checked math; +- `money` — non-negative monetary values and currency-aware quantization; +- `percent` — signed percentage values and percentage calculations; +- `pricing` — quantity prices, discounts, and checked pricing operations; +- `quantity` — non-negative amounts associated with units; +- `unit` — unit parsing, dimensions, and deterministic conversions. + +The normative responsibility and dependency boundary are defined by the +[`radroots_core` package charter](../../docs/specs/radroots_crates_release_v1.md). +The reviewed pre-release surface is recorded in +[`docs/api`](../../docs/api/README.md). + +## Features + +| Feature | Default | Effect | +| --- | --- | --- | +| `std` | yes | Implements `std::error::Error`; value behavior remains portable. | +| `serde` | yes | Implements canonical serialization and deserialization for public values. | + +Disabling default features leaves the `no_std` + `alloc` value model. Features +are additive; enabling one does not select a runtime, backend, global state, or +side effect. + +## Invariants and untrusted input + +Use `try_*`, `checked_*`, parsing, and exact-conversion APIs at trust +boundaries. They reject negative money or quantities, mismatched currencies or +units, division by zero, overflow, and lossy exact conversions as applicable. +Public composite values keep invariant-bearing fields private and expose only +checked construction and arithmetic, so invalid native state cannot be created +through the supported public API. + +Currency and unit parsers normalize their documented textual spellings. +Decimal display and serialization normalize insignificant trailing zeroes. +Pricing quantization uses deterministic currency exponents and documents its +rounding strategy on the operation that performs the rounding. + +The crate forbids unsafe Rust. It does not process credentials, authorize +actors, or provide confidentiality or authenticity. Callers must impose +application-specific limits before accepting unbounded collections or text; +this crate validates value semantics only. + +## Serialization + +With `serde`, decimal numeric components are encoded as strings so JSON and +other number-limited formats do not introduce floating-point loss. Currencies +and units use their canonical string codes; aggregate values use named fields. +Wire compatibility is governed by the repository conformance vectors. + +Deserializing a composite value re-applies the same invariants as its checked +constructor. Callers remain responsible for application-specific policy and +resource limits before committing untrusted decoded data. + +## Execution and commit semantics + +Operations are synchronous and deterministic. There are no asynchronous +cancellation points, deadlines, retries, callbacks, or partial external side +effects. A successful call returns its complete value; an error returns before +any durable commit because the crate owns no durable state. Methods taking +`&mut self` document whether an error leaves the receiver unchanged. + +## Intended consumers + +`radroots_core` is intended for lower-level Radroots domain crates and for +integrators that need the portable value model directly. Applications should +normally begin with the curated `radroots` crate or the advanced +`radroots_sdk` composition surface. + +The package is pre-1.0. Its durable responsibility and package identity are +fixed, while API-breaking changes follow the workspace's pre-1.0 versioning +policy. + +## License + +Licensed under either Apache-2.0 or MIT, at your option. diff --git a/crates/core/examples/checked_pricing.rs b/crates/core/examples/checked_pricing.rs @@ -0,0 +1,14 @@ +use radroots_core::pricing::QuantityPriceOps; +use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; + +fn main() -> Result<(), radroots_core::Error> { + let price = QuantityPrice::try_new( + Money::try_new("6.00".parse::<Decimal>()?, Currency::USD)?, + Quantity::try_new(Decimal::from(2_u32), Unit::MassKg)?, + )?; + let requested = Quantity::try_new(Decimal::from(3_u32), Unit::MassKg)?; + let total = price.try_cost_for_rounded(&requested)?; + + assert_eq!(total.amount().to_string(), "9"); + Ok(()) +} diff --git a/crates/core/src/currency.rs b/crates/core/src/currency.rs @@ -1,3 +1,5 @@ +//! Canonical three-letter currency codes and currency-specific minor units. + use core::fmt; use core::str::FromStr; @@ -10,21 +12,22 @@ use std::string::String; #[cfg(feature = "serde")] use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as DeError}; -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] +#[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] +#[cfg_attr(all(test, feature = "std"), dto(as = "string"))] #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct RadrootsCoreCurrency([u8; 3]); +pub struct Currency([u8; 3]); -impl RadrootsCoreCurrency { +impl Currency { + /// Builds a currency from its canonical three-byte ASCII code. #[inline] - pub const fn from_const(bytes: [u8; 3]) -> Result<Self, RadrootsCoreCurrencyParseError> { + pub const fn from_const(bytes: [u8; 3]) -> Result<Self, ParseError> { if Self::is_ascii_upper(bytes[0]) && Self::is_ascii_upper(bytes[1]) && Self::is_ascii_upper(bytes[2]) { Ok(Self(bytes)) } else { - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Err(ParseError::InvalidFormat) } } @@ -33,10 +36,10 @@ impl RadrootsCoreCurrency { } #[inline] - pub fn from_str_upper(s: &str) -> Result<Self, RadrootsCoreCurrencyParseError> { + pub fn from_str_upper(s: &str) -> Result<Self, ParseError> { let b = s.as_bytes(); if b.len() != 3 || b.iter().any(|c| !c.is_ascii_uppercase()) { - return Err(RadrootsCoreCurrencyParseError::InvalidFormat); + return Err(ParseError::InvalidFormat); } Ok(Self([b[0], b[1], b[2]])) } @@ -46,12 +49,17 @@ impl RadrootsCoreCurrency { core::str::from_utf8(&self.0).unwrap_or("???") } - pub const USD: RadrootsCoreCurrency = RadrootsCoreCurrency(*b"USD"); - pub const EUR: RadrootsCoreCurrency = RadrootsCoreCurrency(*b"EUR"); - pub const GBP: RadrootsCoreCurrency = RadrootsCoreCurrency(*b"GBP"); - pub const JPY: RadrootsCoreCurrency = RadrootsCoreCurrency(*b"JPY"); - pub const CAD: RadrootsCoreCurrency = RadrootsCoreCurrency(*b"CAD"); - pub const AUD: RadrootsCoreCurrency = RadrootsCoreCurrency(*b"AUD"); + #[inline] + pub const fn as_bytes(&self) -> &[u8; 3] { + &self.0 + } + + pub const USD: Currency = Currency(*b"USD"); + pub const EUR: Currency = Currency(*b"EUR"); + pub const GBP: Currency = Currency(*b"GBP"); + pub const JPY: Currency = Currency(*b"JPY"); + pub const CAD: Currency = Currency(*b"CAD"); + pub const AUD: Currency = Currency(*b"AUD"); #[inline] pub const fn minor_unit_exponent(&self) -> u32 { @@ -69,34 +77,34 @@ impl RadrootsCoreCurrency { } } -impl fmt::Debug for RadrootsCoreCurrency { +impl fmt::Debug for Currency { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("RadrootsCoreCurrency") - .field(&self.as_str()) - .finish() + f.debug_tuple("Currency").field(&self.as_str()).finish() } } -impl fmt::Display for RadrootsCoreCurrency { +impl fmt::Display for Currency { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } -impl TryFrom<&str> for RadrootsCoreCurrency { - type Error = RadrootsCoreCurrencyParseError; +impl TryFrom<&str> for Currency { + type Error = ParseError; fn try_from(s: &str) -> Result<Self, Self::Error> { s.parse() } } -impl FromStr for RadrootsCoreCurrency { - type Err = RadrootsCoreCurrencyParseError; +impl FromStr for Currency { + type Err = ParseError; fn from_str(s: &str) -> Result<Self, Self::Err> { + // Currency input is canonicalized to uppercase ASCII. Serialization + // and Display always emit that canonical representation. let s = s.trim(); if s.len() != 3 || !s.chars().all(|c| c.is_ascii_alphabetic()) { - return Err(RadrootsCoreCurrencyParseError::InvalidFormat); + return Err(ParseError::InvalidFormat); } let upper = s.to_ascii_uppercase(); Self::from_str_upper(&upper) @@ -105,14 +113,14 @@ impl FromStr for RadrootsCoreCurrency { #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCoreCurrencyParseError { +pub enum ParseError { InvalidFormat, } -impl fmt::Display for RadrootsCoreCurrencyParseError { +impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - RadrootsCoreCurrencyParseError::InvalidFormat => { + ParseError::InvalidFormat => { write!(f, "currency must be a 3-letter code") } } @@ -120,17 +128,17 @@ impl fmt::Display for RadrootsCoreCurrencyParseError { } #[cfg(feature = "std")] -impl std::error::Error for RadrootsCoreCurrencyParseError {} +impl std::error::Error for ParseError {} #[cfg(feature = "serde")] -impl Serialize for RadrootsCoreCurrency { +impl Serialize for Currency { fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> { ser.serialize_str(self.as_str()) } } #[cfg(feature = "serde")] -impl<'de> Deserialize<'de> for RadrootsCoreCurrency { +impl<'de> Deserialize<'de> for Currency { fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> { let s = String::deserialize(de)?; s.parse().map_err(D::Error::custom) diff --git a/crates/core/src/decimal.rs b/crates/core/src/decimal.rs @@ -1,7 +1,8 @@ +//! Fixed-precision decimal values, checked arithmetic, and exact conversions. + use core::fmt; -use core::ops::{Add, Div, Mul, Sub}; use core::str::FromStr; -use rust_decimal::Decimal; +use rust_decimal::Decimal as RustDecimal; use rust_decimal::prelude::ToPrimitive; #[cfg(all(feature = "serde", not(feature = "std")))] @@ -12,14 +13,83 @@ use alloc::{format, string::ToString}; #[cfg(feature = "serde")] use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as DeError}; -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] +#[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] +#[cfg_attr(all(test, feature = "std"), dto(as = "string"))] #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] -pub struct RadrootsCoreDecimal(pub Decimal); +pub struct Decimal(RustDecimal); + +/// Errors produced while constructing or calculating with a [`Decimal`]. +/// +/// This type intentionally normalizes `rust_decimal` failures so dependency +/// implementation details do not become part of the Radroots public contract. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + InvalidFormat, + OutOfRange, + ScaleOutOfRange, + PrecisionLoss, + ArithmeticOverflow, + DivisionByZero, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFormat => f.write_str("invalid decimal string"), + Self::OutOfRange => f.write_str("decimal is outside the supported range"), + Self::ScaleOutOfRange => f.write_str("decimal scale is outside the supported range"), + Self::PrecisionLoss => f.write_str("decimal operation would lose precision"), + Self::ArithmeticOverflow => f.write_str("decimal arithmetic overflow"), + Self::DivisionByZero => f.write_str("decimal division by zero"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +fn normalize_parse_error(error: rust_decimal::Error) -> Error { + match error { + rust_decimal::Error::ExceedsMaximumPossibleValue + | rust_decimal::Error::LessThanMinimumPossibleValue + | rust_decimal::Error::Underflow + | rust_decimal::Error::ScaleExceedsMaximumPrecision(_) + | rust_decimal::Error::ConversionTo(_) => Error::OutOfRange, + rust_decimal::Error::ErrorString(_) => Error::InvalidFormat, + } +} + +#[inline] +fn canonicalize_zero(value: RustDecimal) -> RustDecimal { + if value.is_zero() { + RustDecimal::ZERO + } else { + value + } +} -impl RadrootsCoreDecimal { - pub const ZERO: Self = Self(Decimal::ZERO); - pub const ONE: Self = Self(Decimal::ONE); +impl Decimal { + pub const ZERO: Self = Self(RustDecimal::ZERO); + pub const ONE: Self = Self(RustDecimal::ONE); + pub const MAX: Self = Self(RustDecimal::MAX); + pub const MIN: Self = Self(RustDecimal::MIN); + pub const MAX_SCALE: u32 = RustDecimal::MAX_SCALE; + + #[inline] + pub(crate) const fn from_parts(lo: u32, mid: u32, hi: u32, scale: u32) -> Self { + Self(RustDecimal::from_parts(lo, mid, hi, false, scale)) + } + + #[inline] + pub(crate) const fn from_backend(value: RustDecimal) -> Self { + Self(value) + } + + #[inline] + pub(crate) const fn into_backend(self) -> RustDecimal { + self.0 + } #[inline] pub fn is_zero(&self) -> bool { @@ -29,13 +99,41 @@ impl RadrootsCoreDecimal { pub fn is_sign_negative(&self) -> bool { self.0.is_sign_negative() } + /// Rescales with deterministic midpoint-away-from-zero rounding when + /// reducing precision. When increasing precision, the closest + /// representable scale is used. Use [`Self::try_rescale_exact`] when + /// either behavior would be ambiguous at a boundary. #[inline] pub fn rescale(&mut self, scale: u32) { self.0.rescale(scale); } + + /// Changes the scale only when the requested representation is exact. + /// + /// Unlike [`Self::rescale`], this method never rounds and never silently + /// substitutes a smaller scale. The value is left unchanged on error. #[inline] - pub fn normalize(&self) -> Decimal { - self.0.normalize() + pub fn try_rescale_exact(&mut self, scale: u32) -> Result<(), Error> { + if scale > Self::MAX_SCALE { + return Err(Error::ScaleOutOfRange); + } + + let original = self.0; + let mut candidate = original; + candidate.rescale(scale); + if candidate.scale() != scale { + return Err(Error::ScaleOutOfRange); + } + if candidate != original { + return Err(Error::PrecisionLoss); + } + + self.0 = candidate; + Ok(()) + } + #[inline] + pub fn normalize(&self) -> Self { + Self(self.0.normalize()) } #[inline] @@ -44,14 +142,23 @@ impl RadrootsCoreDecimal { } #[inline] - pub fn from_str_exact(s: &str) -> Result<Self, rust_decimal::Error> { - Decimal::from_str_exact(s).map(Self) + pub fn from_str_exact(s: &str) -> Result<Self, Error> { + RustDecimal::from_str_exact(s) + .map(canonicalize_zero) + .map(Self) + .map_err(normalize_parse_error) } + /// Converts the shortest deterministic 17-digit display of a finite + /// `f64`. This is a decimal representation of the displayed float, not a + /// promise to preserve the float's binary representation exactly. #[inline] - pub fn from_f64_display(n: f64) -> Result<Self, rust_decimal::Error> { + pub fn from_f64_display(n: f64) -> Result<Self, Error> { + if !n.is_finite() { + return Err(Error::InvalidFormat); + } let s = format!("{:.17}", n); - Decimal::from_str(&s).map(Self) + s.parse() } #[inline] pub fn to_f64_lossy(&self) -> Option<f64> { @@ -66,90 +173,95 @@ impl RadrootsCoreDecimal { None } } + + #[inline] + pub fn checked_add(self, rhs: Self) -> Result<Self, Error> { + self.0 + .checked_add(rhs.0) + .map(canonicalize_zero) + .map(Self) + .ok_or(Error::ArithmeticOverflow) + } + + #[inline] + pub fn checked_sub(self, rhs: Self) -> Result<Self, Error> { + self.0 + .checked_sub(rhs.0) + .map(canonicalize_zero) + .map(Self) + .ok_or(Error::ArithmeticOverflow) + } + + #[inline] + pub fn checked_mul(self, rhs: Self) -> Result<Self, Error> { + self.0 + .checked_mul(rhs.0) + .map(canonicalize_zero) + .map(Self) + .ok_or(Error::ArithmeticOverflow) + } + + #[inline] + pub fn checked_div(self, rhs: Self) -> Result<Self, Error> { + if rhs.is_zero() { + return Err(Error::DivisionByZero); + } + self.0 + .checked_div(rhs.0) + .map(canonicalize_zero) + .map(Self) + .ok_or(Error::ArithmeticOverflow) + } } #[cfg(feature = "serde")] -impl Serialize for RadrootsCoreDecimal { +impl Serialize for Decimal { fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { - serializer.serialize_str(&self.normalize().to_string()) + serializer.serialize_str(&self.0.normalize().to_string()) } } #[cfg(feature = "serde")] -impl<'de> Deserialize<'de> for RadrootsCoreDecimal { +impl<'de> Deserialize<'de> for Decimal { fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { let s = String::deserialize(deserializer)?; - Decimal::from_str(&s) - .map(RadrootsCoreDecimal) - .map_err(D::Error::custom) + s.parse::<Decimal>().map_err(D::Error::custom) } } -impl fmt::Display for RadrootsCoreDecimal { +impl fmt::Display for Decimal { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.normalize().to_string()) + f.write_str(&self.0.normalize().to_string()) } } -impl From<Decimal> for RadrootsCoreDecimal { - fn from(d: Decimal) -> Self { - Self(d) - } -} -impl From<RadrootsCoreDecimal> for Decimal { - fn from(d: RadrootsCoreDecimal) -> Self { - d.0 - } -} -impl From<u32> for RadrootsCoreDecimal { +impl From<u32> for Decimal { fn from(v: u32) -> Self { - Self(Decimal::from(v)) + Self(RustDecimal::from(v)) } } -impl From<i32> for RadrootsCoreDecimal { +impl From<i32> for Decimal { fn from(v: i32) -> Self { - Self(Decimal::from(v)) + Self(RustDecimal::from(v)) } } -impl From<u64> for RadrootsCoreDecimal { +impl From<u64> for Decimal { fn from(v: u64) -> Self { - Self(Decimal::from(v)) + Self(RustDecimal::from(v)) } } -impl From<i64> for RadrootsCoreDecimal { +impl From<i64> for Decimal { fn from(v: i64) -> Self { - Self(Decimal::from(v)) - } -} - -impl Add for RadrootsCoreDecimal { - type Output = Self; - fn add(self, rhs: Self) -> Self { - Self(self.0 + rhs.0) - } -} -impl Sub for RadrootsCoreDecimal { - type Output = Self; - fn sub(self, rhs: Self) -> Self { - Self(self.0 - rhs.0) - } -} -impl Mul for RadrootsCoreDecimal { - type Output = Self; - fn mul(self, rhs: Self) -> Self { - Self(self.0 * rhs.0) - } -} -impl Div for RadrootsCoreDecimal { - type Output = Self; - fn div(self, rhs: Self) -> Self { - Self(self.0 / rhs.0) + Self(RustDecimal::from(v)) } } -impl FromStr for RadrootsCoreDecimal { - type Err = rust_decimal::Error; +impl FromStr for Decimal { + type Err = Error; fn from_str(s: &str) -> Result<Self, Self::Err> { - Decimal::from_str(s).map(RadrootsCoreDecimal) + RustDecimal::from_str(s) + .map(canonicalize_zero) + .map(Decimal) + .map_err(normalize_parse_error) } } diff --git a/crates/core/src/discount.rs b/crates/core/src/discount.rs @@ -1,65 +1,140 @@ +use core::fmt; + #[cfg(not(feature = "std"))] use alloc::string::String; #[cfg(feature = "std")] use std::string::String; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] +#[cfg_attr(all(test, feature = "std"), dto(export))] #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] -pub enum RadrootsCoreDiscountScope { +pub enum DiscountScope { Bin, OrderTotal, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] +#[cfg_attr(all(test, feature = "std"), dto(export))] #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr( feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount") )] -pub enum RadrootsCoreDiscountThreshold { +pub enum DiscountThreshold { BinCount { bin_id: String, min: u32 }, - OrderQuantity { min: crate::RadrootsCoreQuantity }, + OrderQuantity { min: crate::Quantity }, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] +#[cfg_attr(all(test, feature = "std"), dto(export))] #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr( feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount") )] -pub enum RadrootsCoreDiscountValue { - MoneyPerBin(crate::RadrootsCoreMoney), - Percent(crate::RadrootsCorePercent), +pub enum DiscountValue { + MoneyPerBin(crate::Money), + Percent(crate::Percent), } -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] +#[cfg_attr(all(test, feature = "std"), dto(export))] #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] -pub struct RadrootsCoreDiscount { - pub scope: RadrootsCoreDiscountScope, - pub threshold: RadrootsCoreDiscountThreshold, - pub value: RadrootsCoreDiscountValue, +pub struct Discount { + scope: DiscountScope, + threshold: DiscountThreshold, + value: DiscountValue, } -impl RadrootsCoreDiscount { - pub fn is_non_negative(&self) -> bool { - let threshold_ok = match &self.threshold { - RadrootsCoreDiscountThreshold::BinCount { .. } => true, - RadrootsCoreDiscountThreshold::OrderQuantity { min } => !min.amount.is_sign_negative(), - }; - let value_ok = match &self.value { - RadrootsCoreDiscountValue::MoneyPerBin(m) => !m.amount.is_sign_negative(), - RadrootsCoreDiscountValue::Percent(p) => !p.value.is_sign_negative(), +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + NegativeThreshold, + NegativeValue, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NegativeThreshold => f.write_str("discount threshold must be non-negative"), + Self::NegativeValue => f.write_str("discount value must be non-negative"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +impl Discount { + #[inline] + pub fn try_new( + scope: DiscountScope, + threshold: DiscountThreshold, + value: DiscountValue, + ) -> Result<Self, Error> { + let discount = Self { + scope, + threshold, + value, }; - threshold_ok && value_ok + discount.validate()?; + Ok(discount) + } + + #[inline] + pub fn scope(&self) -> &DiscountScope { + &self.scope + } + + #[inline] + pub fn threshold(&self) -> &DiscountThreshold { + &self.threshold + } + + #[inline] + pub fn value(&self) -> &DiscountValue { + &self.value + } + + pub fn validate(&self) -> Result<(), Error> { + if let DiscountThreshold::OrderQuantity { min } = &self.threshold { + min.ensure_non_negative() + .map_err(|_| Error::NegativeThreshold)?; + } + match &self.value { + DiscountValue::MoneyPerBin(money) => money + .ensure_non_negative() + .map_err(|_| Error::NegativeValue)?, + DiscountValue::Percent(percent) if percent.value().is_sign_negative() => { + return Err(Error::NegativeValue); + } + DiscountValue::Percent(_) => {} + } + Ok(()) + } + + pub fn is_non_negative(&self) -> bool { + self.validate().is_ok() + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Discount { + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + #[derive(serde::Deserialize)] + struct Wire { + scope: DiscountScope, + threshold: DiscountThreshold, + value: DiscountValue, + } + + let wire = Wire::deserialize(deserializer)?; + Self::try_new(wire.scope, wire.threshold, wire.value).map_err(serde::de::Error::custom) } } diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs @@ -0,0 +1,106 @@ +use core::fmt; + +/// Crate-wide error for callers that prefer one Radroots-owned error type. +/// +/// Focused module APIs retain their narrower errors; each converts into this +/// aggregate without exposing errors owned by implementation dependencies. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + Currency(crate::currency::ParseError), + Decimal(crate::decimal::Error), + Discount(crate::pricing::DiscountError), + Money(crate::money::Error), + Percent(crate::percent::ParseError), + Pricing(crate::pricing::Error), + Quantity(crate::quantity::Error), + UnitParse(crate::unit::ParseError), + UnitConversion(crate::unit::ConvertError), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Currency(error) => write!(f, "currency: {error}"), + Self::Decimal(error) => write!(f, "decimal: {error}"), + Self::Discount(error) => write!(f, "discount: {error}"), + Self::Money(error) => write!(f, "money: {error}"), + Self::Percent(error) => write!(f, "percent: {error}"), + Self::Pricing(error) => write!(f, "pricing: {error}"), + Self::Quantity(error) => write!(f, "quantity: {error}"), + Self::UnitParse(error) => write!(f, "unit parse: {error}"), + Self::UnitConversion(error) => write!(f, "unit conversion: {error}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Currency(error) => Some(error), + Self::Decimal(error) => Some(error), + Self::Discount(error) => Some(error), + Self::Money(error) => Some(error), + Self::Percent(error) => Some(error), + Self::Pricing(error) => Some(error), + Self::Quantity(error) => Some(error), + Self::UnitParse(error) => Some(error), + Self::UnitConversion(error) => Some(error), + } + } +} + +impl From<crate::currency::ParseError> for Error { + fn from(error: crate::currency::ParseError) -> Self { + Self::Currency(error) + } +} + +impl From<crate::decimal::Error> for Error { + fn from(error: crate::decimal::Error) -> Self { + Self::Decimal(error) + } +} + +impl From<crate::discount::Error> for Error { + fn from(error: crate::discount::Error) -> Self { + Self::Discount(error) + } +} + +impl From<crate::money::Error> for Error { + fn from(error: crate::money::Error) -> Self { + Self::Money(error) + } +} + +impl From<crate::percent::ParseError> for Error { + fn from(error: crate::percent::ParseError) -> Self { + Self::Percent(error) + } +} + +impl From<crate::quantity_price::Error> for Error { + fn from(error: crate::quantity_price::Error) -> Self { + Self::Pricing(error) + } +} + +impl From<crate::quantity::Error> for Error { + fn from(error: crate::quantity::Error) -> Self { + Self::Quantity(error) + } +} + +impl From<crate::unit::ParseError> for Error { + fn from(error: crate::unit::ParseError) -> Self { + Self::UnitParse(error) + } +} + +impl From<crate::unit::ConvertError> for Error { + fn from(error: crate::unit::ConvertError) -> Self { + Self::UnitConversion(error) + } +} diff --git a/crates/core/src/generated/dto_roots.rs b/crates/core/src/generated/dto_roots.rs @@ -3,18 +3,15 @@ pub fn dto_bindgen_roots() -> ::std::vec::Vec<::dto_bindgen::export::RootDescriptor> { ::std::vec![ - ::dto_bindgen::export::RootDescriptor::new::<crate::discount::RadrootsCoreDiscount>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::discount::RadrootsCoreDiscountScope>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::discount::RadrootsCoreDiscountThreshold>( - ), - ::dto_bindgen::export::RootDescriptor::new::<crate::discount::RadrootsCoreDiscountValue>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::money::RadrootsCoreMoney>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::percent::RadrootsCorePercent>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::quantity::RadrootsCoreQuantity>(), - ::dto_bindgen::export::RootDescriptor::new::< - crate::quantity_price::RadrootsCoreQuantityPrice, - >(), - ::dto_bindgen::export::RootDescriptor::new::<crate::unit::RadrootsCoreUnit>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::unit::RadrootsCoreUnitDimension>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::discount::Discount>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::discount::DiscountScope>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::discount::DiscountThreshold>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::discount::DiscountValue>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::money::Money>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::percent::Percent>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::quantity::Quantity>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::quantity_price::QuantityPrice>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::unit::Unit>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::unit::UnitDimension>(), ] } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs @@ -1,35 +1,31 @@ +#![doc = include_str!("../README.md")] #![cfg_attr(not(feature = "std"), no_std)] #![forbid(unsafe_code)] #[cfg(not(feature = "std"))] extern crate alloc; +#[cfg(all(test, not(feature = "std")))] +extern crate std; pub mod currency; pub mod decimal; -pub mod discount; -#[cfg(feature = "dto-bindgen")] -pub mod dto; +mod discount; +#[cfg(all(test, feature = "std"))] +mod dto; +mod error; pub mod money; pub mod percent; +pub mod pricing; pub mod quantity; -pub mod quantity_price; +mod quantity_price; #[cfg(feature = "serde")] -pub mod serde_ext; +mod serde_ext; pub mod unit; -pub use currency::{RadrootsCoreCurrency, RadrootsCoreCurrencyParseError}; -pub use decimal::RadrootsCoreDecimal; -pub use discount::{ - RadrootsCoreDiscount, RadrootsCoreDiscountScope, RadrootsCoreDiscountThreshold, - RadrootsCoreDiscountValue, -}; -pub use money::{RadrootsCoreMoney, RadrootsCoreMoneyInvariantError}; -pub use percent::{RadrootsCorePercent, RadrootsCorePercentParseError}; -pub use quantity::{RadrootsCoreQuantity, RadrootsCoreQuantityInvariantError}; -pub use quantity_price::{ - RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreQuantityPriceOps, -}; -pub use unit::{ - RadrootsCoreUnit, RadrootsCoreUnitConvertError, RadrootsCoreUnitDimension, - RadrootsCoreUnitParseError, convert_mass_decimal, convert_unit_decimal, convert_volume_decimal, - parse_mass_unit, parse_volume_unit, -}; +pub use currency::Currency; +pub use decimal::Decimal; +pub use error::Error; +pub use money::Money; +pub use percent::Percent; +pub use pricing::QuantityPrice; +pub use quantity::Quantity; +pub use unit::Unit; diff --git a/crates/core/src/money.rs b/crates/core/src/money.rs @@ -1,50 +1,90 @@ +//! Non-negative monetary values and currency-aware quantization. + use core::fmt; -use rust_decimal::Decimal; -use rust_decimal::RoundingStrategy; +use rust_decimal::Decimal as BackendDecimal; use rust_decimal::prelude::ToPrimitive; -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] +#[cfg_attr(all(test, feature = "std"), dto(export))] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsCoreMoney { - pub amount: crate::RadrootsCoreDecimal, - pub currency: crate::RadrootsCoreCurrency, +pub struct Money { + amount: crate::Decimal, + currency: crate::Currency, } #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCoreMoneyInvariantError { +pub enum Error { NegativeAmount, NotWholeMinorUnits, AmountOverflow, CurrencyMismatch, + ArithmeticOverflow, + DivisionByZero, + ScaleOutOfRange, + PrecisionLoss, } -impl fmt::Display for RadrootsCoreMoneyInvariantError { +impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::NegativeAmount => write!(f, "money amount must be ≥ 0"), Self::NotWholeMinorUnits => write!(f, "money not a whole number of minor units"), Self::AmountOverflow => write!(f, "money minor-unit conversion overflow"), Self::CurrencyMismatch => write!(f, "money currency mismatch"), + Self::ArithmeticOverflow => write!(f, "money arithmetic overflow"), + Self::DivisionByZero => write!(f, "money division by zero"), + Self::ScaleOutOfRange => write!(f, "money scale is outside the supported range"), + Self::PrecisionLoss => write!(f, "money operation would lose precision"), } } } #[cfg(feature = "std")] -impl std::error::Error for RadrootsCoreMoneyInvariantError {} +impl std::error::Error for Error {} + +impl From<crate::decimal::Error> for Error { + fn from(error: crate::decimal::Error) -> Self { + match error { + crate::decimal::Error::DivisionByZero => Self::DivisionByZero, + crate::decimal::Error::ScaleOutOfRange => Self::ScaleOutOfRange, + crate::decimal::Error::PrecisionLoss => Self::PrecisionLoss, + _ => Self::ArithmeticOverflow, + } + } +} + +impl Money { + #[inline] + pub fn try_new(amount: crate::Decimal, currency: crate::Currency) -> Result<Self, Error> { + if amount.is_sign_negative() && !amount.is_zero() { + return Err(Error::NegativeAmount); + } + Ok(Self { + amount: if amount.is_zero() { + crate::Decimal::ZERO + } else { + amount + }, + currency, + }) + } -impl RadrootsCoreMoney { #[inline] - pub fn new(amount: crate::RadrootsCoreDecimal, currency: crate::RadrootsCoreCurrency) -> Self { - Self { amount, currency } + pub const fn amount(&self) -> crate::Decimal { + self.amount } #[inline] - pub fn zero(currency: crate::RadrootsCoreCurrency) -> Self { + pub const fn currency(&self) -> crate::Currency { + self.currency + } + + #[inline] + pub fn zero(currency: crate::Currency) -> Self { Self { - amount: crate::RadrootsCoreDecimal::ZERO, + amount: crate::Decimal::ZERO, currency, } } @@ -55,145 +95,147 @@ impl RadrootsCoreMoney { } #[inline] - pub fn ensure_non_negative(&self) -> Result<(), RadrootsCoreMoneyInvariantError> { - if self.amount.is_sign_negative() { - return Err(RadrootsCoreMoneyInvariantError::NegativeAmount); + pub(crate) fn ensure_non_negative(&self) -> Result<(), Error> { + if self.amount.is_sign_negative() && !self.amount.is_zero() { + return Err(Error::NegativeAmount); } Ok(()) } #[inline] pub fn quantize_to_currency(self) -> Self { - self.quantize_to_currency_with_strategy(RoundingStrategy::MidpointAwayFromZero) + let mut value = self; + let e = value.currency.minor_unit_exponent(); + value.amount = crate::Decimal::from_backend( + value + .amount + .into_backend() + .round_dp_with_strategy(e, rust_decimal::RoundingStrategy::MidpointAwayFromZero), + ); + value } + /// Changes the amount scale without rounding or changing its value. #[inline] - pub fn quantize_to_currency_with_strategy(mut self, strategy: RoundingStrategy) -> Self { - let e = self.currency.minor_unit_exponent(); - self.amount.0 = self.amount.0.round_dp_with_strategy(e, strategy); - self + pub fn try_with_scale_exact(mut self, scale: u32) -> Result<Self, Error> { + self.amount.try_rescale_exact(scale)?; + Ok(self) } #[inline] - pub fn with_scale(mut self, scale: u32) -> Self { - self.amount.rescale(scale); - self - } - - #[inline] - pub fn checked_add(&self, rhs: &Self) -> Result<Self, RadrootsCoreMoneyInvariantError> { + pub fn checked_add(&self, rhs: &Self) -> Result<Self, Error> { if self.currency != rhs.currency { - return Err(RadrootsCoreMoneyInvariantError::CurrencyMismatch); + return Err(Error::CurrencyMismatch); } - Ok(Self::new(self.amount + rhs.amount, self.currency)) + Self::try_new(self.amount.checked_add(rhs.amount)?, self.currency) } #[inline] - pub fn checked_sub(&self, rhs: &Self) -> Result<Self, RadrootsCoreMoneyInvariantError> { + pub fn checked_sub(&self, rhs: &Self) -> Result<Self, Error> { if self.currency != rhs.currency { - return Err(RadrootsCoreMoneyInvariantError::CurrencyMismatch); + return Err(Error::CurrencyMismatch); } - Ok(Self::new(self.amount - rhs.amount, self.currency)) + Self::try_new(self.amount.checked_sub(rhs.amount)?, self.currency) } #[inline] - pub fn mul_decimal(&self, factor: crate::RadrootsCoreDecimal) -> Self { - Self::new(self.amount * factor, self.currency) + pub fn checked_mul_decimal(&self, factor: crate::Decimal) -> Result<Self, Error> { + Self::try_new(self.amount.checked_mul(factor)?, self.currency) } #[inline] - pub fn div_decimal(&self, divisor: crate::RadrootsCoreDecimal) -> Self { - Self::new(self.amount / divisor, self.currency) + pub fn checked_div_decimal(&self, divisor: crate::Decimal) -> Result<Self, Error> { + Self::try_new(self.amount.checked_div(divisor)?, self.currency) } #[inline] - pub fn from_minor_units_u64(amount_minor: u64, currency: crate::RadrootsCoreCurrency) -> Self { + pub fn from_minor_units_u64(amount_minor: u64, currency: crate::Currency) -> Self { let e = currency.minor_unit_exponent(); - let major = Decimal::from_i128_with_scale(amount_minor as i128, e); - Self::new(crate::RadrootsCoreDecimal(major), currency) + let major = BackendDecimal::from_i128_with_scale(amount_minor as i128, e); + Self { + amount: crate::Decimal::from_backend(major), + currency, + } } #[inline] - pub fn from_minor_units_u32(amount_minor: u32, currency: crate::RadrootsCoreCurrency) -> Self { + pub fn from_minor_units_u32(amount_minor: u32, currency: crate::Currency) -> Self { Self::from_minor_units_u64(amount_minor as u64, currency) } #[inline] - fn pow10(e: u32) -> Decimal { + fn pow10(e: u32) -> BackendDecimal { match e { - 0 => Decimal::ONE, - 1 => Decimal::from(10u32), - 2 => Decimal::from(100u32), - 3 => Decimal::from(1_000u32), + 0 => BackendDecimal::ONE, + 1 => BackendDecimal::from(10u32), + 2 => BackendDecimal::from(100u32), + 3 => BackendDecimal::from(1_000u32), _ => { let p = 10u128.pow(e.min(38)); - Decimal::from(p) + BackendDecimal::from(p) } } } #[inline] - pub fn to_minor_units_u64_exact(&self) -> Result<u64, RadrootsCoreMoneyInvariantError> { + pub fn to_minor_units_u64_exact(&self) -> Result<u64, Error> { let e = self.currency.minor_unit_exponent(); - let as_minor = self.amount.0 * Self::pow10(e); + let as_minor = self + .amount + .checked_mul(crate::Decimal::from_backend(Self::pow10(e))) + .map_err(|_| Error::AmountOverflow)? + .into_backend(); if !as_minor.fract().is_zero() { - return Err(RadrootsCoreMoneyInvariantError::NotWholeMinorUnits); + return Err(Error::NotWholeMinorUnits); } - as_minor - .to_u64() - .ok_or(RadrootsCoreMoneyInvariantError::AmountOverflow) + as_minor.to_u64().ok_or(Error::AmountOverflow) } #[inline] - pub fn to_minor_units_u64_rounded( - &self, - strategy: RoundingStrategy, - ) -> Result<u64, RadrootsCoreMoneyInvariantError> { + pub fn to_minor_units_u64_rounded(&self) -> Result<u64, Error> { let e = self.currency.minor_unit_exponent(); - let scaled = self.amount.0.round_dp_with_strategy(e, strategy); - let as_minor = scaled * Self::pow10(e); + let scaled = self + .amount + .into_backend() + .round_dp_with_strategy(e, rust_decimal::RoundingStrategy::MidpointAwayFromZero); + let as_minor = scaled + .checked_mul(Self::pow10(e)) + .ok_or(Error::AmountOverflow)?; debug_assert!(as_minor.fract().is_zero()); - as_minor - .to_u64() - .ok_or(RadrootsCoreMoneyInvariantError::AmountOverflow) + as_minor.to_u64().ok_or(Error::AmountOverflow) } #[inline] - pub fn to_minor_units_u32_exact(&self) -> Result<u32, RadrootsCoreMoneyInvariantError> { + pub fn to_minor_units_u32_exact(&self) -> Result<u32, Error> { let v = self.to_minor_units_u64_exact()?; - u32::try_from(v).map_err(|_| RadrootsCoreMoneyInvariantError::AmountOverflow) + u32::try_from(v).map_err(|_| Error::AmountOverflow) } #[inline] - pub fn to_minor_units_u32_rounded( - &self, - strategy: RoundingStrategy, - ) -> Result<u32, RadrootsCoreMoneyInvariantError> { - let v = self.to_minor_units_u64_rounded(strategy)?; - u32::try_from(v).map_err(|_| RadrootsCoreMoneyInvariantError::AmountOverflow) + pub fn to_minor_units_u32_rounded(&self) -> Result<u32, Error> { + let v = self.to_minor_units_u64_rounded()?; + u32::try_from(v).map_err(|_| Error::AmountOverflow) } } -impl fmt::Display for RadrootsCoreMoney { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} {}", self.amount, self.currency) - } -} - -use core::ops::{Div, Mul}; +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Money { + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + #[derive(serde::Deserialize)] + struct Wire { + amount: crate::Decimal, + currency: crate::Currency, + } -impl Mul<crate::RadrootsCoreDecimal> for RadrootsCoreMoney { - type Output = Self; - fn mul(self, rhs: crate::RadrootsCoreDecimal) -> Self { - self.mul_decimal(rhs) + let wire = Wire::deserialize(deserializer)?; + Self::try_new(wire.amount, wire.currency).map_err(serde::de::Error::custom) } } -impl Div<crate::RadrootsCoreDecimal> for RadrootsCoreMoney { - type Output = Self; - fn div(self, rhs: crate::RadrootsCoreDecimal) -> Self { - self.div_decimal(rhs) +impl fmt::Display for Money { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {}", self.amount, self.currency) } } @@ -203,10 +245,10 @@ mod tests { #[test] fn pow10_internal_paths_cover_fallback_branches() { - assert_eq!(RadrootsCoreMoney::pow10(0), Decimal::ONE); - assert_eq!(RadrootsCoreMoney::pow10(1), Decimal::from(10u32)); - assert_eq!(RadrootsCoreMoney::pow10(2), Decimal::from(100u32)); - assert_eq!(RadrootsCoreMoney::pow10(3), Decimal::from(1_000u32)); - assert_eq!(RadrootsCoreMoney::pow10(6), Decimal::from(1_000_000u32)); + assert_eq!(Money::pow10(0), BackendDecimal::ONE); + assert_eq!(Money::pow10(1), BackendDecimal::from(10u32)); + assert_eq!(Money::pow10(2), BackendDecimal::from(100u32)); + assert_eq!(Money::pow10(3), BackendDecimal::from(1_000u32)); + assert_eq!(Money::pow10(6), BackendDecimal::from(1_000_000u32)); } } diff --git a/crates/core/src/percent.rs b/crates/core/src/percent.rs @@ -1,80 +1,92 @@ +//! Signed percentage values and deterministic percentage calculations. + use core::fmt; use core::str::FromStr; -use crate::RadrootsCoreDecimal; -use crate::money::RadrootsCoreMoney; +use crate::Decimal; +use crate::money::Money; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] +#[cfg_attr(all(test, feature = "std"), dto(export))] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsCorePercent { +pub struct Percent { #[cfg_attr(feature = "serde", serde(with = "crate::serde_ext::decimal_str"))] - #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] - pub value: RadrootsCoreDecimal, + #[cfg_attr(all(test, feature = "std"), dto(as = "string"))] + value: Decimal, } -impl RadrootsCorePercent { +impl Percent { + /// Creates a percentage value. + /// + /// `Percent` is intentionally signed and unbounded because it is also + /// used for deltas. Domain-specific non-negative rules belong to values + /// such as [`crate::pricing::Discount`]. #[inline] - pub fn new(value: RadrootsCoreDecimal) -> Self { + pub fn new(value: Decimal) -> Self { Self { value } } #[inline] - pub fn from_ratio(ratio_0_to_1: RadrootsCoreDecimal) -> Self { - Self { - value: ratio_0_to_1 * RadrootsCoreDecimal::from(100u32), - } + pub const fn value(&self) -> Decimal { + self.value + } + + #[inline] + pub fn try_from_ratio(ratio_0_to_1: Decimal) -> Result<Self, crate::decimal::Error> { + Ok(Self { + value: ratio_0_to_1.checked_mul(Decimal::from(100u32))?, + }) } #[inline] - pub fn to_ratio(&self) -> RadrootsCoreDecimal { - self.value / RadrootsCoreDecimal::from(100u32) + pub fn try_to_ratio(&self) -> Result<Decimal, crate::decimal::Error> { + self.value.checked_div(Decimal::from(100u32)) } #[inline] - pub fn of_money(&self, base: &RadrootsCoreMoney) -> RadrootsCoreMoney { - base.mul_decimal(self.to_ratio()) + pub fn try_of_money(&self, base: &Money) -> Result<Money, crate::money::Error> { + base.checked_mul_decimal(self.try_to_ratio().map_err(crate::money::Error::from)?) } #[inline] - pub fn of_money_quantized(&self, base: &RadrootsCoreMoney) -> RadrootsCoreMoney { - base.mul_decimal(self.to_ratio()).quantize_to_currency() + pub fn try_of_money_quantized(&self, base: &Money) -> Result<Money, crate::money::Error> { + Ok(self.try_of_money(base)?.quantize_to_currency()) } } -impl fmt::Display for RadrootsCorePercent { +impl fmt::Display for Percent { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}%", self.value.normalize()) } } -impl FromStr for RadrootsCorePercent { - type Err = RadrootsCorePercentParseError; +impl FromStr for Percent { + type Err = ParseError; fn from_str(s: &str) -> Result<Self, Self::Err> { let trimmed = s.trim_end(); let no_pct = trimmed.strip_suffix('%').unwrap_or(trimmed).trim(); let dec = no_pct - .parse::<RadrootsCoreDecimal>() - .map_err(|_| RadrootsCorePercentParseError::InvalidNumber)?; - Ok(RadrootsCorePercent::new(dec)) + .parse::<Decimal>() + .map_err(|_| ParseError::InvalidNumber)?; + Ok(Percent::new(dec)) } } #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCorePercentParseError { +pub enum ParseError { InvalidNumber, } -impl fmt::Display for RadrootsCorePercentParseError { +impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - RadrootsCorePercentParseError::InvalidNumber => write!(f, "invalid percent string"), + ParseError::InvalidNumber => write!(f, "invalid percent string"), } } } #[cfg(feature = "std")] -impl std::error::Error for RadrootsCorePercentParseError {} +impl std::error::Error for ParseError {} diff --git a/crates/core/src/pricing.rs b/crates/core/src/pricing.rs @@ -0,0 +1,10 @@ +//! Deterministic quantity pricing, discounts, and checked cost operations. +//! +//! Import [`QuantityPriceOps`] to calculate costs. Its `try_*` methods reject +//! invalid prices, negative quantities, unit mismatches, and arithmetic +//! overflow without producing a partial result. + +pub use crate::discount::{ + Discount, DiscountScope, DiscountThreshold, DiscountValue, Error as DiscountError, +}; +pub use crate::quantity_price::{Error, QuantityPrice, QuantityPriceOps}; diff --git a/crates/core/src/quantity.rs b/crates/core/src/quantity.rs @@ -1,34 +1,58 @@ +//! Non-negative measured quantities and deterministic unit conversion. + use core::fmt; -use crate::RadrootsCoreDecimal; -use crate::unit::{RadrootsCoreUnit, RadrootsCoreUnitConvertError, convert_unit_decimal}; +use crate::Decimal; +use crate::unit::{ConvertError, Unit, convert_unit_decimal}; #[cfg(not(feature = "std"))] use alloc::string::String; #[cfg(feature = "std")] use std::string::String; -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] +#[cfg_attr(all(test, feature = "std"), dto(export))] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsCoreQuantity { +pub struct Quantity { #[cfg_attr(feature = "serde", serde(with = "crate::serde_ext::decimal_str"))] - #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] - pub amount: RadrootsCoreDecimal, - pub unit: RadrootsCoreUnit, + #[cfg_attr(all(test, feature = "std"), dto(as = "string"))] + amount: Decimal, + unit: Unit, #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] - pub label: Option<String>, + label: Option<String>, } -impl RadrootsCoreQuantity { +impl Quantity { #[inline] - pub fn new(amount: RadrootsCoreDecimal, unit: RadrootsCoreUnit) -> Self { - Self { - amount, + pub fn try_new(amount: Decimal, unit: Unit) -> Result<Self, Error> { + if amount.is_sign_negative() && !amount.is_zero() { + return Err(Error::NegativeAmount); + } + Ok(Self { + amount: if amount.is_zero() { + Decimal::ZERO + } else { + amount + }, unit, label: None, - } + }) + } + + #[inline] + pub const fn amount(&self) -> Decimal { + self.amount + } + + #[inline] + pub const fn unit(&self) -> Unit { + self.unit + } + + #[inline] + pub fn label(&self) -> Option<&str> { + self.label.as_deref() } #[inline] @@ -50,9 +74,9 @@ impl RadrootsCoreQuantity { } #[inline] - pub fn zero(unit: RadrootsCoreUnit) -> Self { + pub fn zero(unit: Unit) -> Self { Self { - amount: RadrootsCoreDecimal::ZERO, + amount: Decimal::ZERO, unit, label: None, } @@ -69,20 +93,17 @@ impl RadrootsCoreQuantity { } #[inline] - pub fn canonical_unit(&self) -> RadrootsCoreUnit { + pub fn canonical_unit(&self) -> Unit { self.unit.canonical_unit() } #[inline] - pub fn try_convert_to( - &self, - unit: RadrootsCoreUnit, - ) -> Result<RadrootsCoreQuantity, RadrootsCoreUnitConvertError> { + pub fn try_convert_to(&self, unit: Unit) -> Result<Quantity, ConvertError> { if self.unit == unit { return Ok(self.clone()); } let amount = convert_unit_decimal(self.amount, self.unit, unit)?; - Ok(RadrootsCoreQuantity { + Ok(Quantity { amount, unit, label: self.label.clone(), @@ -90,98 +111,101 @@ impl RadrootsCoreQuantity { } #[inline] - pub fn to_canonical(&self) -> Result<RadrootsCoreQuantity, RadrootsCoreUnitConvertError> { + pub fn to_canonical(&self) -> Result<Quantity, ConvertError> { self.try_convert_to(self.unit.canonical_unit()) } #[inline] - pub fn ensure_non_negative(&self) -> Result<(), RadrootsCoreQuantityInvariantError> { - if self.amount.is_sign_negative() { - return Err(RadrootsCoreQuantityInvariantError::NegativeAmount); + pub(crate) fn ensure_non_negative(&self) -> Result<(), Error> { + if self.amount.is_sign_negative() && !self.amount.is_zero() { + return Err(Error::NegativeAmount); } Ok(()) } #[inline] - pub fn with_scale(mut self, scale: u32) -> Self { - self.amount.rescale(scale); - self - } - - #[inline] - pub fn try_add( - &self, - rhs: &RadrootsCoreQuantity, - ) -> Result<RadrootsCoreQuantity, RadrootsCoreQuantityInvariantError> { + pub fn try_add(&self, rhs: &Quantity) -> Result<Quantity, Error> { if self.unit != rhs.unit { - return Err(RadrootsCoreQuantityInvariantError::UnitMismatch); + return Err(Error::UnitMismatch); } - Ok(RadrootsCoreQuantity { - amount: self.amount + rhs.amount, + Ok(Quantity { + amount: self.amount.checked_add(rhs.amount)?, unit: self.unit, label: self.label.clone(), }) } #[inline] - pub fn try_sub( - &self, - rhs: &RadrootsCoreQuantity, - ) -> Result<RadrootsCoreQuantity, RadrootsCoreQuantityInvariantError> { + pub fn try_sub(&self, rhs: &Quantity) -> Result<Quantity, Error> { if self.unit != rhs.unit { - return Err(RadrootsCoreQuantityInvariantError::UnitMismatch); + return Err(Error::UnitMismatch); } - Ok(RadrootsCoreQuantity { - amount: self.amount - rhs.amount, + let amount = self.amount.checked_sub(rhs.amount)?; + if amount.is_sign_negative() { + return Err(Error::NegativeAmount); + } + Ok(Quantity { + amount, unit: self.unit, label: self.label.clone(), }) } - pub fn checked_add(&self, rhs: &RadrootsCoreQuantity) -> Option<RadrootsCoreQuantity> { - if self.unit == rhs.unit { - Some(RadrootsCoreQuantity { - amount: self.amount + rhs.amount, - unit: self.unit, - label: self.label.clone(), - }) - } else { - None - } + pub fn checked_add(&self, rhs: &Quantity) -> Option<Quantity> { + self.try_add(rhs).ok() } - pub fn checked_sub(&self, rhs: &RadrootsCoreQuantity) -> Option<RadrootsCoreQuantity> { - if self.unit == rhs.unit { - Some(RadrootsCoreQuantity { - amount: self.amount - rhs.amount, - unit: self.unit, - label: self.label.clone(), - }) - } else { - None - } + pub fn checked_sub(&self, rhs: &Quantity) -> Option<Quantity> { + self.try_sub(rhs).ok() } #[inline] - pub fn mul_decimal(&self, factor: RadrootsCoreDecimal) -> RadrootsCoreQuantity { - RadrootsCoreQuantity { - amount: self.amount * factor, + pub fn checked_mul_decimal(&self, factor: Decimal) -> Result<Quantity, Error> { + let amount = self.amount.checked_mul(factor)?; + if amount.is_sign_negative() { + return Err(Error::NegativeAmount); + } + Ok(Quantity { + amount, unit: self.unit, label: self.label.clone(), - } + }) } #[inline] - pub fn div_decimal(&self, divisor: RadrootsCoreDecimal) -> RadrootsCoreQuantity { - RadrootsCoreQuantity { - amount: self.amount / divisor, + pub fn checked_div_decimal(&self, divisor: Decimal) -> Result<Quantity, Error> { + let amount = self.amount.checked_div(divisor)?; + if amount.is_sign_negative() { + return Err(Error::NegativeAmount); + } + Ok(Quantity { + amount, unit: self.unit, label: self.label.clone(), + }) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Quantity { + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + #[derive(serde::Deserialize)] + struct Wire { + #[serde(with = "crate::serde_ext::decimal_str")] + amount: Decimal, + unit: Unit, + #[serde(default)] + label: Option<String>, } + + let wire = Wire::deserialize(deserializer)?; + Self::try_new(wire.amount, wire.unit) + .map(|quantity| quantity.with_optional_label(wire.label)) + .map_err(serde::de::Error::custom) } } -impl fmt::Display for RadrootsCoreQuantity { +impl fmt::Display for Quantity { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} {}", self.amount.normalize(), self.unit)?; if let Some(label) = &self.label { @@ -193,47 +217,36 @@ impl fmt::Display for RadrootsCoreQuantity { #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCoreQuantityInvariantError { +pub enum Error { NegativeAmount, UnitMismatch, + ArithmeticOverflow, + DivisionByZero, } -impl fmt::Display for RadrootsCoreQuantityInvariantError { +impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - RadrootsCoreQuantityInvariantError::NegativeAmount => { + Error::NegativeAmount => { write!(f, "quantity amount must be ≥ 0") } - RadrootsCoreQuantityInvariantError::UnitMismatch => { + Error::UnitMismatch => { write!(f, "quantity unit mismatch") } + Error::ArithmeticOverflow => write!(f, "quantity arithmetic overflow"), + Error::DivisionByZero => write!(f, "quantity division by zero"), } } } #[cfg(feature = "std")] -impl std::error::Error for RadrootsCoreQuantityInvariantError {} - -use core::ops::{Div, Mul}; - -impl Mul<RadrootsCoreDecimal> for RadrootsCoreQuantity { - type Output = RadrootsCoreQuantity; - fn mul(self, rhs: RadrootsCoreDecimal) -> RadrootsCoreQuantity { - RadrootsCoreQuantity { - amount: self.amount * rhs, - unit: self.unit, - label: self.label, - } - } -} +impl std::error::Error for Error {} -impl Div<RadrootsCoreDecimal> for RadrootsCoreQuantity { - type Output = RadrootsCoreQuantity; - fn div(self, rhs: RadrootsCoreDecimal) -> RadrootsCoreQuantity { - RadrootsCoreQuantity { - amount: self.amount / rhs, - unit: self.unit, - label: self.label, +impl From<crate::decimal::Error> for Error { + fn from(error: crate::decimal::Error) -> Self { + match error { + crate::decimal::Error::DivisionByZero => Self::DivisionByZero, + _ => Self::ArithmeticOverflow, } } } diff --git a/crates/core/src/quantity_price.rs b/crates/core/src/quantity_price.rs @@ -1,194 +1,216 @@ -use crate::{RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreUnit}; +use core::fmt; -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] +use crate::{Decimal, Money, Quantity, Unit}; + +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] +#[cfg_attr(all(test, feature = "std"), dto(export))] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsCoreQuantityPrice { - pub amount: RadrootsCoreMoney, - pub quantity: RadrootsCoreQuantity, +pub struct QuantityPrice { + amount: Money, + quantity: Quantity, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCoreQuantityPriceError { +pub enum Error { PerQuantityZero, - UnitMismatch { - have: RadrootsCoreUnit, - want: RadrootsCoreUnit, - }, - NonConvertibleUnits { - from: RadrootsCoreUnit, - to: RadrootsCoreUnit, - }, + PerQuantityNegative, + NegativePrice, + NegativeRequestedQuantity, + UnitMismatch { have: Unit, want: Unit }, + NonConvertibleUnits { from: Unit, to: Unit }, + ArithmeticOverflow, } -pub trait RadrootsCoreQuantityPriceOps { - #[must_use] - fn cost_for(&self, qty: &RadrootsCoreQuantity) -> RadrootsCoreMoney; +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PerQuantityZero => f.write_str("price quantity must be greater than zero"), + Self::PerQuantityNegative => f.write_str("price quantity must not be negative"), + Self::NegativePrice => f.write_str("price amount must not be negative"), + Self::NegativeRequestedQuantity => { + f.write_str("requested quantity must not be negative") + } + Self::UnitMismatch { have, want } => { + write!(f, "price quantity unit mismatch: have {have}, want {want}") + } + Self::NonConvertibleUnits { from, to } => { + write!(f, "price units are not convertible: {from} -> {to}") + } + Self::ArithmeticOverflow => f.write_str("price arithmetic overflow"), + } + } +} - #[must_use] - fn cost_for_rounded(&self, qty: &RadrootsCoreQuantity) -> RadrootsCoreMoney; +#[cfg(feature = "std")] +impl std::error::Error for Error {} - #[must_use] - fn cost_for_with_quantized_price(&self, qty: &RadrootsCoreQuantity) -> RadrootsCoreMoney; +fn map_conversion_error(error: crate::unit::ConvertError, from: Unit, to: Unit) -> Error { + match error { + crate::unit::ConvertError::ArithmeticOverflow { .. } => Error::ArithmeticOverflow, + _ => Error::NonConvertibleUnits { from, to }, + } +} + +mod sealed { + pub trait Sealed {} - fn try_cost_for( - &self, - qty: &RadrootsCoreQuantity, - ) -> Result<RadrootsCoreMoney, RadrootsCoreQuantityPriceError>; + impl Sealed for super::QuantityPrice {} +} - fn try_cost_for_rounded( - &self, - qty: &RadrootsCoreQuantity, - ) -> Result<RadrootsCoreMoney, RadrootsCoreQuantityPriceError>; +/// Sealed pricing operations implemented by [`QuantityPrice`]. +/// +/// Downstream crates may call this trait but cannot implement it: +/// +/// ```compile_fail +/// use radroots_core::{Money, Quantity}; +/// use radroots_core::pricing::{Error, QuantityPriceOps}; +/// +/// struct ForeignPrice; +/// +/// impl QuantityPriceOps for ForeignPrice { +/// fn try_cost_for(&self, _: &Quantity) -> Result<Money, Error> { panic!() } +/// fn try_cost_for_rounded(&self, _: &Quantity) -> Result<Money, Error> { panic!() } +/// } +/// ``` +pub trait QuantityPriceOps: sealed::Sealed { + /// Calculates the requested cost without silently converting invalid + /// quantities, unit mismatches, or arithmetic failures into zero. + fn try_cost_for(&self, qty: &Quantity) -> Result<Money, Error>; + + /// Calculates first, then rounds the final result to the currency exponent + /// with deterministic midpoint-away-from-zero rounding. + fn try_cost_for_rounded(&self, qty: &Quantity) -> Result<Money, Error>; } -impl RadrootsCoreQuantityPrice { +impl QuantityPrice { + #[inline] + pub fn try_new(amount: Money, quantity: Quantity) -> Result<Self, Error> { + amount + .ensure_non_negative() + .map_err(|_| Error::NegativePrice)?; + if quantity.amount().is_sign_negative() { + return Err(Error::PerQuantityNegative); + } + if quantity.amount().is_zero() { + return Err(Error::PerQuantityZero); + } + Ok(Self { amount, quantity }) + } + + #[inline] + pub fn amount(&self) -> &Money { + &self.amount + } + + #[inline] + pub fn quantity(&self) -> &Quantity { + &self.quantity + } + #[inline] - pub fn new(amount: RadrootsCoreMoney, quantity: RadrootsCoreQuantity) -> Self { - Self { amount, quantity } + pub fn validate(&self) -> Result<(), Error> { + Self::try_new(self.amount.clone(), self.quantity.clone()).map(|_| ()) } #[inline] - pub fn try_cost_for_amount_in( - &self, - amount: RadrootsCoreDecimal, - unit: RadrootsCoreUnit, - ) -> Result<RadrootsCoreMoney, RadrootsCoreQuantityPriceError> { + pub fn try_cost_for_amount_in(&self, amount: Decimal, unit: Unit) -> Result<Money, Error> { use crate::unit::convert_unit_decimal; - let target = self.quantity.unit; + let target = self.quantity.unit(); let normalized = if unit == target { amount } else { - convert_unit_decimal(amount, unit, target).map_err(|_| { - RadrootsCoreQuantityPriceError::NonConvertibleUnits { - from: unit, - to: target, - } - })? + convert_unit_decimal(amount, unit, target) + .map_err(|error| map_conversion_error(error, unit, target))? }; - let qty = RadrootsCoreQuantity::new(normalized, target); + let qty = + Quantity::try_new(normalized, target).map_err(|_| Error::NegativeRequestedQuantity)?; self.try_cost_for_rounded(&qty) } #[inline] - pub fn try_cost_for_quantity_in( - &self, - qty: &RadrootsCoreQuantity, - ) -> Result<RadrootsCoreMoney, RadrootsCoreQuantityPriceError> { - self.try_cost_for_amount_in(qty.amount, qty.unit) + pub fn try_cost_for_quantity_in(&self, qty: &Quantity) -> Result<Money, Error> { + self.try_cost_for_amount_in(qty.amount(), qty.unit()) } #[inline] pub fn is_price_per_canonical_unit(&self) -> bool { - self.quantity.unit == self.quantity.unit.canonical_unit() - && self.quantity.amount == RadrootsCoreDecimal::ONE + self.quantity.unit() == self.quantity.unit().canonical_unit() + && self.quantity.amount() == Decimal::ONE } #[inline] - pub fn try_to_unit_price( - &self, - unit: RadrootsCoreUnit, - ) -> Result<RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError> { + pub fn try_to_unit_price(&self, unit: Unit) -> Result<QuantityPrice, Error> { use crate::unit::convert_unit_decimal; - if self.quantity.amount.is_zero() { - return Err(RadrootsCoreQuantityPriceError::PerQuantityZero); - } + self.validate()?; - let normalized = if self.quantity.unit == unit { - self.quantity.amount + let normalized = if self.quantity.unit() == unit { + self.quantity.amount() } else { - convert_unit_decimal(self.quantity.amount, self.quantity.unit, unit).map_err(|_| { - RadrootsCoreQuantityPriceError::NonConvertibleUnits { - from: self.quantity.unit, - to: unit, - } - })? + convert_unit_decimal(self.quantity.amount(), self.quantity.unit(), unit) + .map_err(|error| map_conversion_error(error, self.quantity.unit(), unit))? }; if normalized.is_zero() { - return Err(RadrootsCoreQuantityPriceError::PerQuantityZero); + return Err(Error::PerQuantityZero); } - let amount = self.amount.div_decimal(normalized); - Ok(RadrootsCoreQuantityPrice { - amount, - quantity: RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, unit), - }) + let amount = self + .amount + .checked_div_decimal(normalized) + .map_err(|_| Error::ArithmeticOverflow)?; + let quantity = + Quantity::try_new(Decimal::ONE, unit).map_err(|_| Error::ArithmeticOverflow)?; + Self::try_new(amount, quantity) } #[inline] - pub fn try_to_canonical_unit_price( - &self, - ) -> Result<RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError> { - self.try_to_unit_price(self.quantity.unit.canonical_unit()) + pub fn try_to_canonical_unit_price(&self) -> Result<QuantityPrice, Error> { + self.try_to_unit_price(self.quantity.unit().canonical_unit()) } } -impl RadrootsCoreQuantityPriceOps for RadrootsCoreQuantityPrice { - #[inline] - fn cost_for(&self, qty: &RadrootsCoreQuantity) -> RadrootsCoreMoney { - if qty.amount.is_zero() { - return RadrootsCoreMoney::zero(self.amount.currency); - } - if self.quantity.amount.is_zero() { - return RadrootsCoreMoney::zero(self.amount.currency); +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for QuantityPrice { + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + #[derive(serde::Deserialize)] + struct Wire { + amount: Money, + quantity: Quantity, } - if qty.unit != self.quantity.unit { - return RadrootsCoreMoney::zero(self.amount.currency); - } - - let ratio = qty.amount / self.quantity.amount; - self.amount.mul_decimal(ratio) - } - #[inline] - fn cost_for_rounded(&self, qty: &RadrootsCoreQuantity) -> RadrootsCoreMoney { - self.cost_for(qty).quantize_to_currency() - } - - #[inline] - fn cost_for_with_quantized_price(&self, qty: &RadrootsCoreQuantity) -> RadrootsCoreMoney { - if qty.amount.is_zero() { - return RadrootsCoreMoney::zero(self.amount.currency); - } - if self.quantity.amount.is_zero() { - return RadrootsCoreMoney::zero(self.amount.currency); - } - if qty.unit != self.quantity.unit { - return RadrootsCoreMoney::zero(self.amount.currency); - } - let unit_price_q = self.amount.clone().quantize_to_currency(); - unit_price_q.mul_decimal(qty.amount / self.quantity.amount) + let wire = Wire::deserialize(deserializer)?; + Self::try_new(wire.amount, wire.quantity).map_err(serde::de::Error::custom) } +} +impl QuantityPriceOps for QuantityPrice { #[inline] - fn try_cost_for( - &self, - qty: &RadrootsCoreQuantity, - ) -> Result<RadrootsCoreMoney, RadrootsCoreQuantityPriceError> { - if self.quantity.amount.is_zero() { - return Err(RadrootsCoreQuantityPriceError::PerQuantityZero); - } - if qty.unit != self.quantity.unit { - return Err(RadrootsCoreQuantityPriceError::UnitMismatch { - have: qty.unit, - want: self.quantity.unit, + fn try_cost_for(&self, qty: &Quantity) -> Result<Money, Error> { + self.validate()?; + qty.ensure_non_negative() + .map_err(|_| Error::NegativeRequestedQuantity)?; + if qty.unit() != self.quantity.unit() { + return Err(Error::UnitMismatch { + have: qty.unit(), + want: self.quantity.unit(), }); } - let ratio = qty.amount / self.quantity.amount; - Ok(self.amount.mul_decimal(ratio)) + let ratio = qty + .amount() + .checked_div(self.quantity.amount()) + .map_err(|_| Error::ArithmeticOverflow)?; + self.amount + .checked_mul_decimal(ratio) + .map_err(|_| Error::ArithmeticOverflow) } #[inline] - fn try_cost_for_rounded( - &self, - qty: &RadrootsCoreQuantity, - ) -> Result<RadrootsCoreMoney, RadrootsCoreQuantityPriceError> { + fn try_cost_for_rounded(&self, qty: &Quantity) -> Result<Money, Error> { Ok(self.try_cost_for(qty)?.quantize_to_currency()) } } diff --git a/crates/core/src/serde_ext.rs b/crates/core/src/serde_ext.rs @@ -7,20 +7,15 @@ use serde::{Deserialize, Deserializer, Serializer, de::Error as DeError}; pub mod decimal_str { use super::*; - use crate::RadrootsCoreDecimal; + use crate::Decimal; use core::str::FromStr; - pub fn serialize<S: Serializer>( - value: &RadrootsCoreDecimal, - serializer: S, - ) -> Result<S::Ok, S::Error> { + pub fn serialize<S: Serializer>(value: &Decimal, serializer: S) -> Result<S::Ok, S::Error> { serializer.serialize_str(&value.normalize().to_string()) } - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result<RadrootsCoreDecimal, D::Error> { + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Decimal, D::Error> { let s = String::deserialize(deserializer)?; - RadrootsCoreDecimal::from_str(&s).map_err(D::Error::custom) + Decimal::from_str(&s).map_err(D::Error::custom) } } diff --git a/crates/core/src/unit.rs b/crates/core/src/unit.rs @@ -1,6 +1,7 @@ +//! Unit codes, dimensions, parsing, and checked deterministic conversion. + use core::fmt; use core::str::FromStr; -use rust_decimal_macros::dec; #[cfg(all(feature = "serde", not(feature = "std")))] use alloc::string::String; @@ -11,43 +12,46 @@ use std::string::String; #[cfg(feature = "serde")] use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as DeError}; -use crate::RadrootsCoreDecimal; +use crate::Decimal; + +const GRAMS_PER_OUNCE: Decimal = Decimal::from_parts(2_579_719_349, 6, 0, 9); +const GRAMS_PER_POUND: Decimal = Decimal::from_parts(45_359_237, 0, 0, 5); -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[cfg_attr(feature = "dto-bindgen", dto(as = "string_enum"))] +#[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] +#[cfg_attr(all(test, feature = "std"), dto(export))] +#[cfg_attr(all(test, feature = "std"), dto(as = "string_enum"))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum RadrootsCoreUnitDimension { - #[cfg_attr(feature = "dto-bindgen", dto(rename = "count"))] +pub enum UnitDimension { + #[cfg_attr(all(test, feature = "std"), dto(rename = "count"))] Count, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "mass"))] + #[cfg_attr(all(test, feature = "std"), dto(rename = "mass"))] Mass, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "volume"))] + #[cfg_attr(all(test, feature = "std"), dto(rename = "volume"))] Volume, } -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[cfg_attr(feature = "dto-bindgen", dto(as = "string_enum"))] +#[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] +#[cfg_attr(all(test, feature = "std"), dto(export))] +#[cfg_attr(all(test, feature = "std"), dto(as = "string_enum"))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum RadrootsCoreUnit { - #[cfg_attr(feature = "dto-bindgen", dto(rename = "each"))] +pub enum Unit { + #[cfg_attr(all(test, feature = "std"), dto(rename = "each"))] Each, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "kg"))] + #[cfg_attr(all(test, feature = "std"), dto(rename = "kg"))] MassKg, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "g"))] + #[cfg_attr(all(test, feature = "std"), dto(rename = "g"))] MassG, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "oz"))] + #[cfg_attr(all(test, feature = "std"), dto(rename = "oz"))] MassOz, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "lb"))] + #[cfg_attr(all(test, feature = "std"), dto(rename = "lb"))] MassLb, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "l"))] + #[cfg_attr(all(test, feature = "std"), dto(rename = "l"))] VolumeL, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "ml"))] + #[cfg_attr(all(test, feature = "std"), dto(rename = "ml"))] VolumeMl, } -impl RadrootsCoreUnit { +impl Unit { #[inline] pub fn code(&self) -> &'static str { match self { @@ -66,22 +70,20 @@ impl RadrootsCoreUnit { } #[inline] - pub fn dimension(&self) -> RadrootsCoreUnitDimension { + pub fn dimension(&self) -> UnitDimension { match self { - Self::Each => RadrootsCoreUnitDimension::Count, - Self::MassKg | Self::MassG | Self::MassOz | Self::MassLb => { - RadrootsCoreUnitDimension::Mass - } - Self::VolumeL | Self::VolumeMl => RadrootsCoreUnitDimension::Volume, + Self::Each => UnitDimension::Count, + Self::MassKg | Self::MassG | Self::MassOz | Self::MassLb => UnitDimension::Mass, + Self::VolumeL | Self::VolumeMl => UnitDimension::Volume, } } #[inline] pub fn canonical_unit(&self) -> Self { match self.dimension() { - RadrootsCoreUnitDimension::Count => Self::Each, - RadrootsCoreUnitDimension::Mass => Self::MassG, - RadrootsCoreUnitDimension::Volume => Self::VolumeMl, + UnitDimension::Count => Self::Each, + UnitDimension::Mass => Self::MassG, + UnitDimension::Volume => Self::VolumeMl, } } @@ -104,20 +106,20 @@ impl RadrootsCoreUnit { } } -impl fmt::Display for RadrootsCoreUnit { +impl fmt::Display for Unit { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.code()) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCoreUnitParseError { +pub enum ParseError { UnknownUnit, NotAMassUnit, NotAVolumeUnit, } -impl fmt::Display for RadrootsCoreUnitParseError { +impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::UnknownUnit => write!(f, "unknown unit string"), @@ -128,75 +130,70 @@ impl fmt::Display for RadrootsCoreUnitParseError { } #[cfg(feature = "std")] -impl std::error::Error for RadrootsCoreUnitParseError {} +impl std::error::Error for ParseError {} #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCoreUnitConvertError { - NotMassUnit { - from: RadrootsCoreUnit, - to: RadrootsCoreUnit, - }, - NotVolumeUnit { - from: RadrootsCoreUnit, - to: RadrootsCoreUnit, - }, - NotConvertibleUnits { - from: RadrootsCoreUnit, - to: RadrootsCoreUnit, - }, +pub enum ConvertError { + NotMassUnit { from: Unit, to: Unit }, + NotVolumeUnit { from: Unit, to: Unit }, + NotConvertibleUnits { from: Unit, to: Unit }, + ArithmeticOverflow { from: Unit, to: Unit }, } -impl fmt::Display for RadrootsCoreUnitConvertError { +impl fmt::Display for ConvertError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - RadrootsCoreUnitConvertError::NotMassUnit { from, to } => { + ConvertError::NotMassUnit { from, to } => { write!(f, "unit conversion requires mass units: {from} -> {to}") } - RadrootsCoreUnitConvertError::NotVolumeUnit { from, to } => { + ConvertError::NotVolumeUnit { from, to } => { write!(f, "unit conversion requires volume units: {from} -> {to}") } - RadrootsCoreUnitConvertError::NotConvertibleUnits { from, to } => { + ConvertError::NotConvertibleUnits { from, to } => { write!( f, "unit conversion requires matching dimensions: {from} -> {to}" ) } + ConvertError::ArithmeticOverflow { from, to } => { + write!(f, "unit conversion arithmetic overflow: {from} -> {to}") + } } } } #[cfg(feature = "std")] -impl std::error::Error for RadrootsCoreUnitConvertError {} +impl std::error::Error for ConvertError {} -impl FromStr for RadrootsCoreUnit { - type Err = RadrootsCoreUnitParseError; +impl FromStr for Unit { + type Err = ParseError; fn from_str(s: &str) -> Result<Self, Self::Err> { let s = s.trim().to_ascii_lowercase(); match s.as_str() { - "each" | "ea" | "count" => Ok(RadrootsCoreUnit::Each), - "kg" | "kilogram" | "kilograms" => Ok(RadrootsCoreUnit::MassKg), - "g" | "gram" | "grams" => Ok(RadrootsCoreUnit::MassG), - "oz" | "ounce" | "ounces" => Ok(RadrootsCoreUnit::MassOz), - "lb" | "pound" | "pounds" => Ok(RadrootsCoreUnit::MassLb), - "l" | "liter" | "litre" | "liters" | "litres" => Ok(RadrootsCoreUnit::VolumeL), + "each" | "ea" | "count" => Ok(Unit::Each), + "kg" | "kilogram" | "kilograms" => Ok(Unit::MassKg), + "g" | "gram" | "grams" => Ok(Unit::MassG), + "oz" | "ounce" | "ounces" => Ok(Unit::MassOz), + "lb" | "pound" | "pounds" => Ok(Unit::MassLb), + "l" | "liter" | "litre" | "liters" | "litres" => Ok(Unit::VolumeL), "ml" | "milliliter" | "millilitre" | "milliliters" | "millilitres" => { - Ok(RadrootsCoreUnit::VolumeMl) + Ok(Unit::VolumeMl) } - _ => Err(RadrootsCoreUnitParseError::UnknownUnit), + _ => Err(ParseError::UnknownUnit), } } } #[cfg(feature = "serde")] -impl Serialize for RadrootsCoreUnit { +impl Serialize for Unit { fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> { ser.serialize_str(self.code()) } } #[cfg(feature = "serde")] -impl<'de> Deserialize<'de> for RadrootsCoreUnit { +impl<'de> Deserialize<'de> for Unit { fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> { let s = String::deserialize(de)?; s.parse().map_err(D::Error::custom) @@ -204,92 +201,114 @@ impl<'de> Deserialize<'de> for RadrootsCoreUnit { } #[inline] -pub fn parse_mass_unit(s: &str) -> Result<RadrootsCoreUnit, RadrootsCoreUnitParseError> { - let u: RadrootsCoreUnit = RadrootsCoreUnit::from_str(s)?; +pub fn parse_mass_unit(s: &str) -> Result<Unit, ParseError> { + let u: Unit = Unit::from_str(s)?; if u.is_mass() { Ok(u) } else { - Err(RadrootsCoreUnitParseError::NotAMassUnit) + Err(ParseError::NotAMassUnit) } } #[inline] -pub fn parse_volume_unit(s: &str) -> Result<RadrootsCoreUnit, RadrootsCoreUnitParseError> { - let u: RadrootsCoreUnit = RadrootsCoreUnit::from_str(s)?; +pub fn parse_volume_unit(s: &str) -> Result<Unit, ParseError> { + let u: Unit = Unit::from_str(s)?; if u.is_volume() { Ok(u) } else { - Err(RadrootsCoreUnitParseError::NotAVolumeUnit) + Err(ParseError::NotAVolumeUnit) } } #[inline] +/// Converts mass using exact decimal factors expressed in grams. +/// +/// The pound and ounce factors are exact definitions. Arithmetic is checked; +/// division uses the decimal backend's deterministic precision and performs no +/// additional application-level rounding. pub fn convert_mass_decimal( - amount: RadrootsCoreDecimal, - from: RadrootsCoreUnit, - to: RadrootsCoreUnit, -) -> Result<RadrootsCoreDecimal, RadrootsCoreUnitConvertError> { + amount: Decimal, + from: Unit, + to: Unit, +) -> Result<Decimal, ConvertError> { + let arithmetic_error = || ConvertError::ArithmeticOverflow { from, to }; let amount_g = match from { - RadrootsCoreUnit::MassG => amount, - RadrootsCoreUnit::MassKg => amount * RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassOz => amount * RadrootsCoreDecimal(dec!(28.349523125)), - RadrootsCoreUnit::MassLb => amount * RadrootsCoreDecimal(dec!(453.59237)), + Unit::MassG => amount, + Unit::MassKg => amount + .checked_mul(Decimal::from(1000u32)) + .map_err(|_| arithmetic_error())?, + Unit::MassOz => amount + .checked_mul(GRAMS_PER_OUNCE) + .map_err(|_| arithmetic_error())?, + Unit::MassLb => amount + .checked_mul(GRAMS_PER_POUND) + .map_err(|_| arithmetic_error())?, _ => { - return Err(RadrootsCoreUnitConvertError::NotMassUnit { from, to }); + return Err(ConvertError::NotMassUnit { from, to }); } }; let to_factor = match to { - RadrootsCoreUnit::MassG => RadrootsCoreDecimal::ONE, - RadrootsCoreUnit::MassKg => RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassOz => RadrootsCoreDecimal(dec!(28.349523125)), - RadrootsCoreUnit::MassLb => RadrootsCoreDecimal(dec!(453.59237)), + Unit::MassG => Decimal::ONE, + Unit::MassKg => Decimal::from(1000u32), + Unit::MassOz => GRAMS_PER_OUNCE, + Unit::MassLb => GRAMS_PER_POUND, _ => { - return Err(RadrootsCoreUnitConvertError::NotMassUnit { from, to }); + return Err(ConvertError::NotMassUnit { from, to }); } }; - Ok(amount_g / to_factor) + amount_g + .checked_div(to_factor) + .map_err(|_| arithmetic_error()) } #[inline] +/// Converts volume using the exact relation `1 L = 1000 mL`. +/// +/// Arithmetic is checked and no application-level rounding is applied. pub fn convert_volume_decimal( - amount: RadrootsCoreDecimal, - from: RadrootsCoreUnit, - to: RadrootsCoreUnit, -) -> Result<RadrootsCoreDecimal, RadrootsCoreUnitConvertError> { + amount: Decimal, + from: Unit, + to: Unit, +) -> Result<Decimal, ConvertError> { + let arithmetic_error = || ConvertError::ArithmeticOverflow { from, to }; let amount_ml = match from { - RadrootsCoreUnit::VolumeMl => amount, - RadrootsCoreUnit::VolumeL => amount * RadrootsCoreDecimal::from(1000u32), + Unit::VolumeMl => amount, + Unit::VolumeL => amount + .checked_mul(Decimal::from(1000u32)) + .map_err(|_| arithmetic_error())?, _ => { - return Err(RadrootsCoreUnitConvertError::NotVolumeUnit { from, to }); + return Err(ConvertError::NotVolumeUnit { from, to }); } }; let to_factor = match to { - RadrootsCoreUnit::VolumeMl => RadrootsCoreDecimal::ONE, - RadrootsCoreUnit::VolumeL => RadrootsCoreDecimal::from(1000u32), + Unit::VolumeMl => Decimal::ONE, + Unit::VolumeL => Decimal::from(1000u32), _ => { - return Err(RadrootsCoreUnitConvertError::NotVolumeUnit { from, to }); + return Err(ConvertError::NotVolumeUnit { from, to }); } }; - Ok(amount_ml / to_factor) + amount_ml + .checked_div(to_factor) + .map_err(|_| arithmetic_error()) } #[inline] pub fn convert_unit_decimal( - amount: RadrootsCoreDecimal, - from: RadrootsCoreUnit, - to: RadrootsCoreUnit, -) -> Result<RadrootsCoreDecimal, RadrootsCoreUnitConvertError> { - if !RadrootsCoreUnit::same_dimension(from, to) { - return Err(RadrootsCoreUnitConvertError::NotConvertibleUnits { from, to }); + amount: Decimal, + from: Unit, + to: Unit, +) -> Result<Decimal, ConvertError> { + if !Unit::same_dimension(from, to) { + return Err(ConvertError::NotConvertibleUnits { from, to }); } match from.dimension() { - RadrootsCoreUnitDimension::Count => Ok(amount), - RadrootsCoreUnitDimension::Mass => convert_mass_decimal(amount, from, to), - RadrootsCoreUnitDimension::Volume => convert_volume_decimal(amount, from, to), + UnitDimension::Count => Ok(amount), + UnitDimension::Mass => convert_mass_decimal(amount, from, to), + UnitDimension::Volume => convert_volume_decimal(amount, from, to), } } @@ -300,25 +319,17 @@ mod tests { #[test] fn convert_paths_cover_unit_branches() { assert_eq!( - convert_mass_decimal( - RadrootsCoreDecimal::ONE, - RadrootsCoreUnit::Each, - RadrootsCoreUnit::MassG - ), - Err(RadrootsCoreUnitConvertError::NotMassUnit { - from: RadrootsCoreUnit::Each, - to: RadrootsCoreUnit::MassG + convert_mass_decimal(Decimal::ONE, Unit::Each, Unit::MassG), + Err(ConvertError::NotMassUnit { + from: Unit::Each, + to: Unit::MassG }) ); assert_eq!( - convert_volume_decimal( - RadrootsCoreDecimal::ONE, - RadrootsCoreUnit::Each, - RadrootsCoreUnit::VolumeMl - ), - Err(RadrootsCoreUnitConvertError::NotVolumeUnit { - from: RadrootsCoreUnit::Each, - to: RadrootsCoreUnit::VolumeMl + convert_volume_decimal(Decimal::ONE, Unit::Each, Unit::VolumeMl), + Err(ConvertError::NotVolumeUnit { + from: Unit::Each, + to: Unit::VolumeMl }) ); } diff --git a/crates/core/tests/common/mod.rs b/crates/core/tests/common/mod.rs @@ -2,27 +2,24 @@ use core::str::FromStr; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCorePercent, - RadrootsCoreQuantity, RadrootsCoreUnit, -}; +use radroots_core::{Currency, Decimal, Money, Percent, Quantity, Unit}; -pub fn dec(s: &str) -> RadrootsCoreDecimal { - RadrootsCoreDecimal::from_str(s).expect("valid decimal") +pub fn dec(s: &str) -> Decimal { + Decimal::from_str(s).expect("valid decimal") } -pub fn currency(code: &str) -> RadrootsCoreCurrency { - RadrootsCoreCurrency::from_str(code).expect("valid currency") +pub fn currency(code: &str) -> Currency { + Currency::from_str(code).expect("valid currency") } -pub fn money(amount: &str, code: &str) -> RadrootsCoreMoney { - RadrootsCoreMoney::new(dec(amount), currency(code)) +pub fn money(amount: &str, code: &str) -> Money { + Money::try_new(dec(amount), currency(code)).expect("valid money") } -pub fn qty(amount: &str, unit: RadrootsCoreUnit) -> RadrootsCoreQuantity { - RadrootsCoreQuantity::new(dec(amount), unit) +pub fn qty(amount: &str, unit: Unit) -> Quantity { + Quantity::try_new(dec(amount), unit).expect("valid quantity") } -pub fn percent(s: &str) -> RadrootsCorePercent { - RadrootsCorePercent::from_str(s).expect("valid percent") +pub fn percent(s: &str) -> Percent { + Percent::from_str(s).expect("valid percent") } diff --git a/crates/core/tests/currency.rs b/crates/core/tests/currency.rs @@ -1,69 +1,63 @@ use core::str::FromStr; -use radroots_core::{RadrootsCoreCurrency, RadrootsCoreCurrencyParseError}; +use radroots_core::{Currency, currency::ParseError}; #[test] fn from_str_upper_accepts_valid() { - let usd = RadrootsCoreCurrency::from_str_upper("USD").unwrap(); + let usd = Currency::from_str_upper("USD").unwrap(); assert_eq!(usd.as_str(), "USD"); } #[test] fn from_str_upper_rejects_invalid() { assert_eq!( - RadrootsCoreCurrency::from_str_upper("Usd"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Currency::from_str_upper("Usd"), + Err(ParseError::InvalidFormat) ); assert_eq!( - RadrootsCoreCurrency::from_str_upper("US"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Currency::from_str_upper("US"), + Err(ParseError::InvalidFormat) ); } #[test] fn from_str_trims_and_uppercases() { - let usd = RadrootsCoreCurrency::from_str(" usd ").unwrap(); + let usd = Currency::from_str(" usd ").unwrap(); assert_eq!(usd.as_str(), "USD"); } #[test] fn from_str_rejects_non_alpha() { - assert_eq!( - RadrootsCoreCurrency::from_str("US1"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) - ); - assert_eq!( - RadrootsCoreCurrency::from_str("US"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) - ); + assert_eq!(Currency::from_str("US1"), Err(ParseError::InvalidFormat)); + assert_eq!(Currency::from_str("US"), Err(ParseError::InvalidFormat)); } #[test] fn from_const_validates_bytes() { - assert!(RadrootsCoreCurrency::from_const(*b"USD").is_ok()); + assert!(Currency::from_const(*b"USD").is_ok()); assert_eq!( - RadrootsCoreCurrency::from_const(*b"1SD"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Currency::from_const(*b"1SD"), + Err(ParseError::InvalidFormat) ); assert_eq!( - RadrootsCoreCurrency::from_const(*b"Usd"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Currency::from_const(*b"Usd"), + Err(ParseError::InvalidFormat) ); assert_eq!( - RadrootsCoreCurrency::from_const(*b"USd"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Currency::from_const(*b"USd"), + Err(ParseError::InvalidFormat) ); assert_eq!( - RadrootsCoreCurrency::from_const(*b"usd"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Currency::from_const(*b"usd"), + Err(ParseError::InvalidFormat) ); } #[test] fn minor_unit_exponent_matches_known_currencies() { - let jpy = RadrootsCoreCurrency::from_str_upper("JPY").unwrap(); - let kwd = RadrootsCoreCurrency::from_str_upper("KWD").unwrap(); - let usd = RadrootsCoreCurrency::from_str_upper("USD").unwrap(); + let jpy = Currency::from_str_upper("JPY").unwrap(); + let kwd = Currency::from_str_upper("KWD").unwrap(); + let usd = Currency::from_str_upper("USD").unwrap(); assert_eq!(jpy.minor_unit_exponent(), 0); assert_eq!(kwd.minor_unit_exponent(), 3); @@ -72,13 +66,13 @@ fn minor_unit_exponent_matches_known_currencies() { #[test] fn display_debug_tryfrom_and_error_display_paths_are_exercised() { - let usd = RadrootsCoreCurrency::from_str("usd").unwrap(); + let usd = Currency::from_str("usd").unwrap(); assert_eq!(usd.to_string(), "USD"); - assert_eq!(format!("{usd:?}"), "RadrootsCoreCurrency(\"USD\")"); - let via_try_from = RadrootsCoreCurrency::try_from("usd").unwrap(); + assert_eq!(format!("{usd:?}"), "Currency(\"USD\")"); + let via_try_from = Currency::try_from("usd").unwrap(); assert_eq!(via_try_from, usd); assert_eq!( - RadrootsCoreCurrencyParseError::InvalidFormat.to_string(), + ParseError::InvalidFormat.to_string(), "currency must be a 3-letter code" ); } @@ -86,10 +80,10 @@ fn display_debug_tryfrom_and_error_display_paths_are_exercised() { #[cfg(feature = "serde")] #[test] fn serde_deserialize_paths_are_exercised() { - let parsed: RadrootsCoreCurrency = serde_json::from_str("\"USD\"").unwrap(); + let parsed: Currency = serde_json::from_str("\"USD\"").unwrap(); assert_eq!(parsed.as_str(), "USD"); - let err = serde_json::from_str::<RadrootsCoreCurrency>("\"US1\"").unwrap_err(); + let err = serde_json::from_str::<Currency>("\"US1\"").unwrap_err(); assert!(err.to_string().contains("currency must be a 3-letter code")); - let non_string_err = serde_json::from_str::<RadrootsCoreCurrency>("123").unwrap_err(); + let non_string_err = serde_json::from_str::<Currency>("123").unwrap_err(); assert!(non_string_err.to_string().contains("invalid type")); } diff --git a/crates/core/tests/decimal.rs b/crates/core/tests/decimal.rs @@ -2,17 +2,17 @@ mod common; use core::str::FromStr; -use radroots_core::RadrootsCoreDecimal; +use radroots_core::{Decimal, decimal::Error}; #[test] fn display_normalizes_trailing_zeros() { - let d = RadrootsCoreDecimal::from_str("1.2300").unwrap(); + let d = Decimal::from_str("1.2300").unwrap(); assert_eq!(d.to_string(), "1.23"); } #[test] fn scale_reflects_input_precision() { - let d = RadrootsCoreDecimal::from_str("1.2300").unwrap(); + let d = Decimal::from_str("1.2300").unwrap(); assert_eq!(d.scale(), 4); } @@ -26,25 +26,93 @@ fn to_u64_exact_requires_whole_number() { #[test] fn from_f64_display_roundtrips_reasonably() { - let d = RadrootsCoreDecimal::from_f64_display(1.25).unwrap(); + let d = Decimal::from_f64_display(1.25).unwrap(); let v = d.to_f64_lossy().expect("f64 conversion"); assert!((v - 1.25).abs() < 1e-12); } #[test] -fn from_str_exact_and_conversion_impl_paths_are_exercised() { - let exact = RadrootsCoreDecimal::from_str_exact("42.000").unwrap(); - assert_eq!(exact, common::dec("42")); +fn parsing_normalizes_zero_and_owns_parse_errors() { + let zero = Decimal::from_str("-0.000").unwrap(); + assert_eq!(zero, Decimal::ZERO); + assert_eq!(zero.scale(), 0); + assert_eq!( + Decimal::from_str("not-a-decimal"), + Err(Error::InvalidFormat) + ); + assert_eq!( + Decimal::from_str_exact("0.00000000000000000000000000001"), + Err(Error::OutOfRange) + ); +} + +#[test] +fn checked_arithmetic_reports_overflow_and_zero_division() { + assert_eq!( + Decimal::MAX.checked_add(Decimal::ONE), + Err(Error::ArithmeticOverflow) + ); + assert_eq!( + Decimal::MIN.checked_sub(Decimal::ONE), + Err(Error::ArithmeticOverflow) + ); + assert_eq!( + Decimal::MAX.checked_mul(Decimal::from(2u32)), + Err(Error::ArithmeticOverflow) + ); + assert_eq!( + Decimal::ONE.checked_div(Decimal::ZERO), + Err(Error::DivisionByZero) + ); +} + +#[test] +fn exact_rescale_rejects_rounding_and_unrepresentable_scales() { + let mut exact = Decimal::from_str("1.2300").unwrap(); + exact.try_rescale_exact(2).unwrap(); + assert_eq!(exact.scale(), 2); + assert_eq!(exact, common::dec("1.23")); + + let mut would_round = common::dec("1.25"); + assert_eq!(would_round.try_rescale_exact(1), Err(Error::PrecisionLoss)); + assert_eq!(would_round, common::dec("1.25")); + + assert_eq!( + would_round.try_rescale_exact(Decimal::MAX_SCALE + 1), + Err(Error::ScaleOutOfRange) + ); +} - let from_decimal = RadrootsCoreDecimal::from(rust_decimal::Decimal::from(5u32)); - assert_eq!(from_decimal, common::dec("5")); - let back: rust_decimal::Decimal = from_decimal.into(); - assert_eq!(back, rust_decimal::Decimal::from(5u32)); +#[test] +fn display_parse_roundtrips_normalized_values() { + for input in ["0", "-42.1250", "999999.000001", "0.00000001"] { + let value = Decimal::from_str(input).unwrap(); + let reparsed = Decimal::from_str(&value.to_string()).unwrap(); + assert_eq!(reparsed, value); + } +} + +#[test] +fn float_conversion_rejects_non_finite_values() { + assert_eq!( + Decimal::from_f64_display(f64::NAN), + Err(Error::InvalidFormat) + ); + assert_eq!( + Decimal::from_f64_display(f64::INFINITY), + Err(Error::InvalidFormat) + ); +} + +#[test] +fn from_str_exact_and_primitive_conversion_paths_are_exercised() { + let exact = Decimal::from_str_exact("42.000").unwrap(); + assert_eq!(exact, common::dec("42")); - let from_u32 = RadrootsCoreDecimal::from(7u32); - let from_i32 = RadrootsCoreDecimal::from(-2i32); - let from_u64 = RadrootsCoreDecimal::from(11u64); - let from_i64 = RadrootsCoreDecimal::from(-9i64); + let from_u32 = Decimal::from(7u32); + let from_i32 = Decimal::from(-2i32); + let from_u64 = Decimal::from(11u64); + let from_i64 = Decimal::from(-9i64); assert_eq!(from_u32, common::dec("7")); assert_eq!(from_i32, common::dec("-2")); assert_eq!(from_u64, common::dec("11")); @@ -54,8 +122,8 @@ fn from_str_exact_and_conversion_impl_paths_are_exercised() { #[cfg(feature = "serde")] #[test] fn serde_deserialize_error_paths_are_exercised() { - let parse_err = serde_json::from_str::<RadrootsCoreDecimal>("\"not-a-decimal\"").unwrap_err(); + let parse_err = serde_json::from_str::<Decimal>("\"not-a-decimal\"").unwrap_err(); assert!(!parse_err.to_string().is_empty()); - let non_string_err = serde_json::from_str::<RadrootsCoreDecimal>("123").unwrap_err(); + let non_string_err = serde_json::from_str::<Decimal>("123").unwrap_err(); assert!(non_string_err.to_string().contains("invalid type")); } diff --git a/crates/core/tests/discount.rs b/crates/core/tests/discount.rs @@ -1,59 +1,52 @@ mod common; use radroots_core::{ - RadrootsCoreDiscount, RadrootsCoreDiscountScope, RadrootsCoreDiscountThreshold, - RadrootsCoreDiscountValue, RadrootsCorePercent, RadrootsCoreUnit, + Percent, + pricing::{Discount, DiscountError, DiscountScope, DiscountThreshold, DiscountValue}, }; #[test] -fn is_non_negative_validates_discount_shapes() { - let money_pos = common::money("1", "USD"); - let money_neg = common::money("-1", "USD"); - let qty_pos = common::qty("1", RadrootsCoreUnit::Each); - let qty_neg = common::qty("-1", RadrootsCoreUnit::Each); - let pct_pos = RadrootsCorePercent::new(common::dec("10")); - let pct_neg = RadrootsCorePercent::new(common::dec("-5")); - - let d = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::Bin, - threshold: RadrootsCoreDiscountThreshold::BinCount { +fn checked_constructor_rejects_negative_percent_values() { + let negative_value = Discount::try_new( + DiscountScope::Bin, + DiscountThreshold::BinCount { bin_id: "bin-1".to_string(), - min: 2, + min: 1, }, - value: RadrootsCoreDiscountValue::MoneyPerBin(money_pos.clone()), - }; - assert!(d.is_non_negative()); + DiscountValue::Percent(Percent::new(common::dec("-0.01"))), + ); + assert_eq!(negative_value, Err(DiscountError::NegativeValue)); +} - let d = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::Bin, - threshold: RadrootsCoreDiscountThreshold::BinCount { +#[test] +fn checked_constructor_and_accessors_preserve_valid_shape() { + let discount = Discount::try_new( + DiscountScope::Bin, + DiscountThreshold::BinCount { bin_id: "bin-1".to_string(), min: 2, }, - value: RadrootsCoreDiscountValue::MoneyPerBin(money_neg), - }; - assert!(!d.is_non_negative()); - - let d = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::OrderTotal, - threshold: RadrootsCoreDiscountThreshold::OrderQuantity { - min: qty_pos.clone(), - }, - value: RadrootsCoreDiscountValue::Percent(pct_pos.clone()), - }; - assert!(d.is_non_negative()); - - let d = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::OrderTotal, - threshold: RadrootsCoreDiscountThreshold::OrderQuantity { min: qty_neg }, - value: RadrootsCoreDiscountValue::Percent(pct_pos), - }; - assert!(!d.is_non_negative()); + DiscountValue::MoneyPerBin(common::money("1.25", "USD")), + ) + .unwrap(); + assert_eq!(discount.scope(), &DiscountScope::Bin); + assert!(matches!( + discount.threshold(), + DiscountThreshold::BinCount { min: 2, .. } + )); + assert!(matches!(discount.value(), DiscountValue::MoneyPerBin(_))); + assert_eq!(discount.validate(), Ok(())); + assert!(discount.is_non_negative()); +} - let d = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::OrderTotal, - threshold: RadrootsCoreDiscountThreshold::OrderQuantity { min: qty_pos }, - value: RadrootsCoreDiscountValue::Percent(pct_neg), - }; - assert!(!d.is_non_negative()); +#[test] +fn discount_error_messages_are_stable() { + assert_eq!( + DiscountError::NegativeThreshold.to_string(), + "discount threshold must be non-negative" + ); + assert_eq!( + DiscountError::NegativeValue.to_string(), + "discount value must be non-negative" + ); } diff --git a/crates/core/tests/error.rs b/crates/core/tests/error.rs @@ -0,0 +1,40 @@ +#[cfg(feature = "std")] +use std::error::Error as _; + +use radroots_core::{Error, Unit, currency, decimal, money, percent, pricing, quantity, unit}; + +#[test] +fn aggregate_error_accepts_every_scoped_error() { + let errors = [ + Error::from(currency::ParseError::InvalidFormat), + Error::from(decimal::Error::ArithmeticOverflow), + Error::from(pricing::DiscountError::NegativeValue), + Error::from(money::Error::CurrencyMismatch), + Error::from(percent::ParseError::InvalidNumber), + Error::from(pricing::Error::PerQuantityZero), + Error::from(quantity::Error::UnitMismatch), + Error::from(unit::ParseError::UnknownUnit), + Error::from(unit::ConvertError::NotConvertibleUnits { + from: Unit::Each, + to: Unit::MassG, + }), + ]; + + for error in errors { + #[cfg(feature = "std")] + assert!(error.source().is_some()); + assert!(!error.to_string().is_empty()); + } +} + +#[test] +fn aggregate_error_display_identifies_the_owning_module() { + assert_eq!( + Error::from(decimal::Error::DivisionByZero).to_string(), + "decimal: decimal division by zero" + ); + assert_eq!( + Error::from(unit::ParseError::UnknownUnit).to_string(), + "unit parse: unknown unit string" + ); +} diff --git a/crates/core/tests/money.rs b/crates/core/tests/money.rs @@ -1,210 +1,139 @@ mod common; -use radroots_core::{RadrootsCoreCurrency, RadrootsCoreMoney, RadrootsCoreMoneyInvariantError}; -use rust_decimal::RoundingStrategy; +use radroots_core::{Currency, Decimal, Money, money::Error}; #[test] -fn zero_and_is_zero() { - let usd = RadrootsCoreCurrency::USD; - let zero = RadrootsCoreMoney::zero(usd); +fn zero_and_checked_constructor_preserve_invariants() { + let zero = Money::zero(Currency::USD); assert!(zero.is_zero()); - assert_eq!(zero.currency, usd); -} + assert_eq!(zero.amount(), Decimal::ZERO); + assert_eq!(zero.currency(), Currency::USD); -#[test] -fn ensure_non_negative_rejects_negative_amount() { - let money = RadrootsCoreMoney::new(common::dec("-1"), RadrootsCoreCurrency::USD); assert_eq!( - money.ensure_non_negative(), - Err(RadrootsCoreMoneyInvariantError::NegativeAmount) + Money::try_new(common::dec("-0.01"), Currency::USD), + Err(Error::NegativeAmount) ); + let signed_zero = Money::try_new(common::dec("-0.00"), Currency::USD).unwrap(); + assert_eq!(signed_zero.amount(), Decimal::ZERO); } #[test] -fn ensure_non_negative_accepts_zero_and_positive() { - let zero = RadrootsCoreMoney::new(common::dec("0"), RadrootsCoreCurrency::USD); - let pos = RadrootsCoreMoney::new(common::dec("1"), RadrootsCoreCurrency::USD); - assert_eq!(zero.ensure_non_negative(), Ok(())); - assert_eq!(pos.ensure_non_negative(), Ok(())); -} - -#[test] -fn quantize_to_currency_rounds_midpoint_away_from_zero() { - let usd = RadrootsCoreCurrency::USD; - let a = RadrootsCoreMoney::new(common::dec("1.234"), usd).quantize_to_currency(); - let b = RadrootsCoreMoney::new(common::dec("1.235"), usd).quantize_to_currency(); - let c = RadrootsCoreMoney::new(common::dec("-1.235"), usd).quantize_to_currency(); - - assert_eq!(a.amount, common::dec("1.23")); - assert_eq!(b.amount, common::dec("1.24")); - assert_eq!(c.amount, common::dec("-1.24")); -} - -#[test] -fn quantize_to_currency_with_strategy_uses_strategy() { - let usd = RadrootsCoreCurrency::USD; - let a = RadrootsCoreMoney::new(common::dec("1.235"), usd) - .quantize_to_currency_with_strategy(RoundingStrategy::MidpointTowardZero); - let b = RadrootsCoreMoney::new(common::dec("-1.235"), usd) - .quantize_to_currency_with_strategy(RoundingStrategy::MidpointTowardZero); - assert_eq!(a.amount, common::dec("1.23")); - assert_eq!(b.amount, common::dec("-1.23")); +fn quantization_is_deterministic_midpoint_away_from_zero() { + let a = common::money("1.234", "USD").quantize_to_currency(); + let b = common::money("1.235", "USD").quantize_to_currency(); + assert_eq!(a.amount(), common::dec("1.23")); + assert_eq!(b.amount(), common::dec("1.24")); } #[test] -fn checked_add_and_sub_require_currency_match() { - let usd = RadrootsCoreCurrency::USD; - let eur = RadrootsCoreCurrency::EUR; - let a = RadrootsCoreMoney::new(common::dec("1.00"), usd); - let b = RadrootsCoreMoney::new(common::dec("2.00"), usd); - let c = RadrootsCoreMoney::new(common::dec("3.00"), eur); +fn checked_arithmetic_reports_domain_failures() { + let one = common::money("1.00", "USD"); + let two = common::money("2.00", "USD"); + let euros = common::money("3.00", "EUR"); - assert_eq!(a.checked_add(&b).unwrap().amount, common::dec("3.00")); + assert_eq!(one.checked_add(&two).unwrap().amount(), common::dec("3")); + assert_eq!(one.checked_add(&euros), Err(Error::CurrencyMismatch)); + assert_eq!(two.checked_sub(&one).unwrap().amount(), common::dec("1")); + assert_eq!(one.checked_sub(&two), Err(Error::NegativeAmount)); assert_eq!( - a.checked_add(&c), - Err(RadrootsCoreMoneyInvariantError::CurrencyMismatch) + one.checked_mul_decimal(common::dec("-1")), + Err(Error::NegativeAmount) ); - assert_eq!(b.checked_sub(&a).unwrap().amount, common::dec("1.00")); -} - -#[test] -fn checked_sub_mismatch_returns_currency_error() { - let usd = RadrootsCoreCurrency::USD; - let eur = RadrootsCoreCurrency::EUR; - let a = RadrootsCoreMoney::new(common::dec("1.00"), usd); - let b = RadrootsCoreMoney::new(common::dec("2.00"), eur); assert_eq!( - a.checked_sub(&b), - Err(RadrootsCoreMoneyInvariantError::CurrencyMismatch) + one.checked_div_decimal(Decimal::ZERO), + Err(Error::DivisionByZero) ); + + let max = Money::try_new(Decimal::MAX, Currency::USD).unwrap(); + assert_eq!(max.checked_add(&one), Err(Error::ArithmeticOverflow)); } #[test] -fn minor_units_exact_and_rounded() { - let usd = RadrootsCoreCurrency::USD; - let exact = RadrootsCoreMoney::new(common::dec("1.23"), usd); - let frac = RadrootsCoreMoney::new(common::dec("1.234"), usd); - let rounded = RadrootsCoreMoney::new(common::dec("1.235"), usd); +fn minor_units_exact_and_rounded_cover_currency_exponents() { + let exact = common::money("1.23", "USD"); + let fractional = common::money("1.234", "USD"); + let rounded = common::money("1.235", "USD"); + assert_eq!(exact.to_minor_units_u64_exact(), Ok(123)); + assert_eq!( + fractional.to_minor_units_u64_exact(), + Err(Error::NotWholeMinorUnits) + ); + assert_eq!(rounded.to_minor_units_u64_rounded(), Ok(124)); - assert_eq!(exact.to_minor_units_u64_exact().unwrap(), 123); assert_eq!( - frac.to_minor_units_u64_exact(), - Err(RadrootsCoreMoneyInvariantError::NotWholeMinorUnits) + common::money("123", "JPY").to_minor_units_u64_exact(), + Ok(123) ); assert_eq!( - rounded - .to_minor_units_u64_rounded(RoundingStrategy::MidpointAwayFromZero) - .unwrap(), - 124 + common::money("1.234", "KWD").to_minor_units_u64_exact(), + Ok(1234) ); } #[test] -fn minor_units_cover_additional_currency_exponents() { - let jpy = RadrootsCoreMoney::new(common::dec("123"), common::currency("JPY")); - assert_eq!(jpy.to_minor_units_u64_exact().unwrap(), 123); - assert_eq!( - jpy.to_minor_units_u64_rounded(RoundingStrategy::MidpointAwayFromZero) - .unwrap(), - 123 - ); +fn minor_unit_constructors_roundtrip_and_detect_u32_overflow() { + for currency in [Currency::JPY, Currency::USD, common::currency("KWD")] { + for minor in [0, 1, 99, 12_345, u32::MAX as u64] { + let money = Money::from_minor_units_u64(minor, currency); + assert_eq!(money.to_minor_units_u64_exact(), Ok(minor)); + } + } - let kwd = RadrootsCoreMoney::new(common::dec("1.234"), common::currency("KWD")); - assert_eq!(kwd.to_minor_units_u64_exact().unwrap(), 1234); -} + let from_u32 = Money::from_minor_units_u32(505, Currency::USD); + assert_eq!(from_u32.amount(), common::dec("5.05")); + assert_eq!(from_u32.to_minor_units_u32_rounded(), Ok(505)); -#[test] -fn minor_units_u32_overflow_is_detected() { - let usd = RadrootsCoreCurrency::USD; - let too_large = RadrootsCoreMoney::from_minor_units_u64(u64::from(u32::MAX) + 1, usd); + let too_large = Money::from_minor_units_u64(u64::from(u32::MAX) + 1, Currency::USD); assert_eq!( too_large.to_minor_units_u32_exact(), - Err(RadrootsCoreMoneyInvariantError::AmountOverflow) + Err(Error::AmountOverflow) ); -} - -#[test] -fn minor_units_u32_exact_success_path_is_exercised() { - let usd = RadrootsCoreCurrency::USD; - let m = RadrootsCoreMoney::new(common::dec("42.01"), usd); - assert_eq!(m.to_minor_units_u32_exact().unwrap(), 4201); - let fractional = RadrootsCoreMoney::new(common::dec("1.001"), usd); assert_eq!( - fractional.to_minor_units_u32_exact(), - Err(RadrootsCoreMoneyInvariantError::NotWholeMinorUnits) + too_large.to_minor_units_u32_rounded(), + Err(Error::AmountOverflow) ); } #[test] -fn from_minor_units_u32_and_u32_rounded_paths_are_exercised() { - let usd = RadrootsCoreCurrency::USD; - let from_u32 = RadrootsCoreMoney::from_minor_units_u32(505, usd); - assert_eq!(from_u32.amount, common::dec("5.05")); +fn exact_scale_change_never_rounds() { + let money = common::money("1.2300", "USD"); + assert_eq!(money.try_with_scale_exact(2).unwrap().amount().scale(), 2); assert_eq!( - from_u32 - .to_minor_units_u32_rounded(RoundingStrategy::MidpointAwayFromZero) - .unwrap(), - 505 + common::money("1.235", "USD").try_with_scale_exact(2), + Err(Error::PrecisionLoss) ); } #[test] -fn minor_units_u32_rounded_overflow_is_detected() { - let usd = RadrootsCoreCurrency::USD; - let too_large = RadrootsCoreMoney::from_minor_units_u64(u64::from(u32::MAX) + 1, usd); +fn display_and_error_messages_are_stable() { + assert_eq!(common::money("10", "USD").to_string(), "10 USD"); assert_eq!( - too_large.to_minor_units_u32_rounded(RoundingStrategy::MidpointAwayFromZero), - Err(RadrootsCoreMoneyInvariantError::AmountOverflow) - ); - let negative = RadrootsCoreMoney::new(common::dec("-1.00"), usd); - assert_eq!( - negative.to_minor_units_u32_rounded(RoundingStrategy::MidpointAwayFromZero), - Err(RadrootsCoreMoneyInvariantError::AmountOverflow) - ); -} - -#[test] -fn with_scale_path_is_exercised() { - let usd = RadrootsCoreCurrency::USD; - let m = RadrootsCoreMoney::new(common::dec("1.2300"), usd).with_scale(1); - assert_eq!(m.amount, common::dec("1.2")); -} - -#[test] -fn from_minor_units_roundtrips() { - let usd = RadrootsCoreCurrency::USD; - let money = RadrootsCoreMoney::from_minor_units_u64(12345, usd); - assert_eq!(money.to_minor_units_u64_exact().unwrap(), 12345); -} - -#[test] -fn display_and_operator_impl_paths_are_exercised() { - let usd = RadrootsCoreCurrency::USD; - let m = RadrootsCoreMoney::new(common::dec("10"), usd); - assert_eq!(m.to_string(), "10 USD"); - - let times = m.clone() * common::dec("2"); - assert_eq!(times.amount, common::dec("20")); - let divided = m / common::dec("4"); - assert_eq!(divided.amount, common::dec("2.5")); -} - -#[test] -fn invariant_error_display_variants_are_exercised() { - assert_eq!( - RadrootsCoreMoneyInvariantError::NegativeAmount.to_string(), + Error::NegativeAmount.to_string(), "money amount must be ≥ 0" ); assert_eq!( - RadrootsCoreMoneyInvariantError::NotWholeMinorUnits.to_string(), + Error::NotWholeMinorUnits.to_string(), "money not a whole number of minor units" ); assert_eq!( - RadrootsCoreMoneyInvariantError::AmountOverflow.to_string(), + Error::AmountOverflow.to_string(), "money minor-unit conversion overflow" ); assert_eq!( - RadrootsCoreMoneyInvariantError::CurrencyMismatch.to_string(), + Error::CurrencyMismatch.to_string(), "money currency mismatch" ); + assert_eq!( + Error::ArithmeticOverflow.to_string(), + "money arithmetic overflow" + ); + assert_eq!(Error::DivisionByZero.to_string(), "money division by zero"); + assert_eq!( + Error::ScaleOutOfRange.to_string(), + "money scale is outside the supported range" + ); + assert_eq!( + Error::PrecisionLoss.to_string(), + "money operation would lose precision" + ); } diff --git a/crates/core/tests/package_boundary.rs b/crates/core/tests/package_boundary.rs @@ -0,0 +1,33 @@ +use radroots_core::unit::UnitDimension; +#[allow(unused_imports)] +use radroots_core::{ + Currency, Decimal, Error, Money, Percent, Quantity, QuantityPrice, Unit, currency as _, + decimal as _, money as _, percent as _, pricing as _, quantity as _, unit as _, +}; + +#[test] +fn approved_module_skeleton_is_public() {} + +#[test] +fn canonical_value_paths_are_public() { + let _ = core::mem::size_of::<Currency>(); + let _ = core::mem::size_of::<Decimal>(); + let _ = core::mem::size_of::<Money>(); + let _ = core::mem::size_of::<Percent>(); + let _ = core::mem::size_of::<Quantity>(); + let _ = core::mem::size_of::<QuantityPrice>(); + let _ = core::mem::size_of::<Unit>(); + let _ = core::mem::size_of::<Error>(); + let _ = UnitDimension::Count; + let _ = core::mem::size_of::<radroots_core::pricing::Discount>(); + let _ = core::mem::size_of::<radroots_core::pricing::DiscountError>(); + let _ = core::mem::size_of::<radroots_core::pricing::Error>(); +} + +#[test] +fn canonical_values_use_checked_construction() { + let money = Money::try_new(Decimal::ONE, Currency::USD).expect("valid money"); + let quantity = Quantity::try_new(Decimal::ONE, Unit::Each).expect("valid quantity"); + let price = QuantityPrice::try_new(money, quantity).expect("valid price"); + assert_eq!(price.quantity().unit(), Unit::Each); +} diff --git a/crates/core/tests/percent.rs b/crates/core/tests/percent.rs @@ -2,48 +2,57 @@ mod common; use core::str::FromStr; -use radroots_core::{RadrootsCorePercent, RadrootsCorePercentParseError}; +use radroots_core::{Decimal, Percent, decimal, money, percent::ParseError}; #[test] -fn ratio_roundtrip() { - let pct = RadrootsCorePercent::from_ratio(common::dec("0.125")); - assert_eq!(pct.value, common::dec("12.5")); - assert_eq!(pct.to_ratio(), common::dec("0.125")); +fn checked_ratio_paths_report_overflow_and_roundtrip() { + assert_eq!( + Percent::try_from_ratio(Decimal::MAX), + Err(decimal::Error::ArithmeticOverflow) + ); + for ratio in ["-0.125", "0", "0.5", "1", "2.75"] { + let ratio = common::dec(ratio); + let percent = Percent::try_from_ratio(ratio).unwrap(); + assert_eq!(percent.try_to_ratio().unwrap(), ratio); + } } #[test] -fn parses_percent_strings() { - let pct = RadrootsCorePercent::from_str("12.5%").unwrap(); - assert_eq!(pct.value, common::dec("12.5")); - - let pct = RadrootsCorePercent::from_str(" 12.5 ").unwrap(); - assert_eq!(pct.value, common::dec("12.5")); - - assert_eq!( - RadrootsCorePercent::from_str("nope"), - Err(RadrootsCorePercentParseError::InvalidNumber) - ); +fn parsing_and_accessors_preserve_signed_values() { + let percent = Percent::from_str("12.5%").unwrap(); + assert_eq!(percent.value(), common::dec("12.5")); + assert_eq!(Percent::from_str(" 12.5 ").unwrap(), percent); + assert_eq!(Percent::from_str("nope"), Err(ParseError::InvalidNumber)); } #[test] -fn of_money_and_quantized() { +fn checked_money_calculation_and_quantization_are_deterministic() { let base = common::money("20.00", "USD"); - let pct = RadrootsCorePercent::from_str("10").unwrap(); - let out = pct.of_money(&base); - assert_eq!(out.amount, common::dec("2.00")); + let percent = Percent::from_str("10").unwrap(); + assert_eq!( + percent.try_of_money(&base).unwrap().amount(), + common::dec("2") + ); let tiny = common::money("0.05", "USD"); - let pct = RadrootsCorePercent::from_str("10").unwrap(); - let rounded = pct.of_money_quantized(&tiny); - assert_eq!(rounded.amount, common::dec("0.01")); + assert_eq!( + percent.try_of_money_quantized(&tiny).unwrap().amount(), + common::dec("0.01") + ); + + let max = radroots_core::Money::try_new(Decimal::MAX, radroots_core::Currency::USD).unwrap(); + let two_hundred = Percent::new(common::dec("200")); + assert_eq!( + two_hundred.try_of_money(&max), + Err(money::Error::ArithmeticOverflow) + ); } #[test] -fn display_and_parse_error_display_paths_are_exercised() { - let pct = RadrootsCorePercent::from_str("12.5%").unwrap(); - assert_eq!(pct.to_string(), "12.5%"); +fn display_and_parse_error_messages_are_stable() { + assert_eq!(Percent::from_str("12.5%").unwrap().to_string(), "12.5%"); assert_eq!( - RadrootsCorePercentParseError::InvalidNumber.to_string(), + ParseError::InvalidNumber.to_string(), "invalid percent string" ); } diff --git a/crates/core/tests/properties.rs b/crates/core/tests/properties.rs @@ -0,0 +1,107 @@ +mod common; + +use radroots_core::{Currency, Decimal, Money, Quantity, Unit}; + +const VALUES: [&str; 8] = ["-100", "-1.25", "-0.01", "0", "0.01", "1.25", "42", "100"]; +const NON_NEGATIVE_VALUES: [&str; 6] = ["0", "0.01", "1", "1.25", "42", "100"]; + +#[test] +fn checked_decimal_arithmetic_obeys_ring_laws_on_representative_values() { + for left in VALUES.map(common::dec) { + assert_eq!(left.checked_add(Decimal::ZERO).unwrap(), left); + assert_eq!(left.checked_mul(Decimal::ONE).unwrap(), left); + for right in VALUES.map(common::dec) { + assert_eq!( + left.checked_add(right).unwrap(), + right.checked_add(left).unwrap() + ); + assert_eq!( + left.checked_mul(right).unwrap(), + right.checked_mul(left).unwrap() + ); + assert_eq!( + left.checked_add(right).unwrap().checked_sub(right).unwrap(), + left + ); + } + } +} + +#[test] +fn checked_decimal_multiplication_distributes_over_addition() { + for left in VALUES.map(common::dec) { + for middle in VALUES.map(common::dec) { + for right in VALUES.map(common::dec) { + let combined = left + .checked_add(middle) + .unwrap() + .checked_mul(right) + .unwrap(); + let distributed = left + .checked_mul(right) + .unwrap() + .checked_add(middle.checked_mul(right).unwrap()) + .unwrap(); + assert_eq!(combined, distributed); + } + } + } +} + +#[test] +fn checked_money_addition_is_commutative_and_associative() { + for first in NON_NEGATIVE_VALUES.map(common::dec) { + for second in NON_NEGATIVE_VALUES.map(common::dec) { + for third in NON_NEGATIVE_VALUES.map(common::dec) { + let first = Money::try_new(first, Currency::USD).unwrap(); + let second = Money::try_new(second, Currency::USD).unwrap(); + let third = Money::try_new(third, Currency::USD).unwrap(); + assert_eq!(first.checked_add(&second), second.checked_add(&first)); + assert_eq!( + first.checked_add(&second).unwrap().checked_add(&third), + first.checked_add(&second.checked_add(&third).unwrap()) + ); + } + } + } +} + +#[test] +fn checked_quantity_addition_preserves_unit_and_is_associative() { + for unit in [Unit::Each, Unit::MassG, Unit::VolumeMl] { + for first in NON_NEGATIVE_VALUES.map(common::dec) { + for second in NON_NEGATIVE_VALUES.map(common::dec) { + let first = Quantity::try_new(first, unit).unwrap(); + let second = Quantity::try_new(second, unit).unwrap(); + let sum = first.try_add(&second).unwrap(); + assert_eq!(sum.unit(), unit); + assert_eq!( + sum.amount(), + first.amount().checked_add(second.amount()).unwrap() + ); + assert_eq!(first.try_add(&second), second.try_add(&first)); + } + } + } +} + +#[test] +fn canonical_unit_conversion_is_idempotent() { + for (unit, canonical) in [ + (Unit::Each, Unit::Each), + (Unit::MassKg, Unit::MassG), + (Unit::MassG, Unit::MassG), + (Unit::MassOz, Unit::MassG), + (Unit::MassLb, Unit::MassG), + (Unit::VolumeL, Unit::VolumeMl), + (Unit::VolumeMl, Unit::VolumeMl), + ] { + for amount in NON_NEGATIVE_VALUES.map(common::dec) { + let quantity = Quantity::try_new(amount, unit).unwrap(); + let once = quantity.to_canonical().unwrap(); + let twice = once.to_canonical().unwrap(); + assert_eq!(once, twice); + assert_eq!(once.unit(), canonical); + } + } +} diff --git a/crates/core/tests/quantity.rs b/crates/core/tests/quantity.rs @@ -1,142 +1,117 @@ mod common; -use radroots_core::{RadrootsCoreQuantityInvariantError, RadrootsCoreUnit}; +use radroots_core::{Decimal, Quantity, Unit, quantity::Error}; #[test] -fn zero_helpers_and_scale_paths_are_exercised() { - let zero = radroots_core::RadrootsCoreQuantity::zero(RadrootsCoreUnit::MassKg); +fn zero_and_checked_constructor_preserve_invariants() { + let zero = Quantity::zero(Unit::MassKg); assert!(zero.is_zero()); - assert_eq!(zero.canonical_unit(), RadrootsCoreUnit::MassG); + assert_eq!(zero.amount(), Decimal::ZERO); + assert_eq!(zero.unit(), Unit::MassKg); + assert_eq!(zero.canonical_unit(), Unit::MassG); assert!(!zero.is_canonical()); - let scaled = common::qty("1.2300", RadrootsCoreUnit::Each).with_scale(1); - assert_eq!(scaled.amount, common::dec("1.2")); + assert_eq!( + Quantity::try_new(common::dec("-1"), Unit::Each), + Err(Error::NegativeAmount) + ); } #[test] -fn label_helpers_set_and_clear() { - let q = common::qty("1", RadrootsCoreUnit::Each).with_label("box"); - assert_eq!(q.label.as_deref(), Some("box")); - - let q = q.clear_label(); - assert!(q.label.is_none()); - - let q = common::qty("1", RadrootsCoreUnit::Each).with_optional_label(Some("case")); - assert_eq!(q.label.as_deref(), Some("case")); - - let q = q.with_optional_label::<&str>(None); - assert!(q.label.is_none()); +fn label_helpers_set_replace_and_clear() { + let quantity = common::qty("1", Unit::Each).with_label("box"); + assert_eq!(quantity.label(), Some("box")); + let quantity = quantity.with_optional_label(Some("case")); + assert_eq!(quantity.label(), Some("case")); + let quantity = quantity.with_optional_label::<&str>(None); + assert_eq!(quantity.label(), None); + assert_eq!(quantity.with_label("bag").clear_label().label(), None); } #[test] -fn ensure_non_negative_rejects_negative_amount() { - let q = common::qty("-1", RadrootsCoreUnit::Each); - assert_eq!( - q.ensure_non_negative(), - Err(RadrootsCoreQuantityInvariantError::NegativeAmount) - ); -} +fn checked_addition_and_subtraction_require_matching_units() { + let first = common::qty("5", Unit::Each).with_label("lhs"); + let second = common::qty("2", Unit::Each); + let kilograms = common::qty("1", Unit::MassKg); -#[test] -fn ensure_non_negative_accepts_non_negative_amount() { - let q = common::qty("0", RadrootsCoreUnit::Each); - assert_eq!(q.ensure_non_negative(), Ok(())); + let sum = first.try_add(&second).unwrap(); + assert_eq!(sum.amount(), common::dec("7")); + assert_eq!(sum.label(), Some("lhs")); + let difference = first.try_sub(&second).unwrap(); + assert_eq!(difference.amount(), common::dec("3")); + assert_eq!(difference.label(), Some("lhs")); + + assert_eq!(first.try_add(&kilograms), Err(Error::UnitMismatch)); + assert_eq!(first.try_sub(&kilograms), Err(Error::UnitMismatch)); + assert!(first.checked_add(&kilograms).is_none()); + assert!(first.checked_sub(&kilograms).is_none()); + assert_eq!(second.try_sub(&first), Err(Error::NegativeAmount)); } #[test] -fn try_add_and_try_sub_require_matching_units() { - let a = common::qty("1", RadrootsCoreUnit::Each).with_label("lhs"); - let b = common::qty("2", RadrootsCoreUnit::Each); - let c = common::qty("1", RadrootsCoreUnit::MassKg); - - let sum = a.try_add(&b).unwrap(); - assert_eq!(sum.amount, common::dec("3")); - assert_eq!(sum.label.as_deref(), Some("lhs")); +fn checked_scaling_reports_negative_overflow_and_zero_division() { + let quantity = common::qty("2", Unit::Each).with_label("unit"); + let scaled = quantity.checked_mul_decimal(common::dec("2.5")).unwrap(); + assert_eq!(scaled.amount(), common::dec("5")); + assert_eq!(scaled.unit(), Unit::Each); + assert_eq!(scaled.label(), Some("unit")); + let divided = quantity.checked_div_decimal(common::dec("2")).unwrap(); + assert_eq!(divided.amount(), common::dec("1")); assert_eq!( - a.try_add(&c), - Err(RadrootsCoreQuantityInvariantError::UnitMismatch) + quantity.checked_mul_decimal(common::dec("-1")), + Err(Error::NegativeAmount) ); assert_eq!( - b.try_sub(&c), - Err(RadrootsCoreQuantityInvariantError::UnitMismatch) + quantity.checked_div_decimal(Decimal::ZERO), + Err(Error::DivisionByZero) ); -} -#[test] -fn try_sub_success_path_is_exercised() { - let a = common::qty("4", RadrootsCoreUnit::Each).with_label("lhs"); - let b = common::qty("1", RadrootsCoreUnit::Each); - let out = a.try_sub(&b).expect("sub result"); - assert_eq!(out.amount, common::dec("3")); - assert_eq!(out.label.as_deref(), Some("lhs")); -} - -#[test] -fn checked_add_and_sub_return_none_on_mismatch() { - let a = common::qty("1", RadrootsCoreUnit::Each); - let b = common::qty("2", RadrootsCoreUnit::MassG); - assert!(a.checked_add(&b).is_none()); - assert!(a.checked_sub(&b).is_none()); -} - -#[test] -fn checked_add_and_sub_return_some_on_matching_units() { - let a = common::qty("5", RadrootsCoreUnit::Each).with_label("lhs"); - let b = common::qty("2", RadrootsCoreUnit::Each); - let added = a.checked_add(&b).expect("added quantity"); - assert_eq!(added.amount, common::dec("7")); - assert_eq!(added.label.as_deref(), Some("lhs")); - - let subbed = a.checked_sub(&b).expect("subbed quantity"); - assert_eq!(subbed.amount, common::dec("3")); - assert_eq!(subbed.label.as_deref(), Some("lhs")); + let max = Quantity::try_new(Decimal::MAX, Unit::Each).unwrap(); + assert_eq!(max.try_add(&quantity), Err(Error::ArithmeticOverflow)); } #[test] -fn mul_and_div_preserve_unit_and_label() { - let q = common::qty("2", RadrootsCoreUnit::Each).with_label("unit"); - let scaled = q.clone().mul_decimal(common::dec("2.5")); - assert_eq!(scaled.amount, common::dec("5")); - assert_eq!(scaled.unit, RadrootsCoreUnit::Each); - assert_eq!(scaled.label.as_deref(), Some("unit")); - - let divided = q.div_decimal(common::dec("2")); - assert_eq!(divided.amount, common::dec("1")); - assert_eq!(divided.unit, RadrootsCoreUnit::Each); - assert_eq!(divided.label.as_deref(), Some("unit")); -} +fn conversion_is_checked_and_preserves_labels() { + let kilograms = common::qty("1", Unit::MassKg).with_label("bag"); + let grams = kilograms.try_convert_to(Unit::MassG).unwrap(); + assert_eq!(grams.amount(), common::dec("1000")); + assert_eq!(grams.unit(), Unit::MassG); + assert_eq!(grams.label(), Some("bag")); + assert!(grams.is_canonical()); -#[test] -fn mul_and_div_operator_impls_are_exercised() { - let qty = common::qty("4", RadrootsCoreUnit::Each).with_label("bag"); - let mul = qty.clone() * common::dec("1.5"); - assert_eq!(mul.amount, common::dec("6")); - assert_eq!(mul.label.as_deref(), Some("bag")); - - let div = qty / common::dec("2"); - assert_eq!(div.amount, common::dec("2")); - assert_eq!(div.label.as_deref(), Some("bag")); -} + let canonical = common::qty("2", Unit::VolumeL).to_canonical().unwrap(); + assert_eq!(canonical.unit(), Unit::VolumeMl); + assert_eq!(canonical.amount(), common::dec("2000")); -#[test] -fn display_includes_label_when_present() { - let q = common::qty("1.5", RadrootsCoreUnit::Each).with_label("bag"); - assert_eq!(q.to_string(), "1.5 each (bag)"); + assert_eq!( + common::qty("1", Unit::Each).try_convert_to(Unit::MassG), + Err(radroots_core::unit::ConvertError::NotConvertibleUnits { + from: Unit::Each, + to: Unit::MassG, + }) + ); } #[test] -fn display_without_label_and_error_display_are_exercised() { - let q = common::qty("1.5", RadrootsCoreUnit::Each); - assert_eq!(q.to_string(), "1.5 each"); - +fn display_and_error_messages_are_stable() { + assert_eq!( + common::qty("1.5", Unit::Each).with_label("bag").to_string(), + "1.5 each (bag)" + ); + assert_eq!(common::qty("1.5", Unit::Each).to_string(), "1.5 each"); assert_eq!( - RadrootsCoreQuantityInvariantError::NegativeAmount.to_string(), + Error::NegativeAmount.to_string(), "quantity amount must be ≥ 0" ); + assert_eq!(Error::UnitMismatch.to_string(), "quantity unit mismatch"); + assert_eq!( + Error::ArithmeticOverflow.to_string(), + "quantity arithmetic overflow" + ); assert_eq!( - RadrootsCoreQuantityInvariantError::UnitMismatch.to_string(), - "quantity unit mismatch" + Error::DivisionByZero.to_string(), + "quantity division by zero" ); } @@ -144,76 +119,13 @@ fn display_without_label_and_error_display_are_exercised() { fn display_propagates_formatter_errors() { use core::fmt::{self, Write}; - struct FailWriter { - fail_on_paren: bool, - fail_on_call: usize, - calls: usize, - } - + struct FailWriter; impl Write for FailWriter { - fn write_str(&mut self, s: &str) -> fmt::Result { - self.calls += 1; - if self.fail_on_paren && s.contains('(') { - return Err(fmt::Error); - } - if self.calls == self.fail_on_call { - Err(fmt::Error) - } else { - Ok(()) - } + fn write_str(&mut self, _: &str) -> fmt::Result { + Err(fmt::Error) } } - let with_label = common::qty("1.5", RadrootsCoreUnit::Each).with_label("bag"); - let mut first_write_fails = FailWriter { - fail_on_paren: false, - fail_on_call: 1, - calls: 0, - }; - assert!(fmt::write(&mut first_write_fails, format_args!("{with_label}")).is_err()); - - let mut second_write_fails = FailWriter { - fail_on_paren: true, - fail_on_call: usize::MAX, - calls: 0, - }; - assert!(fmt::write(&mut second_write_fails, format_args!("{with_label}")).is_err()); -} - -#[test] -fn try_convert_to_changes_unit_and_amount() { - let q = common::qty("1", RadrootsCoreUnit::MassKg); - let converted = q.try_convert_to(RadrootsCoreUnit::MassG).unwrap(); - assert_eq!(converted.amount, common::dec("1000")); - assert_eq!(converted.unit, RadrootsCoreUnit::MassG); -} - -#[test] -fn to_canonical_converts_mass_and_volume() { - let q = common::qty("2", RadrootsCoreUnit::VolumeL); - let canonical = q.to_canonical().unwrap(); - assert_eq!(canonical.unit, RadrootsCoreUnit::VolumeMl); - assert_eq!(canonical.amount, common::dec("2000")); -} - -#[test] -fn try_convert_to_rejects_mismatched_dimensions() { - let q = common::qty("1", RadrootsCoreUnit::Each); - let err = q.try_convert_to(RadrootsCoreUnit::MassG).unwrap_err(); - assert_eq!( - err, - radroots_core::RadrootsCoreUnitConvertError::NotConvertibleUnits { - from: RadrootsCoreUnit::Each, - to: RadrootsCoreUnit::MassG - } - ); -} - -#[test] -fn try_convert_to_same_unit_returns_self_clone() { - let q = common::qty("2", RadrootsCoreUnit::MassG).with_label("x"); - let converted = q - .try_convert_to(RadrootsCoreUnit::MassG) - .expect("same unit"); - assert_eq!(converted, q); + let quantity = common::qty("1.5", Unit::Each).with_label("bag"); + assert!(fmt::write(&mut FailWriter, format_args!("{quantity}")).is_err()); } diff --git a/crates/core/tests/quantity_price.rs b/crates/core/tests/quantity_price.rs @@ -1,248 +1,156 @@ mod common; use radroots_core::{ - RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreQuantityPriceOps, - RadrootsCoreUnit, + Currency, Decimal, Money, Quantity, QuantityPrice, Unit, + pricing::{Error, QuantityPriceOps}, }; -#[test] -fn cost_for_scales_by_ratio() { - let price = RadrootsCoreQuantityPrice::new( - common::money("10", "USD"), - common::qty("1", RadrootsCoreUnit::MassKg), - ); - let cost = price.cost_for(&common::qty("2", RadrootsCoreUnit::MassKg)); - assert_eq!(cost.amount, common::dec("20")); +fn unit_price(amount: &str, per: &str, unit: Unit) -> QuantityPrice { + QuantityPrice::try_new(common::money(amount, "USD"), common::qty(per, unit)) + .expect("valid price") } #[test] -fn cost_for_returns_zero_on_unit_mismatch() { - let price = RadrootsCoreQuantityPrice::new( - common::money("10", "USD"), - common::qty("1", RadrootsCoreUnit::MassKg), - ); - let cost = price.cost_for(&common::qty("1", RadrootsCoreUnit::Each)); - assert!(cost.amount.is_zero()); +fn checked_cost_scales_by_ratio_and_rounds_deterministically() { + let price = unit_price("10", "1", Unit::MassKg); + let cost = price.try_cost_for(&common::qty("2", Unit::MassKg)).unwrap(); + assert_eq!(cost.amount(), common::dec("20")); + + let rounded = unit_price("1.005", "1", Unit::Each) + .try_cost_for_rounded(&common::qty("2", Unit::Each)) + .unwrap(); + assert_eq!(rounded.amount(), common::dec("2.01")); } #[test] -fn cost_for_rounded_and_quantized_price_differ() { - let price = RadrootsCoreQuantityPrice::new( - common::money("1.005", "USD"), - common::qty("1", RadrootsCoreUnit::Each), - ); - let qty = common::qty("2", RadrootsCoreUnit::Each); - let rounded = price.cost_for_rounded(&qty); - let quantized = price.cost_for_with_quantized_price(&qty); - - assert_eq!(rounded.amount, common::dec("2.01")); - assert_eq!(quantized.amount, common::dec("2.02")); +fn checked_constructor_rejects_zero_per_quantity() { + assert_eq!( + QuantityPrice::try_new(common::money("1", "USD"), Quantity::zero(Unit::Each)), + Err(Error::PerQuantityZero) + ); } #[test] -fn try_cost_for_validates_quantity_and_units() { - let price = RadrootsCoreQuantityPrice::new( - common::money("10", "USD"), - common::qty("1", RadrootsCoreUnit::Each), - ); - let zero_price = RadrootsCoreQuantityPrice::new( - common::money("10", "USD"), - common::qty("0", RadrootsCoreUnit::Each), +fn checked_cost_never_silently_converts_failures_to_zero() { + let price = unit_price("10", "1", Unit::Each); + assert_eq!( + price.try_cost_for(&common::qty("1", Unit::MassKg)), + Err(Error::UnitMismatch { + have: Unit::MassKg, + want: Unit::Each, + }) ); - assert_eq!( - zero_price.try_cost_for(&common::qty("1", RadrootsCoreUnit::Each)), - Err(RadrootsCoreQuantityPriceError::PerQuantityZero) + price.try_cost_for_amount_in(common::dec("-1"), Unit::Each), + Err(Error::NegativeRequestedQuantity) ); assert_eq!( - price.try_cost_for(&common::qty("1", RadrootsCoreUnit::MassKg)), - Err(RadrootsCoreQuantityPriceError::UnitMismatch { - have: RadrootsCoreUnit::MassKg, - want: RadrootsCoreUnit::Each + price.try_cost_for_rounded(&common::qty("1", Unit::MassKg)), + Err(Error::UnitMismatch { + have: Unit::MassKg, + want: Unit::Each, }) ); } #[test] -fn try_cost_for_rounded_error_path_is_exercised() { - let price = RadrootsCoreQuantityPrice::new( - common::money("10", "USD"), - common::qty("1", RadrootsCoreUnit::Each), - ); +fn checked_cost_reports_arithmetic_overflow() { + let price = QuantityPrice::try_new( + Money::try_new(Decimal::MAX, Currency::USD).unwrap(), + Quantity::try_new(Decimal::ONE, Unit::Each).unwrap(), + ) + .unwrap(); assert_eq!( - price.try_cost_for_rounded(&common::qty("1", RadrootsCoreUnit::MassKg)), - Err(RadrootsCoreQuantityPriceError::UnitMismatch { - have: RadrootsCoreUnit::MassKg, - want: RadrootsCoreUnit::Each - }) + price.try_cost_for(&Quantity::try_new(Decimal::from(2u32), Unit::Each).unwrap()), + Err(Error::ArithmeticOverflow) ); } #[test] -fn try_cost_for_amount_in_converts_mass_units() { - let price = RadrootsCoreQuantityPrice::new( - common::money("10", "USD"), - common::qty("1", RadrootsCoreUnit::MassKg), +fn amount_and_quantity_conversion_support_mass_and_volume() { + let mass = unit_price("10", "1", Unit::MassKg); + assert_eq!( + mass.try_cost_for_amount_in(common::dec("500"), Unit::MassG) + .unwrap() + .amount(), + common::dec("5") + ); + assert_eq!( + mass.try_cost_for_quantity_in(&common::qty("250", Unit::MassG)) + .unwrap() + .amount(), + common::dec("2.5") + ); + assert_eq!( + mass.try_cost_for_amount_in(common::dec("1"), Unit::Each), + Err(Error::NonConvertibleUnits { + from: Unit::Each, + to: Unit::MassKg, + }) ); - let cost = price - .try_cost_for_amount_in(common::dec("500"), RadrootsCoreUnit::MassG) - .unwrap(); - assert_eq!(cost.amount, common::dec("5")); -} -#[test] -fn try_cost_for_amount_in_converts_volume_units() { - let price = RadrootsCoreQuantityPrice::new( - common::money("10", "USD"), - common::qty("1", RadrootsCoreUnit::VolumeL), + let volume = unit_price("10", "1", Unit::VolumeL); + assert_eq!( + volume + .try_cost_for_amount_in(common::dec("500"), Unit::VolumeMl) + .unwrap() + .amount(), + common::dec("5") ); - let cost = price - .try_cost_for_amount_in(common::dec("500"), RadrootsCoreUnit::VolumeMl) - .unwrap(); - assert_eq!(cost.amount, common::dec("5")); } #[test] -fn try_cost_for_amount_in_rejects_non_convertible_units() { - let price = RadrootsCoreQuantityPrice::new( - common::money("10", "USD"), - common::qty("1", RadrootsCoreUnit::MassKg), - ); +fn unit_price_conversion_is_checked_and_uses_accessors() { + let base = unit_price("5", "2", Unit::MassKg); + let same = base.try_to_unit_price(Unit::MassKg).unwrap(); + assert_eq!(same.quantity().unit(), Unit::MassKg); + assert_eq!(same.quantity().amount(), Decimal::ONE); + assert_eq!(same.amount().amount(), common::dec("2.5")); assert_eq!( - price.try_cost_for_amount_in(common::dec("1"), RadrootsCoreUnit::Each), - Err(RadrootsCoreQuantityPriceError::NonConvertibleUnits { - from: RadrootsCoreUnit::Each, - to: RadrootsCoreUnit::MassKg + base.try_to_unit_price(Unit::VolumeMl), + Err(Error::NonConvertibleUnits { + from: Unit::MassKg, + to: Unit::VolumeMl, }) ); -} -#[test] -fn try_cost_for_amount_in_same_unit_path_is_exercised() { - let price = RadrootsCoreQuantityPrice::new( - common::money("4", "USD"), - common::qty("1", RadrootsCoreUnit::Each), - ); - let out = price - .try_cost_for_amount_in(common::dec("3"), RadrootsCoreUnit::Each) + let canonical = unit_price("6.99", "1", Unit::MassLb) + .try_to_canonical_unit_price() .unwrap(); - assert_eq!(out.amount, common::dec("12")); + assert_eq!(canonical.quantity().unit(), Unit::MassG); + assert_eq!(canonical.quantity().amount(), Decimal::ONE); + assert!(canonical.is_price_per_canonical_unit()); } #[test] -fn try_cost_for_quantity_in_path_is_exercised() { - let price = RadrootsCoreQuantityPrice::new( - common::money("10", "USD"), - common::qty("1", RadrootsCoreUnit::MassKg), - ); - let qty = common::qty("250", RadrootsCoreUnit::MassG); - let out = price.try_cost_for_quantity_in(&qty).unwrap(); - assert_eq!(out.amount, common::dec("2.5")); +fn unit_price_conversion_detects_precision_underflow() { + let tiny = unit_price("1", "0.0000000000000000000000000001", Unit::VolumeMl); + assert_eq!( + tiny.try_to_unit_price(Unit::VolumeL), + Err(Error::PerQuantityZero) + ); } #[test] -fn try_to_unit_price_error_and_same_unit_paths_are_exercised() { - let zero = RadrootsCoreQuantityPrice::new( - common::money("10", "USD"), - common::qty("0", RadrootsCoreUnit::MassKg), - ); +fn pricing_error_messages_are_stable() { assert_eq!( - zero.try_to_unit_price(RadrootsCoreUnit::MassG), - Err(RadrootsCoreQuantityPriceError::PerQuantityZero) + Error::PerQuantityZero.to_string(), + "price quantity must be greater than zero" ); - - let base = RadrootsCoreQuantityPrice::new( - common::money("5", "USD"), - common::qty("2", RadrootsCoreUnit::MassKg), - ); - let same = base.try_to_unit_price(RadrootsCoreUnit::MassKg).unwrap(); - assert_eq!(same.quantity.unit, RadrootsCoreUnit::MassKg); - assert_eq!(same.quantity.amount, common::dec("1")); - assert_eq!(same.amount.amount, common::dec("2.5")); - - let err = base - .try_to_unit_price(RadrootsCoreUnit::VolumeMl) - .unwrap_err(); assert_eq!( - err, - RadrootsCoreQuantityPriceError::NonConvertibleUnits { - from: RadrootsCoreUnit::MassKg, - to: RadrootsCoreUnit::VolumeMl - } + Error::PerQuantityNegative.to_string(), + "price quantity must not be negative" ); -} - -#[test] -fn cost_for_and_quantized_price_zero_paths_are_exercised() { - let p = RadrootsCoreQuantityPrice::new( - common::money("3.33", "USD"), - common::qty("1", RadrootsCoreUnit::Each), - ); - let zero_qty = common::qty("0", RadrootsCoreUnit::Each); - assert!(p.cost_for(&zero_qty).amount.is_zero()); - assert!(p.cost_for_with_quantized_price(&zero_qty).amount.is_zero()); - - let zero_per = RadrootsCoreQuantityPrice::new( - common::money("3.33", "USD"), - common::qty("0", RadrootsCoreUnit::Each), - ); - assert!( - zero_per - .cost_for(&common::qty("1", RadrootsCoreUnit::Each)) - .amount - .is_zero() - ); - assert!( - zero_per - .cost_for_with_quantized_price(&common::qty("1", RadrootsCoreUnit::Each)) - .amount - .is_zero() - ); - - let mismatch_qty = common::qty("1", RadrootsCoreUnit::MassG); - assert!( - p.cost_for_with_quantized_price(&mismatch_qty) - .amount - .is_zero() + assert_eq!( + Error::NegativePrice.to_string(), + "price amount must not be negative" ); -} - -#[test] -fn try_to_unit_price_detects_underflow_to_zero_normalized_amount() { - let tiny = RadrootsCoreQuantityPrice::new( - common::money("1", "USD"), - common::qty("0.0000000000000000000000000001", RadrootsCoreUnit::VolumeMl), - ); - let err = tiny - .try_to_unit_price(RadrootsCoreUnit::VolumeL) - .unwrap_err(); - assert_eq!(err, RadrootsCoreQuantityPriceError::PerQuantityZero); -} - -#[test] -fn try_to_canonical_unit_price_converts_units() { - let price = RadrootsCoreQuantityPrice::new( - common::money("6.99", "USD"), - common::qty("1", RadrootsCoreUnit::MassLb), - ); - let canonical = price.try_to_canonical_unit_price().unwrap(); - assert_eq!(canonical.quantity.unit, RadrootsCoreUnit::MassG); - assert_eq!(canonical.quantity.amount, common::dec("1")); - let expected = common::dec("6.99") / common::dec("453.59237"); - assert_eq!(canonical.amount.amount, expected); -} - -#[test] -fn is_price_per_canonical_unit_detects_canonical() { - let price = RadrootsCoreQuantityPrice::new( - common::money("1.00", "USD"), - common::qty("1", RadrootsCoreUnit::MassG), + assert_eq!( + Error::NegativeRequestedQuantity.to_string(), + "requested quantity must not be negative" ); - assert!(price.is_price_per_canonical_unit()); - - let price = RadrootsCoreQuantityPrice::new( - common::money("1.00", "USD"), - common::qty("1", RadrootsCoreUnit::MassKg), + assert_eq!( + Error::ArithmeticOverflow.to_string(), + "price arithmetic overflow" ); - assert!(!price.is_price_per_canonical_unit()); } diff --git a/crates/core/tests/serde.rs b/crates/core/tests/serde.rs @@ -3,26 +3,27 @@ mod common; use core::str::FromStr; +use std::collections::BTreeSet; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCorePercent, - RadrootsCoreQuantity, RadrootsCoreUnit, -}; +use radroots_core::{Currency, Decimal, Money, Percent, Quantity, QuantityPrice, Unit}; use serde_json::Value; +const SERIALIZATION_VECTORS: &str = + include_str!("../../../contracts/conformance/vectors/core/value_serialization.v1.json"); + #[test] fn decimal_serializes_as_string() { let d = common::dec("1.2300"); let json = serde_json::to_string(&d).unwrap(); assert_eq!(json, "\"1.23\""); - let back: RadrootsCoreDecimal = serde_json::from_str(&json).unwrap(); + let back: Decimal = serde_json::from_str(&json).unwrap(); assert_eq!(back, common::dec("1.23")); } #[test] fn quantity_uses_decimal_str_and_omits_empty_label() { - let q = RadrootsCoreQuantity::new(common::dec("1.2300"), RadrootsCoreUnit::MassKg); + let q = Quantity::try_new(common::dec("1.2300"), Unit::MassKg).unwrap(); let value = serde_json::to_value(&q).unwrap(); assert_eq!(value["amount"], Value::String("1.23".to_string())); @@ -33,34 +34,104 @@ fn quantity_uses_decimal_str_and_omits_empty_label() { #[test] fn quantity_deserializes_decimal_str_via_serde_ext() { let raw = r#"{"amount":"1.2300","unit":"kg","label":"bag"}"#; - let q: RadrootsCoreQuantity = serde_json::from_str(raw).unwrap(); - assert_eq!(q.amount, common::dec("1.23")); - assert_eq!(q.unit, RadrootsCoreUnit::MassKg); - assert_eq!(q.label.as_deref(), Some("bag")); + let q: Quantity = serde_json::from_str(raw).unwrap(); + assert_eq!(q.amount(), common::dec("1.23")); + assert_eq!(q.unit(), Unit::MassKg); + assert_eq!(q.label(), Some("bag")); } #[test] fn quantity_rejects_non_string_decimal_amount() { let raw = r#"{"amount":1.23,"unit":"kg"}"#; - let err = serde_json::from_str::<RadrootsCoreQuantity>(raw).unwrap_err(); + let err = serde_json::from_str::<Quantity>(raw).unwrap_err(); assert!(err.to_string().contains("invalid type")); } #[test] fn money_and_percent_roundtrip_with_strings() { - let money = RadrootsCoreMoney::new(common::dec("2.50"), RadrootsCoreCurrency::USD); + let money = Money::try_new(common::dec("2.50"), Currency::USD).unwrap(); let value = serde_json::to_value(&money).unwrap(); assert_eq!(value["amount"], Value::String("2.5".to_string())); assert_eq!(value["currency"], Value::String("USD".to_string())); - let pct = RadrootsCorePercent::new(common::dec("12.5")); + let pct = Percent::new(common::dec("12.5")); let value = serde_json::to_value(&pct).unwrap(); assert_eq!(value["value"], Value::String("12.5".to_string())); } #[test] +fn native_value_deserialization_enforces_invariants() { + let negative_money = r#"{"amount":"-0.01","currency":"USD"}"#; + assert!( + serde_json::from_str::<Money>(negative_money) + .unwrap_err() + .to_string() + .contains("money amount") + ); + + let negative_quantity = r#"{"amount":"-1","unit":"each"}"#; + assert!( + serde_json::from_str::<Quantity>(negative_quantity) + .unwrap_err() + .to_string() + .contains("quantity amount") + ); + + let zero_price = + r#"{"amount":{"amount":"1","currency":"USD"},"quantity":{"amount":"0","unit":"each"}}"#; + assert!( + serde_json::from_str::<QuantityPrice>(zero_price) + .unwrap_err() + .to_string() + .contains("greater than zero") + ); +} + +#[test] fn currency_serializes_as_code() { - let c = RadrootsCoreCurrency::from_str("usd").unwrap(); + let c = Currency::from_str("usd").unwrap(); let json = serde_json::to_string(&c).unwrap(); assert_eq!(json, "\"USD\""); } + +#[test] +fn canonical_serialization_vectors_roundtrip_through_public_types() { + let suite: Value = serde_json::from_str(SERIALIZATION_VECTORS).unwrap(); + assert_eq!(suite["suite"], "core_value_serialization"); + assert_eq!(suite["contract_version"], "1.0.0"); + + let vectors = suite["vectors"].as_array().unwrap(); + let mut ids = BTreeSet::new(); + for vector in vectors { + let id = vector["id"].as_str().unwrap(); + assert!(ids.insert(id), "duplicate vector id {id}"); + let input = vector["input"].clone(); + let actual = match vector["kind"].as_str().unwrap() { + "core.decimal.serde" => { + serde_json::to_value(serde_json::from_value::<Decimal>(input).unwrap()).unwrap() + } + "core.currency.serde" => { + serde_json::to_value(serde_json::from_value::<Currency>(input).unwrap()).unwrap() + } + "core.money.serde" => { + serde_json::to_value(serde_json::from_value::<Money>(input).unwrap()).unwrap() + } + "core.percent.serde" => { + serde_json::to_value(serde_json::from_value::<Percent>(input).unwrap()).unwrap() + } + "core.quantity.serde" => { + serde_json::to_value(serde_json::from_value::<Quantity>(input).unwrap()).unwrap() + } + "core.unit.serde" => { + serde_json::to_value(serde_json::from_value::<Unit>(input).unwrap()).unwrap() + } + "core.quantity_price.serde" => { + serde_json::to_value(serde_json::from_value::<QuantityPrice>(input).unwrap()) + .unwrap() + } + kind => panic!("unsupported serialization vector kind {kind}"), + }; + assert_eq!(actual, vector["expected"], "vector {id}"); + } + assert_eq!(ids.len(), 9); +} diff --git a/crates/core/tests/unit.rs b/crates/core/tests/unit.rs @@ -3,14 +3,16 @@ mod common; use core::str::FromStr; use radroots_core::{ - RadrootsCoreUnit, RadrootsCoreUnitConvertError, RadrootsCoreUnitParseError, - convert_mass_decimal, convert_unit_decimal, convert_volume_decimal, parse_mass_unit, - parse_volume_unit, + Decimal, Unit, + unit::{ + ConvertError, ParseError, convert_mass_decimal, convert_unit_decimal, + convert_volume_decimal, parse_mass_unit, parse_volume_unit, + }, }; #[test] fn parses_units_and_synonyms() { - use RadrootsCoreUnit::*; + use Unit::*; let cases = [ ("each", Each), ("ea", Each), @@ -29,31 +31,28 @@ fn parses_units_and_synonyms() { ("milliliters", VolumeMl), ]; for (input, expected) in cases { - assert_eq!(RadrootsCoreUnit::from_str(input).unwrap(), expected); + assert_eq!(Unit::from_str(input).unwrap(), expected); } } #[test] fn rejects_unknown_units() { - assert_eq!( - RadrootsCoreUnit::from_str("unknown"), - Err(RadrootsCoreUnitParseError::UnknownUnit) - ); + assert_eq!(Unit::from_str("unknown"), Err(ParseError::UnknownUnit)); } #[test] fn same_dimension_matches_mass_and_volume_groups() { - use RadrootsCoreUnit::*; - assert!(RadrootsCoreUnit::same_dimension(MassKg, MassG)); - assert!(RadrootsCoreUnit::same_dimension(VolumeL, VolumeMl)); - assert!(RadrootsCoreUnit::same_dimension(Each, Each)); - assert!(!RadrootsCoreUnit::same_dimension(MassKg, VolumeL)); - assert!(!RadrootsCoreUnit::same_dimension(Each, MassG)); + use Unit::*; + assert!(Unit::same_dimension(MassKg, MassG)); + assert!(Unit::same_dimension(VolumeL, VolumeMl)); + assert!(Unit::same_dimension(Each, Each)); + assert!(!Unit::same_dimension(MassKg, VolumeL)); + assert!(!Unit::same_dimension(Each, MassG)); } #[test] fn canonical_unit_maps_dimensions() { - use RadrootsCoreUnit::*; + use Unit::*; assert_eq!(Each.canonical_unit(), Each); assert_eq!(MassKg.canonical_unit(), MassG); assert_eq!(MassLb.canonical_unit(), MassG); @@ -62,7 +61,7 @@ fn canonical_unit_maps_dimensions() { #[test] fn code_and_predicate_helpers_cover_all_variants() { - use RadrootsCoreUnit::*; + use Unit::*; assert_eq!(Each.code(), "each"); assert_eq!(MassKg.code(), "kg"); assert_eq!(MassG.code(), "g"); @@ -81,33 +80,21 @@ fn code_and_predicate_helpers_cover_all_variants() { #[test] fn parse_mass_unit_enforces_mass_only() { - assert_eq!(parse_mass_unit("kg"), Ok(RadrootsCoreUnit::MassKg)); - assert_eq!( - parse_mass_unit("each"), - Err(RadrootsCoreUnitParseError::NotAMassUnit) - ); - assert_eq!( - parse_mass_unit("bogus"), - Err(RadrootsCoreUnitParseError::UnknownUnit) - ); + assert_eq!(parse_mass_unit("kg"), Ok(Unit::MassKg)); + assert_eq!(parse_mass_unit("each"), Err(ParseError::NotAMassUnit)); + assert_eq!(parse_mass_unit("bogus"), Err(ParseError::UnknownUnit)); } #[test] fn parse_volume_unit_enforces_volume_only() { - assert_eq!(parse_volume_unit("l"), Ok(RadrootsCoreUnit::VolumeL)); - assert_eq!( - parse_volume_unit("kg"), - Err(RadrootsCoreUnitParseError::NotAVolumeUnit) - ); - assert_eq!( - parse_volume_unit("bogus"), - Err(RadrootsCoreUnitParseError::UnknownUnit) - ); + assert_eq!(parse_volume_unit("l"), Ok(Unit::VolumeL)); + assert_eq!(parse_volume_unit("kg"), Err(ParseError::NotAVolumeUnit)); + assert_eq!(parse_volume_unit("bogus"), Err(ParseError::UnknownUnit)); } #[test] fn convert_mass_decimal_converts_between_mass_units() { - use RadrootsCoreUnit::*; + use Unit::*; let kg_to_g = convert_mass_decimal(common::dec("1"), MassKg, MassG).unwrap(); let g_to_kg = convert_mass_decimal(common::dec("1000"), MassG, MassKg).unwrap(); let lb_to_g = convert_mass_decimal(common::dec("1"), MassLb, MassG).unwrap(); @@ -125,38 +112,28 @@ fn convert_mass_decimal_converts_between_mass_units() { #[test] fn convert_mass_decimal_rejects_non_mass_units() { - let err = convert_mass_decimal( - common::dec("1"), - RadrootsCoreUnit::Each, - RadrootsCoreUnit::MassG, - ) - .unwrap_err(); + let err = convert_mass_decimal(common::dec("1"), Unit::Each, Unit::MassG).unwrap_err(); assert_eq!( err, - RadrootsCoreUnitConvertError::NotMassUnit { - from: RadrootsCoreUnit::Each, - to: RadrootsCoreUnit::MassG + ConvertError::NotMassUnit { + from: Unit::Each, + to: Unit::MassG } ); - let err = convert_mass_decimal( - common::dec("1"), - RadrootsCoreUnit::MassKg, - RadrootsCoreUnit::Each, - ) - .unwrap_err(); + let err = convert_mass_decimal(common::dec("1"), Unit::MassKg, Unit::Each).unwrap_err(); assert_eq!( err, - RadrootsCoreUnitConvertError::NotMassUnit { - from: RadrootsCoreUnit::MassKg, - to: RadrootsCoreUnit::Each + ConvertError::NotMassUnit { + from: Unit::MassKg, + to: Unit::Each } ); } #[test] fn convert_volume_decimal_converts_between_volume_units() { - use RadrootsCoreUnit::*; + use Unit::*; let l_to_ml = convert_volume_decimal(common::dec("1"), VolumeL, VolumeMl).unwrap(); let ml_to_l = convert_volume_decimal(common::dec("250"), VolumeMl, VolumeL).unwrap(); assert_eq!(l_to_ml, common::dec("1000")); @@ -165,38 +142,28 @@ fn convert_volume_decimal_converts_between_volume_units() { #[test] fn convert_volume_decimal_rejects_non_volume_units() { - let err = convert_volume_decimal( - common::dec("1"), - RadrootsCoreUnit::Each, - RadrootsCoreUnit::VolumeMl, - ) - .unwrap_err(); + let err = convert_volume_decimal(common::dec("1"), Unit::Each, Unit::VolumeMl).unwrap_err(); assert_eq!( err, - RadrootsCoreUnitConvertError::NotVolumeUnit { - from: RadrootsCoreUnit::Each, - to: RadrootsCoreUnit::VolumeMl + ConvertError::NotVolumeUnit { + from: Unit::Each, + to: Unit::VolumeMl } ); - let err = convert_volume_decimal( - common::dec("1"), - RadrootsCoreUnit::VolumeMl, - RadrootsCoreUnit::Each, - ) - .unwrap_err(); + let err = convert_volume_decimal(common::dec("1"), Unit::VolumeMl, Unit::Each).unwrap_err(); assert_eq!( err, - RadrootsCoreUnitConvertError::NotVolumeUnit { - from: RadrootsCoreUnit::VolumeMl, - to: RadrootsCoreUnit::Each + ConvertError::NotVolumeUnit { + from: Unit::VolumeMl, + to: Unit::Each } ); } #[test] fn convert_unit_decimal_converts_matching_dimensions() { - use RadrootsCoreUnit::*; + use Unit::*; let kg_to_g = convert_unit_decimal(common::dec("1"), MassKg, MassG).unwrap(); let l_to_ml = convert_unit_decimal(common::dec("2"), VolumeL, VolumeMl).unwrap(); let each_to_each = convert_unit_decimal(common::dec("3"), Each, Each).unwrap(); @@ -206,59 +173,92 @@ fn convert_unit_decimal_converts_matching_dimensions() { } #[test] -fn display_paths_for_unit_and_errors_are_exercised() { - assert_eq!(RadrootsCoreUnit::MassOz.to_string(), "oz"); +fn conversions_report_overflow_without_panicking() { assert_eq!( - RadrootsCoreUnitParseError::UnknownUnit.to_string(), - "unknown unit string" + convert_mass_decimal(Decimal::MAX, Unit::MassKg, Unit::MassG), + Err(ConvertError::ArithmeticOverflow { + from: Unit::MassKg, + to: Unit::MassG, + }) ); assert_eq!( - RadrootsCoreUnitParseError::NotAMassUnit.to_string(), + convert_volume_decimal(Decimal::MAX, Unit::VolumeL, Unit::VolumeMl), + Err(ConvertError::ArithmeticOverflow { + from: Unit::VolumeL, + to: Unit::VolumeMl, + }) + ); +} + +#[test] +fn exact_conversion_pairs_roundtrip() { + let cases = [ + (common::dec("12.5"), Unit::MassKg, Unit::MassG), + (common::dec("3"), Unit::MassLb, Unit::MassG), + (common::dec("7"), Unit::MassOz, Unit::MassG), + (common::dec("1.25"), Unit::VolumeL, Unit::VolumeMl), + ]; + for (amount, from, to) in cases { + let converted = convert_unit_decimal(amount, from, to).unwrap(); + let roundtrip = convert_unit_decimal(converted, to, from).unwrap(); + assert_eq!(roundtrip, amount, "roundtrip for {from} -> {to}"); + } +} + +#[test] +fn display_paths_for_unit_and_errors_are_exercised() { + assert_eq!(Unit::MassOz.to_string(), "oz"); + assert_eq!(ParseError::UnknownUnit.to_string(), "unknown unit string"); + assert_eq!( + ParseError::NotAMassUnit.to_string(), "unit is not a mass unit" ); assert_eq!( - RadrootsCoreUnitParseError::NotAVolumeUnit.to_string(), + ParseError::NotAVolumeUnit.to_string(), "unit is not a volume unit" ); assert_eq!( - RadrootsCoreUnitConvertError::NotMassUnit { - from: RadrootsCoreUnit::Each, - to: RadrootsCoreUnit::MassG + ConvertError::NotMassUnit { + from: Unit::Each, + to: Unit::MassG } .to_string(), "unit conversion requires mass units: each -> g" ); assert_eq!( - RadrootsCoreUnitConvertError::NotVolumeUnit { - from: RadrootsCoreUnit::Each, - to: RadrootsCoreUnit::VolumeL + ConvertError::NotVolumeUnit { + from: Unit::Each, + to: Unit::VolumeL } .to_string(), "unit conversion requires volume units: each -> l" ); assert_eq!( - RadrootsCoreUnitConvertError::NotConvertibleUnits { - from: RadrootsCoreUnit::Each, - to: RadrootsCoreUnit::MassG + ConvertError::NotConvertibleUnits { + from: Unit::Each, + to: Unit::MassG } .to_string(), "unit conversion requires matching dimensions: each -> g" ); + assert_eq!( + ConvertError::ArithmeticOverflow { + from: Unit::MassKg, + to: Unit::MassG, + } + .to_string(), + "unit conversion arithmetic overflow: kg -> g" + ); } #[test] fn convert_unit_decimal_rejects_mismatched_dimensions() { - let err = convert_unit_decimal( - common::dec("1"), - RadrootsCoreUnit::Each, - RadrootsCoreUnit::MassG, - ) - .unwrap_err(); + let err = convert_unit_decimal(common::dec("1"), Unit::Each, Unit::MassG).unwrap_err(); assert_eq!( err, - RadrootsCoreUnitConvertError::NotConvertibleUnits { - from: RadrootsCoreUnit::Each, - to: RadrootsCoreUnit::MassG + ConvertError::NotConvertibleUnits { + from: Unit::Each, + to: Unit::MassG } ); } @@ -266,19 +266,19 @@ fn convert_unit_decimal_rejects_mismatched_dimensions() { #[cfg(feature = "serde")] #[test] fn serde_roundtrip_for_unit_paths() { - use RadrootsCoreUnit::*; + use Unit::*; let all = [Each, MassKg, MassG, MassOz, MassLb, VolumeL, VolumeMl]; for unit in all { let json = serde_json::to_string(&unit).unwrap(); - let back: RadrootsCoreUnit = serde_json::from_str(&json).unwrap(); + let back: Unit = serde_json::from_str(&json).unwrap(); assert_eq!(back, unit); } - let back: RadrootsCoreUnit = serde_json::from_str("\"kg\"").unwrap(); - assert_eq!(back, RadrootsCoreUnit::MassKg); - let unknown_err = serde_json::from_str::<RadrootsCoreUnit>("\"bogus\"").unwrap_err(); + let back: Unit = serde_json::from_str("\"kg\"").unwrap(); + assert_eq!(back, Unit::MassKg); + let unknown_err = serde_json::from_str::<Unit>("\"bogus\"").unwrap_err(); assert!(unknown_err.to_string().contains("unknown unit")); - let err = serde_json::from_str::<RadrootsCoreUnit>("123").unwrap_err(); + let err = serde_json::from_str::<Unit>("123").unwrap_err(); assert!(err.to_string().contains("invalid type")); - let missing_err = serde_json::from_str::<RadrootsCoreUnit>("{}").unwrap_err(); + let missing_err = serde_json::from_str::<Unit>("{}").unwrap_err(); assert!(!missing_err.to_string().is_empty()); } diff --git a/crates/event/Cargo.toml b/crates/event/Cargo.toml @@ -1,36 +1,46 @@ [package] name = "radroots_event" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] +authors.workspace = true rust-version.workspace = true license.workspace = true description = "Domain event model for Radroots" repository.workspace = true homepage.workspace = true documentation = "https://docs.rs/radroots_event" -readme = "README" +readme = "README.md" + +[lib] +name = "radroots_event" [features] default = ["std", "serde"] -dto-bindgen = [ - "std", - "serde", - "knowledge-nip54", - "dep:dto_bindgen", - "radroots_core/dto-bindgen", -] +dto-bindgen = ["std", "serde", "knowledge-nip54", "dep:dto_bindgen"] knowledge = [] knowledge-nip54 = ["knowledge"] signature = ["dep:secp256k1"] -std = ["radroots_blossom/std", "radroots_core/std", "url_nostd/std"] -serde = ["dep:serde", "radroots_core/serde"] +std = [ + "radroots_blossom/std", + "radroots_core/std", + "radroots_identity/std", + "radroots_protocol/std", + "url_nostd/std", +] +serde = [ + "dep:serde", + "radroots_core/serde", + "radroots_identity/serde", + "radroots_protocol/serde", +] [dependencies] dto_bindgen = { workspace = true, optional = true } radroots_blossom = { workspace = true, default-features = false } radroots_core = { workspace = true, default-features = false } +radroots_identity = { workspace = true, default-features = false } +radroots_protocol = { workspace = true, default-features = false } hex = { version = "0.4", default-features = false, features = ["alloc"] } jiff-tzdb = { workspace = true } serde = { workspace = true, default-features = false, features = [ @@ -55,5 +65,5 @@ serde = { workspace = true, default-features = false, features = [ "derive", ] } -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } +[lints] +workspace = true diff --git a/crates/event/README b/crates/event/README.md diff --git a/crates/event/src/admission.rs b/crates/event/src/admission.rs @@ -0,0 +1,7 @@ +//! Event admission and verification-state model. +//! +//! The final typestate surface is introduced by the ordered event refactor. + +/// Internal marker for the target module while its typestates are introduced. +#[allow(dead_code)] +pub(crate) struct ModuleScaffold; diff --git a/crates/event/src/calendar.rs b/crates/event/src/calendar.rs @@ -8,15 +8,15 @@ use alloc::{ use core::{fmt, ops::RangeInclusive, str::FromStr}; use crate::ids::{ - RadrootsAddressableCoordinate, RadrootsAddressableCoordinateParts, RadrootsDTag, - RadrootsEventId, RadrootsPublicKey, RadrootsRelayUrl, + PublicKey, RadrootsAddressableCoordinate, RadrootsAddressableCoordinateParts, RadrootsDTag, + RadrootsEventId, RadrootsRelayUrl, parse_public_key, }; use crate::media::RadrootsAuthoredImage; use crate::wire::v1::{ DEFAULT_CONTENT_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES, DEFAULT_TAG_MAX_COUNT, DEFAULT_TAG_TOTAL_MAX_BYTES, }; -use radroots_blossom::url::RadrootsBlossomBlobUrl; +use radroots_blossom::BlobUrl; use url_nostd::Url; pub const RADROOTS_CALENDAR_SECONDS_PER_DAY: u64 = 86_400; @@ -281,7 +281,7 @@ impl serde::Serialize for RadrootsCalendarUid { pub struct RadrootsCalendarEventReference { coordinate: RadrootsAddressableCoordinate, kind: u32, - author: RadrootsPublicKey, + author: PublicKey, d_tag: RadrootsDTag, relay: Option<String>, } @@ -322,7 +322,7 @@ impl RadrootsCalendarEventReference { self.kind } - pub fn author(&self) -> &RadrootsPublicKey { + pub fn author(&self) -> &PublicKey { &self.author } @@ -400,7 +400,7 @@ impl RadrootsCalendarEventRevisionReference { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsCalendarEventAuthorReference { raw_pubkey: String, - pubkey: RadrootsPublicKey, + pubkey: PublicKey, relay: Option<String>, } @@ -410,7 +410,7 @@ impl RadrootsCalendarEventAuthorReference { relay: Option<&str>, ) -> Result<Self, RadrootsCalendarEventError> { let raw_pubkey = pubkey.as_ref(); - let pubkey = RadrootsPublicKey::parse(raw_pubkey) + let pubkey = parse_public_key(raw_pubkey) .map_err(|_| RadrootsCalendarEventError::InvalidAuthorReference)?; let relay = parse_calendar_reference_relay( relay, @@ -427,7 +427,7 @@ impl RadrootsCalendarEventAuthorReference { &self.raw_pubkey } - pub fn pubkey(&self) -> &RadrootsPublicKey { + pub fn pubkey(&self) -> &PublicKey { &self.pubkey } @@ -437,7 +437,7 @@ impl RadrootsCalendarEventAuthorReference { pub fn is_canonical(&self) -> bool { // Relay hints use strict Radroots syntax; their raw URL spelling is not normalized. - self.raw_pubkey == self.pubkey.as_str() + self.raw_pubkey == self.pubkey.to_hex() && self .relay() .is_none_or(|relay| RadrootsRelayUrl::parse(relay).is_ok()) @@ -743,7 +743,7 @@ impl RadrootsParsedNip52Calendar { pub struct RadrootsAdmittedCalendar { parsed: RadrootsParsedNip52Calendar, uid: RadrootsCalendarUid, - blossom_image: Option<RadrootsBlossomBlobUrl>, + blossom_image: Option<BlobUrl>, } impl RadrootsAdmittedCalendar { @@ -778,7 +778,7 @@ impl RadrootsAdmittedCalendar { let blossom_image = parsed .image() .map(|image| { - RadrootsBlossomBlobUrl::parse(image.as_str()) + BlobUrl::parse(image.as_str()) .map_err(|_| RadrootsCalendarAdmissionError::NonBlossomImage) }) .transpose()?; @@ -813,7 +813,7 @@ impl RadrootsAdmittedCalendar { self.parsed.list_description() } - pub fn blossom_image(&self) -> Option<&RadrootsBlossomBlobUrl> { + pub fn blossom_image(&self) -> Option<&BlobUrl> { self.blossom_image.as_ref() } } @@ -1744,7 +1744,7 @@ impl std::error::Error for RadrootsCalendarAdmissionError {} pub struct RadrootsAdmittedCalendarDateEvent { parsed: RadrootsParsedNip52CalendarDateEvent, d_tag: RadrootsDTag, - blossom_image: Option<RadrootsBlossomBlobUrl>, + blossom_image: Option<BlobUrl>, } impl RadrootsAdmittedCalendarDateEvent { @@ -1772,7 +1772,7 @@ impl RadrootsAdmittedCalendarDateEvent { &self.d_tag } - pub fn blossom_image(&self) -> Option<&RadrootsBlossomBlobUrl> { + pub fn blossom_image(&self) -> Option<&BlobUrl> { self.blossom_image.as_ref() } } @@ -1782,7 +1782,7 @@ pub struct RadrootsAdmittedCalendarTimeEvent { parsed: RadrootsParsedNip52CalendarTimeEvent, d_tag: RadrootsDTag, covered_utc_days: Vec<u64>, - blossom_image: Option<RadrootsBlossomBlobUrl>, + blossom_image: Option<BlobUrl>, } impl RadrootsAdmittedCalendarTimeEvent { @@ -1839,7 +1839,7 @@ impl RadrootsAdmittedCalendarTimeEvent { &self.covered_utc_days } - pub fn blossom_image(&self) -> Option<&RadrootsBlossomBlobUrl> { + pub fn blossom_image(&self) -> Option<&BlobUrl> { self.blossom_image.as_ref() } } @@ -2065,7 +2065,7 @@ fn validate_inbound_calendar_participants( participants: &[RadrootsCalendarParticipant], ) -> Result<(), RadrootsCalendarEventError> { for (index, participant) in participants.iter().enumerate() { - if RadrootsPublicKey::parse(&participant.pubkey).is_err() + if parse_public_key(&participant.pubkey).is_err() || participant .relay .as_deref() @@ -2092,9 +2092,9 @@ fn validate_authored_calendar_participants( } validate_inbound_calendar_participants(participants)?; for (index, participant) in participants.iter().enumerate() { - let pubkey = RadrootsPublicKey::parse(&participant.pubkey) + let pubkey = parse_public_key(&participant.pubkey) .map_err(|_| RadrootsCalendarEventError::InvalidParticipant { index })?; - if pubkey.as_str() != participant.pubkey + if pubkey.to_hex() != participant.pubkey || participant .relay .as_deref() @@ -2159,12 +2159,12 @@ fn validate_admitted_calendar_common( fn admitted_blossom_image( common: &RadrootsParsedNip52CalendarCommon, -) -> Result<Option<RadrootsBlossomBlobUrl>, RadrootsCalendarAdmissionError> { +) -> Result<Option<BlobUrl>, RadrootsCalendarAdmissionError> { common .image .as_ref() .map(|image| { - RadrootsBlossomBlobUrl::parse(image.as_str()) + BlobUrl::parse(image.as_str()) .map_err(|_| RadrootsCalendarAdmissionError::NonBlossomImage) }) .transpose() @@ -2973,7 +2973,7 @@ mod tests { event.coordinate().as_str(), format!("031923:{uppercase_author}:wash-pack") ); - assert_eq!(event.author().as_str(), "a".repeat(64)); + assert_eq!(event.author().to_hex(), "a".repeat(64)); assert_eq!(event.relay(), Some("WSS://Relay.Example/events")); assert!(!event.is_canonical()); @@ -2992,7 +2992,7 @@ mod tests { ) .unwrap(); assert_eq!(author.raw_pubkey(), uppercase_author); - assert_eq!(author.pubkey().as_str(), "a".repeat(64)); + assert_eq!(author.pubkey().to_hex(), "a".repeat(64)); assert!(!author.is_canonical()); assert_eq!( @@ -3211,7 +3211,9 @@ mod tests { fn rsvp_author_hint_must_match_event_coordinate_in_strict_layers() { let uid = RadrootsCalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAQ").unwrap(); let event = canonical_event_reference("wash-pack"); - let mismatched = RadrootsCalendarEventAuthorReference::parse("b".repeat(64), None).unwrap(); + let mismatched = + RadrootsCalendarEventAuthorReference::parse(crate::test_valid_hex_64('b'), None) + .unwrap(); assert_eq!( RadrootsAuthoredCalendarEventRsvp::new( uid.clone(), diff --git a/crates/event/src/comment.rs b/crates/event/src/comment.rs @@ -7,8 +7,8 @@ use core::fmt; use crate::{ ids::{ - RadrootsAddressableCoordinate, RadrootsAddressableCoordinateParts, RadrootsEventId, - RadrootsIdParseError, RadrootsPublicKey, + PublicKey, RadrootsAddressableCoordinate, RadrootsAddressableCoordinateParts, + RadrootsEventId, RadrootsIdParseError, parse_public_key, }, kinds::{ KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_CLASSIFIED_LISTING, KIND_COMMENT, @@ -225,7 +225,7 @@ impl TryFrom<u32> for RadrootsNip22CommentRootKind { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsNip22EventRootReference { event_id: RadrootsEventId, - author: RadrootsPublicKey, + author: PublicKey, kind: RadrootsNip22CommentRootKind, relay: Option<RadrootsNostrRelayHint>, } @@ -233,7 +233,7 @@ pub struct RadrootsNip22EventRootReference { impl RadrootsNip22EventRootReference { pub fn new( event_id: RadrootsEventId, - author: RadrootsPublicKey, + author: PublicKey, kind: RadrootsNip22CommentRootKind, relay: Option<RadrootsNostrRelayHint>, ) -> Result<Self, RadrootsNip22CommentError> { @@ -255,8 +255,7 @@ impl RadrootsNip22EventRootReference { Self::new( RadrootsEventId::parse(event_id) .map_err(RadrootsNip22CommentError::RootEventIdInvalid)?, - RadrootsPublicKey::parse(author) - .map_err(RadrootsNip22CommentError::RootAuthorInvalid)?, + parse_public_key(author).map_err(RadrootsNip22CommentError::RootAuthorInvalid)?, RadrootsNip22CommentRootKind::parse(kind)?, parse_optional_relay(relay)?, ) @@ -266,7 +265,7 @@ impl RadrootsNip22EventRootReference { &self.event_id } - pub const fn author(&self) -> &RadrootsPublicKey { + pub const fn author(&self) -> &PublicKey { &self.author } @@ -291,7 +290,7 @@ impl RadrootsNip22EventRootReference { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsNip22AddressRootReference { coordinate: RadrootsAddressableCoordinate, - author: RadrootsPublicKey, + author: PublicKey, kind: RadrootsNip22CommentRootKind, relay: Option<RadrootsNostrRelayHint>, } @@ -336,7 +335,7 @@ impl RadrootsNip22AddressRootReference { &self.coordinate } - pub const fn author(&self) -> &RadrootsPublicKey { + pub const fn author(&self) -> &PublicKey { &self.author } @@ -361,14 +360,14 @@ impl RadrootsNip22AddressRootReference { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsNip22CommentParentReference { event_id: RadrootsEventId, - author: RadrootsPublicKey, + author: PublicKey, relay: Option<RadrootsNostrRelayHint>, } impl RadrootsNip22CommentParentReference { pub fn new( event_id: RadrootsEventId, - author: RadrootsPublicKey, + author: PublicKey, relay: Option<RadrootsNostrRelayHint>, ) -> Result<Self, RadrootsNip22CommentError> { validate_optional_relay(&relay)?; @@ -387,8 +386,7 @@ impl RadrootsNip22CommentParentReference { Self::new( RadrootsEventId::parse(event_id) .map_err(RadrootsNip22CommentError::ParentEventIdInvalid)?, - RadrootsPublicKey::parse(author) - .map_err(RadrootsNip22CommentError::ParentAuthorInvalid)?, + parse_public_key(author).map_err(RadrootsNip22CommentError::ParentAuthorInvalid)?, parse_optional_relay(relay)?, ) } @@ -397,7 +395,7 @@ impl RadrootsNip22CommentParentReference { &self.event_id } - pub const fn author(&self) -> &RadrootsPublicKey { + pub const fn author(&self) -> &PublicKey { &self.author } @@ -424,7 +422,7 @@ impl RadrootsNip22CommentRoot { } } - pub const fn author(&self) -> &RadrootsPublicKey { + pub const fn author(&self) -> &PublicKey { match self { Self::Event(reference) => reference.author(), Self::Address(reference) => reference.author(), @@ -631,7 +629,7 @@ fn validate_authored_comment_wire_size( "E", reference.event_id().as_str(), reference.relay_or_empty(), - reference.author().as_str(), + reference.author().to_hex().as_str(), ], ); 8usize + usize::from(reference.relay().is_some()) @@ -659,7 +657,7 @@ fn validate_authored_comment_wire_size( &mut tags_json_bytes, &mut tag_count, "P", - root.author().as_str(), + root.author().to_hex().as_str(), root.relay(), ); @@ -676,7 +674,7 @@ fn validate_authored_comment_wire_size( "e", reference.event_id().as_str(), reference.relay_or_empty(), - reference.author().as_str(), + reference.author().to_hex().as_str(), ], ); add_tag( @@ -690,7 +688,7 @@ fn validate_authored_comment_wire_size( &mut tags_json_bytes, &mut tag_count, "p", - reference.author().as_str(), + reference.author().to_hex().as_str(), reference.relay(), ); 8usize + usize::from(reference.relay().is_some()) @@ -726,7 +724,7 @@ fn validate_authored_comment_wire_size( &mut tags_json_bytes, &mut tag_count, "p", - reference.author().as_str(), + reference.author().to_hex().as_str(), reference.relay(), ); 8usize + 3 * usize::from(reference.relay().is_some()) @@ -740,7 +738,7 @@ fn validate_authored_comment_wire_size( "e", parent.event_id().as_str(), parent.relay_or_empty(), - parent.author().as_str(), + parent.author().to_hex().as_str(), ], ); add_tag( @@ -754,7 +752,7 @@ fn validate_authored_comment_wire_size( &mut tags_json_bytes, &mut tag_count, "p", - parent.author().as_str(), + parent.author().to_hex().as_str(), parent.relay(), ); 8usize + usize::from(parent.relay().is_some()) @@ -851,7 +849,7 @@ mod tests { fn event_root(kind: u32) -> RadrootsNip22EventRootReference { RadrootsNip22EventRootReference::parse( "a".repeat(64), - "b".repeat(64), + crate::test_valid_hex_64('b'), kind, Some("wss://relay.example"), ) @@ -860,7 +858,7 @@ mod tests { fn address_root(kind: u32) -> RadrootsNip22AddressRootReference { RadrootsNip22AddressRootReference::parse( - format!("{kind}:{}:victoria-market", "b".repeat(64)), + format!("{kind}:{}:victoria-market", crate::test_valid_hex_64('b')), Some("wss://relay.example"), ) .expect("address root") @@ -903,7 +901,7 @@ mod tests { assert!(matches!( RadrootsNip22EventRootReference::parse( "a".repeat(64), - "b".repeat(64), + crate::test_valid_hex_64('b'), kind, None ), @@ -927,15 +925,18 @@ mod tests { #[test] fn canonicalizes_address_author_and_requires_current_revision() { let root = RadrootsNip22AddressRootReference::parse( - format!("30402:{}:listing", "B".repeat(64)), + format!("30402:{}:listing", crate::test_valid_hex_64('B')), None, ) .expect("address root"); assert_eq!( root.coordinate().as_str(), - format!("30402:{}:listing", "b".repeat(64)) + format!("30402:{}:listing", crate::test_valid_hex_64('b')) + ); + assert_eq!( + root.author().to_hex().as_str(), + crate::test_valid_hex_64('b') ); - assert_eq!(root.author().as_str(), "b".repeat(64)); let comment = RadrootsAuthoredNip22Comment::parse_top_level_address("Comment", root, "E".repeat(64)) @@ -951,13 +952,17 @@ mod tests { fn address_root_rechecks_the_canonical_coordinate_tag_element() { let maximum_d_tag = "x".repeat(512); let root = RadrootsNip22AddressRootReference::parse( - format!("30402:{}:{maximum_d_tag}", "b".repeat(64)), + format!("30402:{}:{maximum_d_tag}", crate::test_valid_hex_64('b')), None, ) .expect("maximum public d tag fits the tag-element budget"); assert!(root.coordinate().as_str().len() <= RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES); - let oversized = format!("30402:{}:{}", "b".repeat(64), "x".repeat(513)); + let oversized = format!( + "30402:{}:{}", + crate::test_valid_hex_64('b'), + "x".repeat(513) + ); assert!(matches!( RadrootsNip22AddressRootReference::parse(oversized, None), Err(RadrootsNip22CommentError::RootCoordinateInvalid( @@ -975,7 +980,7 @@ mod tests { assert!(matches!( RadrootsNip22EventRootReference::parse( "a".repeat(64), - "b".repeat(64), + crate::test_valid_hex_64('b'), KIND_CLASSIFIED_LISTING, Some(&oversized_noncanonical_relay), ), @@ -1010,7 +1015,7 @@ mod tests { ); RadrootsNip22EventRootReference::parse( "a".repeat(64), - "b".repeat(64), + crate::test_valid_hex_64('b'), KIND_CLASSIFIED_LISTING, Some(&exact_relay), ) @@ -1019,7 +1024,7 @@ mod tests { assert!(matches!( RadrootsNip22EventRootReference::parse( "a".repeat(64), - "b".repeat(64), + crate::test_valid_hex_64('b'), KIND_CLASSIFIED_LISTING, Some(&overflow) ), diff --git a/crates/event/src/contract/registry_v7.rs b/crates/event/src/contract/registry_v7.rs @@ -17,11 +17,11 @@ use crate::{ envelope::RadrootsEventEnvelope, ids::{ RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsNip01Coordinate, - RadrootsPublicKey, relay_url_is_valid, + parse_public_key, relay_url_is_valid, }, kinds::*, }; -use radroots_blossom::url::RadrootsBlossomBlobUrl; +use radroots_blossom::BlobUrl; pub const RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 7; @@ -4175,7 +4175,7 @@ fn tag_value_is_valid_in_registry( RadrootsTagValueType::IanaTimeZoneId => RadrootsIanaTimeZoneId::parse(value).is_ok(), RadrootsTagValueType::Kind => value.parse::<u32>().is_ok(), RadrootsTagValueType::Nip01Coordinate => RadrootsNip01Coordinate::parse(value).is_ok(), - RadrootsTagValueType::PublicKey => RadrootsPublicKey::parse(value).is_ok(), + RadrootsTagValueType::PublicKey => parse_public_key(value).is_ok(), RadrootsTagValueType::RelayUrl => relay_url_is_valid(value), RadrootsTagValueType::Text => visible_text_is_valid(value), RadrootsTagValueType::UnixTimestamp => value.parse::<u64>().is_ok(), @@ -4195,7 +4195,7 @@ fn event_pointer_tag_is_valid(tag: &[String]) -> bool { let kind = tag[3].as_str(); let d_tag = tag[4].as_str(); RadrootsEventId::parse(id).is_ok() - && RadrootsPublicKey::parse(author).is_ok() + && parse_public_key(author).is_ok() && kind.parse::<u32>().is_ok() && (d_tag.is_empty() || RadrootsDTag::parse(d_tag).is_ok()) && tag @@ -4335,9 +4335,9 @@ fn validate_calendar_rsvp_contract( }) .map(|parts| parts.pubkey); if let Some(author_hint) = tag_value(tags, "p") { - let hint = RadrootsPublicKey::parse(author_hint).ok(); + let hint = parse_public_key(author_hint).ok(); if hint.as_ref() != event_author.as_ref() - || hint.as_ref().is_none_or(|key| key.as_str() != author_hint) + || hint.as_ref().is_none_or(|key| key.to_hex() != author_hint) { return Err(calendar_tag_mismatch( contract, @@ -4390,7 +4390,7 @@ fn validate_calendar_rsvp_pointer_tag( if event_id { RadrootsEventId::parse(value).is_ok_and(|parsed| parsed.as_str() == value) } else { - RadrootsPublicKey::parse(value).is_ok_and(|parsed| parsed.as_str() == value) + parse_public_key(value).is_ok_and(|parsed| parsed.to_hex() == *value) } }); let relay_is_valid = tag @@ -4443,7 +4443,7 @@ fn validate_calendar_blossom_image( contract: &RadrootsEventContract, ) -> Result<(), RadrootsContractValidationError> { if let Some(image) = tag_value(tags, "image") - && RadrootsBlossomBlobUrl::parse(image).is_err() + && BlobUrl::parse(image).is_err() { return Err(calendar_tag_mismatch( contract, @@ -4605,7 +4605,7 @@ fn validate_calendar_participant_tags( .filter(|tag| tag.first().map(String::as_str) == Some("p")) { let pubkey_is_canonical = tag.get(1).is_some_and(|value| { - RadrootsPublicKey::parse(value).is_ok_and(|pubkey| pubkey.as_str() == value.as_str()) + parse_public_key(value).is_ok_and(|pubkey| pubkey.to_hex() == *value) }); let relay_is_valid = tag .get(2) @@ -4669,7 +4669,7 @@ fn canonical_calendar_coordinate_is_valid(value: &str) -> bool { let Ok(parts) = crate::ids::RadrootsAddressableCoordinateParts::parse(value) else { return false; }; - kind == "31924" && pubkey == parts.pubkey.as_str() && d_tag == parts.d_tag.as_str() + kind == "31924" && pubkey == parts.pubkey.to_hex() && d_tag == parts.d_tag.as_str() } fn canonical_calendar_event_coordinate_is_valid(value: &str) -> bool { @@ -4686,7 +4686,7 @@ fn canonical_calendar_event_coordinate_is_valid(value: &str) -> bool { parts.kind, KIND_CALENDAR_DATE_EVENT | KIND_CALENDAR_TIME_EVENT ) && matches!(kind, "31922" | "31923") - && pubkey == parts.pubkey.as_str() + && pubkey == parts.pubkey.to_hex() && d_tag == parts.d_tag.as_str() } @@ -4723,7 +4723,7 @@ fn validate_canonical_calendar_common_tags( )); } if let Some(image) = tag_value(tags, "image") - && RadrootsBlossomBlobUrl::parse(image).is_err() + && BlobUrl::parse(image).is_err() { return Err(calendar_tag_mismatch( contract, diff --git a/crates/event/src/contract/registry_v7/tests.rs b/crates/event/src/contract/registry_v7/tests.rs @@ -116,7 +116,7 @@ fn synthetic_kind_contract(kind: u32) -> RadrootsKindContract { fn unsigned_event(kind: u32, tags: Vec<Vec<&str>>, content: &str) -> RadrootsEventEnvelope { RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: "0".repeat(64), - author: "1".repeat(64), + author: crate::test_valid_hex_64('1'), created_at: 1_700_000_000, kind, tags: tags @@ -132,7 +132,7 @@ fn unsigned_event(kind: u32, tags: Vec<Vec<&str>>, content: &str) -> RadrootsEve fn unsigned_event_owned(kind: u32, tags: Vec<Vec<String>>, content: &str) -> RadrootsEventEnvelope { RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: "0".repeat(64), - author: "1".repeat(64), + author: crate::test_valid_hex_64('1'), created_at: 1_700_000_000, kind, tags, @@ -143,7 +143,7 @@ fn unsigned_event_owned(kind: u32, tags: Vec<Vec<String>>, content: &str) -> Rad } fn hex_64(character: char) -> String { - core::iter::repeat_n(character, 64).collect() + crate::test_valid_hex_64(character) } fn event_ref_tag(name: &str, event_id: &str, author: &str, kind: u32) -> Vec<String> { @@ -1281,16 +1281,19 @@ fn rejects_custom_knowledge_missing_schema_version() { #[test] fn rejects_authoritative_knowledge_status_fields() { - let event = unsigned_event( + let event = unsigned_event_owned( KIND_KNOWLEDGE_REVIEW, vec![ - vec!["contract", "radroots.knowledge.review.v1"], vec![ - "review_target", - "0000000000000000000000000000000000000000000000000000000000000000", - "1111111111111111111111111111111111111111111111111111111111111111", - "30818", - "soil", + "contract".to_owned(), + "radroots.knowledge.review.v1".to_owned(), + ], + vec![ + "review_target".to_owned(), + "0".repeat(64), + crate::test_valid_hex_64('1'), + "30818".to_owned(), + "soil".to_owned(), ], ], r#"{"schema":"radroots.knowledge.review.v1","schema_version":1,"canon_status":"approved"}"#, @@ -1532,18 +1535,15 @@ fn validate_contract_tags_enforces_declared_value_types() { }) ); - let invalid_event_id = unsigned_event( + let invalid_event_id = unsigned_event_owned( KIND_WIKI_MERGE_REQUEST, vec![ vec![ - "a", - "30818:0000000000000000000000000000000000000000000000000000000000000000:soil", + "a".to_owned(), + format!("30818:{}:soil", crate::test_valid_hex_64('0')), ], - vec![ - "p", - "1111111111111111111111111111111111111111111111111111111111111111", - ], - vec!["e", "not-hex"], + vec!["p".to_owned(), crate::test_valid_hex_64('1')], + vec!["e".to_owned(), "not-hex".to_owned()], ], "", ); @@ -1861,13 +1861,13 @@ fn validate_custom_knowledge_contract_rejects_missing_schema_and_bad_version() { #[test] fn validates_nip54_empty_redirect_content() { - let event = unsigned_event( + let event = unsigned_event_owned( KIND_WIKI_REDIRECT, vec![ - vec!["d", "soil"], + vec!["d".to_owned(), "soil".to_owned()], vec![ - "a", - "30818:0000000000000000000000000000000000000000000000000000000000000000:soil", + "a".to_owned(), + format!("30818:{}:soil", crate::test_valid_hex_64('0')), ], ], "", @@ -1878,13 +1878,13 @@ fn validates_nip54_empty_redirect_content() { Ok(()) ); - let invalid = unsigned_event( + let invalid = unsigned_event_owned( KIND_WIKI_REDIRECT, vec![ - vec!["d", "soil"], + vec!["d".to_owned(), "soil".to_owned()], vec![ - "a", - "30818:0000000000000000000000000000000000000000000000000000000000000000:soil", + "a".to_owned(), + format!("30818:{}:soil", crate::test_valid_hex_64('0')), ], ], "{}", diff --git a/crates/event/src/deletion.rs b/crates/event/src/deletion.rs @@ -510,7 +510,7 @@ mod tests { ) -> RadrootsNip09DeletionAddressTarget { RadrootsNip09DeletionAddressTarget::parse(format!( "{kind}:{}:{identifier}", - character.to_string().repeat(64) + crate::test_valid_hex_64(character) )) .expect("address target") } @@ -519,7 +519,7 @@ mod tests { total_bytes: usize, index: usize, ) -> RadrootsNip09DeletionAddressTarget { - let prefix = format!("30000:{}:", "a".repeat(64)); + let prefix = format!("30000:{}:", crate::test_valid_hex_64('a')); let suffix = format!("{index:04x}"); assert!(prefix.len() + suffix.len() <= total_bytes); RadrootsNip09DeletionAddressTarget::parse(format!( @@ -559,12 +559,14 @@ mod tests { } ); - let address = - RadrootsNip09DeletionAddressTarget::parse(format!("30000:{}:", "B".repeat(64))) - .expect("address target"); + let address = RadrootsNip09DeletionAddressTarget::parse(format!( + "30000:{}:", + crate::test_valid_hex_64('B') + )) + .expect("address target"); assert_eq!( address.coordinate().as_str(), - format!("30000:{}:", "b".repeat(64)) + format!("30000:{}:", crate::test_valid_hex_64('b')) ); assert_eq!(address.kind_hint(), 30_000); @@ -632,11 +634,11 @@ mod tests { ); assert_eq!( request.address_targets()[0].coordinate().as_str(), - format!("30402:{}:produce", "b".repeat(64)) + format!("30402:{}:produce", crate::test_valid_hex_64('b')) ); assert_eq!( request.address_targets()[1].coordinate().as_str(), - format!("31923:{}:harvest", "e".repeat(64)) + format!("31923:{}:harvest", crate::test_valid_hex_64('e')) ); assert_eq!(request.kind_hints(), &[1, 30_402, 31_923]); } @@ -655,9 +657,11 @@ mod tests { } ); - let uppercase_address = - RadrootsNip09DeletionAddressTarget::parse(format!("030402:{}:produce", "A".repeat(64))) - .expect("uppercase address"); + let uppercase_address = RadrootsNip09DeletionAddressTarget::parse(format!( + "030402:{}:produce", + crate::test_valid_hex_64('A') + )) + .expect("uppercase address"); assert_eq!( RadrootsAuthoredNip09DeletionRequest::new( "", @@ -666,7 +670,7 @@ mod tests { ) .unwrap_err(), RadrootsNip09DeletionError::DuplicateAddressTarget { - coordinate: format!("30402:{}:produce", "a".repeat(64)) + coordinate: format!("30402:{}:produce", crate::test_valid_hex_64('a')) } ); } diff --git a/crates/event/src/draft.rs b/crates/event/src/draft.rs @@ -12,7 +12,7 @@ use crate::contract::registry_v7::{ validate_event_contract_parts, }; use crate::ids::{ - RadrootsEventId, RadrootsEventSignature, RadrootsIdParseError, RadrootsPublicKey, + PublicKey, RadrootsEventId, RadrootsEventSignature, RadrootsIdParseError, parse_public_key, }; use crate::wire::v1::{ RadrootsCanonicalEventIdError, RadrootsEventWireError, RadrootsNip01EventWire, @@ -211,7 +211,7 @@ pub struct RadrootsEventDraft { created_at: RadrootsEventTimestamp, tags: RadrootsEventTags, content: String, - expected_pubkey: RadrootsPublicKey, + expected_pubkey: PublicKey, expected_event_id: RadrootsEventId, } @@ -237,7 +237,7 @@ impl RadrootsEventDraft { }); } ensure_generic_draft_authorable(contract)?; - let expected_pubkey = RadrootsPublicKey::parse(expected_pubkey.as_ref())?; + let expected_pubkey = parse_public_key(expected_pubkey.as_ref())?; let content = content.into(); validate_event_contract_parts(kind, &tags, content.as_str(), contract.id).map_err( |error| RadrootsDraftError::ContractShape { @@ -246,8 +246,9 @@ impl RadrootsEventDraft { }, )?; let typed_tags = RadrootsEventTags::new(tags)?; + let expected_pubkey_hex = expected_pubkey.to_hex(); let expected_event_id = compute_nip01_event_id_for_valid_pubkey( - expected_pubkey.as_str(), + expected_pubkey_hex.as_str(), created_at, kind, &typed_tags.to_vec(), @@ -266,8 +267,9 @@ impl RadrootsEventDraft { } pub fn nip01_preimage(&self) -> Result<String, RadrootsDraftError> { + let expected_pubkey = self.expected_pubkey.to_hex(); Ok(nip01_event_id_preimage_for_valid_pubkey( - self.expected_pubkey.as_str(), + expected_pubkey.as_str(), self.created_at.as_u64(), self.kind.as_u32(), &self.tags.to_vec(), @@ -306,8 +308,9 @@ impl RadrootsEventDraft { contract_id: contract.id.to_owned(), error, })?; + let expected_pubkey = self.expected_pubkey.to_hex(); let actual_event_id = compute_nip01_event_id_for_valid_pubkey( - self.expected_pubkey_str(), + expected_pubkey.as_str(), self.created_at_u64(), self.kind_u32(), &self.tags_as_vec(), @@ -367,16 +370,11 @@ impl RadrootsEventDraft { } #[inline] - pub fn expected_pubkey(&self) -> &RadrootsPublicKey { + pub fn expected_pubkey(&self) -> &PublicKey { &self.expected_pubkey } #[inline] - pub fn expected_pubkey_str(&self) -> &str { - self.expected_pubkey.as_str() - } - - #[inline] pub fn expected_event_id(&self) -> &RadrootsEventId { &self.expected_event_id } @@ -413,7 +411,7 @@ impl<'de> serde::Deserialize<'de> for RadrootsEventDraft { created_at: RadrootsEventTimestamp, tags: RadrootsEventTags, content: String, - expected_pubkey: RadrootsPublicKey, + expected_pubkey: PublicKey, expected_event_id: RadrootsEventId, } @@ -432,7 +430,7 @@ impl<'de> serde::Deserialize<'de> for RadrootsEventDraft { value.created_at.as_u64(), value.tags.to_vec(), value.content, - value.expected_pubkey.as_str(), + value.expected_pubkey.to_hex(), ) .map_err(serde::de::Error::custom)?; if draft.expected_event_id != value.expected_event_id { @@ -580,9 +578,9 @@ impl RadrootsSignedEvent { let id = RadrootsEventId::parse(parts.id) .map_err(RadrootsEventEnvelopeError::InvalidId)? .into_string(); - let pubkey = RadrootsPublicKey::parse(parts.pubkey) + let pubkey = parse_public_key(parts.pubkey) .map_err(RadrootsEventEnvelopeError::InvalidAuthor)? - .into_string(); + .to_hex(); let sig = RadrootsEventSignature::parse(parts.sig) .map_err(RadrootsEventEnvelopeError::InvalidSignature)? .into_string(); @@ -663,16 +661,11 @@ impl RadrootsSignedEvent { } #[inline] - pub fn pubkey(&self) -> &RadrootsPublicKey { + pub fn pubkey(&self) -> &PublicKey { self.envelope.author() } #[inline] - pub fn pubkey_str(&self) -> &str { - self.envelope.author_str() - } - - #[inline] pub fn created_at(&self) -> u64 { self.envelope.created_at_u64() } @@ -728,10 +721,10 @@ pub fn validate_signed_nostr_event_matches_draft( draft: &RadrootsEventDraft, ) -> Result<(), RadrootsDraftError> { draft.validate_for_signing()?; - if signed_event.pubkey_str() != draft.expected_pubkey_str() { + if signed_event.pubkey() != draft.expected_pubkey() { return Err(RadrootsDraftError::SignedEventPubkeyMismatch { - expected_pubkey: draft.expected_pubkey_str().to_owned(), - actual_pubkey: signed_event.pubkey_str().to_owned(), + expected_pubkey: draft.expected_pubkey().to_hex(), + actual_pubkey: signed_event.pubkey().to_hex(), }); } if signed_event.created_at() != draft.created_at_u64() { @@ -766,8 +759,9 @@ pub fn validate_signed_nostr_event_matches_draft( actual_event_id: signed_event.id_str().to_owned(), }); } + let signed_pubkey = signed_event.pubkey().to_hex(); let computed_event_id = compute_nip01_event_id_for_valid_pubkey( - signed_event.pubkey_str(), + signed_pubkey.as_str(), draft.created_at_u64(), signed_event.kind(), &signed_tags, @@ -792,9 +786,7 @@ fn verify_bip340_signature( let mut event_id = [0u8; 32]; hex::decode_to_slice(signed_event.id_str(), &mut event_id) .map_err(|_| RadrootsSignatureVerificationError::InvalidEventId)?; - let mut pubkey = [0u8; 32]; - hex::decode_to_slice(signed_event.pubkey_str(), &mut pubkey) - .map_err(|_| RadrootsSignatureVerificationError::InvalidPubkey)?; + let pubkey = signed_event.pubkey().into_bytes(); let mut sig = [0u8; 64]; hex::decode_to_slice(signed_event.sig_str(), &mut sig) .map_err(|_| RadrootsSignatureVerificationError::InvalidSignature)?; @@ -815,7 +807,7 @@ pub fn compute_nip01_event_id( tags: &[Vec<String>], content: &str, ) -> Result<RadrootsEventId, RadrootsDraftError> { - RadrootsPublicKey::parse(pubkey)?; + parse_public_key(pubkey)?; Ok(compute_nip01_event_id_for_valid_pubkey( pubkey, created_at, kind, tags, content, )) @@ -828,7 +820,7 @@ pub fn nip01_event_id_preimage( tags: &[Vec<String>], content: &str, ) -> Result<String, RadrootsDraftError> { - RadrootsPublicKey::parse(pubkey)?; + parse_public_key(pubkey)?; Ok(nip01_event_id_preimage_for_valid_pubkey( pubkey, created_at, kind, tags, content, )) @@ -867,7 +859,7 @@ mod tests { }; fn hex_64(character: char) -> String { - core::iter::repeat_n(character, 64).collect() + crate::test_valid_hex_64(character) } fn hex_128(character: char) -> String { @@ -939,7 +931,7 @@ mod tests { fn signed_event_for_draft(draft: &RadrootsEventDraft) -> RadrootsSignedEvent { let wire = verified_wire( - draft.expected_pubkey_str().to_string(), + draft.expected_pubkey().to_hex(), draft.created_at_u64(), draft.kind_u32(), draft.tags_as_vec(), @@ -1031,7 +1023,7 @@ mod tests { assert_eq!( event_id.as_str(), - "2a15e33622a155ae231b28bebe390869e67a0e228f77ecfcd652b1ce180a9dde" + "679ee570a933961d4f5ee95ed60cbc34d85c9c24a8da5e92f6036462ee0fc852" ); } @@ -1285,8 +1277,7 @@ mod tests { assert_eq!(decoded.envelope().id_str(), decoded.id_str()); assert_eq!(decoded.wire().id, decoded.id_str()); assert_eq!(decoded.id().as_str(), decoded.id_str()); - assert_eq!(decoded.pubkey().as_str(), decoded.pubkey_str()); - assert_eq!(decoded.pubkey_str(), hex_64('e')); + assert_eq!(decoded.pubkey().to_hex(), hex_64('e')); assert_eq!(decoded.created_at(), 10); assert_eq!(decoded.kind(), KIND_POST); assert_eq!(decoded.tags_as_vec(), wire.tags); @@ -1310,7 +1301,7 @@ mod tests { .expect("signed event"); assert_eq!(signed.id_str(), wire.id); - assert_eq!(signed.pubkey_str(), hex_64('2')); + assert_eq!(signed.pubkey().to_hex(), hex_64('2')); assert_eq!(signed.sig_str(), hex_128('3')); assert_eq!(signed.raw_json(), raw_json); @@ -1405,7 +1396,7 @@ mod tests { let signed = RadrootsSignedEvent::from_wire_unchecked( unchecked_wire( hex_64('d'), - draft.expected_pubkey_str().to_string(), + draft.expected_pubkey().to_hex(), draft.created_at_u64(), draft.kind_u32(), draft.tags_as_vec(), @@ -1425,7 +1416,7 @@ mod tests { let signed = RadrootsSignedEvent::from_wire_unchecked( unchecked_wire( draft.expected_event_id_str().to_string(), - draft.expected_pubkey_str().to_string(), + draft.expected_pubkey().to_hex(), draft.created_at_u64() + 1, draft.kind_u32(), draft.tags_as_vec(), @@ -1445,7 +1436,7 @@ mod tests { let signed = RadrootsSignedEvent::from_wire_unchecked( unchecked_wire( draft.expected_event_id_str().to_string(), - draft.expected_pubkey_str().to_string(), + draft.expected_pubkey().to_hex(), draft.created_at_u64(), KIND_PROFILE, draft.tags_as_vec(), @@ -1467,7 +1458,7 @@ mod tests { let signed = RadrootsSignedEvent::from_wire_unchecked( unchecked_wire( draft.expected_event_id_str().to_string(), - draft.expected_pubkey_str().to_string(), + draft.expected_pubkey().to_hex(), draft.created_at_u64(), draft.kind_u32(), tags, @@ -1487,7 +1478,7 @@ mod tests { let signed = RadrootsSignedEvent::from_wire_unchecked( unchecked_wire( draft.expected_event_id_str().to_string(), - draft.expected_pubkey_str().to_string(), + draft.expected_pubkey().to_hex(), draft.created_at_u64(), draft.kind_u32(), draft.tags_as_vec(), @@ -1610,10 +1601,7 @@ mod tests { assert_eq!(draft.kind().as_u32(), draft.kind_u32()); assert_eq!(draft.created_at().as_u64(), draft.created_at_u64()); assert_eq!(draft.tags().to_vec(), draft.tags_as_vec()); - assert_eq!( - draft.expected_pubkey().as_str(), - draft.expected_pubkey_str() - ); + assert_eq!(draft.expected_pubkey().to_hex(), hex_64('a')); assert_eq!( draft.expected_event_id().as_str(), draft.expected_event_id_str() @@ -1623,7 +1611,7 @@ mod tests { assert_eq!(signed.envelope().id_str(), signed.id_str()); assert_eq!(signed.wire().id, signed.id_str()); assert_eq!(signed.id().as_str(), signed.id_str()); - assert_eq!(signed.pubkey().as_str(), signed.pubkey_str()); + assert_eq!(signed.pubkey(), draft.expected_pubkey()); assert_eq!(signed.sig().as_str(), signed.sig_str()); for error in [ diff --git a/crates/event/src/envelope.rs b/crates/event/src/envelope.rs @@ -7,7 +7,7 @@ use alloc::{string::String, string::ToString, vec::Vec}; use std::{string::String, vec::Vec}; use crate::ids::{ - RadrootsEventId, RadrootsEventSignature, RadrootsIdParseError, RadrootsPublicKey, + PublicKey, RadrootsEventId, RadrootsEventSignature, RadrootsIdParseError, parse_public_key, }; use crate::wire::v1::{ DEFAULT_CONTENT_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES, DEFAULT_TAG_MAX_COUNT, @@ -432,7 +432,8 @@ pub struct RadrootsEventEnvelopeParts { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsEventEnvelope { id: RadrootsEventId, - author: RadrootsPublicKey, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + author: PublicKey, #[cfg_attr(feature = "dto-bindgen", dto(int = "json_number"))] created_at: RadrootsEventTimestamp, kind: RadrootsEventKind, @@ -455,9 +456,9 @@ impl RadrootsEventEnvelope { if id.as_str() != parts.id.as_str() { return Err(RadrootsEventEnvelopeError::NonCanonicalId); } - let author = RadrootsPublicKey::parse(parts.author.as_str()) + let author = parse_public_key(parts.author.as_str()) .map_err(RadrootsEventEnvelopeError::InvalidAuthor)?; - if author.as_str() != parts.author.as_str() { + if author.to_hex() != parts.author { return Err(RadrootsEventEnvelopeError::NonCanonicalAuthor); } let sig = RadrootsEventSignature::parse(parts.sig.as_str()) @@ -495,16 +496,11 @@ impl RadrootsEventEnvelope { } #[inline] - pub fn author(&self) -> &RadrootsPublicKey { + pub fn author(&self) -> &PublicKey { &self.author } #[inline] - pub fn author_str(&self) -> &str { - self.author.as_str() - } - - #[inline] pub fn created_at(&self) -> RadrootsEventTimestamp { self.created_at } @@ -561,7 +557,7 @@ impl RadrootsEventEnvelope { pub fn to_nip01_wire(&self) -> RadrootsNip01EventWire { RadrootsNip01EventWire { id: self.id.as_str().to_string(), - pubkey: self.author.as_str().to_string(), + pubkey: self.author.to_hex(), created_at: self.created_at.as_u64(), kind: self.kind.as_u32(), tags: self.tags.to_vec(), @@ -611,7 +607,7 @@ mod tests { use super::*; fn hex_64(character: char) -> String { - core::iter::repeat_n(character, 64).collect() + crate::test_valid_hex_64(character) } fn hex_128(character: char) -> String { @@ -635,7 +631,7 @@ mod tests { let envelope = RadrootsEventEnvelope::new(event_parts()).expect("envelope"); assert_eq!(envelope.id_str(), hex_64('1')); - assert_eq!(envelope.author_str(), hex_64('a')); + assert_eq!(envelope.author().to_hex(), hex_64('a')); assert_eq!(envelope.created_at_u64(), u64::from(u32::MAX) + 1); assert_eq!(envelope.kind_u32(), 30_023); assert_eq!(envelope.kind_class(), RadrootsEventKindClass::Addressable); @@ -937,7 +933,7 @@ mod tests { let envelope = RadrootsEventEnvelope::new(event_parts()).expect("envelope"); assert_eq!(envelope.id().as_str(), envelope.id_str()); - assert_eq!(envelope.author().as_str(), envelope.author_str()); + assert_eq!(envelope.author().to_hex(), hex_64('a')); assert_eq!(envelope.created_at().as_u64(), envelope.created_at_u64()); assert_eq!(envelope.kind().as_u32(), envelope.kind_u32()); assert_eq!(envelope.tags().to_vec(), envelope.tags_as_vec()); diff --git a/crates/event/src/event_head/v1.rs b/crates/event/src/event_head/v1.rs @@ -9,18 +9,18 @@ use crate::contract::registry_v7::{ RadrootsContractMatchError, RadrootsEventClass, RadrootsEventContract, identify_event_contract, }; use crate::envelope::{RadrootsEventEnvelope, RadrootsEventKindClass, RadrootsEventTag}; -use crate::ids::{RadrootsDTag, RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey}; +use crate::ids::{PublicKey, RadrootsDTag, RadrootsEventId, RadrootsIdParseError}; use crate::tags::TAG_D; #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum RadrootsEventHeadCoordinate { Replaceable { kind: u32, - pubkey: RadrootsPublicKey, + pubkey: PublicKey, }, Addressable { kind: u32, - pubkey: RadrootsPublicKey, + pubkey: PublicKey, d_tag: String, }, } @@ -83,7 +83,7 @@ pub fn event_head_candidate_for_class( RadrootsEventClass::Ephemeral => RadrootsEventHeadCandidateResult::NotPersisted, RadrootsEventClass::Replaceable | RadrootsEventClass::Addressable => { let event_id = event.id().clone(); - let pubkey = event.author().clone(); + let pubkey = *event.author(); let coordinate = if class == RadrootsEventClass::Replaceable { RadrootsEventHeadCoordinate::Replaceable { kind: event.kind_u32(), @@ -142,11 +142,11 @@ pub fn event_head_candidate_for_nip01_event_v1( } RadrootsEventKindClass::Replaceable => RadrootsEventHeadCoordinate::Replaceable { kind: event.kind_u32(), - pubkey: event.author().clone(), + pubkey: *event.author(), }, RadrootsEventKindClass::Addressable => RadrootsEventHeadCoordinate::Addressable { kind: event.kind_u32(), - pubkey: event.author().clone(), + pubkey: *event.author(), d_tag: String::from(first_tag_value(event.tag_slices(), TAG_D).unwrap_or("")), }, }; diff --git a/crates/event/src/event_head/v1/tests.rs b/crates/event/src/event_head/v1/tests.rs @@ -1,12 +1,13 @@ use super::*; use crate::RadrootsEventEnvelopeParts; use crate::contract::RadrootsContractMatchError; +use crate::ids::parse_public_key; use crate::kinds::{ KIND_FOLLOW, KIND_LIST_SET_GENERIC, KIND_POST, KIND_PROFILE, KIND_TRADE_PROPOSAL, }; fn hex_64(character: char) -> String { - core::iter::repeat_n(character, 64).collect() + crate::test_valid_hex_64(character) } fn hex_128(character: char) -> String { @@ -90,7 +91,7 @@ fn replaceable_events_use_kind_and_pubkey_coordinates() { candidate.coordinate, RadrootsEventHeadCoordinate::Replaceable { kind: 10002, - pubkey: RadrootsPublicKey::parse(hex_64('a')).unwrap() + pubkey: parse_public_key(hex_64('a')).unwrap() } ); assert_eq!(candidate.created_at, 5); @@ -113,7 +114,7 @@ fn addressable_events_use_kind_pubkey_and_d_tag_coordinates() { candidate.coordinate, RadrootsEventHeadCoordinate::Addressable { kind: 30023, - pubkey: RadrootsPublicKey::parse(hex_64('b')).unwrap(), + pubkey: parse_public_key(hex_64('b')).unwrap(), d_tag: "article-1".to_owned() } ); @@ -198,7 +199,7 @@ fn contract_bridge_uses_replaceable_event_classes() { candidate.coordinate, RadrootsEventHeadCoordinate::Replaceable { kind: KIND_FOLLOW, - pubkey: RadrootsPublicKey::parse(hex_64('a')).unwrap() + pubkey: parse_public_key(hex_64('a')).unwrap() } ); } @@ -211,7 +212,7 @@ fn raw_nip01_bridge_uses_numeric_kind_classes_without_contract_identification() replaceable.coordinate, RadrootsEventHeadCoordinate::Replaceable { kind: 19_999, - pubkey: RadrootsPublicKey::parse(hex_64('a')).unwrap(), + pubkey: parse_public_key(hex_64('a')).unwrap(), } ); @@ -227,7 +228,7 @@ fn raw_nip01_bridge_uses_numeric_kind_classes_without_contract_identification() addressable.coordinate, RadrootsEventHeadCoordinate::Addressable { kind: 39_999, - pubkey: RadrootsPublicKey::parse(hex_64('b')).unwrap(), + pubkey: parse_public_key(hex_64('b')).unwrap(), d_tag: "unsupported".to_owned(), } ); @@ -273,7 +274,7 @@ fn raw_nip01_addressable_coordinates_treat_d_as_opaque_protocol_data() { candidate.coordinate, RadrootsEventHeadCoordinate::Addressable { kind: 39_999, - pubkey: RadrootsPublicKey::parse(hex_64('b')).unwrap(), + pubkey: parse_public_key(hex_64('b')).unwrap(), d_tag: expected.to_owned(), } ); @@ -309,7 +310,7 @@ fn contract_bridge_uses_addressable_event_classes() { candidate.coordinate, RadrootsEventHeadCoordinate::Addressable { kind: KIND_LIST_SET_GENERIC, - pubkey: RadrootsPublicKey::parse(hex_64('b')).unwrap(), + pubkey: parse_public_key(hex_64('b')).unwrap(), d_tag: "member_of.farms".to_owned() } ); @@ -331,7 +332,7 @@ fn contract_bridge_uses_profile_replaceable_heads() { candidate.coordinate, RadrootsEventHeadCoordinate::Replaceable { kind: KIND_PROFILE, - pubkey: RadrootsPublicKey::parse(hex_64('c')).unwrap() + pubkey: parse_public_key(hex_64('c')).unwrap() } ); } diff --git a/crates/event/src/food.rs b/crates/event/src/food.rs @@ -0,0 +1,7 @@ +//! Canonical food-event domain model. +//! +//! Existing food-availability behavior moves here during module normalization. + +/// Internal marker for the target module while its models are consolidated. +#[allow(dead_code)] +pub(crate) struct ModuleScaffold; diff --git a/crates/event/src/food_availability.rs b/crates/event/src/food_availability.rs @@ -4,7 +4,7 @@ use alloc::{string::String, vec::Vec}; use std::{string::String, vec::Vec}; use core::{fmt, str::FromStr}; -use radroots_blossom::hash::{RadrootsBlossomHashPath, RadrootsBlossomSha256}; +use radroots_blossom::hash::{HashPath, Sha256}; use unicode_general_category::{GeneralCategory, get_general_category}; use url_nostd::{Host, Url}; @@ -797,14 +797,12 @@ pub fn food_media_http_url_is_valid(value: &str) -> bool { /// /// A `None` result does not invalidate a standard inbound NIP-58 image URL; it /// only means duplicate-digest diagnostics cannot be derived from its path. -pub fn food_media_blossom_digest(value: &str) -> Option<RadrootsBlossomSha256> { +pub fn food_media_blossom_digest(value: &str) -> Option<Sha256> { if !food_media_http_url_is_valid(value) { return None; } let url = Url::parse(value).ok()?; - RadrootsBlossomHashPath::parse(url.path()) - .ok() - .map(|path| path.hash()) + HashPath::parse(url.path()).ok().map(|path| path.hash()) } fn raw_food_media_host_and_path(value: &str) -> Option<(&str, &str)> { @@ -898,10 +896,7 @@ fn is_control_or_format(character: char) -> bool { #[cfg(test)] mod tests { use super::*; - use radroots_blossom::{ - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType, - RadrootsBlossomSha256, - }; + use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; #[test] fn error_codes_and_messages_are_stable_for_every_variant() { @@ -1021,7 +1016,7 @@ mod tests { #[test] fn inbound_food_media_urls_are_structural_without_claiming_blossom() { - let hash = RadrootsBlossomSha256::digest(b"carrots").to_string(); + let hash = Sha256::digest(b"carrots").to_string(); for valid in [ format!("https://media.example/{hash}.webp"), format!("http://media.example:0/{hash}?download=1"), @@ -1043,7 +1038,7 @@ mod tests { let blossom = format!("https://media.example/{hash}.webp?download=1"); assert_eq!( food_media_blossom_digest(&blossom), - Some(RadrootsBlossomSha256::from_hex(&hash).unwrap()) + Some(Sha256::from_hex(&hash).unwrap()) ); assert_eq!( food_media_blossom_digest("https://media.example/not-a-hash.jpg"), @@ -1371,10 +1366,10 @@ mod tests { bytes: &[u8], dimensions: RadrootsFoodImageDimensions, ) -> RadrootsFoodAvailabilityImage { - let hash = RadrootsBlossomSha256::digest(bytes); - let media_type = RadrootsBlossomMediaType::parse("image/webp").unwrap(); - let descriptor = RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&format!("{origin}/{hash}.webp")).unwrap(), + let hash = Sha256::digest(bytes); + let media_type = MediaType::parse("image/webp").unwrap(); + let descriptor = BlobDescriptor::new( + BlobUrl::parse(&format!("{origin}/{hash}.webp")).unwrap(), hash, bytes.len() as u64, media_type.clone(), diff --git a/crates/event/src/id.rs b/crates/event/src/id.rs @@ -0,0 +1,7 @@ +//! Canonical event identifier types. +//! +//! Identifier ownership is consolidated here by the ordered identifier refactor. + +/// Internal marker for the target module while identifier types are migrated. +#[allow(dead_code)] +pub(crate) struct ModuleScaffold; diff --git a/crates/event/src/ids.rs b/crates/event/src/ids.rs @@ -7,6 +7,7 @@ use alloc::{format, string::String, string::ToString, vec::Vec}; use std::{string::String, vec::Vec}; use core::{borrow::Borrow, fmt, ops::Deref, str::FromStr}; +pub(crate) use radroots_identity::PublicKey; use url_nostd::Url; use crate::kinds::KIND_CLASSIFIED_LISTING; @@ -17,6 +18,7 @@ pub enum RadrootsIdParseError { InvalidFormat, InvalidLength { expected: usize, actual: usize }, InvalidCharacter, + InvalidPublicKey, UnexpectedKind { expected: u32, actual: u32 }, TooLong { max: usize, actual: usize }, } @@ -33,6 +35,9 @@ impl fmt::Display for RadrootsIdParseError { ) } Self::InvalidCharacter => write!(f, "identifier contains an invalid character"), + Self::InvalidPublicKey => { + write!(f, "identifier is not a valid secp256k1 x-only public key") + } Self::UnexpectedKind { expected, actual } => { write!( f, @@ -183,7 +188,6 @@ macro_rules! validated_string_id { }; } -validated_string_id!(RadrootsPublicKey, validate_hex_64); validated_string_id!(RadrootsEventId, validate_hex_64); validated_string_id!(RadrootsEventSignature, validate_hex_128); validated_string_id!(RadrootsTradeId, validate_hex_32); @@ -205,6 +209,19 @@ validated_string_id!(RadrootsEconomicsDigest, validate_economics_digest); validated_string_id!(RadrootsEventPointer, validate_hex_64); validated_string_id!(RadrootsRelayUrl, validate_relay_url); +pub(crate) fn parse_public_key(value: impl AsRef<str>) -> Result<PublicKey, RadrootsIdParseError> { + PublicKey::from_hex(value.as_ref()).map_err(|error| match error { + radroots_identity::Error::InvalidHexLength { expected, actual } + | radroots_identity::Error::InvalidByteLength { expected, actual } => { + RadrootsIdParseError::InvalidLength { expected, actual } + } + radroots_identity::Error::InvalidHexCharacter { .. } => { + RadrootsIdParseError::InvalidCharacter + } + _ => RadrootsIdParseError::InvalidPublicKey, + }) +} + /// Radroots tag-element policy for a NIP-01 coordinate. /// /// NIP-01 does not define this resource limit. @@ -259,7 +276,7 @@ impl std::error::Error for RadrootsNip01CoordinateParseError { #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RadrootsNip01CoordinateParts { pub kind: u32, - pub pubkey: RadrootsPublicKey, + pub pubkey: PublicKey, pub identifier: String, } @@ -277,7 +294,7 @@ impl RadrootsNip01CoordinateParts { pub struct RadrootsNip01Coordinate { canonical: String, kind: u32, - pubkey: RadrootsPublicKey, + pubkey: PublicKey, identifier: String, } @@ -311,8 +328,7 @@ impl RadrootsNip01Coordinate { return Err(RadrootsNip01CoordinateParseError::IdentifierMustBeEmpty { kind }); } - let pubkey = - RadrootsPublicKey::parse(pubkey).map_err(RadrootsNip01CoordinateParseError::Pubkey)?; + let pubkey = parse_public_key(pubkey).map_err(RadrootsNip01CoordinateParseError::Pubkey)?; let identifier = identifier.to_string(); let canonical = format!("{kind}:{pubkey}:{identifier}"); Ok(Self { @@ -334,7 +350,7 @@ impl RadrootsNip01Coordinate { } #[inline] - pub const fn pubkey(&self) -> &RadrootsPublicKey { + pub const fn pubkey(&self) -> &PublicKey { &self.pubkey } @@ -347,7 +363,7 @@ impl RadrootsNip01Coordinate { pub fn parts(&self) -> RadrootsNip01CoordinateParts { RadrootsNip01CoordinateParts { kind: self.kind, - pubkey: self.pubkey.clone(), + pubkey: self.pubkey, identifier: self.identifier.clone(), } } @@ -451,7 +467,7 @@ impl<'de> serde::Deserialize<'de> for RadrootsNip01Coordinate { #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RadrootsAddressableCoordinateParts { pub kind: u32, - pub pubkey: RadrootsPublicKey, + pub pubkey: PublicKey, pub d_tag: RadrootsDTag, } @@ -565,7 +581,7 @@ fn parse_addressable_coordinate_parts( let kind = kind .parse::<u32>() .map_err(|_| RadrootsIdParseError::InvalidFormat)?; - let pubkey = RadrootsPublicKey::parse(pubkey)?; + let pubkey = parse_public_key(pubkey)?; let d_tag = RadrootsDTag::parse(d_tag)?; Ok(RadrootsAddressableCoordinateParts { kind, @@ -683,7 +699,7 @@ mod tests { } fn hex_64(character: char) -> String { - core::iter::repeat_n(character, 64).collect() + crate::test_valid_hex_64(character) } fn hex_32(character: char) -> String { @@ -696,9 +712,12 @@ mod tests { #[test] fn public_keys_and_event_ids_require_64_hex_chars() { - let upper = "A".repeat(64); - let public_key = RadrootsPublicKey::parse(&upper).expect("public key"); - assert_eq!(public_key.as_str(), "a".repeat(64)); + let upper = "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF"; + let public_key = parse_public_key(upper).expect("public key"); + assert_eq!( + public_key.to_hex(), + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + ); let event_id = RadrootsEventId::parse(hex_64('f')).expect("event id"); assert_eq!(event_id.as_str(), hex_64('f')); @@ -725,6 +744,7 @@ mod tests { actual: 7, }, RadrootsIdParseError::InvalidCharacter, + RadrootsIdParseError::InvalidPublicKey, RadrootsIdParseError::UnexpectedKind { expected: KIND_CLASSIFIED_LISTING, actual: 30023, @@ -856,7 +876,7 @@ mod tests { let addr = format!("30402:{}:farm:farm-1:members", hex_64('A')); let parts = RadrootsAddressableCoordinateParts::parse(&addr).expect("coordinate parts"); assert_eq!(parts.kind, 30402); - assert_eq!(parts.pubkey.as_str(), hex_64('a')); + assert_eq!(parts.pubkey.to_hex(), hex_64('a')); assert_eq!(parts.d_tag.as_str(), "farm:farm-1:members"); } @@ -866,7 +886,7 @@ mod tests { let coordinate = RadrootsNip01Coordinate::parse(format!("+0{kind}:{}:", hex_64('A'))) .expect("replaceable coordinate"); assert_eq!(coordinate.kind(), kind); - assert_eq!(coordinate.pubkey().as_str(), hex_64('a')); + assert_eq!(coordinate.pubkey().to_hex(), hex_64('a')); assert_eq!(coordinate.identifier(), ""); assert_eq!(coordinate.as_str(), format!("{kind}:{}:", hex_64('a'))); } @@ -887,7 +907,7 @@ mod tests { .expect("opaque addressable coordinate"); assert_eq!(coordinate.kind(), 30_402); - assert_eq!(coordinate.pubkey().as_str(), hex_64('a')); + assert_eq!(coordinate.pubkey().to_hex(), hex_64('a')); assert_eq!(coordinate.identifier().as_bytes(), identifier.as_bytes()); assert_eq!( coordinate.as_str().as_bytes(), @@ -895,7 +915,7 @@ mod tests { ); let parts = coordinate.parts(); assert_eq!(parts.kind, 30_402); - assert_eq!(parts.pubkey.as_str(), hex_64('a')); + assert_eq!(parts.pubkey.to_hex(), hex_64('a')); assert_eq!(parts.identifier.as_bytes(), identifier.as_bytes()); assert_eq!( RadrootsNip01CoordinateParts::parse(coordinate.as_str()).expect("parts"), @@ -1085,7 +1105,6 @@ mod tests { fn validated_identifier_wrappers_expose_consistent_traits() { let addressable = format!("30402:{}:listing-1", hex_64('0')); - assert_identifier_impls!(RadrootsPublicKey, hex_64('a').as_str()); assert_identifier_impls!(RadrootsEventId, hex_64('b').as_str()); assert_identifier_impls!(RadrootsEventSignature, hex_128('c').as_str()); assert_identifier_impls!(RadrootsDTag, "listing-1"); @@ -1239,7 +1258,7 @@ mod tests { #[allow(dead_code)] #[derive(Debug, serde::Deserialize)] struct MissingPublicKey { - value: RadrootsPublicKey, + value: PublicKey, } #[allow(dead_code)] #[derive(Debug, serde::Deserialize)] diff --git a/crates/event/src/knowledge.rs b/crates/event/src/knowledge.rs @@ -7,8 +7,8 @@ use core::fmt; use crate::RadrootsEventRef; use crate::ids::{ - RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsPublicKey, - RadrootsRelayUrl, + RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsRelayUrl, + parse_public_key, }; use crate::kinds::KIND_WIKI_ARTICLE; @@ -185,7 +185,7 @@ fn validate_pubkey( if value.trim().is_empty() { return Err(RadrootsKnowledgeValidationError::EmptyField(field)); } - RadrootsPublicKey::parse(value) + parse_public_key(value) .map(|_| ()) .map_err(|_| RadrootsKnowledgeValidationError::InvalidField(field)) } @@ -915,7 +915,7 @@ mod tests { use super::*; fn hex_64(character: char) -> String { - core::iter::repeat_n(character, 64).collect() + crate::test_valid_hex_64(character) } fn event_ref() -> RadrootsEventRef { @@ -925,7 +925,7 @@ mod tests { fn event_ref_with_kind(kind: u32) -> RadrootsEventRef { RadrootsEventRef { id: "0".repeat(64), - author: "1".repeat(64), + author: crate::test_valid_hex_64('1'), kind, d_tag: None, relays: None, diff --git a/crates/event/src/lib.rs b/crates/event/src/lib.rs @@ -9,7 +9,33 @@ use alloc::{string::String, vec::Vec}; pub const RADROOTS_EVENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +#[cfg(test)] +/// Returns deterministic 64-character fixtures that are also valid secp256k1 +/// x-only public keys; labels without a curve point are remapped. +pub(crate) fn test_valid_hex_64(character: char) -> String { + if matches!(character, 'b' | 'B') { + let value = "2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4"; + return if character.is_ascii_uppercase() { + value.to_ascii_uppercase() + } else { + value.to_owned() + }; + } + let character = match character { + '0' => '7', + '1' => '8', + '5' => 'd', + '6' => 'e', + '9' => 'a', + 'c' | 'C' => '3', + 'f' | 'F' => '4', + other => other, + }; + core::iter::repeat_n(character, 64).collect() +} + pub mod account; +pub mod admission; pub mod app_data; pub mod article; pub mod calendar; @@ -30,12 +56,14 @@ pub mod farm_file; pub mod farm_workspace; pub mod file_metadata; pub mod follow; +pub mod food; pub mod food_availability; pub mod gcs; pub mod geochat; pub mod gift_wrap; pub mod group; pub mod http_auth; +pub mod id; pub mod ids; pub mod job; pub mod job_feedback; @@ -46,6 +74,7 @@ pub mod kinds; pub mod knowledge; pub mod list; pub mod list_set; +pub mod listing; pub mod location; pub mod media; pub mod message; @@ -67,6 +96,7 @@ pub mod resource_area; pub mod resource_cap; pub mod seal; pub mod social; +pub mod tag; pub mod tags; pub mod trade; pub mod trade_validation; diff --git a/crates/event/src/listing.rs b/crates/event/src/listing.rs @@ -0,0 +1,7 @@ +//! Canonical listing-event domain model. +//! +//! Existing listing behavior moves here during module normalization. + +/// Internal marker for the target module while its models are consolidated. +#[allow(dead_code)] +pub(crate) struct ModuleScaffold; diff --git a/crates/event/src/media.rs b/crates/event/src/media.rs @@ -1,6 +1,6 @@ use core::fmt; -use radroots_blossom::descriptor::RadrootsBlossomByteVerifiedDescriptor; +use radroots_blossom::ByteVerifiedDescriptor; /// Errors raised while constructing strict authored image media. #[non_exhaustive] @@ -36,11 +36,11 @@ impl std::error::Error for RadrootsAuthoredImageError {} /// This typestate does not prove upload completion, network availability, or /// image format safety. Owning runtimes remain responsible for those policies. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsAuthoredImage(RadrootsBlossomByteVerifiedDescriptor); +pub struct RadrootsAuthoredImage(ByteVerifiedDescriptor); impl RadrootsAuthoredImage { pub fn try_from_verified_descriptor( - descriptor: RadrootsBlossomByteVerifiedDescriptor, + descriptor: ByteVerifiedDescriptor, ) -> Result<Self, RadrootsAuthoredImageError> { if !descriptor.media_type().as_str().starts_with("image/") { return Err(RadrootsAuthoredImageError::MediaTypeNotImage); @@ -48,15 +48,15 @@ impl RadrootsAuthoredImage { Ok(Self(descriptor)) } - pub fn descriptor(&self) -> &RadrootsBlossomByteVerifiedDescriptor { + pub fn descriptor(&self) -> &ByteVerifiedDescriptor { &self.0 } } -impl TryFrom<RadrootsBlossomByteVerifiedDescriptor> for RadrootsAuthoredImage { +impl TryFrom<ByteVerifiedDescriptor> for RadrootsAuthoredImage { type Error = RadrootsAuthoredImageError; - fn try_from(value: RadrootsBlossomByteVerifiedDescriptor) -> Result<Self, Self::Error> { + fn try_from(value: ByteVerifiedDescriptor) -> Result<Self, Self::Error> { Self::try_from_verified_descriptor(value) } } @@ -64,10 +64,7 @@ impl TryFrom<RadrootsBlossomByteVerifiedDescriptor> for RadrootsAuthoredImage { #[cfg(test)] mod tests { use super::*; - use radroots_blossom::{ - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType, - RadrootsBlossomSha256, - }; + use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; #[test] fn authored_image_requires_a_byte_verified_image_descriptor() { @@ -94,16 +91,12 @@ mod tests { ); } - fn verified_descriptor( - media_type: &str, - extension: &str, - ) -> RadrootsBlossomByteVerifiedDescriptor { + fn verified_descriptor(media_type: &str, extension: &str) -> ByteVerifiedDescriptor { let bytes = b"hello"; - let hash = RadrootsBlossomSha256::digest(bytes); - let media_type = RadrootsBlossomMediaType::parse(media_type).unwrap(); - RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&format!("https://media.example/{hash}.{extension}")) - .unwrap(), + let hash = Sha256::digest(bytes); + let media_type = MediaType::parse(media_type).unwrap(); + BlobDescriptor::new( + BlobUrl::parse(&format!("https://media.example/{hash}.{extension}")).unwrap(), hash, bytes.len() as u64, media_type.clone(), diff --git a/crates/event/src/operational_listing.rs b/crates/event/src/operational_listing.rs @@ -1,7 +1,5 @@ -use radroots_core::{ - RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, -}; +use radroots_core::pricing::Discount; +use radroots_core::{Decimal, Money, Quantity, QuantityPrice, Unit}; use crate::farm::RadrootsFarmRef; use crate::ids::{RadrootsDTag, RadrootsInventoryBinId}; @@ -127,8 +125,10 @@ pub struct RadrootsOperationalListing { pub bins: Vec<RadrootsOperationalListingBin>, pub resource_area: Option<RadrootsResourceAreaRef>, pub plot: Option<RadrootsPlotRef>, - pub discounts: Option<Vec<RadrootsCoreDiscount>>, - pub inventory_available: Option<RadrootsCoreDecimal>, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<Discount>")))] + pub discounts: Option<Vec<Discount>>, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Decimal")))] + pub inventory_available: Option<Decimal>, pub availability: Option<RadrootsOperationalListingAvailability>, pub delivery_method: Option<RadrootsOperationalListingDeliveryMethod>, pub location: Option<RadrootsOperationalListingPublicLocation>, @@ -169,13 +169,19 @@ pub struct RadrootsOperationalListingProductTagKeys; #[derive(Clone, Debug)] pub struct RadrootsOperationalListingBin { pub bin_id: RadrootsInventoryBinId, - pub quantity: RadrootsCoreQuantity, - pub price_per_canonical_unit: RadrootsCoreQuantityPrice, - pub display_amount: Option<RadrootsCoreDecimal>, - pub display_unit: Option<RadrootsCoreUnit>, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Quantity")))] + pub quantity: Quantity, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "QuantityPrice")))] + pub price_per_canonical_unit: QuantityPrice, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Decimal")))] + pub display_amount: Option<Decimal>, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Unit")))] + pub display_unit: Option<Unit>, pub display_label: Option<String>, - pub display_price: Option<RadrootsCoreMoney>, - pub display_price_unit: Option<RadrootsCoreUnit>, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Money")))] + pub display_price: Option<Money>, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Unit")))] + pub display_price_unit: Option<Unit>, } #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] diff --git a/crates/event/src/order.rs b/crates/event/src/order.rs @@ -9,7 +9,7 @@ use alloc::{ #[cfg(test)] use crate::ids::RadrootsOrderQuoteId; use crate::ids::{ - RadrootsClassifiedListingAddress, RadrootsInventoryBinId, RadrootsOrderId, RadrootsPublicKey, + PublicKey, RadrootsClassifiedListingAddress, RadrootsInventoryBinId, RadrootsOrderId, }; use crate::kinds::*; #[cfg(test)] @@ -17,7 +17,7 @@ use crate::operational_listing::RadrootsOperationalListingParseError; pub use crate::order_economics::*; #[cfg(test)] use crate::trade_validation::RadrootsOperationalListingValidationError; -use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney}; +use radroots_core::{Currency, Decimal, Money}; pub const RADROOTS_COMMERCIAL_LISTING_DOMAIN: &str = "trade:listing"; pub const RADROOTS_ORDER_ENVELOPE_VERSION: u16 = 1; @@ -48,13 +48,13 @@ impl RadrootsOrderEconomics { return Err(RadrootsOrderPayloadError::MissingEconomicItems); } - let mut subtotal = RadrootsCoreMoney::zero(self.currency); + let mut subtotal = Money::zero(self.currency); for (index, item) in self.items.iter().enumerate() { let line_subtotal = validate_economic_item(item, self.currency, index)?; subtotal = checked_money_add(&subtotal, &line_subtotal, "subtotal")?; } - let mut discount_total = RadrootsCoreMoney::zero(self.currency); + let mut discount_total = Money::zero(self.currency); for (index, line) in self.discounts.iter().enumerate() { validate_economic_line(line, self.currency, "discounts", index)?; if line.kind != RadrootsOrderEconomicLineKind::ListingDiscount { @@ -72,7 +72,7 @@ impl RadrootsOrderEconomics { discount_total = checked_money_add(&discount_total, &line.amount, "discount_total")?; } - let mut adjustment_total = RadrootsCoreMoney::zero(self.currency); + let mut adjustment_total = Money::zero(self.currency); let mut total = checked_money_sub_non_negative(&subtotal, &discount_total, "total")?; for (index, line) in self.adjustments.iter().enumerate() { validate_economic_line(line, self.currency, "adjustments", index)?; @@ -142,8 +142,10 @@ impl RadrootsOrderEconomics { pub struct RadrootsOrderRequest { pub order_id: RadrootsOrderId, pub listing_addr: RadrootsClassifiedListingAddress, - pub buyer_pubkey: RadrootsPublicKey, - pub seller_pubkey: RadrootsPublicKey, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub buyer_pubkey: PublicKey, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub seller_pubkey: PublicKey, pub items: Vec<RadrootsOrderItem>, pub economics: RadrootsOrderEconomics, } @@ -152,8 +154,6 @@ impl RadrootsOrderRequest { pub fn validate(&self) -> Result<(), RadrootsOrderPayloadError> { validate_required_field(&self.order_id, "order_id")?; validate_required_field(&self.listing_addr, "listing_addr")?; - validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?; - validate_required_field(&self.seller_pubkey, "seller_pubkey")?; validate_order_items(&self.items)?; self.economics.validate()?; validate_order_economics_binding(&self.items, &self.economics) @@ -222,8 +222,10 @@ impl RadrootsOrderDecisionOutcome { pub struct RadrootsOrderDecision { pub order_id: RadrootsOrderId, pub listing_addr: RadrootsClassifiedListingAddress, - pub buyer_pubkey: RadrootsPublicKey, - pub seller_pubkey: RadrootsPublicKey, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub buyer_pubkey: PublicKey, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub seller_pubkey: PublicKey, pub decision: RadrootsOrderDecisionOutcome, } @@ -231,8 +233,6 @@ impl RadrootsOrderDecision { pub fn validate(&self) -> Result<(), RadrootsOrderPayloadError> { validate_required_field(&self.order_id, "order_id")?; validate_required_field(&self.listing_addr, "listing_addr")?; - validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?; - validate_required_field(&self.seller_pubkey, "seller_pubkey")?; self.decision.validate() } } @@ -248,8 +248,10 @@ impl RadrootsOrderDecision { pub struct RadrootsOrderCancellation { pub order_id: RadrootsOrderId, pub listing_addr: RadrootsClassifiedListingAddress, - pub buyer_pubkey: RadrootsPublicKey, - pub seller_pubkey: RadrootsPublicKey, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub buyer_pubkey: PublicKey, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub seller_pubkey: PublicKey, pub reason: String, } @@ -257,8 +259,6 @@ impl RadrootsOrderCancellation { pub fn validate(&self) -> Result<(), RadrootsOrderPayloadError> { validate_required_field(&self.order_id, "order_id")?; validate_required_field(&self.listing_addr, "listing_addr")?; - validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?; - validate_required_field(&self.seller_pubkey, "seller_pubkey")?; validate_required_field(&self.reason, "reason") } } @@ -527,9 +527,9 @@ fn validate_order_items(items: &[RadrootsOrderItem]) -> Result<(), RadrootsOrder fn validate_economic_item( item: &RadrootsOrderEconomicItem, - expected_currency: RadrootsCoreCurrency, + expected_currency: Currency, index: usize, -) -> Result<RadrootsCoreMoney, RadrootsOrderPayloadError> { +) -> Result<Money, RadrootsOrderPayloadError> { validate_required_field(&item.bin_id, "economics.items.bin_id")?; if item.bin_count == 0 { return Err(RadrootsOrderPayloadError::InvalidEconomicItemBinCount { index }); @@ -551,14 +551,11 @@ fn validate_economic_item( "items.line_subtotal", )?; - let quantity_total = checked_decimal_mul( - item.quantity_amount, - RadrootsCoreDecimal::from(item.bin_count), - ) - .ok_or(RadrootsOrderPayloadError::InvalidEconomicItemSubtotal { index })?; + let quantity_total = checked_decimal_mul(item.quantity_amount, Decimal::from(item.bin_count)) + .ok_or(RadrootsOrderPayloadError::InvalidEconomicItemSubtotal { index })?; let expected_subtotal = checked_decimal_mul(item.unit_price_amount, quantity_total) .ok_or(RadrootsOrderPayloadError::InvalidEconomicItemSubtotal { index })?; - if item.line_subtotal.amount != expected_subtotal { + if item.line_subtotal.amount() != expected_subtotal { return Err(RadrootsOrderPayloadError::InvalidEconomicItemSubtotal { index }); } Ok(item.line_subtotal.clone()) @@ -621,16 +618,16 @@ fn normalized_order_item_counts( fn validate_economic_line( line: &RadrootsOrderEconomicLine, - expected_currency: RadrootsCoreCurrency, + expected_currency: Currency, field: &'static str, index: usize, ) -> Result<(), RadrootsOrderPayloadError> { validate_required_field(&line.id, "economics.line.id")?; validate_required_field(&line.reason, "economics.line.reason")?; - if line.amount.currency != expected_currency { + if line.amount.currency() != expected_currency { return Err(RadrootsOrderPayloadError::InvalidEconomicCurrency { field }); } - if line.amount.amount.is_zero() || line.amount.amount.is_sign_negative() { + if line.amount.amount().is_zero() || line.amount.amount().is_sign_negative() { return Err(RadrootsOrderPayloadError::InvalidEconomicLineAmount { field, index }); } Ok(()) @@ -662,81 +659,74 @@ fn validate_economic_line_order( } fn validate_total_money( - money: &RadrootsCoreMoney, - expected_currency: RadrootsCoreCurrency, + money: &Money, + expected_currency: Currency, field: &'static str, ) -> Result<(), RadrootsOrderPayloadError> { - if money.currency != expected_currency { + if money.currency() != expected_currency { return Err(RadrootsOrderPayloadError::InvalidEconomicCurrency { field }); } - if money.amount.is_sign_negative() { + if money.amount().is_sign_negative() { return Err(RadrootsOrderPayloadError::InvalidEconomicTotal { field }); } Ok(()) } fn validate_total_matches( - actual: &RadrootsCoreMoney, - expected: &RadrootsCoreMoney, + actual: &Money, + expected: &Money, field: &'static str, ) -> Result<(), RadrootsOrderPayloadError> { - if actual.currency != expected.currency { + if actual.currency() != expected.currency() { return Err(RadrootsOrderPayloadError::InvalidEconomicCurrency { field }); } - if actual.amount != expected.amount { + if actual.amount() != expected.amount() { return Err(RadrootsOrderPayloadError::InvalidEconomicTotal { field }); } Ok(()) } -fn checked_decimal_add( - left: RadrootsCoreDecimal, - right: RadrootsCoreDecimal, -) -> Option<RadrootsCoreDecimal> { - left.0.checked_add(right.0).map(RadrootsCoreDecimal) +fn checked_decimal_add(left: Decimal, right: Decimal) -> Option<Decimal> { + left.checked_add(right).ok() } -fn checked_decimal_sub( - left: RadrootsCoreDecimal, - right: RadrootsCoreDecimal, -) -> Option<RadrootsCoreDecimal> { - left.0.checked_sub(right.0).map(RadrootsCoreDecimal) +fn checked_decimal_sub(left: Decimal, right: Decimal) -> Option<Decimal> { + left.checked_sub(right).ok() } -fn checked_decimal_mul( - left: RadrootsCoreDecimal, - right: RadrootsCoreDecimal, -) -> Option<RadrootsCoreDecimal> { - left.0.checked_mul(right.0).map(RadrootsCoreDecimal) +fn checked_decimal_mul(left: Decimal, right: Decimal) -> Option<Decimal> { + left.checked_mul(right).ok() } fn checked_money_add( - left: &RadrootsCoreMoney, - right: &RadrootsCoreMoney, + left: &Money, + right: &Money, field: &'static str, -) -> Result<RadrootsCoreMoney, RadrootsOrderPayloadError> { - if left.currency != right.currency { +) -> Result<Money, RadrootsOrderPayloadError> { + if left.currency() != right.currency() { return Err(RadrootsOrderPayloadError::InvalidEconomicCurrency { field }); } - let amount = checked_decimal_add(left.amount, right.amount) + let amount = checked_decimal_add(left.amount(), right.amount()) .ok_or(RadrootsOrderPayloadError::InvalidEconomicTotal { field })?; - Ok(RadrootsCoreMoney::new(amount, left.currency)) + Money::try_new(amount, left.currency()) + .map_err(|_| RadrootsOrderPayloadError::InvalidEconomicTotal { field }) } fn checked_money_sub_non_negative( - left: &RadrootsCoreMoney, - right: &RadrootsCoreMoney, + left: &Money, + right: &Money, field: &'static str, -) -> Result<RadrootsCoreMoney, RadrootsOrderPayloadError> { - if left.currency != right.currency { +) -> Result<Money, RadrootsOrderPayloadError> { + if left.currency() != right.currency() { return Err(RadrootsOrderPayloadError::InvalidEconomicCurrency { field }); } - let amount = checked_decimal_sub(left.amount, right.amount) + let amount = checked_decimal_sub(left.amount(), right.amount()) .ok_or(RadrootsOrderPayloadError::InvalidEconomicTotal { field })?; if amount.is_sign_negative() { return Err(RadrootsOrderPayloadError::InvalidEconomicTotal { field }); } - Ok(RadrootsCoreMoney::new(amount, left.currency)) + Money::try_new(amount, left.currency()) + .map_err(|_| RadrootsOrderPayloadError::InvalidEconomicTotal { field }) } fn validate_inventory_commitments( @@ -757,22 +747,17 @@ fn validate_inventory_commitments( #[cfg(test)] mod tests { use super::*; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, - }; + use radroots_core::{Currency, Decimal, Money, Unit}; - fn pubkey(character: char) -> RadrootsPublicKey { - core::iter::repeat_n(character, 64) - .collect::<String>() - .parse() - .unwrap() + fn pubkey(character: char) -> PublicKey { + crate::test_valid_hex_64(character).parse().unwrap() } - fn buyer_pubkey() -> RadrootsPublicKey { + fn buyer_pubkey() -> PublicKey { pubkey('b') } - fn seller_pubkey() -> RadrootsPublicKey { + fn seller_pubkey() -> PublicKey { pubkey('a') } @@ -808,12 +793,16 @@ mod tests { } } - fn decimal(raw: &str) -> RadrootsCoreDecimal { + fn decimal(raw: &str) -> Decimal { raw.parse().unwrap() } - fn usd(raw: &str) -> RadrootsCoreMoney { - RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD) + fn usd(raw: &str) -> Money { + money(raw, Currency::USD) + } + + fn money(raw: &str, currency: Currency) -> Money { + Money::try_new(decimal(raw), currency).unwrap() } fn sample_order_economics() -> RadrootsOrderEconomics { @@ -821,24 +810,24 @@ mod tests { quote_id: quote_id("quote-1"), quote_version: 1, pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency: RadrootsCoreCurrency::USD, + currency: Currency::USD, items: vec![ RadrootsOrderEconomicItem { bin_id: bin_id("bin-a"), bin_count: 2, quantity_amount: decimal("1.5"), - quantity_unit: RadrootsCoreUnit::Each, + quantity_unit: Unit::Each, unit_price_amount: decimal("4"), - unit_price_currency: RadrootsCoreCurrency::USD, + unit_price_currency: Currency::USD, line_subtotal: usd("12"), }, RadrootsOrderEconomicItem { bin_id: bin_id("bin-b"), bin_count: 1, quantity_amount: decimal("2"), - quantity_unit: RadrootsCoreUnit::Each, + quantity_unit: Unit::Each, unit_price_amount: decimal("3"), - unit_price_currency: RadrootsCoreCurrency::USD, + unit_price_currency: Currency::USD, line_subtotal: usd("6"), }, ], @@ -880,14 +869,14 @@ mod tests { quote_id: quote_id("quote-bound-1"), quote_version: 1, pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency: RadrootsCoreCurrency::USD, + currency: Currency::USD, items: vec![RadrootsOrderEconomicItem { bin_id: bin_id("bin-1"), bin_count: 2, quantity_amount: decimal("1"), - quantity_unit: RadrootsCoreUnit::Each, + quantity_unit: Unit::Each, unit_price_amount: decimal("5"), - unit_price_currency: RadrootsCoreCurrency::USD, + unit_price_currency: Currency::USD, line_subtotal: usd("10"), }], discounts: Vec::new(), @@ -1128,7 +1117,7 @@ mod tests { #[test] fn order_economics_validation_rejects_mixed_currency() { let mut economics = sample_order_economics(); - economics.items[0].unit_price_currency = RadrootsCoreCurrency::EUR; + economics.items[0].unit_price_currency = Currency::EUR; assert_eq!( economics.validate().unwrap_err(), RadrootsOrderPayloadError::InvalidEconomicCurrency { @@ -1137,8 +1126,7 @@ mod tests { ); let mut economics = sample_order_economics(); - economics.adjustments[0].amount = - RadrootsCoreMoney::new(decimal("2"), RadrootsCoreCurrency::EUR); + economics.adjustments[0].amount = money("2", Currency::EUR); assert_eq!( economics.validate().unwrap_err(), RadrootsOrderPayloadError::InvalidEconomicCurrency { @@ -1164,8 +1152,7 @@ mod tests { ); let mut economics = sample_order_economics(); - economics.items[0].line_subtotal = - RadrootsCoreMoney::new(decimal("12"), RadrootsCoreCurrency::EUR); + economics.items[0].line_subtotal = money("12", Currency::EUR); assert_eq!( economics.validate().unwrap_err(), RadrootsOrderPayloadError::InvalidEconomicCurrency { @@ -1222,20 +1209,13 @@ mod tests { ); let mut economics = sample_order_economics(); - economics.subtotal = RadrootsCoreMoney::new(decimal("18"), RadrootsCoreCurrency::EUR); + economics.subtotal = money("18", Currency::EUR); assert_eq!( economics.validate().unwrap_err(), RadrootsOrderPayloadError::InvalidEconomicCurrency { field: "subtotal" } ); let mut economics = sample_order_economics(); - economics.subtotal = usd("-1"); - assert_eq!( - economics.validate().unwrap_err(), - RadrootsOrderPayloadError::InvalidEconomicTotal { field: "subtotal" } - ); - - let mut economics = sample_order_economics(); economics.discount_total = usd("4"); assert_eq!( economics.validate().unwrap_err(), @@ -1335,49 +1315,21 @@ mod tests { index: 0 } ); - - let mut economics = sample_order_economics(); - economics.adjustments[0].amount = usd("-1"); - assert_eq!( - economics.validate().unwrap_err(), - RadrootsOrderPayloadError::InvalidEconomicLineAmount { - field: "adjustments", - index: 0 - } - ); } #[test] fn order_economics_helpers_cover_currency_error_paths() { assert_eq!( - validate_total_money(&usd("-1"), RadrootsCoreCurrency::USD, "subtotal").unwrap_err(), - RadrootsOrderPayloadError::InvalidEconomicTotal { field: "subtotal" } - ); - assert_eq!( - validate_total_matches( - &usd("1"), - &RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::EUR), - "total" - ) - .unwrap_err(), + validate_total_matches(&usd("1"), &money("1", Currency::EUR), "total").unwrap_err(), RadrootsOrderPayloadError::InvalidEconomicCurrency { field: "total" } ); assert_eq!( - checked_money_add( - &usd("1"), - &RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::EUR), - "subtotal" - ) - .unwrap_err(), + checked_money_add(&usd("1"), &money("1", Currency::EUR), "subtotal").unwrap_err(), RadrootsOrderPayloadError::InvalidEconomicCurrency { field: "subtotal" } ); assert_eq!( - checked_money_sub_non_negative( - &usd("1"), - &RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::EUR), - "total" - ) - .unwrap_err(), + checked_money_sub_non_negative(&usd("1"), &money("1", Currency::EUR), "total") + .unwrap_err(), RadrootsOrderPayloadError::InvalidEconomicCurrency { field: "total" } ); } diff --git a/crates/event/src/order_economics.rs b/crates/event/src/order_economics.rs @@ -3,9 +3,7 @@ #[cfg(not(feature = "std"))] use alloc::{string::String, vec::Vec}; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, -}; +use radroots_core::{Currency, Decimal, Money, Unit}; use crate::ids::{RadrootsInventoryBinId, RadrootsOrderQuoteId}; @@ -99,11 +97,16 @@ pub enum RadrootsOrderEconomicEffect { pub struct RadrootsOrderEconomicItem { pub bin_id: RadrootsInventoryBinId, pub bin_count: u32, - pub quantity_amount: RadrootsCoreDecimal, - pub quantity_unit: RadrootsCoreUnit, - pub unit_price_amount: RadrootsCoreDecimal, - pub unit_price_currency: RadrootsCoreCurrency, - pub line_subtotal: RadrootsCoreMoney, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Decimal")))] + pub quantity_amount: Decimal, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Unit")))] + pub quantity_unit: Unit, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Decimal")))] + pub unit_price_amount: Decimal, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Currency")))] + pub unit_price_currency: Currency, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Money")))] + pub line_subtotal: Money, } #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] @@ -119,7 +122,8 @@ pub struct RadrootsOrderEconomicLine { pub kind: RadrootsOrderEconomicLineKind, pub actor: RadrootsOrderEconomicActor, pub effect: RadrootsOrderEconomicEffect, - pub amount: RadrootsCoreMoney, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Money")))] + pub amount: Money, pub reason: String, } @@ -132,10 +136,14 @@ pub struct RadrootsOrderEconomicLine { )] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsOrderEconomicTotals { - pub subtotal: RadrootsCoreMoney, - pub discount_total: RadrootsCoreMoney, - pub adjustment_total: RadrootsCoreMoney, - pub total: RadrootsCoreMoney, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Money")))] + pub subtotal: Money, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Money")))] + pub discount_total: Money, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Money")))] + pub adjustment_total: Money, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Money")))] + pub total: Money, } #[cfg_attr( @@ -149,12 +157,17 @@ pub struct RadrootsOrderEconomics { pub quote_id: RadrootsOrderQuoteId, pub quote_version: u32, pub pricing_basis: RadrootsOrderPricingBasis, - pub currency: RadrootsCoreCurrency, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Currency")))] + pub currency: Currency, pub items: Vec<RadrootsOrderEconomicItem>, pub discounts: Vec<RadrootsOrderEconomicLine>, pub adjustments: Vec<RadrootsOrderEconomicLine>, - pub subtotal: RadrootsCoreMoney, - pub discount_total: RadrootsCoreMoney, - pub adjustment_total: RadrootsCoreMoney, - pub total: RadrootsCoreMoney, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Money")))] + pub subtotal: Money, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Money")))] + pub discount_total: Money, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Money")))] + pub adjustment_total: Money, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Money")))] + pub total: Money, } diff --git a/crates/event/src/post.rs b/crates/event/src/post.rs @@ -2,7 +2,7 @@ use alloc::{format, string::String, vec::Vec}; use core::fmt; -use radroots_blossom::url::RadrootsBlossomApprovedBlobUrl; +use radroots_blossom::url::ApprovedBlobUrl; use url_nostd::Url; use crate::media::RadrootsAuthoredImage; @@ -227,7 +227,7 @@ pub struct RadrootsAuthoredPostImage { image: RadrootsAuthoredImage, dimensions: RadrootsPostImageDimensions, alt: String, - fallbacks: Vec<RadrootsBlossomApprovedBlobUrl>, + fallbacks: Vec<ApprovedBlobUrl>, imeta_tag: Vec<String>, } @@ -267,7 +267,7 @@ impl RadrootsAuthoredPostImage { pub fn try_with_fallback( mut self, - fallback: RadrootsBlossomApprovedBlobUrl, + fallback: ApprovedBlobUrl, ) -> Result<Self, RadrootsAuthoredPostError> { if fallback.as_blob_url().hash_path().hash() != self.image.descriptor().sha256() { return Err(RadrootsAuthoredPostError::ImageFallbackHashMismatch); @@ -294,7 +294,7 @@ impl RadrootsAuthoredPostImage { &self.alt } - pub fn fallbacks(&self) -> &[RadrootsBlossomApprovedBlobUrl] { + pub fn fallbacks(&self) -> &[ApprovedBlobUrl] { &self.fallbacks } @@ -450,7 +450,7 @@ fn derive_imeta_tag( image: &RadrootsAuthoredImage, dimensions: RadrootsPostImageDimensions, alt: &str, - fallbacks: &[RadrootsBlossomApprovedBlobUrl], + fallbacks: &[ApprovedBlobUrl], ) -> Result<Vec<String>, RadrootsAuthoredPostError> { let descriptor = image.descriptor(); let mut tag = Vec::with_capacity(7 + fallbacks.len()); diff --git a/crates/event/src/profile.rs b/crates/event/src/profile.rs @@ -336,10 +336,7 @@ impl RadrootsAuthoredProfile { #[cfg(test)] mod tests { use super::*; - use radroots_blossom::{ - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, - RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomMediaType, RadrootsBlossomSha256, - }; + use radroots_blossom::{BlobDescriptor, BlobUrl, ByteVerifiedDescriptor, MediaType, Sha256}; #[test] fn maps_profile_type_to_tag_value() { @@ -535,16 +532,12 @@ mod tests { } } - fn verified_descriptor( - media_type: &str, - extension: &str, - ) -> RadrootsBlossomByteVerifiedDescriptor { + fn verified_descriptor(media_type: &str, extension: &str) -> ByteVerifiedDescriptor { let bytes = b"hello"; - let hash = RadrootsBlossomSha256::digest(bytes); - let media_type = RadrootsBlossomMediaType::parse(media_type).unwrap(); - RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&format!("https://media.example/{hash}.{extension}")) - .unwrap(), + let hash = Sha256::digest(bytes); + let media_type = MediaType::parse(media_type).unwrap(); + BlobDescriptor::new( + BlobUrl::parse(&format!("https://media.example/{hash}.{extension}")).unwrap(), hash, bytes.len() as u64, media_type.clone(), diff --git a/crates/event/src/reply.rs b/crates/event/src/reply.rs @@ -3,7 +3,7 @@ use alloc::string::String; use core::fmt; use crate::{ - ids::{RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey}, + ids::{PublicKey, RadrootsEventId, RadrootsIdParseError, parse_public_key}, post::{ RADROOTS_POST_CONTENT_MAX_BYTES, RADROOTS_POST_EVENT_WIRE_MAX_BYTES, RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, RADROOTS_POST_TAG_TOTAL_MAX_BYTES, @@ -116,14 +116,14 @@ impl std::error::Error for RadrootsNip10ReplyError { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsNip10ReplyReference { event_id: RadrootsEventId, - author: RadrootsPublicKey, + author: PublicKey, relay: Option<RadrootsNostrRelayHint>, } impl RadrootsNip10ReplyReference { pub fn new( event_id: RadrootsEventId, - author: RadrootsPublicKey, + author: PublicKey, relay: Option<RadrootsNostrRelayHint>, ) -> Result<Self, RadrootsNip10ReplyError> { if let Some(relay) = &relay { @@ -143,8 +143,7 @@ impl RadrootsNip10ReplyReference { ) -> Result<Self, RadrootsNip10ReplyError> { let event_id = RadrootsEventId::parse(event_id).map_err(RadrootsNip10ReplyError::EventIdInvalid)?; - let author = - RadrootsPublicKey::parse(author).map_err(RadrootsNip10ReplyError::AuthorInvalid)?; + let author = parse_public_key(author).map_err(RadrootsNip10ReplyError::AuthorInvalid)?; let relay = match relay { None | Some("") => None, Some(relay) => Some( @@ -159,7 +158,7 @@ impl RadrootsNip10ReplyReference { &self.event_id } - pub const fn author(&self) -> &RadrootsPublicKey { + pub const fn author(&self) -> &PublicKey { &self.author } @@ -296,14 +295,14 @@ fn validate_authored_reply_wire_size( &mut tag_bytes, &mut tags_json_bytes, &mut tag_count, - &["p", root.author.as_str()], + &["p", root.author.to_hex().as_str()], ); if let Some(parent) = parent.filter(|parent| parent.author != root.author) { add_tag( &mut tag_bytes, &mut tags_json_bytes, &mut tag_count, - &["p", parent.author.as_str()], + &["p", parent.author.to_hex().as_str()], ); } @@ -362,7 +361,7 @@ mod tests { fn reference(event: char, author: char) -> RadrootsNip10ReplyReference { RadrootsNip10ReplyReference::parse( event.to_string().repeat(64), - author.to_string().repeat(64), + crate::test_valid_hex_64(author), Some("wss://relay.example"), ) .expect("reference") @@ -404,19 +403,20 @@ mod tests { fn parses_and_canonicalizes_reference_identifiers() { let reference = RadrootsNip10ReplyReference::parse( "A".repeat(64), - "B".repeat(64), + crate::test_valid_hex_64('B'), Some("wss://relay.example"), ) .expect("reference"); assert_eq!(reference.event_id().as_str(), "a".repeat(64)); - assert_eq!(reference.author().as_str(), "b".repeat(64)); + assert_eq!(reference.author().to_hex(), crate::test_valid_hex_64('b')); assert_eq!( reference.relay().expect("relay").as_str(), "wss://relay.example" ); let error = - RadrootsNip10ReplyReference::parse("not-an-id", "b".repeat(64), None).unwrap_err(); + RadrootsNip10ReplyReference::parse("not-an-id", crate::test_valid_hex_64('b'), None) + .unwrap_err(); assert_eq!(error.code(), "reply_event_id_invalid"); } @@ -441,13 +441,17 @@ mod tests { "{prefix}{}", "a".repeat(RADROOTS_POST_TAG_ELEMENT_MAX_BYTES - prefix.len()) ); - RadrootsNip10ReplyReference::parse("a".repeat(64), "b".repeat(64), Some(&exact_relay)) - .expect("exact tag-element limit"); + RadrootsNip10ReplyReference::parse( + "a".repeat(64), + crate::test_valid_hex_64('b'), + Some(&exact_relay), + ) + .expect("exact tag-element limit"); let overflow_relay = format!("{exact_relay}a"); assert!(matches!( RadrootsNip10ReplyReference::parse( "a".repeat(64), - "b".repeat(64), + crate::test_valid_hex_64('b'), Some(&overflow_relay), ), Err(RadrootsNip10ReplyError::TagElementTooLarge { diff --git a/crates/event/src/repost.rs b/crates/event/src/repost.rs @@ -40,13 +40,13 @@ mod tests { fn repost_models_represent_note_and_generic_targets() { let note_target = RadrootsSocialTarget::Event { id: "a".repeat(64), - author: Some("b".repeat(64)), + author: Some(crate::test_valid_hex_64('b')), event_kind: Some(1), relays: None, }; let article_target = RadrootsSocialTarget::Address { address: "30023:pubkey:article".to_string(), - author: Some("b".repeat(64)), + author: Some(crate::test_valid_hex_64('b')), event_kind: Some(30023), relays: Some(vec!["wss://relay.example".to_string()]), }; diff --git a/crates/event/src/resource_cap.rs b/crates/event/src/resource_cap.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] -use radroots_core::{RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreUnit}; +use radroots_core::{Decimal, Quantity, Unit}; use crate::resource_area::RadrootsResourceAreaRef; @@ -34,9 +34,12 @@ pub struct RadrootsResourceHarvestCap { pub start: u64, #[cfg_attr(feature = "dto-bindgen", dto(int = "json_string"))] pub end: u64, - pub cap_quantity: RadrootsCoreQuantity, - pub display_amount: Option<RadrootsCoreDecimal>, - pub display_unit: Option<RadrootsCoreUnit>, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Quantity")))] + pub cap_quantity: Quantity, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Decimal")))] + pub display_amount: Option<Decimal>, + #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Unit")))] + pub display_unit: Option<Unit>, pub display_label: Option<String>, pub tags: Option<Vec<String>>, } diff --git a/crates/event/src/social.rs b/crates/event/src/social.rs @@ -149,7 +149,7 @@ mod tests { fn supports_nip22_target_shapes() { let event = RadrootsSocialTarget::Event { id: "a".repeat(64), - author: Some("b".repeat(64)), + author: Some(crate::test_valid_hex_64('b')), event_kind: Some(30023), relays: Some(vec!["wss://relay.example".to_string()]), }; diff --git a/crates/event/src/tag.rs b/crates/event/src/tag.rs @@ -0,0 +1,7 @@ +//! Canonical event-tag types. +//! +//! Existing tag behavior moves here during module normalization. + +/// Internal marker for the target module while tag types are migrated. +#[allow(dead_code)] +pub(crate) struct ModuleScaffold; diff --git a/crates/event/src/trade.rs b/crates/event/src/trade.rs @@ -12,8 +12,8 @@ use std::{string::String, vec::Vec}; use core::fmt; use crate::ids::{ - RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsEventId, RadrootsIdParseError, - RadrootsInventoryBinId, RadrootsPublicKey, RadrootsTradeCandidateId, RadrootsTradeId, + PublicKey, RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsEventId, + RadrootsIdParseError, RadrootsInventoryBinId, RadrootsTradeCandidateId, RadrootsTradeId, RadrootsTradeMutationId, }; use crate::kinds::{ @@ -94,12 +94,12 @@ pub struct RadrootsTradeMutationEnvelopeV1 { pub schema_version: u16, pub trade_id: RadrootsTradeId, pub root_mutation_id: Option<RadrootsTradeMutationId>, - pub buyer_pubkey: RadrootsPublicKey, - pub seller_pubkey: RadrootsPublicKey, + pub buyer_pubkey: PublicKey, + pub seller_pubkey: PublicKey, pub farm_id: RadrootsDTag, pub parent_mutation_ids: Vec<RadrootsTradeMutationId>, - pub author_pubkey: RadrootsPublicKey, - pub counterparty_pubkey: RadrootsPublicKey, + pub author_pubkey: PublicKey, + pub counterparty_pubkey: PublicKey, pub authored_at_unix_s: u64, pub body: RadrootsTradeMutationBodyV1, } @@ -217,8 +217,8 @@ pub struct RadrootsTradeCandidateTermsV1 { pub schema_version: u16, pub base_candidate_id: Option<RadrootsTradeCandidateId>, pub supersession_intent: Option<String>, - pub buyer_pubkey: RadrootsPublicKey, - pub seller_pubkey: RadrootsPublicKey, + pub buyer_pubkey: PublicKey, + pub seller_pubkey: PublicKey, pub farm_id: RadrootsDTag, pub lines: Vec<RadrootsTradeCandidateLineV1>, pub line_tombstones: Vec<RadrootsTradeLineTombstoneV1>, @@ -451,7 +451,7 @@ impl RadrootsTradePrivateTermsRefV1 { #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct RadrootsSellerReservationAssertionV1 { pub reservation_id: RadrootsDTag, - pub inventory_authority_id: RadrootsPublicKey, + pub inventory_authority_id: PublicKey, pub inventory_epoch: u64, pub candidate_id: RadrootsTradeCandidateId, pub commitments: Vec<RadrootsSellerReservationLineV1>, @@ -1076,6 +1076,7 @@ impl<'de> Visitor<'de> for NoDuplicateJsonValueVisitor { #[cfg(all(test, feature = "serde"))] mod tests { use super::*; + use crate::ids::parse_public_key; fn hex_64(character: char) -> String { core::iter::repeat_n(character, 64).collect() @@ -1085,8 +1086,8 @@ mod tests { core::iter::repeat_n(character, 32).collect() } - fn pubkey(character: char) -> RadrootsPublicKey { - RadrootsPublicKey::parse(hex_64(character)).unwrap() + fn pubkey(character: char) -> PublicKey { + parse_public_key(crate::test_valid_hex_64(character)).unwrap() } fn event_id(character: char) -> RadrootsEventId { @@ -1118,7 +1119,7 @@ mod tests { line_id: RadrootsDTag::parse("line-1").unwrap(), listing_addr: RadrootsClassifiedListingAddress::parse(format!( "30402:{}:listing-1", - hex_64('b') + crate::test_valid_hex_64('b') )) .unwrap(), listing_event_id: event_id('c'), @@ -1559,7 +1560,7 @@ mod tests { fn trade_candidate_deserialization_rejects_non_classified_listing_coordinates() { let mut value = serde_json::to_value(candidate()).expect("candidate json"); value["lines"][0]["listing_addr"] = - serde_json::json!(format!("30023:{}:listing-1", hex_64('b'))); + serde_json::json!(format!("30023:{}:listing-1", crate::test_valid_hex_64('b'))); assert!(serde_json::from_value::<RadrootsTradeCandidateTermsV1>(value).is_err()); } diff --git a/crates/event/src/wire/v1.rs b/crates/event/src/wire/v1.rs @@ -15,9 +15,7 @@ use std::{collections::BTreeMap, string::String, vec::Vec}; use crate::envelope::{ RadrootsEventEnvelope, RadrootsEventEnvelopeError, RadrootsEventEnvelopeParts, }; -use crate::ids::{ - RadrootsEventId, RadrootsEventSignature, RadrootsIdParseError, RadrootsPublicKey, -}; +use crate::ids::{RadrootsEventId, RadrootsEventSignature, RadrootsIdParseError, parse_public_key}; use core::fmt; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; @@ -376,8 +374,8 @@ pub fn canonical_nip01_event_id_preimage_v1( tags: &[Vec<String>], content: &str, ) -> Result<String, RadrootsCanonicalEventIdError> { - let pubkey = - RadrootsPublicKey::parse(pubkey).map_err(RadrootsCanonicalEventIdError::InvalidPubkey)?; + let pubkey = parse_public_key(pubkey).map_err(RadrootsCanonicalEventIdError::InvalidPubkey)?; + let pubkey = pubkey.to_hex(); let mut preimage = String::new(); preimage.push_str("[0,"); push_canonical_json_string(&mut preimage, pubkey.as_str()); @@ -453,13 +451,13 @@ fn take_canonical_pubkey( object: &mut Map<String, Value>, ) -> Result<String, RadrootsEventWireError> { let raw = take_string(object, "pubkey")?; - let parsed = RadrootsPublicKey::parse(raw.as_str()).map_err(|error| { + let parsed = parse_public_key(raw.as_str()).map_err(|error| { RadrootsEventWireError::InvalidIdentifier { field: "pubkey", error, } })?; - canonical_identifier_string("pubkey", raw, parsed.into_string()) + canonical_identifier_string("pubkey", raw, parsed.to_hex()) } fn take_canonical_signature( diff --git a/crates/event/src/wire/v1/tests.rs b/crates/event/src/wire/v1/tests.rs @@ -2,7 +2,7 @@ use super::*; use serde_json::json; fn hex_64(character: char) -> String { - core::iter::repeat_n(character, 64).collect() + crate::test_valid_hex_64(character) } fn hex_128(character: char) -> String { diff --git a/crates/event/tests/package_boundary.rs b/crates/event/tests/package_boundary.rs @@ -0,0 +1,96 @@ +use std::collections::BTreeSet; + +#[cfg(feature = "knowledge")] +#[allow(unused_imports)] +use radroots_event::knowledge as _; +#[allow(unused_imports)] +use radroots_event::{ + admission as _, calendar as _, contract as _, draft as _, envelope as _, farm as _, food as _, + id as _, listing as _, media as _, post as _, profile as _, social as _, tag as _, trade as _, + wire as _, +}; + +const MANIFEST: &str = include_str!("../Cargo.toml"); +const ROOT: &str = include_str!("../src/lib.rs"); + +#[test] +fn manifest_has_final_identity_and_required_radroots_dependencies() { + assert!(MANIFEST.contains("name = \"radroots_event\"")); + assert!(MANIFEST.contains("version = \"0.1.0-alpha\"")); + assert!(MANIFEST.contains("publish = false")); + assert!(MANIFEST.contains("[lib]\nname = \"radroots_event\"")); + assert!(MANIFEST.contains("default = [\"std\", \"serde\"]")); + assert_eq!( + table_keys(MANIFEST, "[dependencies]") + .into_iter() + .filter(|dependency| dependency.starts_with("radroots_")) + .collect::<BTreeSet<_>>(), + BTreeSet::from([ + "radroots_blossom", + "radroots_core", + "radroots_identity", + "radroots_protocol", + ]) + ); + assert!( + MANIFEST.contains("radroots_protocol = { workspace = true, default-features = false }") + ); +} + +#[test] +fn crate_root_declares_every_approved_module() { + let declared = root_declarations("pub mod "); + for module in [ + "admission", + "calendar", + "contract", + "draft", + "envelope", + "farm", + "food", + "id", + "knowledge", + "listing", + "media", + "post", + "profile", + "social", + "tag", + "trade", + "wire", + ] { + assert!( + declared.contains(module), + "missing approved module {module}" + ); + } +} + +fn table_keys<'a>(manifest: &'a str, heading: &str) -> BTreeSet<&'a str> { + let Some((_, table)) = manifest.split_once(heading) else { + return BTreeSet::new(); + }; + table + .lines() + .skip(1) + .take_while(|line| !line.trim_start().starts_with('[')) + .filter_map(|line| { + let line = line.trim(); + (line + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte == b'_') + && !line.starts_with('#')) + .then(|| line.split_once('=').map(|(key, _)| key.trim())) + .flatten() + }) + .collect() +} + +fn root_declarations(prefix: &str) -> BTreeSet<&str> { + ROOT.lines() + .map(str::trim) + .filter_map(|line| line.strip_prefix(prefix)) + .filter_map(|name| name.strip_suffix(';')) + .collect() +} diff --git a/crates/event/tests/source_boundary.rs b/crates/event/tests/source_boundary.rs @@ -8,6 +8,10 @@ struct ForbiddenEventName { const FORBIDDEN_EVENT_NAMES: &[ForbiddenEventName] = &[ ForbiddenEventName { + pattern: "RadrootsPublicKey", + reason: "public author keys must use radroots_identity::PublicKey", + }, + ForbiddenEventName { pattern: "pub fn from_wire_unchecked", reason: "unchecked signed-event construction must not be public API", }, diff --git a/crates/event_codec/Cargo.toml b/crates/event_codec/Cargo.toml @@ -1,21 +1,34 @@ [package] name = "radroots_event_codec" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] +authors.workspace = true rust-version.workspace = true license.workspace = true description = "Event codec layer for Radroots" repository.workspace = true homepage.workspace = true documentation = "https://docs.rs/radroots_event_codec" -readme = "README" +readme = "README.md" + +[lib] +name = "radroots_event_codec" [features] default = ["std"] -std = ["radroots_blossom/std", "radroots_core/std", "radroots_event/std"] -serde = ["dep:serde", "radroots_core/serde", "radroots_event/serde"] +std = [ + "radroots_blossom/std", + "radroots_core/std", + "radroots_event/std", + "radroots_identity/std", +] +serde = [ + "dep:serde", + "radroots_core/serde", + "radroots_event/serde", + "radroots_identity/serde", +] serde_json = [ "serde", "dep:hex", @@ -32,6 +45,7 @@ contract-manifest = ["knowledge", "dep:hex", "dep:sha2"] radroots_blossom = { workspace = true, default-features = false } radroots_core = { workspace = true, default-features = false } radroots_event = { workspace = true, default-features = false } +radroots_identity = { workspace = true, default-features = false } serde = { workspace = true, default-features = false, features = [ "alloc", ], optional = true } @@ -53,5 +67,5 @@ radroots_blossom = { workspace = true, default-features = false, features = [ radroots_test_fixtures = { workspace = true } serde_json = { workspace = true, features = ["std"] } -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } +[lints] +workspace = true diff --git a/crates/event_codec/README b/crates/event_codec/README.md diff --git a/crates/event_codec/contracts/phase1_publication_media_readiness_v1.descriptor.json b/crates/event_codec/contracts/phase1_publication_media_readiness_v1.descriptor.json @@ -7,22 +7,22 @@ }, "contract_id": "radroots_event_codec.phase1_publication_media_readiness_v1", "manifest": { - "byte_length": 14193, + "byte_length": 15240, "hash_algorithm": "sha256_bytes_v1", "path": "crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.json", - "sha256": "97bcb8b03defd0e6058bddf1b9714347e3917e1188e6fdd1513f236cc11e95d9" + "sha256": "2f9644668cc1e736c0f42f5c9566b6fb74af557d4e29a424d5ccc06af4e6fa82" }, "manifest_schema": { - "byte_length": 2628, + "byte_length": 2845, "hash_algorithm": "sha256_bytes_v1", "path": "crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.schema.json", - "sha256": "7c0a9ec9aa36959dbf705dbf4273e254351a0e286d1bbadf0980f4fd883e2f7d" + "sha256": "5ba2b98ab59deb24b2d07f3df763560a421f47a68ea9973b72689d91bb790239" }, "manifest_sidecar": { "byte_length": 65, "hash_algorithm": "sha256_bytes_v1", "path": "crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.sha256", - "sha256": "78980b567981907d6bd4be58584cd60e53010badc2c9868be2a70300e335114c" + "sha256": "9d5841c2eae8d2ffb1172a4944b182bdf7a6292a877adc1fcc58ac3c3b4d1c74" }, "predecessor_contract_ids": [ "radroots_event_codec.phase1_publication_allowlist_v1", diff --git a/crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.json b/crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.json @@ -4,8 +4,8 @@ "authority_id": "phase1_publication_media_readiness_v1", "manifest_schema": { "path": "crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.schema.json", - "byte_length": 2628, - "sha256": "7c0a9ec9aa36959dbf705dbf4273e254351a0e286d1bbadf0980f4fd883e2f7d", + "byte_length": 2845, + "sha256": "5ba2b98ab59deb24b2d07f3df763560a421f47a68ea9973b72689d91bb790239", "hash_algorithm": "sha256_bytes_v1" }, "predecessors": [ @@ -55,12 +55,21 @@ "hash_algorithm": "sha256_bytes_v1" } ], + "artifact_supersessions": [ + { + "path": "crates/event_codec/tests/publication_artifact.rs", + "byte_length": 34346, + "sha256": "0e6d8b67b1793d66f4030d657cf09f86f66ad75226a19c4ef2a1fa2154534e14", + "hash_algorithm": "sha256_bytes_v1" + } + ], "source_supersessions": [ "CHANGELOG.md", "contracts/operations.toml", "contracts/releases/1.0.0-alpha.1.toml", "crates/event_codec/Cargo.toml", "crates/event_codec/src/wire/publication.rs", + "crates/event_codec/tests/publication_artifact.rs", "tools/xtask/src/contract.rs", "tools/xtask/src/contract/nip09_reconciliation.rs", "tools/xtask/src/contract/phase1_publication_artifact.rs", @@ -138,10 +147,26 @@ "hash_algorithm": "sha256_bytes_v1" } ], + "artifact_supersessions": [ + { + "path": "crates/blossom/tests/publication_readiness.rs", + "byte_length": 31933, + "sha256": "48796d2e4c171187fbf2efc66c5dd14e0510fc724baab5ffbd1dc156ba762c4b", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/blossom/tests/publication_readiness_persistence.rs", + "byte_length": 11410, + "sha256": "57c8576a1121c66996492eff79beada9f6a7287ec5fede295411dbb821ec4fad", + "hash_algorithm": "sha256_bytes_v1" + } + ], "source_supersessions": [ "CHANGELOG.md", "contracts/operations.toml", "contracts/releases/1.0.0-alpha.1.toml", + "crates/blossom/tests/publication_readiness.rs", + "crates/blossom/tests/publication_readiness_persistence.rs", "tools/xtask/src/contract.rs", "tools/xtask/src/contract/nip09_reconciliation.rs", "tools/xtask/src/contract/phase1_publication_artifact.rs", @@ -286,8 +311,8 @@ "hash_algorithm": "sha256_bytes_v1", "executor": { "path": "crates/event_codec/tests/publication_media_readiness.rs", - "byte_length": 22995, - "sha256": "3de03a5aa1d8b37083efabac44c423659b263ebf8597831b056fdd257f333b85", + "byte_length": 22915, + "sha256": "773cd2b34348c3014b2a7ca4518116782cd8f35d444aea4f3a345f96025f23fa", "hash_algorithm": "sha256_bytes_v1" }, "executor_test": "phase1_publication_media_readiness_vector_executes_every_case", diff --git a/crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.schema.json b/crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.schema.json @@ -51,6 +51,13 @@ "items": { "additionalProperties": false, "properties": { + "artifact_supersessions": { + "items": { + "$ref": "#/$defs/file" + }, + "minItems": 1, + "type": "array" + }, "contract_id": { "minLength": 1, "type": "string" @@ -75,6 +82,7 @@ "required": [ "contract_id", "immutable_artifacts", + "artifact_supersessions", "source_supersessions" ], "type": "object" diff --git a/crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.sha256 b/crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.sha256 @@ -1 +1 @@ -97bcb8b03defd0e6058bddf1b9714347e3917e1188e6fdd1513f236cc11e95d9 +2f9644668cc1e736c0f42f5c9566b6fb74af557d4e29a424d5ccc06af4e6fa82 diff --git a/crates/event_codec/src/admission.rs b/crates/event_codec/src/admission.rs @@ -317,7 +317,8 @@ mod tests { mod signed { use super::*; use crate::{ - test_fixtures::FIXTURE_ALICE_SECRET_KEY_HEX, verification::verify_nip01_event, + test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_ALICE_SECRET_KEY_HEX}, + verification::verify_nip01_event, }; use nostr::secp256k1::Message; use nostr::{Keys, SECP256K1}; @@ -506,12 +507,22 @@ mod tests { fn comment_tags() -> Vec<Vec<String>> { vec![ - vec!["E".into(), "a".repeat(64), String::new(), "b".repeat(64)], + vec![ + "E".into(), + "a".repeat(64), + String::new(), + FIXTURE_ALICE_PUBLIC_KEY_HEX.into(), + ], vec!["K".into(), KIND_CLASSIFIED_LISTING.to_string()], - vec!["P".into(), "b".repeat(64)], - vec!["e".into(), "a".repeat(64), String::new(), "b".repeat(64)], + vec!["P".into(), FIXTURE_ALICE_PUBLIC_KEY_HEX.into()], + vec![ + "e".into(), + "a".repeat(64), + String::new(), + FIXTURE_ALICE_PUBLIC_KEY_HEX.into(), + ], vec!["k".into(), KIND_CLASSIFIED_LISTING.to_string()], - vec!["p".into(), "b".repeat(64)], + vec!["p".into(), FIXTURE_ALICE_PUBLIC_KEY_HEX.into()], ] } diff --git a/crates/event_codec/src/calendar/decode.rs b/crates/event_codec/src/calendar/decode.rs @@ -32,6 +32,7 @@ use radroots_event::{ DEFAULT_TAG_TOTAL_MAX_BYTES, }, }; +use radroots_identity::PublicKey; use crate::error::EventParseError; use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; @@ -583,8 +584,7 @@ fn calendar_participants_from_tags( if !(2..=4).contains(&tag.len()) { return Err(EventParseError::InvalidTag("p")); } - radroots_event::ids::RadrootsPublicKey::parse(&tag[1]) - .map_err(|_| EventParseError::InvalidTag(TAG_P))?; + PublicKey::from_hex(&tag[1]).map_err(|_| EventParseError::InvalidTag(TAG_P))?; if tag.len() == 3 && tag[2].is_empty() { return Err(EventParseError::InvalidTag(TAG_P)); } diff --git a/crates/event_codec/src/calendar/encode.rs b/crates/event_codec/src/calendar/encode.rs @@ -288,7 +288,7 @@ fn push_calendar_author_reference( tags: &mut Vec<Vec<String>>, reference: &RadrootsCalendarEventAuthorReference, ) { - let mut tag = vec![TAG_P.to_string(), reference.pubkey().as_str().to_string()]; + let mut tag = vec![TAG_P.to_string(), reference.pubkey().to_hex()]; if let Some(relay) = reference.relay() { tag.push(relay.to_string()); } diff --git a/crates/event_codec/src/comment/authored.rs b/crates/event_codec/src/comment/authored.rs @@ -15,6 +15,7 @@ use radroots_event::{ relay_hint::RadrootsNostrRelayHint, wire::RadrootsNip01EventWireParts, }; +use radroots_identity::PublicKey; /// Builds deterministic unsigned kind-1111 wire parts for a strict NIP-22 /// comment. @@ -33,7 +34,7 @@ pub fn authored_nip22_comment_to_wire_parts( } } tags.push(vec!["K".to_string(), root.kind().as_u32().to_string()]); - tags.push(participant_tag("P", root.author().as_str(), root.relay())); + tags.push(participant_tag("P", root.author(), root.relay())); match (root, position) { ( @@ -42,11 +43,7 @@ pub fn authored_nip22_comment_to_wire_parts( ) => { tags.push(event_reference_tag("e", reference)); tags.push(vec!["k".to_string(), root.kind().as_u32().to_string()]); - tags.push(participant_tag( - "p", - reference.author().as_str(), - reference.relay(), - )); + tags.push(participant_tag("p", reference.author(), reference.relay())); } ( RadrootsNip22CommentRoot::Address(reference), @@ -59,20 +56,12 @@ pub fn authored_nip22_comment_to_wire_parts( reference.relay(), )); tags.push(vec!["k".to_string(), root.kind().as_u32().to_string()]); - tags.push(participant_tag( - "p", - reference.author().as_str(), - reference.relay(), - )); + tags.push(participant_tag("p", reference.author(), reference.relay())); } (_, RadrootsNip22CommentPosition::Nested { parent }) => { tags.push(parent_reference_tag(parent)); tags.push(vec!["k".to_string(), KIND_COMMENT.to_string()]); - tags.push(participant_tag( - "p", - parent.author().as_str(), - parent.relay(), - )); + tags.push(participant_tag("p", parent.author(), parent.relay())); } _ => unreachable!("authored Comment constructors preserve root and position compatibility"), } @@ -89,7 +78,7 @@ fn event_reference_tag(name: &str, reference: &RadrootsNip22EventRootReference) name.to_string(), reference.event_id().as_str().to_string(), reference.relay_or_empty().to_string(), - reference.author().as_str().to_string(), + reference.author().to_hex(), ] } @@ -102,16 +91,16 @@ fn parent_reference_tag(reference: &RadrootsNip22CommentParentReference) -> Vec< "e".to_string(), reference.event_id().as_str().to_string(), reference.relay_or_empty().to_string(), - reference.author().as_str().to_string(), + reference.author().to_hex(), ] } fn participant_tag( name: &str, - author: &str, + author: &PublicKey, relay: Option<&RadrootsNostrRelayHint>, ) -> Vec<String> { - optional_relay_tag(name, author, relay) + optional_relay_tag(name, &author.to_hex(), relay) } fn optional_relay_tag( @@ -138,27 +127,22 @@ mod tests { kinds::{KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_CLASSIFIED_LISTING}, }; + fn h(character: char) -> String { + crate::test_fixtures::fixture_public_key_hex(character) + } + fn event_root(relay: Option<&str>) -> RadrootsNip22EventRootReference { - RadrootsNip22EventRootReference::parse( - "a".repeat(64), - "b".repeat(64), - KIND_CLASSIFIED_LISTING, - relay, - ) - .expect("event root") + RadrootsNip22EventRootReference::parse(h('a'), h('b'), KIND_CLASSIFIED_LISTING, relay) + .expect("event root") } fn address_root(relay: Option<&str>) -> RadrootsNip22AddressRootReference { - RadrootsNip22AddressRootReference::parse( - format!("31922:{}:market-day", "b".repeat(64)), - relay, - ) - .expect("address root") + RadrootsNip22AddressRootReference::parse(format!("31922:{}:market-day", h('b')), relay) + .expect("address root") } fn parent(relay: Option<&str>) -> RadrootsNip22CommentParentReference { - RadrootsNip22CommentParentReference::parse("c".repeat(64), "d".repeat(64), relay) - .expect("parent") + RadrootsNip22CommentParentReference::parse(h('c'), h('d'), relay).expect("parent") } #[derive(Clone, Copy)] @@ -180,7 +164,7 @@ mod tests { AuthoredShape::TopAddress => RadrootsAuthoredNip22Comment::parse_top_level_address( content, address_root(None), - "e".repeat(64), + h('e'), ), AuthoredShape::NestedEvent => { RadrootsAuthoredNip22Comment::nested(content, event_root(None), parent(None)) @@ -198,22 +182,12 @@ mod tests { assert_eq!( authored_nip22_comment_to_wire_parts(&comment).tags, vec![ - vec![ - "E".to_string(), - "a".repeat(64), - String::new(), - "b".repeat(64), - ], + vec!["E".to_string(), h('a'), String::new(), h('b'),], vec!["K".to_string(), KIND_CLASSIFIED_LISTING.to_string()], - vec!["P".to_string(), "b".repeat(64)], - vec![ - "e".to_string(), - "a".repeat(64), - String::new(), - "b".repeat(64), - ], + vec!["P".to_string(), h('b')], + vec!["e".to_string(), h('a'), String::new(), h('b'),], vec!["k".to_string(), KIND_CLASSIFIED_LISTING.to_string()], - vec!["p".to_string(), "b".repeat(64)], + vec!["p".to_string(), h('b')], ] ); } @@ -223,7 +197,7 @@ mod tests { let comment = RadrootsAuthoredNip22Comment::parse_top_level_address( "Comment", address_root(Some("wss://relay.example")), - "e".repeat(64), + h('e'), ) .expect("comment"); let tags = authored_nip22_comment_to_wire_parts(&comment).tags; @@ -234,11 +208,7 @@ mod tests { ); assert_eq!( tags[4], - vec![ - "e".to_string(), - "e".repeat(64), - "wss://relay.example".to_string() - ] + vec!["e".to_string(), h('e'), "wss://relay.example".to_string()] ); } @@ -247,8 +217,8 @@ mod tests { for root in [ RadrootsNip22CommentRoot::from( RadrootsNip22EventRootReference::parse( - "a".repeat(64), - "b".repeat(64), + h('a'), + h('b'), KIND_CALENDAR_TIME_EVENT, Some("wss://root.example"), ) @@ -256,7 +226,7 @@ mod tests { ), RadrootsNip22CommentRoot::from( RadrootsNip22AddressRootReference::parse( - format!("31922:{}:market", "b".repeat(64)), + format!("31922:{}:market", h('b')), Some("wss://root.example"), ) .expect("address root"), @@ -273,11 +243,7 @@ mod tests { assert_eq!(tags[4], vec!["k".to_string(), KIND_COMMENT.to_string()]); assert_eq!( tags[5], - vec![ - "p".to_string(), - "d".repeat(64), - "wss://parent.example".to_string() - ] + vec!["p".to_string(), h('d'), "wss://parent.example".to_string()] ); } diff --git a/crates/event_codec/src/comment/inbound/registry_v7.rs b/crates/event_codec/src/comment/inbound/registry_v7.rs @@ -13,11 +13,12 @@ use radroots_event::{ }, ids::{ RadrootsAddressableCoordinate, RadrootsAddressableCoordinateParts, RadrootsEventId, - RadrootsIdParseError, RadrootsPublicKey, + RadrootsIdParseError, }, kinds::KIND_COMMENT, relay_hint::RadrootsNostrRelayHint, }; +use radroots_identity::PublicKey; use crate::verification::v1::RadrootsSignatureVerifiedEvent; @@ -127,7 +128,7 @@ impl RadrootsNip22CommentDiagnostic { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsInboundNip22Participant { tag_index: usize, - pubkey: RadrootsPublicKey, + pubkey: PublicKey, relay: Option<RadrootsNostrRelayHint>, raw_tag: Vec<String>, } @@ -137,7 +138,7 @@ impl RadrootsInboundNip22Participant { self.tag_index } - pub const fn pubkey(&self) -> &RadrootsPublicKey { + pub const fn pubkey(&self) -> &PublicKey { &self.pubkey } @@ -158,7 +159,7 @@ pub struct RadrootsInboundNip22EventRoot { kind_raw_tag: Vec<String>, kind: RadrootsNip22CommentRootKind, relay: Option<RadrootsNostrRelayHint>, - author_hint: Option<RadrootsPublicKey>, + author_hint: Option<PublicKey>, author: RadrootsInboundNip22Participant, raw_tag: Vec<String>, } @@ -188,7 +189,7 @@ impl RadrootsInboundNip22EventRoot { self.relay.as_ref() } - pub const fn author_hint(&self) -> Option<&RadrootsPublicKey> { + pub const fn author_hint(&self) -> Option<&PublicKey> { self.author_hint.as_ref() } @@ -277,7 +278,7 @@ pub struct RadrootsInboundNip22CommentParent { kind_raw_tag: Vec<String>, kind: u32, relay: Option<RadrootsNostrRelayHint>, - author_hint: Option<RadrootsPublicKey>, + author_hint: Option<PublicKey>, author: RadrootsInboundNip22Participant, raw_tag: Vec<String>, } @@ -307,7 +308,7 @@ impl RadrootsInboundNip22CommentParent { self.relay.as_ref() } - pub const fn author_hint(&self) -> Option<&RadrootsPublicKey> { + pub const fn author_hint(&self) -> Option<&PublicKey> { self.author_hint.as_ref() } @@ -354,7 +355,7 @@ pub struct RadrootsInboundNip22TopLevelEventReference { kind_raw_tag: Vec<String>, kind: u32, relay: Option<RadrootsNostrRelayHint>, - author_hint: Option<RadrootsPublicKey>, + author_hint: Option<PublicKey>, author: RadrootsInboundNip22Participant, raw_tag: Vec<String>, } @@ -430,7 +431,7 @@ impl RadrootsInboundNip22TopLevelEventReference { self.relay.as_ref() } - pub const fn author_hint(&self) -> Option<&RadrootsPublicKey> { + pub const fn author_hint(&self) -> Option<&PublicKey> { self.author_hint.as_ref() } @@ -1092,12 +1093,12 @@ type IndexedTag<'a> = (usize, &'a Vec<String>); struct ParsedEventReference { event_id: RadrootsEventId, relay: Option<RadrootsNostrRelayHint>, - author_hint: Option<RadrootsPublicKey>, + author_hint: Option<PublicKey>, } struct ParsedAddressReference { coordinate: RadrootsAddressableCoordinate, - author: RadrootsPublicKey, + author: PublicKey, kind: RadrootsNip22CommentRootKind, relay: Option<RadrootsNostrRelayHint>, } @@ -1255,7 +1256,7 @@ fn parse_required_root_author( if !matches!(tag.len(), 2 | 3) { return Err(RadrootsNip22CommentProjectionError::RootAuthorInvalid { tag_index }); } - let pubkey = RadrootsPublicKey::parse(&tag[1]) + let pubkey = PublicKey::from_hex(&tag[1]) .map_err(|_| RadrootsNip22CommentProjectionError::RootAuthorInvalid { tag_index })?; let relay = project_relay(tag_index, tag, 2, RelayRole::RootAuthor, diagnostics); Ok(RadrootsInboundNip22Participant { @@ -1279,7 +1280,7 @@ fn project_participants( }); continue; } - let Ok(pubkey) = RadrootsPublicKey::parse(&tag[1]) else { + let Ok(pubkey) = PublicKey::from_hex(&tag[1]) else { diagnostics.push(RadrootsNip22CommentDiagnostic::ParentAuthorInvalidIgnored { tag_index, raw_tag: tag.clone(), @@ -1322,7 +1323,7 @@ fn parse_event_reference( let relay = project_relay(tag_index, tag, 2, role.relay_role(), diagnostics); let author_hint = tag .get(3) - .and_then(|value| match RadrootsPublicKey::parse(value) { + .and_then(|value| match PublicKey::from_hex(value) { Ok(author) => Some(author), Err(_) => { diagnostics.push(role.author_hint_diagnostic(tag_index, tag.clone())); @@ -1407,8 +1408,8 @@ fn require_parent_cardinality( fn select_direct_parent_author( mut participants: Vec<RadrootsInboundNip22Participant>, - expected_author: &RadrootsPublicKey, - author_hint: Option<&RadrootsPublicKey>, + expected_author: &PublicKey, + author_hint: Option<&PublicKey>, ) -> Result< ( RadrootsInboundNip22Participant, @@ -1434,7 +1435,7 @@ fn select_direct_parent_author( fn select_nested_parent_author( mut participants: Vec<RadrootsInboundNip22Participant>, - author_hint: Option<&RadrootsPublicKey>, + author_hint: Option<&PublicKey>, ) -> Result< ( RadrootsInboundNip22Participant, diff --git a/crates/event_codec/src/comment/inbound/registry_v7/tests.rs b/crates/event_codec/src/comment/inbound/registry_v7/tests.rs @@ -1,7 +1,7 @@ use super::*; fn h(character: char) -> String { - character.to_string().repeat(64) + crate::test_fixtures::fixture_public_key_hex(character) } fn top_event_tags() -> Vec<Vec<String>> { @@ -111,9 +111,9 @@ fn nested_parent_uses_author_hint_and_keeps_mentions() { panic!("nested"); }; assert_eq!(parent.event_id().as_str(), h('c')); - assert_eq!(parent.author().pubkey().as_str(), h('d')); + assert_eq!(parent.author().pubkey().to_hex(), h('d')); assert_eq!(projection.mentions().len(), 1); - assert_eq!(projection.mentions()[0].pubkey().as_str(), h('e')); + assert_eq!(projection.mentions()[0].pubkey().to_hex(), h('e')); } #[test] diff --git a/crates/event_codec/src/deletion/authored.rs b/crates/event_codec/src/deletion/authored.rs @@ -54,26 +54,23 @@ mod tests { RadrootsNip09DeletionEventTarget, }; + fn h(character: char) -> String { + crate::test_fixtures::fixture_public_key_hex(character) + } + #[test] fn emits_exact_canonical_two_element_target_and_kind_tags() { let request = RadrootsAuthoredNip09DeletionRequest::new( "superseded", vec![ - RadrootsNip09DeletionEventTarget::parse("f".repeat(64), 30_402) - .expect("event target"), - RadrootsNip09DeletionEventTarget::parse("a".repeat(64), 1).expect("event target"), + RadrootsNip09DeletionEventTarget::parse(h('f'), 30_402).expect("event target"), + RadrootsNip09DeletionEventTarget::parse(h('a'), 1).expect("event target"), ], vec![ - RadrootsNip09DeletionAddressTarget::parse(format!( - "31923:{}:market", - "e".repeat(64) - )) - .expect("address target"), - RadrootsNip09DeletionAddressTarget::parse(format!( - "30402:{}:produce", - "b".repeat(64) - )) - .expect("address target"), + RadrootsNip09DeletionAddressTarget::parse(format!("31923:{}:market", h('e'))) + .expect("address target"), + RadrootsNip09DeletionAddressTarget::parse(format!("30402:{}:produce", h('b'))) + .expect("address target"), ], ) .expect("deletion request"); @@ -84,10 +81,10 @@ mod tests { assert_eq!( parts.tags, vec![ - vec!["e".to_string(), "a".repeat(64)], - vec!["e".to_string(), "f".repeat(64)], - vec!["a".to_string(), format!("30402:{}:produce", "b".repeat(64))], - vec!["a".to_string(), format!("31923:{}:market", "e".repeat(64))], + vec!["e".to_string(), h('a')], + vec!["e".to_string(), h('f')], + vec!["a".to_string(), format!("30402:{}:produce", h('b'))], + vec!["a".to_string(), format!("31923:{}:market", h('e'))], vec!["k".to_string(), "1".to_string()], vec!["k".to_string(), "30402".to_string()], vec!["k".to_string(), "31923".to_string()], @@ -98,8 +95,7 @@ mod tests { #[test] fn core_wire_estimator_matches_emitted_json_at_the_strict_boundary() { - let target = - RadrootsNip09DeletionEventTarget::parse("a".repeat(64), 1).expect("event target"); + let target = RadrootsNip09DeletionEventTarget::parse(h('a'), 1).expect("event target"); let mut lower = 0usize; let mut upper = radroots_event::deletion::RADROOTS_NIP09_DELETION_CONTENT_MAX_BYTES; while lower < upper { @@ -142,11 +138,11 @@ mod tests { fn strict_wire_size_equals_independent_max_u64_serialization_with_escaping() { let request = RadrootsAuthoredNip09DeletionRequest::new( "reason \"quoted\" \\\n\u{0001} 🌱", - vec![RadrootsNip09DeletionEventTarget::parse("A".repeat(64), 5).expect("event target")], + vec![RadrootsNip09DeletionEventTarget::parse(h('A'), 5).expect("event target")], vec![ RadrootsNip09DeletionAddressTarget::parse(format!( "30000:{}:victoria:\"crop\\row:\u{0002}:雪", - "B".repeat(64) + h('B') )) .expect("address target"), ], diff --git a/crates/event_codec/src/deletion/reconciliation_v1.rs b/crates/event_codec/src/deletion/reconciliation_v1.rs @@ -400,8 +400,7 @@ pub mod evaluator { } else { return None; }; - RadrootsNip01Coordinate::parse(format!("{kind}:{}:{identifier}", event.author().as_str())) - .ok() + RadrootsNip01Coordinate::parse(format!("{kind}:{}:{identifier}", event.author())).ok() } const fn decision( diff --git a/crates/event_codec/src/deletion/reconciliation_v1/evaluator/tests.rs b/crates/event_codec/src/deletion/reconciliation_v1/evaluator/tests.rs @@ -495,7 +495,7 @@ fn coordinate_for( RadrootsNip01Coordinate::parse(format!( "{}:{}:{identifier}", target.event().kind_u32(), - target.event().author_str() + target.event().author().to_hex() )) .expect("target coordinate") } diff --git a/crates/event_codec/src/deletion/reconciliation_v1/inbound/tests.rs b/crates/event_codec/src/deletion/reconciliation_v1/inbound/tests.rs @@ -1,7 +1,7 @@ use super::*; fn h(character: char) -> String { - character.to_string().repeat(64) + crate::test_fixtures::fixture_public_key_hex(character) } fn valid_event_tags() -> Vec<Vec<String>> { diff --git a/crates/event_codec/src/event_ref.rs b/crates/event_codec/src/event_ref.rs @@ -7,8 +7,9 @@ use alloc::{ use radroots_event::{ RadrootsEventRef, - ids::{RadrootsDTag, RadrootsEventId, RadrootsPublicKey, RadrootsRelayUrl}, + ids::{RadrootsDTag, RadrootsEventId, RadrootsRelayUrl}, }; +use radroots_identity::PublicKey; use crate::error::EventParseError; @@ -39,7 +40,7 @@ pub fn parse_event_ref_tag( let id = &tag[1]; RadrootsEventId::parse(id).map_err(|_| EventParseError::InvalidTag(tag_name))?; let author = &tag[2]; - RadrootsPublicKey::parse(author).map_err(|_| EventParseError::InvalidTag(tag_name))?; + PublicKey::from_hex(author).map_err(|_| EventParseError::InvalidTag(tag_name))?; let kind_s = &tag[3]; let kind: u32 = kind_s .parse() diff --git a/crates/event_codec/src/farm/mod.rs b/crates/event_codec/src/farm/mod.rs @@ -14,10 +14,7 @@ mod tests { farm_members_list_set, farm_operational_listings_list_set_from_listings, farm_plots_list_set_from_plots, member_of_farms_list_set, }; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, - }; + use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; use radroots_event::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}; use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId}; #[cfg(feature = "serde_json")] @@ -31,7 +28,7 @@ mod tests { #[cfg(feature = "serde_json")] const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; #[cfg(feature = "serde_json")] - const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const AUTHOR: &str = crate::test_fixtures::FIXTURE_ALICE_PUBLIC_KEY_HEX; #[cfg(feature = "serde_json")] const EVENT_SIG: &str = concat!( "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", @@ -499,20 +496,12 @@ mod tests { primary_bin_id: bin_id("bin-1"), bins: vec![RadrootsOperationalListingBin { bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::Each, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(10u32), - RadrootsCoreCurrency::USD, - ), - RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::Each, - ), - ), + quantity: Quantity::try_new(Decimal::from(1u32), Unit::Each).unwrap(), + price_per_canonical_unit: QuantityPrice::try_new( + Money::try_new(Decimal::from(10u32), Currency::USD).unwrap(), + Quantity::try_new(Decimal::from(1u32), Unit::Each).unwrap(), + ) + .unwrap(), display_amount: None, display_unit: None, display_label: None, diff --git a/crates/event_codec/src/farm_crdt/mod.rs b/crates/event_codec/src/farm_crdt/mod.rs @@ -29,7 +29,7 @@ mod tests { const DOCUMENT_ID: &str = "AAAAAAAAAAAAAAAAAAAAAg"; const GROUP_ID: &str = "field-group"; const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const AUTHOR: &str = crate::test_fixtures::FIXTURE_ALICE_PUBLIC_KEY_HEX; const EVENT_SIG: &str = concat!( "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" diff --git a/crates/event_codec/src/farm_file/mod.rs b/crates/event_codec/src/farm_file/mod.rs @@ -26,7 +26,7 @@ mod tests { const OWNER_DOCUMENT_ID: &str = "AAAAAAAAAAAAAAAAAAAAAg"; const GROUP_ID: &str = "field-group"; const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const AUTHOR: &str = crate::test_fixtures::FIXTURE_ALICE_PUBLIC_KEY_HEX; const EVENT_SIG: &str = concat!( "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" diff --git a/crates/event_codec/src/farm_workspace/mod.rs b/crates/event_codec/src/farm_workspace/mod.rs @@ -29,7 +29,7 @@ mod tests { const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; const GROUP_ID: &str = "field-group"; const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const AUTHOR: &str = crate::test_fixtures::FIXTURE_ALICE_PUBLIC_KEY_HEX; const EVENT_SIG: &str = concat!( "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" diff --git a/crates/event_codec/src/food_availability/inbound/registry_v7.rs b/crates/event_codec/src/food_availability/inbound/registry_v7.rs @@ -4,7 +4,7 @@ use alloc::{boxed::Box, string::String, string::ToString, vec::Vec}; use core::fmt; -use radroots_blossom::{hash::RadrootsBlossomSha256, url::RadrootsBlossomBlobUrl}; +use radroots_blossom::{BlobUrl, Sha256}; use radroots_event::{ RadrootsEventTags, classified_listing::RadrootsClassifiedListingPartition, @@ -646,13 +646,13 @@ pub(crate) fn project_strict_verified_food_availability_event( .into()); } let mut seen_urls = Vec::<String>::new(); - let mut seen_digests = Vec::<RadrootsBlossomSha256>::new(); + let mut seen_digests = Vec::<Sha256>::new(); for tag in matching_tags(&tags, "image") { if tag.len() != 3 { return Err(RadrootsFoodAvailabilityProjectionError::TagInvalid); } - let url = RadrootsBlossomBlobUrl::parse(&tag[1]) - .and_then(RadrootsBlossomBlobUrl::approve) + let url = BlobUrl::parse(&tag[1]) + .and_then(BlobUrl::approve) .map_err(|_| RadrootsFoodAvailabilityProjectionError::TagInvalid)?; RadrootsFoodImageDimensions::parse(&tag[2])?; if seen_urls.iter().any(|seen| seen == &tag[1]) { @@ -706,7 +706,7 @@ fn project_images( let bounded = &image_tags[..image_tags.len().min(RADROOTS_FOOD_IMAGE_MAX_COUNT)]; let mut images = Vec::with_capacity(bounded.len()); let mut seen_urls = Vec::<String>::new(); - let mut seen_digests = Vec::<RadrootsBlossomSha256>::new(); + let mut seen_digests = Vec::<Sha256>::new(); for tag in bounded { let raw_url = tag.get(1).cloned(); diff --git a/crates/event_codec/src/food_availability/revision.rs b/crates/event_codec/src/food_availability/revision.rs @@ -82,7 +82,7 @@ pub fn validate_food_availability_revision( let current_event = current.event(); if previous_event.kind_u32() != current_event.kind_u32() - || previous_event.author_str() != current_event.author_str() + || previous_event.author().to_hex() != current_event.author().to_hex() || previous_projection.identifier() != current_projection.identifier() { return Err(RadrootsFoodAvailabilityRevisionError::CoordinateChanged); diff --git a/crates/event_codec/src/http_auth/mod.rs b/crates/event_codec/src/http_auth/mod.rs @@ -10,7 +10,7 @@ mod tests { use crate::http_auth::encode::{http_auth_build_tags, to_wire_parts, to_wire_parts_with_kind}; const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const AUTHOR: &str = crate::test_fixtures::FIXTURE_ALICE_PUBLIC_KEY_HEX; const EVENT_SIG: &str = concat!( "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" diff --git a/crates/event_codec/src/job/traits.rs b/crates/event_codec/src/job/traits.rs @@ -116,7 +116,7 @@ pub trait JobEventLike { pub trait JobEventBorrow<'a> { fn raw_id(&'a self) -> &'a str; - fn raw_author(&'a self) -> &'a str; + fn raw_author(&'a self) -> String; fn raw_content(&'a self) -> &'a str; fn raw_kind(&'a self) -> u32; } @@ -147,7 +147,7 @@ impl<'a, E: JobEventBorrow<'a>> JobEventLike for BorrowedEventAdapter<'a, E> { } #[inline] fn raw_author(&self) -> String { - self.inner.raw_author().to_owned() + self.inner.raw_author() } #[inline] fn raw_published_at(&self) -> u64 { @@ -177,8 +177,8 @@ impl<'a> JobEventBorrow<'a> for radroots_event::RadrootsEventEnvelope { self.id_str() } #[inline] - fn raw_author(&'a self) -> &'a str { - self.author_str() + fn raw_author(&'a self) -> String { + self.author().to_hex() } #[inline] fn raw_content(&'a self) -> &'a str { diff --git a/crates/event_codec/src/knowledge/decode.rs b/crates/event_codec/src/knowledge/decode.rs @@ -378,7 +378,7 @@ fn json_content<T: DeserializeOwned>(content: &str) -> Result<T, EventParseError fn parsed<T>(event: RadrootsEventEnvelope, data: T) -> RadrootsParsedEvent<T> { let parsed_data = RadrootsParsedData::new( event.id_str().to_string(), - event.author_str().to_string(), + event.author().to_hex().to_string(), event.created_at_u64(), event.kind_u32(), data, diff --git a/crates/event_codec/src/operational_listing/decode.rs b/crates/event_codec/src/operational_listing/decode.rs @@ -4,10 +4,8 @@ use alloc::{ vec::Vec, }; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney, - RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, -}; +use radroots_core::pricing::Discount; +use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; use radroots_event::{ RadrootsEventEnvelope, farm::RadrootsFarmRef, @@ -115,9 +113,9 @@ struct OperationalListingLocationDraft { fn parse_decimal( value: &str, field: &'static str, -) -> Result<RadrootsCoreDecimal, OperationalListingDecodeError> { +) -> Result<Decimal, OperationalListingDecodeError> { value - .parse::<RadrootsCoreDecimal>() + .parse::<Decimal>() .map_err(|_| OperationalListingDecodeError::InvalidNumber(field)) } @@ -160,18 +158,15 @@ fn reject_private_listing_location_content( fn parse_currency( value: &str, field: &'static str, -) -> Result<RadrootsCoreCurrency, OperationalListingDecodeError> { +) -> Result<Currency, OperationalListingDecodeError> { let upper = value.trim().to_ascii_uppercase(); - RadrootsCoreCurrency::from_str_upper(&upper) + Currency::from_str_upper(&upper) .map_err(|_| OperationalListingDecodeError::InvalidCurrency(field)) } -fn parse_unit( - value: &str, - field: &'static str, -) -> Result<RadrootsCoreUnit, OperationalListingDecodeError> { +fn parse_unit(value: &str, field: &'static str) -> Result<Unit, OperationalListingDecodeError> { value - .parse::<RadrootsCoreUnit>() + .parse::<Unit>() .map_err(|_| OperationalListingDecodeError::InvalidUnit(field)) } @@ -391,9 +386,9 @@ fn decode_operational_listing_from_event_parts( let mut primary_bin_id: Option<String> = None; let mut bin_drafts: Vec<BinDraft> = Vec::new(); let mut bin_order = 0usize; - let mut discounts: Vec<RadrootsCoreDiscount> = Vec::new(); + let mut discounts: Vec<Discount> = Vec::new(); let mut location: Option<OperationalListingLocationDraft> = None; - let mut inventory_available: Option<RadrootsCoreDecimal> = None; + let mut inventory_available: Option<Decimal> = None; let mut availability_status: Option<RadrootsOperationalListingStatus> = None; let mut availability_start: Option<u64> = None; let mut availability_end: Option<u64> = None; @@ -477,7 +472,10 @@ fn decode_operational_listing_from_event_parts( if bin.quantity.is_some() { return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); } - bin.quantity = Some(RadrootsCoreQuantity::new(amount, unit)); + bin.quantity = + Some(Quantity::try_new(amount, unit).map_err(|_| { + OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN) + })?); match tag.as_slice() { [_, _, _, _, display_amount_raw, display_unit_raw] @@ -509,10 +507,12 @@ fn decode_operational_listing_from_event_parts( let currency = parse_currency(&tag[3], TAG_RADROOTS_PRICE)?; let per_amount = parse_decimal(&tag[4], TAG_RADROOTS_PRICE)?; let per_unit = parse_unit(&tag[5], TAG_RADROOTS_PRICE)?; - let price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(amount, currency), - RadrootsCoreQuantity::new(per_amount, per_unit), - ); + let money = Money::try_new(amount, currency) + .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE))?; + let quantity = Quantity::try_new(per_amount, per_unit) + .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE))?; + let price_per_canonical_unit = QuantityPrice::try_new(money, quantity) + .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE))?; if !price_per_canonical_unit.is_price_per_canonical_unit() { return Err(OperationalListingDecodeError::InvalidTag( TAG_RADROOTS_PRICE, @@ -535,7 +535,10 @@ fn decode_operational_listing_from_event_parts( [_, _, _, _, _, _, display_price_raw, display_unit_raw] => { let display_price = parse_decimal(display_price_raw, TAG_RADROOTS_PRICE)?; let display_unit = parse_unit(display_unit_raw, TAG_RADROOTS_PRICE)?; - bin.display_price = Some(RadrootsCoreMoney::new(display_price, currency)); + bin.display_price = + Some(Money::try_new(display_price, currency).map_err(|_| { + OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE) + })?); bin.display_price_unit = Some(display_unit); } _ => {} @@ -744,7 +747,7 @@ pub fn data_from_nostr_event( ) -> Result<RadrootsParsedData<RadrootsOperationalListing>, EventParseError> { data_from_event( event.id_str().to_string(), - event.author_str().to_string(), + event.author().to_hex().to_string(), event.created_at_u64(), event.kind_u32(), event.content().to_string(), @@ -757,7 +760,7 @@ pub fn parsed_from_nostr_event( ) -> Result<RadrootsParsedEvent<RadrootsOperationalListing>, EventParseError> { parsed_from_event( event.id_str().to_string(), - event.author_str().to_string(), + event.author().to_hex().to_string(), event.created_at_u64(), event.kind_u32(), event.content().to_string(), @@ -808,7 +811,7 @@ fn parse_image_size(value: &str) -> Option<RadrootsOperationalListingImageSize> Some(RadrootsOperationalListingImageSize { w, h }) } -fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, OperationalListingDecodeError> { +fn parse_discount(payload: &str) -> Result<Discount, OperationalListingDecodeError> { #[cfg(feature = "serde_json")] { serde_json::from_str(payload) @@ -825,13 +828,13 @@ fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, OperationalList struct BinDraft { bin_id: String, order_index: usize, - quantity: Option<RadrootsCoreQuantity>, - display_amount: Option<RadrootsCoreDecimal>, - display_unit: Option<RadrootsCoreUnit>, + quantity: Option<Quantity>, + display_amount: Option<Decimal>, + display_unit: Option<Unit>, display_label: Option<String>, - price_per_canonical_unit: Option<RadrootsCoreQuantityPrice>, - display_price: Option<RadrootsCoreMoney>, - display_price_unit: Option<RadrootsCoreUnit>, + price_per_canonical_unit: Option<QuantityPrice>, + display_price: Option<Money>, + display_price_unit: Option<Unit>, } fn upsert_bin<'a>( @@ -873,7 +876,7 @@ fn build_bins( .ok_or(OperationalListingDecodeError::MissingTag( TAG_RADROOTS_PRICE, ))?; - if quantity.unit != price.quantity.unit { + if quantity.unit() != price.quantity().unit() { return Err(OperationalListingDecodeError::InvalidTag( TAG_RADROOTS_PRICE, )); diff --git a/crates/event_codec/src/operational_listing/encode.rs b/crates/event_codec/src/operational_listing/encode.rs @@ -71,18 +71,15 @@ fn operational_listing_markdown_content(listing: &RadrootsOperationalListing) -> mod tests { use super::*; use core::str::FromStr; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, - }; + use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; use radroots_event::{ farm::RadrootsFarmRef, ids::{RadrootsDTag, RadrootsInventoryBinId}, operational_listing::{RadrootsOperationalListingBin, RadrootsOperationalListingProduct}, }; - fn decimal(value: &str) -> RadrootsCoreDecimal { - RadrootsCoreDecimal::from_str(value).expect("decimal") + fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("decimal") } fn listing_with(title: &str, summary: Option<&str>) -> RadrootsOperationalListing { @@ -107,11 +104,12 @@ mod tests { primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), bins: vec![RadrootsOperationalListingBin { bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), - quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG), - ), + quantity: Quantity::try_new(decimal("1"), Unit::MassG).unwrap(), + price_per_canonical_unit: QuantityPrice::try_new( + Money::try_new(decimal("1"), Currency::USD).unwrap(), + Quantity::try_new(Decimal::ONE, Unit::MassG).unwrap(), + ) + .unwrap(), display_amount: None, display_unit: None, display_label: None, diff --git a/crates/event_codec/src/operational_listing/tags.rs b/crates/event_codec/src/operational_listing/tags.rs @@ -8,9 +8,9 @@ use alloc::{ vec::Vec, }; +use radroots_core::Money; #[cfg(any(feature = "serde_json", test))] -use radroots_core::RadrootsCoreDiscount; -use radroots_core::RadrootsCoreMoney; +use radroots_core::pricing::Discount; use radroots_event::farm::RadrootsFarmRef; use radroots_event::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA}; use radroots_event::location::{has_textual_locality, is_public_geohash5}; @@ -322,14 +322,14 @@ fn tag_listing_plot(plot: &RadrootsPlotRef) -> Result<Vec<String>, EventEncodeEr } fn tag_listing_bin(bin: &RadrootsOperationalListingBin) -> Result<Vec<String>, EventEncodeError> { - let unit = bin.quantity.unit; + let unit = bin.quantity.unit(); if unit != unit.canonical_unit() { return Err(EventEncodeError::EmptyRequiredField("bin.quantity")); } let mut tag = Vec::with_capacity(7); tag.push(TAG_RADROOTS_BIN.to_string()); tag.push(bin.bin_id.to_string()); - tag.push(bin.quantity.amount.to_string()); + tag.push(bin.quantity.amount().to_string()); tag.push(unit.code().to_string()); if let Some(amount) = bin.display_amount.as_ref() { let Some(unit) = bin.display_unit else { @@ -341,7 +341,7 @@ fn tag_listing_bin(bin: &RadrootsOperationalListingBin) -> Result<Vec<String>, E .display_label .as_deref() .and_then(clean_value) - .or_else(|| bin.quantity.label.as_deref().and_then(clean_value)) + .or_else(|| bin.quantity.label().and_then(clean_value)) { tag.push(label); } @@ -361,16 +361,16 @@ fn tag_listing_price(bin: &RadrootsOperationalListingBin) -> Result<Vec<String>, let mut tag = Vec::with_capacity(8); tag.push(TAG_RADROOTS_PRICE.to_string()); tag.push(bin.bin_id.to_string()); - tag.push(price.amount.amount.to_string()); - tag.push(price.amount.currency.as_str().to_string()); - tag.push(price.quantity.amount.to_string()); - tag.push(price.quantity.unit.code().to_string()); + tag.push(price.amount().amount().to_string()); + tag.push(price.amount().currency().as_str().to_string()); + tag.push(price.quantity().amount().to_string()); + tag.push(price.quantity().unit().code().to_string()); match (&bin.display_price, bin.display_price_unit) { (Some(price_display), Some(unit)) => { - if price_display.currency != price.amount.currency { + if price_display.currency() != price.amount().currency() { return Err(EventEncodeError::EmptyRequiredField("bin.display_price")); } - tag.push(price_display.amount.to_string()); + tag.push(price_display.amount().to_string()); tag.push(unit.code().to_string()); } (None, None) => {} @@ -384,19 +384,17 @@ fn tag_listing_price(bin: &RadrootsOperationalListingBin) -> Result<Vec<String>, Ok(tag) } -fn bin_total_price( - bin: &RadrootsOperationalListingBin, -) -> Result<RadrootsCoreMoney, EventEncodeError> { +fn bin_total_price(bin: &RadrootsOperationalListingBin) -> Result<Money, EventEncodeError> { bin.price_per_canonical_unit .try_cost_for_quantity_in(&bin.quantity) .map_err(|_| EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")) } -fn tag_listing_price_generic(price: &RadrootsCoreMoney) -> Vec<String> { +fn tag_listing_price_generic(price: &Money) -> Vec<String> { let mut tag = Vec::with_capacity(4); tag.push(TAG_PRICE.to_string()); - tag.push(price.amount.to_string()); - tag.push(price.currency.as_str().to_string()); + tag.push(price.amount().to_string()); + tag.push(price.currency().as_str().to_string()); tag } @@ -434,7 +432,7 @@ fn clean_value(value: &str) -> Option<String> { } #[cfg(any(feature = "serde_json", test))] -fn discount_tag_payload(discount: &RadrootsCoreDiscount) -> Result<String, EventEncodeError> { +fn discount_tag_payload(discount: &Discount) -> Result<String, EventEncodeError> { #[cfg(feature = "serde_json")] { serde_json::to_string(discount).map_err(|_| EventEncodeError::Json) @@ -462,11 +460,8 @@ mod tests { use crate::test_fixtures::{ CDN_PRIMARY_HTTPS, FIXTURE_ALICE_NPUB, FIXTURE_ALICE_PUBLIC_KEY_HEX, }; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscountScope, - RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, - }; + use radroots_core::pricing::{DiscountScope, DiscountThreshold, DiscountValue}; + use radroots_core::{Currency, Decimal, Quantity, QuantityPrice, Unit}; use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId}; use radroots_event::operational_listing::{ RadrootsOperationalListingImageSize, RadrootsOperationalListingProduct, @@ -482,8 +477,20 @@ mod tests { format!("{CDN_PRIMARY_HTTPS}/{path}") } - fn decimal(value: &str) -> RadrootsCoreDecimal { - RadrootsCoreDecimal::from_str(value).expect("valid decimal") + fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("valid decimal") + } + + fn money(amount: Decimal, currency: Currency) -> Money { + Money::try_new(amount, currency).unwrap() + } + + fn quantity(amount: Decimal, unit: Unit) -> Quantity { + Quantity::try_new(amount, unit).unwrap() + } + + fn price(amount: Money, quantity: Quantity) -> QuantityPrice { + QuantityPrice::try_new(amount, quantity).unwrap() } fn d_tag(raw: &str) -> RadrootsDTag { @@ -511,20 +518,16 @@ mod tests { fn base_bin() -> RadrootsOperationalListingBin { RadrootsOperationalListingBin { bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new(decimal("1000"), RadrootsCoreUnit::MassG) - .with_label("bag"), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("0.01"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG), + quantity: quantity(decimal("1000"), Unit::MassG).with_label("bag"), + price_per_canonical_unit: price( + money(decimal("0.01"), Currency::USD), + quantity(Decimal::ONE, Unit::MassG), ), display_amount: Some(decimal("1")), - display_unit: Some(RadrootsCoreUnit::MassKg), + display_unit: Some(Unit::MassKg), display_label: Some("kilobag".to_string()), - display_price: Some(RadrootsCoreMoney::new( - decimal("10"), - RadrootsCoreCurrency::USD, - )), - display_price_unit: Some(RadrootsCoreUnit::MassKg), + display_price: Some(money(decimal("10"), Currency::USD)), + display_price_unit: Some(Unit::MassKg), } } @@ -704,17 +707,15 @@ mod tests { #[test] fn discount_payload_without_serde_json_errors() { - let discount = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::Bin, - threshold: RadrootsCoreDiscountThreshold::BinCount { + let discount = Discount::try_new( + DiscountScope::Bin, + DiscountThreshold::BinCount { bin_id: "bin-1".to_string(), min: 2, }, - value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new( - decimal("1"), - RadrootsCoreCurrency::USD, - )), - }; + DiscountValue::MoneyPerBin(money(decimal("1"), Currency::USD)), + ) + .unwrap(); #[cfg(feature = "serde_json")] { let payload = discount_tag_payload(&discount).expect("serde_json payload"); @@ -803,7 +804,7 @@ mod tests { assert_eq!(generic_tag[0], "price"); let mut non_canonical = base_bin(); - non_canonical.quantity = RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg); + non_canonical.quantity = quantity(decimal("1"), Unit::MassKg); let err = tag_listing_bin(&non_canonical).expect_err("non canonical quantity"); assert!(matches!( err, @@ -834,9 +835,9 @@ mod tests { assert_eq!(no_display_tag.len(), 4); let mut invalid_unit_price = base_bin(); - invalid_unit_price.price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::MassG), + invalid_unit_price.price_per_canonical_unit = price( + money(decimal("10"), Currency::USD), + quantity(decimal("2"), Unit::MassG), ); let err = tag_listing_price(&invalid_unit_price).expect_err("not unit price"); assert!(matches!( @@ -845,10 +846,7 @@ mod tests { )); let mut mismatch_display_currency = base_bin(); - mismatch_display_currency.display_price = Some(RadrootsCoreMoney::new( - decimal("10"), - RadrootsCoreCurrency::EUR, - )); + mismatch_display_currency.display_price = Some(money(decimal("10"), Currency::EUR)); let err = tag_listing_price(&mismatch_display_currency).expect_err("currency mismatch"); assert!(matches!( err, @@ -880,9 +878,9 @@ mod tests { assert_eq!(tag.len(), 6); let mut invalid_cost = base_bin(); - invalid_cost.price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::Each), + invalid_cost.price_per_canonical_unit = price( + money(decimal("10"), Currency::USD), + quantity(Decimal::ONE, Unit::Each), ); let err = bin_total_price(&invalid_cost).expect_err("invalid cost conversion"); assert!(matches!( @@ -895,9 +893,9 @@ mod tests { fn operational_listing_tags_propagate_bin_and_resource_area_validation_errors() { let mut listing = base_listing(); listing.discounts = None; - listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::MassG), + listing.bins[0].price_per_canonical_unit = price( + money(decimal("10"), Currency::USD), + quantity(decimal("2"), Unit::MassG), ); let err = operational_listing_tags_with_options( &listing, @@ -911,11 +909,10 @@ mod tests { let mut listing = base_listing(); listing.discounts = None; - listing.bins[0].quantity = - RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg); - listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG), + listing.bins[0].quantity = quantity(decimal("1"), Unit::MassKg); + listing.bins[0].price_per_canonical_unit = price( + money(decimal("10"), Currency::USD), + quantity(Decimal::ONE, Unit::MassG), ); let err = operational_listing_tags_with_options( &listing, @@ -929,11 +926,10 @@ mod tests { let mut listing = base_listing(); listing.discounts = None; - listing.bins[0].quantity = - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::Each); - listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG), + listing.bins[0].quantity = quantity(Decimal::ONE, Unit::Each); + listing.bins[0].price_per_canonical_unit = price( + money(decimal("10"), Currency::USD), + quantity(Decimal::ONE, Unit::MassG), ); let err = operational_listing_tags_with_options( &listing, @@ -965,17 +961,17 @@ mod tests { #[test] fn operational_listing_tags_required_errors_and_success_paths() { let mut listing_with_discount = base_listing(); - listing_with_discount.discounts = Some(vec![RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::Bin, - threshold: RadrootsCoreDiscountThreshold::BinCount { - bin_id: "bin-1".to_string(), - min: 2, - }, - value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new( - decimal("1"), - RadrootsCoreCurrency::USD, - )), - }]); + listing_with_discount.discounts = Some(vec![ + Discount::try_new( + DiscountScope::Bin, + DiscountThreshold::BinCount { + bin_id: "bin-1".to_string(), + min: 2, + }, + DiscountValue::MoneyPerBin(money(decimal("1"), Currency::USD)), + ) + .unwrap(), + ]); #[cfg(feature = "serde_json")] { let tags = operational_listing_tags_with_options( @@ -1300,7 +1296,7 @@ mod tests { let mut first = base_bin(); first.bin_id = bin_id("bin-2"); first.display_label = None; - first.quantity = RadrootsCoreQuantity::new(decimal("1000"), RadrootsCoreUnit::MassG); + first.quantity = quantity(decimal("1000"), Unit::MassG); let mut second = base_bin(); second.bin_id = bin_id("bin-1"); listing_without_primary_match.primary_bin_id = bin_id("bin-missing"); diff --git a/crates/event_codec/src/order/decode.rs b/crates/event_codec/src/order/decode.rs @@ -4,9 +4,7 @@ use alloc::{string::String, vec::Vec}; #[cfg(feature = "serde_json")] use radroots_event::{ RadrootsEventEnvelope, RadrootsEventPtr, - ids::{ - RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey, - }, + ids::{RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsIdParseError}, kinds::is_order_event_kind, order::{ RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderEnvelope, @@ -16,6 +14,8 @@ use radroots_event::{ tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}, }; #[cfg(feature = "serde_json")] +use radroots_identity::PublicKey; +#[cfg(feature = "serde_json")] use serde::de::DeserializeOwned; #[cfg(feature = "serde_json")] @@ -95,7 +95,7 @@ impl std::error::Error for RadrootsOrderEnvelopeParseError { #[cfg(feature = "serde_json")] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsOrderEventContext { - pub counterparty_pubkey: RadrootsPublicKey, + pub counterparty_pubkey: PublicKey, pub listing_event: Option<RadrootsEventPtr>, pub root_event_id: Option<RadrootsEventId>, pub prev_event_id: Option<RadrootsEventId>, @@ -223,7 +223,7 @@ pub fn order_event_context_from_tags( ) -> Result<RadrootsOrderEventContext, RadrootsOrderEnvelopeParseError> { let counterparty_pubkey = parse_order_counterparty_tag(tags).map_err(map_tag_parse_error_for_order_envelope)?; - let counterparty_pubkey = RadrootsPublicKey::parse(&counterparty_pubkey) + let counterparty_pubkey = PublicKey::from_hex(&counterparty_pubkey) .map_err(|_| RadrootsOrderEnvelopeParseError::InvalidTag("p"))?; let listing_event = parse_order_listing_event_tag(tags).map_err(map_tag_parse_error_for_order_envelope)?; @@ -316,8 +316,8 @@ fn validate_order_binding<T>( envelope: &RadrootsOrderEnvelope<T>, payload_order_id: &str, payload_listing_addr: &str, - expected_author: &str, - expected_counterparty: &str, + expected_author: &PublicKey, + expected_counterparty: &PublicKey, ) -> Result<(), RadrootsOrderEnvelopeParseError> { if envelope.order_id != payload_order_id { return Err(RadrootsOrderEnvelopeParseError::PayloadBindingMismatch( @@ -329,11 +329,11 @@ fn validate_order_binding<T>( "listing_addr", )); } - if event.author_str() != expected_author { + if event.author() != expected_author { return Err(RadrootsOrderEnvelopeParseError::AuthorMismatch); } let context = order_event_context_from_tags(envelope.message_type, &event.tags_as_vec())?; - if context.counterparty_pubkey.as_str() != expected_counterparty { + if &context.counterparty_pubkey != expected_counterparty { return Err(RadrootsOrderEnvelopeParseError::CounterpartyTagMismatch); } Ok(()) @@ -350,14 +350,12 @@ mod tests { order_cancellation_event_build, order_decision_event_build, order_request_event_build, }; use crate::order::tags::TAG_LISTING_EVENT; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, - }; + use radroots_core::{Currency, Decimal, Money, Unit}; use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeParts, RadrootsEventPtr, ids::{ RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsInventoryBinId, - RadrootsOrderId, RadrootsOrderQuoteId, RadrootsPublicKey, + RadrootsOrderId, RadrootsOrderQuoteId, }, kinds::{KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST}, order::{ @@ -369,28 +367,28 @@ mod tests { }, tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}, }; + use radroots_identity::PublicKey; - fn pubkey(character: char) -> RadrootsPublicKey { - core::iter::repeat_n(character, 64) - .collect::<String>() + fn pubkey(character: char) -> PublicKey { + crate::test_fixtures::fixture_public_key_hex(character) .parse() .unwrap() } - fn buyer_pubkey() -> RadrootsPublicKey { + fn buyer_pubkey() -> PublicKey { pubkey('b') } - fn seller_pubkey() -> RadrootsPublicKey { + fn seller_pubkey() -> PublicKey { pubkey('a') } fn buyer_pubkey_wire() -> String { - buyer_pubkey().into_string() + buyer_pubkey().to_hex() } fn seller_pubkey_wire() -> String { - seller_pubkey().into_string() + seller_pubkey().to_hex() } fn listing_addr() -> RadrootsClassifiedListingAddress { @@ -462,12 +460,12 @@ mod tests { } } - fn decimal(raw: &str) -> RadrootsCoreDecimal { + fn decimal(raw: &str) -> Decimal { raw.parse().unwrap() } - fn usd(raw: &str) -> RadrootsCoreMoney { - RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD) + fn usd(raw: &str) -> Money { + Money::try_new(decimal(raw), Currency::USD).unwrap() } fn request_economics() -> RadrootsOrderEconomics { @@ -475,14 +473,14 @@ mod tests { quote_id: quote_id("quote-1"), quote_version: 1, pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency: RadrootsCoreCurrency::USD, + currency: Currency::USD, items: vec![RadrootsOrderEconomicItem { bin_id: bin_id("lb"), bin_count: 3, quantity_amount: decimal("1"), - quantity_unit: RadrootsCoreUnit::Each, + quantity_unit: Unit::Each, unit_price_amount: decimal("5"), - unit_price_currency: RadrootsCoreCurrency::USD, + unit_price_currency: Currency::USD, line_subtotal: usd("15"), }], discounts: Vec::<RadrootsOrderEconomicLine>::new(), @@ -1140,7 +1138,7 @@ mod tests { assert_eq!(err, RadrootsOrderEnvelopeParseError::AuthorMismatch); let mut tags = built.tags; - tags[0] = vec!["p".into(), pubkey('c').into_string()]; + tags[0] = vec!["p".into(), pubkey('c').to_hex()]; let counterparty_mismatch = event_envelope(buyer_pubkey_wire(), built.kind, tags, built.content); let err = order_request_from_event(&counterparty_mismatch).unwrap_err(); diff --git a/crates/event_codec/src/order/encode.rs b/crates/event_codec/src/order/encode.rs @@ -13,6 +13,8 @@ use radroots_event::{ use crate::{error::EventEncodeError, order::tags::order_envelope_tags}; #[cfg(feature = "serde_json")] use radroots_event::wire::RadrootsNip01EventWireParts; +#[cfg(feature = "serde_json")] +use radroots_identity::PublicKey; #[cfg(feature = "serde_json")] fn map_order_envelope_error(error: RadrootsOrderEnvelopeError) -> EventEncodeError { @@ -75,7 +77,7 @@ fn map_order_payload_error(error: RadrootsOrderPayloadError) -> EventEncodeError #[cfg(feature = "serde_json")] struct OrderEnvelopeEventBuildParts<'a, T> { - recipient_pubkey: &'a str, + recipient_pubkey: &'a PublicKey, message_type: RadrootsOrderEventType, listing_addr: &'a str, order_id: &'a str, @@ -110,7 +112,7 @@ fn order_envelope_event_build<T: serde::Serialize>( envelope.validate().map_err(map_order_envelope_error)?; let content = serde_json::to_string(&envelope).map_err(|_| EventEncodeError::Json)?; let tags = order_envelope_tags( - parts.recipient_pubkey, + parts.recipient_pubkey.to_hex(), parts.listing_addr, Some(parts.order_id), parts.listing_event, @@ -192,6 +194,9 @@ mod tests { ids::RadrootsEventId, order::{RadrootsOrderEnvelopeError, RadrootsOrderEventType, RadrootsOrderPayloadError}, }; + use radroots_identity::PublicKey; + + const RECIPIENT: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; fn event_id(character: char) -> RadrootsEventId { core::iter::repeat_n(character, 64) @@ -307,11 +312,12 @@ mod tests { #[test] fn order_envelope_event_build_requires_context_tags_by_message_type() { let payload = payload(); + let recipient_pubkey = PublicKey::from_hex(RECIPIENT).expect("recipient public key"); let root_event_id = event_id('1'); let prev_event_id = event_id('2'); let missing_listing_event = order_envelope_event_build(OrderEnvelopeEventBuildParts { - recipient_pubkey: "recipient", + recipient_pubkey: &recipient_pubkey, message_type: RadrootsOrderEventType::OrderRequested, listing_addr: "listing-address", order_id: "order-1", @@ -324,7 +330,7 @@ mod tests { assert_empty_required(missing_listing_event, "listing_event.id"); let missing_root = order_envelope_event_build(OrderEnvelopeEventBuildParts { - recipient_pubkey: "recipient", + recipient_pubkey: &recipient_pubkey, message_type: RadrootsOrderEventType::OrderDecision, listing_addr: "listing-address", order_id: "order-1", @@ -337,7 +343,7 @@ mod tests { assert_empty_required(missing_root, "root_event_id"); let missing_prev = order_envelope_event_build(OrderEnvelopeEventBuildParts { - recipient_pubkey: "recipient", + recipient_pubkey: &recipient_pubkey, message_type: RadrootsOrderEventType::OrderDecision, listing_addr: "listing-address", order_id: "order-1", @@ -350,7 +356,7 @@ mod tests { assert_empty_required(missing_prev, "prev_event_id"); let invalid_listing_event = order_envelope_event_build(OrderEnvelopeEventBuildParts { - recipient_pubkey: "recipient", + recipient_pubkey: &recipient_pubkey, message_type: RadrootsOrderEventType::OrderRequested, listing_addr: "listing-address", order_id: "order-1", diff --git a/crates/event_codec/src/parsed.rs b/crates/event_codec/src/parsed.rs @@ -95,7 +95,7 @@ mod tests { use radroots_event::{RadrootsEventEnvelope, RadrootsEventEnvelopeParts}; const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const AUTHOR: &str = crate::test_fixtures::FIXTURE_ALICE_PUBLIC_KEY_HEX; const SIG: &str = concat!( "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" @@ -162,7 +162,7 @@ mod tests { ) .unwrap(); assert_eq!(out.event.id_str(), EVENT_ID); - assert_eq!(out.event.author_str(), AUTHOR); + assert_eq!(out.event.author().to_hex(), AUTHOR); assert_eq!(out.event.created_at_u64(), 77); assert_eq!(out.event.kind_u32(), 1111); assert_eq!(out.event.content(), "hello"); diff --git a/crates/event_codec/src/relay_auth/mod.rs b/crates/event_codec/src/relay_auth/mod.rs @@ -12,7 +12,7 @@ mod tests { }; const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const AUTHOR: &str = crate::test_fixtures::FIXTURE_ALICE_PUBLIC_KEY_HEX; const EVENT_SIG: &str = concat!( "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" diff --git a/crates/event_codec/src/reply/authored.rs b/crates/event_codec/src/reply/authored.rs @@ -45,38 +45,32 @@ fn event_tag(reference: &RadrootsNip10ReplyReference, marker: &str) -> Vec<Strin } fn public_key_tag(reference: &RadrootsNip10ReplyReference) -> Vec<String> { - vec!["p".to_string(), reference.author().as_str().to_string()] + vec!["p".to_string(), reference.author().to_hex()] } #[cfg(test)] mod tests { use super::*; + fn h(character: char) -> String { + crate::test_fixtures::fixture_public_key_hex(character) + } + #[test] fn nested_reply_deduplicates_equal_reference_authors() { - let author = "b".repeat(64); - let root = RadrootsNip10ReplyReference::parse("a".repeat(64), &author, None) - .expect("root reference"); - let parent = RadrootsNip10ReplyReference::parse("c".repeat(64), &author, None) - .expect("parent reference"); + let author = h('b'); + let root = + RadrootsNip10ReplyReference::parse(h('a'), &author, None).expect("root reference"); + let parent = + RadrootsNip10ReplyReference::parse(h('c'), &author, None).expect("parent reference"); let reply = RadrootsAuthoredNip10Reply::nested("Reply", root, parent).expect("nested reply"); assert_eq!( authored_nip10_reply_to_wire_parts(&reply).tags, vec![ - vec![ - "e".to_string(), - "a".repeat(64), - String::new(), - "root".to_string(), - ], - vec![ - "e".to_string(), - "c".repeat(64), - String::new(), - "reply".to_string(), - ], + vec!["e".to_string(), h('a'), String::new(), "root".to_string(),], + vec!["e".to_string(), h('c'), String::new(), "reply".to_string(),], vec!["p".to_string(), author], ] ); diff --git a/crates/event_codec/src/reply/inbound/registry_v7.rs b/crates/event_codec/src/reply/inbound/registry_v7.rs @@ -7,7 +7,7 @@ use core::fmt; use std::collections::BTreeSet; use radroots_event::{ - ids::{RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey}, + ids::{RadrootsEventId, RadrootsIdParseError}, kinds::KIND_POST, post::{ RADROOTS_POST_CONTENT_MAX_BYTES, RADROOTS_POST_EVENT_WIRE_MAX_BYTES, @@ -15,6 +15,7 @@ use radroots_event::{ }, relay_hint::RadrootsNostrRelayHint, }; +use radroots_identity::{Error as PublicKeyError, PublicKey}; use crate::verification::v1::RadrootsSignatureVerifiedEvent; @@ -54,7 +55,7 @@ pub enum RadrootsNip10ReplyDiagnostic { ReferenceAuthorIgnored { tag_index: usize, raw_tag: Vec<String>, - error: RadrootsIdParseError, + error: PublicKeyError, }, CitationShapeIgnored { tag_index: usize, @@ -76,7 +77,7 @@ pub enum RadrootsNip10ReplyDiagnostic { ReplyAuthorIgnored { tag_index: usize, raw_tag: Vec<String>, - error: RadrootsIdParseError, + error: PublicKeyError, }, ReplyAuthorRelayIgnored { tag_index: usize, @@ -149,7 +150,7 @@ pub struct RadrootsInboundNip10EventReference { raw_tag: Vec<String>, event_id: RadrootsEventId, relay: Option<RadrootsNostrRelayHint>, - author_hint: Option<RadrootsPublicKey>, + author_hint: Option<PublicKey>, } impl RadrootsInboundNip10EventReference { @@ -169,7 +170,7 @@ impl RadrootsInboundNip10EventReference { self.relay.as_ref() } - pub const fn author_hint(&self) -> Option<&RadrootsPublicKey> { + pub const fn author_hint(&self) -> Option<&PublicKey> { self.author_hint.as_ref() } } @@ -178,7 +179,7 @@ impl RadrootsInboundNip10EventReference { pub struct RadrootsInboundNip10Participant { tag_index: usize, raw_tag: Vec<String>, - pubkey: RadrootsPublicKey, + pubkey: PublicKey, relay: Option<RadrootsNostrRelayHint>, } @@ -191,7 +192,7 @@ impl RadrootsInboundNip10Participant { &self.raw_tag } - pub const fn pubkey(&self) -> &RadrootsPublicKey { + pub const fn pubkey(&self) -> &PublicKey { &self.pubkey } @@ -575,7 +576,7 @@ fn parse_event_reference( } }; let author_hint = if tag.len() == 5 { - match RadrootsPublicKey::parse(&tag[4]) { + match PublicKey::from_hex(&tag[4]) { Ok(author) => Some(author), Err(error) => { diagnostics.push(RadrootsNip10ReplyDiagnostic::ReferenceAuthorIgnored { @@ -651,7 +652,7 @@ fn project_supplemental_reference( } }; let author_hint = if tag.len() == 5 { - match RadrootsPublicKey::parse(&tag[4]) { + match PublicKey::from_hex(&tag[4]) { Ok(author) => Some(author), Err(error) => { diagnostics.push(RadrootsNip10ReplyDiagnostic::ReferenceAuthorIgnored { @@ -705,7 +706,7 @@ fn project_participants( continue; } } - let pubkey = match RadrootsPublicKey::parse(&tag[1]) { + let pubkey = match PublicKey::from_hex(&tag[1]) { Ok(pubkey) => pubkey, Err(error) => { diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorIgnored { @@ -727,7 +728,7 @@ fn project_participants( None } }; - if !seen.insert(pubkey.clone()) { + if !seen.insert(pubkey) { diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorDuplicateIgnored { tag_index, raw_tag: tag.clone(), diff --git a/crates/event_codec/src/reply/inbound/registry_v7/tests.rs b/crates/event_codec/src/reply/inbound/registry_v7/tests.rs @@ -1,12 +1,16 @@ use super::*; +fn h(character: char) -> String { + crate::test_fixtures::fixture_public_key_hex(character) +} + #[test] fn tolerant_inbound_accepts_blank_content_and_absent_participants() { let projection = project_nip10_reply_parts( KIND_POST, &[vec![ "e".to_string(), - "a".repeat(64), + h('a'), String::new(), "root".to_string(), ]], @@ -31,10 +35,10 @@ fn tolerant_inbound_accepts_blank_content_and_absent_participants() { #[test] fn tolerant_inbound_retains_raw_optional_metadata_and_orders_diagnostics() { - let root_id = "a".repeat(64); - let parent_id = "d".repeat(64); - let participant = "b".repeat(64); - let parent_author_hint = "c".repeat(64); + let root_id = h('a'); + let parent_id = h('d'); + let participant = h('b'); + let parent_author_hint = h('c'); let tags = vec![ vec![ "e".to_string(), @@ -76,7 +80,7 @@ fn tolerant_inbound_retains_raw_optional_metadata_and_orders_diagnostics() { assert_eq!(projection.participants().len(), 1); assert_eq!(projection.participants()[0].tag_index(), 4); assert_eq!(projection.participants()[0].raw_tag(), tags[4]); - assert_eq!(projection.participants()[0].pubkey().as_str(), participant); + assert_eq!(projection.participants()[0].pubkey().to_hex(), participant); assert!(projection.participants()[0].relay().is_none()); assert_eq!( @@ -121,8 +125,8 @@ fn tolerant_inbound_retains_raw_optional_metadata_and_orders_diagnostics() { #[test] fn inbound_projection_uses_the_canonical_relay_hint_profile() { - let root_id = "a".repeat(64); - let root_author = "b".repeat(64); + let root_id = h('a'); + let root_author = h('b'); for relay in [ "wss://%65xample.com", "wss://127.1", @@ -198,8 +202,8 @@ fn inbound_relay_syntax_and_tag_element_budgets_remain_separate() { RadrootsNostrRelayHint::parse(&relay).expect("relay syntax has no Reply wire budget"); let tags = vec![ - vec!["e".to_string(), "a".repeat(64), relay, "root".to_string()], - vec!["p".to_string(), "b".repeat(64)], + vec!["e".to_string(), h('a'), relay, "root".to_string()], + vec!["p".to_string(), h('b')], ]; assert!(matches!( project_nip10_reply_parts(KIND_POST, &tags, "Reply", 10), @@ -214,7 +218,7 @@ fn inbound_relay_syntax_and_tag_element_budgets_remain_separate() { #[test] fn tolerant_inbound_keeps_reference_ids_and_markers_as_hard_gates() { - let author = "b".repeat(64); + let author = h('b'); for (tag, expected) in [ ( vec![ @@ -228,7 +232,7 @@ fn tolerant_inbound_keeps_reference_ids_and_markers_as_hard_gates() { ( vec![ "e".to_string(), - "a".repeat(64), + h('a'), String::new(), "mention".to_string(), ], @@ -248,8 +252,8 @@ fn tolerant_inbound_keeps_reference_ids_and_markers_as_hard_gates() { #[test] fn marked_inbound_retains_citations_and_ignores_malformed_supplements() { - let root_id = "a".repeat(64); - let citation_id = "c".repeat(64); + let root_id = h('a'); + let citation_id = h('c'); let tags = vec![ vec!["e".to_string()], vec![ @@ -257,12 +261,12 @@ fn marked_inbound_retains_citations_and_ignores_malformed_supplements() { citation_id.clone(), "wss://relay.example".to_string(), String::new(), - "b".repeat(64), + h('b'), ], vec!["e".to_string(), "not-an-event-id".to_string()], vec![ "e".to_string(), - "d".repeat(64), + h('d'), String::new(), "mention".to_string(), ], @@ -293,8 +297,8 @@ fn marked_inbound_retains_citations_and_ignores_malformed_supplements() { projection.citations()[0] .author_hint() .expect("citation author") - .as_str(), - "b".repeat(64) + .to_hex(), + h('b') ); assert_eq!( projection @@ -322,8 +326,8 @@ fn marked_inbound_retains_citations_and_ignores_malformed_supplements() { #[test] fn positional_inbound_accepts_empty_markers_and_tolerates_middle_citations() { - let root_id = "a".repeat(64); - let root_author = "b".repeat(64); + let root_id = h('a'); + let root_author = h('b'); let direct_tags = vec![ vec![ "e".to_string(), @@ -343,15 +347,15 @@ fn positional_inbound_accepts_empty_markers_and_tolerates_middle_citations() { .root() .author_hint() .expect("root author hint") - .as_str(), + .to_hex(), root_author ); assert!(direct.diagnostics().is_empty()); - let parent_id = "c".repeat(64); - let parent_author = "d".repeat(64); - let citation_id = "e".repeat(64); - let citation_author = "f".repeat(64); + let parent_id = h('c'); + let parent_author = h('d'); + let citation_id = h('e'); + let citation_author = h('f'); let nested_tags = vec![ vec![ "e".to_string(), diff --git a/crates/event_codec/src/resource_cap/encode.rs b/crates/event_codec/src/resource_cap/encode.rs @@ -106,7 +106,7 @@ pub fn to_wire_parts_with_kind( mod tests { use super::*; use crate::test_fixtures::FIXTURE_ALICE_PUBLIC_KEY_HEX; - use radroots_core::{RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreUnit}; + use radroots_core::{Decimal, Quantity, Unit}; use radroots_event::resource_area::RadrootsResourceAreaRef; use radroots_event::resource_cap::RadrootsResourceHarvestProduct; @@ -123,10 +123,7 @@ mod tests { }, start: 1, end: 2, - cap_quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassG, - ), + cap_quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(), display_amount: None, display_unit: None, display_label: None, diff --git a/crates/event_codec/src/resource_cap/mod.rs b/crates/event_codec/src/resource_cap/mod.rs @@ -7,7 +7,7 @@ pub mod encode; mod tests { use crate::error::EventEncodeError; use crate::resource_cap::encode::resource_harvest_cap_build_tags; - use radroots_core::{RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreUnit}; + use radroots_core::{Decimal, Quantity, Unit}; use radroots_event::resource_area::RadrootsResourceAreaRef; use radroots_event::resource_cap::{ RadrootsResourceHarvestCap, RadrootsResourceHarvestProduct, @@ -27,10 +27,7 @@ mod tests { }, start: 1735689600, end: 1767225600, - cap_quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(100000u32), - RadrootsCoreUnit::MassG, - ), + cap_quantity: Quantity::try_new(Decimal::from(100000u32), Unit::MassG).unwrap(), display_amount: None, display_unit: None, display_label: None, @@ -74,10 +71,7 @@ mod tests { }, start: 1735689600, end: 1767225600, - cap_quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(100000u32), - RadrootsCoreUnit::MassG, - ), + cap_quantity: Quantity::try_new(Decimal::from(100000u32), Unit::MassG).unwrap(), display_amount: None, display_unit: None, display_label: None, diff --git a/crates/event_codec/src/test_fixtures.rs b/crates/event_codec/src/test_fixtures.rs @@ -98,6 +98,23 @@ pub const FIXTURE_DIEGO: ApprovedFixtureIdentity = ApprovedFixtureIdentity { npub: FIXTURE_DIEGO_NPUB, }; +pub fn fixture_public_key_hex(character: char) -> String { + let canonical = match character.to_ascii_lowercase() { + 'a' => FIXTURE_ALICE_PUBLIC_KEY_HEX, + 'b' => FIXTURE_BOB_PUBLIC_KEY_HEX, + 'c' => FIXTURE_CAROL_PUBLIC_KEY_HEX, + 'd' => FIXTURE_DIEGO_PUBLIC_KEY_HEX, + 'e' => "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + 'f' => "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + _ => panic!("unsupported fixture public-key label: {character}"), + }; + if character.is_ascii_uppercase() { + canonical.to_ascii_uppercase() + } else { + canonical.to_owned() + } +} + pub const RELAY_PRIMARY_WSS: &str = "wss://relay.example.com"; pub const RELAY_SECONDARY_WSS: &str = "wss://relay-2.example.com"; pub const RELAY_TERTIARY_WSS: &str = "wss://relay-3.example.com"; diff --git a/crates/event_codec/src/trade/mod.rs b/crates/event_codec/src/trade/mod.rs @@ -121,7 +121,7 @@ fn validate_trade_mutation_tags( return Err(RadrootsTradeMutationParseError::TradeIdTagMismatch); } let counterparty = required_tag_value(tags, "p")?; - if counterparty != envelope.counterparty_pubkey.as_str() { + if counterparty != envelope.counterparty_pubkey.to_hex() { return Err(RadrootsTradeMutationParseError::CounterpartyTagMismatch); } let mut parents = Vec::new(); @@ -216,7 +216,7 @@ mod tests { RadrootsEventEnvelope, RadrootsEventEnvelopeParts, ids::{ RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsEventId, - RadrootsInventoryBinId, RadrootsPublicKey, RadrootsTradeId, + RadrootsInventoryBinId, RadrootsTradeId, }, trade::{ RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, @@ -226,6 +226,7 @@ mod tests { RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1, }, }; + use radroots_identity::PublicKey; fn hex_64(character: char) -> String { core::iter::repeat_n(character, 64).collect() @@ -235,8 +236,8 @@ mod tests { core::iter::repeat_n(character, 32).collect() } - fn pubkey(character: char) -> RadrootsPublicKey { - RadrootsPublicKey::parse(hex_64(character)).unwrap() + fn pubkey(character: char) -> PublicKey { + PublicKey::from_hex(&crate::test_fixtures::fixture_public_key_hex(character)).unwrap() } fn event_id(character: char) -> RadrootsEventId { @@ -276,7 +277,7 @@ mod tests { line_id: RadrootsDTag::parse("line-1").unwrap(), listing_addr: RadrootsClassifiedListingAddress::parse(format!( "30402:{}:listing-1", - hex_64('b') + pubkey('b').to_hex() )) .unwrap(), listing_event_id: event_id('c'), @@ -340,7 +341,7 @@ mod tests { let envelope = trade_mutation_from_event( &RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: hex_64('e'), - author: hex_64('a'), + author: pubkey('a').to_hex(), created_at: 1_799_000_000, kind: parts.kind, tags: parts.tags, @@ -401,7 +402,7 @@ mod tests { *tags .iter_mut() .find(|tag| tag.first().map(String::as_str) == Some("p")) - .unwrap() = vec!["p".into(), hex_64('c')]; + .unwrap() = vec!["p".into(), pubkey('c').to_hex()]; assert_eq!( validate_trade_mutation_tags(&proposal(), &tags).unwrap_err(), RadrootsTradeMutationParseError::CounterpartyTagMismatch @@ -454,7 +455,7 @@ mod tests { let built = trade_mutation_event_build(proposal()).unwrap(); let invalid_kind = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: hex_64('e'), - author: hex_64('a'), + author: pubkey('a').to_hex(), created_at: 1_799_000_000, kind: 1, tags: built.tags.clone(), @@ -469,7 +470,7 @@ mod tests { let kind_contract_mismatch = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: hex_64('e'), - author: hex_64('a'), + author: pubkey('a').to_hex(), created_at: 1_799_000_000, kind: radroots_event::kinds::KIND_TRADE_DECISION, tags: built.tags, diff --git a/crates/event_codec/src/verification/v1.rs b/crates/event_codec/src/verification/v1.rs @@ -142,7 +142,7 @@ pub fn verify_event_id_v1( RadrootsEventId::parse(event.id_str()) .map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)?; let expected = compute_canonical_nip01_event_id_v1( - event.author_str(), + &event.author().to_hex(), event.created_at_u64(), event.kind_u32(), &event.tags_as_vec(), @@ -232,7 +232,7 @@ fn raw_event_from_radroots( ) -> Result<nostr::Event, RadrootsNip01VerificationError> { let id = nostr::EventId::from_hex(event.id_str()) .map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)?; - let public_key = nostr::secp256k1::XOnlyPublicKey::from_str(event.author_str()) + let public_key = nostr::secp256k1::XOnlyPublicKey::from_str(&event.author().to_hex()) .map(nostr::PublicKey::from) .map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)?; let kind = u16::try_from(event.kind_u32()).map_err(|_| { diff --git a/crates/event_codec/src/verification/v1/tests.rs b/crates/event_codec/src/verification/v1/tests.rs @@ -70,7 +70,7 @@ fn id_verification_rejects_an_out_of_range_kind_before_hashing() { let original = signed_max_kind_event(); let kind = u32::from(u16::MAX) + 1; let id = compute_canonical_nip01_event_id_v1( - original.author_str(), + &original.author().to_hex(), original.created_at_u64(), kind, &original.tags_as_vec(), @@ -80,7 +80,7 @@ fn id_verification_rejects_an_out_of_range_kind_before_hashing() { .into_string(); let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id, - author: original.author_str().to_owned(), + author: original.author().to_hex().to_owned(), created_at: original.created_at_u64(), kind, tags: original.tags_as_vec(), diff --git a/crates/event_codec/src/wire/publication.rs b/crates/event_codec/src/wire/publication.rs @@ -33,11 +33,10 @@ use alloc::{ use core::{cmp::Ordering, fmt}; use radroots_blossom::{ + ApprovedBlobUrl, ByteVerifiedDescriptor, MediaType, RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, - RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, RadrootsBlossomApprovedBlobUrl, - RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomMediaType, - RadrootsBlossomRasterDimensions, RadrootsBlossomRasterFormat, RadrootsBlossomSha256, - url::RadrootsBlossomBlobUrl, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, RasterDimensions, RasterFormat, Sha256, + url::BlobUrl, }; use radroots_event::{ RadrootsEventTags, @@ -47,7 +46,7 @@ use radroots_event::{ RadrootsParsedNip52CalendarCommon, }, food_availability::{RADROOTS_FOOD_AVAILABILITY_CONTRACT_ID, RadrootsFoodAvailabilityDetails}, - ids::{RadrootsEventId, RadrootsPublicKey}, + ids::RadrootsEventId, kinds::{ KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_CLASSIFIED_LISTING, KIND_POST, KIND_PROFILE, @@ -62,8 +61,9 @@ use radroots_event::{ compute_canonical_nip01_event_id, }, }; +use radroots_identity::PublicKey; use serde::{Deserialize, Serialize, ser::SerializeSeq}; -use sha2::{Digest, Sha256}; +use sha2::{Digest, Sha256 as Sha2_256}; use crate::{ calendar::{decode, encode}, @@ -237,18 +237,18 @@ impl RadrootsPhase1PublicationDraft { /// prove that the URL was uploaded or remains retrievable. #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsPhase1PublicationMediaReference { - url: RadrootsBlossomApprovedBlobUrl, - sha256: RadrootsBlossomSha256, + url: ApprovedBlobUrl, + sha256: Sha256, size: u64, - media_type: RadrootsBlossomMediaType, + media_type: MediaType, } impl RadrootsPhase1PublicationMediaReference { - pub fn url(&self) -> &RadrootsBlossomApprovedBlobUrl { + pub fn url(&self) -> &ApprovedBlobUrl { &self.url } - pub const fn sha256(&self) -> RadrootsBlossomSha256 { + pub const fn sha256(&self) -> Sha256 { self.sha256 } @@ -256,14 +256,11 @@ impl RadrootsPhase1PublicationMediaReference { self.size } - pub fn media_type(&self) -> &RadrootsBlossomMediaType { + pub fn media_type(&self) -> &MediaType { &self.media_type } - fn from_verified( - descriptor: &RadrootsBlossomByteVerifiedDescriptor, - url: RadrootsBlossomApprovedBlobUrl, - ) -> Self { + fn from_verified(descriptor: &ByteVerifiedDescriptor, url: ApprovedBlobUrl) -> Self { Self { url, sha256: descriptor.sha256(), @@ -275,18 +272,18 @@ impl RadrootsPhase1PublicationMediaReference { fn from_wire( wire: &MediaReferenceWire, ) -> Result<Self, RadrootsPhase1PublicationArtifactError> { - let parsed_url = RadrootsBlossomBlobUrl::parse(&wire.url) - .and_then(RadrootsBlossomBlobUrl::approve) + let parsed_url = BlobUrl::parse(&wire.url) + .and_then(BlobUrl::approve) .map_err(|_| RadrootsPhase1PublicationArtifactError::InvalidMediaReference)?; if parsed_url.as_str() != wire.url { return Err(RadrootsPhase1PublicationArtifactError::InvalidMediaReference); } - let sha256 = RadrootsBlossomSha256::from_hex(&wire.sha256) + let sha256 = Sha256::from_hex(&wire.sha256) .map_err(|_| RadrootsPhase1PublicationArtifactError::InvalidMediaReference)?; if parsed_url.as_blob_url().hash_path().hash() != sha256 { return Err(RadrootsPhase1PublicationArtifactError::InvalidMediaReference); } - let media_type = RadrootsBlossomMediaType::parse(&wire.media_type) + let media_type = MediaType::parse(&wire.media_type) .map_err(|_| RadrootsPhase1PublicationArtifactError::InvalidMediaReference)?; if media_type.as_str() != wire.media_type || !wire.media_type.starts_with("image/") { return Err(RadrootsPhase1PublicationArtifactError::InvalidMediaReference); @@ -347,7 +344,7 @@ impl fmt::Display for RadrootsPhase1PublicationArtifactDigest { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsPhase1PublicationArtifact { semantic_variant: RadrootsPhase1PublicationSemanticVariant, - expected_author: RadrootsPublicKey, + expected_author: PublicKey, draft: RadrootsPhase1PublicationDraft, expected_event_id: RadrootsEventId, media_references: Vec<RadrootsPhase1PublicationMediaReference>, @@ -501,7 +498,7 @@ impl RadrootsPhase1PublicationArtifact { self.semantic_variant.event_contract_id() } - pub fn expected_author(&self) -> &RadrootsPublicKey { + pub fn expected_author(&self) -> &PublicKey { &self.expected_author } @@ -634,7 +631,7 @@ impl RadrootsPhase1PublicationArtifact { } canonicalize_media_references(&mut media_references)?; let expected_event_id = compute_canonical_nip01_event_id( - expected_author.as_str(), + &expected_author.to_hex(), created_at, parts.kind, &parts.tags, @@ -818,12 +815,10 @@ fn encoding_error(code: &'static str) -> RadrootsPhase1PublicationArtifactError RadrootsPhase1PublicationArtifactError::AuthoredEncoding { code } } -fn parse_expected_author( - value: &str, -) -> Result<RadrootsPublicKey, RadrootsPhase1PublicationArtifactError> { - let author = RadrootsPublicKey::parse(value) +fn parse_expected_author(value: &str) -> Result<PublicKey, RadrootsPhase1PublicationArtifactError> { + let author = PublicKey::from_hex(value) .map_err(|_| RadrootsPhase1PublicationArtifactError::InvalidExpectedAuthor)?; - if author.as_str() != value { + if author.to_hex() != value { return Err(RadrootsPhase1PublicationArtifactError::InvalidExpectedAuthor); } Ok(author) @@ -841,12 +836,12 @@ fn parse_expected_event_id( } fn validate_draft_identifier( - author: &RadrootsPublicKey, + author: &PublicKey, draft: &RadrootsPhase1PublicationDraft, expected_event_id: &RadrootsEventId, ) -> Result<(), RadrootsPhase1PublicationArtifactError> { let computed = compute_canonical_nip01_event_id( - author.as_str(), + &author.to_hex(), draft.created_at, draft.kind, &draft.tags, @@ -862,7 +857,7 @@ fn validate_draft_identifier( #[derive(Serialize)] struct SignedEventSizeWire<'a> { id: &'a str, - pubkey: &'a str, + pubkey: String, created_at: u64, kind: u32, tags: &'a [Vec<String>], @@ -871,7 +866,7 @@ struct SignedEventSizeWire<'a> { } fn validate_signed_event_wire_size( - author: &RadrootsPublicKey, + author: &PublicKey, draft: &RadrootsPhase1PublicationDraft, expected_event_id: &RadrootsEventId, ) -> Result<(), RadrootsPhase1PublicationArtifactError> { @@ -886,7 +881,7 @@ fn validate_signed_event_wire_size( } fn signed_event_wire_size( - author: &RadrootsPublicKey, + author: &PublicKey, draft: &RadrootsPhase1PublicationDraft, expected_event_id: &RadrootsEventId, ) -> Result<usize, RadrootsPhase1PublicationArtifactError> { @@ -896,7 +891,7 @@ fn signed_event_wire_size( ); Ok(serde_json::to_vec(&SignedEventSizeWire { id: expected_event_id.as_str(), - pubkey: author.as_str(), + pubkey: author.to_hex(), created_at: draft.created_at, kind: draft.kind, tags: &draft.tags, @@ -1140,7 +1135,7 @@ fn validate_post( ) else { return Err(RadrootsPhase1PublicationArtifactError::InvalidPostProfile); }; - RadrootsBlossomRasterDimensions::new(dimensions.width(), dimensions.height()) + RasterDimensions::new(dimensions.width(), dimensions.height()) .map_err(|_| RadrootsPhase1PublicationArtifactError::InvalidPostProfile)?; let mut tag = vec![ "imeta".to_string(), @@ -1361,7 +1356,7 @@ fn validate_food_availability( let (Some(url), Some(dimensions)) = (image.url(), image.dimensions()) else { return Err(RadrootsPhase1PublicationArtifactError::InvalidFoodAvailabilityProfile); }; - RadrootsBlossomRasterDimensions::new(dimensions.width(), dimensions.height()) + RasterDimensions::new(dimensions.width(), dimensions.height()) .map_err(|_| RadrootsPhase1PublicationArtifactError::InvalidFoodAvailabilityProfile)?; canonical.push(vec![ "image".to_string(), @@ -1428,7 +1423,7 @@ fn validate_media_reference_envelope( if reference.url.as_str().len() > RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES || reference.size == 0 || reference.size > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES - || RadrootsBlossomRasterFormat::from_media_type(&reference.media_type).is_err() + || RasterFormat::from_media_type(&reference.media_type).is_err() { return Err(RadrootsPhase1PublicationArtifactError::InvalidMediaReference); } @@ -1499,7 +1494,7 @@ struct ArtifactPayloadWire<'a> { semantic_variant: &'a str, authored_operation_id: &'a str, event_contract_id: &'a str, - expected_author: &'a str, + expected_author: String, draft: DraftPayloadWire<'a>, expected_event_id: &'a str, media_references: MediaReferencesPayloadWire<'a>, @@ -1508,7 +1503,7 @@ struct ArtifactPayloadWire<'a> { #[derive(Serialize)] struct MediaReferencePayloadWire<'a> { url: &'a str, - sha256: RadrootsBlossomSha256, + sha256: Sha256, size: u64, media_type: &'a str, } @@ -1535,7 +1530,7 @@ impl Serialize for MediaReferencesPayloadWire<'_> { fn artifact_payload_wire<'a>( variant: RadrootsPhase1PublicationSemanticVariant, - author: &'a RadrootsPublicKey, + author: &'a PublicKey, draft: &'a RadrootsPhase1PublicationDraft, expected_event_id: &'a RadrootsEventId, media: &'a [RadrootsPhase1PublicationMediaReference], @@ -1545,7 +1540,7 @@ fn artifact_payload_wire<'a>( semantic_variant: variant.as_str(), authored_operation_id: variant.authored_operation_id(), event_contract_id: variant.event_contract_id(), - expected_author: author.as_str(), + expected_author: author.to_hex(), draft: DraftPayloadWire { created_at: draft.created_at, kind: draft.kind, @@ -1560,7 +1555,7 @@ fn artifact_payload_wire<'a>( #[cfg(test)] fn compute_artifact_digest( variant: RadrootsPhase1PublicationSemanticVariant, - author: &RadrootsPublicKey, + author: &PublicKey, draft: &RadrootsPhase1PublicationDraft, expected_event_id: &RadrootsEventId, media: &[RadrootsPhase1PublicationMediaReference], @@ -1571,7 +1566,7 @@ fn compute_artifact_digest( fn serialize_artifact_payload( variant: RadrootsPhase1PublicationSemanticVariant, - author: &RadrootsPublicKey, + author: &PublicKey, draft: &RadrootsPhase1PublicationDraft, expected_event_id: &RadrootsEventId, media: &[RadrootsPhase1PublicationMediaReference], @@ -1587,7 +1582,7 @@ fn serialize_artifact_payload( } fn digest_artifact_payload(payload: &[u8]) -> RadrootsPhase1PublicationArtifactDigest { - let mut hasher = Sha256::new(); + let mut hasher = Sha2_256::new(); hasher.update(ARTIFACT_DIGEST_DOMAIN); hasher.update(ARTIFACT_DIGEST_DOMAIN_TERMINATOR); hasher.update(payload); @@ -1596,7 +1591,7 @@ fn digest_artifact_payload(payload: &[u8]) -> RadrootsPhase1PublicationArtifactD fn build_canonical_artifact( variant: RadrootsPhase1PublicationSemanticVariant, - author: &RadrootsPublicKey, + author: &PublicKey, draft: &RadrootsPhase1PublicationDraft, expected_event_id: &RadrootsEventId, media: &[RadrootsPhase1PublicationMediaReference], @@ -1627,7 +1622,7 @@ mod tests { #[test] fn signed_event_wire_size_accepts_exact_limit_and_rejects_one_over() { - let author = RadrootsPublicKey::parse(AUTHOR).unwrap(); + let author = PublicKey::from_hex(AUTHOR).unwrap(); let mut draft = RadrootsPhase1PublicationDraft { created_at: 1_784_347_200, kind: KIND_POST, @@ -1660,7 +1655,7 @@ mod tests { #[test] fn publication_artifact_digest_has_exactly_one_nul_domain_terminator() { - let author = RadrootsPublicKey::parse(AUTHOR).unwrap(); + let author = PublicKey::from_hex(AUTHOR).unwrap(); let draft = RadrootsPhase1PublicationDraft { created_at: 1_784_347_200, kind: KIND_POST, @@ -1685,7 +1680,7 @@ mod tests { ) .unwrap(); - let mut exact = Sha256::new(); + let mut exact = Sha2_256::new(); exact.update(b"radroots.phase1.publication-artifact.v1"); exact.update([0]); exact.update(&payload); @@ -1696,7 +1691,7 @@ mod tests { b"radroots.phase1.publication-artifact.v1".as_slice(), b"radroots.phase1.publication-artifact.v1\0\0".as_slice(), ] { - let mut alternate = Sha256::new(); + let mut alternate = Sha2_256::new(); alternate.update(prefix); alternate.update(&payload); let alternate: [u8; 32] = alternate.finalize().into(); @@ -1733,12 +1728,9 @@ mod tests { ); } - fn event_id( - author: &RadrootsPublicKey, - draft: &RadrootsPhase1PublicationDraft, - ) -> RadrootsEventId { + fn event_id(author: &PublicKey, draft: &RadrootsPhase1PublicationDraft) -> RadrootsEventId { compute_canonical_nip01_event_id( - author.as_str(), + &author.to_hex(), draft.created_at, draft.kind, &draft.tags, diff --git a/crates/event_codec/src/wire/publication/media_readiness.rs b/crates/event_codec/src/wire/publication/media_readiness.rs @@ -20,10 +20,8 @@ use std::{ }; use radroots_blossom::{ - RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, - RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, - RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomRasterDimensions, - RadrootsBlossomRasterFormat, + PublicationReadinessEvidence, RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, RasterDimensions, RasterFormat, }; use serde::{ Deserialize, Deserializer, Serialize, @@ -116,7 +114,7 @@ impl fmt::Display for RadrootsPhase1PublicationMediaReadinessBindingDigest { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsPhase1MediaReadyPublicationArtifact { allowlisted_artifact: RadrootsPhase1AllowlistedPublicationArtifact, - evidence: Vec<RadrootsBlossomPublicationReadinessEvidence>, + evidence: Vec<PublicationReadinessEvidence>, binding_digest: RadrootsPhase1PublicationMediaReadinessBindingDigest, canonical_json: Vec<u8>, } @@ -130,7 +128,7 @@ impl RadrootsPhase1MediaReadyPublicationArtifact { self.allowlisted_artifact.artifact() } - pub fn evidence(&self) -> &[RadrootsBlossomPublicationReadinessEvidence] { + pub fn evidence(&self) -> &[PublicationReadinessEvidence] { &self.evidence } @@ -194,10 +192,8 @@ impl RadrootsPhase1MediaReadyPublicationArtifact { .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::AllocationFailed)?; for raw in wire.evidence { evidence.push( - RadrootsBlossomPublicationReadinessEvidence::from_canonical_json( - raw.get().as_bytes(), - ) - .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::EvidenceInvalid)?, + PublicationReadinessEvidence::from_canonical_json(raw.get().as_bytes()) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::EvidenceInvalid)?, ); } let ready = build_media_readiness_binding(allowlisted_artifact, evidence)?; @@ -217,7 +213,7 @@ pub fn bind_phase1_publication_media_readiness<I>( evidence: I, ) -> Result<RadrootsPhase1MediaReadyPublicationArtifact, RadrootsPhase1PublicationMediaReadinessError> where - I: IntoIterator<Item = RadrootsBlossomPublicationReadinessEvidence>, + I: IntoIterator<Item = PublicationReadinessEvidence>, { let mut bounded = Vec::new(); let mut evidence = evidence.into_iter(); @@ -256,7 +252,7 @@ pub fn validate_phase1_publication_media_readiness( fn build_media_readiness_binding( allowlisted_artifact: RadrootsPhase1AllowlistedPublicationArtifact, - evidence: Vec<RadrootsBlossomPublicationReadinessEvidence>, + evidence: Vec<PublicationReadinessEvidence>, ) -> Result<RadrootsPhase1MediaReadyPublicationArtifact, RadrootsPhase1PublicationMediaReadinessError> { validate_evidence_parity(allowlisted_artifact.artifact(), &evidence)?; @@ -281,7 +277,7 @@ fn build_media_readiness_binding( fn validate_evidence_parity( artifact: &RadrootsPhase1PublicationArtifact, - evidence: &[RadrootsBlossomPublicationReadinessEvidence], + evidence: &[PublicationReadinessEvidence], ) -> Result<(), RadrootsPhase1PublicationMediaReadinessError> { let media = artifact.media_references(); if evidence.len() != media.len() { @@ -301,10 +297,10 @@ fn validate_evidence_parity( RadrootsPhase1PublicationMediaReadinessError::EvidenceOrderMismatch { index }, ); } - let expected_format = RadrootsBlossomRasterFormat::from_media_type(reference.media_type()) - .map_err( - |_| RadrootsPhase1PublicationMediaReadinessError::EvidenceFactMismatch { index }, - )?; + let expected_format = + RasterFormat::from_media_type(reference.media_type()).map_err(|_| { + RadrootsPhase1PublicationMediaReadinessError::EvidenceFactMismatch { index } + })?; if observation.sha256() != reference.sha256() || observation.size() != reference.size() || observation.media_type() != reference.media_type() @@ -325,10 +321,7 @@ fn validate_evidence_parity( fn expected_dimensions( artifact: &RadrootsPhase1PublicationArtifact, -) -> Result< - Vec<Option<RadrootsBlossomRasterDimensions>>, - RadrootsPhase1PublicationMediaReadinessError, -> { +) -> Result<Vec<Option<RasterDimensions>>, RadrootsPhase1PublicationMediaReadinessError> { match artifact.semantic_variant() { RadrootsPhase1PublicationSemanticVariant::PhotoUpdate | RadrootsPhase1PublicationSemanticVariant::Ask => post_dimensions(artifact), @@ -343,10 +336,7 @@ fn expected_dimensions( fn post_dimensions( artifact: &RadrootsPhase1PublicationArtifact, -) -> Result< - Vec<Option<RadrootsBlossomRasterDimensions>>, - RadrootsPhase1PublicationMediaReadinessError, -> { +) -> Result<Vec<Option<RasterDimensions>>, RadrootsPhase1PublicationMediaReadinessError> { let draft = artifact.draft(); let projection = project_inbound_post_parts(draft.kind(), draft.tags(), draft.content()) .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid)?; @@ -361,10 +351,8 @@ fn post_dimensions( let dimensions = imeta .dimensions() .ok_or(RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid)?; - let dimensions = - RadrootsBlossomRasterDimensions::new(dimensions.width(), dimensions.height()).map_err( - |_| RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid, - )?; + let dimensions = RasterDimensions::new(dimensions.width(), dimensions.height()) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid)?; entries.push((url.to_string(), dimensions)); entries.extend( imeta @@ -379,10 +367,7 @@ fn post_dimensions( fn food_dimensions( artifact: &RadrootsPhase1PublicationArtifact, -) -> Result< - Vec<Option<RadrootsBlossomRasterDimensions>>, - RadrootsPhase1PublicationMediaReadinessError, -> { +) -> Result<Vec<Option<RasterDimensions>>, RadrootsPhase1PublicationMediaReadinessError> { let draft = artifact.draft(); let tags = radroots_event::RadrootsEventTags::new(draft.tags().to_vec()) .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid)?; @@ -407,10 +392,8 @@ fn food_dimensions( let dimensions = image .dimensions() .ok_or(RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid)?; - let dimensions = - RadrootsBlossomRasterDimensions::new(dimensions.width(), dimensions.height()).map_err( - |_| RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid, - )?; + let dimensions = RasterDimensions::new(dimensions.width(), dimensions.height()) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid)?; entries.push((url.to_string(), dimensions)); } align_dimensions(artifact.media_references(), entries) @@ -418,11 +401,8 @@ fn food_dimensions( fn align_dimensions( media: &[RadrootsPhase1PublicationMediaReference], - mut entries: Vec<(String, RadrootsBlossomRasterDimensions)>, -) -> Result< - Vec<Option<RadrootsBlossomRasterDimensions>>, - RadrootsPhase1PublicationMediaReadinessError, -> { + mut entries: Vec<(String, RasterDimensions)>, +) -> Result<Vec<Option<RasterDimensions>>, RadrootsPhase1PublicationMediaReadinessError> { entries.sort_by(|left, right| left.0.cmp(&right.0)); if entries.windows(2).any(|pair| pair[0].0 == pair[1].0) { return Err(RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid); @@ -444,7 +424,7 @@ fn align_dimensions( fn compute_binding_digest( artifact: &RadrootsPhase1PublicationArtifact, - evidence: &[RadrootsBlossomPublicationReadinessEvidence], + evidence: &[PublicationReadinessEvidence], ) -> RadrootsPhase1PublicationMediaReadinessBindingDigest { let mut hasher = Sha256::new(); hasher.update(BINDING_DIGEST_DOMAIN); @@ -463,7 +443,7 @@ fn compute_binding_digest( fn serialize_binding( artifact: &RadrootsPhase1PublicationArtifact, - evidence: &[RadrootsBlossomPublicationReadinessEvidence], + evidence: &[PublicationReadinessEvidence], binding_digest: RadrootsPhase1PublicationMediaReadinessBindingDigest, ) -> Result<Vec<u8>, RadrootsPhase1PublicationMediaReadinessError> { let mut raw_evidence = Vec::new(); diff --git a/crates/event_codec/tests/app_data.rs b/crates/event_codec/tests/app_data.rs @@ -132,7 +132,7 @@ fn app_data_metadata_and_index_from_event_roundtrip() { ) .unwrap(); assert_eq!(index.event.id_str(), EVENT_ID); - assert_eq!(index.event.author_str(), AUTHOR); + assert_eq!(index.event.author().to_hex(), AUTHOR); assert_eq!(index.event.created_at_u64(), 42); assert_eq!(index.event.kind_u32(), KIND_APP_DATA); assert_eq!(index.event.content(), "payload"); diff --git a/crates/event_codec/tests/article.rs b/crates/event_codec/tests/article.rs @@ -218,7 +218,7 @@ fn article_wrappers_preserve_event_metadata() { .unwrap(); assert_eq!(parsed.event.id_str(), EVENT_ID); - assert_eq!(parsed.event.author_str(), AUTHOR); + assert_eq!(parsed.event.author().to_hex(), AUTHOR); assert_eq!(parsed.event.sig_str(), EVENT_SIG); assert_eq!(parsed.data.data.d_tag, VALID_D_TAG); } diff --git a/crates/event_codec/tests/calendar.rs b/crates/event_codec/tests/calendar.rs @@ -1,7 +1,4 @@ -use radroots_blossom::{ - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType, - RadrootsBlossomSha256, -}; +use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; use radroots_event::{ RadrootsAuthoredImage, RadrootsEventTags, calendar::{ @@ -44,8 +41,8 @@ use radroots_event_codec::{ const DATE_D_TAG: &str = "fernwood-csa-pickup"; const TIME_D_TAG: &str = "wash-pack-shift"; const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const EVENT_AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; -const SECOND_AUTHOR: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const EVENT_AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; +const SECOND_AUTHOR: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; const EVENT_SIG: &str = concat!( "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" @@ -57,10 +54,10 @@ fn date(value: &str) -> RadrootsCalendarDate { fn authored_image() -> RadrootsAuthoredImage { let bytes = b"canonical-calendar-image"; - let hash = RadrootsBlossomSha256::digest(bytes); - let media_type = RadrootsBlossomMediaType::parse("image/webp").unwrap(); - let descriptor = RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&format!("https://media.example.test/{hash}.webp")).unwrap(), + let hash = Sha256::digest(bytes); + let media_type = MediaType::parse("image/webp").unwrap(); + let descriptor = BlobDescriptor::new( + BlobUrl::parse(&format!("https://media.example.test/{hash}.webp")).unwrap(), hash, bytes.len() as u64, media_type.clone(), diff --git a/crates/event_codec/tests/calendar_conformance.rs b/crates/event_codec/tests/calendar_conformance.rs @@ -1,8 +1,6 @@ use std::{borrow::Cow, fs, path::Path}; -use radroots_blossom::{ - RadrootsBlossomBlobDescriptor, RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomError, -}; +use radroots_blossom::{BlobDescriptor, ByteVerifiedDescriptor, Error}; use radroots_event::{ RadrootsAuthoredImage, calendar::{ @@ -596,7 +594,7 @@ fn assert_author_reference( let relay = expected.get("relay").and_then(Value::as_str); assert_eq!(actual.raw_pubkey(), pubkey, "{}", vector_id(vector)); assert_eq!( - actual.pubkey().as_str(), + actual.pubkey().to_hex(), pubkey.to_ascii_lowercase(), "{} normalized author key", vector_id(vector) @@ -606,7 +604,7 @@ fn assert_author_reference( relay.is_none_or(|relay| radroots_event::ids::RadrootsRelayUrl::parse(relay).is_ok()); assert_eq!( actual.is_canonical(), - pubkey == actual.pubkey().as_str() && canonical_relay, + pubkey == actual.pubkey().to_hex() && canonical_relay, "{} author canonicality", vector_id(vector) ); @@ -1414,15 +1412,13 @@ fn authored_image(input: &Value, vector_id: &str) -> RadrootsAuthoredImage { .unwrap_or_else(|error| panic!("{vector_id} image failed: {error}")) } -fn verified_descriptor(input: &Value, vector_id: &str) -> RadrootsBlossomByteVerifiedDescriptor { +fn verified_descriptor(input: &Value, vector_id: &str) -> ByteVerifiedDescriptor { verified_descriptor_result(input) .unwrap_or_else(|error| panic!("{vector_id} byte verification failed: {error}")) } -fn verified_descriptor_result( - input: &Value, -) -> Result<RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomError> { - let descriptor: RadrootsBlossomBlobDescriptor = +fn verified_descriptor_result(input: &Value) -> Result<ByteVerifiedDescriptor, Error> { + let descriptor: BlobDescriptor = serde_json::from_value(input["descriptor"].clone()).expect("image descriptor must parse"); let media_type = descriptor.media_type().clone(); descriptor.approve_reference()?.verify_bytes( diff --git a/crates/event_codec/tests/common/mod.rs b/crates/event_codec/tests/common/mod.rs @@ -5,7 +5,7 @@ use radroots_event::{ }; pub const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -pub const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +pub const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; pub const EVENT_SIG: &str = concat!( "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" diff --git a/crates/event_codec/tests/coverage_edges.rs b/crates/event_codec/tests/coverage_edges.rs @@ -20,7 +20,7 @@ fn assert_invalid_kind<T>(result: Result<T, EventParseError>, expected: &'static fn parsed_args() -> (String, String, u64, u32, String, Vec<Vec<String>>, String) { ( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df".to_string(), 1, KIND_POST, String::new(), diff --git a/crates/event_codec/tests/domain_encode_non_serde.rs b/crates/event_codec/tests/domain_encode_non_serde.rs @@ -3,10 +3,7 @@ mod test_fixtures; use std::str::FromStr; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, -}; +use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; use radroots_event::{ coop::{RadrootsCoop, RadrootsCoopLocation, RadrootsCoopRef}, document::{RadrootsDocument, RadrootsDocumentSubject}, @@ -54,8 +51,20 @@ const VALID_AREA_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAw"; const VALID_CAP_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABA"; const VALID_DOC_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; -fn decimal(value: &str) -> RadrootsCoreDecimal { - RadrootsCoreDecimal::from_str(value).expect("valid decimal") +fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("valid decimal") +} + +fn money(amount: Decimal, currency: Currency) -> Money { + Money::try_new(amount, currency).unwrap() +} + +fn quantity(amount: Decimal, unit: Unit) -> Quantity { + Quantity::try_new(amount, unit).unwrap() +} + +fn quantity_price(amount: Money, quantity: Quantity) -> QuantityPrice { + QuantityPrice::try_new(amount, quantity).unwrap() } fn listing_d_tag(raw: &str) -> RadrootsDTag { @@ -160,12 +169,9 @@ fn sample_plot() -> RadrootsPlot { } fn sample_listing() -> RadrootsOperationalListing { - let quantity = - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); - let price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), - quantity.clone(), - ); + let quantity = quantity(Decimal::from(1u32), Unit::Each); + let price_per_canonical_unit = + quantity_price(money(Decimal::from(10u32), Currency::USD), quantity.clone()); RadrootsOperationalListing { d_tag: listing_d_tag(VALID_DOC_D_TAG), @@ -239,7 +245,7 @@ fn sample_resource_cap() -> RadrootsResourceHarvestCap { }, start: 1, end: 2, - cap_quantity: RadrootsCoreQuantity::new(decimal("1000"), RadrootsCoreUnit::MassG), + cap_quantity: quantity(decimal("1000"), Unit::MassG), display_amount: None, display_unit: None, display_label: None, @@ -676,7 +682,7 @@ fn listing_encode_paths() { .clone() .with_label("fallback-label"); listing_with_display_fallback.bins[0].display_amount = Some(decimal("1")); - listing_with_display_fallback.bins[0].display_unit = Some(RadrootsCoreUnit::Each); + listing_with_display_fallback.bins[0].display_unit = Some(Unit::Each); listing_with_display_fallback.bins[0].display_label = None; let display_tags = operational_listing_tags_with_options( &listing_with_display_fallback, @@ -862,7 +868,7 @@ fn listing_encode_paths() { let mut invalid = sample_listing(); invalid.bins[0].display_price = None; - invalid.bins[0].display_price_unit = Some(RadrootsCoreUnit::Each); + invalid.bins[0].display_price_unit = Some(Unit::Each); let err = operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) .expect_err("missing display price"); @@ -872,10 +878,7 @@ fn listing_encode_paths() { )); let mut invalid = sample_listing(); - invalid.bins[0].display_price = Some(RadrootsCoreMoney::new( - decimal("10"), - RadrootsCoreCurrency::USD, - )); + invalid.bins[0].display_price = Some(money(decimal("10"), Currency::USD)); invalid.bins[0].display_price_unit = None; let err = operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) @@ -886,11 +889,8 @@ fn listing_encode_paths() { )); let mut listing_with_display_price = sample_listing(); - listing_with_display_price.bins[0].display_price = Some(RadrootsCoreMoney::new( - decimal("10"), - RadrootsCoreCurrency::USD, - )); - listing_with_display_price.bins[0].display_price_unit = Some(RadrootsCoreUnit::Each); + listing_with_display_price.bins[0].display_price = Some(money(decimal("10"), Currency::USD)); + listing_with_display_price.bins[0].display_price_unit = Some(Unit::Each); let display_price_tags = operational_listing_tags_with_options( &listing_with_display_price, OperationalListingTagOptions::default(), @@ -903,10 +903,7 @@ fn listing_encode_paths() { })); let mut invalid = listing_with_display_price.clone(); - invalid.bins[0].display_price = Some(RadrootsCoreMoney::new( - decimal("10"), - RadrootsCoreCurrency::EUR, - )); + invalid.bins[0].display_price = Some(money(decimal("10"), Currency::EUR)); let err = operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) .expect_err("display price currency mismatch"); @@ -917,7 +914,7 @@ fn listing_encode_paths() { let mut invalid = sample_listing(); invalid.bins[0].display_amount = None; - invalid.bins[0].display_unit = Some(RadrootsCoreUnit::Each); + invalid.bins[0].display_unit = Some(Unit::Each); let err = operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) .expect_err("missing display amount"); @@ -927,10 +924,10 @@ fn listing_encode_paths() { )); let mut invalid = sample_listing(); - invalid.bins[0].quantity = RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg); - invalid.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG), + invalid.bins[0].quantity = quantity(decimal("1"), Unit::MassKg); + invalid.bins[0].price_per_canonical_unit = quantity_price( + money(decimal("10"), Currency::USD), + quantity(decimal("1"), Unit::MassG), ); let err = operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) @@ -941,9 +938,9 @@ fn listing_encode_paths() { )); let mut invalid = sample_listing(); - invalid.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::Each), + invalid.bins[0].price_per_canonical_unit = quantity_price( + money(decimal("10"), Currency::USD), + quantity(decimal("2"), Unit::Each), ); let err = operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) @@ -954,9 +951,9 @@ fn listing_encode_paths() { )); let mut invalid = sample_listing(); - invalid.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG), + invalid.bins[0].price_per_canonical_unit = quantity_price( + money(decimal("10"), Currency::USD), + quantity(decimal("1"), Unit::MassG), ); let err = operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) @@ -1302,6 +1299,6 @@ fn document_encode_paths() { #[test] fn resource_harvest_cap_money_type_sanity() { - let money = RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::USD); - assert_eq!(money.amount.to_string(), "1"); + let money = money(decimal("1"), Currency::USD); + assert_eq!(money.amount().to_string(), "1"); } diff --git a/crates/event_codec/tests/fixtures/calendar_nip52_baseline.v1.json b/crates/event_codec/tests/fixtures/calendar_nip52_baseline.v1.json @@ -42,13 +42,13 @@ ["g", "C23NB62W20ST"], ["summary", " Weekly pickup "], ["image", "https://ordinary.example.test/images/market.jpg?size=large"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://relay.example.test/events"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "", " host "], + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"], + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://relay.example.test/events"], + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "", " host "], ["t", "vegetables"], ["t", "CSA"], ["r", "ipfs://bafybeigdyrzt/reference"], - ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "wss://relay.example.test"], + ["a", "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-calendar", "wss://relay.example.test"], ["name", "Deprecated display name"], ["D", "extension", "preserved"] ] @@ -64,14 +64,14 @@ "summary": " Weekly pickup ", "image": "https://ordinary.example.test/images/market.jpg?size=large", "participants": [ - {"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "relay": null, "role": null}, - {"pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "relay": "wss://relay.example.test/events", "role": null}, - {"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "relay": null, "role": " host "} + {"pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "relay": null, "role": null}, + {"pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://relay.example.test/events", "role": null}, + {"pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "relay": null, "role": " host "} ], "categories": ["vegetables", "CSA"], "references": ["ipfs://bafybeigdyrzt/reference"], "calendar_requests": [ - {"calendar": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "relay": "wss://relay.example.test"} + {"calendar": "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-calendar", "relay": "wss://relay.example.test"} ], "legacy_name": "Deprecated display name", "date_day_extensions": [["D", "extension", "preserved"]] @@ -150,7 +150,7 @@ "input": { "kind": 31922, "content": "", - "tags": [["d", "bad-participant"], ["title", "Bad participant"], ["start", "2026-06-20"], ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", ""]] + "tags": [["d", "bad-participant"], ["title", "Bad participant"], ["start", "2026-06-20"], ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", ""]] }, "expected": { "error": "invalid_tag", "tag": "p" } }, @@ -160,7 +160,7 @@ "input": { "kind": 31922, "content": "", - "tags": [["d", "bad-request"], ["title", "Bad request"], ["start", "2026-06-20"], ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:not-a-calendar"]] + "tags": [["d", "bad-request"], ["title", "Bad request"], ["start", "2026-06-20"], ["a", "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:not-a-calendar"]] }, "expected": { "error": "invalid_tag", "tag": "a" } }, @@ -357,8 +357,8 @@ ["title", "Regional farm calendar"], ["description", "NIP-51 client-facing set description"], ["image", "https://ordinary.example.test/calendars/victoria.jpg"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:moss-street-market", "wss://date-relay.example.test/events"], - ["a", "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:harvest-window", "WSS://Time-Relay.Example.Test/events"] + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:moss-street-market", "wss://date-relay.example.test/events"], + ["a", "31923:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:harvest-window", "WSS://Time-Relay.Example.Test/events"] ] }, "expected": { @@ -369,11 +369,11 @@ "image": "https://ordinary.example.test/calendars/victoria.jpg", "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:moss-street-market", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:moss-street-market", "relay": "wss://date-relay.example.test/events" }, { - "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:harvest-window", + "coordinate": "31923:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:harvest-window", "relay": "WSS://Time-Relay.Example.Test/events" } ] @@ -388,7 +388,7 @@ "tags": [ ["d", "uppercase-observed"], ["title", "Uppercase observed"], - ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour", "WSS://Relay.Example.Test/events"] + ["a", "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:farm-tour", "WSS://Relay.Example.Test/events"] ] }, "expected": { @@ -399,7 +399,7 @@ "image": null, "event_references": [ { - "coordinate": "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour", + "coordinate": "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:farm-tour", "relay": "WSS://Relay.Example.Test/events" } ] @@ -414,8 +414,8 @@ "tags": [ ["d", "duplicates-observed"], ["title", "Duplicates observed"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://one.example.test"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://two.example.test"] + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "wss://one.example.test"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "wss://two.example.test"] ] }, "expected": { @@ -426,11 +426,11 @@ "image": null, "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "relay": "wss://one.example.test" }, { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "relay": "wss://two.example.test" } ] @@ -497,7 +497,7 @@ "tags": [ ["d", "bad-a-arity"], ["title", "Bad a arity"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://relay.example.test", "unexpected"] + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "wss://relay.example.test", "unexpected"] ] }, "expected": {"error": "invalid_tag", "tag": "a"} @@ -518,7 +518,7 @@ "input": { "kind": 31924, "content": "", - "tags": [["d", "wrong-coordinate-kind"], ["title", "Wrong coordinate kind"], ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:another-calendar"]] + "tags": [["d", "wrong-coordinate-kind"], ["title", "Wrong coordinate kind"], ["a", "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:another-calendar"]] }, "expected": {"error": "invalid_tag", "tag": "a"} }, @@ -531,7 +531,7 @@ "tags": [ ["d", "bad-relay"], ["title", "Bad relay"], - ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:harvest-window", "https://relay.example.test"] + ["a", "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:harvest-window", "https://relay.example.test"] ] }, "expected": {"error": "invalid_tag", "tag": "a"} @@ -554,14 +554,14 @@ "content": "", "tags": [ ["d", "farm-tour-rsvp"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"] ] }, "expected": { "d": "farm-tour-rsvp", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "relay": null }, "revision_reference": null, @@ -580,28 +580,28 @@ "content": "I can help at the produce table.", "tags": [ ["d", "market-rsvp"], - ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:market-shift", "wss://coordinate-relay.example.test/events"], - ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://event-relay.example.test/events"], + ["a", "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:market-shift", "wss://coordinate-relay.example.test/events"], + ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://event-relay.example.test/events"], ["status", "tentative"], ["fb", "busy"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://author-relay.example.test/events"] + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "wss://author-relay.example.test/events"] ] }, "expected": { "d": "market-rsvp", "event_reference": { - "coordinate": "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:market-shift", + "coordinate": "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:market-shift", "relay": "wss://coordinate-relay.example.test/events" }, "revision_reference": { - "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "id": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://event-relay.example.test/events" }, "status": "tentative", "observed_free_busy": "busy", "effective_free_busy": "busy", "author_hint": { - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "relay": "wss://author-relay.example.test/events" }, "note": "I can help at the produce table." @@ -615,7 +615,7 @@ "content": "Unable to attend.", "tags": [ ["d", "declined-rsvp"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:seed-swap"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:seed-swap"], ["status", "declined"], ["fb", "busy"] ] @@ -623,7 +623,7 @@ "expected": { "d": "declined-rsvp", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:seed-swap", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:seed-swap", "relay": null }, "revision_reference": null, @@ -642,28 +642,28 @@ "content": "", "tags": [ ["d", "uppercase-rsvp"], - ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour", "WSS://Coordinate-Relay.Example.Test/events"], - ["e", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", "WSS://Event-Relay.Example.Test/events"], + ["a", "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:farm-tour", "WSS://Coordinate-Relay.Example.Test/events"], + ["e", "E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF", "WSS://Event-Relay.Example.Test/events"], ["status", "accepted"], ["fb", "free"], - ["p", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "WSS://Author-Relay.Example.Test/events"] + ["p", "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", "WSS://Author-Relay.Example.Test/events"] ] }, "expected": { "d": "uppercase-rsvp", "event_reference": { - "coordinate": "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour", + "coordinate": "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:farm-tour", "relay": "WSS://Coordinate-Relay.Example.Test/events" }, "revision_reference": { - "id": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "id": "E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF", "relay": "WSS://Event-Relay.Example.Test/events" }, "status": "accepted", "observed_free_busy": "free", "effective_free_busy": "free", "author_hint": { - "pubkey": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "pubkey": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", "relay": "WSS://Author-Relay.Example.Test/events" }, "note": null @@ -675,7 +675,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"]] + "tags": [["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"]] }, "expected": {"error": "missing_tag", "tag": "d"} }, @@ -691,7 +691,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "missing-status"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"]] + "tags": [["d", "missing-status"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"]] }, "expected": {"error": "missing_tag", "tag": "status"} }, @@ -701,7 +701,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "one"], ["d", "two"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"]] + "tags": [["d", "one"], ["d", "two"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"]] }, "expected": {"error": "duplicate_tag", "tag": "d"} }, @@ -713,8 +713,8 @@ "content": "", "tags": [ ["d", "duplicate-a"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], - ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-shift"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], + ["a", "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-shift"], ["status", "accepted"] ] }, @@ -726,7 +726,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "duplicate-status"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["status", "tentative"]] + "tags": [["d", "duplicate-status"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], ["status", "tentative"]] }, "expected": {"error": "duplicate_tag", "tag": "status"} }, @@ -738,8 +738,8 @@ "content": "", "tags": [ ["d", "duplicate-e"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], - ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], + ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], ["e", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], ["status", "accepted"] ] @@ -752,7 +752,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "duplicate-fb"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["fb", "free"], ["fb", "busy"]] + "tags": [["d", "duplicate-fb"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], ["fb", "free"], ["fb", "busy"]] }, "expected": {"error": "duplicate_tag", "tag": "fb"} }, @@ -764,10 +764,10 @@ "content": "", "tags": [ ["d", "duplicate-p"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"] + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"], + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"] ] }, "expected": {"error": "duplicate_tag", "tag": "p"} @@ -780,7 +780,7 @@ "content": "", "tags": [ ["d", "bad-a-arity"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://relay.example.test", "unexpected"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "wss://relay.example.test", "unexpected"], ["status", "accepted"] ] }, @@ -794,8 +794,8 @@ "content": "", "tags": [ ["d", "bad-e-arity"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], - ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://relay.example.test", "unexpected"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], + ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://relay.example.test", "unexpected"], ["status", "accepted"] ] }, @@ -809,9 +809,9 @@ "content": "", "tags": [ ["d", "bad-p-role"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://relay.example.test", "host"] + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "wss://relay.example.test", "host"] ] }, "expected": {"error": "invalid_tag", "tag": "p"} @@ -822,7 +822,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "wrong-kind"], ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar"], ["status", "accepted"]] + "tags": [["d", "wrong-kind"], ["a", "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-calendar"], ["status", "accepted"]] }, "expected": {"error": "invalid_tag", "tag": "a"} }, @@ -838,7 +838,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-event-id"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["e", "not-an-event-id"], ["status", "accepted"]] + "tags": [["d", "bad-event-id"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["e", "not-an-event-id"], ["status", "accepted"]] }, "expected": {"error": "invalid_tag", "tag": "e"} }, @@ -848,7 +848,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-author"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["p", "not-a-pubkey"]] + "tags": [["d", "bad-author"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], ["p", "not-a-pubkey"]] }, "expected": {"error": "invalid_tag", "tag": "p"} }, @@ -858,7 +858,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-a-relay"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "https://relay.example.test"], ["status", "accepted"]] + "tags": [["d", "bad-a-relay"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour", "https://relay.example.test"], ["status", "accepted"]] }, "expected": {"error": "invalid_tag", "tag": "a"} }, @@ -868,7 +868,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-e-relay"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "https://relay.example.test"], ["status", "accepted"]] + "tags": [["d", "bad-e-relay"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "https://relay.example.test"], ["status", "accepted"]] }, "expected": {"error": "invalid_tag", "tag": "e"} }, @@ -878,7 +878,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-p-relay"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "https://relay.example.test"]] + "tags": [["d", "bad-p-relay"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "https://relay.example.test"]] }, "expected": {"error": "invalid_tag", "tag": "p"} }, @@ -888,7 +888,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-status"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "maybe"]] + "tags": [["d", "bad-status"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "maybe"]] }, "expected": {"error": "invalid_tag", "tag": "status"} }, @@ -898,7 +898,7 @@ "input": { "kind": 31925, "content": "", - "tags": [["d", "bad-fb"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["fb", "unknown"]] + "tags": [["d", "bad-fb"], ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], ["fb", "unknown"]] }, "expected": {"error": "invalid_tag", "tag": "fb"} }, @@ -960,7 +960,7 @@ "content": "", "tags": [ ["d", "bad-status-arity"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted", "unexpected"] ] }, @@ -974,7 +974,7 @@ "content": "", "tags": [ ["d", "bad-fb-arity"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-tour"], ["status", "accepted"], ["fb", "free", "unexpected"] ] diff --git a/crates/event_codec/tests/fixtures/calendar_radroots_profile.v1.json b/crates/event_codec/tests/fixtures/calendar_radroots_profile.v1.json @@ -49,12 +49,12 @@ } }, "participants": [ - {"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "relay": "wss://relay.example.test", "role": "host"} + {"pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "relay": "wss://relay.example.test", "role": "host"} ], "categories": ["csa", "vegetables"], "references": ["https://example.test/pickup-details"], "calendar_requests": [ - {"calendar": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "relay": "wss://relay.example.test"} + {"calendar": "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-calendar", "relay": "wss://relay.example.test"} ] }, "expected": { @@ -71,11 +71,11 @@ ["g", "c23nb62w20st"], ["summary", "Weekly pickup"], ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://relay.example.test", "host"], + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "wss://relay.example.test", "host"], ["t", "csa"], ["t", "vegetables"], ["r", "https://example.test/pickup-details"], - ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "wss://relay.example.test"] + ["a", "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:farm-calendar", "wss://relay.example.test"] ] }, "media_verification": "byte_verified", @@ -188,7 +188,7 @@ "input": { "kind": 31922, "content": "", - "tags": [["d", "farm-tour"], ["title", "Urban farm tour"], ["start", "2026-07-18"], ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "WSS://relay.example.test"]] + "tags": [["d", "farm-tour"], ["title", "Urban farm tour"], ["start", "2026-07-18"], ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "WSS://relay.example.test"]] }, "expected": {"error": "non_canonical_field"} }, @@ -278,11 +278,11 @@ "list_description": "NIP-51 set description for compatible clients.", "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "wss://date-relay.example.test/events" }, { - "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg", + "coordinate": "31923:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:CCCCCCCCCCCCCCCCCCCCCg", "relay": "wss://time-relay.example.test/events" } ] @@ -294,8 +294,8 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "South Island farm schedule"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://date-relay.example.test/events"], - ["a", "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg", "wss://time-relay.example.test/events"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "wss://date-relay.example.test/events"], + ["a", "31923:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:CCCCCCCCCCCCCCCCCCCCCg", "wss://time-relay.example.test/events"], ["description", "NIP-51 set description for compatible clients."] ] } @@ -320,7 +320,7 @@ }, "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null } ] @@ -332,7 +332,7 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Victoria farm calendar"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"] ] }, @@ -360,11 +360,11 @@ "title": "Duplicate event calendar", "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "wss://one.example.test" }, { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "wss://two.example.test" } ] @@ -379,7 +379,7 @@ "title": "Uppercase relay", "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "WSS://Relay.Example.Test/events" } ] @@ -394,7 +394,7 @@ "title": "Wrong coordinate kind", "event_references": [ { - "coordinate": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null } ] @@ -410,8 +410,8 @@ "image": { "bytes_utf8": "victoria-calendar", "descriptor": { - "url": "https://media.example/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.webp", - "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "url": "https://media.example/585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df.webp", + "sha256": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "size": 17, "type": "image/webp", "uploaded": 1784347200 @@ -444,8 +444,8 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "South Island farm schedule"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://date-relay.example.test/events"], - ["a", "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg", "wss://time-relay.example.test/events"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "wss://date-relay.example.test/events"], + ["a", "31923:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:CCCCCCCCCCCCCCCCCCCCCg", "wss://time-relay.example.test/events"], ["description", "NIP-51 set description for compatible clients."], ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"] ] @@ -456,11 +456,11 @@ "list_description": "NIP-51 set description for compatible clients.", "event_references": [ { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "wss://date-relay.example.test/events" }, { - "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg", + "coordinate": "31923:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:CCCCCCCCCCCCCCCCCCCCCg", "relay": "wss://time-relay.example.test/events" } ], @@ -477,7 +477,7 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Uppercase reference"], - ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Relay.Example.Test/events"] + ["a", "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Relay.Example.Test/events"] ] }, "expected": {"error": "non_canonical_field", "field": "event_reference"} @@ -491,8 +491,8 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Duplicate reference"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://one.example.test"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://two.example.test"] + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "wss://one.example.test"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "wss://two.example.test"] ] }, "expected": {"error": "duplicate_event_reference"} @@ -527,7 +527,7 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null }, "status": "accepted" @@ -538,7 +538,7 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"] ] } @@ -550,17 +550,17 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "wss://coordinate-relay.example.test/events" }, "revision_reference": { - "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "id": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://event-relay.example.test/events" }, "status": "tentative", "free_busy": "busy", "author_hint": { - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "relay": "wss://author-relay.example.test/events" }, "note": "I can help with the wash-pack shift." @@ -571,11 +571,11 @@ "content": "I can help with the wash-pack shift.", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://coordinate-relay.example.test/events"], + ["a", "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "wss://coordinate-relay.example.test/events"], ["status", "tentative"], - ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://event-relay.example.test/events"], + ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://event-relay.example.test/events"], ["fb", "busy"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://author-relay.example.test/events"] + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "wss://author-relay.example.test/events"] ] }, "observed_free_busy": "busy", @@ -588,7 +588,7 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null }, "status": "declined", @@ -602,7 +602,7 @@ "input": { "d": "farm-tour-rsvp", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null }, "status": "accepted" @@ -615,11 +615,11 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null }, "revision_reference": { - "id": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "id": "E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF", "relay": null }, "status": "accepted" @@ -632,12 +632,12 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null }, "status": "accepted", "author_hint": { - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } }, @@ -649,7 +649,7 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "WSS://Relay.Example.Test/events" }, "status": "accepted" @@ -662,7 +662,7 @@ "input": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31924:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": null }, "status": "accepted" @@ -677,7 +677,7 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"] ] }, @@ -696,25 +696,25 @@ "content": "I can help with the wash-pack shift.", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://coordinate-relay.example.test/events"], - ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://event-relay.example.test/events"], + ["a", "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "wss://coordinate-relay.example.test/events"], + ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://event-relay.example.test/events"], ["status", "tentative"], ["fb", "busy"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://author-relay.example.test/events"] + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "wss://author-relay.example.test/events"] ] }, "expected": { "d": "DDDDDDDDDDDDDDDDDDDDDw", "event_reference": { - "coordinate": "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", + "coordinate": "31923:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "relay": "wss://coordinate-relay.example.test/events" }, "revision_reference": { - "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "id": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://event-relay.example.test/events" }, "author_hint": { - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "relay": "wss://author-relay.example.test/events" }, "status": "tentative", @@ -730,7 +730,7 @@ "content": "Unable to attend.", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "declined"], ["fb", "busy"] ] @@ -750,10 +750,10 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Coordinate-Relay.Example.Test/events"], - ["e", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", "WSS://Event-Relay.Example.Test/events"], + ["a", "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Coordinate-Relay.Example.Test/events"], + ["e", "E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF", "WSS://Event-Relay.Example.Test/events"], ["status", "accepted"], - ["p", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "WSS://Author-Relay.Example.Test/events"] + ["p", "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF", "WSS://Author-Relay.Example.Test/events"] ] }, "expected": {"error": "non_canonical_field"} @@ -766,9 +766,9 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"], - ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"] + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"] ] }, "expected": {"error": "author_hint_mismatch"} @@ -781,7 +781,7 @@ "content": "", "tags": [ ["d", "farm-tour-rsvp"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"] ] }, @@ -796,7 +796,7 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Uppercase coordinate"], - ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ"] + ["a", "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:BBBBBBBBBBBBBBBBBBBBBQ"] ] }, "expected": {"error": "non_canonical_field", "field": "event_reference"} @@ -810,7 +810,7 @@ "tags": [ ["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Uppercase relay"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Relay.Example.Test/events"] + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Relay.Example.Test/events"] ] }, "expected": {"error": "non_canonical_field", "field": "event_reference"} @@ -823,7 +823,7 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"] ] }, @@ -837,8 +837,8 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], - ["e", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], + ["e", "E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF"], ["status", "accepted"] ] }, @@ -852,9 +852,9 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"], - ["p", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"] + ["p", "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF"] ] }, "expected": {"error": "non_canonical_field", "field": "author_hint"} @@ -867,7 +867,7 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Coordinate-Relay.Example.Test/events"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Coordinate-Relay.Example.Test/events"], ["status", "accepted"] ] }, @@ -881,8 +881,8 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], - ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "WSS://Event-Relay.Example.Test/events"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], + ["e", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "WSS://Event-Relay.Example.Test/events"], ["status", "accepted"] ] }, @@ -896,9 +896,9 @@ "content": "", "tags": [ ["d", "DDDDDDDDDDDDDDDDDDDDDw"], - ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"], + ["a", "31922:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:BBBBBBBBBBBBBBBBBBBBBQ"], ["status", "accepted"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "WSS://Author-Relay.Example.Test/events"] + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "WSS://Author-Relay.Example.Test/events"] ] }, "expected": {"error": "non_canonical_field", "field": "author_hint"} diff --git a/crates/event_codec/tests/fixtures/comment_verified_profile.v1.json b/crates/event_codec/tests/fixtures/comment_verified_profile.v1.json @@ -14560,7 +14560,7 @@ "id": "project_parent_author_mismatch", "kind": "social.comment.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"c44cc89ecf98c8cf2e596ea8bb7b99993fcd2767c0ed2842c8ca3d8281342c0a\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000200,\"kind\":1111,\"tags\":[[\"E\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://victoria.example\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"K\",\"30402\"],[\"P\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://victoria.example\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://victoria.example\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"k\",\"30402\"],[\"p\",\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"wss://victoria.example\"]],\"content\":\"Are these carrots available Saturday?\",\"sig\":\"ca7d4eff81d73e21fbd9459733b8e4cb8ea92661b2c2b4fcc23dbf68bdd648e07243c6c78260592957b02b64044ee7e9af93a98b754b3177425e637af7592497\"}" + "event_json": "{\"id\":\"85d1e0149f8263efc02185a1bba50c3955fdee1fd7598e197b20b493f8e5a022\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000200,\"kind\":1111,\"tags\":[[\"E\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://victoria.example\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"K\",\"30402\"],[\"P\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://victoria.example\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://victoria.example\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"k\",\"30402\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\",\"wss://victoria.example\"]],\"content\":\"Are these carrots available Saturday?\",\"sig\":\"cb00151021c7152d8990d1d84c1781c9f9e835909c94485231363e2223220d6a480debb1abc75d6f450c4c63050966a4688740621f33cbe05cd0b181c50003ef\"}" }, "expected": { "error": "comment_parent_author_mismatch" diff --git a/crates/event_codec/tests/fixtures/deletion_verified_profile.v1.json b/crates/event_codec/tests/fixtures/deletion_verified_profile.v1.json @@ -213,8 +213,8 @@ "kind": "social.deletion_request.build_authored_draft.valid", "input": { "address_targets": [ - "31923:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:market", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce" + "31923:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:market", + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce" ], "content": "superseded", "event_targets": [ @@ -250,11 +250,11 @@ ], [ "a", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce" + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce" ], [ "a", - "31923:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:market" + "31923:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:market" ], [ "k", @@ -13117,51 +13117,51 @@ "id": "nip09_project_signed_address_replaceable_boundaries", "kind": "social.deletion_request.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"ca629e60259cd0ac499e85fe3e3c97c9a846311ad17cf9576bf00fe7dd349e5c\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":2,\"kind\":5,\"tags\":[[\"a\",\"0:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:\"],[\"a\",\"3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:\"],[\"a\",\"10000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:\"],[\"a\",\"19999:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:\"]],\"content\":\"\",\"sig\":\"8b29a65a47a30047eaab29ad774f6afb116be114937ea7a7aeb158cd7fc81f08dfa413386942fc4a7db0e64875c94ca4aa2f2272696c62b424649d261afafd44\"}" + "event_json": "{\"id\":\"e0ef10c9e31c32deabe3cb516f289415de0266f8cb96cd2c603f8db1ed4b0d9a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":2,\"kind\":5,\"tags\":[[\"a\",\"0:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:\"],[\"a\",\"3:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:\"],[\"a\",\"10000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:\"],[\"a\",\"19999:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:\"]],\"content\":\"\",\"sig\":\"1c1ab28a42f8b49daf29cfd4511d46a54a6ee32ffdab2d9bfede5fb55045140023eb4ddc57284e0cc9984f3c94167bd4f996ab74c486338d216c75c83f541c72\"}" }, "expected": { "address_targets": [ { - "coordinate": "0:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:", + "coordinate": "0:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:", "identifier": "", "kind": 0, - "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "raw_tag": [ "a", - "0:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:" + "0:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:" ], "tag_index": 0 }, { - "coordinate": "10000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:", + "coordinate": "10000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:", "identifier": "", "kind": 10000, - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "raw_tag": [ "a", - "10000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:" + "10000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:" ], "tag_index": 2 }, { - "coordinate": "19999:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:", + "coordinate": "19999:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:", "identifier": "", "kind": 19999, - "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "pubkey": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4", "raw_tag": [ "a", - "19999:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:" + "19999:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:" ], "tag_index": 3 }, { - "coordinate": "3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:", + "coordinate": "3:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:", "identifier": "", "kind": 3, - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "raw_tag": [ "a", - "3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:" + "3:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:" ], "tag_index": 1 } @@ -13173,19 +13173,19 @@ "raw_tags": [ [ "a", - "0:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:" + "0:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:" ], [ "a", - "3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:" + "3:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:" ], [ "a", - "10000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:" + "10000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:" ], [ "a", - "19999:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:" + "19999:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:" ] ] } @@ -13194,29 +13194,29 @@ "id": "nip09_project_signed_addressable_boundaries", "kind": "social.deletion_request.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"550e49ccc8f4f479bef48605d375ba4ff5f0f8a0fcac449234ace7419b42fa99\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":3,\"kind\":5,\"tags\":[[\"a\",\"30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:\"],[\"a\",\"39999:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm:row:7\"]],\"content\":\"\",\"sig\":\"897bb197ab6a688ecc057e57151fc24519a1fddb6f6f734821effc8d0a9d2629c13cf13797582b5572c5bc6f3a0a6d4cb96a58ffdfbf5d910deae99e32399a2d\"}" + "event_json": "{\"id\":\"1d3c5d5f0e141adbd65a871ab9ac5c742f75c70ec729631945a73c461dd01fa4\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":3,\"kind\":5,\"tags\":[[\"a\",\"30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:\"],[\"a\",\"39999:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:farm:row:7\"]],\"content\":\"\",\"sig\":\"ac7e31bdeab52734e8ef6913c0e23867e791c793d0e79501620a1144ba3e41216fb650c5ca23e5f0b2d4cdb40b15da525cde9bdffe09bcf7e97744178abcb651\"}" }, "expected": { "address_targets": [ { - "coordinate": "30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:", + "coordinate": "30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:", "identifier": "", "kind": 30000, - "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "raw_tag": [ "a", - "30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:" + "30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:" ], "tag_index": 0 }, { - "coordinate": "39999:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm:row:7", + "coordinate": "39999:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:farm:row:7", "identifier": "farm:row:7", "kind": 39999, - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "raw_tag": [ "a", - "39999:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm:row:7" + "39999:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:farm:row:7" ], "tag_index": 1 } @@ -13228,11 +13228,11 @@ "raw_tags": [ [ "a", - "30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:" + "30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:" ], [ "a", - "39999:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm:row:7" + "39999:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:farm:row:7" ] ] } @@ -13241,18 +13241,18 @@ "id": "nip09_project_signed_mixed_raw_retention", "kind": "social.deletion_request.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"4206395f0c18d86536bd57e6ca38cbb48dcf44ac1d0a22904190442445d2fc3f\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":4,\"kind\":5,\"tags\":[[\"x\",\"unknown\",\"retained\"],[\"e\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"wss://relay.example\",\"root\"],[\"a\",\"30000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:produce\",\"trailing\"],[\"k\",\"30000\",\"trailing\"]],\"content\":\"raw order\",\"sig\":\"d6078beab27d6ed5c3427a98639a081c255d2faed7efd89207f1839b40f5b06c1584f91224a9a3e13360861923488f89147091c130c701cb3053ec27e1a0d7dc\"}" + "event_json": "{\"id\":\"f50b6892d049f34e941eb691ef4a4b956217a44ca68f3aa0c4ef7e0ca360d092\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":4,\"kind\":5,\"tags\":[[\"x\",\"unknown\",\"retained\"],[\"e\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"wss://relay.example\",\"root\"],[\"a\",\"30000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:produce\",\"trailing\"],[\"k\",\"30000\",\"trailing\"]],\"content\":\"raw order\",\"sig\":\"df09f81f6b6ca7e4c18aee1a9476ed56b6fbe5acba2ce7be73f6a617db7db9a522691f64034377902cfd0f36750992fd2984c1c334f49a2f49ba3d2a7c0a66a7\"}" }, "expected": { "address_targets": [ { - "coordinate": "30000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:produce", + "coordinate": "30000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:produce", "identifier": "produce", "kind": 30000, - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "raw_tag": [ "a", - "30000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:produce", + "30000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:produce", "trailing" ], "tag_index": 2 @@ -13297,7 +13297,7 @@ ], [ "a", - "30000:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:produce", + "30000:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:produce", "trailing" ], [ @@ -13312,18 +13312,18 @@ "id": "nip09_project_signed_duplicate_targets_first_provenance", "kind": "social.deletion_request.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"ef7b4f50235a637d9859d7f5ad879bc1e423a59da6835e82cbcd7c368c733b26\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":5,\"kind\":5,\"tags\":[[\"e\",\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\"first\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"second\"],[\"a\",\"030000:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:produce\",\"first\"],[\"a\",\"30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce\",\"second\"]],\"content\":\"\",\"sig\":\"dded0dcec9cebf45c443ec736ccf096911cf57adfd45b2b3143c60e26c95582167c05bbce452b0adcbf333fe4471c0c95d58fc466a87e6349820301d738047b3\"}" + "event_json": "{\"id\":\"b922ee8c829b7caf95227a5b290511f81a7f7c56b98af57673adfd5bbf8b4834\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":5,\"kind\":5,\"tags\":[[\"e\",\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\"first\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"second\"],[\"a\",\"030000:E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF:produce\",\"first\"],[\"a\",\"30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce\",\"second\"]],\"content\":\"\",\"sig\":\"c945d8be7013cc8db307a70d271977fedfbc479416f457baf08b18a4e9e64923420af6156bf44e84e6c466e71ecd4c1baf529384067fd8d2f82d4fc0802a1534\"}" }, "expected": { "address_targets": [ { - "coordinate": "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce", + "coordinate": "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce", "identifier": "produce", "kind": 30000, - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "raw_tag": [ "a", - "030000:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:produce", + "030000:E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF:produce", "first" ], "tag_index": 2 @@ -13356,12 +13356,12 @@ ], [ "a", - "030000:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:produce", + "030000:E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF:produce", "first" ], [ "a", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce", + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce", "second" ] ] @@ -13371,29 +13371,29 @@ "id": "nip09_project_signed_canonical_effect_sorting", "kind": "social.deletion_request.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"682de0df23f548c7e1e8272ff7776c5574ea12b18041e46004f6045263433a27\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":6,\"kind\":5,\"tags\":[[\"e\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"a\",\"31923:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:market\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"],[\"a\",\"30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce\"]],\"content\":\"\",\"sig\":\"9d6a1bc42f42739042c0bef26ff5557176f571b152cd30b730b3a2ea388a874827f7ecf1ed4241234fe50143da77640542ecd5af4263900981760d1be616eea2\"}" + "event_json": "{\"id\":\"c85bb88aff6fe36198851dbc8638ad8b288a861d18d4834ed61e3545cde97a09\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":6,\"kind\":5,\"tags\":[[\"e\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"a\",\"31923:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:market\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"],[\"a\",\"30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce\"]],\"content\":\"\",\"sig\":\"1f15aeb3016d5533c675d227b79d59f59e77c78cbbe4d59ec25a2d7624c8836c162990e1b388c6c0ee6800e18f512b0216a2ade79788d1b032c0f74278558f3a\"}" }, "expected": { "address_targets": [ { - "coordinate": "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce", + "coordinate": "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce", "identifier": "produce", "kind": 30000, - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "raw_tag": [ "a", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce" + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce" ], "tag_index": 3 }, { - "coordinate": "31923:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:market", + "coordinate": "31923:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:market", "identifier": "market", "kind": 31923, - "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "pubkey": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4", "raw_tag": [ "a", - "31923:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:market" + "31923:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:market" ], "tag_index": 1 } @@ -13426,7 +13426,7 @@ ], [ "a", - "31923:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:market" + "31923:5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4:market" ], [ "e", @@ -13434,7 +13434,7 @@ ], [ "a", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce" + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce" ] ] } @@ -13443,18 +13443,18 @@ "id": "nip09_project_signed_kind_advisory_diagnostics", "kind": "social.deletion_request.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"e8c11c5d1fd81bee0423a6f20db42786cc36f4c8922845c22ad0753bc4d10447\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":7,\"kind\":5,\"tags\":[[\"a\",\"30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:produce\"],[\"k\",\"30000\"],[\"k\"],[\"k\",\"030000\"],[\"k\",\"30000\",\"duplicate\"],[\"k\",\"5\"]],\"content\":\"\",\"sig\":\"3c7b7ad36ef173e61d85cd22989e5694e8373c670bf3b84dabf602c4dc17313eba107952969ac92521900e7f10a1fce80a6f90120e09705740a3dc5b4491c1a9\"}" + "event_json": "{\"id\":\"a7759259b243f84005168ed7a11c626d1156c99393e51610c54f511ece3ea516\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":7,\"kind\":5,\"tags\":[[\"a\",\"30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:produce\"],[\"k\",\"30000\"],[\"k\"],[\"k\",\"030000\"],[\"k\",\"30000\",\"duplicate\"],[\"k\",\"5\"]],\"content\":\"\",\"sig\":\"6205f197be2f061e31950234b4ad3a9b90659710a2d65836f8d243517fc4e1aac2f3d54f4eab401dba81d6481be687a2a0df9c482e6cc575ace1e5b3f405a065\"}" }, "expected": { "address_targets": [ { - "coordinate": "30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:produce", + "coordinate": "30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:produce", "identifier": "produce", "kind": 30000, - "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", "raw_tag": [ "a", - "30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:produce" + "30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:produce" ], "tag_index": 0 } @@ -13516,7 +13516,7 @@ "raw_tags": [ [ "a", - "30000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:produce" + "30000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:produce" ], [ "k", @@ -23395,7 +23395,7 @@ "id": "nip09_project_signed_address_target_unsupported_kind", "kind": "social.deletion_request.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"9489e523cdf3103b1608cd03a4c31f8c2f83fa8b092521cac782ee8ff7e006ad\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":33,\"kind\":5,\"tags\":[[\"a\",\"20000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:\"]],\"content\":\"\",\"sig\":\"b3479000500fd8453232d995e1c36073ce20f0ef579ddcc6122c9dd23525b37d726fe1f0a98e036a208726876a737dfb6fdc21acb10e28cf59ed80962b2a0ad2\"}" + "event_json": "{\"id\":\"5c6e3d68c69fa58615cb7db74edaabf055390101947bcced291ba00edd8b2a7d\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":33,\"kind\":5,\"tags\":[[\"a\",\"20000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:\"]],\"content\":\"\",\"sig\":\"df67f12a606578fd9da58fe2e2edf1964844d816afc08d34b625d976dc536a6d218953c38f8151265ca6f4f4cdac6a973fba40c4925e27756a54d072379adea8\"}" }, "expected": { "error": "deletion_address_target_invalid" @@ -23405,7 +23405,7 @@ "id": "nip09_project_signed_address_target_identifier_forbidden", "kind": "social.deletion_request.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"cb5e015b280e5a5cdce1aab86ca69ff0b5ab02463e59e385aa2423b91850c374\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":34,\"kind\":5,\"tags\":[[\"a\",\"10000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:forbidden\"]],\"content\":\"\",\"sig\":\"689d9788b632d8400d9f29a460b94402ff0dac7ef5a1df971235ce32fe91f758ecee66dfee6c208b6eef6d3dff6d42c874e7b6ea10d1a8a7ecc5cc4fdf167985\"}" + "event_json": "{\"id\":\"d3dabd8d980b0303eaf0384984ae3e30fcd4a3d4780d4bc03645427fab3ec48a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":34,\"kind\":5,\"tags\":[[\"a\",\"10000:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:forbidden\"]],\"content\":\"\",\"sig\":\"afd5a1c45d68e29c1747d608d4ced1afbe9c3079a4df2696184a9a354a2b360a8881eb35764a17d2cbe66fe69a107237058936bf5259d7e4fa0197d48cc9eef7\"}" }, "expected": { "error": "deletion_address_target_invalid" @@ -23554,18 +23554,18 @@ "id": "nip09_admit_signed_address_target", "kind": "social.deletion_request.verify_and_admit_event.valid", "input": { - "event_json": "{\"id\":\"ca06409e5b9f6020a4ddd64c01cddb21a60bc0c878aae16e4e86ec862db0259a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":51,\"kind\":5,\"tags\":[[\"a\",\"30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce\"]],\"content\":\"\",\"sig\":\"3a9d2b64027f54aa7c4cf7a71f59f46626c7f7da328c14f15df0e1e20c135d6c39249ecd87ae2a52cd09fa6248b2124c1dde1b7c0c7a79cec859d1314a4ae050\"}" + "event_json": "{\"id\":\"1db5e2667b7de3f36f5bdff85ca82d5edfb76936bcdf3a15042b94866f835117\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":51,\"kind\":5,\"tags\":[[\"a\",\"30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce\"]],\"content\":\"\",\"sig\":\"5cec31910d443b971d276589b8770dfe6692f9e5dc7169a9359d4222c1ecb9cbc3366bc649388d5bef6ac92f84ed5e46025013143fd1b5457d465e5b94d2d24e\"}" }, "expected": { "address_targets": [ { - "coordinate": "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce", + "coordinate": "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce", "identifier": "produce", "kind": 30000, - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "raw_tag": [ "a", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce" + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce" ], "tag_index": 0 } @@ -23577,7 +23577,7 @@ "raw_tags": [ [ "a", - "30000:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:produce" + "30000:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:produce" ] ] } @@ -23586,18 +23586,18 @@ "id": "nip09_admit_signed_mixed_tolerant_projection", "kind": "social.deletion_request.verify_and_admit_event.valid", "input": { - "event_json": "{\"id\":\"be56e90b66e7e2c8bbd72d97e940f28a9da0549e3ff3ea9fb76246f08f8054e2\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":52,\"kind\":5,\"tags\":[[\"x\",\"retained\"],[\"e\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"relay\"],[\"a\",\"31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:market\",\"extra\"],[\"k\",\"31923\"]],\"content\":\"withdrawn\",\"sig\":\"24b858e8f25c939112a4f69a9d5e5c53e7deeffcefe3311adacb2ad6ff1ad4df7c247f5e92d90efb1d023dcc3d733f3e71f63c3d66e10464db1b8e2156663c92\"}" + "event_json": "{\"id\":\"114bc1c32e196912dbe03352046f1718e0e66363711ec4c3491891b04080108a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":52,\"kind\":5,\"tags\":[[\"x\",\"retained\"],[\"e\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"relay\"],[\"a\",\"31923:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:market\",\"extra\"],[\"k\",\"31923\"]],\"content\":\"withdrawn\",\"sig\":\"8e0ee72c28606705747eae21f0cbb546ea22946b160c46353b673e07004093aea29650be9cd4d12d34111a8f0efac9b7140f7d3f3f9a46c5ec9b7a00887b89c8\"}" }, "expected": { "address_targets": [ { - "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:market", + "coordinate": "31923:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:market", "identifier": "market", "kind": 31923, - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "raw_tag": [ "a", - "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:market", + "31923:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:market", "extra" ], "tag_index": 2 @@ -23638,7 +23638,7 @@ ], [ "a", - "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:market", + "31923:1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c:market", "extra" ], [ diff --git a/crates/event_codec/tests/fixtures/post_verified_profiles.v1.json b/crates/event_codec/tests/fixtures/post_verified_profiles.v1.json @@ -143,7 +143,7 @@ "content": "Direct reply", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://root.relay.example" } }, @@ -152,7 +152,7 @@ "content": "Direct reply", "tags": [ ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "root"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"] ] } }, @@ -163,12 +163,12 @@ "content": "Nested reply", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null }, "parent": { "event_id": "2222222222222222222222222222222222222222222222222222222222222222", - "author": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "author": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": "wss://parent.relay.example" } }, @@ -178,8 +178,8 @@ "tags": [ ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "reply"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"] + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"] ] } }, @@ -190,12 +190,12 @@ "content": "Ambiguous", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null }, "parent": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "author": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null } }, @@ -210,7 +210,7 @@ "content": "Invalid", "root": { "event_id": "not-an-event-id", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } }, @@ -225,7 +225,7 @@ "content": "Canonical relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://[2001:db8::1]:8443/nostr?region=ca-bc&next=%2Ffeed" } }, @@ -234,7 +234,7 @@ "content": "Canonical relay", "tags": [ ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://[2001:db8::1]:8443/nostr?region=ca-bc&next=%2Ffeed", "root"], - ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"] ] } }, @@ -245,7 +245,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://%" } }, @@ -260,7 +260,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://%zz" } }, @@ -275,7 +275,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://256.1.1.1" } }, @@ -290,7 +290,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://[v1.foo]" } }, @@ -305,7 +305,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://relay.example:" } }, @@ -320,7 +320,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://relay.example:0" } }, @@ -335,7 +335,7 @@ "content": "Invalid relay", "root": { "event_id": "1111111111111111111111111111111111111111111111111111111111111111", - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "author": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "wss://relay.example:65536" } }, @@ -347,7 +347,7 @@ "id": "project_signed_nip10_marked_direct", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Direct reply\",\"sig\":\"16afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd\"}" + "event_json": "{\"id\":\"d62b9340ac9808fc84a751788d017344ede91aea1c89081be3f7bff4a2266354\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Direct reply\",\"sig\":\"206758bee31328a53457e3190b046a4a184941223f4e8ed1696db28d71c9af03138fe9add32d5e97d6f3b50bd0f78e4d5a91629dbf4d12aedd4315ce96c97e8c\"}" }, "expected": { "style": "marked", @@ -365,8 +365,8 @@ "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -377,7 +377,7 @@ "id": "project_signed_nip10_marked_with_citation", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"b6bd8c4e2c8e3098a0b6fdbefbf7a25285887519e2ea58738af4221ddb5fd39e\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000016,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://relay.example\",\"\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Marked reply with citation\",\"sig\":\"9f6516def04521e4e9c9b27f1225e43c03919914c8ced58dfb0afd5ee4fb950bcbbe42ba505f00f75be399ac4a61f7a7eb612b58051bc78ae245b7b122f760e4\"}" + "event_json": "{\"id\":\"5e6db69a9c9968a8f1eb3699ddcf19c0e7b7c9255df616ce7032928b93ad0f6a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000016,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://relay.example\",\"\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Marked reply with citation\",\"sig\":\"d610d090bab46e06d4a674d4bdfd3204f64bc8d1b94929d64cd9ddfd604de89dc9c5ac87229e2f095091e7cac2805ef2ad45325b019e2a79c89b677981f3b068\"}" }, "expected": { "style": "marked", @@ -394,17 +394,17 @@ "citations": [ { "tag_index": 0, - "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://relay.example", "", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://relay.example", "", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "3333333333333333333333333333333333333333333333333333333333333333", "relay": "wss://relay.example", - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" } ], "participants": [ { "tag_index": 2, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -415,7 +415,7 @@ "id": "project_signed_nip10_marked_with_malformed_citation", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"c346aaff3f00a5c8fac4d8dcc15f286f56acbc4608c2e67e799266643f2544fa\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000017,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"\",\"mention\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Marked reply with malformed citation\",\"sig\":\"25d8248ba0220435036611dfbf14c4d52f63a2f29e568f9fd79435faa126d7f862068a1db66dac9a2179911e084eb12a03948a51f0ad7a50ab4fc6a83491037b\"}" + "event_json": "{\"id\":\"19a98ea4c777451618f4f36549abbc352f55c27e254978a2a0dcc0c910cc52ce\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000017,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"\",\"mention\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Marked reply with malformed citation\",\"sig\":\"8fbb467df2771e7a6fc67ca12bfefa2e5b635bfe16d8b963df29d7d8a8df3387e7f9229677045317e470247656db8c87261dc1cf38580c1856b32b2a4fb5791c\"}" }, "expected": { "style": "marked", @@ -433,8 +433,8 @@ "participants": [ { "tag_index": 2, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -451,7 +451,7 @@ "id": "project_signed_nip10_marked_nested_reordered", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"8179bf853f4b24f4ba7af58f162c0dabf08e74c2fe27df4e5573e48d86a8c1c5\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000002,\"kind\":1,\"tags\":[[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\",\"reply\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Nested reply\",\"sig\":\"53b88d556d28f98b43ee36eb8b6453d1fa36da7058891100608ab8e0842dc3d1c49de29323f08679ecbee9e49cf3a09c59139c9e7285826dc7db479dd6219fd3\"}" + "event_json": "{\"id\":\"be4126cc38031f6d47a3d00c81d281999fb4dc6e73ae4bf4b757271be5b0abc0\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000002,\"kind\":1,\"tags\":[[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\",\"reply\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"root\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Nested reply\",\"sig\":\"4e8439d145b4ce2366818fa6f0722e23bf664c92dbf10728fbad3c223d38150ef160566854e052172ff8c060f8924d030bd24c32919967b6f131771c1376e590\"}" }, "expected": { "style": "marked", @@ -459,30 +459,30 @@ "direct": false, "root": { "tag_index": 1, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "root", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "1111111111111111111111111111111111111111111111111111111111111111", "relay": "wss://root.relay.example", - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" }, "parent": { "tag_index": 0, - "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "reply", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], "event_id": "2222222222222222222222222222222222222222222222222222222222222222", "relay": "wss://parent.relay.example", - "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + "author_hint": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c" }, "citations": [], "participants": [ { "tag_index": 2, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null }, { "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -493,7 +493,7 @@ "id": "project_signed_nip10_positional_direct", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"802da76963580d5932a400a2e56e73caccb80c78d43845c62f5fd95feee600e8\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000003,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Legacy direct reply\",\"sig\":\"9a1a958170b2faf3c467bf174e776f7f61bf886644657cc12d92d6a9782db89f273508e1d940412645bbb73a6ce7948b7d20daa77647aa5fd8f9e164159826e7\"}" + "event_json": "{\"id\":\"a7141da919755d6ace9b5fa400928d512b4ef785c0ec03d55989bc2d6cbce860\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000003,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Legacy direct reply\",\"sig\":\"eb2112223ed35f4b0919578a4981e08b403779391f6f9a831ea51763779028d85f72ce8224b1ae469ad6baea21d1d275210bb438a126b4391dab0c347051917a\"}" }, "expected": { "style": "legacy_positional", @@ -511,8 +511,8 @@ "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -523,7 +523,7 @@ "id": "project_signed_nip10_positional_many", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"71af1b702a370218eeb1d6d4fd4734c8cd5fbc64c02ef90196365bda0755490b\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000004,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Legacy nested reply\",\"sig\":\"49d2bf6c65edab401f7d981bf7bd87b7b1ece61d347f75270f38cf67597791f4058c6bd58444a52f831b0cd15c1f91e18958b3ca877a196ababd7055247d65aa\"}" + "event_json": "{\"id\":\"d3f1220f5cb9d51a76cf2551d27cbbb7cfbb5f68ad221045118eb1fd313c9d64\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000004,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"]],\"content\":\"Legacy nested reply\",\"sig\":\"41a3c1da0c331dc67b1a79eb440dee86b1e6baf11f54fd8e1771ebacb0c0681500d5eece1933fa3c68ca745d552a97753378acac06ce5a7328da0086387f5c59\"}" }, "expected": { "style": "legacy_positional", @@ -555,14 +555,14 @@ "participants": [ { "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null }, { "tag_index": 4, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null } ], @@ -573,7 +573,7 @@ "id": "project_signed_nip10_positional_direct_with_author_hint", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"ff050a31d73546d609080281ad2c1e98f878aba826108e7ee0462cf6b397a941\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000018,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Legacy direct with author hint\",\"sig\":\"e71bcb61d49862cd7fa1c776e4fe51aa9e4198234ee93efeacc9afc3d7038c00dee246f1df19eb0fd22aee0a70436708faec39e92bee16e86601f1c91fee1117\"}" + "event_json": "{\"id\":\"f885bad6dc41077036097e81bedb19a0b6b78a48cee24513eb788a997353802c\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000018,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Legacy direct with author hint\",\"sig\":\"473111ec136bcf38035a1848c6fa02ac2ba2a946c3270485d93c11287a3ebb0ac87e6a90b1b91d8beb4f8f958a2b58a3cf494622eafad8fb91cb1fdf5f64bcd0\"}" }, "expected": { "style": "legacy_positional", @@ -581,18 +581,18 @@ "direct": true, "root": { "tag_index": 0, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "1111111111111111111111111111111111111111111111111111111111111111", "relay": "wss://root.relay.example", - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" }, "parent": null, "citations": [], "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -603,7 +603,7 @@ "id": "project_signed_nip10_positional_many_with_author_hints", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"2473084e4272dc675f34e18b9b2aaa3a164e3132bf19056c734759c922acafa7\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000019,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://citation.relay.example\",\"\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\",\"\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"]],\"content\":\"Legacy nested with author hints\",\"sig\":\"e94c18ae8d2c8785a32f42dfff57712508018e2820d24497fbafcfcecc50890d19a4c15d48b2729c4f9dbe73e4c985e055097174b4505ed3a68858d4d56f7809\"}" + "event_json": "{\"id\":\"1f8d3d805ead850160ed74b446aadca7f5f44591484e1cdb22b471e5d689ece3\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000019,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://citation.relay.example\",\"\",\"5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\",\"\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"p\",\"5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4\"]],\"content\":\"Legacy nested with author hints\",\"sig\":\"6c927cc43a95f998ae43956c8e6bc0ab7c18e70dd91eef66ed8360dead902a83c921134e826100b13ea04122ca4bcdf3b0250b0852307141f0a0ed193e87fd9f\"}" }, "expected": { "style": "legacy_positional", @@ -611,44 +611,44 @@ "direct": false, "root": { "tag_index": 0, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "1111111111111111111111111111111111111111111111111111111111111111", "relay": "wss://root.relay.example", - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" }, "parent": { "tag_index": 2, - "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], "event_id": "2222222222222222222222222222222222222222222222222222222222222222", "relay": "wss://parent.relay.example", - "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + "author_hint": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c" }, "citations": [ { "tag_index": 1, - "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://citation.relay.example", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://citation.relay.example", "", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"], "event_id": "3333333333333333333333333333333333333333333333333333333333333333", "relay": "wss://citation.relay.example", - "author_hint": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + "author_hint": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4" } ], "participants": [ { "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null }, { "tag_index": 4, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null }, { "tag_index": 5, - "raw_tag": ["p", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], - "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "raw_tag": ["p", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"], + "pubkey": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4", "relay": null } ], @@ -659,7 +659,7 @@ "id": "project_signed_nip10_positional_malformed_middle_citation", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"ce7684655dafc04bcb8048f4ba95da4b4780316ce90143c332ca4ed40af60ac7\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000020,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"\",\"mention\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Legacy nested with malformed citation\",\"sig\":\"a278e44d6c44fe196c3557d01a99ec3cf3eaa31e16b66370743183db671eac6da75f11151b9da194582b03f529c4e9f871cf743d58dea430d0d12c3ed90c4997\"}" + "event_json": "{\"id\":\"6ea3acdf3d4df0e0dc107a514c1801f658ff44345d1c300ab12f553aaeddb40e\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000020,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"\",\"mention\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"]],\"content\":\"Legacy nested with malformed citation\",\"sig\":\"261e5b2c9632cab25a308b86011655a82dbcda58c4b1908fe8f6465eebf9f250c00086927d9ddf93b73d24467c6e18774bc1b1ffe08a47342b287d86cd2a2b15\"}" }, "expected": { "style": "legacy_positional", @@ -683,14 +683,14 @@ "participants": [ { "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null }, { "tag_index": 4, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null } ], @@ -707,7 +707,7 @@ "id": "project_signed_nip10_precedes_ask_and_media", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"7911ed2e9e2e08173a357f5f83fcdc415ca0c87e95682bad28cefe3a616f5be3\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000005,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://media.example/image.webp\",\"x malformed\"]],\"content\":\"Reply with compatibility metadata\",\"sig\":\"9bd6c5f40c8587893b3460a53931c1253b2764c44922b25ffb64fe3e96e7fe2b8d139d5fca5ffd7b6f5a1cf19598c2a34e7ab48f5d15b18e695a5e39c81fc9f4\"}" + "event_json": "{\"id\":\"cc9440768dea0bf58b002ace8c836893ac6c7a522bdf25048fa34a6a55c03a4a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000005,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://media.example/image.webp\",\"x malformed\"]],\"content\":\"Reply with compatibility metadata\",\"sig\":\"e28dd20ed2d04aac40f67a8cccdd6d8cb261fa54c04c1ea57d021d4d4ab9a3c48ca5e52c5c182f4204302315ee9903f39c90117558457a84daeef2505f240294\"}" }, "expected": { "style": "marked", @@ -725,8 +725,8 @@ "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -737,7 +737,7 @@ "id": "project_signed_nip10_invalid_relay", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"15e0f001e760eb1ac3fd15b8a85e43a5c07603d4ceccdc6ccb93e6cc1876a626\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000006,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"https://relay.example\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Invalid relay\",\"sig\":\"18065b4433c33b29a06ecec64f0f863482479aec2832fcde3c95d2814b5ed91310fdd44df507f346e0231206be239ed4ac85353fc60a838435ae10feae2cc7c6\"}" + "event_json": "{\"id\":\"85b05a80dd168098ea8dcdc6dfa656bcca902120f82bf36a5a5b94c1c949e122\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000006,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"https://relay.example\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Invalid relay\",\"sig\":\"de303499d4cee838d854be0001e686529eda96d37a7f9886f469bf49ad2269aea4b4d3671a1ca820996ba597eece5915d42fdf98452d19bcc630312b0b63f56e\"}" }, "expected": { "style": "marked", @@ -755,8 +755,8 @@ "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -773,7 +773,7 @@ "id": "project_signed_nip10_canonical_relay_authorities", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"a9f7683ed2eaf63e64af15dddf726ea32ec86e6079cd1038edf379c2de96a199\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000020,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed\",\"\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"ws://127.0.0.1:21003\",\"reply\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://[2001:db8::1]:8443/nostr\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"ws://localhost:21003\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"wss://participant.relay.example?region=ca-bc\"],[\"p\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"]],\"content\":\"Canonical relay authorities\",\"sig\":\"4b1691d3d3abcb62a70041da50a0c9790871454a2fb234bac6061a97b4407f91299e905210792c1005883a800d31a7580a218172b4c8ac2834e2c6bcf5b0d925\"}" + "event_json": "{\"id\":\"e7166651c6bd2fd462f7820fca73690049ee9a589eaf161495b38c9b5fe34f92\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000020,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed\",\"\",\"5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"ws://127.0.0.1:21003\",\"reply\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://[2001:db8::1]:8443/nostr\",\"root\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"ws://localhost:21003\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\",\"wss://participant.relay.example?region=ca-bc\"],[\"p\",\"5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4\"]],\"content\":\"Canonical relay authorities\",\"sig\":\"b0faf3e55b6309077108e9e97425d5ea850cbcf5f8b73ad1bbf85731c549ab482dc7dca2cfd7f89a3e1ddd0f1ae8d243e07523ac5251310b0c685cfe0134d073\"}" }, "expected": { "style": "marked", @@ -781,44 +781,44 @@ "direct": false, "root": { "tag_index": 2, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://[2001:db8::1]:8443/nostr", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://[2001:db8::1]:8443/nostr", "root", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "1111111111111111111111111111111111111111111111111111111111111111", "relay": "wss://[2001:db8::1]:8443/nostr", - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" }, "parent": { "tag_index": 1, - "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "ws://127.0.0.1:21003", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "ws://127.0.0.1:21003", "reply", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], "event_id": "2222222222222222222222222222222222222222222222222222222222222222", "relay": "ws://127.0.0.1:21003", - "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + "author_hint": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c" }, "citations": [ { "tag_index": 0, - "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed", "", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"], "event_id": "3333333333333333333333333333333333333333333333333333333333333333", "relay": "wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed", - "author_hint": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + "author_hint": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4" } ], "participants": [ { "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "ws://localhost:21003"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "ws://localhost:21003"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": "ws://localhost:21003" }, { "tag_index": 4, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://participant.relay.example?region=ca-bc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "wss://participant.relay.example?region=ca-bc"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": "wss://participant.relay.example?region=ca-bc" }, { "tag_index": 5, - "raw_tag": ["p", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], - "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "raw_tag": ["p", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"], + "pubkey": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4", "relay": null } ], @@ -829,7 +829,7 @@ "id": "project_signed_nip10_malformed_relay_authorities", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"715bf99c903fcc3ff2f78411de07df916ab6b4476142c4f6974cf5eeddcabfd5\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000021,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://%\",\"\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://%zz\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://256.1.1.1\",\"reply\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"wss://[v1.foo]\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"]],\"content\":\"Malformed relay authorities\",\"sig\":\"37644f8a4b353d758aa1ce78f78fd17c702c4fd862b0408c3c61000ef9533150dc4888ef78b1023f6ee26b0abe786e31e9cc442f62c12260af04750b58f3f20e\"}" + "event_json": "{\"id\":\"fe9fbccea1eb7daf0b7fe3bce5507c5973617b82e8134f2fab82c726bf421ef3\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000021,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://%\",\"\",\"5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://%zz\",\"root\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://256.1.1.1\",\"reply\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://[v1.foo]\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"],[\"p\",\"5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4\"]],\"content\":\"Malformed relay authorities\",\"sig\":\"9b179c47c13301bfe31f1d0027dcca7dabf1ebdcb0a0e6f7764cb767c346963c8b801549716f4422e22b8d9311c1bb840faa7751c651e1ad0746f76953617a95\"}" }, "expected": { "style": "marked", @@ -837,44 +837,44 @@ "direct": false, "root": { "tag_index": 1, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://%zz", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://%zz", "root", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "1111111111111111111111111111111111111111111111111111111111111111", "relay": null, - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" }, "parent": { "tag_index": 2, - "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://256.1.1.1", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://256.1.1.1", "reply", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], "event_id": "2222222222222222222222222222222222222222222222222222222222222222", "relay": null, - "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + "author_hint": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c" }, "citations": [ { "tag_index": 0, - "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://%", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://%", "", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"], "event_id": "3333333333333333333333333333333333333333333333333333333333333333", "relay": null, - "author_hint": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + "author_hint": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4" } ], "participants": [ { "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://[v1.foo]"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://[v1.foo]"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null }, { "tag_index": 4, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null }, { "tag_index": 5, - "raw_tag": ["p", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], - "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "raw_tag": ["p", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"], + "pubkey": "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4", "relay": null } ], @@ -882,22 +882,22 @@ { "code": "reply_reference_relay_ignored", "tag_index": 0, - "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://%", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"] + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://%", "", "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"] }, { "code": "reply_reference_relay_ignored", "tag_index": 1, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://%zz", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://%zz", "root", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"] }, { "code": "reply_reference_relay_ignored", "tag_index": 2, - "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://256.1.1.1", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"] + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://256.1.1.1", "reply", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"] }, { "code": "reply_author_relay_ignored", "tag_index": 3, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://[v1.foo]"] + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://[v1.foo]"] } ] } @@ -906,7 +906,7 @@ "id": "project_signed_nip10_ambiguous_same_reference", "kind": "social.reply.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"86f19437493f84ecd999d1cd5890ff53fa157b99309f65c056de48e82a8c75bf\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000007,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"reply\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Ambiguous\",\"sig\":\"730b282fadf78838f6dad8830711fff6cff5b6da0d3c42db49faf1ef547cf3908ec0c901e1ac7069f8c346e9ec73cea879830a8687e18d87e476a1e0f82a0ca7\"}" + "event_json": "{\"id\":\"8642b1d2380d0a52c13011863530f781da7654c37f8176645abf2eb08d39d24d\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000007,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"reply\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Ambiguous\",\"sig\":\"1f61730dda3b1d00ff2f85701f3e928bfdbd9f1dff8ab2c0971413e03194718c1bba766e62738074ad245d2fae23ebeec2cd3ab45eb353dd82a116833139de42\"}" }, "expected": { "error": "reply_reference_ambiguous" @@ -945,7 +945,7 @@ "id": "project_signed_nip10_invalid_event_id", "kind": "social.reply.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"2c071cfb2696c8cb2e676564290007bcbc995d08a5404462f747d6ceb73194fe\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000009,\"kind\":1,\"tags\":[[\"e\",\"not-an-event-id\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Invalid event id\",\"sig\":\"712bed9b975b2742dbb4b241e70893c67fbd4debab110c7d44bf365500405cc9c5c39efa1f9a3260a3a57f10cd0b981d84c327ff4ae0f1afa21c6e9fc8f8b851\"}" + "event_json": "{\"id\":\"8847b627c9e8e07a6506fcd66e2db952b76d12128498093f149f7b166400b3d0\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000009,\"kind\":1,\"tags\":[[\"e\",\"not-an-event-id\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Invalid event id\",\"sig\":\"83e1bf2c28e54ef653d52b352f9f41693030f50e105d4d67027df357187e84343ab44ceb039e09c3e93806692a4b670a94f00e407eb86d86b9af20d5c92f5602\"}" }, "expected": { "error": "reply_event_id_invalid" @@ -955,7 +955,7 @@ "id": "project_signed_nip10_author_hint_mismatch", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"a54d89f21c0a80df592eab60b07b0800ea5bd212fbe17d0f9deb852c109775a9\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000010,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Author mismatch\",\"sig\":\"3a9b2af22a1b63a1bf6a4cdf0f83497a199ff6f5edc5b1ea9fd0a70c4dd12034431e3992be9ee80827cd0f396e70d0a9f0c316408a269c80949cb5a0cf59c5f7\"}" + "event_json": "{\"id\":\"24e1807536ffe4dcdb01267f6eca1e53bf34c3e2d20a13111b864d5af8b14c43\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000010,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"]],\"content\":\"Author mismatch\",\"sig\":\"ae4be83e55311c831de6e47fb070b42bd92632010eca8f045519fddccdb029ebfbe8e7a4d58885522d1a4fc18cb43c6e3f5db8ecb6a11ec5f536e3a3a74c5c61\"}" }, "expected": { "style": "marked", @@ -963,18 +963,18 @@ "direct": true, "root": { "tag_index": 0, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], "event_id": "1111111111111111111111111111111111111111111111111111111111111111", "relay": null, - "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author_hint": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" }, "parent": null, "citations": [], "participants": [ { "tag_index": 1, - "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], - "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "raw_tag": ["p", "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"], + "pubkey": "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c", "relay": null } ], @@ -982,7 +982,7 @@ { "code": "reply_author_mismatch_ignored", "tag_index": 0, - "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"] } ] } @@ -991,7 +991,7 @@ "id": "project_signed_nip10_blank_content", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"bb57163b91910e28747d2ea50c89a84fc4d72ce0509d87e97492518f71081d39\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000013,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"\",\"sig\":\"b1833129fc7568362e5c308a3d4b44b09c5d7b7c474b1335cb9a332e9167689678c009547f312b5bc79442bd018ed8ed2af6c64eafd3112bb96e154d6b7b4b48\"}" + "event_json": "{\"id\":\"399f0d0258c1546392643f1fab84fa4ee5fc6cfaf28608f6dff48c027c1f1ec4\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000013,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"\",\"sig\":\"d0409197aa610b8febfea4fb527355b8f43021298c272cd1978a5f9e3edd75d2f08a6cc96fa32d2a3b70026a0403b285813231558cf8cddaffd0a1a3bf8426c7\"}" }, "expected": { "style": "marked", @@ -1009,8 +1009,8 @@ "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -1021,7 +1021,7 @@ "id": "project_signed_nip10_invalid_author_hint_tolerated", "kind": "social.reply.project_verified_event.valid", "input": { - "event_json": "{\"id\":\"64a50435d0e23c12ae6e52b0eddcd0e9a8efb07f0c59e2b41f839a30b9593d56\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000014,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\",\"not-a-pubkey\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Malformed optional author\",\"sig\":\"3a554dd47779861e5c020bdcad2103b4831eaf7930b8a801bb99d7c0bcd898ef1a4a79aa8a3856533ab1b88bed0b2a8c44afafa6118daf8b172d502e22eeb95f\"}" + "event_json": "{\"id\":\"d4d58ea56554e78570dc2d75507b1211879002d69b902787b02e93b864315c09\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000014,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\",\"not-a-pubkey\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Malformed optional author\",\"sig\":\"072e5c49445db582f80620d45e07836bd76860de7b347c48259bc94d718b04b89c02cce87dee6e32b27137a218b7fe0a387856e81b6268bf8bcb3fe0e5ab2f9f\"}" }, "expected": { "style": "marked", @@ -1039,8 +1039,8 @@ "participants": [ { "tag_index": 1, - "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw_tag": ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "relay": null } ], @@ -1086,7 +1086,7 @@ "id": "project_signed_nip10_lone_reply_marker", "kind": "social.reply.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"eb5f2eea6aa74ffcbd758cb5fb72749babcad7343717cb30caac19200154f03a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000011,\"kind\":1,\"tags\":[[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"\",\"reply\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Lone reply\",\"sig\":\"b4f2d2a564f248fc7012e427a1fea9267de728342485c3f8a4f19066cd15e4295b14a51f7aab898d38e61a5c86d134a80863e6ae1394081b4d9e368dee65e94b\"}" + "event_json": "{\"id\":\"e5822b15847a9952e156f9c46b3e1a3673da63e6ed72035a9088722c3bbfa3df\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000011,\"kind\":1,\"tags\":[[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"\",\"reply\"],[\"p\",\"1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c\"]],\"content\":\"Lone reply\",\"sig\":\"0b4f90ad15407fd37119ae1e6c34e9c00c200790735fe457c19f53a45576a5cd444fd1506bd0e4d3b5ad50c1905e4022cc901ae74de42d78e5f68d018ac6f977\"}" }, "expected": { "error": "reply_marker_count" @@ -1096,7 +1096,7 @@ "id": "project_signed_nip10_unknown_marker", "kind": "social.reply.project_verified_event.invalid", "input": { - "event_json": "{\"id\":\"266e800a1a46fefad41fd745d3750eac53e5527339be4aa0b28f0fb8ebe34bea\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000012,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"mention\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Unknown marker\",\"sig\":\"fd06119aad71aef0851c900fed068540ca6dde40ccfb8fc70c74cf8d10f0fdd13aac49863753fa8f1fd679c1890ba410259a291780682cc3f8fcf5b89e381afe\"}" + "event_json": "{\"id\":\"69f5cd728b45ee3688d949c318c4b46a1a7320d8f700b289339e537235007968\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000012,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"mention\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Unknown marker\",\"sig\":\"52a5d203bd422ba9c2962093d09bdf03d8fd02eaa5ab4eb518888e1fcf9db7a13e6d305e356baa1a6edf36f9752cc1830c9d7f9493d705fad6655e5d580571f5\"}" }, "expected": { "error": "reply_marker_missing" @@ -1106,7 +1106,7 @@ "id": "admit_signed_nip10_marked_direct", "kind": "social.reply.verify_and_admit_event.valid", "input": { - "event_json": "{\"id\":\"2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Direct reply\",\"sig\":\"16afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd\"}" + "event_json": "{\"id\":\"d62b9340ac9808fc84a751788d017344ede91aea1c89081be3f7bff4a2266354\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Direct reply\",\"sig\":\"206758bee31328a53457e3190b046a4a184941223f4e8ed1696db28d71c9af03138fe9add32d5e97d6f3b50bd0f78e4d5a91629dbf4d12aedd4315ce96c97e8c\"}" }, "expected": { "contract_id": "radroots.social.reply.v1", @@ -1118,7 +1118,7 @@ "id": "admit_signed_nip10_positional_direct", "kind": "social.reply.verify_and_admit_event.valid", "input": { - "event_json": "{\"id\":\"802da76963580d5932a400a2e56e73caccb80c78d43845c62f5fd95feee600e8\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000003,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Legacy direct reply\",\"sig\":\"9a1a958170b2faf3c467bf174e776f7f61bf886644657cc12d92d6a9782db89f273508e1d940412645bbb73a6ce7948b7d20daa77647aa5fd8f9e164159826e7\"}" + "event_json": "{\"id\":\"a7141da919755d6ace9b5fa400928d512b4ef785c0ec03d55989bc2d6cbce860\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000003,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Legacy direct reply\",\"sig\":\"eb2112223ed35f4b0919578a4981e08b403779391f6f9a831ea51763779028d85f72ce8224b1ae469ad6baea21d1d275210bb438a126b4391dab0c347051917a\"}" }, "expected": { "contract_id": "radroots.social.reply.v1", @@ -1130,7 +1130,7 @@ "id": "admit_signed_nip10_invalid_signature", "kind": "social.reply.verify_and_admit_event.invalid", "input": { - "event_json": "{\"id\":\"2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Direct reply\",\"sig\":\"06afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd\"}" + "event_json": "{\"id\":\"d62b9340ac9808fc84a751788d017344ede91aea1c89081be3f7bff4a2266354\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Direct reply\",\"sig\":\"006758bee31328a53457e3190b046a4a184941223f4e8ed1696db28d71c9af03138fe9add32d5e97d6f3b50bd0f78e4d5a91629dbf4d12aedd4315ce96c97e8c\"}" }, "expected": { "error": "signature_invalid" diff --git a/crates/event_codec/tests/fixtures/profile_verified_event.v1.json b/crates/event_codec/tests/fixtures/profile_verified_event.v1.json @@ -4,12 +4,12 @@ "vectors": [ { "expected": { - "event_id": "a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958", + "event_id": "407b62d3d2c91737c4cba8352ec844b2c5cdd2c9dd441edd93a3c70cdbf8d7e7", "kind": 65535 }, "id": "event_verify_max_kind_001", "input": { - "event_json": "{\"id\":\"a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000104,\"kind\":65535,\"tags\":[],\"content\":\"maximum-kind\",\"sig\":\"d79b19843a0bfd769c02c73866d44a3a06f7b11e107a5257971b60e700aa25565802fd3a7eed4042fe8db7d709a465e5f61478eb8291178831bf48f6b0980671\"}" + "event_json": "{\"id\":\"407b62d3d2c91737c4cba8352ec844b2c5cdd2c9dd441edd93a3c70cdbf8d7e7\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000104,\"kind\":65535,\"tags\":[],\"content\":\"maximum-kind\",\"sig\":\"d1791afa8b3428151e6d6c8425842bcc68ebbce202bb6abb24976ad514794f8df5b4602cf69eee26fb20b7afcb249fb7a0eac5c953839838bccdb03bd1212ab6\"}" }, "kind": "event.verify_nip01.valid" }, @@ -19,7 +19,7 @@ }, "id": "event_verify_id_mismatch_001", "input": { - "event_json": "{\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"created_at\":1800000100,\"id\":\"fdcebf1cbdcddea8027b5e214cb7b223fa662d8b85f5d68acf2e8849be0d71c4\",\"kind\":0,\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"sig\":\"e5448d11671bcf73aa8d56941aff9df46d4e9fb250596671950e5f3c9d747440523efd33d8b9ff4f2a2ef1bd4b79e0a7cf5850132172b1db4b642ef2b348f721\",\"tags\":[]}" + "event_json": "{\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"created_at\":1800000100,\"id\":\"062bee187e9e645b81ec26ade05a69b5e8398caf527be8de0d9a45311ed0c7a0\",\"kind\":0,\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"sig\":\"4290da0bb6422986647bc8cd5f63bd52d49f41e7b665d3b47105b8109183e8d596f322c531d4061df53e1d2b70fda12d5d1c14f3720d7a56d9d0a03746af5109\",\"tags\":[]}" }, "kind": "event.verify_nip01.invalid_id" }, @@ -29,19 +29,19 @@ }, "id": "event_verify_signature_invalid_001", "input": { - "event_json": "{\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"created_at\":1800000100,\"id\":\"b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f\",\"kind\":0,\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"sig\":\"d015e365c530c44fbc33e970af13616e482fafe65d0947edcdaa1e7b3038e9285b4b6826bdf454e8381d66e9dc6978d9974358572d97850ebc726fcc539e2b88\",\"tags\":[]}" + "event_json": "{\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"created_at\":1800000100,\"id\":\"762bee187e9e645b81ec26ade05a69b5e8398caf527be8de0d9a45311ed0c7a0\",\"kind\":0,\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"sig\":\"0290da0bb6422986647bc8cd5f63bd52d49f41e7b665d3b47105b8109183e8d596f322c531d4061df53e1d2b70fda12d5d1c14f3720d7a56d9d0a03746af5109\",\"tags\":[]}" }, "kind": "event.verify_nip01.invalid_signature" }, { "expected": { - "error": "malformed_envelope" + "error": "invalid_public_key" }, "id": "event_verify_malformed_curve_pubkey_001", "input": { "event_json": "{\"id\":\"8d71bc1e4126e8b16c7e9229487d9028df9508c156d5e501d1487eda799af161\",\"pubkey\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"created_at\":1800000107,\"kind\":0,\"tags\":[],\"content\":\"malformed-curve-pubkey\",\"sig\":\"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\"}" }, - "kind": "event.verify_nip01.malformed_envelope" + "kind": "event.wire.invalid_public_key" }, { "expected": { @@ -50,13 +50,13 @@ }, "id": "event_verify_kind_overflow_001", "input": { - "event_json": "{\"content\":\"overflow-kind\",\"created_at\":1800000106,\"id\":\"82dff74958bd7e1e52ad98add08bbb70d4ce9cf9d90e4741c64c212df0804ccb\",\"kind\":65536,\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"sig\":\"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"tags\":[]}" + "event_json": "{\"content\":\"overflow-kind\",\"created_at\":1800000106,\"id\":\"ecb111a334b5d374db240f42c32fd57ce61f42d4bbe1c1d88b62ed72755f906c\",\"kind\":65536,\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"sig\":\"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"tags\":[]}" }, "kind": "event.verify_nip01.kind_overflow" }, { "expected": { - "event_id": "b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f", + "event_id": "762bee187e9e645b81ec26ade05a69b5e8398caf527be8de0d9a45311ed0c7a0", "projected": { "bot": false, "display_name": "Moss Street Farm" @@ -69,7 +69,7 @@ }, "id": "profile_admit_tagless_tolerant_001", "input": { - "event_json": "{\"id\":\"b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000100,\"kind\":0,\"tags\":[],\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"sig\":\"e5448d11671bcf73aa8d56941aff9df46d4e9fb250596671950e5f3c9d747440523efd33d8b9ff4f2a2ef1bd4b79e0a7cf5850132172b1db4b642ef2b348f721\"}" + "event_json": "{\"id\":\"762bee187e9e645b81ec26ade05a69b5e8398caf527be8de0d9a45311ed0c7a0\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000100,\"kind\":0,\"tags\":[],\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"sig\":\"4290da0bb6422986647bc8cd5f63bd52d49f41e7b665d3b47105b8109183e8d596f322c531d4061df53e1d2b70fda12d5d1c14f3720d7a56d9d0a03746af5109\"}" }, "kind": "profile.verify_and_admit.valid" }, @@ -80,7 +80,7 @@ }, "id": "profile_admit_wrong_kind_001", "input": { - "event_json": "{\"id\":\"05b2ebdf444f09330198f8c07ea3e3c7d8ac97d94c41e957b74bce921f611080\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000102,\"kind\":1,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"not-a-profile\\\"}\",\"sig\":\"3d12c2dfea5cff7e9993166d20b9092f4a6a9dd7e9f4af26355d2f4c4fa0e1ba7a0e5fe6e4b90c93ae57d52cc71b38b409051702200868dfd601dbdd74b89710\"}" + "event_json": "{\"id\":\"530131615015d1631d89c80e5cbde5487ce44de24d1164e7d9d8be86f47f1399\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000102,\"kind\":1,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"not-a-profile\\\"}\",\"sig\":\"1e62c75d7cd1427ce03728ebc27413c284a9c72a0c1049ea6e537ccf7f1a3ed139718468c3f0cf78dc2da7d22ccf35843463272d8f42f59bccd69ffebb8ef3bf\"}" }, "kind": "profile.verify_and_admit.invalid_kind" }, @@ -90,19 +90,19 @@ }, "id": "profile_admit_non_object_001", "input": { - "event_json": "{\"id\":\"671f2d9f80a2fa2759c9514ae36fd18296cee71417fc86995525f2c659a5f781\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000103,\"kind\":0,\"tags\":[],\"content\":\"[]\",\"sig\":\"67d0420384e366407499ce4ab501cd87a02bea0d4221007b7a3ff01d9b68a0b9d372882184ab48775a8516f6a6554b506fc3e7652162a53e08a43e63daeacac7\"}" + "event_json": "{\"id\":\"6205ecbaead32e5bb64afa301446f3ba9cf47d3113b23db6978800f47e5e4183\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000103,\"kind\":0,\"tags\":[],\"content\":\"[]\",\"sig\":\"909f64b7b5393a80e7db8d0aa788d301133f966c23346e71ca9c019f8b7ac297fe7e27b5049410d60f5858c9060bc7e86fcf8b165c53da2c45f200a00b03f98f\"}" }, "kind": "profile.verify_and_admit.invalid_metadata" }, { "expected": { "created_at": 1800000105, - "event_id": "ad00fc208cc7036b95fadb7a4660bc600aa007502430250630d70d15b50695b3" + "event_id": "73956f3c723129dbe43ec2865261f6de04632b2817daede8b3cc47ab93faac3f" }, "id": "profile_equal_time_lowest_id_001", "input": { - "first_event_json": "{\"id\":\"fdcebf1cbdcddea8027b5e214cb7b223fa662d8b85f5d68acf2e8849be0d71c4\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000105,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"arrival-a\\\"}\",\"sig\":\"445a4a08bd365acbe3d68b1433cd8952f8974cb1f454c7d6790881b123c1e7a6449662f78e3044436c7cda8745ae74aa8ee382e292160a3a3f420dd3bb4f275c\"}", - "second_event_json": "{\"id\":\"ad00fc208cc7036b95fadb7a4660bc600aa007502430250630d70d15b50695b3\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000105,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"arrival-b\\\"}\",\"sig\":\"6b52cb1cbcf2639104b564144517d8479833272690514af9e9d45b170a1c88320ac3425b9339aa525e591584f97666b1c556b4709fb6ef27959775e70c8caabe\"}" + "first_event_json": "{\"id\":\"73956f3c723129dbe43ec2865261f6de04632b2817daede8b3cc47ab93faac3f\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000105,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"arrival-a\\\"}\",\"sig\":\"18c2196e1f9eba23147f138f2ee2e5b140bbfc48ec37f72f4919c28d2d03eaeb60269fe7c6d61a8d93f5d25e05a94f5f87dc1a449850b239114bb7fe76cd6190\"}", + "second_event_json": "{\"id\":\"e6e8ff39b7e4365d8fd89a8d47fb6c4e33ce364a449aeb108d5219b4f5d23658\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1800000105,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"arrival-b\\\"}\",\"sig\":\"0b87cc548732f65032187fe33e1f357fd8fe4d1d4c6ec3a2ee8e42713f6bfa73c12f2e6740374ec11b1f318d78c07730dc495abbe50ba025dfbe697041297a22\"}" }, "kind": "profile.select_equal_time_head" } diff --git a/crates/event_codec/tests/food_availability_conformance.rs b/crates/event_codec/tests/food_availability_conformance.rs @@ -4,10 +4,7 @@ use std::{borrow::Cow, collections::BTreeSet, fs, path::Path}; use nostr::secp256k1::Message; use nostr::{Event as NostrEvent, JsonUtil, Keys, SECP256K1}; -use radroots_blossom::{ - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType, - RadrootsBlossomSha256, -}; +use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; use radroots_event::food_availability::{ RadrootsFoodAvailabilityDetails, RadrootsFoodAvailabilityDetailsParts, RadrootsFoodAvailabilityError, RadrootsFoodAvailabilityImage, RadrootsFoodAvailabilityStatus, @@ -392,11 +389,11 @@ fn strict_details( fn strict_image(input: &ImageInput, vector_id: &str) -> RadrootsFoodAvailabilityImage { let bytes = input.bytes_utf8.as_bytes(); - let hash = RadrootsBlossomSha256::digest(bytes); - let media_type = RadrootsBlossomMediaType::parse(&input.media_type) + let hash = Sha256::digest(bytes); + let media_type = MediaType::parse(&input.media_type) .unwrap_or_else(|error| panic!("{vector_id} image media type failed: {error}")); - let verified = RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&input.url) + let verified = BlobDescriptor::new( + BlobUrl::parse(&input.url) .unwrap_or_else(|error| panic!("{vector_id} image URL failed: {error}")), hash, bytes.len() as u64, diff --git a/crates/event_codec/tests/job_traits.rs b/crates/event_codec/tests/job_traits.rs @@ -14,7 +14,7 @@ use radroots_event_codec::job::traits::{BorrowedEventAdapter, JobEventLike}; use test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, RELAY_PRIMARY_WSS}; const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; const EVENT_SIG: &str = concat!( "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" @@ -67,7 +67,7 @@ fn borrowed_event_adapter_builds_request_metadata() { let metadata = adapter.to_job_request_metadata().unwrap(); assert_eq!(metadata.id, event.id_str()); - assert_eq!(metadata.author, event.author_str()); + assert_eq!(metadata.author, event.author().to_hex()); assert_eq!(metadata.published_at, event.created_at_u64()); assert_eq!(metadata.kind, event.kind_u32()); assert_eq!(metadata.data, req); @@ -134,7 +134,7 @@ fn borrowed_event_adapter_builds_request_metadata_and_index() { let index = adapter.to_job_request_event_index().unwrap(); assert_eq!(index.event.id_str(), event.id_str()); - assert_eq!(index.event.author_str(), event.author_str()); + assert_eq!(index.event.author().to_hex(), event.author().to_hex()); assert_eq!(index.event.created_at_u64(), event.created_at_u64()); assert_eq!(index.event.kind_u32(), event.kind_u32()); assert_eq!(index.event.content(), event.content()); @@ -151,7 +151,7 @@ fn borrowed_event_adapter_builds_result_metadata_and_index() { let adapter = BorrowedEventAdapter::new(&event, event.created_at_u64(), &tags, event.sig_str()); let metadata = adapter.to_job_result_metadata().unwrap(); assert_eq!(metadata.id, event.id_str()); - assert_eq!(metadata.author, event.author_str()); + assert_eq!(metadata.author, event.author().to_hex()); assert_eq!(metadata.published_at, event.created_at_u64()); assert_eq!(metadata.kind, event.kind_u32()); assert_eq!(metadata.data.kind, result.kind); @@ -160,7 +160,7 @@ fn borrowed_event_adapter_builds_result_metadata_and_index() { let index = adapter.to_job_result_event_index().unwrap(); assert_eq!(index.event.id_str(), event.id_str()); - assert_eq!(index.event.author_str(), event.author_str()); + assert_eq!(index.event.author().to_hex(), event.author().to_hex()); assert_eq!(index.event.created_at_u64(), event.created_at_u64()); assert_eq!(index.event.kind_u32(), event.kind_u32()); assert_eq!(index.event.content(), event.content()); @@ -177,7 +177,7 @@ fn borrowed_event_adapter_builds_feedback_metadata_and_index() { let adapter = BorrowedEventAdapter::new(&event, event.created_at_u64(), &tags, event.sig_str()); let metadata = adapter.to_job_feedback_metadata().unwrap(); assert_eq!(metadata.id, event.id_str()); - assert_eq!(metadata.author, event.author_str()); + assert_eq!(metadata.author, event.author().to_hex()); assert_eq!(metadata.published_at, event.created_at_u64()); assert_eq!(metadata.kind, event.kind_u32()); assert_eq!(metadata.data.kind, feedback.kind); @@ -186,7 +186,7 @@ fn borrowed_event_adapter_builds_feedback_metadata_and_index() { let index = adapter.to_job_feedback_event_index().unwrap(); assert_eq!(index.event.id_str(), event.id_str()); - assert_eq!(index.event.author_str(), event.author_str()); + assert_eq!(index.event.author().to_hex(), event.author().to_hex()); assert_eq!(index.event.created_at_u64(), event.created_at_u64()); assert_eq!(index.event.kind_u32(), event.kind_u32()); assert_eq!(index.event.content(), event.content()); diff --git a/crates/event_codec/tests/knowledge.rs b/crates/event_codec/tests/knowledge.rs @@ -93,7 +93,7 @@ fn event_with_parts( ) -> RadrootsEventEnvelope { RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: event.id_str().to_string(), - author: event.author_str().to_string(), + author: event.author().to_hex().to_string(), created_at: event.created_at_u64(), kind: event.kind_u32(), tags, diff --git a/crates/event_codec/tests/knowledge_fixtures.rs b/crates/event_codec/tests/knowledge_fixtures.rs @@ -90,7 +90,7 @@ fn event_with_parts( ) -> RadrootsEventEnvelope { RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: event.id_str().to_string(), - author: event.author_str().to_string(), + author: event.author().to_hex().to_string(), created_at: event.created_at_u64(), kind: event.kind_u32(), tags, diff --git a/crates/event_codec/tests/message.rs b/crates/event_codec/tests/message.rs @@ -273,7 +273,7 @@ fn message_metadata_and_index_from_event_roundtrip() { let index = parsed_from_event( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + test_fixtures::FIXTURE_ALICE_PUBLIC_KEY_HEX.to_string(), 77, KIND_MESSAGE, "hello".to_string(), diff --git a/crates/event_codec/tests/operational_listing.rs b/crates/event_codec/tests/operational_listing.rs @@ -1,10 +1,7 @@ #![cfg(feature = "serde_json")] -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope, - RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, - RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, -}; +use radroots_core::pricing::{Discount, DiscountScope, DiscountThreshold, DiscountValue}; +use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeParts, farm::RadrootsFarmRef, @@ -40,7 +37,7 @@ use std::{borrow::Cow, collections::BTreeSet, fs, path::Path, str::FromStr}; use serde_json::Value; const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; const EVENT_SIG: &str = concat!( "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" @@ -59,6 +56,18 @@ fn bin_id(raw: &str) -> RadrootsInventoryBinId { raw.parse().unwrap() } +fn money(amount: Decimal, currency: Currency) -> Money { + Money::try_new(amount, currency).unwrap() +} + +fn quantity(amount: Decimal, unit: Unit) -> Quantity { + Quantity::try_new(amount, unit).unwrap() +} + +fn quantity_price(amount: Money, quantity: Quantity) -> QuantityPrice { + QuantityPrice::try_new(amount, quantity).unwrap() +} + fn sample_operational_listing_tags() -> Vec<Vec<String>> { operational_listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap() } @@ -103,12 +112,8 @@ fn assert_invalid_tag(tags: Vec<Vec<String>>, expected: &'static str) { } fn sample_listing(d_tag: &str) -> RadrootsOperationalListing { - let quantity = - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); - let price = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), - quantity.clone(), - ); + let quantity = quantity(Decimal::from(1u32), Unit::Each); + let price = quantity_price(money(Decimal::from(10u32), Currency::USD), quantity.clone()); RadrootsOperationalListing { d_tag: listing_d_tag(d_tag), @@ -222,8 +227,8 @@ fn operational_listing_from_event_parts_does_not_allow_json_content_to_override_ let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); listing.product.title = "Content override".to_string(); listing.product.category = "Content category".to_string(); - listing.inventory_available = Some(RadrootsCoreDecimal::from(99u32)); - listing.bins[0].quantity.amount = RadrootsCoreDecimal::from(42u32); + listing.inventory_available = Some(Decimal::from(99u32)); + listing.bins[0].quantity = quantity(Decimal::from(42u32), Unit::Each); let decoded = operational_listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()) @@ -232,10 +237,7 @@ fn operational_listing_from_event_parts_does_not_allow_json_content_to_override_ assert_eq!(decoded.product.title, "Widget"); assert_eq!(decoded.product.category, "Tools"); assert_eq!(decoded.inventory_available, None); - assert_eq!( - decoded.bins[0].quantity.amount, - RadrootsCoreDecimal::from(1u32) - ); + assert_eq!(decoded.bins[0].quantity.amount(), Decimal::from(1u32)); } #[test] @@ -306,22 +308,30 @@ fn execute_listing_parse_vector(vector: &Value, id: &str) { .find(|bin| bin.bin_id == listing.primary_bin_id) .expect("primary listing bin"); assert_eq!( - primary.quantity.amount.to_string(), + primary.quantity.amount().to_string(), expected["quantity_amount"], "{id}" ); assert_eq!( - primary.quantity.unit.code(), + primary.quantity.unit().code(), expected["quantity_unit"], "{id}" ); assert_eq!( - primary.price_per_canonical_unit.amount.amount.to_string(), + primary + .price_per_canonical_unit + .amount() + .amount() + .to_string(), expected["price_amount"], "{id}" ); assert_eq!( - primary.price_per_canonical_unit.amount.currency.as_str(), + primary + .price_per_canonical_unit + .amount() + .currency() + .as_str(), expected["price_currency"], "{id}" ); @@ -351,10 +361,10 @@ fn execute_listing_parse_vector(vector: &Value, id: &str) { } fn sample_listing_full(d_tag: &str) -> RadrootsOperationalListing { - let qty_amount = RadrootsCoreDecimal::from_str("1000").unwrap(); - let price_amount = RadrootsCoreDecimal::from_str("0.01").unwrap(); - let display_qty = RadrootsCoreDecimal::from_str("1").unwrap(); - let display_price = RadrootsCoreDecimal::from_str("10").unwrap(); + let qty_amount = Decimal::from_str("1000").unwrap(); + let price_amount = Decimal::from_str("0.01").unwrap(); + let display_qty = Decimal::from_str("1").unwrap(); + let display_price = Decimal::from_str("10").unwrap(); RadrootsOperationalListing { d_tag: listing_d_tag(d_tag), @@ -377,33 +387,30 @@ fn sample_listing_full(d_tag: &str) -> RadrootsOperationalListing { primary_bin_id: bin_id("bin-1"), bins: vec![RadrootsOperationalListingBin { bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new(qty_amount, RadrootsCoreUnit::MassG), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(price_amount, RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG), + quantity: quantity(qty_amount, Unit::MassG), + price_per_canonical_unit: quantity_price( + money(price_amount, Currency::USD), + quantity(Decimal::from(1u32), Unit::MassG), ), display_amount: Some(display_qty), - display_unit: Some(RadrootsCoreUnit::MassKg), + display_unit: Some(Unit::MassKg), display_label: Some("bag".to_string()), - display_price: Some(RadrootsCoreMoney::new( - display_price, - RadrootsCoreCurrency::USD, - )), - display_price_unit: Some(RadrootsCoreUnit::MassKg), + display_price: Some(money(display_price, Currency::USD)), + display_price_unit: Some(Unit::MassKg), }], resource_area: None, plot: None, - discounts: Some(vec![RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::Bin, - threshold: RadrootsCoreDiscountThreshold::BinCount { - bin_id: "bin-1".to_string(), - min: 5, - }, - value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new( - RadrootsCoreDecimal::from_str("2").unwrap(), - RadrootsCoreCurrency::USD, - )), - }]), + discounts: Some(vec![ + Discount::try_new( + DiscountScope::Bin, + DiscountThreshold::BinCount { + bin_id: "bin-1".to_string(), + min: 5, + }, + DiscountValue::MoneyPerBin(money(Decimal::from_str("2").unwrap(), Currency::USD)), + ) + .unwrap(), + ]), inventory_available: None, availability: None, delivery_method: None, @@ -735,11 +742,8 @@ fn operational_listing_from_event_covers_bin_and_price_error_paths() { ); let decoded = operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); - assert_eq!( - decoded.bins[0].display_amount, - Some(RadrootsCoreDecimal::from(1u32)) - ); - assert_eq!(decoded.bins[0].display_unit, Some(RadrootsCoreUnit::Each)); + assert_eq!(decoded.bins[0].display_amount, Some(Decimal::from(1u32))); + assert_eq!(decoded.bins[0].display_unit, Some(Unit::Each)); assert_eq!(decoded.bins[0].display_label, None); let mut tags = sample_operational_listing_tags(); @@ -1144,7 +1148,7 @@ fn listing_parsed_wrappers_preserve_event_metadata() { ) .unwrap(); assert_eq!(parsed.event.id_str(), EVENT_ID); - assert_eq!(parsed.event.author_str(), AUTHOR); + assert_eq!(parsed.event.author().to_hex(), AUTHOR); assert_eq!(parsed.event.created_at_u64(), 7); assert_eq!(parsed.event.sig_str(), EVENT_SIG); assert_eq!(parsed.data.data.d_tag, listing.d_tag); @@ -1325,25 +1329,16 @@ fn operational_listing_tags_full_uses_single_generic_price_for_primary_bin() { let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAw"); listing.bins.push(RadrootsOperationalListingBin { bin_id: bin_id("bin-2"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from_str("500").unwrap(), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new( - RadrootsCoreDecimal::from_str("0.02").unwrap(), - RadrootsCoreCurrency::USD, - ), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG), + quantity: quantity(Decimal::from_str("500").unwrap(), Unit::MassG), + price_per_canonical_unit: quantity_price( + money(Decimal::from_str("0.02").unwrap(), Currency::USD), + quantity(Decimal::from(1u32), Unit::MassG), ), - display_amount: Some(RadrootsCoreDecimal::from(500u32)), - display_unit: Some(RadrootsCoreUnit::MassG), + display_amount: Some(Decimal::from(500u32)), + display_unit: Some(Unit::MassG), display_label: Some("sample".to_string()), - display_price: Some(RadrootsCoreMoney::new( - RadrootsCoreDecimal::from_str("10").unwrap(), - RadrootsCoreCurrency::USD, - )), - display_price_unit: Some(RadrootsCoreUnit::MassG), + display_price: Some(money(Decimal::from_str("10").unwrap(), Currency::USD)), + display_price_unit: Some(Unit::MassG), }); let tags = operational_listing_tags_full(&listing).unwrap(); @@ -1365,7 +1360,7 @@ fn operational_listing_tags_full_uses_single_generic_price_for_primary_bin() { #[test] fn operational_listing_tags_full_includes_trade_fields() { let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); - let inventory = RadrootsCoreDecimal::from_str("12.5").unwrap(); + let inventory = Decimal::from_str("12.5").unwrap(); let inventory_value = inventory.to_string(); listing.inventory_available = Some(inventory); listing.availability = Some(RadrootsOperationalListingAvailability::Window { diff --git a/crates/event_codec/tests/post.rs b/crates/event_codec/tests/post.rs @@ -1,7 +1,4 @@ -use radroots_blossom::{ - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomByteVerifiedDescriptor, - RadrootsBlossomMediaType, RadrootsBlossomSha256, -}; +use radroots_blossom::{BlobDescriptor, BlobUrl, ByteVerifiedDescriptor, MediaType, Sha256}; use radroots_event::{ RadrootsAuthoredImage, post::{ @@ -425,16 +422,11 @@ fn authored_image(bytes: &[u8], media_type: &str, extension: &str) -> RadrootsAu .unwrap() } -fn verified_descriptor( - bytes: &[u8], - media_type: &str, - extension: &str, -) -> RadrootsBlossomByteVerifiedDescriptor { - let hash = RadrootsBlossomSha256::digest(bytes); - let media_type = RadrootsBlossomMediaType::parse(media_type).unwrap(); - RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&format!("https://media.example/{hash}.{extension}")) - .unwrap(), +fn verified_descriptor(bytes: &[u8], media_type: &str, extension: &str) -> ByteVerifiedDescriptor { + let hash = Sha256::digest(bytes); + let media_type = MediaType::parse(media_type).unwrap(); + BlobDescriptor::new( + BlobUrl::parse(&format!("https://media.example/{hash}.{extension}")).unwrap(), hash, bytes.len() as u64, media_type.clone(), @@ -450,16 +442,16 @@ fn verified_descriptor( fn verified_descriptor_with_url_element_bytes( bytes: &[u8], element_bytes: usize, -) -> RadrootsBlossomByteVerifiedDescriptor { - let hash = RadrootsBlossomSha256::digest(bytes); +) -> ByteVerifiedDescriptor { + let hash = Sha256::digest(bytes); let prefix = format!("https://media.example/{hash}."); let extension_bytes = element_bytes .checked_sub("url ".len() + prefix.len()) .expect("requested URL tag element is large enough"); assert!(extension_bytes > 0); - let media_type = RadrootsBlossomMediaType::parse("image/webp").unwrap(); - let descriptor = RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&format!("{prefix}{}", "x".repeat(extension_bytes))).unwrap(), + let media_type = MediaType::parse("image/webp").unwrap(); + let descriptor = BlobDescriptor::new( + BlobUrl::parse(&format!("{prefix}{}", "x".repeat(extension_bytes))).unwrap(), hash, bytes.len() as u64, media_type.clone(), @@ -481,9 +473,9 @@ fn fallback_url( bytes: &[u8], host: &str, extension: &str, -) -> radroots_blossom::RadrootsBlossomApprovedBlobUrl { - let hash = RadrootsBlossomSha256::digest(bytes); - RadrootsBlossomBlobUrl::parse(&format!("https://{host}/{hash}.{extension}")) +) -> radroots_blossom::url::ApprovedBlobUrl { + let hash = Sha256::digest(bytes); + BlobUrl::parse(&format!("https://{host}/{hash}.{extension}")) .unwrap() .approve() .unwrap() @@ -492,13 +484,13 @@ fn fallback_url( fn fallback_url_with_element_bytes( bytes: &[u8], element_bytes: usize, -) -> radroots_blossom::RadrootsBlossomApprovedBlobUrl { +) -> radroots_blossom::url::ApprovedBlobUrl { let prefix = fallback_element_prefix(bytes); let extension_bytes = element_bytes .checked_sub(prefix.len()) .expect("requested fallback tag element is large enough"); assert!(extension_bytes > 0); - let fallback = RadrootsBlossomBlobUrl::parse(&format!( + let fallback = BlobUrl::parse(&format!( "{}{}", prefix.strip_prefix("fallback ").unwrap(), "x".repeat(extension_bytes) @@ -511,7 +503,7 @@ fn fallback_url_with_element_bytes( } fn fallback_element_prefix(bytes: &[u8]) -> String { - let hash = RadrootsBlossomSha256::digest(bytes); + let hash = Sha256::digest(bytes); format!("fallback https://cache.example/{hash}.") } diff --git a/crates/event_codec/tests/profile.rs b/crates/event_codec/tests/profile.rs @@ -13,6 +13,8 @@ use radroots_event_codec::profile::decode::{ data_from_event, parsed_from_event, profile_from_content, }; +const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + #[test] fn profile_from_content_parses_bot_boolean() { let content = r#"{"name":"alice","bot":true}"#; @@ -184,7 +186,7 @@ fn profile_metadata_ignores_short_unknown_and_unrelated_profile_type_tags() { fn profile_parsed_event_preserves_wire_event_and_decoded_data() { let parsed = parsed_from_event( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + AUTHOR.to_string(), 42, KIND_PROFILE, "{\"name\":\"alice\"}".to_string(), @@ -204,10 +206,7 @@ fn profile_parsed_event_preserves_wire_event_and_decoded_data() { parsed.event.id_str(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ); - assert_eq!( - parsed.event.author_str(), - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - ); + assert_eq!(parsed.event.author().to_hex(), AUTHOR); assert_eq!(parsed.event.created_at_u64(), 42); assert_eq!(parsed.event.kind_u32(), KIND_PROFILE); assert_eq!(parsed.event.content(), "{\"name\":\"alice\"}"); diff --git a/crates/event_codec/tests/profile_conformance.rs b/crates/event_codec/tests/profile_conformance.rs @@ -2,7 +2,7 @@ use std::{borrow::Cow, fs, path::Path}; -use radroots_blossom::{RadrootsBlossomBlobDescriptor, RadrootsBlossomByteVerifiedDescriptor}; +use radroots_blossom::{BlobDescriptor, ByteVerifiedDescriptor}; use radroots_event::{ media::{RadrootsAuthoredImage, RadrootsAuthoredImageError}, profile::{ @@ -52,7 +52,7 @@ struct AuthoredProfileInput { #[serde(deny_unknown_fields)] struct AuthoredMediaInput { bytes_utf8: String, - descriptor: RadrootsBlossomBlobDescriptor, + descriptor: BlobDescriptor, } #[test] @@ -266,10 +266,7 @@ fn authored_image(input: &AuthoredMediaInput, vector_id: &str) -> RadrootsAuthor .unwrap_or_else(|error| panic!("{vector_id} Profile image failed: {error}")) } -fn verified_descriptor( - input: &AuthoredMediaInput, - vector_id: &str, -) -> RadrootsBlossomByteVerifiedDescriptor { +fn verified_descriptor(input: &AuthoredMediaInput, vector_id: &str) -> ByteVerifiedDescriptor { let media_type = input.descriptor.media_type().clone(); input .descriptor diff --git a/crates/event_codec/tests/publication_artifact.rs b/crates/event_codec/tests/publication_artifact.rs @@ -1,9 +1,6 @@ #![cfg(feature = "serde_json")] -use radroots_blossom::{ - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomByteVerifiedDescriptor, - RadrootsBlossomMediaType, RadrootsBlossomSha256, -}; +use radroots_blossom::{BlobDescriptor, BlobUrl, ByteVerifiedDescriptor, MediaType, Sha256}; use radroots_event::{ RadrootsAuthoredImage, calendar::{ @@ -136,7 +133,7 @@ fn publication_artifact_conformance_vector_executes_every_case() { case.id ); assert_eq!( - RadrootsBlossomSha256::digest(&artifact.to_canonical_json()).to_hex(), + Sha256::digest(&artifact.to_canonical_json()).to_hex(), case.expected.canonical_json_sha256.unwrap(), "{}", case.id @@ -167,7 +164,7 @@ fn publication_artifact_conformance_vector_executes_every_case() { case.id ); assert_eq!( - RadrootsBlossomSha256::digest(&bytes).to_hex(), + Sha256::digest(&bytes).to_hex(), case.expected.canonical_json_sha256.unwrap(), "{}", case.id @@ -185,7 +182,7 @@ fn publication_artifact_conformance_vector_executes_every_case() { case.id ); assert_eq!( - RadrootsBlossomSha256::digest(&reloaded.to_canonical_json()).to_hex(), + Sha256::digest(&reloaded.to_canonical_json()).to_hex(), case.expected.canonical_json_sha256.unwrap(), "{}", case.id @@ -284,7 +281,7 @@ fn publication_artifact_round_trips_every_closed_variant() { assert_eq!(artifact.semantic_variant(), variant); assert_eq!(artifact.authored_operation_id(), operation); assert_eq!(artifact.event_contract_id(), contract); - assert_eq!(artifact.expected_author().as_str(), AUTHOR); + assert_eq!(artifact.expected_author().to_hex(), AUTHOR); assert_eq!(artifact.draft().kind(), kind); assert_eq!(artifact.media_references().len(), media_count); let canonical_json = artifact.to_canonical_json(); @@ -573,7 +570,7 @@ fn publication_artifact_reload_accepts_extensionless_post_fallback() { "image/webp", ); let hash = image.descriptor().sha256(); - let fallback = RadrootsBlossomBlobUrl::parse(&format!("https://backup.example/{hash}")) + let fallback = BlobUrl::parse(&format!("https://backup.example/{hash}")) .unwrap() .approve() .unwrap(); @@ -864,7 +861,7 @@ fn all_artifacts() -> Vec<RadrootsPhase1PublicationArtifact> { fn authored_post_image(bytes: &[u8]) -> RadrootsAuthoredPostImage { let image = authored_image(bytes, "media.example", "webp", "image/webp"); let hash = image.descriptor().sha256(); - let fallback = RadrootsBlossomBlobUrl::parse(&format!("https://backup.example/{hash}.webp")) + let fallback = BlobUrl::parse(&format!("https://backup.example/{hash}.webp")) .unwrap() .approve() .unwrap(); @@ -918,11 +915,11 @@ fn verified_descriptor( host: &str, extension: &str, media_type: &str, -) -> RadrootsBlossomByteVerifiedDescriptor { - let sha256 = RadrootsBlossomSha256::digest(bytes); - let media_type = RadrootsBlossomMediaType::parse(media_type).unwrap(); - RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&format!("https://{host}/{sha256}.{extension}")).unwrap(), +) -> ByteVerifiedDescriptor { + let sha256 = Sha256::digest(bytes); + let media_type = MediaType::parse(media_type).unwrap(); + BlobDescriptor::new( + BlobUrl::parse(&format!("https://{host}/{sha256}.{extension}")).unwrap(), sha256, bytes.len() as u64, media_type.clone(), diff --git a/crates/event_codec/tests/publication_media_readiness.rs b/crates/event_codec/tests/publication_media_readiness.rs @@ -1,7 +1,7 @@ #![cfg(feature = "serde_json")] use radroots_blossom::{ - RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, RadrootsBlossomPublicationReadinessEvidence, + PublicationReadinessEvidence, RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, }; use radroots_event::wire::compute_canonical_nip01_event_id; use radroots_event_codec::wire::publication::{ @@ -370,7 +370,7 @@ fn artifact_canonical_json(fixture: &str) -> Vec<u8> { fn evidence_for_fixture( fixture: &str, media: &[RadrootsPhase1PublicationMediaReference], -) -> Vec<RadrootsBlossomPublicationReadinessEvidence> { +) -> Vec<PublicationReadinessEvidence> { let dimensions = expected_dimensions(fixture); assert_eq!(dimensions.len(), media.len()); media @@ -397,7 +397,7 @@ fn evidence_for_reference( reference: &RadrootsPhase1PublicationMediaReference, dimensions: (u32, u32), size: u64, -) -> RadrootsBlossomPublicationReadinessEvidence { +) -> PublicationReadinessEvidence { let media_type = reference.media_type().as_str(); let (raster_format, format_code) = match media_type { "image/jpeg" => ("jpeg", 1), @@ -434,10 +434,7 @@ fn evidence_for_reference( uploaded, evidence_digest, }; - RadrootsBlossomPublicationReadinessEvidence::from_canonical_json( - &serde_json::to_vec(&wire).unwrap(), - ) - .unwrap() + PublicationReadinessEvidence::from_canonical_json(&serde_json::to_vec(&wire).unwrap()).unwrap() } fn evidence_digest( diff --git a/crates/event_codec/tests/report.rs b/crates/event_codec/tests/report.rs @@ -18,7 +18,7 @@ use radroots_event_codec::{ }; const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const REPORTED: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const REPORTED: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; const FILE_HASH: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; const ARTICLE_D_TAG: &str = "DDDDDDDDDDDDDDDDDDDDDA"; diff --git a/crates/event_codec/tests/repost.rs b/crates/event_codec/tests/repost.rs @@ -23,7 +23,7 @@ use radroots_event_codec::{ }; const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; const EVENT_SIG: &str = concat!( "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" diff --git a/crates/event_codec/tests/structured_decode.rs b/crates/event_codec/tests/structured_decode.rs @@ -6,7 +6,7 @@ mod test_fixtures; mod common; use common::{EVENT_ID, EVENT_SIG}; -use radroots_core::{RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreUnit}; +use radroots_core::{Decimal, Quantity, Unit}; use radroots_event::coop::RadrootsCoop; use radroots_event::document::{RadrootsDocument, RadrootsDocumentSubject}; use radroots_event::farm::{RadrootsFarm, RadrootsFarmRef}; @@ -176,10 +176,7 @@ fn sample_resource_cap(d_tag: &str) -> RadrootsResourceHarvestCap { }, start: 100, end: 200, - cap_quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassG, - ), + cap_quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(), display_amount: None, display_unit: None, display_label: None, @@ -577,7 +574,7 @@ fn plot_metadata_and_index_decode_roundtrip() { EVENT_SIG.to_string(), ) .expect("plot index"); - assert_eq!(index.event.author_str(), TEST_PUBKEY_HEX); + assert_eq!(index.event.author().to_hex(), TEST_PUBKEY_HEX); assert_eq!(index.data.data.d_tag, d_tag); } diff --git a/crates/event_codec/tests/structured_encode_default.rs b/crates/event_codec/tests/structured_encode_default.rs @@ -3,10 +3,7 @@ #[path = "../src/test_fixtures.rs"] mod test_fixtures; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, -}; +use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; use radroots_event::coop::{RadrootsCoop, RadrootsCoopLocation, RadrootsCoopRef}; use radroots_event::document::{RadrootsDocument, RadrootsDocumentSubject}; use radroots_event::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}; @@ -110,12 +107,12 @@ fn sample_gcs() -> RadrootsGcsLocation { } fn sample_listing(d_tag: &str) -> RadrootsOperationalListing { - let quantity = - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); - let price = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), + let quantity = Quantity::try_new(Decimal::from(1u32), Unit::Each).unwrap(); + let price = QuantityPrice::try_new( + Money::try_new(Decimal::from(10u32), Currency::USD).unwrap(), quantity.clone(), - ); + ) + .unwrap(); RadrootsOperationalListing { d_tag: listing_d_tag(d_tag), published_at: None, @@ -260,10 +257,7 @@ fn sample_resource_cap() -> RadrootsResourceHarvestCap { }, start: 1, end: 2, - cap_quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassG, - ), + cap_quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(), display_amount: None, display_unit: None, display_label: None, @@ -571,10 +565,7 @@ fn structured_build_tags_cover_optional_and_error_paths() { }, start: 1, end: 2, - cap_quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassG, - ), + cap_quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(), display_amount: None, display_unit: None, display_label: None, @@ -831,10 +822,7 @@ fn structured_build_tags_cover_required_field_errors() { }, start: 1, end: 2, - cap_quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassG, - ), + cap_quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(), display_amount: None, display_unit: None, display_label: None, diff --git a/crates/event_codec/tests/tag_builders.rs b/crates/event_codec/tests/tag_builders.rs @@ -1,11 +1,8 @@ #[path = "../src/test_fixtures.rs"] mod test_fixtures; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope, - RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, - RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, -}; +use radroots_core::pricing::{Discount, DiscountScope, DiscountThreshold, DiscountValue}; +use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; use radroots_event::RadrootsEventPtr; use radroots_event::app_data::RadrootsAppData; use radroots_event::coop::RadrootsCoop; @@ -112,12 +109,12 @@ fn sample_gcs() -> RadrootsGcsLocation { } fn sample_listing() -> RadrootsOperationalListing { - let quantity = - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); - let price = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), + let quantity = Quantity::try_new(Decimal::from(1u32), Unit::Each).unwrap(); + let price = QuantityPrice::try_new( + Money::try_new(Decimal::from(10u32), Currency::USD).unwrap(), quantity.clone(), - ); + ) + .unwrap(); RadrootsOperationalListing { d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), @@ -272,10 +269,7 @@ fn event_tag_builder_impls_build_tags_for_all_supported_types() { }, start: 1, end: 2, - cap_quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassG, - ), + cap_quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(), display_amount: None, display_unit: None, display_label: None, @@ -455,7 +449,7 @@ fn listing_and_message_builders_cover_optional_shapes() { assert!(!operational_listing_build_tags(&listing).unwrap().is_empty()); let mut listing_with_trade = listing.clone(); - listing_with_trade.inventory_available = Some(RadrootsCoreDecimal::from(12u32)); + listing_with_trade.inventory_available = Some(Decimal::from(12u32)); let with_trade_fields: fn() -> OperationalListingTagOptions = OperationalListingTagOptions::with_trade_fields; let trade_options = with_trade_fields(); @@ -571,17 +565,17 @@ fn listing_and_message_builders_cover_optional_shapes() { )); let mut listing_with_discount_payload = listing_with_trade.clone(); - listing_with_discount_payload.discounts = Some(vec![RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::Bin, - threshold: RadrootsCoreDiscountThreshold::BinCount { - bin_id: "bin-1".to_string(), - min: 2, - }, - value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreCurrency::USD, - )), - }]); + listing_with_discount_payload.discounts = Some(vec![ + Discount::try_new( + DiscountScope::Bin, + DiscountThreshold::BinCount { + bin_id: "bin-1".to_string(), + min: 2, + }, + DiscountValue::MoneyPerBin(Money::try_new(Decimal::from(1u32), Currency::USD).unwrap()), + ) + .unwrap(), + ]); #[cfg(feature = "serde_json")] { let tags = diff --git a/crates/event_codec/tests/verified_comment_conformance.rs b/crates/event_codec/tests/verified_comment_conformance.rs @@ -346,7 +346,7 @@ fn authored_comment( object_optional_str(root, "relay", &vector.id), )?; assert_eq!( - root_reference.author().as_str(), + root_reference.author().to_hex(), object_str(root, "author", &vector.id), "{} declared address author drifted", vector.id @@ -449,7 +449,7 @@ fn projection_snapshot(projection: &RadrootsInboundNip22CommentProjection) -> Va "kind_raw_tag": root.kind_raw_tag(), "kind": root.kind().as_u32(), "relay": root.relay().map(|relay| relay.as_str()), - "author_hint": root.author_hint().map(|author| author.as_str()), + "author_hint": root.author_hint().map(|author| author.to_hex()), "author": participant_snapshot(root.author()), "raw_tag": root.raw_tag(), }), @@ -475,7 +475,7 @@ fn projection_snapshot(projection: &RadrootsInboundNip22CommentProjection) -> Va "kind_raw_tag": reference.kind_raw_tag(), "kind": reference.kind(), "relay": reference.relay().map(|relay| relay.as_str()), - "author_hint": reference.author_hint().map(|author| author.as_str()), + "author_hint": reference.author_hint().map(|author| author.to_hex()), "author": participant_snapshot(reference.author()), "raw_tag": reference.raw_tag(), }, @@ -511,7 +511,7 @@ fn projection_snapshot(projection: &RadrootsInboundNip22CommentProjection) -> Va "kind_raw_tag": parent.kind_raw_tag(), "kind": parent.kind(), "relay": parent.relay().map(|relay| relay.as_str()), - "author_hint": parent.author_hint().map(|author| author.as_str()), + "author_hint": parent.author_hint().map(|author| author.to_hex()), "author": participant_snapshot(parent.author()), "raw_tag": parent.raw_tag(), }, @@ -543,7 +543,7 @@ fn projection_snapshot(projection: &RadrootsInboundNip22CommentProjection) -> Va fn participant_snapshot(participant: &RadrootsInboundNip22Participant) -> Value { json!({ "tag_index": participant.tag_index(), - "pubkey": participant.pubkey().as_str(), + "pubkey": participant.pubkey().to_hex(), "relay": participant.relay().map(|relay| relay.as_str()), "raw_tag": participant.raw_tag(), }) diff --git a/crates/event_codec/tests/verified_deletion_conformance.rs b/crates/event_codec/tests/verified_deletion_conformance.rs @@ -765,7 +765,7 @@ fn projection_snapshot(projection: &RadrootsInboundNip09DeletionProjection) -> V "tag_index": target.tag_index(), "coordinate": target.coordinate().as_str(), "kind": target.coordinate().kind(), - "pubkey": target.coordinate().pubkey().as_str(), + "pubkey": target.coordinate().pubkey().to_hex(), "identifier": target.coordinate().identifier(), "raw_tag": target.raw_tag(), })).collect::<Vec<_>>(), diff --git a/crates/event_codec/tests/verified_post_conformance.rs b/crates/event_codec/tests/verified_post_conformance.rs @@ -2,10 +2,7 @@ use std::{borrow::Cow, collections::BTreeMap, fs, path::Path}; -use radroots_blossom::{ - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType, - RadrootsBlossomSha256, -}; +use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; use radroots_event::{ RadrootsAuthoredImage, RadrootsEventEnvelope, RadrootsNip01EventWire, contract::identify_event_contract, @@ -583,7 +580,7 @@ fn reply_event_reference_value(reference: &RadrootsInboundNip10EventReference) - "raw_tag": reference.raw_tag(), "event_id": reference.event_id().as_str(), "relay": reference.relay().map(|relay| relay.as_str()), - "author_hint": reference.author_hint().map(|author| author.as_str()), + "author_hint": reference.author_hint().map(|author| author.to_hex()), }) } @@ -591,7 +588,7 @@ fn reply_participant_value(participant: &RadrootsInboundNip10Participant) -> Val json!({ "tag_index": participant.tag_index(), "raw_tag": participant.raw_tag(), - "pubkey": participant.pubkey().as_str(), + "pubkey": participant.pubkey().to_hex(), "relay": participant.relay().map(|relay| relay.as_str()), }) } @@ -668,11 +665,11 @@ fn authored_image( input: &Value, ) -> Result<RadrootsAuthoredPostImage, RadrootsAuthoredPostError> { let bytes = value_str(vector, input, "bytes_utf8").as_bytes(); - let media_type = RadrootsBlossomMediaType::parse(value_str(vector, input, "media_type")) + let media_type = MediaType::parse(value_str(vector, input, "media_type")) .unwrap_or_else(|error| panic!("{} media type setup failed: {error}", vector.id)); - let hash = RadrootsBlossomSha256::digest(bytes); - let descriptor = RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(value_str(vector, input, "url")) + let hash = Sha256::digest(bytes); + let descriptor = BlobDescriptor::new( + BlobUrl::parse(value_str(vector, input, "url")) .unwrap_or_else(|error| panic!("{} URL setup failed: {error}", vector.id)), hash, u64::try_from(bytes.len()).expect("authored image byte length must fit u64"), @@ -697,7 +694,7 @@ fn authored_image( if let Some(fallbacks) = input.get("fallbacks").and_then(Value::as_array) { for fallback in fallbacks { image = image.try_with_fallback( - RadrootsBlossomBlobUrl::parse( + BlobUrl::parse( fallback .as_str() .unwrap_or_else(|| panic!("{} image fallback must be a string", vector.id)), diff --git a/crates/event_codec/tests/verified_profile_conformance.rs b/crates/event_codec/tests/verified_profile_conformance.rs @@ -7,6 +7,7 @@ use radroots_event::event_head::{ RadrootsEventHeadDecision, event_head_candidate_for_event, select_event_head, }; use radroots_event::{RadrootsEventEnvelope, RadrootsEventEnvelopeParts, RadrootsNip01EventWire}; +use radroots_event::{RadrootsEventWireError, ids::RadrootsIdParseError}; use radroots_event_codec::profile::admission::{ RadrootsAdmittedProfileEvent, RadrootsProfileAdmissionError, verify_and_admit_profile_event, }; @@ -89,7 +90,7 @@ fn execute(vector: &Vector) { "event.verify_nip01.valid" => verify_valid(vector), "event.verify_nip01.invalid_id" => verify_invalid_id(vector), "event.verify_nip01.invalid_signature" => verify_invalid_signature(vector), - "event.verify_nip01.malformed_envelope" => verify_malformed_envelope(vector), + "event.wire.invalid_public_key" => reject_invalid_wire_public_key(vector), "event.verify_nip01.kind_overflow" => verify_kind_overflow(vector), "profile.verify_and_admit.valid" => profile_admit_valid(vector), "profile.verify_and_admit.invalid_kind" => profile_admit_invalid_kind(vector), @@ -133,11 +134,17 @@ fn verify_invalid_signature(vector: &Vector) { assert_eq!(error, RadrootsNip01VerificationError::SignatureInvalid); } -fn verify_malformed_envelope(vector: &Vector) { - let error = verify_nip01_event(canonical_envelope(input_str(vector, "event_json"))) - .expect_err("invalid secp256k1 public key must fail envelope conversion"); - assert_eq!(error.code(), expected_str(vector, "error")); - assert_eq!(error, RadrootsNip01VerificationError::MalformedEnvelope); +fn reject_invalid_wire_public_key(vector: &Vector) { + let error = RadrootsNip01EventWire::parse_json(input_str(vector, "event_json")) + .expect_err("invalid secp256k1 public key must fail wire parsing"); + assert_eq!(expected_str(vector, "error"), "invalid_public_key"); + assert_eq!( + error, + RadrootsEventWireError::InvalidIdentifier { + field: "pubkey", + error: RadrootsIdParseError::InvalidPublicKey, + } + ); } fn verify_kind_overflow(vector: &Vector) { diff --git a/crates/event_codec/tests/wire.rs b/crates/event_codec/tests/wire.rs @@ -58,7 +58,7 @@ fn draft_validation_accepts_wire_parts_without_signed_envelope() { assert_eq!(draft.kind_u32(), KIND_GEOCHAT); assert_eq!(draft.created_at_u64(), 99); - assert_eq!(draft.expected_pubkey_str(), "a".repeat(64)); + assert_eq!(draft.expected_pubkey().to_hex(), "a".repeat(64)); assert_eq!(draft.content(), "hello"); assert_eq!(draft.tags().len(), 1); assert_eq!(draft.expected_event_id_str().len(), 64); diff --git a/crates/event_index/Cargo.toml b/crates/event_index/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_event_index" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/event_store/Cargo.toml b/crates/event_store/Cargo.toml @@ -29,6 +29,10 @@ radroots_event_codec = { workspace = true, default-features = false, features = "serde_json", "std", ] } +radroots_identity = { workspace = true, default-features = false, features = [ + "serde", + "std", +] } radroots_transport = { workspace = true, default-features = false } hex = { workspace = true } futures = { workspace = true, optional = true } diff --git a/crates/event_store/src/error.rs b/crates/event_store/src/error.rs @@ -136,6 +136,8 @@ pub enum RadrootsEventStoreError { Json(#[from] serde_json::Error), #[error("identifier parse error: {0}")] IdParse(#[from] RadrootsIdParseError), + #[error("identity value parse error: {0}")] + Identity(#[from] radroots_identity::Error), #[error("event wire error: {0}")] EventWire(#[from] RadrootsEventWireError), #[error("signed event error: {0}")] diff --git a/crates/event_store/src/model.rs b/crates/event_store/src/model.rs @@ -41,10 +41,11 @@ pub use raw_source_rebuild_v1::{ use crate::RadrootsEventStoreError; use radroots_event::RadrootsEventKind; use radroots_event::ids::{ - RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, RadrootsPublicKey, - RadrootsTradeCandidateId, RadrootsTradeId, RadrootsTradeMutationId, + RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, RadrootsTradeCandidateId, + RadrootsTradeId, RadrootsTradeMutationId, }; use radroots_event::trade::RadrootsTradeMutationKindV1; +use radroots_identity::PublicKey; use radroots_transport::{ RadrootsTransportKind, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetUri, @@ -514,10 +515,10 @@ pub struct RadrootsStoredTradeMutation { pub candidate_id: Option<RadrootsTradeCandidateId>, pub proposal_mutation_id: Option<RadrootsTradeMutationId>, pub target_claim_mutation_id: Option<RadrootsTradeMutationId>, - pub author_pubkey: RadrootsPublicKey, - pub counterparty_pubkey: RadrootsPublicKey, - pub buyer_pubkey: RadrootsPublicKey, - pub seller_pubkey: RadrootsPublicKey, + pub author_pubkey: PublicKey, + pub counterparty_pubkey: PublicKey, + pub buyer_pubkey: PublicKey, + pub seller_pubkey: PublicKey, pub farm_id: RadrootsDTag, pub authored_at_unix_s: u64, pub canonical_payload_bytes: Vec<u8>, @@ -549,7 +550,7 @@ pub struct RadrootsStoredTradeTransportEnvelope { pub mutation_id: RadrootsTradeMutationId, pub trade_id: RadrootsTradeId, pub transport_kind: String, - pub pubkey: RadrootsPublicKey, + pub pubkey: PublicKey, pub created_at: u64, pub event_seq: i64, pub payload_sha256: String, @@ -562,7 +563,7 @@ pub struct RadrootsStoredSellerReservation { pub trade_id: RadrootsTradeId, pub candidate_id: RadrootsTradeCandidateId, pub claim_mutation_id: RadrootsTradeMutationId, - pub inventory_authority_pubkey: RadrootsPublicKey, + pub inventory_authority_pubkey: PublicKey, pub inventory_epoch: u64, pub assertion_commitment: String, pub reservation_expires_at_unix_s: u64, @@ -611,7 +612,8 @@ mod tests { use radroots_event::event_head::{ RadrootsCurrentEventHead, RadrootsEventHeadCoordinate, RadrootsEventHeadDecision, }; - use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; + use radroots_event::ids::RadrootsEventId; + use radroots_identity::PublicKey; #[test] fn admission_status_event_class_and_observation_values_roundtrip() { @@ -910,7 +912,7 @@ mod tests { fn head_decisions_and_tag_metadata_names_cover_all_variants() { let coordinate = RadrootsEventHeadCoordinate::Addressable { kind: 30_023, - pubkey: RadrootsPublicKey::parse( + pubkey: PublicKey::from_hex( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) .expect("pubkey"), diff --git a/crates/event_store/src/model/addressable_transition_feed_v1.rs b/crates/event_store/src/model/addressable_transition_feed_v1.rs @@ -3,7 +3,8 @@ use super::{ RadrootsNip09SuppressionEvidenceV1, }; use crate::RadrootsEventStoreError; -use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; +use radroots_event::ids::RadrootsEventId; +use radroots_identity::PublicKey; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -288,7 +289,7 @@ pub struct RadrootsAddressableTransitionEventReferenceV1 { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsAddressableTransitionCauseV1 { pub(crate) event: RadrootsAddressableTransitionEventReferenceV1, - pub(crate) pubkey: RadrootsPublicKey, + pub(crate) pubkey: PublicKey, pub(crate) created_at: u64, pub(crate) kind: u32, pub(crate) admission_status: RadrootsEventAdmissionStatus, @@ -301,7 +302,7 @@ impl RadrootsAddressableTransitionCauseV1 { &self.event } - pub const fn pubkey(&self) -> &RadrootsPublicKey { + pub const fn pubkey(&self) -> &PublicKey { &self.pubkey } @@ -335,7 +336,7 @@ impl RadrootsAddressableTransitionCauseV1 { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsAddressableTransitionCoordinateV1 { pub(crate) kind: u32, - pub(crate) pubkey: RadrootsPublicKey, + pub(crate) pubkey: PublicKey, pub(crate) d_tag: String, } @@ -344,7 +345,7 @@ impl RadrootsAddressableTransitionCoordinateV1 { self.kind } - pub const fn pubkey(&self) -> &RadrootsPublicKey { + pub const fn pubkey(&self) -> &PublicKey { &self.pubkey } @@ -372,7 +373,7 @@ impl RadrootsAddressableTransitionEventReferenceV1 { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsStoreProducedCanonicalEventV1 { pub(crate) event_id: RadrootsEventId, - pub(crate) pubkey: RadrootsPublicKey, + pub(crate) pubkey: PublicKey, pub(crate) created_at: u64, pub(crate) kind: u32, pub(crate) raw_json: String, @@ -383,7 +384,7 @@ impl RadrootsStoreProducedCanonicalEventV1 { &self.event_id } - pub const fn pubkey(&self) -> &RadrootsPublicKey { + pub const fn pubkey(&self) -> &PublicKey { &self.pubkey } diff --git a/crates/event_store/src/model/food_availability_projection_v1.rs b/crates/event_store/src/model/food_availability_projection_v1.rs @@ -2,19 +2,20 @@ use super::{ RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1, RadrootsEventStoreSourceGeneration, }; use crate::RadrootsEventStoreError; -use radroots_blossom::RadrootsBlossomSha256; +use radroots_blossom::Sha256; use radroots_event::{ food_availability::{ RADROOTS_FOOD_IMAGE_MAX_COUNT, RadrootsFoodAvailabilityStatus, RadrootsFoodContent, RadrootsFoodIdentifier, RadrootsFoodImageDimensions, RadrootsFoodPrice, RadrootsFoodPublishedAt, RadrootsFoodQuantity, RadrootsFoodText, food_media_blossom_digest, }, - ids::{RadrootsEventId, RadrootsPublicKey}, + ids::RadrootsEventId, }; use radroots_event_codec::food_availability::inbound::{ RadrootsFoodAvailabilityImageDiagnostic, RadrootsInboundFoodAvailabilityImage, RadrootsInboundFoodAvailabilityProjection, }; +use radroots_identity::PublicKey; pub const RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1: u32 = 1; pub const RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1: u32 = @@ -159,7 +160,7 @@ pub struct RadrootsStoredFoodAvailabilityImageV1 { raw_tag: Vec<String>, url: Option<String>, dimensions: Option<RadrootsFoodImageDimensions>, - blossom_sha256: Option<RadrootsBlossomSha256>, + blossom_sha256: Option<Sha256>, diagnostics: Vec<RadrootsFoodAvailabilityImageDiagnostic>, } @@ -207,7 +208,7 @@ impl RadrootsStoredFoodAvailabilityImageV1 { self.dimensions } - pub const fn blossom_sha256(&self) -> Option<RadrootsBlossomSha256> { + pub const fn blossom_sha256(&self) -> Option<Sha256> { self.blossom_sha256 } @@ -228,7 +229,7 @@ impl RadrootsStoredFoodAvailabilityImageV1 { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsStoredFoodAvailabilityV1 { source_generation: RadrootsEventStoreSourceGeneration, - pubkey: RadrootsPublicKey, + pubkey: PublicKey, identifier: RadrootsFoodIdentifier, event_id: RadrootsEventId, event_seq: i64, @@ -250,7 +251,7 @@ impl RadrootsStoredFoodAvailabilityV1 { #[allow(clippy::too_many_arguments)] pub(crate) fn from_projection( source_generation: RadrootsEventStoreSourceGeneration, - pubkey: RadrootsPublicKey, + pubkey: PublicKey, event_id: RadrootsEventId, event_seq: i64, created_at: u64, @@ -307,7 +308,7 @@ impl RadrootsStoredFoodAvailabilityV1 { self.source_generation } - pub const fn pubkey(&self) -> &RadrootsPublicKey { + pub const fn pubkey(&self) -> &PublicKey { &self.pubkey } diff --git a/crates/event_store/src/nip09/reconciliation_v1.rs b/crates/event_store/src/nip09/reconciliation_v1.rs @@ -569,24 +569,24 @@ impl RequestIndex { fn insert(&mut self, request: &RadrootsAdmittedNip09DeletionRequestEventV1) { let request_event = request.event(); - let request_author = request_event.author_str(); - let request_id = request_event.id_str(); + let request_author = request_event.author().to_hex(); + let request_id = request_event.id_str().to_owned(); for target in request.projection().event_targets() { self.event_targets .entry(target.event_id().as_str().to_owned()) .or_default() - .entry(request_author.to_owned()) + .entry(request_author.clone()) .and_modify(|current| { - if request_id < current.as_str() { - *current = request_id.to_owned(); + if request_id.as_str() < current.as_str() { + *current = request_id.clone(); } }) - .or_insert_with(|| request_id.to_owned()); + .or_insert_with(|| request_id.clone()); } for target in request.projection().address_targets() { let coordinate = ( i64::from(target.coordinate().kind()), - target.coordinate().pubkey().as_str().to_owned(), + target.coordinate().pubkey().to_hex(), target.coordinate().identifier().to_owned(), ); let evidence = self.address_targets.entry(coordinate.clone()).or_default(); @@ -594,11 +594,11 @@ impl RequestIndex { let replace = evidence.authorized.as_ref().is_none_or(|current| { request_event.created_at_u64() > current.created_at || (request_event.created_at_u64() == current.created_at - && request_id < current.request_id.as_str()) + && request_id.as_str() < current.request_id.as_str()) }); if replace { evidence.authorized = Some(IndexedRequestEvidence { - request_id: request_id.to_owned(), + request_id: request_id.clone(), created_at: request_event.created_at_u64(), }); } @@ -626,7 +626,7 @@ impl RequestIndex { .event_targets .get(event.id_str()) .map_or((None, false), |by_author| { - let authorized = by_author.get(event.author_str()).cloned(); + let authorized = by_author.get(&event.author().to_hex()).cloned(); let unauthorized = by_author.len() > usize::from(authorized.is_some()); (authorized, unauthorized) }); @@ -1775,7 +1775,11 @@ async fn load_reconciliation_snapshot( .map_err(|_| raw_mismatch(event_id.as_str(), "raw_json"))?; let event = ingest.event(); compare_raw_field(event.id_str() == event_id, event_id.as_str(), "event_id")?; - compare_raw_field(event.author_str() == pubkey, event_id.as_str(), "pubkey")?; + compare_raw_field( + event.author().to_hex() == pubkey, + event_id.as_str(), + "pubkey", + )?; let created_at: i64 = row.try_get("created_at")?; compare_raw_field( i64::try_from(event.created_at_u64()).ok() == Some(created_at), @@ -2227,7 +2231,7 @@ async fn rebuild_raw_heads( "INSERT INTO event_envelope_head(coordinate_type, kind, pubkey, d_tag, event_id, created_at, updated_at_ms) VALUES ('replaceable', ?, ?, NULL, ?, ?, ?)", ) .bind(i64::from(*kind)) - .bind(pubkey.as_str()) + .bind(pubkey.to_hex()) .bind(winner.candidate.event_id.as_str()) .bind(i64_from_u64( "raw_head_created_at", @@ -2246,7 +2250,7 @@ async fn rebuild_raw_heads( "INSERT INTO event_envelope_head(coordinate_type, kind, pubkey, d_tag, event_id, created_at, updated_at_ms) VALUES ('addressable', ?, ?, ?, ?, ?, ?)", ) .bind(i64::from(*kind)) - .bind(pubkey.as_str()) + .bind(pubkey.to_hex()) .bind(d_tag) .bind(winner.candidate.event_id.as_str()) .bind(i64_from_u64( @@ -2384,35 +2388,36 @@ fn event_coordinate_fact( ) -> Result<Option<EventCoordinateFact>, RadrootsEventStoreError> { let envelope = event.verified_event.event(); let kind = envelope.kind_u32(); - let (coordinate_type, raw_d_tag, nip09_d_tag) = - if matches!(kind, 0 | 3) || (10_000..=19_999).contains(&kind) { - ("replaceable", String::new(), Some(String::new())) - } else if (30_000..=39_999).contains(&kind) { - let raw_d_tag_value = envelope - .tag_slices() - .iter() - .find(|tag| tag.as_slice().first().is_some_and(|name| name == "d")) - .and_then(|tag| tag.as_slice().get(1)) - .cloned(); - let nip09_d_tag = raw_d_tag_value.as_ref().and_then(|d_tag| { - RadrootsNip01Coordinate::parse(format!("{kind}:{}:{d_tag}", envelope.author_str())) - .ok() - .map(|coordinate| coordinate.identifier().to_owned()) - }); - ( - "addressable", - raw_d_tag_value.unwrap_or_default(), - nip09_d_tag, - ) - } else { - return Ok(None); - }; + let (coordinate_type, raw_d_tag, nip09_d_tag) = if matches!(kind, 0 | 3) + || (10_000..=19_999).contains(&kind) + { + ("replaceable", String::new(), Some(String::new())) + } else if (30_000..=39_999).contains(&kind) { + let raw_d_tag_value = envelope + .tag_slices() + .iter() + .find(|tag| tag.as_slice().first().is_some_and(|name| name == "d")) + .and_then(|tag| tag.as_slice().get(1)) + .cloned(); + let nip09_d_tag = raw_d_tag_value.as_ref().and_then(|d_tag| { + RadrootsNip01Coordinate::parse(format!("{kind}:{}:{d_tag}", envelope.author().to_hex())) + .ok() + .map(|coordinate| coordinate.identifier().to_owned()) + }); + ( + "addressable", + raw_d_tag_value.unwrap_or_default(), + nip09_d_tag, + ) + } else { + return Ok(None); + }; Ok(Some(EventCoordinateFact { event_id: envelope.id_str().to_owned(), event_seq: event.seq, coordinate_type: coordinate_type.to_owned(), kind: i64::from(kind), - pubkey: envelope.author_str().to_owned(), + pubkey: envelope.author().to_hex().to_owned(), created_at: i64_from_u64("created_at", envelope.created_at_u64())?, inserted_at_ms: event.inserted_at_ms, admission_status: event.admission.status.as_str().to_owned(), @@ -2502,7 +2507,7 @@ async fn validate_nip09_fact_graph( expected_requests.insert(RequestFact { request_event_id: request.event().id_str().to_owned(), request_event_seq: event.seq, - request_pubkey: request.event().author_str().to_owned(), + request_pubkey: request.event().author().to_hex().to_owned(), request_created_at: i64_from_u64( "request_created_at", request.event().created_at_u64(), @@ -2526,7 +2531,7 @@ async fn validate_nip09_fact_graph( expected_address_targets.insert(AddressTargetFact { request_event_id: request.event().id_str().to_owned(), target_kind: i64::from(target.coordinate().kind()), - target_pubkey: target.coordinate().pubkey().as_str().to_owned(), + target_pubkey: target.coordinate().pubkey().to_hex(), target_d_tag: target.coordinate().identifier().to_owned(), inclusive_cutoff: i64_from_u64( "inclusive_cutoff", @@ -2677,7 +2682,7 @@ async fn persist_request_fact( .bind(generation.as_bytes().as_slice()) .bind(event.id_str()) .bind(request_seq) - .bind(event.author_str()) + .bind(event.author().to_hex()) .bind(i64_from_u64( "request_created_at", event.created_at_u64(), @@ -2709,7 +2714,7 @@ async fn persist_request_fact( .bind(generation.as_bytes().as_slice()) .bind(event.id_str()) .bind(i64::from(target.coordinate().kind())) - .bind(target.coordinate().pubkey().as_str()) + .bind(target.coordinate().pubkey().to_hex()) .bind(target.coordinate().identifier()) .bind(i64_from_u64("inclusive_cutoff", event.created_at_u64())?) .bind(i64_from_usize("source_tag_index", target.tag_index())?) @@ -2956,7 +2961,7 @@ fn desired_addressable_states( })?; let state = addressable_state_for_event( i64::from(kind), - pubkey.as_str(), + &pubkey.to_hex(), d_tag.as_str(), winner.event_seq, event, @@ -3215,7 +3220,7 @@ fn expected_transition_history( field: "transition.kind", value: kind, })?, - pubkey: radroots_event::ids::RadrootsPublicKey::parse(pubkey.as_str())?, + pubkey: radroots_identity::PublicKey::from_hex(pubkey.as_str())?, d_tag: d_tag.clone(), }; let winner = winners.get(&coordinate).ok_or_else(|| { @@ -3344,7 +3349,7 @@ fn request_affected_addressable_coordinates<'a>( if winners.contains_key(&coordinate) { affected.insert(( i64::from(kind), - target.coordinate().pubkey().as_str().to_owned(), + target.coordinate().pubkey().to_hex(), target.coordinate().identifier().to_owned(), )); } @@ -4454,7 +4459,7 @@ mod tests { event_seq: 1, coordinate_type: "regular".to_owned(), kind: i64::from(KIND_DELETION_REQUEST), - pubkey: deletion.author_str().to_owned(), + pubkey: deletion.author().to_hex(), created_at: i64::try_from(deletion.created_at_u64()).expect("created_at"), inserted_at_ms: 1, admission_status: "admitted".to_owned(), @@ -5365,7 +5370,7 @@ INSERT INTO radroots_event_store_owned_child_probe(id, parent_id) VALUES (1, 999 .iter() .any(|address_target| { i64::from(address_target.coordinate().kind()) == coordinate_key.0 - && address_target.coordinate().pubkey().as_str() + && address_target.coordinate().pubkey().to_hex() == coordinate_key.1.as_str() && address_target.coordinate().identifier() == coordinate_key.2.as_str() }) @@ -5703,7 +5708,7 @@ INSERT INTO caller_child(id, parent_id) VALUES (1, 999);", "INSERT INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'verified', ?, ?, ?, ?, ?, ?)", ) .bind(event.id_str()) - .bind(event.author_str()) + .bind(event.author().to_hex()) .bind(i64::try_from(event.created_at_u64()).expect("created_at")) .bind(i64::from(event.kind_u32())) .bind(tags_json) @@ -5758,7 +5763,7 @@ INSERT INTO caller_child(id, parent_id) VALUES (1, 999);", ) -> RadrootsEventIngest { let wire = RadrootsNip01EventWire { id: envelope.id_str().to_owned(), - pubkey: envelope.author_str().to_owned(), + pubkey: envelope.author().to_hex().to_owned(), created_at: envelope.created_at_u64(), kind: envelope.kind_u32(), tags: envelope.tags_as_vec(), @@ -5895,7 +5900,7 @@ INSERT INTO caller_child(id, parent_id) VALUES (1, 999);", "INSERT INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, '[]', ?, ?, ?, 'verified', ?, ?, ?, ?, ?, ?)", ) .bind(event.id_str()) - .bind(event.author_str()) + .bind(event.author().to_hex()) .bind(i64::try_from(event.created_at_u64()).expect("created_at")) .bind(i64::from(event.kind_u32())) .bind(event.content()) diff --git a/crates/event_store/src/nip09/reconciliation_v1/result_vector_executor.rs b/crates/event_store/src/nip09/reconciliation_v1/result_vector_executor.rs @@ -228,7 +228,7 @@ async fn seed_v1_raw_event( "INSERT INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'verified', ?, ?, ?, ?, ?, ?)", ) .bind(event.id_str()) - .bind(event.author_str()) + .bind(event.author().to_hex()) .bind( i64::try_from(event.created_at_u64()) .unwrap_or_else(|_| panic!("{case_id}: created_at exceeds SQLite range")), diff --git a/crates/event_store/src/nip09/reconciliation_v1/visibility_oracle_v1.rs b/crates/event_store/src/nip09/reconciliation_v1/visibility_oracle_v1.rs @@ -250,12 +250,12 @@ impl<'a> OracleRequestIndexV1<'a> { let mut address_targets = BTreeMap::<(u32, String, String), OracleAddressRequestEvidenceV1>::new(); for (index, request) in requests.iter().enumerate() { - let request_author = request.event().author_str(); + let request_author = request.event().author().to_hex(); for target in request.projection().event_targets() { event_targets .entry(target.event_id().as_str().to_owned()) .or_default() - .entry(request_author.to_owned()) + .entry(request_author.clone()) .and_modify(|current| { if request.event().id() < requests[*current].event().id() { *current = index; @@ -266,7 +266,7 @@ impl<'a> OracleRequestIndexV1<'a> { for target in request.projection().address_targets() { let coordinate = ( target.coordinate().kind(), - target.coordinate().pubkey().as_str().to_owned(), + target.coordinate().pubkey().to_hex(), target.coordinate().identifier().to_owned(), ); let evidence = address_targets.entry(coordinate.clone()).or_default(); @@ -307,7 +307,7 @@ impl<'a> OracleRequestIndexV1<'a> { .event_targets .get(event.id_str()) .map_or((None, false), |by_author| { - let authorized = by_author.get(event.author_str()).copied(); + let authorized = by_author.get(&event.author().to_hex()).copied(); ( authorized, by_author.len() > usize::from(authorized.is_some()), @@ -383,11 +383,11 @@ fn oracle_nip01_coordinate_key(event: &RadrootsEventEnvelope) -> Option<(u32, St _ => return None, }; let coordinate = - RadrootsNip01Coordinate::parse(format!("{kind}:{}:{identifier}", event.author_str())) + RadrootsNip01Coordinate::parse(format!("{kind}:{}:{identifier}", event.author().to_hex())) .ok()?; Some(( coordinate.kind(), - coordinate.pubkey().as_str().to_owned(), + coordinate.pubkey().to_hex(), coordinate.identifier().to_owned(), )) } @@ -574,7 +574,7 @@ mod tests { ); let coordinate = format!( "30402:{}:unrepresentable-cutoff", - target.event().author_str() + target.event().author().to_hex() ); let created_at = u64::try_from(i64::MAX).expect("i64 maximum fits u64") + 1; let keys = @@ -648,7 +648,7 @@ mod tests { "{}", vec![vec!["d".to_owned(), "high-fan-in".to_owned()]], ); - let coordinate = format!("30402:{}:high-fan-in", target.event().author_str()); + let coordinate = format!("30402:{}:high-fan-in", target.event().author().to_hex()); let mut requests = (0..REQUEST_COUNT) .map(|index| { let ingest = signed_ingest_with_tags( @@ -699,7 +699,10 @@ mod tests { ); request_tags.push(vec![ "a".to_owned(), - format!("30402:{}:{identifier}", address_target.event().author_str()), + format!( + "30402:{}:{identifier}", + address_target.event().author().to_hex() + ), ]); targets.push(address_target); } @@ -782,7 +785,7 @@ mod tests { "stale address", vec![vec![ "a".to_owned(), - format!("30402:{}:stale", stale.event().author_str()), + format!("30402:{}:stale", stale.event().author().to_hex()), ]], )), admitted_request(signed_ingest_with_tags_and_key( @@ -818,7 +821,7 @@ mod tests { "stale address", vec![vec![ "a".to_owned(), - format!("30402:{}:exact", exact.event().author_str()), + format!("30402:{}:exact", exact.event().author().to_hex()), ]], )), admitted_request(signed_ingest_with_tags( @@ -853,7 +856,7 @@ mod tests { "address reference", vec![vec![ "a".to_owned(), - format!("30402:{}:address", address.event().author_str()), + format!("30402:{}:address", address.event().author().to_hex()), ]], ))]; let address_index = OracleRequestIndexV1::new(&address_requests); @@ -881,7 +884,7 @@ mod tests { vec!["e".to_owned(), both.event().id_str().to_owned()], vec![ "a".to_owned(), - format!("30402:{}:both", both.event().author_str()), + format!("30402:{}:both", both.event().author().to_hex()), ], ], ))]; @@ -907,7 +910,7 @@ mod tests { "{}", vec![vec!["d".to_owned(), "invariant".to_owned()]], ); - let coordinate = format!("30402:{}:invariant", target.event().author_str()); + let coordinate = format!("30402:{}:invariant", target.event().author().to_hex()); let first = admitted_request(signed_ingest_with_tags( 5, 1_700_200_010, diff --git a/crates/event_store/src/source_maintenance_v1.rs b/crates/event_store/src/source_maintenance_v1.rs @@ -81,7 +81,7 @@ pub(crate) fn raw_source_capacity_delta_v1( let event = ingest.event(); let raw_event_bytes = raw_event_row_bytes_v1( event.id_str(), - event.author_str(), + &event.author().to_hex(), tags_json, event.content(), event.sig_str(), diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs @@ -1961,14 +1961,16 @@ fn bool_from_i64(field: &'static str, value: i64) -> Result<bool, RadrootsEventS fn parse_id<T>(value: String) -> Result<T, RadrootsEventStoreError> where - T: TryFrom<String, Error = radroots_event::ids::RadrootsIdParseError>, + T: TryFrom<String>, + T::Error: Into<RadrootsEventStoreError>, { T::try_from(value).map_err(Into::into) } fn parse_optional_id<T>(value: Option<String>) -> Result<Option<T>, RadrootsEventStoreError> where - T: TryFrom<String, Error = radroots_event::ids::RadrootsIdParseError>, + T: TryFrom<String>, + T::Error: Into<RadrootsEventStoreError>, { value.map(parse_id).transpose() } @@ -2027,9 +2029,7 @@ mod tests { use radroots_event::food_availability::{ RadrootsFoodAvailabilityStatus, RadrootsFoodIdentifier, }; - use radroots_event::ids::{ - RadrootsClassifiedListingAddress, RadrootsInventoryBinId, RadrootsPublicKey, - }; + use radroots_event::ids::{RadrootsClassifiedListingAddress, RadrootsInventoryBinId}; use radroots_event::kinds::{ KIND_CALENDAR_DATE_EVENT, KIND_CLASSIFIED_LISTING, KIND_DELETION_REQUEST, KIND_FARM, KIND_GEOCHAT, KIND_LIST_SET_RELAY, KIND_POST, KIND_PROFILE, KIND_RELAY_AUTH, @@ -2049,12 +2049,15 @@ mod tests { compute_canonical_nip01_event_id, }; use radroots_event_codec::food_availability::inbound::RadrootsFoodAvailabilityImageDiagnostic; + use radroots_identity::PublicKey; use std::sync::Arc; const FIXTURE_ALICE_SECRET_KEY_HEX: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + const FIXTURE_BOB_PUBLIC_KEY_HEX: &str = + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; struct FixedGeneration([u8; 32]); @@ -2133,11 +2136,13 @@ mod tests { RadrootsTradeId::parse("1".repeat(32)).expect("trade id") } - fn public_key(character: char) -> RadrootsPublicKey { - if character == 'a' { - return RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("alice pubkey"); - } - RadrootsPublicKey::parse(event_id(character)).expect("pubkey") + fn public_key(character: char) -> PublicKey { + let public_key_hex = match character { + 'a' => FIXTURE_ALICE_PUBLIC_KEY_HEX, + 'b' => FIXTURE_BOB_PUBLIC_KEY_HEX, + _ => panic!("unsupported fixture public key label: {character}"), + }; + PublicKey::from_hex(public_key_hex).expect("fixture pubkey") } fn candidate_terms() -> RadrootsTradeCandidateTermsV1 { @@ -2278,7 +2283,7 @@ mod tests { content: String, keys: &RadrootsNostrKeys, ) -> RadrootsSignedEvent { - let counterparty = canonical.envelope.counterparty_pubkey.as_str().to_owned(); + let counterparty = canonical.envelope.counterparty_pubkey.to_hex(); let mut tags = vec![ vec![ "contract".to_owned(), @@ -2334,9 +2339,10 @@ mod tests { fn raw_source_text_bytes(event: &RadrootsSignedEvent) -> (u64, u64) { let tags = event.tags_as_vec(); let tags_json = serde_json::to_string(&tags).expect("tags JSON"); + let pubkey = event.pubkey().to_hex(); let event_bytes = [ event.id_str(), - event.pubkey_str(), + pubkey.as_str(), tags_json.as_str(), event.content(), event.sig_str(), @@ -2524,7 +2530,7 @@ mod tests { fn profile_coordinate() -> RadrootsEventHeadCoordinate { RadrootsEventHeadCoordinate::Replaceable { kind: KIND_PROFILE, - pubkey: RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("pubkey"), + pubkey: PublicKey::from_hex(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("pubkey"), } } @@ -3134,7 +3140,7 @@ mod tests { "INSERT INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'verified', 'admitted', 'radroots.post.v1', 'regular', 1, ?, ?)", ) .bind(event.id_str()) - .bind(event.author_str()) + .bind(event.author().to_hex()) .bind(i64::try_from(event.created_at_u64()).expect("created_at")) .bind(i64::from(event.kind_u32())) .bind(tags_json) @@ -3404,7 +3410,7 @@ mod tests { assert_eq!(cursor_generation, target_generation); let projected = store .food_availability_v1( - &RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"), + &PublicKey::from_hex(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"), &RadrootsFoodIdentifier::parse("v4-rebuild-carrots").expect("identifier"), ) .await @@ -3534,7 +3540,7 @@ mod tests { let raw_digest = raw_authority_digest(&store).await; let projected = store .food_availability_v1( - &RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"), + &PublicKey::from_hex(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"), &RadrootsFoodIdentifier::parse("migration-carrots").expect("identifier"), ) .await @@ -3546,7 +3552,7 @@ mod tests { assert_eq!( projected.images()[0].blossom_sha256(), Some( - radroots_blossom::RadrootsBlossomSha256::from_hex( + radroots_blossom::Sha256::from_hex( "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", ) .expect("Blossom digest"), @@ -3586,7 +3592,7 @@ mod tests { assert_eq!(raw_authority_digest(&store).await, raw_digest); let rebuilt = store .food_availability_v1( - &RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"), + &PublicKey::from_hex(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"), &RadrootsFoodIdentifier::parse("migration-carrots").expect("identifier"), ) .await @@ -6799,7 +6805,7 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", ); assert!(seller_reservation_for_mutation(&revision_decision).is_some()); assert!(seller_reservation_for_mutation(&cancellation).is_none()); - assert_eq!(public_key('b').as_str(), event_id('b')); + assert_eq!(public_key('b').to_hex(), FIXTURE_BOB_PUBLIC_KEY_HEX); for kind in [ RadrootsTradeMutationKindV1::Proposal, @@ -7536,7 +7542,7 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", .expect("sealed projection count"); assert_eq!(initial_projection_count, 2); - let author = RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); + let author = PublicKey::from_hex(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); let carrot_id = RadrootsFoodIdentifier::parse("nantes-carrots").expect("identifier"); let projected = store .food_availability_v1(&author, &carrot_id) @@ -7564,7 +7570,7 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", assert_eq!( projected.images()[0].blossom_sha256(), Some( - radroots_blossom::RadrootsBlossomSha256::from_hex( + radroots_blossom::Sha256::from_hex( "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", ) .expect("Blossom digest"), @@ -7574,7 +7580,7 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", assert_eq!( projected.images()[1].blossom_sha256(), Some( - radroots_blossom::RadrootsBlossomSha256::from_hex( + radroots_blossom::Sha256::from_hex( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) .expect("Blossom digest"), @@ -7734,7 +7740,7 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", if reason.contains("image index exceeds") )); - let author = RadrootsPublicKey::parse(event.pubkey_str()).expect("author"); + let author = event.pubkey().clone(); let event_id = RadrootsEventId::parse(event.id_str()).expect("event id"); for (event_seq, source_transition_seq, expected_reason) in [ (0, 1, "event sequence must be positive"), @@ -8408,7 +8414,7 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", "active", Vec::new(), ); - let author = RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); + let author = PublicKey::from_hex(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); let identifier = RadrootsFoodIdentifier::parse("nantes-carrots").expect("identifier"); for (observed_at_ms, event) in [(20_000, &active), (20_001, &sold)] { @@ -8519,7 +8525,7 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", deletion.id_str() ); assert_eq!( - deletion_cause.pubkey().as_str(), + &deletion_cause.pubkey().to_hex(), FIXTURE_ALICE_PUBLIC_KEY_HEX ); assert_eq!(deletion_cause.created_at(), 230); @@ -8570,7 +8576,7 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", let store = RadrootsEventStore::open_memory().await.expect("open"); let identifier = RadrootsFoodIdentifier::parse("AAAAAAAAAAAAAAAAAAAAAg").expect("identifier"); - let author = RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); + let author = PublicKey::from_hex(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); let food = food_availability_event( 200, identifier.as_str(), @@ -8693,7 +8699,7 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", "active", Vec::new(), ); - let author = RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); + let author = PublicKey::from_hex(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); let identifier = RadrootsFoodIdentifier::parse("rollback-carrots").expect("identifier"); let mut transaction = store.begin_write_transaction().await.expect("transaction"); @@ -8789,7 +8795,7 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", .await .expect("wrong-author deletion remains a valid event"); - let author = RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); + let author = PublicKey::from_hex(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); let identifier = RadrootsFoodIdentifier::parse("protected-carrots").expect("identifier"); let projection = store .food_availability_v1(&author, &identifier) diff --git a/crates/event_store/src/store/addressable_transition_feed_v1.rs b/crates/event_store/src/store/addressable_transition_feed_v1.rs @@ -20,7 +20,8 @@ use crate::model::{ use crate::nip09::reconciliation_v1::{ EventAdmission, ReconciliationProfile, generation_from_blob, }; -use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; +use radroots_event::ids::RadrootsEventId; +use radroots_identity::PublicKey; use sqlx::{QueryBuilder, Row, Sqlite, SqliteConnection}; impl RadrootsEventStore { @@ -286,7 +287,7 @@ async fn transition_from_row( } let coordinate = RadrootsAddressableTransitionCoordinateV1 { kind, - pubkey: RadrootsPublicKey::parse(pubkey.as_str()) + pubkey: PublicKey::from_hex(pubkey.as_str()) .map_err(|error| corruption(format!("transition pubkey is invalid: {error}")))?, d_tag, }; @@ -374,7 +375,7 @@ async fn transition_from_row( let visible_event = if visible_reference.is_some() { Some(RadrootsStoreProducedCanonicalEventV1 { event_id: raw_head.event_id().clone(), - pubkey: coordinate.pubkey().clone(), + pubkey: *coordinate.pubkey(), created_at: raw_event.created_at, kind: coordinate.kind(), raw_json: raw_event.raw_json.clone(), @@ -496,7 +497,7 @@ async fn validate_incremental_cause( coordinate: &RadrootsAddressableTransitionCoordinateV1, raw_head: &RadrootsAddressableTransitionEventReferenceV1, cause_reference: Option<&RadrootsAddressableTransitionEventReferenceV1>, - cause: Option<&(RadrootsStoredRawEvent, EventAdmission, RadrootsPublicKey)>, + cause: Option<&(RadrootsStoredRawEvent, EventAdmission, PublicKey)>, suppression: Option<&RadrootsNip09SuppressionEvidenceV1>, decision: RadrootsAddressableTransitionRawHeadDecisionV1, ) -> Result<(), RadrootsEventStoreError> { @@ -523,7 +524,7 @@ async fn validate_incremental_cause( "non-head incremental transition was not caused by an admitted deletion request", )); } - let author_matches = cause_event.pubkey == coordinate.pubkey().as_str(); + let author_matches = cause_event.pubkey == coordinate.pubkey().to_hex(); let records_author_mismatch = suppression.is_some_and(|evidence| { evidence.reason() == crate::model::RadrootsNip09SuppressionReason::RequestAuthorMismatch @@ -542,7 +543,7 @@ async fn validate_incremental_cause( .bind(generation.as_bytes().as_slice()) .bind(cause_reference.event_id().as_str()) .bind(i64::from(coordinate.kind())) - .bind(coordinate.pubkey().as_str()) + .bind(coordinate.pubkey().to_hex()) .bind(coordinate.d_tag()) .fetch_one(&mut *connection) .await?; @@ -580,7 +581,7 @@ async fn validate_retraction_lineage( ) .bind(generation.as_bytes().as_slice()) .bind(i64::from(coordinate.kind())) - .bind(coordinate.pubkey().as_str()) + .bind(coordinate.pubkey().to_hex()) .bind(coordinate.d_tag()) .bind(transition_seq) .fetch_optional(&mut *connection) @@ -810,7 +811,7 @@ fn parse_event_id( async fn load_and_validate_stored_event( connection: &mut SqliteConnection, reference: &RadrootsAddressableTransitionEventReferenceV1, -) -> Result<(RadrootsStoredRawEvent, EventAdmission, RadrootsPublicKey), RadrootsEventStoreError> { +) -> Result<(RadrootsStoredRawEvent, EventAdmission, PublicKey), RadrootsEventStoreError> { let row = sqlx::query( "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes WHERE seq = ? AND event_id = ?", ) @@ -831,25 +832,19 @@ async fn load_and_validate_stored_event( .map_err(|error| corruption(format!("stored raw event cannot be reverified: {error}")))?; let event = reconstructed.event(); let event_pubkey = event.author().clone(); - let tags_json = serde_json::to_string(&event.tags_as_vec()) - .expect("an in-memory vector of string tags always serializes as JSON"); - if ( - stored.event_id.as_str(), - stored.pubkey.as_str(), - stored.created_at, - stored.kind, - stored.tags_json.as_str(), - stored.content.as_str(), - stored.sig.as_str(), - ) != ( - event.id_str(), - event.author_str(), - event.created_at_u64(), - event.kind_u32(), - tags_json.as_str(), - event.content(), - event.sig_str(), - ) { + let tags_json = serde_json::to_string(&event.tags_as_vec()).map_err(|error| { + corruption(format!( + "stored raw event tags cannot be canonicalized: {error}" + )) + })?; + if stored.event_id != event.id_str() + || stored.pubkey != event.author().to_hex() + || stored.created_at != event.created_at_u64() + || stored.kind != event.kind_u32() + || stored.tags_json != tags_json + || stored.content != event.content() + || stored.sig != event.sig_str() + { return Err(corruption(format!( "stored event `{}` disagrees with its signed raw JSON", reference.event_id() @@ -884,12 +879,9 @@ async fn validate_addressable_reference( reference: &RadrootsAddressableTransitionEventReferenceV1, event: &RadrootsStoredRawEvent, ) -> Result<(), RadrootsEventStoreError> { - if (event.event_class, event.kind, event.pubkey.as_str()) - != ( - StoredEventClass::Addressable, - coordinate.kind(), - coordinate.pubkey().as_str(), - ) + if event.event_class != StoredEventClass::Addressable + || event.kind != coordinate.kind() + || event.pubkey != coordinate.pubkey().to_hex() { return Err(corruption(format!( "event `{}` does not match transition coordinate `{}:{}:{}`", @@ -906,7 +898,7 @@ async fn validate_addressable_reference( .bind(reference.event_seq()) .bind(reference.event_id().as_str()) .bind(i64::from(coordinate.kind())) - .bind(coordinate.pubkey().as_str()) + .bind(coordinate.pubkey().to_hex()) .bind(coordinate.d_tag()) .fetch_one(&mut *connection) .await?; diff --git a/crates/event_store/src/store/food_availability_projection_v1.rs b/crates/event_store/src/store/food_availability_projection_v1.rs @@ -17,11 +17,12 @@ use crate::nip09::reconciliation_v1::{ }; use crate::{RadrootsEventStoreError, RadrootsEventStoreRawSourceRebuildDriftV1}; use radroots_event::food_availability::RadrootsFoodIdentifier; -use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; +use radroots_event::ids::RadrootsEventId; use radroots_event_codec::food_availability::inbound::{ RadrootsFoodAvailabilityImageDiagnostic, RadrootsFoodAvailabilityProjectionOutcome, project_verified_food_availability_event_registry_v7, }; +use radroots_identity::PublicKey; use serde::Deserialize; use sqlx::{Row, SqliteConnection}; @@ -50,13 +51,13 @@ struct FoodAvailabilityProjectionCursorState { impl RadrootsEventStore { pub async fn food_availability_v1( &self, - pubkey: &RadrootsPublicKey, + pubkey: &PublicKey, identifier: &RadrootsFoodIdentifier, ) -> Result<Option<RadrootsStoredFoodAvailabilityV1>, RadrootsEventStoreError> { let mut tx = self.pool.begin().await?; validate_food_availability_projection_hook_state_fast_v1(&mut tx).await?; let row = sqlx::query(FOOD_AVAILABILITY_POINT_QUERY_V1) - .bind(pubkey.as_str()) + .bind(pubkey.to_hex()) .bind(identifier.as_str()) .fetch_optional(&mut *tx) .await?; @@ -364,7 +365,7 @@ async fn apply_transition( "SELECT event_id FROM radroots_event_store_food_availability_projection WHERE source_generation = ? AND pubkey = ? AND d_tag = ?", ) .bind(transition.source_generation().as_bytes().as_slice()) - .bind(coordinate.pubkey().as_str()) + .bind(coordinate.pubkey().to_hex()) .bind(coordinate.d_tag()) .fetch_optional(&mut *connection) .await?; @@ -378,7 +379,7 @@ async fn apply_transition( "DELETE FROM radroots_event_store_food_availability_projection WHERE source_generation = ? AND pubkey = ? AND d_tag = ? AND event_id = ?", ) .bind(transition.source_generation().as_bytes().as_slice()) - .bind(coordinate.pubkey().as_str()) + .bind(coordinate.pubkey().to_hex()) .bind(coordinate.d_tag()) .bind(retracted.event_id().as_str()) .execute(&mut *connection) @@ -432,7 +433,7 @@ async fn apply_transition( }; let event = ingest.event(); if canonical.event_id().as_str() != event.id_str() - || canonical.pubkey().as_str() != event.author_str() + || canonical.pubkey() != event.author() || canonical.created_at() != event.created_at_u64() || canonical.kind() != event.kind_u32() { @@ -442,7 +443,7 @@ async fn apply_transition( } let stored = RadrootsStoredFoodAvailabilityV1::from_projection( transition.source_generation(), - canonical.pubkey().clone(), + *canonical.pubkey(), canonical.event_id().clone(), transition.raw_head().event_seq(), canonical.created_at(), @@ -468,7 +469,7 @@ async fn persist_projection( "INSERT INTO radroots_event_store_food_availability_projection(source_generation, kind, pubkey, d_tag, event_id, event_seq, created_at, contract_id, content, title, summary, published_at, location, price_amount, price_currency, price_unit, quantity_amount, quantity_unit, status, diagnostic_codes_json, source_transition_seq) VALUES (?, 30402, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(projection.source_generation().as_bytes().as_slice()) - .bind(projection.pubkey().as_str()) + .bind(projection.pubkey().to_hex()) .bind(projection.identifier().as_str()) .bind(projection.event_id().as_str()) .bind(projection.event_seq()) @@ -514,7 +515,7 @@ async fn persist_image( "INSERT INTO radroots_event_store_food_availability_image(source_generation, pubkey, d_tag, image_index, raw_tag_json, url, width, height, blossom_sha256, qualifies, diagnostic_codes_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(projection.source_generation().as_bytes().as_slice()) - .bind(projection.pubkey().as_str()) + .bind(projection.pubkey().to_hex()) .bind(projection.identifier().as_str()) .bind(i64::from(image.image_index())) .bind(raw_tag_json) @@ -551,7 +552,7 @@ pub(crate) async fn validate_food_availability_projection_hook_v1( validate_projection_source_transition(connection, &projection).await?; validate_fts_row(connection, &projection).await?; actual_coordinates.push(( - projection.pubkey().as_str().to_owned(), + projection.pubkey().to_hex(), projection.identifier().as_str().to_owned(), projection.event_id().as_str().to_owned(), projection.event_seq(), @@ -620,7 +621,7 @@ async fn validate_projection_source_transition( ) .bind(projection.source_transition_seq()) .bind(projection.source_generation().as_bytes().as_slice()) - .bind(projection.pubkey().as_str()) + .bind(projection.pubkey().to_hex()) .bind(projection.identifier().as_str()) .bind(projection.event_id().as_str()) .bind(projection.event_seq()) @@ -721,7 +722,7 @@ fn load_and_validate_projection_row( row.try_get("source_generation")?, "stored projection generation is invalid", )?; - let pubkey = RadrootsPublicKey::parse(row.try_get::<String, _>("pubkey")?.as_str()) + let pubkey = PublicKey::from_hex(row.try_get::<String, _>("pubkey")?.as_str()) .map_err(|error| projection_drift(format!("stored pubkey is invalid: {error}")))?; let event_id = RadrootsEventId::parse(row.try_get::<String, _>("event_id")?.as_str()) .map_err(|error| projection_drift(format!("stored event id is invalid: {error}")))?; @@ -733,7 +734,7 @@ fn load_and_validate_projection_row( let ingest = RadrootsEventIngest::from_raw_json(raw_json, 0) .map_err(|error| projection_drift(format!("projected event reverify failed: {error}")))?; if ingest.event().id_str() != event_id.as_str() - || ingest.event().author_str() != pubkey.as_str() + || ingest.event().author() != &pubkey || ingest.event().created_at_u64() != created_at || ingest.event().kind_u32() != 30_402 { @@ -850,7 +851,7 @@ fn validate_image_rows( let stored_blossom_sha256 = row .blossom_sha256 .map(|value| { - radroots_blossom::RadrootsBlossomSha256::from_hex(value.as_str()).map_err(|error| { + radroots_blossom::Sha256::from_hex(value.as_str()).map_err(|error| { projection_drift(format!("stored Blossom digest is invalid: {error}")) }) }) @@ -889,7 +890,7 @@ async fn validate_fts_row( .await? .ok_or_else(|| projection_drift("FoodAvailability FTS row is missing"))?; if row.try_get::<String, _>("event_id")? != projection.event_id().as_str() - || row.try_get::<String, _>("pubkey")? != projection.pubkey().as_str() + || row.try_get::<String, _>("pubkey")? != projection.pubkey().to_hex() || row.try_get::<String, _>("d_tag")? != projection.identifier().as_str() || row.try_get::<String, _>("title")? != projection.title().as_str() || row.try_get::<String, _>("summary")? != projection.summary().as_str() diff --git a/crates/event_store/src/store/post_core_extensions_v1.rs b/crates/event_store/src/store/post_core_extensions_v1.rs @@ -78,7 +78,7 @@ async fn store_trade_mutation_event( return Ok(()); } }; - if parsed.author_pubkey.as_str() != event.author_str() { + if &parsed.author_pubkey != event.author() { storage .quarantine_trade( Some(parsed.trade_id.as_str()), diff --git a/crates/event_store/src/store/post_core_storage_v1.rs b/crates/event_store/src/store/post_core_storage_v1.rs @@ -115,10 +115,10 @@ impl<'borrow, 'db> PostCoreStorageV1<'borrow, 'db> { .bind(candidate_id) .bind(proposal_mutation_id) .bind(target_claim_mutation_id) - .bind(write.mutation.author_pubkey.as_str()) - .bind(write.mutation.counterparty_pubkey.as_str()) - .bind(write.mutation.buyer_pubkey.as_str()) - .bind(write.mutation.seller_pubkey.as_str()) + .bind(write.mutation.author_pubkey.to_hex()) + .bind(write.mutation.counterparty_pubkey.to_hex()) + .bind(write.mutation.buyer_pubkey.to_hex()) + .bind(write.mutation.seller_pubkey.to_hex()) .bind(write.mutation.farm_id.as_str()) .bind(i64_from_u64( "authored_at_unix_s", @@ -206,7 +206,7 @@ impl<'borrow, 'db> PostCoreStorageV1<'borrow, 'db> { .bind(write.mutation_id.as_str()) .bind(write.mutation.trade_id.as_str()) .bind(RadrootsTransportKind::Nostr.canonical_label()) - .bind(write.event.author_str()) + .bind(write.event.author().to_hex()) .bind(i64_from_u64("created_at", write.event.created_at_u64())?) .bind(write.stored_event_seq) .bind(write.payload_sha256) @@ -268,7 +268,7 @@ impl<'borrow, 'db> PostCoreStorageV1<'borrow, 'db> { .bind(mutation.trade_id.as_str()) .bind(reservation.candidate_id.as_str()) .bind(claim_mutation_id.as_str()) - .bind(reservation.inventory_authority_id.as_str()) + .bind(reservation.inventory_authority_id.to_hex()) .bind(i64_from_u64( "inventory_epoch", reservation.inventory_epoch, diff --git a/crates/event_store/src/store/protocol_reconciliation_v1.rs b/crates/event_store/src/store/protocol_reconciliation_v1.rs @@ -426,7 +426,7 @@ async fn insert_raw_event( "INSERT OR IGNORE INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(event.id_str()) - .bind(event.author_str()) + .bind(event.author().to_hex()) .bind(i64_from_u64("created_at", event.created_at_u64())?) .bind(i64::from(event.kind_u32())) .bind(tags_json) @@ -603,14 +603,14 @@ async fn upsert_head( "DELETE FROM event_envelope_head WHERE coordinate_type = 'replaceable' AND kind = ? AND pubkey = ? AND d_tag IS NULL", ) .bind(i64::from(*kind)) - .bind(pubkey.as_str()) + .bind(pubkey.to_hex()) .execute(&mut **tx) .await?; sqlx::query( "INSERT INTO event_envelope_head(coordinate_type, kind, pubkey, d_tag, event_id, created_at, updated_at_ms) VALUES ('replaceable', ?, ?, NULL, ?, ?, ?)", ) .bind(i64::from(*kind)) - .bind(pubkey.as_str()) + .bind(pubkey.to_hex()) .bind(candidate.event_id.as_str()) .bind(i64_from_u64("created_at", candidate.created_at)?) .bind(updated_at_ms) @@ -626,7 +626,7 @@ async fn upsert_head( "DELETE FROM event_envelope_head WHERE coordinate_type = 'addressable' AND kind = ? AND pubkey = ? AND d_tag = ?", ) .bind(i64::from(*kind)) - .bind(pubkey.as_str()) + .bind(pubkey.to_hex()) .bind(d_tag.as_str()) .execute(&mut **tx) .await?; @@ -634,7 +634,7 @@ async fn upsert_head( "INSERT INTO event_envelope_head(coordinate_type, kind, pubkey, d_tag, event_id, created_at, updated_at_ms) VALUES ('addressable', ?, ?, ?, ?, ?, ?)", ) .bind(i64::from(*kind)) - .bind(pubkey.as_str()) + .bind(pubkey.to_hex()) .bind(d_tag.as_str()) .bind(candidate.event_id.as_str()) .bind(i64_from_u64("created_at", candidate.created_at)?) diff --git a/crates/event_store/src/store/protocol_storage_v1.rs b/crates/event_store/src/store/protocol_storage_v1.rs @@ -5,7 +5,7 @@ use crate::model::reconciliation_v1::{ }; use radroots_event::envelope::RadrootsEventKind; use radroots_event::event_head::v1::RadrootsEventHeadCoordinate; -use radroots_event::ids::RadrootsPublicKey; +use radroots_identity::PublicKey; use sqlx::sqlite::SqliteRow; use sqlx::{Row, Sqlite, Transaction}; @@ -101,7 +101,7 @@ pub(super) async fn raw_head_snapshot_in_transaction( "SELECT event.seq, event.event_id, event.pubkey, event.created_at, event.kind, event.tags_json, event.content, event.sig, event.raw_json, event.verification_status, event.contract_status, event.contract_id, event.event_class, event.projection_eligible, event.inserted_at_ms, event.updated_at_ms, head.coordinate_type AS raw_head_coordinate_type, head.kind AS raw_head_kind, head.pubkey AS raw_head_pubkey, head.d_tag AS raw_head_d_tag, head.event_id AS raw_head_event_id, head.created_at AS raw_head_created_at, head.updated_at_ms AS raw_head_updated_at_ms FROM event_envelope_head AS head LEFT JOIN event_envelopes AS event ON event.event_id = head.event_id WHERE head.coordinate_type = 'replaceable' AND head.kind = ? AND head.pubkey = ? AND head.d_tag IS NULL", ) .bind(i64::from(*kind)) - .bind(pubkey.as_str()) + .bind(pubkey.to_hex()) .fetch_optional(&mut **tx) .await? } @@ -114,7 +114,7 @@ pub(super) async fn raw_head_snapshot_in_transaction( "SELECT event.seq, event.event_id, event.pubkey, event.created_at, event.kind, event.tags_json, event.content, event.sig, event.raw_json, event.verification_status, event.contract_status, event.contract_id, event.event_class, event.projection_eligible, event.inserted_at_ms, event.updated_at_ms, head.coordinate_type AS raw_head_coordinate_type, head.kind AS raw_head_kind, head.pubkey AS raw_head_pubkey, head.d_tag AS raw_head_d_tag, head.event_id AS raw_head_event_id, head.created_at AS raw_head_created_at, head.updated_at_ms AS raw_head_updated_at_ms FROM event_envelope_head AS head LEFT JOIN event_envelopes AS event ON event.event_id = head.event_id WHERE head.coordinate_type = 'addressable' AND head.kind = ? AND head.pubkey = ? AND head.d_tag = ?", ) .bind(i64::from(*kind)) - .bind(pubkey.as_str()) + .bind(pubkey.to_hex()) .bind(d_tag.as_str()) .fetch_optional(&mut **tx) .await? @@ -140,7 +140,7 @@ pub(super) fn raw_head_coordinate_for_stored_event( let inconsistent = || RadrootsEventStoreError::StoredHeadInconsistent { event_id: event.event_id.clone(), }; - let pubkey = RadrootsPublicKey::parse(event.pubkey.clone()).map_err(|_| inconsistent())?; + let pubkey = PublicKey::from_hex(&event.pubkey).map_err(|_| inconsistent())?; match event.event_class { StoredEventClass::Replaceable => Ok(RadrootsEventHeadCoordinate::Replaceable { kind: event.kind, @@ -192,7 +192,7 @@ fn validate_raw_head_snapshot( StoredEventClass::Replaceable if raw_head.d_tag.is_none() => { RadrootsEventHeadCoordinate::Replaceable { kind: raw_head.kind, - pubkey: RadrootsPublicKey::parse(raw_head.pubkey.clone()).map_err(|_| { + pubkey: PublicKey::from_hex(&raw_head.pubkey).map_err(|_| { RadrootsEventStoreError::StoredHeadInconsistent { event_id: raw_head.event_id.clone(), } @@ -201,7 +201,7 @@ fn validate_raw_head_snapshot( } StoredEventClass::Addressable => RadrootsEventHeadCoordinate::Addressable { kind: raw_head.kind, - pubkey: RadrootsPublicKey::parse(raw_head.pubkey.clone()).map_err(|_| { + pubkey: PublicKey::from_hex(&raw_head.pubkey).map_err(|_| { RadrootsEventStoreError::StoredHeadInconsistent { event_id: raw_head.event_id.clone(), } @@ -339,7 +339,7 @@ mod tests { let wrong_requested = RadrootsEventHeadCoordinate::Replaceable { kind: 10_002, - pubkey: RadrootsPublicKey::parse(event.pubkey.clone()).expect("pubkey"), + pubkey: PublicKey::from_hex(&event.pubkey).expect("pubkey"), }; assert!(validate_raw_head_snapshot(&wrong_requested, &head, &event).is_err()); @@ -426,7 +426,7 @@ mod tests { let coordinate = RadrootsEventHeadCoordinate::Replaceable { kind: 10_001, - pubkey: RadrootsPublicKey::parse(pubkey).expect("pubkey"), + pubkey: PublicKey::from_hex(&pubkey).expect("pubkey"), }; let mut transaction = store.pool().begin().await.expect("transaction"); assert!(matches!( diff --git a/crates/event_store/tests/food_availability_projection_v1_result_vector.rs b/crates/event_store/tests/food_availability_projection_v1_result_vector.rs @@ -1,7 +1,7 @@ #![forbid(unsafe_code)] -use radroots_blossom::RadrootsBlossomSha256; -use radroots_event::{food_availability::RadrootsFoodIdentifier, ids::RadrootsPublicKey}; +use radroots_blossom::Sha256; +use radroots_event::food_availability::RadrootsFoodIdentifier; use radroots_event_store::{ RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1, RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1, @@ -12,8 +12,9 @@ use radroots_event_store::{ RadrootsStoreProducedCanonicalEventV1, RadrootsStoredFoodAvailabilityV1, RadrootsStoredRawEvent, }; +use radroots_identity::PublicKey; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; +use sha2::{Digest, Sha256 as Sha256Hasher}; const RESULT_VECTOR_EXECUTOR_ID: &str = "radroots_event_store.food_availability_projection_v1.result_vector_executor.v1"; @@ -304,7 +305,7 @@ async fn food_availability_projection_v1_result_vector() { } fn sha256_hex(bytes: &[u8]) -> String { - hex::encode(Sha256::digest(bytes)) + hex::encode(Sha256Hasher::digest(bytes)) } fn raw_head_decision_code(decision: &RadrootsRawHeadDecision) -> &'static str { @@ -401,7 +402,7 @@ async fn execute_case(case: ProjectionCase) { .unwrap_or_else(|error| panic!("{}: active source generation: {error}", case.id)); assert_eq!(case.expected.coordinate.kind, 30_402, "{}", case.id); - let public_key = RadrootsPublicKey::parse(&case.expected.coordinate.pubkey) + let public_key = PublicKey::from_hex(&case.expected.coordinate.pubkey) .unwrap_or_else(|error| panic!("{}: expected public key: {error}", case.id)); let identifier = RadrootsFoodIdentifier::parse(&case.expected.coordinate.d_tag) .unwrap_or_else(|error| panic!("{}: expected identifier: {error}", case.id)); @@ -513,7 +514,7 @@ async fn execute_case(case: ProjectionCase) { case.id ); assert_eq!( - actual.coordinate().pubkey().as_str(), + actual.coordinate().pubkey().to_hex(), expected.coordinate.pubkey, "{}", case.id @@ -563,7 +564,7 @@ async fn execute_case(case: ProjectionCase) { match (expected.cause_event.0.as_ref(), actual.cause_event()) { (Some(expected), Some(actual)) => { assert_event_reference(&case.id, "cause", &expected.event, actual.event()); - assert_eq!(actual.pubkey().as_str(), expected.pubkey, "{}", case.id); + assert_eq!(actual.pubkey().to_hex(), expected.pubkey, "{}", case.id); assert_eq!(actual.created_at(), expected.created_at, "{}", case.id); assert_eq!(actual.kind(), expected.kind, "{}", case.id); assert_eq!( @@ -805,7 +806,7 @@ fn assert_canonical_visible_event( .unwrap_or_else(|| panic!("{}: expected event is absent from input", case.id)); assert_eq!(actual.event_id().as_str(), observed.event.id, "{}", case.id); assert_eq!( - actual.pubkey().as_str(), + actual.pubkey().to_hex(), observed.event.pubkey, "{}", case.id @@ -968,7 +969,7 @@ fn assert_projection( "{case_id}" ); let expected_blossom_sha256 = expected.blossom_sha256.0.as_deref().map(|value| { - RadrootsBlossomSha256::from_hex(value) + Sha256::from_hex(value) .unwrap_or_else(|error| panic!("{case_id}: expected Blossom digest: {error}")) }); assert_eq!( diff --git a/crates/geocoder/Cargo.toml b/crates/geocoder/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_geocoder" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/geonames/Cargo.toml b/crates/geonames/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "radroots_geonames" +description = "GeoNames-backed geocoding for Radroots" +version = "0.1.0-alpha" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +readme = "README.md" +publish = false + +[lib] +name = "radroots_geonames" + +[lints] +workspace = true diff --git a/crates/geonames/README.md b/crates/geonames/README.md @@ -0,0 +1,7 @@ +# radroots_geonames + +GeoNames-backed geocoding for Radroots. + +This package root is established for the Release V1 refactor. Dataset models, +indexes, and deterministic lookup behavior are migrated in the subsequent +GeoNames checkpoints. diff --git a/crates/geonames/src/lib.rs b/crates/geonames/src/lib.rs @@ -0,0 +1 @@ +//! GeoNames-backed geocoding for Radroots. diff --git a/crates/identity/Cargo.toml b/crates/identity/Cargo.toml @@ -1,48 +1,32 @@ [package] name = "radroots_identity" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] +authors.workspace = true rust-version.workspace = true license.workspace = true -description = "Identity model for Radroots" +description = "Public identity and account value types for Radroots" repository.workspace = true homepage.workspace = true documentation = "https://docs.rs/radroots_identity" -readme = "README" +readme = "README.md" + +[lib] +name = "radroots_identity" [features] -default = ["std", "json-file", "nip49"] -std = [ - "dep:radroots_protected_store", - "dep:radroots_runtime_paths", - "dep:radroots_secret_vault", -] -json-file = ["std", "dep:radroots_runtime"] -nip49 = ["std", "nostr/nip49"] -secrecy = ["dep:secrecy"] -zeroize = ["dep:zeroize"] +default = ["std", "serde"] +std = ["thiserror/std"] +serde = ["dep:serde"] [dependencies] -radroots_runtime = { workspace = true, optional = true } -radroots_protected_store = { workspace = true, optional = true, features = [ - "std", -] } -radroots_runtime_paths = { workspace = true, optional = true } -radroots_secret_vault = { workspace = true, optional = true, features = [ - "std", -] } -nostr = { workspace = true } -secrecy = { workspace = true, optional = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } -tracing = { workspace = true } -zeroize = { workspace = true, optional = true } +k256 = { version = "0.13", default-features = false, features = ["arithmetic"] } +serde = { workspace = true, optional = true } +thiserror = { version = "2", default-features = false } [dev-dependencies] -tempfile = { workspace = true } +serde_json = { workspace = true, features = ["std"] } -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } +[lints] +workspace = true diff --git a/crates/identity/README b/crates/identity/README @@ -1,25 +0,0 @@ -# radroots_identity - -This is the README for `radroots_identity`, which provides identity models, -encrypted file storage, and profile utilities for the `radroots` core -libraries. - -## Overview - - * public and private identity, profile, file, and identifier types; - * username validation, normalization, and parser helpers; - * default identity path constants and JSON file support behind feature flags; - * encrypted identity-file and public-profile storage helpers for local runtime - consumers; - * optional NIP-49, `secrecy`, and `zeroize` support for protected key - material. - -## Copyright - -Except as otherwise noted, all files in the `radroots_identity` distribution -are - - Copyright (c) 2025 Tyson Lupul - -For information on usage and redistribution, and for a DISCLAIMER OF ALL -WARRANTIES, see LICENSE included in the `radroots_identity` distribution. diff --git a/crates/identity/README.md b/crates/identity/README.md @@ -0,0 +1,146 @@ +# radroots_identity + +`radroots_identity` provides the portable public identity and account values +shared by Radroots packages: canonical public keys, identity and account IDs, +public profiles, normalized usernames, and transport-neutral account status. + +The crate supports `no_std` environments with `alloc`. It performs no I/O, +starts no tasks or threads, reads no clocks or process state, and owns no key +generation, secrets, signing, persistence, networking, or account selection. + +## Example + +Construct public identities from validated keys and derive account identifiers +without exposing or inventing secret material: + +```rust +use radroots_identity::{AccountId, Profile, PublicIdentity, PublicKey, Username}; + +let public_key = PublicKey::from_hex( + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", +)?; +let username = Username::parse(" Alice.Farm ")?; +let identity = PublicIdentity::new(public_key) + .with_profile(Profile::new().with_username(username)); +let account_id = AccountId::from_public_identity(&identity); + +assert_eq!(identity.id().as_bytes(), public_key.as_bytes()); +assert_eq!(account_id.as_bytes(), public_key.as_bytes()); +assert_eq!( + identity.profile().and_then(Profile::username).map(Username::as_str), + Some("alice.farm"), +); +# Ok::<(), radroots_identity::Error>(()) +``` + +The same program is available as the +[`public_identity`](examples/public_identity.rs) example. + +## Public API + +The crate root intentionally exports `AccountId`, `IdentityId`, +`PublicIdentity`, `PublicKey`, `Profile`, `Username`, and the aggregate +[`Error`](https://docs.rs/radroots_identity/latest/radroots_identity/enum.Error.html). +Focused values remain in these modules: + +- `account` — derived account IDs, public account records, and observable + readiness status; +- `key` — validated 32-byte x-only secp256k1 public keys and identity IDs; +- `profile` — public identity metadata and the invariant-matched public + identity aggregate; +- `username` — canonical username parsing, bounds, and normalization. + +The normative responsibility and dependency boundary are defined by the +[`radroots_identity` package charter](../../docs/specs/radroots_crates_release_v1.md). +The reviewed pre-release surface is recorded in +[`docs/api`](../../docs/api/README.md). Signing, Nostr-key, secret-provider, +and storage ownership is documented in the +[`identity` migration boundary](../../docs/migration/identity.md). + +## Features + +| Feature | Default | Effect | +| --- | --- | --- | +| `std` | yes | Implements the standard error integration; value behavior remains portable. | +| `serde` | yes | Implements checked canonical serialization and deserialization for public values. | + +Disabling default features leaves the `no_std` + `alloc` value model. Features +are additive; enabling one does not select a runtime, backend, global account, +secret source, or side effect. + +## Invariants and untrusted input + +`PublicKey`, `IdentityId`, and `AccountId` store exactly 32 validated bytes. +Text parsing accepts exact-width hexadecimal input and emits canonical +lowercase hexadecimal output. `PublicKey` validation confirms that the x-only +bytes identify a secp256k1 curve point. The three Rust types remain distinct so +callers cannot accidentally substitute an account identifier for a public key. + +`PublicIdentity` derives its `IdentityId` from its `PublicKey` and rejects +separately decoded parts that do not match. `account::Record` derives its +`AccountId` from its public identity and rejects mismatched IDs or an update +timestamp before creation. `Record::touch_updated` rejects time reversal and +leaves the timestamp unchanged on error. `Record::set_label` does not read a +clock or update the timestamp; the composing host owns clock policy and must +advance the timestamp explicitly when appropriate. + +`Username` trims surrounding whitespace, folds ASCII uppercase to lowercase, +enforces its byte bounds, and rejects unsupported characters or leading, +trailing, and consecutive dots. Account labels are opaque host-facing strings: +callers must apply their own content and resource limits before accepting them +from untrusted sources. + +## Serialization + +With `serde`, identifiers are canonical lowercase 64-character hexadecimal +strings and usernames are canonical strings. Profiles, public identities, and +account records use named fields and reject unknown fields. Deserializing a +public identity or account record re-applies identifier and timestamp +invariants; invalid native state is not admitted through the supported serde +surface. Account status variant names use `snake_case`. + +Serialization contains public metadata only and does not add confidentiality, +authenticity, event signing, or transport framing. Versioned cross-process wire +DTOs belong in `radroots_protocol` rather than this native value crate. + +## Execution and commit semantics + +Operations are synchronous and deterministic. Username parsing and account +record construction may allocate in proportion to caller-supplied username or +label text, so callers should cap untrusted input bytes before invoking them. +There are no asynchronous cancellation points, deadlines, retries, callbacks, +partial external effects, or durable commit points. A successful call returns +or updates the complete in-memory value; an error returns before any durable +commit because this crate owns no durable state. + +## Security boundary + +The crate forbids unsafe Rust. Public keys and identifiers are public data and +must not be treated as proof of control; authentication requires a verified +signature in the appropriate event or signing layer. This crate deliberately +has no raw secret keys, key generation, nsec/NIP-49 helpers, keyrings, vaults, +files, SQLite, runtime paths, signer sessions, or upstream Nostr events. + +```compile_fail +use radroots_identity::{SecretKey, generate_keypair}; +``` + +Do not recreate those responsibilities through compatibility wrappers around +this crate. Compose the signing, Nostr, secrets, and storage packages named by +the migration boundary instead. + +## Intended consumers + +`radroots_identity` is intended for lower-level Radroots event, signing, +transport, storage, and domain packages and for integrators that need the +portable public value model directly. Applications should normally begin with +the curated `radroots` crate or the advanced `radroots_sdk` composition +surface. + +The package is pre-1.0. Its durable responsibility and package identity are +fixed, while API-breaking changes follow the workspace's pre-1.0 versioning +policy. + +## License + +Licensed under either Apache-2.0 or MIT, at your option. diff --git a/crates/identity/examples/public_identity.rs b/crates/identity/examples/public_identity.rs @@ -0,0 +1,21 @@ +use radroots_identity::{AccountId, Profile, PublicIdentity, PublicKey, Username}; + +fn main() -> Result<(), radroots_identity::Error> { + let public_key = + PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df")?; + let username = Username::parse(" Alice.Farm ")?; + let identity = + PublicIdentity::new(public_key).with_profile(Profile::new().with_username(username)); + let account_id = AccountId::from_public_identity(&identity); + + assert_eq!(identity.id().as_bytes(), public_key.as_bytes()); + assert_eq!(account_id.as_bytes(), public_key.as_bytes()); + assert_eq!( + identity + .profile() + .and_then(Profile::username) + .map(Username::as_str), + Some("alice.farm"), + ); + Ok(()) +} diff --git a/crates/identity/src/account.rs b/crates/identity/src/account.rs @@ -0,0 +1,357 @@ +//! Public, transport-neutral account value types. +//! +//! [`AccountId`] is derived from the same canonical public bytes as an +//! identity. [`Record`] combines that identifier with a [`PublicIdentity`], an +//! optional host-facing label, and caller-supplied timestamps while preserving +//! their cross-field invariants. [`Status`] reports only observable readiness; +//! it contains no secret, signer, persistence, or host-selection state. +//! +//! This module does not read a clock or persist mutations. In particular, +//! [`Record::set_label`] and [`Record::touch_updated`] are separate operations +//! so the composing host retains explicit timestamp and commit policy. + +use alloc::string::String; + +use crate::{Error, IdentityId, PublicIdentity, key::define_identifier}; + +define_identifier! { + /// A canonical public account identifier. + pub struct AccountId; +} + +impl AccountId { + /// Derives the account identifier from its public identity identifier. + #[must_use] + pub const fn from_identity_id(identity_id: IdentityId) -> Self { + Self::from_validated_bytes(identity_id.into_bytes()) + } + + /// Derives the account identifier from a public identity. + #[must_use] + pub const fn from_public_identity(public_identity: &PublicIdentity) -> Self { + Self::from_identity_id(public_identity.id()) + } +} + +impl From<IdentityId> for AccountId { + fn from(value: IdentityId) -> Self { + Self::from_identity_id(value) + } +} + +impl From<&PublicIdentity> for AccountId { + fn from(value: &PublicIdentity) -> Self { + Self::from_public_identity(value) + } +} + +/// A portable public account record. +/// +/// The account identifier is always derived from `public_identity`. Secret +/// material, persistence location, account selection, and signer state are +/// intentionally absent. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Record { + account_id: AccountId, + public_identity: PublicIdentity, + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + label: Option<String>, + created_at_unix: u64, + updated_at_unix: u64, +} + +impl Record { + /// Creates a public account record at the supplied Unix timestamp. + #[must_use] + pub fn new( + public_identity: PublicIdentity, + label: Option<String>, + created_at_unix: u64, + ) -> Self { + Self { + account_id: AccountId::from_public_identity(&public_identity), + public_identity, + label, + created_at_unix, + updated_at_unix: created_at_unix, + } + } + + /// Validates an account record assembled from separately decoded parts. + pub fn try_from_parts( + account_id: AccountId, + public_identity: PublicIdentity, + label: Option<String>, + created_at_unix: u64, + updated_at_unix: u64, + ) -> Result<Self, Error> { + if account_id != AccountId::from_public_identity(&public_identity) { + return Err(Error::AccountIdMismatch); + } + if updated_at_unix < created_at_unix { + return Err(Error::AccountUpdatedBeforeCreated { + created_at_unix, + updated_at_unix, + }); + } + Ok(Self { + account_id, + public_identity, + label, + created_at_unix, + updated_at_unix, + }) + } + + /// Returns the canonical account identifier. + #[must_use] + pub const fn id(&self) -> AccountId { + self.account_id + } + + /// Borrows the public identity represented by this account. + #[must_use] + pub const fn public_identity(&self) -> &PublicIdentity { + &self.public_identity + } + + /// Borrows the optional host-facing label. + #[must_use] + pub fn label(&self) -> Option<&str> { + self.label.as_deref() + } + + /// Replaces the optional host-facing label without changing identity. + pub fn set_label(&mut self, label: Option<String>) { + self.label = label; + } + + /// Returns the record creation timestamp as Unix seconds. + #[must_use] + pub const fn created_at_unix(&self) -> u64 { + self.created_at_unix + } + + /// Returns the latest record update timestamp as Unix seconds. + #[must_use] + pub const fn updated_at_unix(&self) -> u64 { + self.updated_at_unix + } + + /// Advances the record update timestamp without permitting time reversal. + pub fn touch_updated(&mut self, updated_at_unix: u64) -> Result<(), Error> { + if updated_at_unix < self.updated_at_unix { + return Err(Error::AccountUpdateRegressed { + current_updated_at_unix: self.updated_at_unix, + proposed_updated_at_unix: updated_at_unix, + }); + } + self.updated_at_unix = updated_at_unix; + Ok(()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Record { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct RecordRepr { + account_id: AccountId, + public_identity: PublicIdentity, + #[serde(default)] + label: Option<String>, + created_at_unix: u64, + updated_at_unix: u64, + } + + let value = RecordRepr::deserialize(deserializer)?; + Self::try_from_parts( + value.account_id, + value.public_identity, + value.label, + value.created_at_unix, + value.updated_at_unix, + ) + .map_err(serde::de::Error::custom) + } +} + +/// Publicly observable account readiness without secret or signer details. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Status { + /// No account has been configured by the composing host. + #[default] + NotConfigured, + /// The account can be observed but has no available signing capability. + PublicOnly { account: Record }, + /// The composing host reports that the account can sign. + Ready { account: Record }, +} + +impl Status { + /// Borrows the configured account, when present. + #[must_use] + pub const fn account(&self) -> Option<&Record> { + match self { + Self::NotConfigured => None, + Self::PublicOnly { account } | Self::Ready { account } => Some(account), + } + } + + /// Reports whether the composing host marked the account ready to sign. + #[must_use] + pub const fn is_ready(&self) -> bool { + matches!(self, Self::Ready { .. }) + } +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "serde")] + use alloc::format; + use alloc::string::ToString; + use core::str::FromStr; + + use super::*; + + const ALICE: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + const BOB: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + + #[test] + fn account_ids_round_trip_through_identity_ids() { + let identity_id = IdentityId::from_hex(ALICE).expect("valid identity ID"); + let account_id = AccountId::from(identity_id); + + assert_eq!(account_id.to_hex(), ALICE); + assert_eq!(account_id.as_bytes(), identity_id.as_bytes()); + assert_eq!(AccountId::from_str(ALICE).unwrap(), account_id); + } + + #[test] + fn account_ids_validate_and_order_canonical_bytes() { + let alice = AccountId::from_hex(ALICE).expect("alice account"); + let bob = AccountId::from_hex(BOB).expect("bob account"); + + assert_eq!(AccountId::from_bytes(alice.into_bytes()).unwrap(), alice); + assert!(alice < bob); + assert!(AccountId::from_hex("not-an-account").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn account_ids_serde_as_validated_canonical_hex() { + let account_id = AccountId::from_hex(ALICE).expect("valid account ID"); + let encoded = serde_json::to_string(&account_id).expect("serialize account ID"); + + assert_eq!(encoded, format!("\"{ALICE}\"")); + assert_eq!( + serde_json::from_str::<AccountId>(&encoded).expect("deserialize account ID"), + account_id + ); + assert!(serde_json::from_str::<AccountId>("\"not-an-account\"").is_err()); + } + + fn public_identity(value: &str) -> PublicIdentity { + PublicIdentity::new(crate::PublicKey::from_hex(value).expect("public key")) + } + + #[test] + fn records_derive_identity_and_enforce_monotonic_timestamps() { + let identity = public_identity(ALICE); + let mut record = Record::new(identity.clone(), Some("primary".to_string()), 10); + + assert_eq!(record.id(), AccountId::from_public_identity(&identity)); + assert_eq!(record.public_identity(), &identity); + assert_eq!(record.label(), Some("primary")); + assert_eq!(record.created_at_unix(), 10); + assert_eq!(record.updated_at_unix(), 10); + assert!(matches!( + record.touch_updated(9), + Err(Error::AccountUpdateRegressed { + current_updated_at_unix: 10, + proposed_updated_at_unix: 9, + }) + )); + assert_eq!(record.updated_at_unix(), 10); + + record.touch_updated(12).expect("advance timestamp"); + assert!(matches!( + record.touch_updated(11), + Err(Error::AccountUpdateRegressed { + current_updated_at_unix: 12, + proposed_updated_at_unix: 11, + }) + )); + record.set_label(None); + assert_eq!(record.updated_at_unix(), 12); + assert_eq!(record.label(), None); + + let wrong_id = AccountId::from_identity_id(IdentityId::from_hex(BOB).unwrap()); + assert!(matches!( + Record::try_from_parts(wrong_id, identity, None, 10, 10), + Err(Error::AccountIdMismatch) + )); + } + + #[test] + fn status_exposes_only_public_account_readiness() { + let record = Record::new(public_identity(ALICE), None, 10); + let not_configured = Status::default(); + let public_only = Status::PublicOnly { + account: record.clone(), + }; + let ready = Status::Ready { + account: record.clone(), + }; + + assert!(not_configured.account().is_none()); + assert_eq!(public_only.account(), Some(&record)); + assert!(!public_only.is_ready()); + assert_eq!(ready.account(), Some(&record)); + assert!(ready.is_ready()); + } + + #[cfg(feature = "serde")] + #[test] + fn records_and_status_serde_revalidate_public_identity_and_timestamps() { + let record = Record::new(public_identity(ALICE), Some("primary".to_string()), 10); + let status = Status::Ready { + account: record.clone(), + }; + let encoded_record = serde_json::to_string(&record).expect("serialize record"); + let encoded_status = serde_json::to_string(&status).expect("serialize status"); + + assert_eq!( + serde_json::from_str::<Record>(&encoded_record).expect("deserialize record"), + record + ); + assert_eq!( + serde_json::from_str::<Status>(&encoded_status).expect("deserialize status"), + status + ); + assert!( + serde_json::from_str::<Record>(&encoded_record.replace( + &format!("\"account_id\":\"{ALICE}\""), + &format!("\"account_id\":\"{BOB}\""), + )) + .is_err() + ); + assert!( + serde_json::from_str::<Record>( + &encoded_record.replace("\"created_at_unix\":10", "\"created_at_unix\":11") + ) + .is_err() + ); + assert!(!encoded_record.contains("secret")); + assert!(!encoded_status.contains("signer")); + } +} diff --git a/crates/identity/src/error.rs b/crates/identity/src/error.rs @@ -1,74 +1,55 @@ use thiserror::Error; -#[cfg(not(feature = "std"))] -use alloc::string::String; +/// Errors produced while validating public identity values. +#[non_exhaustive] +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum Error { + #[error("identifier byte representation must contain {expected} bytes, but contained {actual}")] + InvalidByteLength { expected: usize, actual: usize }, -#[cfg(all(feature = "std", feature = "json-file"))] -use radroots_runtime::RuntimeJsonError; -#[cfg(feature = "std")] -use std::{io, path::PathBuf}; - -#[derive(Debug, Error)] -pub enum IdentityError { - #[cfg(feature = "std")] - #[error("identity file missing at {0}")] - NotFound(PathBuf), - - #[cfg(feature = "std")] #[error( - "identity file missing at {0} and generation is not permitted \ - (pass --allow-generate-identity)" + "identifier hexadecimal representation must contain {expected} bytes, but contained {actual}" )] - GenerationNotAllowed(PathBuf), - - #[cfg(feature = "std")] - #[error("failed to read identity file at {0}: {1}")] - Read(PathBuf, #[source] io::Error), - - #[cfg(feature = "std")] - #[error("failed to create identity directory {0}: {1}")] - CreateDir(PathBuf, #[source] io::Error), - - #[cfg(feature = "std")] - #[error("failed to write identity file at {0}: {1}")] - Write(PathBuf, #[source] io::Error), - - #[error("invalid identity JSON: {0}")] - InvalidJson(#[from] serde_json::Error), - - #[error("invalid secret key: {0}")] - InvalidSecretKey(#[from] nostr::key::Error), + InvalidHexLength { expected: usize, actual: usize }, - #[cfg(feature = "nip49")] - #[error("failed to encrypt secret key: {0}")] - EncryptSecretKey(String), + #[error("identifier contains non-hexadecimal data at byte {index}")] + InvalidHexCharacter { index: usize }, - #[cfg(feature = "nip49")] - #[error("invalid encrypted secret key: {0}")] - InvalidEncryptedSecretKey(String), + #[error("public key bytes are not a valid secp256k1 x-only public key")] + InvalidPublicKeyBytes, - #[cfg(feature = "nip49")] - #[error("failed to decrypt encrypted secret key: {0}")] - DecryptEncryptedSecretKey(String), + #[error("public identity identifier does not match its public key")] + IdentityIdMismatch, - #[error("invalid public key: {0}")] - InvalidPublicKey(String), + #[error("account identifier does not match its public identity")] + AccountIdMismatch, - #[error("public key does not match secret key")] - PublicKeyMismatch, - - #[error("unsupported identity file format")] - InvalidIdentityFormat, - - #[cfg(all(feature = "std", feature = "json-file"))] - #[error(transparent)] - Store(#[from] RuntimeJsonError), - - #[cfg(feature = "std")] - #[error(transparent)] - Paths(#[from] radroots_runtime_paths::RadrootsRuntimePathsError), + #[error( + "account updated timestamp {updated_at_unix} precedes created timestamp {created_at_unix}" + )] + AccountUpdatedBeforeCreated { + created_at_unix: u64, + updated_at_unix: u64, + }, - #[cfg(feature = "std")] - #[error("protected identity storage error at {path}: {message}")] - ProtectedStorage { path: PathBuf, message: String }, + #[error( + "account update timestamp {proposed_updated_at_unix} precedes current timestamp {current_updated_at_unix}" + )] + AccountUpdateRegressed { + current_updated_at_unix: u64, + proposed_updated_at_unix: u64, + }, + + #[error("username length must be between {min} and {max} ASCII bytes, but was {actual}")] + InvalidUsernameLength { + min: usize, + max: usize, + actual: usize, + }, + + #[error("username contains an invalid character at byte {index}")] + InvalidUsernameCharacter { index: usize }, + + #[error("username dots cannot be leading, trailing, or consecutive")] + InvalidUsernameDotPlacement, } diff --git a/crates/identity/src/identity.rs b/crates/identity/src/identity.rs @@ -1,552 +0,0 @@ -use crate::error::IdentityError; -use core::convert::Infallible; -use core::fmt; -use nostr::{Keys, SecretKey}; -#[cfg(feature = "nip49")] -use nostr::{ - nips::nip19::{FromBech32, ToBech32}, - nips::nip49::{EncryptedSecretKey, KeySecurity}, -}; -use serde::{Deserialize, Serialize}; - -#[cfg(not(feature = "std"))] -use alloc::string::String; -#[cfg(all(feature = "std", feature = "json-file"))] -use radroots_runtime::JsonFile; -#[cfg(feature = "std")] -use radroots_runtime_paths::{ - RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, default_shared_identity_path, -}; -#[cfg(feature = "std")] -use std::{ - fs, - path::{Path, PathBuf}, -}; - -pub const DEFAULT_IDENTITY_PATH: &str = "default.json"; - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct RadrootsIdentityId(String); - -#[derive(Debug, Clone)] -pub struct RadrootsIdentity { - keys: Keys, - profile: Option<RadrootsIdentityProfile>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RadrootsIdentityPublic { - pub id: RadrootsIdentityId, - pub public_key_hex: String, - pub public_key_npub: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile: Option<RadrootsIdentityProfile>, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RadrootsIdentityProfile { - #[serde(skip_serializing_if = "Option::is_none")] - pub identifier: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option<nostr::Event>, - #[serde(skip_serializing_if = "Option::is_none")] - pub application_handler: Option<nostr::Event>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RadrootsIdentityFile { - pub secret_key: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub public_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub identifier: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option<nostr::Event>, - #[serde(skip_serializing_if = "Option::is_none")] - pub application_handler: Option<nostr::Event>, -} - -#[derive(Debug, Clone, Copy)] -pub enum RadrootsIdentitySecretKeyFormat { - Hex, - Nsec, -} - -#[cfg(feature = "nip49")] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum RadrootsIdentityEncryptedSecretKeySecurity { - Weak, - Medium, - #[default] - Unknown, -} - -#[cfg(feature = "nip49")] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RadrootsIdentityEncryptedSecretKeyOptions { - pub log_n: u8, - pub key_security: RadrootsIdentityEncryptedSecretKeySecurity, -} - -#[cfg(feature = "nip49")] -impl Default for RadrootsIdentityEncryptedSecretKeyOptions { - fn default() -> Self { - Self { - log_n: 16, - key_security: RadrootsIdentityEncryptedSecretKeySecurity::Unknown, - } - } -} - -#[cfg(feature = "nip49")] -impl From<RadrootsIdentityEncryptedSecretKeySecurity> for KeySecurity { - fn from(value: RadrootsIdentityEncryptedSecretKeySecurity) -> Self { - match value { - RadrootsIdentityEncryptedSecretKeySecurity::Weak => Self::Weak, - RadrootsIdentityEncryptedSecretKeySecurity::Medium => Self::Medium, - RadrootsIdentityEncryptedSecretKeySecurity::Unknown => Self::Unknown, - } - } -} - -impl RadrootsIdentityId { - pub fn from_public_key(public_key: nostr::PublicKey) -> Self { - Self(public_key.to_hex()) - } - - pub fn parse(value: &str) -> Result<Self, IdentityError> { - let public_key = parse_public_key(value)?; - Ok(Self::from_public_key(public_key)) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } - - pub fn into_string(self) -> String { - self.0 - } -} - -impl From<nostr::PublicKey> for RadrootsIdentityId { - fn from(value: nostr::PublicKey) -> Self { - Self::from_public_key(value) - } -} - -impl TryFrom<&str> for RadrootsIdentityId { - type Error = IdentityError; - - fn try_from(value: &str) -> Result<Self, Self::Error> { - Self::parse(value) - } -} - -impl AsRef<str> for RadrootsIdentityId { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -impl fmt::Display for RadrootsIdentityId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.0.as_str()) - } -} - -impl RadrootsIdentityPublic { - pub fn new(public_key: nostr::PublicKey) -> Self { - let id = RadrootsIdentityId::from_public_key(public_key); - use nostr::nips::nip19::ToBech32; - let public_key_npub = infallible_to_string(public_key.to_bech32()); - Self { - id, - public_key_hex: public_key.to_hex(), - public_key_npub, - profile: None, - } - } - - pub fn with_profile(mut self, profile: RadrootsIdentityProfile) -> Self { - self.profile = if profile.is_empty() { - None - } else { - Some(profile) - }; - self - } -} - -impl RadrootsIdentityProfile { - pub fn is_empty(&self) -> bool { - self.identifier.is_none() && self.metadata.is_none() && self.application_handler.is_none() - } -} - -impl RadrootsIdentity { - pub fn new(keys: Keys) -> Self { - Self { - keys, - profile: None, - } - } - - pub fn with_profile(keys: Keys, profile: RadrootsIdentityProfile) -> Self { - let profile = if profile.is_empty() { - None - } else { - Some(profile) - }; - Self { keys, profile } - } - - #[cfg(feature = "std")] - pub fn generate() -> Self { - Self::new(Keys::generate()) - } - - #[cfg(feature = "std")] - pub fn generate_with_profile(profile: RadrootsIdentityProfile) -> Self { - Self::with_profile(Keys::generate(), profile) - } - - pub fn keys(&self) -> &Keys { - &self.keys - } - - pub fn into_keys(self) -> Keys { - self.keys - } - - pub fn public_key(&self) -> nostr::PublicKey { - self.keys.public_key() - } - - pub fn id(&self) -> RadrootsIdentityId { - RadrootsIdentityId::from_public_key(self.keys.public_key()) - } - - pub fn public_key_hex(&self) -> String { - self.keys.public_key().to_hex() - } - - pub fn public_key_npub(&self) -> String { - use nostr::nips::nip19::ToBech32; - infallible_to_string(self.keys.public_key().to_bech32()) - } - - pub fn npub(&self) -> String { - self.public_key_npub() - } - - pub fn secret_key_hex(&self) -> String { - self.keys.secret_key().to_secret_hex() - } - - pub fn secret_key_nsec(&self) -> String { - use nostr::nips::nip19::ToBech32; - infallible_to_string(self.keys.secret_key().to_bech32()) - } - - pub fn nsec(&self) -> String { - self.secret_key_nsec() - } - - #[cfg(feature = "nip49")] - /// Export the current secret key as a NIP-49 `ncryptsec` payload. - /// - /// This is an explicit operator-facing import or export format, not the - /// canonical local file-storage contract for Radroots runtimes. - pub fn encrypt_secret_key_ncryptsec(&self, password: &str) -> Result<String, IdentityError> { - self.encrypt_secret_key_ncryptsec_with_options( - password, - RadrootsIdentityEncryptedSecretKeyOptions::default(), - ) - } - - #[cfg(feature = "nip49")] - /// Export the current secret key as a NIP-49 `ncryptsec` payload with - /// explicit encryption options. - /// - /// This remains scoped to import or export behavior and must not become the - /// generic local secret-storage format. - pub fn encrypt_secret_key_ncryptsec_with_options( - &self, - password: &str, - options: RadrootsIdentityEncryptedSecretKeyOptions, - ) -> Result<String, IdentityError> { - let encrypted = EncryptedSecretKey::new( - self.keys.secret_key(), - password, - options.log_n, - options.key_security.into(), - ) - .map_err(|source| IdentityError::EncryptSecretKey(source.to_string()))?; - // The ncryptsec HRP and payload shape are fixed here, so encoding should not fail. - Ok(encrypted - .to_bech32() - .expect("ncryptsec bech32 encoding should succeed")) - } - - pub fn secret_key_bytes(&self) -> [u8; SecretKey::LEN] { - self.keys.secret_key().to_secret_bytes() - } - - #[cfg(feature = "secrecy")] - pub fn secret_key_hex_secret(&self) -> secrecy::SecretString { - use secrecy::SecretString; - SecretString::new(self.secret_key_hex().into()) - } - - #[cfg(feature = "zeroize")] - pub fn secret_key_bytes_zeroizing(&self) -> zeroize::Zeroizing<[u8; SecretKey::LEN]> { - zeroize::Zeroizing::new(self.secret_key_bytes()) - } - - pub fn profile(&self) -> Option<&RadrootsIdentityProfile> { - self.profile.as_ref() - } - - pub fn profile_mut(&mut self) -> Option<&mut RadrootsIdentityProfile> { - self.profile.as_mut() - } - - pub fn set_profile(&mut self, profile: RadrootsIdentityProfile) { - self.profile = if profile.is_empty() { - None - } else { - Some(profile) - }; - } - - pub fn clear_profile(&mut self) { - self.profile = None; - } - - pub fn to_public(&self) -> RadrootsIdentityPublic { - let mut public = RadrootsIdentityPublic::new(self.keys.public_key()); - if let Some(profile) = &self.profile { - public.profile = Some(profile.clone()); - } - public - } - - pub fn to_file(&self) -> RadrootsIdentityFile { - self.to_file_with_secret_format(RadrootsIdentitySecretKeyFormat::Hex) - } - - pub fn to_file_with_secret_format( - &self, - format: RadrootsIdentitySecretKeyFormat, - ) -> RadrootsIdentityFile { - let secret_key = match format { - RadrootsIdentitySecretKeyFormat::Hex => self.secret_key_hex(), - RadrootsIdentitySecretKeyFormat::Nsec => self.secret_key_nsec(), - }; - let (identifier, metadata, application_handler) = match &self.profile { - Some(profile) => ( - profile.identifier.clone(), - profile.metadata.clone(), - profile.application_handler.clone(), - ), - None => (None, None, None), - }; - RadrootsIdentityFile { - secret_key, - public_key: Some(self.public_key_hex()), - identifier, - metadata, - application_handler, - } - } - - #[cfg(feature = "std")] - pub fn from_file(file: RadrootsIdentityFile) -> Result<Self, IdentityError> { - Self::try_from(file) - } - - #[cfg(feature = "std")] - pub fn from_secret_key_str(secret_key: &str) -> Result<Self, IdentityError> { - Ok(Self::new(Keys::parse(secret_key)?)) - } - - #[cfg(feature = "nip49")] - /// Import a secret key from a NIP-49 `ncryptsec` payload. - /// - /// This path is explicit by design so encrypted exports do not become an - /// ambient local file-storage format. - pub fn from_encrypted_secret_key_str( - secret_key: &str, - password: &str, - ) -> Result<Self, IdentityError> { - let encrypted = EncryptedSecretKey::from_bech32(secret_key) - .map_err(|source| IdentityError::InvalidEncryptedSecretKey(source.to_string()))?; - let secret_key = encrypted - .decrypt(password) - .map_err(|source| IdentityError::DecryptEncryptedSecretKey(source.to_string()))?; - Ok(Self::new(Keys::new(secret_key))) - } - - #[cfg(feature = "std")] - pub fn from_secret_key_bytes(secret_key: &[u8]) -> Result<Self, IdentityError> { - if secret_key.len() != SecretKey::LEN { - return Err(IdentityError::InvalidIdentityFormat); - } - let secret_key = SecretKey::from_slice(secret_key)?; - Ok(Self::new(Keys::new(secret_key))) - } - - #[cfg(feature = "std")] - pub fn load_from_path_auto(path: impl AsRef<Path>) -> Result<Self, IdentityError> { - let path = path.as_ref(); - let bytes = read_identity_bytes(path)?; - parse_identity_bytes(&bytes) - } - - #[cfg(feature = "std")] - pub fn default_path() -> Result<PathBuf, IdentityError> { - Self::default_path_for( - &RadrootsPathResolver::current(), - RadrootsPathProfile::InteractiveUser, - &RadrootsPathOverrides::default(), - ) - } - - #[cfg(feature = "std")] - pub fn default_path_for( - resolver: &RadrootsPathResolver, - profile: RadrootsPathProfile, - overrides: &RadrootsPathOverrides, - ) -> Result<PathBuf, IdentityError> { - Ok(default_shared_identity_path(resolver, profile, overrides)?) - } - - #[cfg(all(feature = "std", feature = "json-file"))] - fn resolve_load_or_generate_path<P: AsRef<Path>>( - path: Option<P>, - ) -> Result<PathBuf, IdentityError> { - path.map(|p| p.as_ref().to_path_buf()) - .map(Ok) - .unwrap_or_else(Self::default_path) - } - - #[cfg(all(feature = "std", feature = "json-file"))] - fn load_or_generate_at( - path: Result<PathBuf, IdentityError>, - allow_generate: bool, - ) -> Result<Self, IdentityError> { - let path = path?; - if path.exists() { - return Self::load_from_path_auto(&path); - } - if !allow_generate { - return Err(IdentityError::GenerationNotAllowed(path)); - } - let identity = Self::generate(); - identity.save_json(&path)?; - Ok(identity) - } - - #[cfg(all(feature = "std", feature = "json-file"))] - pub fn load_or_generate<P: AsRef<Path>>( - path: Option<P>, - allow_generate: bool, - ) -> Result<Self, IdentityError> { - Self::load_or_generate_at(Self::resolve_load_or_generate_path(path), allow_generate) - } - - #[cfg(all(feature = "std", feature = "json-file"))] - pub fn save_json(&self, path: impl AsRef<Path>) -> Result<(), IdentityError> { - let payload = self.to_file(); - let mut store = JsonFile::load_or_create_with(path.as_ref(), || payload.clone())?; - store.value = payload; - store.save()?; - Ok(()) - } -} - -#[cfg(feature = "std")] -impl TryFrom<RadrootsIdentityFile> for RadrootsIdentity { - type Error = IdentityError; - - fn try_from(file: RadrootsIdentityFile) -> Result<Self, Self::Error> { - let keys = Keys::parse(&file.secret_key)?; - validate_public_key(&keys, file.public_key.as_deref())?; - let profile = RadrootsIdentityProfile { - identifier: file.identifier, - metadata: file.metadata, - application_handler: file.application_handler, - }; - if profile.is_empty() { - Ok(Self::new(keys)) - } else { - Ok(Self::with_profile(keys, profile)) - } - } -} - -impl From<Keys> for RadrootsIdentity { - fn from(keys: Keys) -> Self { - Self::new(keys) - } -} - -#[cfg(feature = "std")] -fn read_identity_bytes(path: &Path) -> Result<Vec<u8>, IdentityError> { - match fs::read(path) { - Ok(bytes) => Ok(bytes), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - Err(IdentityError::NotFound(path.to_path_buf())) - } - Err(err) => Err(IdentityError::Read(path.to_path_buf(), err)), - } -} - -#[cfg(feature = "std")] -fn parse_identity_bytes(bytes: &[u8]) -> Result<RadrootsIdentity, IdentityError> { - if bytes.len() == SecretKey::LEN { - return RadrootsIdentity::from_secret_key_bytes(bytes); - } - - let text = std::str::from_utf8(bytes).map_err(|_| IdentityError::InvalidIdentityFormat)?; - let trimmed = text.trim(); - if trimmed.is_empty() { - return Err(IdentityError::InvalidIdentityFormat); - } - if trimmed.starts_with('{') { - let file: RadrootsIdentityFile = serde_json::from_str(trimmed)?; - return RadrootsIdentity::from_file(file); - } - RadrootsIdentity::from_secret_key_str(trimmed) -} - -fn validate_public_key(keys: &Keys, public_key: Option<&str>) -> Result<(), IdentityError> { - let Some(public_key) = public_key else { - return Ok(()); - }; - let parsed = parse_public_key(public_key)?; - if parsed != keys.public_key() { - return Err(IdentityError::PublicKeyMismatch); - } - Ok(()) -} - -fn parse_public_key(value: &str) -> Result<nostr::PublicKey, IdentityError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(IdentityError::InvalidPublicKey(value.to_string())); - } - nostr::PublicKey::parse(trimmed) - .or_else(|_| nostr::PublicKey::from_hex(trimmed)) - .map_err(|_| IdentityError::InvalidPublicKey(value.to_string())) -} - -fn infallible_to_string(value: Result<String, Infallible>) -> String { - match value { - Ok(value) => value, - Err(err) => match err {}, - } -} diff --git a/crates/identity/src/key.rs b/crates/identity/src/key.rs @@ -0,0 +1,369 @@ +//! Canonical public-key and identity identifier value types. +//! +//! [`PublicKey`] validates a 32-byte x-only secp256k1 public key. [`IdentityId`] +//! is a distinct Rust type derived from the same canonical bytes. Both values +//! parse exact-width hexadecimal text, preserve fixed-width binary form, and +//! emit lowercase hexadecimal text without exposing secret-key or Nostr +//! bech32 behavior. + +use crate::Error; + +pub(crate) const IDENTIFIER_BYTE_LENGTH: usize = 32; +pub(crate) const IDENTIFIER_HEX_LENGTH: usize = IDENTIFIER_BYTE_LENGTH * 2; + +const HEX_ALPHABET: &[u8; 16] = b"0123456789abcdef"; + +pub(crate) struct EncodedHex([u8; IDENTIFIER_HEX_LENGTH]); + +impl EncodedHex { + pub(crate) fn new(bytes: &[u8; IDENTIFIER_BYTE_LENGTH]) -> Self { + let mut encoded = [0; IDENTIFIER_HEX_LENGTH]; + for (index, byte) in bytes.iter().copied().enumerate() { + encoded[index * 2] = HEX_ALPHABET[usize::from(byte >> 4)]; + encoded[index * 2 + 1] = HEX_ALPHABET[usize::from(byte & 0x0f)]; + } + Self(encoded) + } + + pub(crate) fn as_str(&self) -> &str { + core::str::from_utf8(&self.0).expect("the hexadecimal alphabet is valid UTF-8") + } +} + +fn decode_nibble(byte: u8, index: usize) -> Result<u8, Error> { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => Err(Error::InvalidHexCharacter { index }), + } +} + +pub(crate) fn parse_hex(value: &str) -> Result<[u8; IDENTIFIER_BYTE_LENGTH], Error> { + let encoded = value.as_bytes(); + if encoded.len() != IDENTIFIER_HEX_LENGTH { + return Err(Error::InvalidHexLength { + expected: IDENTIFIER_HEX_LENGTH, + actual: encoded.len(), + }); + } + + let mut bytes = [0; IDENTIFIER_BYTE_LENGTH]; + for (index, output) in bytes.iter_mut().enumerate() { + let high_index = index * 2; + let high = decode_nibble(encoded[high_index], high_index)?; + let low = decode_nibble(encoded[high_index + 1], high_index + 1)?; + *output = (high << 4) | low; + } + Ok(bytes) +} + +pub(crate) fn validate_public_key_bytes(bytes: &[u8; IDENTIFIER_BYTE_LENGTH]) -> Result<(), Error> { + let mut compressed = [0; IDENTIFIER_BYTE_LENGTH + 1]; + compressed[0] = 0x02; + compressed[1..].copy_from_slice(bytes); + k256::PublicKey::from_sec1_bytes(&compressed) + .map(|_| ()) + .map_err(|_| Error::InvalidPublicKeyBytes) +} + +macro_rules! define_identifier { + ($(#[$meta:meta])* $visibility:vis struct $name:ident;) => { + $(#[$meta])* + #[repr(transparent)] + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + $visibility struct $name([u8; $crate::key::IDENTIFIER_BYTE_LENGTH]); + + impl $name { + /// The canonical binary representation length. + pub const BYTE_LENGTH: usize = $crate::key::IDENTIFIER_BYTE_LENGTH; + + /// The canonical hexadecimal representation length. + pub const HEX_LENGTH: usize = $crate::key::IDENTIFIER_HEX_LENGTH; + + /// Constructs the value from its canonical fixed-width bytes. + pub fn from_bytes(bytes: [u8; Self::BYTE_LENGTH]) -> Result<Self, $crate::Error> { + $crate::key::validate_public_key_bytes(&bytes)?; + Ok(Self::from_validated_bytes(bytes)) + } + + pub(crate) const fn from_validated_bytes( + bytes: [u8; Self::BYTE_LENGTH], + ) -> Self { + Self(bytes) + } + + /// Parses an exact-width byte slice. + pub fn from_slice(bytes: &[u8]) -> Result<Self, $crate::Error> { + let bytes: [u8; Self::BYTE_LENGTH] = bytes.try_into().map_err(|_| { + $crate::Error::InvalidByteLength { + expected: Self::BYTE_LENGTH, + actual: bytes.len(), + } + })?; + Self::from_bytes(bytes) + } + + /// Parses a 64-character hexadecimal representation. + pub fn from_hex(value: &str) -> Result<Self, $crate::Error> { + Self::from_bytes($crate::key::parse_hex(value)?) + } + + /// Borrows the canonical fixed-width bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; Self::BYTE_LENGTH] { + &self.0 + } + + /// Returns the canonical fixed-width bytes. + #[must_use] + pub const fn into_bytes(self) -> [u8; Self::BYTE_LENGTH] { + self.0 + } + + /// Encodes the value as canonical lowercase hexadecimal text. + #[must_use] + pub fn to_hex(self) -> alloc::string::String { + alloc::string::String::from( + $crate::key::EncodedHex::new(&self.0).as_str(), + ) + } + } + + impl core::fmt::Debug for $name { + fn fmt( + &self, + formatter: &mut core::fmt::Formatter<'_>, + ) -> core::fmt::Result { + write!(formatter, "{}(\"{}\")", stringify!($name), self) + } + } + + impl core::fmt::Display for $name { + fn fmt( + &self, + formatter: &mut core::fmt::Formatter<'_>, + ) -> core::fmt::Result { + formatter.write_str($crate::key::EncodedHex::new(&self.0).as_str()) + } + } + + impl core::str::FromStr for $name { + type Err = $crate::Error; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::from_hex(value) + } + } + + impl TryFrom<&str> for $name { + type Error = $crate::Error; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + Self::from_hex(value) + } + } + + impl TryFrom<alloc::string::String> for $name { + type Error = $crate::Error; + + fn try_from(value: alloc::string::String) -> Result<Self, Self::Error> { + Self::from_hex(&value) + } + } + + impl TryFrom<&[u8]> for $name { + type Error = $crate::Error; + + fn try_from(value: &[u8]) -> Result<Self, Self::Error> { + Self::from_slice(value) + } + } + + impl TryFrom<[u8; $crate::key::IDENTIFIER_BYTE_LENGTH]> for $name { + type Error = $crate::Error; + + fn try_from( + value: [u8; $crate::key::IDENTIFIER_BYTE_LENGTH], + ) -> Result<Self, Self::Error> { + Self::from_bytes(value) + } + } + + impl AsRef<[u8]> for $name { + fn as_ref(&self) -> &[u8] { + self.as_bytes() + } + } + + #[cfg(feature = "serde")] + impl serde::Serialize for $name { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str($crate::key::EncodedHex::new(&self.0).as_str()) + } + } + + #[cfg(feature = "serde")] + impl<'de> serde::Deserialize<'de> for $name { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + struct IdentifierVisitor; + + impl serde::de::Visitor<'_> for IdentifierVisitor { + type Value = $name; + + fn expecting( + &self, + formatter: &mut core::fmt::Formatter<'_>, + ) -> core::fmt::Result { + formatter.write_str(concat!( + "a 64-character hexadecimal ", + stringify!($name) + )) + } + + fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> + where + E: serde::de::Error, + { + $name::from_hex(value).map_err(E::custom) + } + } + + deserializer.deserialize_str(IdentifierVisitor) + } + } + }; +} + +pub(crate) use define_identifier; + +define_identifier! { + /// A canonical 32-byte public key. + /// + /// The key is an explicit byte value and intentionally does not dereference + /// to text: + /// + /// ```compile_fail + /// use radroots_identity::PublicKey; + /// + /// fn accepts_text(_: &str) {} + /// let key = PublicKey::from_hex( + /// "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + /// ).unwrap(); + /// accepts_text(&key); + /// ``` + pub struct PublicKey; +} + +define_identifier! { + /// A canonical public identity identifier. + pub struct IdentityId; +} + +impl IdentityId { + /// Derives the identity identifier from its canonical public key. + #[must_use] + pub const fn from_public_key(public_key: PublicKey) -> Self { + Self::from_validated_bytes(public_key.into_bytes()) + } +} + +impl From<PublicKey> for IdentityId { + fn from(value: PublicKey) -> Self { + Self::from_public_key(value) + } +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "serde")] + use alloc::format; + use alloc::string::{String, ToString}; + use core::str::FromStr; + + use super::*; + + const ALICE: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + const BOB: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + + #[test] + fn public_keys_validate_and_canonicalize_hex() { + let uppercase = ALICE.to_ascii_uppercase(); + let public_key = PublicKey::from_hex(&uppercase).expect("valid fixture public key"); + + assert_eq!(public_key.to_hex(), ALICE); + assert_eq!(public_key.to_string(), ALICE); + assert_eq!(PublicKey::from_str(ALICE).unwrap(), public_key); + assert_eq!(PublicKey::try_from(ALICE).unwrap(), public_key); + } + + #[test] + fn public_keys_reject_invalid_encodings_and_curve_points() { + assert!(matches!( + PublicKey::from_hex("00"), + Err(Error::InvalidHexLength { + expected: PublicKey::HEX_LENGTH, + actual: 2, + }) + )); + + let mut invalid_hex = ALICE.as_bytes().to_vec(); + invalid_hex[17] = b'g'; + let invalid_hex = String::from_utf8(invalid_hex).expect("ASCII test input"); + assert!(matches!( + PublicKey::from_hex(&invalid_hex), + Err(Error::InvalidHexCharacter { index: 17 }) + )); + assert!(matches!( + PublicKey::from_bytes([0; PublicKey::BYTE_LENGTH]), + Err(Error::InvalidPublicKeyBytes) + )); + assert!(matches!( + PublicKey::from_slice(&[0; PublicKey::BYTE_LENGTH - 1]), + Err(Error::InvalidByteLength { + expected: PublicKey::BYTE_LENGTH, + actual, + }) if actual == PublicKey::BYTE_LENGTH - 1 + )); + } + + #[test] + fn canonical_bytes_round_trip_and_order() { + let alice = PublicKey::from_hex(ALICE).expect("alice fixture"); + let bob = PublicKey::from_hex(BOB).expect("bob fixture"); + + assert_eq!(PublicKey::from_bytes(alice.into_bytes()).unwrap(), alice); + assert_eq!(PublicKey::try_from(alice.as_ref()).unwrap(), alice); + assert!(alice < bob); + } + + #[test] + fn identity_ids_are_distinct_key_derived_values() { + let public_key = PublicKey::from_hex(ALICE).expect("valid fixture public key"); + let identity_id = IdentityId::from(public_key); + + assert_eq!(identity_id.to_hex(), ALICE); + assert_eq!(identity_id.as_bytes(), public_key.as_bytes()); + assert_eq!(IdentityId::from_hex(ALICE).unwrap(), identity_id); + } + + #[cfg(feature = "serde")] + #[test] + fn key_values_serde_as_validated_canonical_hex() { + let public_key = PublicKey::from_hex(ALICE).expect("valid fixture public key"); + let encoded = serde_json::to_string(&public_key).expect("serialize public key"); + + assert_eq!(encoded, format!("\"{ALICE}\"")); + assert_eq!( + serde_json::from_str::<PublicKey>(&encoded).expect("deserialize public key"), + public_key + ); + assert!(serde_json::from_str::<IdentityId>("\"invalid\"").is_err()); + } +} diff --git a/crates/identity/src/lib.rs b/crates/identity/src/lib.rs @@ -1,34 +1,18 @@ +#![doc = include_str!("../README.md")] #![cfg_attr(not(feature = "std"), no_std)] #![cfg_attr(coverage_nightly, feature(coverage_attribute))] #![forbid(unsafe_code)] -#[cfg(not(feature = "std"))] extern crate alloc; -pub mod error; -pub mod identity; -#[cfg(feature = "std")] -pub mod storage; +pub mod account; +mod error; +pub mod key; +pub mod profile; pub mod username; -pub use error::IdentityError; -pub use identity::{ - DEFAULT_IDENTITY_PATH, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityId, - RadrootsIdentityProfile, RadrootsIdentityPublic, RadrootsIdentitySecretKeyFormat, -}; -#[cfg(feature = "nip49")] -pub use identity::{ - RadrootsIdentityEncryptedSecretKeyOptions, RadrootsIdentityEncryptedSecretKeySecurity, -}; -#[cfg(feature = "std")] -pub use storage::{ - RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, - RadrootsEncryptedIdentityFile, encrypted_identity_wrapping_key_path, load_encrypted_identity, - load_encrypted_identity_with_key_slot, load_identity_profile, rotate_encrypted_identity, - rotate_encrypted_identity_with_key_slot, store_encrypted_identity, - store_encrypted_identity_with_key_slot, store_identity_profile, -}; -pub use username::{ - RADROOTS_USERNAME_MAX_LEN, RADROOTS_USERNAME_MIN_LEN, RADROOTS_USERNAME_REGEX, - radroots_username_is_valid, radroots_username_normalize, -}; +pub use account::AccountId; +pub use error::Error; +pub use key::{IdentityId, PublicKey}; +pub use profile::{Profile, PublicIdentity}; +pub use username::Username; diff --git a/crates/identity/src/profile.rs b/crates/identity/src/profile.rs @@ -0,0 +1,207 @@ +//! Public, transport-neutral identity profiles. +//! +//! [`Profile`] contains optional public metadata only. [`PublicIdentity`] +//! couples a validated [`PublicKey`] to its derived [`IdentityId`] and rejects +//! mismatched separately decoded parts. Empty profiles are omitted so one +//! public identity has one canonical native representation. +//! +//! Profiles do not perform discovery, authorization, event verification, +//! persistence, or secret handling. + +use crate::{Error, IdentityId, PublicKey, Username}; + +/// Public identity metadata that is independent of any transport event. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct Profile { + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + username: Option<Username>, +} + +impl Profile { + /// Creates an empty public profile. + #[must_use] + pub const fn new() -> Self { + Self { username: None } + } + + /// Returns a profile with its canonical username set. + #[must_use] + pub fn with_username(mut self, username: Username) -> Self { + self.username = Some(username); + self + } + + /// Borrows the canonical username, when present. + #[must_use] + pub fn username(&self) -> Option<&Username> { + self.username.as_ref() + } + + /// Reports whether the profile contains no public metadata. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.username.is_none() + } +} + +/// A public identity with an invariant-matched identifier and public key. +/// +/// Secret-bearing identity containers are intentionally absent: +/// +/// ```compile_fail +/// use radroots_identity::RadrootsIdentity; +/// ``` +/// +/// Public identities expose no secret-key access: +/// +/// ```compile_fail +/// use radroots_identity::{PublicIdentity, PublicKey}; +/// +/// let key = PublicKey::from_hex( +/// "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", +/// ).unwrap(); +/// let identity = PublicIdentity::new(key); +/// let _ = identity.secret_key_bytes(); +/// ``` +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PublicIdentity { + id: IdentityId, + public_key: PublicKey, + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + profile: Option<Profile>, +} + +impl PublicIdentity { + /// Creates a public identity whose identifier is derived from its key. + #[must_use] + pub const fn new(public_key: PublicKey) -> Self { + Self { + id: IdentityId::from_public_key(public_key), + public_key, + profile: None, + } + } + + /// Validates an identity assembled from separately decoded parts. + pub fn try_from_parts( + id: IdentityId, + public_key: PublicKey, + profile: Option<Profile>, + ) -> Result<Self, Error> { + if id != IdentityId::from_public_key(public_key) { + return Err(Error::IdentityIdMismatch); + } + Ok(Self { + id, + public_key, + profile: profile.filter(|value| !value.is_empty()), + }) + } + + /// Returns a public identity with non-empty profile metadata attached. + #[must_use] + pub fn with_profile(mut self, profile: Profile) -> Self { + self.profile = (!profile.is_empty()).then_some(profile); + self + } + + /// Returns the canonical identity identifier. + #[must_use] + pub const fn id(&self) -> IdentityId { + self.id + } + + /// Returns the canonical public key. + #[must_use] + pub const fn public_key(&self) -> PublicKey { + self.public_key + } + + /// Borrows public profile metadata, when present. + #[must_use] + pub fn profile(&self) -> Option<&Profile> { + self.profile.as_ref() + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for PublicIdentity { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct PublicIdentityRepr { + id: IdentityId, + public_key: PublicKey, + #[serde(default)] + profile: Option<Profile>, + } + + let value = PublicIdentityRepr::deserialize(deserializer)?; + Self::try_from_parts(value.id, value.public_key, value.profile) + .map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALICE: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + const BOB: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + + #[test] + fn public_identity_derives_and_protects_its_identifier() { + let public_key = PublicKey::from_hex(ALICE).unwrap(); + let identity = PublicIdentity::new(public_key); + + assert_eq!(identity.id(), IdentityId::from(public_key)); + assert_eq!(identity.public_key(), public_key); + assert!(identity.profile().is_none()); + + let mismatched_id = IdentityId::from_hex(BOB).unwrap(); + assert!(matches!( + PublicIdentity::try_from_parts(mismatched_id, public_key, None), + Err(Error::IdentityIdMismatch) + )); + } + + #[test] + fn public_identity_discards_empty_profiles_and_retains_usernames() { + let public_key = PublicKey::from_hex(ALICE).unwrap(); + let empty = PublicIdentity::new(public_key).with_profile(Profile::new()); + assert!(empty.profile().is_none()); + + let username = Username::parse("Alice.Farm").unwrap(); + let identity = PublicIdentity::new(public_key) + .with_profile(Profile::new().with_username(username.clone())); + assert_eq!( + identity.profile().and_then(Profile::username), + Some(&username) + ); + } + + #[cfg(feature = "serde")] + #[test] + fn public_identity_serde_revalidates_key_identity_and_profile() { + let public_key = PublicKey::from_hex(ALICE).unwrap(); + let identity = PublicIdentity::new(public_key) + .with_profile(Profile::new().with_username(Username::parse("Alice.Farm").unwrap())); + let encoded = serde_json::to_string(&identity).unwrap(); + assert_eq!( + serde_json::from_str::<PublicIdentity>(&encoded).unwrap(), + identity + ); + + let mismatched = encoded.replace(ALICE, BOB).replacen(BOB, ALICE, 1); + assert!(serde_json::from_str::<PublicIdentity>(&mismatched).is_err()); + assert!(!encoded.contains("metadata")); + assert!(!encoded.contains("application_handler")); + } +} diff --git a/crates/identity/src/storage.rs b/crates/identity/src/storage.rs @@ -1,663 +0,0 @@ -use std::borrow::Cow; -use std::fs; -use std::path::{Path, PathBuf}; - -use radroots_protected_store::{ - RadrootsProtectedFileKeySource, RadrootsProtectedStoreEnvelope, sidecar_path, -}; -use radroots_secret_vault::RadrootsSecretVaultAccessError; - -use crate::{IdentityError, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityPublic}; - -pub const RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT: &str = "radroots_identity"; -pub const RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX: &str = ".key"; - -#[derive(Debug, Clone)] -pub struct RadrootsEncryptedIdentityFile { - path: PathBuf, - key_slot: Cow<'static, str>, -} - -impl RadrootsEncryptedIdentityFile { - #[must_use] - pub fn new(path: impl AsRef<Path>) -> Self { - Self::new_path(path.as_ref()) - } - - #[must_use] - fn new_path(path: &Path) -> Self { - Self::with_key_slot_path(path, RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT) - } - - #[must_use] - pub fn with_key_slot(path: impl AsRef<Path>, key_slot: impl Into<Cow<'static, str>>) -> Self { - Self::with_key_slot_path(path.as_ref(), key_slot) - } - - #[must_use] - fn with_key_slot_path(path: &Path, key_slot: impl Into<Cow<'static, str>>) -> Self { - Self { - path: path.to_path_buf(), - key_slot: key_slot.into(), - } - } - - #[must_use] - pub fn path(&self) -> &Path { - self.path.as_path() - } - - #[must_use] - pub fn key_slot(&self) -> &str { - self.key_slot.as_ref() - } - - #[must_use] - pub fn wrapping_key_path(&self) -> PathBuf { - encrypted_identity_wrapping_key_path(&self.path) - } - - pub fn store(&self, identity: &RadrootsIdentity) -> Result<(), IdentityError> { - if let Some(parent) = self.path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent) - .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?; - } - - let payload = identity_file_payload(identity); - let key_source = RadrootsProtectedFileKeySource::from_sidecar_suffix( - &self.path, - RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, - ); - let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key( - &key_source, - self.key_slot(), - &payload, - ) - .map_err(|error| { - protected_storage_message(&self.path, "seal encrypted identity", &error) - })?; - let encoded = encode_encrypted_identity(&envelope); - fs::write(&self.path, encoded) - .map_err(|source| IdentityError::Write(self.path.clone(), source))?; - apply_secret_permissions(&self.path)?; - Ok(()) - } - - pub fn load(&self) -> Result<RadrootsIdentity, IdentityError> { - let encoded = fs::read(&self.path).map_err(|source| { - if source.kind() == std::io::ErrorKind::NotFound { - IdentityError::NotFound(self.path.clone()) - } else { - IdentityError::Read(self.path.clone(), source) - } - })?; - let key_source = RadrootsProtectedFileKeySource::from_sidecar_suffix( - &self.path, - RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, - ); - let envelope = RadrootsProtectedStoreEnvelope::decode_json(&encoded).map_err(|error| { - protected_storage_message(&self.path, "decode encrypted identity", &error) - })?; - let plaintext = envelope - .open_with_wrapped_key(&key_source) - .map_err(|error| { - protected_storage_message(&self.path, "open encrypted identity", &error) - })?; - let file: RadrootsIdentityFile = serde_json::from_slice(&plaintext)?; - RadrootsIdentity::try_from(file) - } - - pub fn rotate(&self) -> Result<(), IdentityError> { - let identity = self.load()?; - let backup = self.rotation_backup()?; - - if let Err(error) = self.store(&identity) { - let _ = fs::write(&self.path, &backup.envelope); - let _ = set_secret_permissions(&self.path); - let _ = fs::write(&backup.key_path, &backup.key); - let _ = set_secret_permissions(&backup.key_path); - return Err(error); - } - - Ok(()) - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn rotation_backup(&self) -> Result<EncryptedIdentityRotationBackup, IdentityError> { - let envelope = fs::read(&self.path) - .map_err(|source| IdentityError::Read(self.path.clone(), source))?; - let key_path = self.wrapping_key_path(); - let key = - fs::read(&key_path).map_err(|source| IdentityError::Read(key_path.clone(), source))?; - - fs::remove_file(&key_path) - .map_err(|source| IdentityError::Write(key_path.clone(), source))?; - - Ok(EncryptedIdentityRotationBackup { - envelope, - key_path, - key, - }) - } -} - -struct EncryptedIdentityRotationBackup { - envelope: Vec<u8>, - key_path: PathBuf, - key: Vec<u8>, -} - -#[must_use] -pub fn encrypted_identity_wrapping_key_path(path: impl AsRef<Path>) -> PathBuf { - encrypted_identity_wrapping_key_path_ref(path.as_ref()) -} - -fn encrypted_identity_wrapping_key_path_ref(path: &Path) -> PathBuf { - sidecar_path(path, RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX) -} - -pub fn store_encrypted_identity( - path: impl AsRef<Path>, - identity: &RadrootsIdentity, -) -> Result<(), IdentityError> { - store_encrypted_identity_path(path.as_ref(), identity) -} - -fn store_encrypted_identity_path( - path: &Path, - identity: &RadrootsIdentity, -) -> Result<(), IdentityError> { - RadrootsEncryptedIdentityFile::new_path(path).store(identity) -} - -pub fn store_encrypted_identity_with_key_slot( - path: impl AsRef<Path>, - key_slot: impl Into<Cow<'static, str>>, - identity: &RadrootsIdentity, -) -> Result<(), IdentityError> { - store_encrypted_identity_with_key_slot_path(path.as_ref(), key_slot, identity) -} - -fn store_encrypted_identity_with_key_slot_path( - path: &Path, - key_slot: impl Into<Cow<'static, str>>, - identity: &RadrootsIdentity, -) -> Result<(), IdentityError> { - RadrootsEncryptedIdentityFile::with_key_slot_path(path, key_slot).store(identity) -} - -pub fn rotate_encrypted_identity(path: impl AsRef<Path>) -> Result<(), IdentityError> { - rotate_encrypted_identity_path(path.as_ref()) -} - -fn rotate_encrypted_identity_path(path: &Path) -> Result<(), IdentityError> { - RadrootsEncryptedIdentityFile::new_path(path).rotate() -} - -pub fn rotate_encrypted_identity_with_key_slot( - path: impl AsRef<Path>, - key_slot: impl Into<Cow<'static, str>>, -) -> Result<(), IdentityError> { - rotate_encrypted_identity_with_key_slot_path(path.as_ref(), key_slot) -} - -fn rotate_encrypted_identity_with_key_slot_path( - path: &Path, - key_slot: impl Into<Cow<'static, str>>, -) -> Result<(), IdentityError> { - RadrootsEncryptedIdentityFile::with_key_slot_path(path, key_slot).rotate() -} - -pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<RadrootsIdentity, IdentityError> { - load_encrypted_identity_path(path.as_ref()) -} - -fn load_encrypted_identity_path(path: &Path) -> Result<RadrootsIdentity, IdentityError> { - RadrootsEncryptedIdentityFile::new_path(path).load() -} - -pub fn load_encrypted_identity_with_key_slot( - path: impl AsRef<Path>, - key_slot: impl Into<Cow<'static, str>>, -) -> Result<RadrootsIdentity, IdentityError> { - load_encrypted_identity_with_key_slot_path(path.as_ref(), key_slot) -} - -fn load_encrypted_identity_with_key_slot_path( - path: &Path, - key_slot: impl Into<Cow<'static, str>>, -) -> Result<RadrootsIdentity, IdentityError> { - RadrootsEncryptedIdentityFile::with_key_slot_path(path, key_slot).load() -} - -pub fn store_identity_profile( - path: impl AsRef<Path>, - identity: &RadrootsIdentity, -) -> Result<(), IdentityError> { - store_identity_profile_path(path.as_ref(), identity) -} - -fn store_identity_profile_path( - path: &Path, - identity: &RadrootsIdentity, -) -> Result<(), IdentityError> { - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent) - .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?; - } - - let encoded = identity_profile_payload(identity); - fs::write(path, encoded).map_err(|source| IdentityError::Write(path.to_path_buf(), source))?; - apply_secret_permissions(path)?; - Ok(()) -} - -pub fn load_identity_profile( - path: impl AsRef<Path>, -) -> Result<RadrootsIdentityPublic, IdentityError> { - load_identity_profile_path(path.as_ref()) -} - -fn load_identity_profile_path(path: &Path) -> Result<RadrootsIdentityPublic, IdentityError> { - let encoded = match fs::read(path) { - Ok(encoded) => encoded, - Err(source) if source.kind() == std::io::ErrorKind::NotFound => { - return Err(IdentityError::NotFound(path.to_path_buf())); - } - Err(source) => return Err(IdentityError::Read(path.to_path_buf(), source)), - }; - if let Ok(public_identity) = serde_json::from_slice::<RadrootsIdentityPublic>(&encoded) { - return Ok(public_identity); - } - RadrootsIdentity::load_from_path_auto(path).map(|identity| identity.to_public()) -} - -fn identity_file_payload(identity: &RadrootsIdentity) -> Vec<u8> { - serde_json::to_vec(&identity.to_file()).expect("identity file serialization is infallible") -} - -fn identity_profile_payload(identity: &RadrootsIdentity) -> Vec<u8> { - serde_json::to_vec_pretty(&identity.to_public()) - .expect("identity profile serialization is infallible") -} - -fn encode_encrypted_identity(envelope: &RadrootsProtectedStoreEnvelope) -> Vec<u8> { - envelope - .encode_json() - .expect("protected-store envelope serialization is infallible") -} - -#[cfg_attr(coverage_nightly, coverage(off))] -fn apply_secret_permissions(path: &Path) -> Result<(), IdentityError> { - set_secret_permissions(path).map_err(|error| secret_permission_error(path, error)) -} - -fn protected_storage_message( - path: &Path, - action: &str, - message: &dyn core::fmt::Display, -) -> IdentityError { - IdentityError::ProtectedStorage { - path: path.to_path_buf(), - message: format!("failed to {action}: {message}"), - } -} - -fn secret_permission_error(path: &Path, error: RadrootsSecretVaultAccessError) -> IdentityError { - protected_storage_message(path, "update secret-file permissions", &error) -} - -#[cfg(unix)] -#[cfg_attr(coverage_nightly, coverage(off))] -fn set_secret_permissions(path: &Path) -> Result<(), RadrootsSecretVaultAccessError> { - use std::os::unix::fs::PermissionsExt; - - let permissions = std::fs::Permissions::from_mode(0o600); - fs::set_permissions(path, permissions) - .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) -} - -#[cfg(not(unix))] -#[cfg_attr(coverage_nightly, coverage(off))] -fn set_secret_permissions(_path: &Path) -> Result<(), RadrootsSecretVaultAccessError> { - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn encrypted_identity_round_trips() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("identity.enc.json"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - store_encrypted_identity(&path, &identity).expect("store encrypted identity"); - - let loaded = load_encrypted_identity(&path).expect("load encrypted identity"); - assert_eq!(loaded.id(), identity.id()); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - assert!(encrypted_identity_wrapping_key_path(&path).is_file()); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn encrypted_identity_rotation_rewraps_key() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("identity.enc.json"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - store_encrypted_identity(&path, &identity).expect("store encrypted identity"); - let key_path = encrypted_identity_wrapping_key_path(&path); - let before = fs::read(&key_path).expect("key before"); - - rotate_encrypted_identity(&path).expect("rotate encrypted identity"); - - let after = fs::read(&key_path).expect("key after"); - assert_ne!(before, after); - let loaded = load_encrypted_identity(&path).expect("load rotated identity"); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn encrypted_identity_supports_custom_key_slot() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("identity.enc.json"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - store_encrypted_identity_with_key_slot(&path, "myc_identity", &identity) - .expect("store encrypted identity"); - let loaded = load_encrypted_identity_with_key_slot(&path, "myc_identity") - .expect("load encrypted identity"); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn identity_profile_round_trips() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("profile.json"); - let mut identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - identity.set_profile(crate::RadrootsIdentityProfile::default()); - - store_identity_profile(&path, &identity).expect("store profile"); - - let loaded = load_identity_profile(&path).expect("load profile"); - assert_eq!(loaded.id, identity.id()); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn encrypted_identity_file_accessors_and_wrappers_use_expected_paths() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("identity.enc.json"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - let default_file = RadrootsEncryptedIdentityFile::new(path.as_path()); - assert_eq!(default_file.path(), path.as_path()); - assert_eq!( - default_file.key_slot(), - RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT - ); - assert_eq!( - default_file.wrapping_key_path(), - encrypted_identity_wrapping_key_path(path.as_path()) - ); - - let custom_file = - RadrootsEncryptedIdentityFile::with_key_slot(path.as_path(), "custom_identity"); - assert_eq!(custom_file.key_slot(), "custom_identity"); - - store_encrypted_identity(path.as_path(), &identity).expect("store encrypted identity"); - rotate_encrypted_identity(path.as_path()).expect("rotate encrypted identity"); - let loaded = load_encrypted_identity(path.as_path()).expect("load encrypted identity"); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - - store_encrypted_identity_with_key_slot(path.as_path(), "custom_identity", &identity) - .expect("store encrypted identity with slot"); - rotate_encrypted_identity_with_key_slot(path.as_path(), "custom_identity") - .expect("rotate encrypted identity with slot"); - let loaded = load_encrypted_identity_with_key_slot(path.as_path(), "custom_identity") - .expect("load encrypted identity with slot"); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn encrypted_identity_load_reports_read_decode_and_open_errors() { - let temp = tempfile::tempdir().expect("tempdir"); - let missing = temp.path().join("missing.enc.json"); - let missing_error = load_encrypted_identity(missing.as_path()).expect_err("missing"); - assert!(matches!(missing_error, IdentityError::NotFound(path) if path == missing)); - - let read_error = load_encrypted_identity(temp.path()).expect_err("directory read"); - assert!(matches!(read_error, IdentityError::Read(path, _) if path == temp.path())); - - let invalid = temp.path().join("invalid.enc.json"); - fs::write(&invalid, b"not-json").expect("write invalid envelope"); - let decode_error = load_encrypted_identity(invalid.as_path()).expect_err("decode error"); - assert!(matches!( - decode_error, - IdentityError::ProtectedStorage { path, message } - if path == invalid && message.contains("decode encrypted identity") - )); - - let invalid_plaintext = temp.path().join("invalid-plaintext.enc.json"); - let key_source = RadrootsProtectedFileKeySource::from_sidecar_suffix( - invalid_plaintext.as_path(), - RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, - ); - let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key( - &key_source, - RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, - b"not identity json", - ) - .expect("seal invalid plaintext"); - fs::write( - &invalid_plaintext, - envelope.encode_json().expect("encode invalid plaintext"), - ) - .expect("write invalid plaintext envelope"); - let invalid_plaintext_error = - load_encrypted_identity(invalid_plaintext.as_path()).expect_err("invalid plaintext"); - assert!(matches!( - invalid_plaintext_error, - IdentityError::InvalidJson(_) - )); - - let path = temp.path().join("identity.enc.json"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - store_encrypted_identity_with_key_slot(path.as_path(), "right_slot", &identity) - .expect("store encrypted identity"); - fs::write( - encrypted_identity_wrapping_key_path(path.as_path()), - b"short", - ) - .expect("corrupt wrapping key"); - let open_error = load_encrypted_identity(path.as_path()).expect_err("open"); - assert!(matches!( - open_error, - IdentityError::ProtectedStorage { path: error_path, message } - if error_path == path && message.contains("open encrypted identity") - )); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn encrypted_identity_store_reports_create_write_and_seal_errors() { - let temp = tempfile::tempdir().expect("tempdir"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - let blocked_parent = temp.path().join("blocked-parent"); - fs::write(&blocked_parent, b"not-a-directory").expect("blocked parent"); - let create_path = blocked_parent.join("identity.enc.json"); - let create_error = - store_encrypted_identity(create_path.as_path(), &identity).expect_err("create dir"); - assert!( - matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent) - ); - - let directory_path = temp.path().join("identity-as-directory.enc.json"); - fs::create_dir(&directory_path).expect("identity directory"); - let write_error = - store_encrypted_identity(directory_path.as_path(), &identity).expect_err("write dir"); - assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path)); - - let sealed_path = temp.path().join("seal-error.enc.json"); - fs::create_dir(encrypted_identity_wrapping_key_path(sealed_path.as_path())) - .expect("blocking key directory"); - let seal_error = - store_encrypted_identity(sealed_path.as_path(), &identity).expect_err("seal"); - assert!(matches!( - seal_error, - IdentityError::ProtectedStorage { path, message } - if path == sealed_path && message.contains("seal encrypted identity") - )); - } - - #[cfg(unix)] - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn encrypted_identity_rotation_restores_wrapping_key_after_store_failure() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("identity.enc.json"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - store_encrypted_identity(path.as_path(), &identity).expect("store encrypted identity"); - let key_path = encrypted_identity_wrapping_key_path(path.as_path()); - let key_before = fs::read(&key_path).expect("key before"); - - fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("read only"); - let error = rotate_encrypted_identity(path.as_path()).expect_err("rotate failure"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("writable"); - - assert!(matches!(error, IdentityError::Write(error_path, _) if error_path == path)); - assert_eq!(fs::read(&key_path).expect("restored key"), key_before); - let loaded = load_encrypted_identity(path.as_path()).expect("load restored identity"); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn identity_profile_storage_reports_errors_and_private_fallback() { - let temp = tempfile::tempdir().expect("tempdir"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - let blocked_parent = temp.path().join("blocked-profile-parent"); - fs::write(&blocked_parent, b"not-a-directory").expect("blocked parent"); - let create_path = blocked_parent.join("profile.json"); - let create_error = - store_identity_profile(create_path.as_path(), &identity).expect_err("create dir"); - assert!( - matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent) - ); - - let directory_path = temp.path().join("profile-as-directory.json"); - fs::create_dir(&directory_path).expect("profile directory"); - let write_error = - store_identity_profile(directory_path.as_path(), &identity).expect_err("write dir"); - assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path)); - - let missing = temp.path().join("missing-profile.json"); - let missing_error = load_identity_profile(missing.as_path()).expect_err("missing"); - assert!(matches!(missing_error, IdentityError::NotFound(path) if path == missing)); - - let read_error = load_identity_profile(temp.path()).expect_err("directory read"); - assert!(matches!(read_error, IdentityError::Read(path, _) if path == temp.path())); - - let private_profile = temp.path().join("private-profile.json"); - fs::write( - &private_profile, - serde_json::to_vec(&identity.to_file()).expect("identity file"), - ) - .expect("write private profile"); - let loaded = load_identity_profile(private_profile.as_path()).expect("load fallback"); - assert_eq!(loaded.id, identity.id()); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn protected_storage_permission_message_uses_operator_action() { - let path = Path::new("missing-secret-file"); - let error = secret_permission_error( - path, - RadrootsSecretVaultAccessError::Backend("permission denied".into()), - ); - - assert!(matches!( - error, - IdentityError::ProtectedStorage { path: error_path, message } - if error_path == path - && message.contains("update secret-file permissions") - && message.contains("permission denied") - )); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn storage_supports_parentless_relative_files() { - let temp = tempfile::tempdir().expect("tempdir"); - let previous = std::env::current_dir().expect("current dir"); - std::env::set_current_dir(temp.path()).expect("set temp cwd"); - - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - let encrypted_path = Path::new("identity.enc.json"); - store_encrypted_identity(encrypted_path, &identity).expect("store encrypted"); - let loaded = load_encrypted_identity(encrypted_path).expect("load encrypted"); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - - let profile_path = Path::new("profile.json"); - store_identity_profile(profile_path, &identity).expect("store profile"); - let loaded = load_identity_profile(profile_path).expect("load profile"); - assert_eq!(loaded.id, identity.id()); - - let empty_path = Path::new(""); - let encrypted_error = - store_encrypted_identity(empty_path, &identity).expect_err("empty encrypted path"); - assert!(matches!(encrypted_error, IdentityError::Write(_, _))); - let profile_error = - store_identity_profile(empty_path, &identity).expect_err("empty profile path"); - assert!(matches!(profile_error, IdentityError::Write(_, _))); - - std::env::set_current_dir(previous).expect("restore cwd"); - } -} diff --git a/crates/identity/src/test_fixtures.rs b/crates/identity/src/test_fixtures.rs @@ -1,107 +0,0 @@ -#![forbid(unsafe_code)] -#![allow(dead_code)] - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ApprovedFixtureIdentity { - pub label: &'static str, - pub username: &'static str, - pub email: &'static str, - pub secret_key_hex: &'static str, - pub public_key_hex: &'static str, - pub nsec: &'static str, - pub npub: &'static str, -} - -pub const APPROVED_FIXTURE_NAMESPACE: &str = "radroots-approved-fixture-v1"; - -pub const FIXTURE_ALICE_LABEL: &str = "fixture_alice"; -pub const FIXTURE_ALICE_USERNAME: &str = "fixture_alice"; -pub const FIXTURE_ALICE_EMAIL: &str = "fixture_alice@fixtures.test"; -pub const FIXTURE_ALICE_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; -pub const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; -pub const FIXTURE_ALICE_NSEC: &str = - "nsec1zrznqntvnt36rgt00ps0rny0tca8vgj6ye3m82vf5rthtyvm0h6syu7drz"; -pub const FIXTURE_ALICE_NPUB: &str = - "npub1tp2ez55a5zatxxemrv0eses3ea05xhw2snuh3jy7azjqejn3q00s3vy5a9"; -pub const FIXTURE_ALICE: ApprovedFixtureIdentity = ApprovedFixtureIdentity { - label: FIXTURE_ALICE_LABEL, - username: FIXTURE_ALICE_USERNAME, - email: FIXTURE_ALICE_EMAIL, - secret_key_hex: FIXTURE_ALICE_SECRET_KEY_HEX, - public_key_hex: FIXTURE_ALICE_PUBLIC_KEY_HEX, - nsec: FIXTURE_ALICE_NSEC, - npub: FIXTURE_ALICE_NPUB, -}; - -pub const FIXTURE_BOB_LABEL: &str = "fixture_bob"; -pub const FIXTURE_BOB_USERNAME: &str = "fixture_bob"; -pub const FIXTURE_BOB_EMAIL: &str = "fixture_bob@fixtures.test"; -pub const FIXTURE_BOB_SECRET_KEY_HEX: &str = - "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; -pub const FIXTURE_BOB_PUBLIC_KEY_HEX: &str = - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; -pub const FIXTURE_BOB_NSEC: &str = - "nsec1tyujayrg7ejrrvf0wqscldsjs89kksea0unu2htp7xnrlcdfdluqrjya9h"; -pub const FIXTURE_BOB_NPUB: &str = - "npub1uqnxu08mp55gd7guw06ls68nhxp8xuf7tlxe0sypvcl42x9ykwhsd55k2g"; -pub const FIXTURE_BOB: ApprovedFixtureIdentity = ApprovedFixtureIdentity { - label: FIXTURE_BOB_LABEL, - username: FIXTURE_BOB_USERNAME, - email: FIXTURE_BOB_EMAIL, - secret_key_hex: FIXTURE_BOB_SECRET_KEY_HEX, - public_key_hex: FIXTURE_BOB_PUBLIC_KEY_HEX, - nsec: FIXTURE_BOB_NSEC, - npub: FIXTURE_BOB_NPUB, -}; - -pub const FIXTURE_CAROL_LABEL: &str = "fixture_carol"; -pub const FIXTURE_CAROL_USERNAME: &str = "fixture_carol"; -pub const FIXTURE_CAROL_EMAIL: &str = "fixture_carol@fixtures.test"; -pub const FIXTURE_CAROL_SECRET_KEY_HEX: &str = - "4d6c20fdd86857de77ff5cfa5c545751ba2efd126e0b6642dae9764d782d6509"; -pub const FIXTURE_CAROL_PUBLIC_KEY_HEX: &str = - "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"; -pub const FIXTURE_CAROL_NSEC: &str = - "nsec1f4kzplwcdptaualltna9c4zh2xazalgjdc9kvsk6a9my67pdv5ys2pqkaj"; -pub const FIXTURE_CAROL_NPUB: &str = - "npub1r9ft33558zvtemluludhdxwy5a66f5fmf2d6qztt5fh0q3yjhvwqgzmkl6"; -pub const FIXTURE_CAROL: ApprovedFixtureIdentity = ApprovedFixtureIdentity { - label: FIXTURE_CAROL_LABEL, - username: FIXTURE_CAROL_USERNAME, - email: FIXTURE_CAROL_EMAIL, - secret_key_hex: FIXTURE_CAROL_SECRET_KEY_HEX, - public_key_hex: FIXTURE_CAROL_PUBLIC_KEY_HEX, - nsec: FIXTURE_CAROL_NSEC, - npub: FIXTURE_CAROL_NPUB, -}; - -pub const FIXTURE_DIEGO_LABEL: &str = "fixture_diego"; -pub const FIXTURE_DIEGO_USERNAME: &str = "fixture_diego"; -pub const FIXTURE_DIEGO_EMAIL: &str = "fixture_diego@fixtures.test"; -pub const FIXTURE_DIEGO_SECRET_KEY_HEX: &str = - "9de56c1fdfce9ab00af85b3d7003c1d15cffb84cdf303c3a83c1a3fb1a2d0db0"; -pub const FIXTURE_DIEGO_PUBLIC_KEY_HEX: &str = - "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"; -pub const FIXTURE_DIEGO_NSEC: &str = - "nsec1nhjkc87le6dtqzhctv7hqq7p69w0lwzvmucrcw5rcx3lkx3dpkcqkrmgp5"; -pub const FIXTURE_DIEGO_NPUB: &str = - "npub1t5l2kmncadlyv757r94xx3tvn7hmj0ac3dc99wpj9xrs3zvj82jqwwcglm"; -pub const FIXTURE_DIEGO: ApprovedFixtureIdentity = ApprovedFixtureIdentity { - label: FIXTURE_DIEGO_LABEL, - username: FIXTURE_DIEGO_USERNAME, - email: FIXTURE_DIEGO_EMAIL, - secret_key_hex: FIXTURE_DIEGO_SECRET_KEY_HEX, - public_key_hex: FIXTURE_DIEGO_PUBLIC_KEY_HEX, - nsec: FIXTURE_DIEGO_NSEC, - npub: FIXTURE_DIEGO_NPUB, -}; - -pub const RELAY_PRIMARY_WSS: &str = "wss://relay.example.com"; -pub const RELAY_SECONDARY_WSS: &str = "wss://relay-2.example.com"; -pub const RELAY_TERTIARY_WSS: &str = "wss://relay-3.example.com"; - -pub const APP_PRIMARY_HTTPS: &str = "https://app.example.com"; -pub const API_PRIMARY_HTTPS: &str = "https://api.example.com"; -pub const CDN_PRIMARY_HTTPS: &str = "https://cdn.example.com"; diff --git a/crates/identity/src/username.rs b/crates/identity/src/username.rs @@ -1,91 +1,207 @@ -#![forbid(unsafe_code)] +//! Canonical public usernames. +//! +//! [`Username`] trims surrounding whitespace, lowercases ASCII input, enforces +//! [`MIN_LENGTH`] and [`MAX_LENGTH`], and accepts only the documented public +//! character set and dot placement. Parsing and serde decoding use the same +//! validator and always produce canonical text. -#[cfg(not(feature = "std"))] use alloc::string::String; +use core::{fmt, str::FromStr}; -pub const RADROOTS_USERNAME_MIN_LEN: usize = 3; -pub const RADROOTS_USERNAME_MAX_LEN: usize = 30; -pub const RADROOTS_USERNAME_REGEX: &str = r"^(?!.*\.\.)(?!\.)(?!.*\.$)[a-z0-9._-]{3,30}$"; +use crate::Error; -pub fn radroots_username_is_valid(username: &str) -> bool { - if !username.is_ascii() { - return false; +/// Minimum canonical username length in ASCII bytes. +pub const MIN_LENGTH: usize = 3; + +/// Maximum canonical username length in ASCII bytes. +pub const MAX_LENGTH: usize = 30; + +/// A normalized public Radroots username. +/// +/// Usernames are lowercase ASCII and may contain letters, digits, `.`, `_`, +/// and `-`. A dot cannot occur first, last, or consecutively. Parsing trims +/// surrounding whitespace and canonicalizes ASCII uppercase letters. +#[repr(transparent)] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Username(String); + +impl Username { + /// Parses and normalizes a public username. + pub fn parse(value: &str) -> Result<Self, Error> { + let canonical = value.trim().to_ascii_lowercase(); + validate(&canonical)?; + Ok(Self(canonical)) } - let len = username.len(); - if !(RADROOTS_USERNAME_MIN_LEN..=RADROOTS_USERNAME_MAX_LEN).contains(&len) { - return false; + + /// Borrows the canonical username text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Returns the canonical username text. + #[must_use] + pub fn into_string(self) -> String { + self.0 } - let bytes = username.as_bytes(); +} + +fn validate(value: &str) -> Result<(), Error> { + let length = value.len(); + if !(MIN_LENGTH..=MAX_LENGTH).contains(&length) { + return Err(Error::InvalidUsernameLength { + min: MIN_LENGTH, + max: MAX_LENGTH, + actual: length, + }); + } + + let bytes = value.as_bytes(); if bytes.first() == Some(&b'.') || bytes.last() == Some(&b'.') { - return false; + return Err(Error::InvalidUsernameDotPlacement); } - let mut prev_dot = false; - for &byte in bytes { + + let mut previous_dot = false; + for (index, byte) in bytes.iter().copied().enumerate() { if byte == b'.' { - if prev_dot { - return false; + if previous_dot { + return Err(Error::InvalidUsernameDotPlacement); } - prev_dot = true; + previous_dot = true; continue; } - prev_dot = false; - let is_alpha = byte.is_ascii_lowercase(); - let is_digit = byte.is_ascii_digit(); - let is_allowed = is_alpha || is_digit || byte == b'_' || byte == b'-'; - if !is_allowed { - return false; + previous_dot = false; + if !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')) { + return Err(Error::InvalidUsernameCharacter { index }); } } - true + Ok(()) } -pub fn radroots_username_normalize(input: &str) -> Option<String> { - let trimmed = input.trim(); - if trimmed.is_empty() { - return None; +impl fmt::Debug for Username { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("Username") + .field(&self.as_str()) + .finish() } - let normalized = trimmed.to_ascii_lowercase(); - if radroots_username_is_valid(&normalized) { - Some(normalized) - } else { - None +} + +impl fmt::Display for Username { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for Username { + type Err = Error; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) } } -#[cfg(all(test, feature = "std"))] +impl TryFrom<&str> for Username { + type Error = Error; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + Self::parse(value) + } +} + +impl TryFrom<String> for Username { + type Error = Error; + + fn try_from(value: String) -> Result<Self, Self::Error> { + Self::parse(&value) + } +} + +impl AsRef<str> for Username { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for Username { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Username { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + struct UsernameVisitor; + + impl serde::de::Visitor<'_> for UsernameVisitor { + type Value = Username; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a valid public Radroots username") + } + + fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> + where + E: serde::de::Error, + { + Username::parse(value).map_err(E::custom) + } + } + + deserializer.deserialize_str(UsernameVisitor) + } +} + +#[cfg(test)] mod tests { + use alloc::string::ToString; + use super::*; #[test] - fn valid_usernames() { - for name in ["radroots", "radroots_1", "radroots.test", "rr-01"] { - assert!(radroots_username_is_valid(name)); - } + fn usernames_normalize_to_one_canonical_form() { + let username = Username::parse(" RadRoots.Test ").unwrap(); + assert_eq!(username.as_str(), "radroots.test"); + assert_eq!(username.to_string(), "radroots.test"); + assert_eq!(Username::from_str("radroots.test").unwrap(), username); } #[test] - fn invalid_usernames() { - for name in [ - "ra", - ".radroots", - "radroots.", - "radroots..test", - "radroots!", - "RADROOTS", - "rädroots", - "radroots-radroots-radroots-radroots", - ] { - assert!(!radroots_username_is_valid(name)); + fn usernames_reject_invalid_lengths_characters_and_dots() { + assert!(matches!( + Username::parse("rr"), + Err(Error::InvalidUsernameLength { actual: 2, .. }) + )); + assert!(matches!( + Username::parse("rad roots"), + Err(Error::InvalidUsernameCharacter { index: 3 }) + )); + for value in [".radroots", "radroots.", "radroots..test"] { + assert!(matches!( + Username::parse(value), + Err(Error::InvalidUsernameDotPlacement) + )); } + assert!(matches!( + Username::parse("rädroots"), + Err(Error::InvalidUsernameCharacter { index: 1 }) + )); } + #[cfg(feature = "serde")] #[test] - fn normalize_usernames() { - assert_eq!( - radroots_username_normalize(" RadRoots "), - Some("radroots".to_string()) - ); - assert_eq!(radroots_username_normalize("ra"), None); - assert_eq!(radroots_username_normalize(" "), None); + fn username_serde_is_checked_and_canonical() { + let username: Username = serde_json::from_str("\" RadRoots \"").unwrap(); + assert_eq!(username.as_str(), "radroots"); + assert_eq!(serde_json::to_string(&username).unwrap(), "\"radroots\""); + assert!(serde_json::from_str::<Username>("\"rr\"").is_err()); } } diff --git a/crates/identity/tests/account_values.rs b/crates/identity/tests/account_values.rs @@ -0,0 +1,61 @@ +use radroots_identity::{ + AccountId, Error, PublicIdentity, PublicKey, + account::{Record, Status}, +}; + +const ALICE: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; +const BOB: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + +fn public_identity(value: &str) -> PublicIdentity { + PublicIdentity::new(PublicKey::from_hex(value).expect("valid public key")) +} + +#[test] +fn public_account_values_preserve_identity_and_readiness() { + let identity = public_identity(ALICE); + let record = Record::new(identity.clone(), Some("primary".into()), 10); + let status = Status::Ready { + account: record.clone(), + }; + + assert_eq!(record.id(), AccountId::from_public_identity(&identity)); + assert_eq!(record.public_identity(), &identity); + assert_eq!(record.label(), Some("primary")); + assert_eq!(status.account(), Some(&record)); + assert!(status.is_ready()); +} + +#[test] +fn decoded_account_parts_cannot_change_identity_or_reverse_time() { + let identity = public_identity(ALICE); + let wrong_id = AccountId::from_public_identity(&public_identity(BOB)); + + assert!(matches!( + Record::try_from_parts(wrong_id, identity.clone(), None, 10, 10), + Err(Error::AccountIdMismatch) + )); + assert!(matches!( + Record::try_from_parts( + AccountId::from_public_identity(&identity), + identity, + None, + 10, + 9, + ), + Err(Error::AccountUpdatedBeforeCreated { + created_at_unix: 10, + updated_at_unix: 9, + }) + )); +} + +#[test] +fn public_identity_values_are_send_sync_without_host_state() { + fn assert_send_sync<T: Send + Sync>() {} + + assert_send_sync::<AccountId>(); + assert_send_sync::<PublicIdentity>(); + assert_send_sync::<PublicKey>(); + assert_send_sync::<Record>(); + assert_send_sync::<Status>(); +} diff --git a/crates/identity/tests/conformance.rs b/crates/identity/tests/conformance.rs @@ -0,0 +1,83 @@ +#![cfg(feature = "serde")] + +use std::collections::BTreeSet; + +use radroots_identity::{ + AccountId, IdentityId, Profile, PublicIdentity, PublicKey, Username, + account::{Record, Status}, +}; +use serde_json::Value; + +const PUBLIC_VALUE_VECTORS: &str = + include_str!("../../../contracts/conformance/vectors/identity/public_values.v1.json"); + +#[test] +fn public_value_vectors_are_unique_complete_and_executable() { + let suite: Value = serde_json::from_str(PUBLIC_VALUE_VECTORS).expect("valid vector suite"); + assert_eq!(suite["suite"], "identity_public_values"); + assert_eq!(suite["contract_version"], "1.0.0"); + + let vectors = suite["vectors"].as_array().expect("vector array"); + let mut ids = BTreeSet::new(); + for vector in vectors { + let id = vector["id"].as_str().expect("vector id"); + assert!(ids.insert(id), "duplicate vector id {id}"); + let kind = vector["kind"].as_str().expect("vector kind"); + let input = vector["input"].clone(); + if let Some(expected) = vector.get("expected") { + let actual = execute_valid(kind, input); + assert_eq!(&actual, expected, "vector {id}"); + } else { + let expected = vector["expected_error_contains"] + .as_str() + .expect("invalid vector error fragment"); + let error = execute_invalid(kind, input); + assert!( + error.contains(expected), + "vector {id} expected error containing {expected:?}, got {error:?}" + ); + } + } + assert_eq!(ids.len(), 14); +} + +fn execute_valid(kind: &str, input: Value) -> Value { + match kind { + "identity.public_key.serde" => round_trip::<PublicKey>(input), + "identity.identity_id.serde" => round_trip::<IdentityId>(input), + "identity.account_id.serde" => round_trip::<AccountId>(input), + "identity.username.serde" => round_trip::<Username>(input), + "identity.profile.serde" => round_trip::<Profile>(input), + "identity.public_identity.serde" => round_trip::<PublicIdentity>(input), + "identity.account_record.serde" => round_trip::<Record>(input), + "identity.account_status.serde" => round_trip::<Status>(input), + unsupported => panic!("unsupported valid identity vector kind {unsupported}"), + } +} + +fn execute_invalid(kind: &str, input: Value) -> String { + match kind { + "identity.public_key.invalid" => decode_error::<PublicKey>(input), + "identity.public_identity.invalid" => decode_error::<PublicIdentity>(input), + "identity.account_record.invalid" => decode_error::<Record>(input), + "identity.username.invalid" => decode_error::<Username>(input), + unsupported => panic!("unsupported invalid identity vector kind {unsupported}"), + } +} + +fn round_trip<T>(input: Value) -> Value +where + T: serde::de::DeserializeOwned + serde::Serialize, +{ + serde_json::to_value(serde_json::from_value::<T>(input).expect("valid vector input")) + .expect("serialize canonical value") +} + +fn decode_error<T>(input: Value) -> String +where + T: serde::de::DeserializeOwned + core::fmt::Debug, +{ + serde_json::from_value::<T>(input) + .expect_err("invalid vector must be rejected") + .to_string() +} diff --git a/crates/identity/tests/identity.rs b/crates/identity/tests/identity.rs @@ -1,1027 +0,0 @@ -#[path = "../src/test_fixtures.rs"] -mod test_fixtures; - -use radroots_identity::{ - DEFAULT_IDENTITY_PATH, IdentityError, RadrootsIdentity, RadrootsIdentityId, - RadrootsIdentityProfile, RadrootsIdentityPublic, RadrootsIdentitySecretKeyFormat, -}; -use radroots_identity::{ - RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, - RadrootsEncryptedIdentityFile, encrypted_identity_wrapping_key_path, load_encrypted_identity, - load_encrypted_identity_with_key_slot, load_identity_profile, rotate_encrypted_identity, - rotate_encrypted_identity_with_key_slot, store_encrypted_identity, - store_encrypted_identity_with_key_slot, store_identity_profile, -}; -#[cfg(feature = "nip49")] -use radroots_identity::{ - RadrootsIdentityEncryptedSecretKeyOptions, RadrootsIdentityEncryptedSecretKeySecurity, -}; -use radroots_protected_store::{RadrootsProtectedFileKeySource, RadrootsProtectedStoreEnvelope}; -use radroots_runtime_paths::{ - RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, - RadrootsPlatform, -}; -use std::{ - ffi::OsString, - path::PathBuf, - sync::{Mutex, OnceLock}, -}; -use test_fixtures::{ApprovedFixtureIdentity, FIXTURE_ALICE, FIXTURE_BOB}; - -fn home_env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) -} - -struct EnvVarGuard { - key: &'static str, - previous: Option<OsString>, -} - -impl EnvVarGuard { - fn remove(key: &'static str) -> Self { - let previous = std::env::var_os(key); - unsafe { std::env::remove_var(key) }; - Self { key, previous } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - if let Some(value) = self.previous.as_ref() { - unsafe { std::env::set_var(self.key, value) }; - } else { - unsafe { std::env::remove_var(self.key) }; - } - } -} - -fn fixture_keys(fixture: ApprovedFixtureIdentity) -> nostr::Keys { - let secret = nostr::SecretKey::from_hex(fixture.secret_key_hex).unwrap(); - nostr::Keys::new(secret) -} - -fn fixture_identity(fixture: ApprovedFixtureIdentity) -> RadrootsIdentity { - RadrootsIdentity::from_secret_key_str(fixture.secret_key_hex).unwrap() -} - -fn profile_with_identifier(value: &str) -> RadrootsIdentityProfile { - RadrootsIdentityProfile { - identifier: Some(value.to_string()), - ..Default::default() - } -} - -fn sample_event(content: &str) -> nostr::Event { - nostr::EventBuilder::text_note(content) - .sign_with_keys(&fixture_keys(FIXTURE_ALICE)) - .unwrap() -} - -#[test] -fn load_from_json_file_hex() { - let identity = fixture_identity(FIXTURE_ALICE); - let json = serde_json::to_string(&identity.to_file()).unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, json).unwrap(); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn legacy_embedded_profile_is_rejected_instead_of_silently_discarded() { - let identity = fixture_identity(FIXTURE_ALICE); - let mut file = serde_json::to_value(identity.to_file()).unwrap(); - file.as_object_mut().unwrap().insert( - "profile".to_owned(), - serde_json::json!({ - "name": "legacy-profile", - "picture": "https://example.test/unverified.png" - }), - ); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - let encoded = serde_json::to_vec_pretty(&file).unwrap(); - std::fs::write(&path, &encoded).unwrap(); - - let error = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); - assert!( - matches!(&error, IdentityError::InvalidJson(source) if source.to_string().contains("unknown field `profile`")), - "unexpected error: {error}" - ); - assert_eq!(std::fs::read(path).unwrap(), encoded); -} - -#[test] -fn legacy_nested_identity_profile_is_rejected_without_rewriting() { - let identity = fixture_identity(FIXTURE_ALICE); - let mut public = serde_json::to_value( - identity - .to_public() - .with_profile(profile_with_identifier("alice")), - ) - .unwrap(); - public["profile"].as_object_mut().unwrap().insert( - "profile".to_owned(), - serde_json::json!({ - "name": "legacy-profile", - "picture": "https://example.test/unverified.png" - }), - ); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("profile.json"); - let encoded = serde_json::to_vec_pretty(&public).unwrap(); - std::fs::write(&path, &encoded).unwrap(); - - let error = load_identity_profile(&path).unwrap_err(); - assert!(matches!(error, IdentityError::InvalidJson(_))); - assert_eq!(std::fs::read(path).unwrap(), encoded); -} - -#[test] -fn load_from_text_file_hex() { - let identity = fixture_identity(FIXTURE_ALICE); - let secret = identity.secret_key_hex(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.txt"); - std::fs::write(&path, secret).unwrap(); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn load_from_text_file_nsec() { - let identity = fixture_identity(FIXTURE_ALICE); - let secret = identity.secret_key_nsec(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.txt"); - std::fs::write(&path, secret).unwrap(); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn load_from_binary_file() { - let identity = fixture_identity(FIXTURE_ALICE); - let secret = identity.secret_key_bytes(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.key"); - std::fs::write(&path, secret).unwrap(); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn load_or_generate_missing_disallowed() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - - let err = RadrootsIdentity::load_or_generate(Some(&path), false).unwrap_err(); - assert!(matches!(err, IdentityError::GenerationNotAllowed(p) if p == path)); -} - -#[test] -fn load_or_generate_missing_allowed_creates_json() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - - let identity = RadrootsIdentity::load_or_generate(Some(&path), true).unwrap(); - assert!(path.exists()); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key(), identity.public_key()); -} - -#[test] -fn load_from_json_file_public_key_npub() { - let identity = fixture_identity(FIXTURE_ALICE); - let mut file = identity.to_file(); - file.public_key = Some(identity.public_key_npub()); - let json = serde_json::to_string(&file).unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, json).unwrap(); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn load_from_json_file_public_key_mismatch() { - let identity = fixture_identity(FIXTURE_ALICE); - let mut file = identity.to_file(); - file.public_key = Some(FIXTURE_BOB.public_key_hex.to_string()); - let json = serde_json::to_string(&file).unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, json).unwrap(); - - let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); - assert!(matches!(err, IdentityError::PublicKeyMismatch)); -} - -#[test] -fn identity_id_matches_public_key_hex() { - let identity = fixture_identity(FIXTURE_ALICE); - - let id = identity.id(); - assert_eq!(id.as_str(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn identity_id_parses_hex_and_npub() { - let from_hex = RadrootsIdentityId::parse(FIXTURE_ALICE.public_key_hex).unwrap(); - let from_npub = RadrootsIdentityId::parse(FIXTURE_ALICE.npub).unwrap(); - assert_eq!(from_hex.as_str(), FIXTURE_ALICE.public_key_hex); - assert_eq!(from_npub.as_str(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn to_public_projection_excludes_secret_key_fields() { - let identity = fixture_identity(FIXTURE_ALICE); - let public = identity.to_public(); - - assert_eq!(public.id.as_str(), FIXTURE_ALICE.public_key_hex); - assert_eq!(public.public_key_hex, FIXTURE_ALICE.public_key_hex); - assert_eq!(public.public_key_npub, FIXTURE_ALICE.npub); - assert!(public.profile.is_none()); - - let json = serde_json::to_string(&public).unwrap(); - assert!(!json.contains("secret_key")); - assert!(!json.contains(&identity.secret_key_hex())); -} - -#[test] -fn identity_id_trait_paths_and_string_conversions() { - let public_key = fixture_identity(FIXTURE_ALICE).public_key(); - let public_key_hex = FIXTURE_ALICE.public_key_hex.to_string(); - - let from_impl = RadrootsIdentityId::from(public_key); - assert_eq!(from_impl.as_ref(), public_key_hex); - - let from_try = RadrootsIdentityId::try_from(public_key_hex.as_str()).unwrap(); - assert_eq!(from_try.to_string(), public_key_hex); - assert_eq!(from_try.clone().into_string(), public_key_hex); -} - -#[test] -fn identity_profile_state_mutation_paths() { - let mut identity = RadrootsIdentity::with_profile( - fixture_keys(FIXTURE_ALICE), - RadrootsIdentityProfile::default(), - ); - assert!(identity.profile().is_none()); - - identity.set_profile(RadrootsIdentityProfile::default()); - assert!(identity.profile().is_none()); - - let profile = profile_with_identifier("radroots-user"); - identity.set_profile(profile.clone()); - assert!(identity.profile().is_some()); - - let profile_mut = identity.profile_mut().unwrap(); - profile_mut.identifier = Some("radroots-user-updated".to_string()); - assert_eq!( - identity.profile().and_then(|p| p.identifier.as_deref()), - Some("radroots-user-updated") - ); - - let public = identity.to_public(); - assert!(public.profile.is_some()); - - identity.clear_profile(); - assert!(identity.profile().is_none()); - - let public_without_profile = RadrootsIdentityPublic::new(identity.public_key()) - .with_profile(RadrootsIdentityProfile::default()); - assert!(public_without_profile.profile.is_none()); - - let public_with_profile = - RadrootsIdentityPublic::new(identity.public_key()).with_profile(profile); - assert!(public_with_profile.profile.is_some()); -} - -#[test] -fn identity_accessor_paths_and_secret_formats() { - let identity = fixture_identity(FIXTURE_ALICE); - - assert_eq!( - identity.keys().public_key().to_hex(), - FIXTURE_ALICE.public_key_hex - ); - assert_eq!(identity.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); - assert_eq!(identity.npub(), FIXTURE_ALICE.npub); - assert_eq!(identity.nsec(), FIXTURE_ALICE.nsec); - - let file_nsec = identity.to_file_with_secret_format(RadrootsIdentitySecretKeyFormat::Nsec); - assert_eq!(file_nsec.secret_key, FIXTURE_ALICE.nsec); - - let from_keys: RadrootsIdentity = fixture_keys(FIXTURE_ALICE).into(); - let roundtrip_keys = from_keys.clone().into_keys(); - assert_eq!( - roundtrip_keys.public_key().to_hex(), - FIXTURE_ALICE.public_key_hex - ); -} - -#[cfg(feature = "nip49")] -#[test] -fn encrypted_secret_key_round_trips_to_identity() { - let identity = fixture_identity(FIXTURE_ALICE); - let encrypted = identity - .encrypt_secret_key_ncryptsec("fixture-password") - .unwrap(); - assert!(encrypted.starts_with("ncryptsec1")); - - let decrypted = - RadrootsIdentity::from_encrypted_secret_key_str(&encrypted, "fixture-password").unwrap(); - assert_eq!(decrypted.public_key(), identity.public_key()); -} - -#[cfg(feature = "nip49")] -#[test] -fn encrypted_secret_key_options_propagate_to_output() { - use nostr::nips::nip19::FromBech32; - use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity}; - - let identity = fixture_identity(FIXTURE_ALICE); - let encrypted = identity - .encrypt_secret_key_ncryptsec_with_options( - "fixture-password", - RadrootsIdentityEncryptedSecretKeyOptions { - log_n: 15, - key_security: RadrootsIdentityEncryptedSecretKeySecurity::Medium, - }, - ) - .unwrap(); - let parsed = EncryptedSecretKey::from_bech32(&encrypted).unwrap(); - assert_eq!(parsed.log_n(), 15); - assert_eq!(parsed.key_security(), KeySecurity::Medium); -} - -#[cfg(feature = "nip49")] -#[test] -fn encrypted_secret_key_weak_security_and_invalid_log_n_paths() { - use nostr::nips::nip49::KeySecurity; - - assert_eq!( - KeySecurity::from(RadrootsIdentityEncryptedSecretKeySecurity::Weak), - KeySecurity::Weak - ); - - let identity = fixture_identity(FIXTURE_ALICE); - let err = identity - .encrypt_secret_key_ncryptsec_with_options( - "fixture-password", - RadrootsIdentityEncryptedSecretKeyOptions { - log_n: 255, - key_security: RadrootsIdentityEncryptedSecretKeySecurity::Weak, - }, - ) - .unwrap_err(); - assert!(matches!(err, IdentityError::EncryptSecretKey(_))); -} - -#[cfg(feature = "nip49")] -#[test] -fn encrypted_secret_key_rejects_invalid_and_wrong_password_inputs() { - let identity = fixture_identity(FIXTURE_ALICE); - let encrypted = identity - .encrypt_secret_key_ncryptsec("fixture-password") - .unwrap(); - - let invalid = - RadrootsIdentity::from_encrypted_secret_key_str("not-an-encrypted-secret", "password") - .unwrap_err(); - assert!(matches!( - invalid, - IdentityError::InvalidEncryptedSecretKey(_) - )); - - let wrong_password = - RadrootsIdentity::from_encrypted_secret_key_str(&encrypted, "wrong-password").unwrap_err(); - assert!(matches!( - wrong_password, - IdentityError::DecryptEncryptedSecretKey(_) - )); -} - -#[cfg(feature = "nip49")] -#[test] -fn load_from_path_auto_rejects_nip49_export_format() { - let identity = fixture_identity(FIXTURE_ALICE); - let encrypted = identity - .encrypt_secret_key_ncryptsec("fixture-password") - .unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.ncryptsec"); - std::fs::write(&path, encrypted).unwrap(); - - let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); - assert!(matches!(err, IdentityError::InvalidSecretKey(_))); -} - -#[test] -fn parse_failures_cover_public_key_errors() { - let err_empty = RadrootsIdentityId::parse(" ").unwrap_err(); - assert!(matches!(err_empty, IdentityError::InvalidPublicKey(_))); - - let err_invalid = RadrootsIdentityId::parse("invalid-public-key-value").unwrap_err(); - assert!(matches!(err_invalid, IdentityError::InvalidPublicKey(_))); -} - -#[test] -fn from_secret_key_bytes_rejects_wrong_length() { - let err = RadrootsIdentity::from_secret_key_bytes(&[1, 2, 3]).unwrap_err(); - assert!(matches!(err, IdentityError::InvalidIdentityFormat)); -} - -#[test] -fn from_secret_key_str_rejects_invalid_secret() { - let err = RadrootsIdentity::from_secret_key_str("not-a-secret-key").unwrap_err(); - assert!(matches!(err, IdentityError::InvalidSecretKey(_))); -} - -#[test] -fn from_secret_key_bytes_rejects_invalid_scalar() { - let err = RadrootsIdentity::from_secret_key_bytes(&[0u8; 32]).unwrap_err(); - assert!(matches!(err, IdentityError::InvalidSecretKey(_))); -} - -#[test] -fn load_from_path_reports_not_found_and_read_errors() { - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("missing-identity.json"); - let not_found = RadrootsIdentity::load_from_path_auto(&missing).unwrap_err(); - assert!(matches!(not_found, IdentityError::NotFound(path) if path == missing)); - - let read_error = RadrootsIdentity::load_from_path_auto(dir.path()).unwrap_err(); - assert!(matches!(read_error, IdentityError::Read(path, _) if path == dir.path())); -} - -#[test] -fn load_from_path_rejects_invalid_payloads() { - let dir = tempfile::tempdir().unwrap(); - - let blank_path = dir.path().join("identity-blank.txt"); - std::fs::write(&blank_path, " \n\t ").unwrap(); - let blank_err = RadrootsIdentity::load_from_path_auto(&blank_path).unwrap_err(); - assert!(matches!(blank_err, IdentityError::InvalidIdentityFormat)); - - let invalid_utf8_path = dir.path().join("identity-invalid-utf8.bin"); - std::fs::write(&invalid_utf8_path, [0xff, 0xfe, 0xfd]).unwrap(); - let utf8_err = RadrootsIdentity::load_from_path_auto(&invalid_utf8_path).unwrap_err(); - assert!(matches!(utf8_err, IdentityError::InvalidIdentityFormat)); - - let invalid_json_path = dir.path().join("identity-invalid-json.json"); - std::fs::write(&invalid_json_path, "{invalid").unwrap(); - let json_err = RadrootsIdentity::load_from_path_auto(&invalid_json_path).unwrap_err(); - assert!(matches!(json_err, IdentityError::InvalidJson(_))); -} - -#[test] -fn load_from_json_file_without_public_key_succeeds() { - let identity = fixture_identity(FIXTURE_ALICE); - let mut file = identity.to_file(); - file.public_key = None; - let json = serde_json::to_string(&file).unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, json).unwrap(); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn load_from_json_file_rejects_invalid_secret_key_string() { - let payload = serde_json::json!({ - "secret_key": "invalid-secret-key", - "public_key": null, - }); - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, payload.to_string()).unwrap(); - - let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); - assert!(matches!(err, IdentityError::InvalidSecretKey(_))); -} - -#[test] -fn load_from_json_file_rejects_invalid_public_key_value() { - let identity = fixture_identity(FIXTURE_ALICE); - let mut file = identity.to_file(); - file.public_key = Some("invalid-public-key".to_string()); - let json = serde_json::to_string(&file).unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, json).unwrap(); - - let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); - assert!(matches!(err, IdentityError::InvalidPublicKey(_))); -} - -#[test] -fn save_json_rejects_directory_target() { - let identity = fixture_identity(FIXTURE_ALICE); - let dir = tempfile::tempdir().unwrap(); - let err = identity.save_json(dir.path()).unwrap_err(); - assert!(matches!(err, IdentityError::Store(_))); -} - -#[cfg(unix)] -#[test] -fn save_json_reports_write_failure_on_read_only_directory() { - use std::os::unix::fs::PermissionsExt; - - let identity = fixture_identity(FIXTURE_ALICE); - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - identity.save_json(path.as_path()).unwrap(); - - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); - let err_path = identity.save_json(path.as_path()).unwrap_err(); - assert!(matches!(err_path, IdentityError::Store(_))); - let err_path_buf = identity.save_json(&path).unwrap_err(); - assert!(matches!(err_path_buf, IdentityError::Store(_))); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); -} - -#[cfg(unix)] -#[test] -fn load_or_generate_reports_save_failure_when_parent_not_writable() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().unwrap(); - let parent = dir.path().join("readonly"); - std::fs::create_dir(&parent).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap(); - - let path = parent.join("identity.json"); - let err = RadrootsIdentity::load_or_generate::<&std::path::Path>(Some(path.as_path()), true) - .unwrap_err(); - assert!(matches!(err, IdentityError::Store(_))); - let err_path_buf = RadrootsIdentity::load_or_generate(Some(&path), true).unwrap_err(); - assert!(matches!(err_path_buf, IdentityError::Store(_))); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); -} - -#[test] -fn load_or_generate_uses_default_path_when_missing() { - let resolver = RadrootsPathResolver::new( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(PathBuf::from("/home/treesap")), - ..RadrootsHostEnvironment::default() - }, - ); - let default_path = RadrootsIdentity::default_path_for( - &resolver, - RadrootsPathProfile::InteractiveUser, - &RadrootsPathOverrides::default(), - ) - .unwrap(); - - let denied = RadrootsIdentity::load_or_generate::<&std::path::Path>(Some(&default_path), false) - .unwrap_err(); - assert!(matches!(denied, IdentityError::GenerationNotAllowed(path) if path == default_path)); - assert_eq!( - default_path.file_name().and_then(std::ffi::OsStr::to_str), - Some(DEFAULT_IDENTITY_PATH) - ); - assert_eq!( - default_path, - PathBuf::from("/home/treesap/.radroots/secrets/shared/identities/default.json") - ); -} - -#[test] -fn default_path_matches_current_resolver_default_path() { - let expected = RadrootsIdentity::default_path_for( - &RadrootsPathResolver::current(), - RadrootsPathProfile::InteractiveUser, - &RadrootsPathOverrides::default(), - ) - .unwrap(); - - assert_eq!(RadrootsIdentity::default_path().unwrap(), expected); -} - -#[test] -fn default_path_for_reports_missing_home_dir() { - let resolver = - RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); - let err = RadrootsIdentity::default_path_for( - &resolver, - RadrootsPathProfile::InteractiveUser, - &RadrootsPathOverrides::default(), - ) - .unwrap_err(); - assert!(matches!(err, IdentityError::Paths(_))); -} - -#[test] -fn load_or_generate_without_explicit_path_propagates_default_path_errors() { - let _lock = home_env_lock().lock().unwrap(); - let _guard = EnvVarGuard::remove("HOME"); - - let err = RadrootsIdentity::load_or_generate::<&std::path::Path>(None, false).unwrap_err(); - assert!(matches!(err, IdentityError::Paths(_))); -} - -#[test] -fn load_or_generate_creates_at_explicit_default_path() { - let dir = tempfile::tempdir().unwrap(); - let default_path = dir.path().join(DEFAULT_IDENTITY_PATH); - let generated = - RadrootsIdentity::load_or_generate::<&std::path::Path>(Some(&default_path), true).unwrap(); - assert!(default_path.exists()); - - let loaded = RadrootsIdentity::load_from_path_auto(&default_path).unwrap(); - assert_eq!(generated.public_key(), loaded.public_key()); -} - -#[test] -fn load_or_generate_prefers_existing_path() { - let identity = fixture_identity(FIXTURE_ALICE); - let payload = serde_json::to_string(&identity.to_file()).unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, payload).unwrap(); - - let loaded = RadrootsIdentity::load_or_generate(Some(&path), false).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn path_ref_variants_cover_success_paths() { - let identity = fixture_identity(FIXTURE_ALICE); - let dir = tempfile::tempdir().unwrap(); - - let saved_path = dir.path().join("saved.json"); - identity.save_json(saved_path.as_path()).unwrap(); - let loaded = RadrootsIdentity::load_from_path_auto(saved_path.as_path()).unwrap(); - assert_eq!(loaded.public_key(), identity.public_key()); - - let generated_path = dir.path().join("generated.json"); - let generated = - RadrootsIdentity::load_or_generate(Some(generated_path.as_path()), true).unwrap(); - assert!(generated_path.exists()); - let roundtrip = RadrootsIdentity::load_from_path_auto(generated_path.as_path()).unwrap(); - assert_eq!(generated.public_key(), roundtrip.public_key()); -} - -#[test] -fn generate_with_profile_retains_profile() { - let profile = profile_with_identifier("runtime-user"); - let identity = RadrootsIdentity::generate_with_profile(profile); - assert_eq!( - identity.profile().and_then(|p| p.identifier.as_deref()), - Some("runtime-user") - ); -} - -#[test] -fn identity_profile_is_empty_checks_metadata_and_application_handler() { - let profile_with_metadata = RadrootsIdentityProfile { - metadata: Some(sample_event("metadata")), - ..Default::default() - }; - assert!(!profile_with_metadata.is_empty()); - - let profile_with_handler = RadrootsIdentityProfile { - application_handler: Some(sample_event("handler")), - ..Default::default() - }; - assert!(!profile_with_handler.is_empty()); -} - -#[test] -fn identity_error_display_variants_are_exercised() { - let missing_path = PathBuf::from("/tmp/missing-identity.json"); - assert_eq!( - IdentityError::NotFound(missing_path.clone()).to_string(), - format!("identity file missing at {}", missing_path.display()) - ); - assert_eq!( - IdentityError::GenerationNotAllowed(missing_path.clone()).to_string(), - format!( - "identity file missing at {} and generation is not permitted (pass --allow-generate-identity)", - missing_path.display() - ) - ); - assert!( - IdentityError::Read(missing_path.clone(), std::io::Error::other("boom")) - .to_string() - .contains("failed to read identity file") - ); - - let json_err = serde_json::from_str::<serde_json::Value>("{").unwrap_err(); - assert!( - IdentityError::InvalidJson(json_err) - .to_string() - .contains("invalid identity JSON") - ); - - let secret_err = nostr::Keys::parse("not-a-secret-key").unwrap_err(); - assert!( - IdentityError::InvalidSecretKey(secret_err) - .to_string() - .contains("invalid secret key") - ); - - #[cfg(feature = "nip49")] - { - assert_eq!( - IdentityError::EncryptSecretKey("encrypt failed".into()).to_string(), - "failed to encrypt secret key: encrypt failed" - ); - assert_eq!( - IdentityError::InvalidEncryptedSecretKey("bad payload".into()).to_string(), - "invalid encrypted secret key: bad payload" - ); - assert_eq!( - IdentityError::DecryptEncryptedSecretKey("bad password".into()).to_string(), - "failed to decrypt encrypted secret key: bad password" - ); - } - - assert_eq!( - IdentityError::InvalidPublicKey("bad-pubkey".into()).to_string(), - "invalid public key: bad-pubkey" - ); - assert_eq!( - IdentityError::PublicKeyMismatch.to_string(), - "public key does not match secret key" - ); - assert_eq!( - IdentityError::InvalidIdentityFormat.to_string(), - "unsupported identity file format" - ); - - #[cfg(all(feature = "std", feature = "json-file"))] - { - let store_err = fixture_identity(FIXTURE_ALICE) - .save_json(tempfile::tempdir().unwrap().path()) - .unwrap_err(); - assert!(!store_err.to_string().is_empty()); - } - - let paths_err = IdentityError::from( - radroots_runtime_paths::RadrootsRuntimePathsError::MissingHomeDir { - platform: RadrootsPlatform::Linux, - }, - ); - assert_eq!( - paths_err.to_string(), - "interactive_user on linux requires a home directory" - ); -} - -#[cfg(feature = "secrecy")] -#[test] -fn secret_key_hex_secret_returns_secret_string() { - use secrecy::ExposeSecret; - - let identity = fixture_identity(FIXTURE_ALICE); - let secret = identity.secret_key_hex_secret(); - assert_eq!(secret.expose_secret(), &identity.secret_key_hex()); -} - -#[cfg(feature = "zeroize")] -#[test] -fn secret_key_zeroizing_bytes_matches_raw_secret() { - let identity = fixture_identity(FIXTURE_ALICE); - let raw = identity.secret_key_bytes(); - let protected = identity.secret_key_bytes_zeroizing(); - assert_eq!(&*protected, &raw); -} - -#[test] -fn encrypted_identity_storage_public_api_round_trips_and_reports_errors() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("identity.enc.json"); - let identity = fixture_identity(FIXTURE_ALICE); - - let default_file = RadrootsEncryptedIdentityFile::new(path.as_path()); - assert_eq!(default_file.path(), path.as_path()); - assert_eq!( - default_file.key_slot(), - RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT - ); - assert_eq!( - default_file.wrapping_key_path(), - encrypted_identity_wrapping_key_path(path.as_path()) - ); - - let custom_file = - RadrootsEncryptedIdentityFile::with_key_slot(path.as_path(), "field_identity"); - assert_eq!(custom_file.key_slot(), "field_identity"); - - store_encrypted_identity(path.as_path(), &identity).unwrap(); - rotate_encrypted_identity(path.as_path()).unwrap(); - let loaded = load_encrypted_identity(path.as_path()).unwrap(); - assert_eq!(loaded.public_key(), identity.public_key()); - - store_encrypted_identity_with_key_slot(path.as_path(), "field_identity", &identity).unwrap(); - rotate_encrypted_identity_with_key_slot(path.as_path(), "field_identity").unwrap(); - let loaded = load_encrypted_identity_with_key_slot(path.as_path(), "field_identity").unwrap(); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - - let path_buf_api = temp.path().join("identity-pathbuf.enc.json"); - let path_buf_ref = &path_buf_api; - let path_buf_file = RadrootsEncryptedIdentityFile::new(path_buf_ref); - assert_eq!(path_buf_file.path(), path_buf_api.as_path()); - let path_buf_file = - RadrootsEncryptedIdentityFile::with_key_slot(path_buf_ref, "path_buf_identity"); - assert_eq!(path_buf_file.key_slot(), "path_buf_identity"); - store_encrypted_identity(path_buf_ref, &identity).unwrap(); - rotate_encrypted_identity(path_buf_ref).unwrap(); - let loaded = load_encrypted_identity(path_buf_ref).unwrap(); - assert_eq!(loaded.public_key(), identity.public_key()); - store_encrypted_identity_with_key_slot(path_buf_ref, "path_buf_identity", &identity).unwrap(); - rotate_encrypted_identity_with_key_slot(path_buf_ref, "path_buf_identity").unwrap(); - let loaded = load_encrypted_identity_with_key_slot(path_buf_ref, "path_buf_identity").unwrap(); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - - let missing = temp.path().join("missing.enc.json"); - let missing_error = load_encrypted_identity(missing.as_path()).unwrap_err(); - assert!(matches!(missing_error, IdentityError::NotFound(error_path) if error_path == missing)); - - let read_error = load_encrypted_identity(temp.path()).unwrap_err(); - assert!(matches!(read_error, IdentityError::Read(error_path, _) if error_path == temp.path())); - - let invalid = temp.path().join("invalid.enc.json"); - std::fs::write(&invalid, b"not-json").unwrap(); - let decode_error = load_encrypted_identity(invalid.as_path()).unwrap_err(); - assert!(matches!( - decode_error, - IdentityError::ProtectedStorage { path: error_path, message } - if error_path == invalid && message.contains("decode encrypted identity") - )); - - let invalid_plaintext = temp.path().join("invalid-plaintext.enc.json"); - let key_source = RadrootsProtectedFileKeySource::from_sidecar_suffix( - invalid_plaintext.as_path(), - RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, - ); - let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key( - &key_source, - RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, - b"not identity json", - ) - .unwrap(); - std::fs::write(&invalid_plaintext, envelope.encode_json().unwrap()).unwrap(); - let invalid_plaintext_error = load_encrypted_identity(invalid_plaintext.as_path()).unwrap_err(); - assert!(matches!( - invalid_plaintext_error, - IdentityError::InvalidJson(_) - )); - - std::fs::write( - encrypted_identity_wrapping_key_path(path.as_path()), - b"short", - ) - .unwrap(); - let open_error = load_encrypted_identity(path.as_path()).unwrap_err(); - assert!(matches!( - open_error, - IdentityError::ProtectedStorage { path: error_path, message } - if error_path == path && message.contains("open encrypted identity") - )); -} - -#[test] -fn encrypted_identity_storage_public_api_reports_store_errors() { - let temp = tempfile::tempdir().unwrap(); - let identity = fixture_identity(FIXTURE_ALICE); - - let blocked_parent = temp.path().join("blocked-parent"); - std::fs::write(&blocked_parent, b"not-a-directory").unwrap(); - let create_path = blocked_parent.join("identity.enc.json"); - let create_error = store_encrypted_identity(create_path.as_path(), &identity).unwrap_err(); - assert!(matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent)); - - let directory_path = temp.path().join("identity-as-directory.enc.json"); - std::fs::create_dir(&directory_path).unwrap(); - let write_error = store_encrypted_identity(directory_path.as_path(), &identity).unwrap_err(); - assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path)); - - let sealed_path = temp.path().join("seal-error.enc.json"); - std::fs::create_dir(encrypted_identity_wrapping_key_path(sealed_path.as_path())).unwrap(); - let seal_error = store_encrypted_identity(sealed_path.as_path(), &identity).unwrap_err(); - assert!(matches!( - seal_error, - IdentityError::ProtectedStorage { path, message } - if path == sealed_path && message.contains("seal encrypted identity") - )); -} - -#[cfg(unix)] -#[test] -fn encrypted_identity_storage_public_api_restores_key_after_rotation_failure() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("identity.enc.json"); - let identity = fixture_identity(FIXTURE_ALICE); - - store_encrypted_identity(path.as_path(), &identity).unwrap(); - let key_path = encrypted_identity_wrapping_key_path(path.as_path()); - let key_before = std::fs::read(&key_path).unwrap(); - - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap(); - let error = rotate_encrypted_identity(path.as_path()).unwrap_err(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); - - assert!(matches!(error, IdentityError::Write(error_path, _) if error_path == path)); - assert_eq!(std::fs::read(&key_path).unwrap(), key_before); - let loaded = load_encrypted_identity(path.as_path()).unwrap(); - assert_eq!(loaded.public_key(), identity.public_key()); -} - -#[test] -fn identity_profile_storage_public_api_reports_errors_and_private_fallback() { - let temp = tempfile::tempdir().unwrap(); - let identity = fixture_identity(FIXTURE_ALICE); - - let path = temp.path().join("profile.json"); - store_identity_profile(path.as_path(), &identity).unwrap(); - let loaded = load_identity_profile(path.as_path()).unwrap(); - assert_eq!(loaded.id, identity.id()); - - let path_buf_profile = temp.path().join("profile-pathbuf.json"); - store_identity_profile(&path_buf_profile, &identity).unwrap(); - let loaded = load_identity_profile(&path_buf_profile).unwrap(); - assert_eq!(loaded.id, identity.id()); - - let blocked_parent = temp.path().join("blocked-profile-parent"); - std::fs::write(&blocked_parent, b"not-a-directory").unwrap(); - let create_path = blocked_parent.join("profile.json"); - let create_error = store_identity_profile(create_path.as_path(), &identity).unwrap_err(); - assert!(matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent)); - - let directory_path = temp.path().join("profile-as-directory.json"); - std::fs::create_dir(&directory_path).unwrap(); - let write_error = store_identity_profile(directory_path.as_path(), &identity).unwrap_err(); - assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path)); - - let missing = temp.path().join("missing-profile.json"); - let missing_error = load_identity_profile(missing.as_path()).unwrap_err(); - assert!(matches!(missing_error, IdentityError::NotFound(path) if path == missing)); - - let read_error = load_identity_profile(temp.path()).unwrap_err(); - assert!(matches!(read_error, IdentityError::Read(path, _) if path == temp.path())); - - let private_profile = temp.path().join("private-profile.json"); - std::fs::write( - &private_profile, - serde_json::to_vec(&identity.to_file()).unwrap(), - ) - .unwrap(); - let loaded = load_identity_profile(private_profile.as_path()).unwrap(); - assert_eq!(loaded.public_key_hex, FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn storage_public_api_supports_parentless_relative_files() { - let temp = tempfile::tempdir().unwrap(); - let previous = std::env::current_dir().unwrap(); - std::env::set_current_dir(temp.path()).unwrap(); - - let identity = fixture_identity(FIXTURE_ALICE); - let encrypted_path = std::path::Path::new("identity.enc.json"); - store_encrypted_identity(encrypted_path, &identity).unwrap(); - let loaded = load_encrypted_identity(encrypted_path).unwrap(); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - - let profile_path = std::path::Path::new("profile.json"); - store_identity_profile(profile_path, &identity).unwrap(); - let loaded = load_identity_profile(profile_path).unwrap(); - assert_eq!(loaded.id, identity.id()); - - let empty_path = std::path::Path::new(""); - let encrypted_error = store_encrypted_identity(empty_path, &identity).unwrap_err(); - assert!(matches!(encrypted_error, IdentityError::Write(_, _))); - let profile_error = store_identity_profile(empty_path, &identity).unwrap_err(); - assert!(matches!(profile_error, IdentityError::Write(_, _))); - - std::env::set_current_dir(previous).unwrap(); -} diff --git a/crates/identity/tests/package_boundary.rs b/crates/identity/tests/package_boundary.rs @@ -0,0 +1,173 @@ +use std::{collections::BTreeSet, fs, path::Path}; + +use radroots_identity::{ + AccountId, Error, IdentityId, Profile, PublicIdentity, PublicKey, Username, + account::{Record, Status}, +}; + +const MANIFEST: &str = include_str!("../Cargo.toml"); +const ROOT: &str = include_str!("../src/lib.rs"); + +#[test] +fn manifest_has_exact_features_and_dependencies() { + assert_eq!( + table_keys(MANIFEST, "[features]"), + BTreeSet::from(["default", "serde", "std"]) + ); + assert_eq!( + table_keys(MANIFEST, "[dependencies]"), + BTreeSet::from(["k256", "serde", "thiserror"]) + ); + assert_eq!( + table_keys(MANIFEST, "[dev-dependencies]"), + BTreeSet::from(["serde_json"]) + ); + assert!(MANIFEST.contains("default = [\"std\", \"serde\"]")); + assert!(MANIFEST.contains("std = [\"thiserror/std\"]")); + assert!(MANIFEST.contains("serde = [\"dep:serde\"]")); + assert!(MANIFEST.contains( + "k256 = { version = \"0.13\", default-features = false, features = [\"arithmetic\"] }" + )); + assert!(MANIFEST.contains("serde = { workspace = true, optional = true }")); + assert!(MANIFEST.contains("thiserror = { version = \"2\", default-features = false }")); + + for forbidden in [ + "json-file", + "nostr", + "radroots_protected_store", + "radroots_runtime", + "radroots_runtime_paths", + "radroots_secret_vault", + "tracing", + "tempfile", + ] { + assert!( + !MANIFEST.contains(forbidden), + "identity manifest must not contain host persistence edge {forbidden}" + ); + } +} + +#[test] +fn crate_root_matches_the_curated_module_and_export_contract() { + assert!(ROOT.contains("#![cfg_attr(not(feature = \"std\"), no_std)]")); + assert_eq!( + root_declarations("pub mod "), + BTreeSet::from(["account", "key", "profile", "username"]) + ); + assert_eq!(root_declarations("mod "), BTreeSet::from(["error"])); + assert_eq!( + ROOT.lines() + .map(str::trim) + .filter(|line| line.starts_with("pub use ")) + .collect::<BTreeSet<_>>(), + BTreeSet::from([ + "pub use account::AccountId;", + "pub use error::Error;", + "pub use key::{IdentityId, PublicKey};", + "pub use profile::{Profile, PublicIdentity};", + "pub use username::Username;", + ]) + ); +} + +#[test] +fn intended_public_paths_and_traits_compile() { + fn assert_public_value<T: Clone + core::fmt::Debug + Eq + Send + Sync>() {} + + assert_public_value::<AccountId>(); + assert_public_value::<IdentityId>(); + assert_public_value::<Profile>(); + assert_public_value::<PublicIdentity>(); + assert_public_value::<PublicKey>(); + assert_public_value::<Record>(); + assert_public_value::<Status>(); + assert_public_value::<Username>(); + let _ = core::mem::size_of::<Error>(); +} + +#[test] +fn production_sources_have_no_forbidden_owners_or_public_traits() { + let source_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut sources = Vec::new(); + collect_rust_sources(&source_root, &mut sources); + assert!(!sources.is_empty()); + + for path in sources { + let source = fs::read_to_string(&path).expect("read identity source"); + let production = source.split("\n#[cfg(test)]").next().unwrap_or(&source); + for line in production.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("//") { + continue; + } + assert!( + !trimmed.starts_with("pub trait ") && !trimmed.starts_with("pub unsafe trait "), + "identity must not publish traits: {}: {trimmed}", + path.display() + ); + for forbidden in [ + "RadrootsIdentity", + "RadrootsSecret", + "SecretKey", + "PrivateKey", + "secret_key", + "Nsec", + "nsec::", + "nip49", + "Nip49", + "nostr::", + "std::fs", + "std::path", + "PathBuf", + "Sqlite", + "keyring", + ] { + assert!( + !line.contains(forbidden), + "identity production source must not contain {forbidden}: {}: {trimmed}", + path.display() + ); + } + } + } +} + +fn table_keys<'a>(manifest: &'a str, heading: &str) -> BTreeSet<&'a str> { + let table = manifest + .split_once(heading) + .unwrap_or_else(|| panic!("missing manifest table {heading}")) + .1; + table + .lines() + .skip(1) + .take_while(|line| !line.trim_start().starts_with('[')) + .filter_map(|line| { + let line = line.trim(); + (!line.is_empty() && !line.starts_with('#')) + .then(|| line.split_once('=').map(|(key, _)| key.trim())) + .flatten() + }) + .collect() +} + +fn root_declarations(prefix: &str) -> BTreeSet<&str> { + ROOT.lines() + .map(str::trim) + .filter_map(|line| { + line.strip_prefix(prefix) + .and_then(|name| name.strip_suffix(';')) + }) + .collect() +} + +fn collect_rust_sources(directory: &Path, paths: &mut Vec<std::path::PathBuf>) { + for entry in fs::read_dir(directory).expect("read identity source directory") { + let path = entry.expect("source directory entry").path(); + if path.is_dir() { + collect_rust_sources(&path, paths); + } else if path.extension().and_then(|value| value.to_str()) == Some("rs") { + paths.push(path); + } + } +} diff --git a/crates/identity/tests/public_identifiers.rs b/crates/identity/tests/public_identifiers.rs @@ -0,0 +1,99 @@ +use core::str::FromStr; + +use radroots_identity::{AccountId, Error, IdentityId, PublicKey}; + +const ALICE_PUBLIC_KEY: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; +const BOB_PUBLIC_KEY: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + +#[test] +fn public_key_canonicalizes_hex_and_round_trips_bytes() { + let uppercase = ALICE_PUBLIC_KEY.to_ascii_uppercase(); + let public_key = PublicKey::from_hex(&uppercase).expect("valid fixture public key"); + + assert_eq!(public_key.to_string(), ALICE_PUBLIC_KEY); + assert_eq!(public_key.to_hex(), ALICE_PUBLIC_KEY); + assert_eq!(PublicKey::from_str(ALICE_PUBLIC_KEY).unwrap(), public_key); + assert_eq!( + PublicKey::try_from(public_key.into_bytes()).unwrap(), + public_key + ); + assert_eq!( + PublicKey::try_from(public_key.as_ref()).unwrap(), + public_key + ); +} + +#[test] +fn public_key_rejects_noncanonical_lengths_characters_and_points() { + assert!(matches!( + PublicKey::from_hex("00"), + Err(Error::InvalidHexLength { + expected: PublicKey::HEX_LENGTH, + actual: 2, + }) + )); + + let mut invalid_hex = ALICE_PUBLIC_KEY.as_bytes().to_vec(); + invalid_hex[17] = b'g'; + let invalid_hex = core::str::from_utf8(&invalid_hex).unwrap(); + assert!(matches!( + PublicKey::from_hex(invalid_hex), + Err(Error::InvalidHexCharacter { index: 17 }) + )); + + assert!(matches!( + PublicKey::from_slice(&[1; PublicKey::BYTE_LENGTH - 1]), + Err(Error::InvalidByteLength { + expected: PublicKey::BYTE_LENGTH, + actual, + }) if actual == PublicKey::BYTE_LENGTH - 1 + )); + assert!(matches!( + PublicKey::from_bytes([0; PublicKey::BYTE_LENGTH]), + Err(Error::InvalidPublicKeyBytes) + )); +} + +#[test] +fn identity_and_account_identifiers_preserve_semantics_and_ordering() { + let alice_key = PublicKey::from_hex(ALICE_PUBLIC_KEY).unwrap(); + let bob_key = PublicKey::from_hex(BOB_PUBLIC_KEY).unwrap(); + let alice_identity = IdentityId::from_public_key(alice_key); + let alice_account = AccountId::from_identity_id(alice_identity); + + assert_eq!(alice_identity.to_hex(), ALICE_PUBLIC_KEY); + assert_eq!(alice_account.to_hex(), ALICE_PUBLIC_KEY); + assert_eq!( + IdentityId::from_hex(ALICE_PUBLIC_KEY).unwrap(), + alice_identity + ); + assert_eq!( + AccountId::from_hex(ALICE_PUBLIC_KEY).unwrap(), + alice_account + ); + assert!(alice_identity < IdentityId::from_public_key(bob_key)); +} + +#[cfg(feature = "serde")] +#[test] +fn public_identifiers_use_checked_canonical_serde_strings() { + let public_key = PublicKey::from_hex(ALICE_PUBLIC_KEY).unwrap(); + let identity_id = IdentityId::from(public_key); + let account_id = AccountId::from(identity_id); + + for encoded in [ + serde_json::to_string(&public_key).unwrap(), + serde_json::to_string(&identity_id).unwrap(), + serde_json::to_string(&account_id).unwrap(), + ] { + assert_eq!(encoded, format!("\"{ALICE_PUBLIC_KEY}\"")); + } + + assert_eq!( + serde_json::from_str::<PublicKey>(&format!("\"{}\"", ALICE_PUBLIC_KEY.to_uppercase())) + .unwrap(), + public_key + ); + assert!(serde_json::from_str::<IdentityId>("\"invalid\"").is_err()); + assert!(serde_json::from_str::<AccountId>("null").is_err()); +} diff --git a/crates/identity/tests/retired_surface.rs b/crates/identity/tests/retired_surface.rs @@ -0,0 +1,208 @@ +use std::{ + collections::BTreeSet, + fs, + path::{Path, PathBuf}, +}; + +const RELEASE_POLICY: &str = include_str!("../../../contracts/releases/publish_policy.toml"); + +const RETIRED_IDENTIFIERS: &[&str] = &[ + "IdentityError", + "RadrootsEncryptedIdentityFile", + "RadrootsIdentity", + "RadrootsIdentityEncryptedSecretKeyOptions", + "RadrootsIdentityEncryptedSecretKeySecurity", + "RadrootsIdentityFile", + "RadrootsIdentityId", + "RadrootsIdentityProfile", + "RadrootsIdentityPublic", + "RadrootsIdentitySecretKeyFormat", + concat!("Radroots", "PublicKey"), +]; + +const RETIRED_FUNCTIONS_AND_CONSTANTS: &[&str] = &[ + "DEFAULT_IDENTITY_PATH", + "RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT", + "RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX", + "encrypted_identity_wrapping_key_path", + "load_encrypted_identity", + "load_identity_profile", + "rotate_encrypted_identity", + "store_encrypted_identity", + "store_identity_profile", +]; + +#[test] +fn first_party_production_sources_do_not_restore_the_retired_identity_surface() { + let workspace_root = workspace_root(); + let mut sources = Vec::new(); + collect_production_rust_sources(&workspace_root.join("crates"), &mut sources); + assert!( + !sources.is_empty(), + "workspace production sources are required" + ); + + let mut findings = Vec::new(); + for path in sources { + let source = fs::read_to_string(&path).expect("read production source"); + for (line_index, line) in source.lines().enumerate() { + if line.trim_start().starts_with("//") { + continue; + } + for retired in RETIRED_IDENTIFIERS { + if contains_identifier(line, retired) { + findings.push(format!( + "{}:{} restores retired identity identifier `{retired}`", + relative_path(&workspace_root, &path), + line_index + 1, + )); + } + } + for retired in RETIRED_FUNCTIONS_AND_CONSTANTS { + if contains_identifier(line, retired) { + findings.push(format!( + "{}:{} restores retired identity operation `{retired}`", + relative_path(&workspace_root, &path), + line_index + 1, + )); + } + } + } + } + + assert!( + findings.is_empty(), + "retired identity surface violations:\n{}", + findings.join("\n") + ); +} + +#[test] +fn retired_identity_implementation_modules_are_absent() { + let identity_source = workspace_root().join("crates/identity/src"); + for retired in ["identity.rs", "storage.rs", "test_fixtures.rs"] { + assert!( + !identity_source.join(retired).exists(), + "retired identity module must remain absent: {retired}" + ); + } +} + +#[test] +fn release_policy_keeps_replacement_public_and_mixed_packages_private() { + assert!( + table(RELEASE_POLICY, "[publication]").contains("frozen = true"), + "publication must remain frozen during the refactor" + ); + + let approved = string_array(RELEASE_POLICY, "[publication]", "approved_packages"); + assert!(approved.contains("radroots_identity")); + for mixed in [ + "radroots_authority", + "radroots_nostr_accounts", + "radroots_nostr_signer", + ] { + assert!( + !approved.contains(mixed), + "mixed package must not be approved for publication: {mixed}" + ); + } + + let private = string_array(RELEASE_POLICY, "[workspace_classification]", "private"); + for mixed in [ + "radroots_authority", + "radroots_nostr_accounts", + "radroots_nostr_signer", + ] { + assert!( + private.contains(mixed), + "mixed package must remain explicitly private until its scheduled retirement: {mixed}" + ); + let manifest = workspace_root() + .join("crates") + .join(mixed.strip_prefix("radroots_").expect("package prefix")) + .join("Cargo.toml"); + assert!( + fs::read_to_string(&manifest) + .unwrap_or_else(|error| panic!("read {}: {error}", manifest.display())) + .lines() + .any(|line| line.trim() == "publish = false"), + "mixed package manifest must remain unpublished: {}", + manifest.display() + ); + } +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("lib workspace root") + .to_path_buf() +} + +fn collect_production_rust_sources(directory: &Path, paths: &mut Vec<PathBuf>) { + for entry in fs::read_dir(directory).expect("read workspace source directory") { + let path = entry.expect("workspace source entry").path(); + if path.is_dir() { + if path.file_name().and_then(|name| name.to_str()) != Some("target") { + collect_production_rust_sources(&path, paths); + } + } else if path.extension().and_then(|extension| extension.to_str()) == Some("rs") + && path + .components() + .any(|component| component.as_os_str() == "src") + { + paths.push(path); + } + } +} + +fn contains_identifier(source: &str, identifier: &str) -> bool { + source.match_indices(identifier).any(|(index, _)| { + let before = source[..index].chars().next_back(); + let after = source[index + identifier.len()..].chars().next(); + before.is_none_or(|character| !is_identifier_character(character)) + && after.is_none_or(|character| !is_identifier_character(character)) + }) +} + +fn is_identifier_character(character: char) -> bool { + character == '_' || character.is_ascii_alphanumeric() +} + +fn table<'a>(source: &'a str, heading: &str) -> &'a str { + let body = source + .split_once(heading) + .unwrap_or_else(|| panic!("missing table {heading}")) + .1; + body.split_once("\n[").map_or(body, |(current, _)| current) +} + +fn string_array<'a>(source: &'a str, heading: &str, key: &str) -> BTreeSet<&'a str> { + let table = table(source, heading); + let marker = format!("{key} = ["); + let values = table + .split_once(&marker) + .unwrap_or_else(|| panic!("missing {heading} {key}")) + .1 + .split_once(']') + .unwrap_or_else(|| panic!("unterminated {heading} {key}")) + .0; + values + .split(',') + .map(str::trim) + .filter_map(|value| { + value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + }) + .collect() +} + +fn relative_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .expect("source path is under workspace root") + .to_string_lossy() + .replace('\\', "/") +} diff --git a/crates/log/Cargo.toml b/crates/log/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_log" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/net/src/net.rs b/crates/net/src/net.rs @@ -148,10 +148,9 @@ impl Net { pub fn selected_nostr_keys(&self) -> Option<radroots_nostr::prelude::RadrootsNostrKeys> { let signer = self.selected_nostr_signer()?; self.accounts - .resolve_signing_identity_for_signer(&signer) + .resolve_signing_keys_for_signer(&signer) .ok() .flatten() - .map(|identity| identity.into_keys()) } } @@ -172,13 +171,23 @@ impl NetHandle { mod tests { use crate::builder::NetBuilder; #[cfg(feature = "nostr-client")] - use radroots_identity::RadrootsIdentity; + use radroots_identity::{PublicIdentity, PublicKey}; + #[cfg(feature = "nostr-client")] + use radroots_nostr::prelude::RadrootsNostrKeys; #[cfg(feature = "nostr-client")] use radroots_nostr_signer::prelude::{ RadrootsNostrRemoteSessionSignerCapability, RadrootsNostrSignerCapability, RadrootsNostrSignerConnectionId, }; + #[cfg(feature = "nostr-client")] + fn public_identity() -> PublicIdentity { + let keys = RadrootsNostrKeys::generate(); + PublicIdentity::new( + PublicKey::from_hex(&keys.public_key().to_hex()).expect("public identity"), + ) + } + #[test] fn builds_minimal() { let cfg = crate::config::NetConfig::default(); @@ -217,7 +226,7 @@ mod tests { assert!(net.selected_nostr_signer().is_none()); net.accounts - .generate_identity(Some("primary".into()), true) + .generate_keys(Some("primary".into()), true) .expect("generate account"); assert!(net.selected_nostr_keys().is_some()); assert!(net.selected_nostr_signer().is_some()); @@ -225,8 +234,8 @@ mod tests { let remote = RadrootsNostrSignerCapability::RemoteSession(Box::new( RadrootsNostrRemoteSessionSignerCapability::new( RadrootsNostrSignerConnectionId::new_v7(), - RadrootsIdentity::generate().to_public(), - RadrootsIdentity::generate().to_public(), + public_identity(), + public_identity(), ), )); net.set_nostr_signer(Some(remote.clone())); diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml @@ -1,16 +1,22 @@ [package] name = "radroots_nostr" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] +authors.workspace = true rust-version.workspace = true license.workspace = true description = "Nostr integration layer for Radroots" repository.workspace = true homepage.workspace = true documentation = "https://docs.rs/radroots_nostr" -readme = "README" +readme = "README.md" + +[lib] +name = "radroots_nostr" + +[lints] +workspace = true [features] default = ["std"] @@ -21,6 +27,7 @@ codec = ["dep:radroots_event_codec", "radroots_event_codec/nostr"] events = [ "std", "dep:radroots_event_codec", + "dep:radroots_identity", "radroots_event/std", "radroots_event/serde", "radroots_event_codec/nostr", diff --git a/crates/nostr/README b/crates/nostr/README.md diff --git a/crates/nostr/src/blossom.rs b/crates/nostr/src/blossom.rs @@ -8,9 +8,11 @@ use base64::{ engine::general_purpose::{GeneralPurpose, NO_PAD, URL_SAFE_NO_PAD}, }; use radroots_blossom::{ - RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, RadrootsBlossomAuthoredUploadClaim, - RadrootsBlossomAuthorizationValidation, RadrootsBlossomError, - RadrootsBlossomParsedAuthorizationClaim, RadrootsBlossomValidatedAuthorizationClaim, + AuthorizationClaim, Error, + authorization::{ + AuthoredUploadClaim, AuthorizationValidation, RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, + ValidatedAuthorizationClaim, + }, }; use crate::types::{ @@ -89,7 +91,7 @@ impl AsRef<str> for RadrootsNostrBlossomAuthorizationHeader { #[derive(Clone, PartialEq, Eq)] pub struct RadrootsNostrVerifiedBlossomAuthorization { event: RadrootsNostrEvent, - claim: RadrootsBlossomValidatedAuthorizationClaim, + claim: ValidatedAuthorizationClaim, } impl fmt::Debug for RadrootsNostrVerifiedBlossomAuthorization { @@ -116,7 +118,7 @@ impl RadrootsNostrVerifiedBlossomAuthorization { self.event.created_at } - pub fn claim(&self) -> &RadrootsBlossomValidatedAuthorizationClaim { + pub fn claim(&self) -> &ValidatedAuthorizationClaim { &self.claim } } @@ -137,7 +139,7 @@ pub enum RadrootsNostrBlossomError { InvalidEventId, InvalidEventSignature, EventSigning, - BlossomClaim(RadrootsBlossomError), + BlossomClaim(Error), } impl RadrootsNostrBlossomError { @@ -159,7 +161,7 @@ impl RadrootsNostrBlossomError { } } - pub fn blossom_claim_error(&self) -> Option<&RadrootsBlossomError> { + pub fn blossom_claim_error(&self) -> Option<&Error> { match self { Self::BlossomClaim(error) => Some(error), _ => None, @@ -210,8 +212,8 @@ impl fmt::Display for RadrootsNostrBlossomError { impl std::error::Error for RadrootsNostrBlossomError {} -impl From<RadrootsBlossomError> for RadrootsNostrBlossomError { - fn from(error: RadrootsBlossomError) -> Self { +impl From<Error> for RadrootsNostrBlossomError { + fn from(error: Error) -> Self { Self::BlossomClaim(error) } } @@ -219,7 +221,7 @@ impl From<RadrootsBlossomError> for RadrootsNostrBlossomError { /// Sign a direct kind-24242 event from a strict authored BUD-11 upload claim. pub fn radroots_nostr_sign_blossom_authorization( keys: &RadrootsNostrKeys, - claim: &RadrootsBlossomAuthoredUploadClaim, + claim: &AuthoredUploadClaim, ) -> Result<RadrootsNostrSignedBlossomAuthorization, RadrootsNostrBlossomError> { let wire = claim.wire_parts(); let tags = wire @@ -268,7 +270,7 @@ pub fn radroots_nostr_encode_blossom_authorization_header( /// Decode, authenticate, parse, and validate a BUD-11 authorization value. pub fn radroots_nostr_decode_verify_blossom_authorization_header( header: &str, - validation: &RadrootsBlossomAuthorizationValidation, + validation: &AuthorizationValidation, ) -> Result<RadrootsNostrVerifiedBlossomAuthorization, RadrootsNostrBlossomError> { if header.trim_start() != header { return Err(RadrootsNostrBlossomError::InvalidHeaderWhitespace); @@ -317,12 +319,8 @@ pub fn radroots_nostr_decode_verify_blossom_authorization_header( .iter() .map(|tag| tag.as_slice().to_vec()) .collect(); - let claim = RadrootsBlossomParsedAuthorizationClaim::parse( - &event.content, - event.created_at.as_secs(), - &tags, - )? - .validate(validation)?; + let claim = AuthorizationClaim::parse(&event.content, event.created_at.as_secs(), &tags)? + .validate(validation)?; Ok(RadrootsNostrVerifiedBlossomAuthorization { event, claim }) } diff --git a/crates/nostr/src/client.rs b/crates/nostr/src/client.rs @@ -6,7 +6,6 @@ use std::collections::HashMap; use std::net::SocketAddr; use nostr_sdk::{Client, ClientBuilder, ClientOptions}; -use radroots_identity::RadrootsIdentity; use crate::error::RadrootsNostrError; #[cfg(feature = "events")] @@ -141,14 +140,6 @@ impl RadrootsNostrClient { Self { inner } } - pub fn from_identity(identity: &RadrootsIdentity) -> Self { - Self::new(identity.keys().clone()) - } - - pub fn from_identity_owned(identity: RadrootsIdentity) -> Self { - Self::new(identity.into_keys()) - } - pub fn from_inner(inner: Client) -> Self { Self { inner } } @@ -474,6 +465,7 @@ pub async fn radroots_nostr_fetch_event_by_id( mod tests { use super::{RadrootsNostrClient, RadrootsNostrClientOptions}; use crate::error::RadrootsNostrError; + use crate::test_fixtures::FIXTURE_BOB_PUBLIC_KEY_HEX; use crate::types::{ RadrootsNostrFilter, RadrootsNostrGenericEventBuilder, RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrSecretKey, RadrootsNostrSubscriptionId, RadrootsNostrTag, @@ -638,7 +630,7 @@ mod tests { let client = RadrootsNostrClient::new(keys); let reference = radroots_event::reply::RadrootsNip10ReplyReference::parse( "a".repeat(64), - "b".repeat(64), + FIXTURE_BOB_PUBLIC_KEY_HEX, None, ) .expect("reference"); diff --git a/crates/nostr/src/draft_signing.rs b/crates/nostr/src/draft_signing.rs @@ -5,9 +5,10 @@ use crate::events::radroots_nostr_build_event_unchecked; use crate::types::{RadrootsNostrKeys, RadrootsNostrTimestamp}; use nostr::JsonUtil; use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent}; -use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; +use radroots_event::ids::RadrootsEventId; use radroots_event::wire::RadrootsNip01EventWire; use radroots_event_codec::wire::publication::RadrootsPhase1PublicationDraft; +use radroots_identity::PublicKey; pub fn radroots_nostr_sign_frozen_draft( keys: &RadrootsNostrKeys, @@ -15,9 +16,9 @@ pub fn radroots_nostr_sign_frozen_draft( ) -> Result<RadrootsSignedEvent, RadrootsNostrError> { draft.validate_for_signing()?; let actual_pubkey = keys.public_key().to_hex(); - if actual_pubkey != draft.expected_pubkey_str() { + if actual_pubkey != draft.expected_pubkey().to_hex() { return Err(RadrootsNostrError::FrozenDraftPubkeyMismatch { - expected_pubkey: draft.expected_pubkey_str().to_owned(), + expected_pubkey: draft.expected_pubkey().to_hex().to_owned(), actual_pubkey, }); } @@ -45,13 +46,14 @@ pub fn radroots_nostr_sign_frozen_draft( pub fn radroots_nostr_sign_phase1_publication_draft( keys: &RadrootsNostrKeys, draft: &RadrootsPhase1PublicationDraft, - expected_pubkey: &RadrootsPublicKey, + expected_pubkey: &PublicKey, expected_event_id: &RadrootsEventId, ) -> Result<RadrootsSignedEvent, RadrootsNostrError> { let actual_pubkey = keys.public_key().to_hex(); - if actual_pubkey != expected_pubkey.as_str() { + let expected_pubkey = expected_pubkey.to_hex(); + if actual_pubkey != expected_pubkey { return Err(RadrootsNostrError::FrozenDraftPubkeyMismatch { - expected_pubkey: expected_pubkey.as_str().to_owned(), + expected_pubkey, actual_pubkey, }); } @@ -110,7 +112,7 @@ mod tests { let signed = radroots_nostr_sign_frozen_draft(&keys, &draft).expect("signed event"); assert_eq!(signed.id_str(), draft.expected_event_id_str()); - assert_eq!(signed.pubkey_str(), draft.expected_pubkey_str()); + assert_eq!(signed.pubkey().to_hex(), draft.expected_pubkey().to_hex()); assert_eq!(signed.created_at(), draft.created_at_u64()); assert_eq!(signed.kind(), draft.kind_u32()); assert_eq!(signed.tags_as_vec(), draft.tags_as_vec()); diff --git a/crates/nostr/src/event_verify.rs b/crates/nostr/src/event_verify.rs @@ -89,7 +89,7 @@ mod tests { ) -> RadrootsEventEnvelope { RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: event.id_str().to_owned(), - author: event.author_str().to_owned(), + author: event.author().to_hex().to_owned(), created_at: event.created_at_u64(), kind, tags: event.tags_as_vec(), @@ -173,7 +173,7 @@ mod tests { let original = signed_event(); let kind = u32::from(u16::MAX) + 1; let id = compute_canonical_nip01_event_id( - original.author_str(), + &original.author().to_hex(), original.created_at_u64(), kind, &original.tags_as_vec(), @@ -183,7 +183,7 @@ mod tests { .into_string(); let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id, - author: original.author_str().to_owned(), + author: original.author().to_hex().to_owned(), created_at: original.created_at_u64(), kind, tags: original.tags_as_vec(), diff --git a/crates/nostr/src/events/comment.rs b/crates/nostr/src/events/comment.rs @@ -60,6 +60,7 @@ fn builder_from_wire_parts( #[cfg(test)] mod tests { use super::*; + use crate::test_fixtures::FIXTURE_BOB_PUBLIC_KEY_HEX; use radroots_event::{ comment::{RadrootsAuthoredNip22Comment, RadrootsNip22EventRootReference}, kinds::{KIND_CLASSIFIED_LISTING, KIND_COMMENT}, @@ -69,7 +70,7 @@ mod tests { fn typed_comment_builder_signs_exact_kind_and_tags() { let root = RadrootsNip22EventRootReference::parse( "a".repeat(64), - "b".repeat(64), + FIXTURE_BOB_PUBLIC_KEY_HEX, KIND_CLASSIFIED_LISTING, None, ) diff --git a/crates/nostr/src/job_adapter.rs b/crates/nostr/src/job_adapter.rs @@ -37,8 +37,8 @@ impl<'a> JobEventBorrow<'a> for RadrootsNostrEventAdapter<'a> { &self.id_hex } #[inline] - fn raw_author(&'a self) -> &'a str { - &self.author_hex + fn raw_author(&'a self) -> String { + self.author_hex.clone() } #[inline] fn raw_content(&'a self) -> &'a str { diff --git a/crates/nostr/tests/blossom_conformance.rs b/crates/nostr/tests/blossom_conformance.rs @@ -2,11 +2,13 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use radroots_blossom::{ - RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS, - RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, RadrootsBlossomAuthoredUploadClaim, - RadrootsBlossomAuthorizationContent, RadrootsBlossomAuthorizationTarget, - RadrootsBlossomAuthorizationValidation, RadrootsBlossomParsedAuthorizationClaim, - RadrootsBlossomServerDomain, RadrootsBlossomServerScopeRequirement, RadrootsBlossomSha256, + Sha256, + authorization::{ + AuthoredUploadClaim, AuthorizationClaim, AuthorizationContent, AuthorizationTarget, + AuthorizationValidation, RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, + RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS, RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, + ServerDomain, ServerScopeRequirement, + }, }; use radroots_nostr::blossom::{ RadrootsNostrBlossomError, radroots_nostr_decode_verify_blossom_authorization_header, @@ -203,7 +205,7 @@ fn header_payload(header: &str) -> &str { &header[payload_start..] } -fn vector_validation(vector: &Vector) -> RadrootsBlossomAuthorizationValidation { +fn vector_validation(vector: &Vector) -> AuthorizationValidation { let validation = vector.input.get("validation").expect("validation input"); let target = validation.get("target").expect("validation target"); let target_type = target @@ -211,7 +213,7 @@ fn vector_validation(vector: &Vector) -> RadrootsBlossomAuthorizationValidation .and_then(Value::as_str) .expect("validation target type"); let target_hash = || { - RadrootsBlossomSha256::from_hex( + Sha256::from_hex( target .get("hash") .and_then(Value::as_str) @@ -220,15 +222,15 @@ fn vector_validation(vector: &Vector) -> RadrootsBlossomAuthorizationValidation .expect("valid validation target hash") }; let target = match target_type { - "get_blob" => RadrootsBlossomAuthorizationTarget::GetBlob(target_hash()), - "upload" => RadrootsBlossomAuthorizationTarget::Upload(target_hash()), - "list" => RadrootsBlossomAuthorizationTarget::List, - "delete_blob" => RadrootsBlossomAuthorizationTarget::DeleteBlob(target_hash()), - "mirror" => RadrootsBlossomAuthorizationTarget::Mirror(target_hash()), - "media" => RadrootsBlossomAuthorizationTarget::Media(target_hash()), + "get_blob" => AuthorizationTarget::GetBlob(target_hash()), + "upload" => AuthorizationTarget::Upload(target_hash()), + "list" => AuthorizationTarget::List, + "delete_blob" => AuthorizationTarget::DeleteBlob(target_hash()), + "mirror" => AuthorizationTarget::Mirror(target_hash()), + "media" => AuthorizationTarget::Media(target_hash()), other => panic!("{} has unsupported target {other}", vector.id), }; - let server = RadrootsBlossomServerDomain::parse( + let server = ServerDomain::parse( validation .get("server_domain") .and_then(Value::as_str) @@ -240,11 +242,11 @@ fn vector_validation(vector: &Vector) -> RadrootsBlossomAuthorizationValidation .and_then(Value::as_str) .expect("validation server_scope") { - "optional_any_match" => RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, - "required_any_match" => RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + "optional_any_match" => ServerScopeRequirement::OptionalAnyMatch, + "required_any_match" => ServerScopeRequirement::RequiredAnyMatch, other => panic!("{} has unsupported server scope {other}", vector.id), }; - RadrootsBlossomAuthorizationValidation::new( + AuthorizationValidation::new( target, server, server_scope, @@ -304,17 +306,17 @@ fn keys() -> RadrootsNostrKeys { RadrootsNostrKeys::parse(SECRET_KEY).expect("fixed test secret key") } -fn hash() -> RadrootsBlossomSha256 { - RadrootsBlossomSha256::from_hex(HASH).expect("fixed test hash") +fn hash() -> Sha256 { + Sha256::from_hex(HASH).expect("fixed test hash") } -fn server() -> RadrootsBlossomServerDomain { - RadrootsBlossomServerDomain::parse(SERVER).expect("fixed test server") +fn server() -> ServerDomain { + ServerDomain::parse(SERVER).expect("fixed test server") } -fn authored_claim() -> RadrootsBlossomAuthoredUploadClaim { - RadrootsBlossomAuthoredUploadClaim::new( - RadrootsBlossomAuthorizationContent::parse(CONTENT).expect("fixed test content"), +fn authored_claim() -> AuthoredUploadClaim { + AuthoredUploadClaim::new( + AuthorizationContent::parse(CONTENT).expect("fixed test content"), server(), hash(), CREATED_AT, @@ -323,11 +325,11 @@ fn authored_claim() -> RadrootsBlossomAuthoredUploadClaim { .expect("fixed authored claim") } -fn validation() -> RadrootsBlossomAuthorizationValidation { - RadrootsBlossomAuthorizationValidation::new( - RadrootsBlossomAuthorizationTarget::Upload(hash()), +fn validation() -> AuthorizationValidation { + AuthorizationValidation::new( + AuthorizationTarget::Upload(hash()), server(), - RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + ServerScopeRequirement::RequiredAnyMatch, CREATED_AT + 1, RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, ) @@ -557,7 +559,7 @@ fn blossom_header_authenticates_before_claim_parsing() { ); let raw_tags: Vec<Vec<String>> = Vec::new(); - let pure_error = RadrootsBlossomParsedAuthorizationClaim::parse("", CREATED_AT, &raw_tags) + let pure_error = AuthorizationClaim::parse("", CREATED_AT, &raw_tags) .expect_err("empty content is not a claim"); let adapter_error = radroots_nostr_decode_verify_blossom_authorization_header( &raw_header(&malformed_claim), @@ -570,10 +572,10 @@ fn blossom_header_authenticates_before_claim_parsing() { let signed = radroots_nostr_sign_blossom_authorization(&keys(), &authored_claim()) .expect("sign authored authorization"); - let media_policy = RadrootsBlossomAuthorizationValidation::new( - RadrootsBlossomAuthorizationTarget::Media(hash()), + let media_policy = AuthorizationValidation::new( + AuthorizationTarget::Media(hash()), server(), - RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + ServerScopeRequirement::RequiredAnyMatch, CREATED_AT + 1, RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, ) diff --git a/crates/nostr/tests/food_availability_profile.rs b/crates/nostr/tests/food_availability_profile.rs @@ -1,7 +1,4 @@ -use radroots_blossom::{ - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType, - RadrootsBlossomSha256, -}; +use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; use radroots_event::food_availability::{ RadrootsFoodAvailabilityDetails, RadrootsFoodAvailabilityDetailsParts, RadrootsFoodAvailabilityImage, RadrootsFoodAvailabilityStatus, RadrootsFoodContent, @@ -213,11 +210,10 @@ fn details_with_images( fn blossom_image() -> RadrootsFoodAvailabilityImage { let bytes = b"victoria-carrots-image-fixture"; - let hash = RadrootsBlossomSha256::digest(bytes); - let media_type = RadrootsBlossomMediaType::parse("image/webp").expect("image media type"); - let verified = RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&format!("https://media.example/{hash}.webp")) - .expect("Blossom URL"), + let hash = Sha256::digest(bytes); + let media_type = MediaType::parse("image/webp").expect("image media type"); + let verified = BlobDescriptor::new( + BlobUrl::parse(&format!("https://media.example/{hash}.webp")).expect("Blossom URL"), hash, bytes.len() as u64, media_type.clone(), diff --git a/crates/nostr/tests/post_profile.rs b/crates/nostr/tests/post_profile.rs @@ -1,10 +1,7 @@ #[path = "../src/test_fixtures.rs"] mod test_fixtures; -use radroots_blossom::{ - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType, - RadrootsBlossomSha256, -}; +use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; use radroots_event::{ RadrootsAuthoredImage, post::{ @@ -66,10 +63,10 @@ fn typed_post_builders_preserve_strict_wire_profiles() { fn authored_image() -> RadrootsAuthoredPostImage { let bytes = b"strawberries"; - let hash = RadrootsBlossomSha256::digest(bytes); - let media_type = RadrootsBlossomMediaType::parse("image/webp").unwrap(); - let descriptor = RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&format!("https://media.example/{hash}.webp")).unwrap(), + let hash = Sha256::digest(bytes); + let media_type = MediaType::parse("image/webp").unwrap(); + let descriptor = BlobDescriptor::new( + BlobUrl::parse(&format!("https://media.example/{hash}.webp")).unwrap(), hash, bytes.len() as u64, media_type.clone(), diff --git a/crates/nostr_accounts/Cargo.toml b/crates/nostr_accounts/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_nostr_accounts" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] @@ -16,6 +16,7 @@ exclude = ["src/nostrdb_bridge.rs"] [features] default = ["std", "file-store", "memory-vault"] std = [ + "dep:nostr", "dep:serde", "dep:serde_json", "dep:radroots_identity", @@ -29,9 +30,10 @@ memory-vault = ["std", "radroots_secret_vault/memory-vault"] os-keyring = ["std", "radroots_secret_vault/os-keyring"] [dependencies] +nostr = { workspace = true, optional = true } radroots_identity = { workspace = true, optional = true, default-features = false, features = [ "std", - "json-file", + "serde", ] } radroots_nostr_signer = { workspace = true, optional = true } radroots_protected_store = { workspace = true, optional = true, default-features = false, features = [ diff --git a/crates/nostr_accounts/src/error.rs b/crates/nostr_accounts/src/error.rs @@ -31,8 +31,8 @@ pub enum RadrootsNostrAccountsError { } #[cfg(feature = "std")] -impl From<radroots_identity::IdentityError> for RadrootsNostrAccountsError { - fn from(value: radroots_identity::IdentityError) -> Self { +impl From<radroots_identity::Error> for RadrootsNostrAccountsError { + fn from(value: radroots_identity::Error) -> Self { Self::Identity(value.to_string()) } } @@ -54,14 +54,14 @@ impl From<radroots_secret_vault::RadrootsSecretVaultAccessError> for RadrootsNos #[cfg(test)] mod tests { use super::*; - use radroots_identity::IdentityError; + use radroots_identity::Error as IdentityValueError; use radroots_runtime::RuntimeJsonError; use radroots_secret_vault::RadrootsSecretVaultAccessError; use std::path::PathBuf; #[test] fn converts_identity_error() { - let source = IdentityError::PublicKeyMismatch; + let source = IdentityValueError::IdentityIdMismatch; let converted: RadrootsNostrAccountsError = source.into(); assert!(converted.to_string().starts_with("identity error:")); } diff --git a/crates/nostr_accounts/src/lib.rs b/crates/nostr_accounts/src/lib.rs @@ -16,10 +16,7 @@ pub mod prelude { #[cfg(feature = "std")] pub use crate::manager::RadrootsNostrAccountsManager; #[cfg(feature = "std")] - pub use crate::model::{ - RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION, RadrootsNostrAccountRecord, - RadrootsNostrAccountStatus, RadrootsNostrAccountStoreState, - }; + pub use crate::model::{RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION, RadrootsNostrAccountStoreState}; #[cfg(feature = "std")] pub use crate::store::{ RadrootsNostrAccountStore, RadrootsNostrFileAccountStore, RadrootsNostrMemoryAccountStore, diff --git a/crates/nostr_accounts/src/manager.rs b/crates/nostr_accounts/src/manager.rs @@ -1,7 +1,5 @@ use crate::error::RadrootsNostrAccountsError; -use crate::model::{ - RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountStoreState, -}; +use crate::model::RadrootsNostrAccountStoreState; #[cfg(feature = "memory-vault")] use crate::store::RadrootsNostrMemoryAccountStore; use crate::store::{RadrootsNostrAccountStore, RadrootsNostrFileAccountStore}; @@ -10,7 +8,11 @@ use crate::vault::RadrootsNostrSecretVaultMemory; #[cfg(feature = "os-keyring")] use crate::vault::RadrootsNostrSecretVaultOsKeyring; use crate::vault::{RadrootsSecretVault, account_secret_slot}; -use radroots_identity::{RadrootsIdentity, RadrootsIdentityId, RadrootsIdentityPublic}; +use nostr::{Keys, PublicKey as NostrPublicKey, SecretKey}; +use radroots_identity::{ + AccountId, PublicIdentity, PublicKey as IdentityPublicKey, + account::{Record as AccountRecord, Status as AccountStatus}, +}; use radroots_nostr_signer::prelude::{ RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, RadrootsNostrSignerCapability, @@ -55,11 +57,11 @@ impl RadrootsNostrAccountsManager { } let mut state_dirty = false; - if let Some(default_account_id) = state.default_account_id.clone() { + if let Some(default_account_id) = state.default_account_id { let exists = state .accounts .iter() - .any(|record| record.account_id == default_account_id); + .any(|record| record.id() == default_account_id); if !exists { state.default_account_id = None; state_dirty = true; @@ -122,27 +124,21 @@ impl RadrootsNostrAccountsManager { Ok((manager, resolved)) } - pub fn list_accounts( - &self, - ) -> Result<Vec<RadrootsNostrAccountRecord>, RadrootsNostrAccountsError> { + pub fn list_accounts(&self) -> Result<Vec<AccountRecord>, RadrootsNostrAccountsError> { let guard = self.state.read().map_err(|_| { RadrootsNostrAccountsError::Store("accounts state lock poisoned".into()) })?; Ok(guard.accounts.clone()) } - pub fn default_account_id( - &self, - ) -> Result<Option<RadrootsIdentityId>, RadrootsNostrAccountsError> { + pub fn default_account_id(&self) -> Result<Option<AccountId>, RadrootsNostrAccountsError> { let guard = self.state.read().map_err(|_| { RadrootsNostrAccountsError::Store("accounts state lock poisoned".into()) })?; - Ok(guard.default_account_id.clone()) + Ok(guard.default_account_id) } - pub fn default_account( - &self, - ) -> Result<Option<RadrootsNostrAccountRecord>, RadrootsNostrAccountsError> { + pub fn default_account(&self) -> Result<Option<AccountRecord>, RadrootsNostrAccountsError> { let guard = self.state.read().map_err(|_| { RadrootsNostrAccountsError::Store("accounts state lock poisoned".into()) })?; @@ -152,61 +148,57 @@ impl RadrootsNostrAccountsManager { Ok(guard .accounts .iter() - .find(|record| &record.account_id == default_account_id) + .find(|record| record.id() == *default_account_id) .cloned()) } pub fn default_public_identity( &self, - ) -> Result<Option<RadrootsIdentityPublic>, RadrootsNostrAccountsError> { + ) -> Result<Option<PublicIdentity>, RadrootsNostrAccountsError> { Ok(self .default_account()? - .map(|record| record.public_identity.clone())) + .map(|record| record.public_identity().clone())) } - pub fn default_account_status( - &self, - ) -> Result<RadrootsNostrAccountStatus, RadrootsNostrAccountsError> { + pub fn default_account_status(&self) -> Result<AccountStatus, RadrootsNostrAccountsError> { let Some(record) = self.default_account()? else { - return Ok(RadrootsNostrAccountStatus::NotConfigured); + return Ok(AccountStatus::NotConfigured); }; Ok(match self.local_signer_availability(&record)? { RadrootsNostrLocalSignerAvailability::PublicOnly => { - RadrootsNostrAccountStatus::PublicOnly { account: record } + AccountStatus::PublicOnly { account: record } } RadrootsNostrLocalSignerAvailability::SecretBacked => { - RadrootsNostrAccountStatus::Ready { account: record } + AccountStatus::Ready { account: record } } }) } - pub fn default_signing_identity( - &self, - ) -> Result<Option<RadrootsIdentity>, RadrootsNostrAccountsError> { + pub fn default_signing_keys(&self) -> Result<Option<Keys>, RadrootsNostrAccountsError> { let Some(record) = self.default_account()? else { return Ok(None); }; - self.resolve_signing_identity(record) + self.resolve_signing_keys(record) } - pub fn get_signing_identity( + pub fn get_signing_keys( &self, - account_id: &RadrootsIdentityId, - ) -> Result<Option<RadrootsIdentity>, RadrootsNostrAccountsError> { + account_id: &AccountId, + ) -> Result<Option<Keys>, RadrootsNostrAccountsError> { let guard = self.state.read().map_err(|_| { RadrootsNostrAccountsError::Store("accounts state lock poisoned".into()) })?; let Some(record) = guard .accounts .iter() - .find(|record| &record.account_id == account_id) + .find(|record| record.id() == *account_id) .cloned() else { return Ok(None); }; drop(guard); - self.resolve_signing_identity(record) + self.resolve_signing_keys(record) } pub fn default_signer_capability( @@ -220,7 +212,7 @@ impl RadrootsNostrAccountsManager { pub fn get_signer_capability( &self, - account_id: &RadrootsIdentityId, + account_id: &AccountId, ) -> Result<Option<RadrootsNostrSignerCapability>, RadrootsNostrAccountsError> { let guard = self.state.read().map_err(|_| { RadrootsNostrAccountsError::Store("accounts state lock poisoned".into()) @@ -228,7 +220,7 @@ impl RadrootsNostrAccountsManager { let Some(record) = guard .accounts .iter() - .find(|record| &record.account_id == account_id) + .find(|record| record.id() == *account_id) .cloned() else { return Ok(None); @@ -237,44 +229,44 @@ impl RadrootsNostrAccountsManager { Ok(Some(self.local_signer_capability(record)?)) } - pub fn resolve_signing_identity_for_signer( + pub fn resolve_signing_keys_for_signer( &self, signer: &RadrootsNostrSignerCapability, - ) -> Result<Option<RadrootsIdentity>, RadrootsNostrAccountsError> { + ) -> Result<Option<Keys>, RadrootsNostrAccountsError> { match signer { RadrootsNostrSignerCapability::LocalAccount(capability) => { - self.get_signing_identity(&capability.account_id) + self.get_signing_keys(&capability.account_id) } RadrootsNostrSignerCapability::RemoteSession(_) => Ok(None), } } - pub fn upsert_identity( + pub fn upsert_keys( &self, - identity: &RadrootsIdentity, + keys: &Keys, label: Option<String>, make_default: bool, - ) -> Result<RadrootsIdentityId, RadrootsNostrAccountsError> { - let account_id = identity.id(); - let secret_key_hex = Zeroizing::new(identity.secret_key_hex()); + ) -> Result<AccountId, RadrootsNostrAccountsError> { + let public_identity = public_identity_from_keys(keys)?; + let account_id = AccountId::from_public_identity(&public_identity); + let secret_key_hex = Zeroizing::new(keys.secret_key().to_secret_hex()); self.vault.store_secret( account_secret_slot(&account_id).as_str(), secret_key_hex.as_str(), )?; - let public_identity = identity.to_public(); self.upsert_public_identity(public_identity, label, make_default) } /// Attaches matching secret material to an existing account without import semantics. - pub fn attach_identity_secret( + pub fn attach_secret_keys( &self, - account_id: &RadrootsIdentityId, - identity: &RadrootsIdentity, + account_id: &AccountId, + keys: &Keys, make_default: bool, - ) -> Result<RadrootsNostrAccountRecord, RadrootsNostrAccountsError> { - let account_id = account_id.clone(); - let public_key_hex = identity.public_key_hex(); + ) -> Result<AccountRecord, RadrootsNostrAccountsError> { + let account_id = *account_id; + let public_key_hex = keys.public_key().to_hex(); let updated_at_unix = now_unix_secs(); let mut guard = self.state.write().map_err(|_| { RadrootsNostrAccountsError::Store("accounts state lock poisoned".into()) @@ -283,23 +275,23 @@ impl RadrootsNostrAccountsManager { let Some(record) = next .accounts .iter_mut() - .find(|record| record.account_id == account_id) + .find(|record| record.id() == account_id) else { return Err(RadrootsNostrAccountsError::AccountNotFound( account_id.to_string(), )); }; - if record.public_identity.public_key_hex.as_str() != public_key_hex.as_str() { + if record.public_identity().public_key().to_hex() != public_key_hex { return Err(RadrootsNostrAccountsError::PublicKeyMismatch); } - let secret_key_hex = Zeroizing::new(identity.secret_key_hex()); + let secret_key_hex = Zeroizing::new(keys.secret_key().to_secret_hex()); self.vault.store_secret( account_secret_slot(&account_id).as_str(), secret_key_hex.as_str(), )?; - record.touch_updated(updated_at_unix); + record.touch_updated(updated_at_unix)?; let updated_record = record.clone(); if make_default { next.default_account_id = Some(account_id); @@ -311,30 +303,30 @@ impl RadrootsNostrAccountsManager { pub fn upsert_public_identity( &self, - public_identity: RadrootsIdentityPublic, + public_identity: PublicIdentity, label: Option<String>, make_default: bool, - ) -> Result<RadrootsIdentityId, RadrootsNostrAccountsError> { + ) -> Result<AccountId, RadrootsNostrAccountsError> { let updated_at_unix = now_unix_secs(); - let account_id = public_identity.id.clone(); + let account_id = AccountId::from_public_identity(&public_identity); self.update_state(|state| { - if public_identity.id.as_str() != public_identity.public_key_hex { - return Err(RadrootsNostrAccountsError::InvalidState( - "public identity id does not match public key".into(), - )); - } if let Some(existing) = state .accounts .iter_mut() - .find(|record| record.account_id == account_id) + .find(|record| record.id() == account_id) { - existing.public_identity = public_identity.clone(); - if let Some(next_label) = label.clone() { - existing.label = Some(next_label); - } - existing.touch_updated(updated_at_unix); + let next_label = label + .clone() + .or_else(|| existing.label().map(ToOwned::to_owned)); + *existing = AccountRecord::try_from_parts( + existing.id(), + public_identity.clone(), + next_label, + existing.created_at_unix(), + updated_at_unix, + )?; } else { - state.accounts.push(RadrootsNostrAccountRecord::new( + state.accounts.push(AccountRecord::new( public_identity.clone(), label.clone(), updated_at_unix, @@ -342,32 +334,32 @@ impl RadrootsNostrAccountsManager { } if state.default_account_id.is_none() || make_default { - state.default_account_id = Some(account_id.clone()); + state.default_account_id = Some(account_id); } Ok(()) })?; Ok(account_id) } - pub fn generate_identity( + pub fn generate_keys( &self, label: Option<String>, make_default: bool, - ) -> Result<RadrootsIdentityId, RadrootsNostrAccountsError> { - let identity = RadrootsIdentity::generate(); - self.upsert_identity(&identity, label, make_default) + ) -> Result<AccountId, RadrootsNostrAccountsError> { + let keys = Keys::generate(); + self.upsert_keys(&keys, label, make_default) } pub fn set_default_account( &self, - account_id: &RadrootsIdentityId, + account_id: &AccountId, ) -> Result<(), RadrootsNostrAccountsError> { - let account_id = account_id.clone(); + let account_id = *account_id; self.update_state(|state| { let exists = state .accounts .iter() - .any(|record| record.account_id == account_id); + .any(|record| record.id() == account_id); if !exists { return Err(RadrootsNostrAccountsError::AccountNotFound( account_id.to_string(), @@ -388,7 +380,7 @@ impl RadrootsNostrAccountsManager { pub fn resolve_account_selector( &self, selector: &str, - ) -> Result<RadrootsNostrAccountRecord, RadrootsNostrAccountsError> { + ) -> Result<AccountRecord, RadrootsNostrAccountsError> { let normalized = selector.trim(); if normalized.is_empty() { return Err(RadrootsNostrAccountsError::InvalidAccountSelector( @@ -396,6 +388,7 @@ impl RadrootsNostrAccountsManager { )); } + let selector_public_key = NostrPublicKey::parse(normalized).ok(); let guard = self.state.read().map_err(|_| { RadrootsNostrAccountsError::Store("accounts state lock poisoned".into()) })?; @@ -403,8 +396,10 @@ impl RadrootsNostrAccountsManager { .accounts .iter() .find(|record| { - record.account_id.as_str() == normalized - || record.public_identity.public_key_npub == normalized + record.id().to_hex() == normalized + || selector_public_key.is_some_and(|key| { + record.public_identity().public_key().to_hex() == key.to_hex() + }) }) .cloned() { @@ -414,7 +409,7 @@ impl RadrootsNostrAccountsManager { let mut label_matches = guard .accounts .iter() - .filter(|record| record.label.as_deref() == Some(normalized)) + .filter(|record| record.label() == Some(normalized)) .cloned(); let Some(record) = label_matches.next() else { return Err(RadrootsNostrAccountsError::AccountNotFound( @@ -429,23 +424,18 @@ impl RadrootsNostrAccountsManager { Ok(record) } - pub fn remove_account( - &self, - account_id: &RadrootsIdentityId, - ) -> Result<(), RadrootsNostrAccountsError> { - let account_id = account_id.clone(); + pub fn remove_account(&self, account_id: &AccountId) -> Result<(), RadrootsNostrAccountsError> { + let account_id = *account_id; self.update_state(|state| { let before = state.accounts.len(); - state - .accounts - .retain(|record| record.account_id != account_id); + state.accounts.retain(|record| record.id() != account_id); if state.accounts.len() == before { return Err(RadrootsNostrAccountsError::AccountNotFound( account_id.to_string(), )); } - if state.default_account_id.as_ref() == Some(&account_id) { + if state.default_account_id == Some(account_id) { state.default_account_id = None; } Ok(()) @@ -457,43 +447,40 @@ impl RadrootsNostrAccountsManager { pub fn export_secret_hex( &self, - account_id: &RadrootsIdentityId, + account_id: &AccountId, ) -> Result<Option<String>, RadrootsNostrAccountsError> { self.vault .load_secret(account_secret_slot(account_id).as_str()) .map_err(Into::into) } - fn resolve_signing_identity( + fn resolve_signing_keys( &self, - record: RadrootsNostrAccountRecord, - ) -> Result<Option<RadrootsIdentity>, RadrootsNostrAccountsError> { + record: AccountRecord, + ) -> Result<Option<Keys>, RadrootsNostrAccountsError> { let Some(secret_key_hex) = self .vault - .load_secret(account_secret_slot(&record.account_id).as_str())? + .load_secret(account_secret_slot(&record.id()).as_str())? else { return Ok(None); }; let secret_key_hex = Zeroizing::new(secret_key_hex); - let mut identity = RadrootsIdentity::from_secret_key_str(secret_key_hex.as_str())?; - if identity.public_key_hex() != record.public_identity.public_key_hex { + let keys = keys_from_secret_hex(secret_key_hex.as_str())?; + if keys.public_key().to_hex() != record.public_identity().public_key().to_hex() { return Err(RadrootsNostrAccountsError::PublicKeyMismatch); } - if let Some(profile) = record.public_identity.profile { - identity.set_profile(profile); - } - Ok(Some(identity)) + Ok(Some(keys)) } fn local_signer_capability( &self, - record: RadrootsNostrAccountRecord, + record: AccountRecord, ) -> Result<RadrootsNostrSignerCapability, RadrootsNostrAccountsError> { let availability = self.local_signer_availability(&record)?; Ok(RadrootsNostrSignerCapability::LocalAccount(Box::new( RadrootsNostrLocalSignerCapability::new( - record.account_id, - record.public_identity, + record.id(), + record.public_identity().clone(), availability, ), ))) @@ -501,18 +488,18 @@ impl RadrootsNostrAccountsManager { fn local_signer_availability( &self, - record: &RadrootsNostrAccountRecord, + record: &AccountRecord, ) -> Result<RadrootsNostrLocalSignerAvailability, RadrootsNostrAccountsError> { let Some(secret_key_hex) = self .vault - .load_secret(account_secret_slot(&record.account_id).as_str())? + .load_secret(account_secret_slot(&record.id()).as_str())? else { return Ok(RadrootsNostrLocalSignerAvailability::PublicOnly); }; let secret_key_hex = Zeroizing::new(secret_key_hex); - let identity = RadrootsIdentity::from_secret_key_str(secret_key_hex.as_str())?; - if identity.public_key_hex() != record.public_identity.public_key_hex { + let keys = keys_from_secret_hex(secret_key_hex.as_str())?; + if keys.public_key().to_hex() != record.public_identity().public_key().to_hex() { return Err(RadrootsNostrAccountsError::PublicKeyMismatch); } Ok(RadrootsNostrLocalSignerAvailability::SecretBacked) @@ -535,6 +522,17 @@ impl RadrootsNostrAccountsManager { } } +fn public_identity_from_keys(keys: &Keys) -> Result<PublicIdentity, RadrootsNostrAccountsError> { + let public_key = IdentityPublicKey::from_hex(&keys.public_key().to_hex())?; + Ok(PublicIdentity::new(public_key)) +} + +fn keys_from_secret_hex(secret_key_hex: &str) -> Result<Keys, RadrootsNostrAccountsError> { + let secret_key = SecretKey::from_hex(secret_key_hex) + .map_err(|error| RadrootsNostrAccountsError::Identity(error.to_string()))?; + Ok(Keys::new(secret_key)) +} + fn local_file_backed_secret_vault( backend: RadrootsSecretBackend, secrets_dir: &Path, @@ -579,7 +577,8 @@ mod tests { }; use crate::vault::RadrootsNostrSecretVaultMemory; use crate::vault::RadrootsSecretVault; - use radroots_identity::RadrootsIdentityProfile; + use nostr::{Keys, ToBech32}; + use radroots_identity::{Profile, Username}; use radroots_secret_vault::{ RadrootsHostVaultCapabilities, RadrootsSecretBackend, RadrootsSecretBackendAvailability, RadrootsSecretBackendSelection, @@ -590,6 +589,31 @@ mod tests { use std::sync::RwLock; use std::thread; + trait TestKeysExt { + fn id(&self) -> AccountId; + fn to_public(&self) -> PublicIdentity; + fn public_key_hex(&self) -> String; + fn secret_key_hex(&self) -> String; + } + + impl TestKeysExt for Keys { + fn id(&self) -> AccountId { + AccountId::from_public_identity(&self.to_public()) + } + + fn to_public(&self) -> PublicIdentity { + public_identity_from_keys(self).expect("public identity") + } + + fn public_key_hex(&self) -> String { + self.public_key().to_hex() + } + + fn secret_key_hex(&self) -> String { + self.secret_key().to_secret_hex() + } + } + mod removed_surface_fixtures { pub const MIGRATE_LEGACY_IDENTITY_FILE: &str = "migrate_legacy_identity_file"; pub const SELECTED_ACCOUNT_ID: &str = "selected_account_id"; @@ -775,19 +799,22 @@ mod tests { .join(); } - fn status_kind(status: &RadrootsNostrAccountStatus) -> &'static str { + fn status_kind(status: &AccountStatus) -> &'static str { match status { - RadrootsNostrAccountStatus::NotConfigured => "not-configured", - RadrootsNostrAccountStatus::PublicOnly { .. } => "public-only", - RadrootsNostrAccountStatus::Ready { .. } => "ready", + AccountStatus::NotConfigured => "not-configured", + AccountStatus::PublicOnly { .. } => "public-only", + AccountStatus::Ready { .. } => "ready", + _ => "unknown", } } - fn status_account(status: &RadrootsNostrAccountStatus) -> Option<&RadrootsNostrAccountRecord> { + fn status_account(status: &AccountStatus) -> Option<&AccountRecord> { match status { - RadrootsNostrAccountStatus::NotConfigured => None, - RadrootsNostrAccountStatus::PublicOnly { account } - | RadrootsNostrAccountStatus::Ready { account } => Some(account), + AccountStatus::NotConfigured => None, + AccountStatus::PublicOnly { account } | AccountStatus::Ready { account } => { + Some(account) + } + _ => None, } } @@ -801,7 +828,7 @@ mod tests { let manager = RadrootsNostrAccountsManager::new(store.clone(), vault.clone()).expect("manager"); let created_id = manager - .generate_identity(Some("primary".into()), true) + .generate_keys(Some("primary".into()), true) .expect("create identity"); let default_account_id = manager @@ -816,12 +843,7 @@ mod tests { .expect("default2") .expect("default2 id"); assert_eq!(default_account_id_2, created_id); - assert!( - manager2 - .default_signing_identity() - .expect("signing") - .is_some() - ); + assert!(manager2.default_signing_keys().expect("signing").is_some()); } #[test] @@ -833,9 +855,9 @@ mod tests { RadrootsNostrSecretVaultMemory::new(), ) .expect("manager"); - let identity = RadrootsIdentity::generate(); + let identity = Keys::generate(); let account_id = manager - .upsert_identity(&identity, Some("primary".into()), true) + .upsert_keys(&identity, Some("primary".into()), true) .expect("upsert"); let reloaded = RadrootsNostrAccountsManager::new_file_backed_with_vault( @@ -859,7 +881,7 @@ mod tests { &path, serde_json::to_vec_pretty(&json!({ "version": 1, - "default_account_id": RadrootsIdentity::generate().id(), + "default_account_id": Keys::generate().id(), "accounts": [], })) .expect("serialize store"), @@ -884,7 +906,7 @@ mod tests { #[test] fn new_reports_save_error_when_dirty_state_requires_rewrite() { let state = RadrootsNostrAccountStoreState { - default_account_id: Some(RadrootsIdentity::generate().id()), + default_account_id: Some(Keys::generate().id()), ..Default::default() }; let store = Arc::new(SaveErrorStore::new(state)); @@ -1069,48 +1091,43 @@ mod tests { let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); let manager = RadrootsNostrAccountsManager::new(store, vault).expect("manager"); - let identity = RadrootsIdentity::generate(); + let identity = Keys::generate(); let public = identity.to_public(); manager .upsert_public_identity(public, Some("watch".into()), true) .expect("watch"); - assert!( - manager - .default_signing_identity() - .expect("signing") - .is_none() - ); + assert!(manager.default_signing_keys().expect("signing").is_none()); let status = manager .default_account_status() .expect("default account status"); assert_eq!(status_kind(&status), "public-only"); let account = status_account(&status).expect("account"); - assert_eq!(account.label.as_deref(), Some("watch")); + assert_eq!(account.label(), Some("watch")); } #[test] - fn attach_identity_secret_upgrades_existing_watch_only_account() { + fn attach_secret_keys_upgrades_existing_watch_only_account() { let manager = RadrootsNostrAccountsManager::new_in_memory(); - let identity = RadrootsIdentity::generate(); + let identity = Keys::generate(); let account_id = manager .upsert_public_identity(identity.to_public(), Some("watch".into()), false) .expect("watch"); manager.clear_default_account().expect("clear default"); let attached = manager - .attach_identity_secret(&account_id, &identity, false) + .attach_secret_keys(&account_id, &identity, false) .expect("attach secret"); - assert_eq!(attached.account_id, account_id); - assert_eq!(attached.label.as_deref(), Some("watch")); + assert_eq!(attached.id(), account_id); + assert_eq!(attached.label(), Some("watch")); assert_eq!( - attached.public_identity.public_key_hex, + attached.public_identity().public_key().to_hex(), identity.public_key_hex() ); assert_eq!(manager.list_accounts().expect("list").len(), 1); let signing_identity = manager - .get_signing_identity(&account_id) + .get_signing_keys(&account_id) .expect("signing") .expect("secret backed"); assert_eq!(signing_identity.public_key_hex(), identity.public_key_hex()); @@ -1118,18 +1135,18 @@ mod tests { } #[test] - fn attach_identity_secret_preserves_existing_default_when_not_requested() { + fn attach_secret_keys_preserves_existing_default_when_not_requested() { let manager = RadrootsNostrAccountsManager::new_in_memory(); let default_account_id = manager - .generate_identity(Some("primary".into()), true) + .generate_keys(Some("primary".into()), true) .expect("primary"); - let identity = RadrootsIdentity::generate(); + let identity = Keys::generate(); let account_id = manager .upsert_public_identity(identity.to_public(), Some("watch".into()), false) .expect("watch"); manager - .attach_identity_secret(&account_id, &identity, false) + .attach_secret_keys(&account_id, &identity, false) .expect("attach secret"); assert_eq!( @@ -1139,18 +1156,18 @@ mod tests { } #[test] - fn attach_identity_secret_can_explicitly_make_default() { + fn attach_secret_keys_can_explicitly_make_default() { let manager = RadrootsNostrAccountsManager::new_in_memory(); manager - .generate_identity(Some("primary".into()), true) + .generate_keys(Some("primary".into()), true) .expect("primary"); - let identity = RadrootsIdentity::generate(); + let identity = Keys::generate(); let account_id = manager .upsert_public_identity(identity.to_public(), Some("watch".into()), false) .expect("watch"); manager - .attach_identity_secret(&account_id, &identity, true) + .attach_secret_keys(&account_id, &identity, true) .expect("attach secret"); assert_eq!( @@ -1160,13 +1177,13 @@ mod tests { } #[test] - fn attach_identity_secret_rejects_missing_account_without_storing_secret() { + fn attach_secret_keys_rejects_missing_account_without_storing_secret() { let manager = RadrootsNostrAccountsManager::new_in_memory(); - let identity = RadrootsIdentity::generate(); + let identity = Keys::generate(); let missing_id = identity.id(); let err = manager - .attach_identity_secret(&missing_id, &identity, false) + .attach_secret_keys(&missing_id, &identity, false) .expect_err("missing account"); assert_eq!(err.to_string(), format!("account not found: {missing_id}")); @@ -1180,17 +1197,17 @@ mod tests { } #[test] - fn attach_identity_secret_rejects_public_key_mismatch_without_storing_secret() { + fn attach_secret_keys_rejects_public_key_mismatch_without_storing_secret() { let manager = RadrootsNostrAccountsManager::new_in_memory(); - let public_identity = RadrootsIdentity::generate(); + let public_identity = Keys::generate(); let account_id = manager .upsert_public_identity(public_identity.to_public(), Some("watch".into()), false) .expect("watch"); manager.clear_default_account().expect("clear default"); - let mismatched_identity = RadrootsIdentity::generate(); + let mismatched_identity = Keys::generate(); let err = manager - .attach_identity_secret(&account_id, &mismatched_identity, false) + .attach_secret_keys(&account_id, &mismatched_identity, false) .expect_err("public key mismatch"); assert_eq!(err.to_string(), "public key does not match secret key"); @@ -1202,7 +1219,7 @@ mod tests { ); assert!( manager - .get_signing_identity(&account_id) + .get_signing_keys(&account_id) .expect("signing") .is_none() ); @@ -1210,35 +1227,33 @@ mod tests { } #[test] - fn attach_identity_secret_reports_vault_store_error() { + fn attach_secret_keys_reports_vault_store_error() { let manager = RadrootsNostrAccountsManager::new( Arc::new(RadrootsNostrMemoryAccountStore::new()), Arc::new(VaultStoreError), ) .expect("manager"); - let identity = RadrootsIdentity::generate(); + let identity = Keys::generate(); let account_id = manager .upsert_public_identity(identity.to_public(), Some("watch".into()), false) .expect("watch"); let err = manager - .attach_identity_secret(&account_id, &identity, false) + .attach_secret_keys(&account_id, &identity, false) .expect_err("vault store error"); assert!(err.to_string().starts_with("vault error:")); } #[test] - fn attach_identity_secret_reports_store_save_error_after_secret_store() { - let identity = RadrootsIdentity::generate(); + fn attach_secret_keys_reports_store_save_error_after_secret_store() { + let identity = Keys::generate(); let public_identity = identity.to_public(); - let account_id = public_identity.id.clone(); + let account_id = AccountId::from_public_identity(&public_identity); let mut state = RadrootsNostrAccountStoreState::default(); - state.accounts.push(RadrootsNostrAccountRecord::new( - public_identity, - Some("watch".into()), - 1, - )); + state + .accounts + .push(AccountRecord::new(public_identity, Some("watch".into()), 1)); let manager = RadrootsNostrAccountsManager::new( Arc::new(SaveErrorStore::new(state)), Arc::new(RadrootsNostrSecretVaultMemory::new()), @@ -1246,7 +1261,7 @@ mod tests { .expect("manager"); let err = manager - .attach_identity_secret(&account_id, &identity, false) + .attach_secret_keys(&account_id, &identity, false) .expect_err("store save error"); assert_eq!(err.to_string(), "store error: store save failed"); @@ -1256,7 +1271,7 @@ mod tests { fn default_account_status_reports_ready_for_signing_identity() { let manager = RadrootsNostrAccountsManager::new_in_memory(); let default_account_id = manager - .generate_identity(Some("primary".into()), true) + .generate_keys(Some("primary".into()), true) .expect("generate"); let status = manager @@ -1264,8 +1279,8 @@ mod tests { .expect("default account status"); assert_eq!(status_kind(&status), "ready"); let account = status_account(&status).expect("account"); - assert_eq!(account.account_id, default_account_id); - assert_eq!(account.label.as_deref(), Some("primary")); + assert_eq!(account.id(), default_account_id); + assert_eq!(account.label(), Some("primary")); let signer = manager .default_signer_capability() @@ -1302,7 +1317,7 @@ mod tests { fn upsert_public_identity_without_label_preserves_existing_label() { let manager = RadrootsNostrAccountsManager::new_in_memory(); let account_id = manager - .generate_identity(Some("primary".into()), true) + .generate_keys(Some("primary".into()), true) .expect("generate"); let existing = manager @@ -1316,9 +1331,9 @@ mod tests { let records = manager.list_accounts().expect("list"); let record = records .into_iter() - .find(|record| record.account_id == account_id) + .find(|record| record.id() == account_id) .expect("account"); - assert_eq!(record.label.as_deref(), Some("primary")); + assert_eq!(record.label(), Some("primary")); } #[test] @@ -1343,7 +1358,7 @@ mod tests { let store = Arc::new(RadrootsNostrMemoryAccountStore::new()); let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); let state = RadrootsNostrAccountStoreState { - default_account_id: Some(RadrootsIdentity::generate().id()), + default_account_id: Some(Keys::generate().id()), ..Default::default() }; store.save(&state).expect("save"); @@ -1369,7 +1384,7 @@ mod tests { ); assert!( manager - .default_signing_identity() + .default_signing_keys() .expect("default signing") .is_none() ); @@ -1385,10 +1400,10 @@ mod tests { assert_eq!(status_kind(&status), "not-configured"); assert!(status_account(&status).is_none()); - let missing_id = RadrootsIdentity::generate().id(); + let missing_id = Keys::generate().id(); assert!( manager - .get_signing_identity(&missing_id) + .get_signing_keys(&missing_id) .expect("signing") .is_none() ); @@ -1404,7 +1419,7 @@ mod tests { fn default_account_status_propagates_secret_integrity_errors() { let manager = RadrootsNostrAccountsManager::new_in_memory(); let account_id = manager - .generate_identity(Some("primary".into()), true) + .generate_keys(Some("primary".into()), true) .expect("generate"); manager .vault @@ -1416,9 +1431,9 @@ mod tests { .expect("default account status"); assert_eq!(status_kind(&status), "public-only"); let account = status_account(&status).expect("account"); - assert_eq!(account.account_id, account_id); + assert_eq!(account.id(), account_id); - let wrong_identity = RadrootsIdentity::generate(); + let wrong_identity = Keys::generate(); manager .vault .store_secret( @@ -1443,15 +1458,14 @@ mod tests { assert!(default_err.to_string().starts_with("store error:")); let mut load_error_state = RadrootsNostrAccountStoreState::default(); - let load_error_public = RadrootsIdentity::generate().to_public(); - load_error_state - .accounts - .push(RadrootsNostrAccountRecord::new( - load_error_public.clone(), - Some("watch".into()), - 1, - )); - load_error_state.default_account_id = Some(load_error_public.id.clone()); + let load_error_public = Keys::generate().to_public(); + load_error_state.accounts.push(AccountRecord::new( + load_error_public.clone(), + Some("watch".into()), + 1, + )); + load_error_state.default_account_id = + Some(AccountId::from_public_identity(&load_error_public)); let load_error_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); load_error_store .save(&load_error_state) @@ -1465,15 +1479,14 @@ mod tests { assert!(vault_load_error.to_string().starts_with("vault error:")); let mut invalid_secret_state = RadrootsNostrAccountStoreState::default(); - let invalid_secret_public = RadrootsIdentity::generate().to_public(); - invalid_secret_state - .accounts - .push(RadrootsNostrAccountRecord::new( - invalid_secret_public.clone(), - Some("invalid".into()), - 1, - )); - invalid_secret_state.default_account_id = Some(invalid_secret_public.id.clone()); + let invalid_secret_public = Keys::generate().to_public(); + invalid_secret_state.accounts.push(AccountRecord::new( + invalid_secret_public.clone(), + Some("invalid".into()), + 1, + )); + invalid_secret_state.default_account_id = + Some(AccountId::from_public_identity(&invalid_secret_public)); let invalid_secret_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); invalid_secret_store .save(&invalid_secret_state) @@ -1490,15 +1503,14 @@ mod tests { #[test] fn signer_capability_paths_propagate_secret_parse_errors() { let mut invalid_secret_state = RadrootsNostrAccountStoreState::default(); - let invalid_secret_public = RadrootsIdentity::generate().to_public(); - invalid_secret_state - .accounts - .push(RadrootsNostrAccountRecord::new( - invalid_secret_public.clone(), - Some("invalid".into()), - 1, - )); - invalid_secret_state.default_account_id = Some(invalid_secret_public.id.clone()); + let invalid_secret_public = Keys::generate().to_public(); + invalid_secret_state.accounts.push(AccountRecord::new( + invalid_secret_public.clone(), + Some("invalid".into()), + 1, + )); + invalid_secret_state.default_account_id = + Some(AccountId::from_public_identity(&invalid_secret_public)); let invalid_secret_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); invalid_secret_store .save(&invalid_secret_state) @@ -1517,7 +1529,7 @@ mod tests { ); let signer_error = invalid_secret_manager - .get_signer_capability(&invalid_secret_public.id) + .get_signer_capability(&AccountId::from_public_identity(&invalid_secret_public)) .expect_err("signer invalid secret"); assert!(signer_error.to_string().starts_with("identity error:")); } @@ -1526,10 +1538,10 @@ mod tests { fn select_remove_export_and_lookup_paths() { let manager = RadrootsNostrAccountsManager::new_in_memory(); let first_id = manager - .generate_identity(Some("first".into()), true) + .generate_keys(Some("first".into()), true) .expect("first"); let second_id = manager - .generate_identity(Some("second".into()), false) + .generate_keys(Some("second".into()), false) .expect("second"); manager @@ -1537,7 +1549,7 @@ mod tests { .expect("set default second"); assert_eq!( manager.default_account_id().expect("default"), - Some(second_id.clone()) + Some(second_id) ); assert!( manager @@ -1547,7 +1559,7 @@ mod tests { ); assert!( manager - .get_signing_identity(&second_id) + .get_signing_keys(&second_id) .expect("signing") .is_some() ); @@ -1562,7 +1574,7 @@ mod tests { ); assert!( manager - .get_signing_identity(&first_id) + .get_signing_keys(&first_id) .expect("first signing") .is_some() ); @@ -1585,7 +1597,7 @@ mod tests { fn upsert_public_identity_updates_label_and_respects_default_flag() { let manager = RadrootsNostrAccountsManager::new_in_memory(); let original_default = manager - .generate_identity(Some("primary".into()), true) + .generate_keys(Some("primary".into()), true) .expect("generate"); let existing = manager @@ -1600,18 +1612,18 @@ mod tests { .list_accounts() .expect("list") .into_iter() - .find(|record| record.account_id == existing.id) + .find(|record| record.id() == AccountId::from_public_identity(&existing)) .expect("record"); - assert_eq!(renamed.label.as_deref(), Some("renamed")); + assert_eq!(renamed.label(), Some("renamed")); - let watch_only = RadrootsIdentity::generate().to_public(); - let watch_id = watch_only.id.clone(); + let watch_only = Keys::generate().to_public(); + let watch_id = AccountId::from_public_identity(&watch_only); manager .upsert_public_identity(watch_only.clone(), Some("watch".into()), false) .expect("upsert watch"); assert_eq!( manager.default_account_id().expect("default"), - Some(original_default.clone()) + Some(original_default) ); manager @@ -1624,26 +1636,13 @@ mod tests { } #[test] - fn upsert_public_identity_rejects_mismatched_id() { - let manager = RadrootsNostrAccountsManager::new_in_memory(); - let mut public_identity = RadrootsIdentity::generate().to_public(); - let other = RadrootsIdentity::generate().to_public(); - public_identity.id = other.id.clone(); - - let err = manager - .upsert_public_identity(public_identity, None, true) - .expect_err("id mismatch"); - assert!(err.to_string().starts_with("invalid account state:")); - } - - #[test] fn remove_non_default_account_keeps_current_default() { let manager = RadrootsNostrAccountsManager::new_in_memory(); let default_account_id = manager - .generate_identity(Some("selected".into()), true) + .generate_keys(Some("selected".into()), true) .expect("default"); let removable_id = manager - .generate_identity(Some("removable".into()), false) + .generate_keys(Some("removable".into()), false) .expect("removable"); manager.remove_account(&removable_id).expect("remove"); @@ -1657,10 +1656,10 @@ mod tests { fn clear_default_account_clears_default_without_removing_accounts() { let manager = RadrootsNostrAccountsManager::new_in_memory(); manager - .generate_identity(Some("primary".into()), true) + .generate_keys(Some("primary".into()), true) .expect("primary"); manager - .generate_identity(Some("secondary".into()), false) + .generate_keys(Some("secondary".into()), false) .expect("secondary"); manager.clear_default_account().expect("clear default"); @@ -1673,38 +1672,42 @@ mod tests { fn resolve_account_selector_matches_exact_id_npub_and_unique_label() { let manager = RadrootsNostrAccountsManager::new_in_memory(); let account_id = manager - .generate_identity(Some("primary".into()), true) + .generate_keys(Some("primary".into()), true) .expect("primary"); let default_account = manager .default_account() .expect("default account") .expect("default record"); - let npub = default_account.public_identity.public_key_npub.clone(); + let npub = + NostrPublicKey::from_hex(&default_account.public_identity().public_key().to_hex()) + .expect("Nostr public key") + .to_bech32() + .expect("npub"); let resolved_by_id = manager - .resolve_account_selector(account_id.as_str()) + .resolve_account_selector(&account_id.to_hex()) .expect("resolve by id"); - assert_eq!(resolved_by_id.account_id, account_id); + assert_eq!(resolved_by_id.id(), account_id); let resolved_by_npub = manager .resolve_account_selector(&npub) .expect("resolve by npub"); - assert_eq!(resolved_by_npub.account_id, account_id); + assert_eq!(resolved_by_npub.id(), account_id); let resolved_by_label = manager .resolve_account_selector("primary") .expect("resolve by label"); - assert_eq!(resolved_by_label.account_id, account_id); + assert_eq!(resolved_by_label.id(), account_id); } #[test] fn resolve_account_selector_rejects_empty_and_ambiguous_labels() { let manager = RadrootsNostrAccountsManager::new_in_memory(); manager - .generate_identity(Some("shared".into()), true) + .generate_keys(Some("shared".into()), true) .expect("first"); manager - .generate_identity(Some("shared".into()), false) + .generate_keys(Some("shared".into()), false) .expect("second"); let empty = manager @@ -1732,8 +1735,8 @@ mod tests { let store = Arc::new(RadrootsNostrMemoryAccountStore::new()); let vault = Arc::new(VaultRemoveError); let manager = RadrootsNostrAccountsManager::new(store, vault.clone()).expect("manager"); - let public = RadrootsIdentity::generate().to_public(); - let account_id = public.id.clone(); + let public = Keys::generate().to_public(); + let account_id = AccountId::from_public_identity(&public); vault .store_secret(account_secret_slot(&account_id).as_str(), "secret") .expect("vault store"); @@ -1754,18 +1757,18 @@ mod tests { } #[test] - fn resolve_signing_identity_mismatch_and_profile_paths() { + fn resolve_signing_keys_mismatch_and_public_profile_paths() { let store = Arc::new(RadrootsNostrMemoryAccountStore::new()); let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); let manager = RadrootsNostrAccountsManager::new(store, vault.clone()).expect("manager"); - let mismatch_public = RadrootsIdentity::generate().to_public(); - let mismatch_id = mismatch_public.id.clone(); + let mismatch_public = Keys::generate().to_public(); + let mismatch_id = AccountId::from_public_identity(&mismatch_public); manager .upsert_public_identity(mismatch_public, Some("mismatch".into()), true) .expect("upsert mismatch"); - let wrong_identity = RadrootsIdentity::generate(); + let wrong_identity = Keys::generate(); vault .store_secret( account_secret_slot(&mismatch_id).as_str(), @@ -1774,7 +1777,7 @@ mod tests { .expect("vault store"); let mismatch = manager - .default_signing_identity() + .default_signing_keys() .expect_err("public key mismatch"); assert!( mismatch @@ -1782,24 +1785,30 @@ mod tests { .contains("public key does not match secret key") ); - let mut with_profile = RadrootsIdentity::generate(); - let profile = RadrootsIdentityProfile { - identifier: Some("profile-id".to_string()), - ..RadrootsIdentityProfile::default() - }; - with_profile.set_profile(profile); + let with_profile = Keys::generate(); + let profiled_identity = with_profile.to_public().with_profile( + Profile::new().with_username(Username::parse("profile-id").expect("username")), + ); let profile_id = manager - .upsert_identity(&with_profile, Some("profile".into()), true) + .upsert_public_identity(profiled_identity, Some("profile".into()), true) .expect("upsert profile"); + manager + .attach_secret_keys(&profile_id, &with_profile, true) + .expect("attach profile keys"); let resolved = manager - .get_signing_identity(&profile_id) + .get_signing_keys(&profile_id) .expect("resolve") .expect("identity"); + assert_eq!(resolved.public_key(), with_profile.public_key()); + let stored_profile = manager + .default_public_identity() + .expect("default public identity") + .expect("public identity"); assert_eq!( - resolved + stored_profile .profile() - .and_then(|value| value.identifier.clone()) - .as_deref(), + .and_then(Profile::username) + .map(Username::as_str), Some("profile-id") ); @@ -1809,7 +1818,7 @@ mod tests { .expect("local signer"); assert!( manager - .resolve_signing_identity_for_signer(&local_signer) + .resolve_signing_keys_for_signer(&local_signer) .expect("resolve local signer") .is_some() ); @@ -1817,13 +1826,13 @@ mod tests { let remote_signer = RadrootsNostrSignerCapability::RemoteSession(Box::new( radroots_nostr_signer::prelude::RadrootsNostrRemoteSessionSignerCapability::new( radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId::new_v7(), - RadrootsIdentity::generate().to_public(), - RadrootsIdentity::generate().to_public(), + Keys::generate().to_public(), + Keys::generate().to_public(), ), )); assert!( manager - .resolve_signing_identity_for_signer(&remote_signer) + .resolve_signing_keys_for_signer(&remote_signer) .expect("resolve remote signer") .is_none() ); @@ -1849,7 +1858,7 @@ mod tests { ) .expect("manager"); let save_error = save_error_manager - .upsert_public_identity(RadrootsIdentity::generate().to_public(), None, true) + .upsert_public_identity(Keys::generate().to_public(), None, true) .expect_err("save error"); assert!(save_error.to_string().starts_with("store error:")); @@ -1858,22 +1867,21 @@ mod tests { Arc::new(VaultStoreError), ) .expect("manager"); - let identity = RadrootsIdentity::generate(); + let identity = Keys::generate(); let vault_store_error = vault_store_error_manager - .upsert_identity(&identity, None, true) + .upsert_keys(&identity, None, true) .expect_err("vault store error"); assert!(vault_store_error.to_string().starts_with("vault error:")); let mut load_error_state = RadrootsNostrAccountStoreState::default(); - let load_error_public = RadrootsIdentity::generate().to_public(); - load_error_state - .accounts - .push(RadrootsNostrAccountRecord::new( - load_error_public.clone(), - Some("watch".into()), - 1, - )); - load_error_state.default_account_id = Some(load_error_public.id.clone()); + let load_error_public = Keys::generate().to_public(); + load_error_state.accounts.push(AccountRecord::new( + load_error_public.clone(), + Some("watch".into()), + 1, + )); + load_error_state.default_account_id = + Some(AccountId::from_public_identity(&load_error_public)); let load_error_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); load_error_store .save(&load_error_state) @@ -1882,20 +1890,19 @@ mod tests { RadrootsNostrAccountsManager::new(load_error_store, Arc::new(VaultLoadError)) .expect("manager"); let vault_load_error = vault_load_error_manager - .default_signing_identity() + .default_signing_keys() .expect_err("vault load error"); assert!(vault_load_error.to_string().starts_with("vault error:")); let mut invalid_secret_state = RadrootsNostrAccountStoreState::default(); - let invalid_secret_public = RadrootsIdentity::generate().to_public(); - invalid_secret_state - .accounts - .push(RadrootsNostrAccountRecord::new( - invalid_secret_public.clone(), - Some("invalid".into()), - 1, - )); - invalid_secret_state.default_account_id = Some(invalid_secret_public.id.clone()); + let invalid_secret_public = Keys::generate().to_public(); + invalid_secret_state.accounts.push(AccountRecord::new( + invalid_secret_public.clone(), + Some("invalid".into()), + 1, + )); + invalid_secret_state.default_account_id = + Some(AccountId::from_public_identity(&invalid_secret_public)); let invalid_secret_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); invalid_secret_store .save(&invalid_secret_state) @@ -1904,7 +1911,7 @@ mod tests { RadrootsNostrAccountsManager::new(invalid_secret_store, Arc::new(VaultInvalidSecret)) .expect("manager"); let invalid_secret = invalid_secret_manager - .default_signing_identity() + .default_signing_keys() .expect_err("invalid secret"); assert!(invalid_secret.to_string().starts_with("identity error:")); } @@ -1927,7 +1934,7 @@ mod tests { .expect_err("default public poisoned"); assert!(default_public_err.to_string().starts_with("store error:")); let default_signing_err = manager - .default_signing_identity() + .default_signing_keys() .expect_err("default signing poisoned"); assert!(default_signing_err.to_string().starts_with("store error:")); let default_signer_err = manager @@ -1935,14 +1942,14 @@ mod tests { .expect_err("default signer poisoned"); assert!(default_signer_err.to_string().starts_with("store error:")); - let account_id = RadrootsIdentity::generate().id(); + let account_id = Keys::generate().id(); let signing_err = manager - .get_signing_identity(&account_id) + .get_signing_keys(&account_id) .expect_err("signing poisoned"); assert!(signing_err.to_string().starts_with("store error:")); - let attach_identity = RadrootsIdentity::generate(); + let attach_identity = Keys::generate(); let attach_err = manager - .attach_identity_secret(&account_id, &attach_identity, false) + .attach_secret_keys(&account_id, &attach_identity, false) .expect_err("attach poisoned"); assert!(attach_err.to_string().starts_with("store error:")); let signer_err = manager @@ -1966,7 +1973,7 @@ mod tests { .expect_err("remove poisoned"); assert!(remove_err.to_string().starts_with("store error:")); let upsert_err = manager - .upsert_public_identity(RadrootsIdentity::generate().to_public(), None, false) + .upsert_public_identity(Keys::generate().to_public(), None, false) .expect_err("upsert poisoned"); assert!(upsert_err.to_string().starts_with("store error:")); } @@ -1991,7 +1998,7 @@ mod tests { let poisoned_load = save_error_store.load().expect_err("poisoned load"); assert!(poisoned_load.to_string().starts_with("store error:")); - let account_id = RadrootsIdentity::generate().id(); + let account_id = Keys::generate().id(); let vault_store_error = VaultStoreError; assert!( vault_store_error diff --git a/crates/nostr_accounts/src/model.rs b/crates/nostr_accounts/src/model.rs @@ -1,30 +1,13 @@ -use radroots_identity::{RadrootsIdentityId, RadrootsIdentityPublic}; +use radroots_identity::{AccountId, account::Record}; use serde::{Deserialize, Serialize}; pub const RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION: u32 = 2; #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RadrootsNostrAccountRecord { - pub account_id: RadrootsIdentityId, - pub public_identity: RadrootsIdentityPublic, - #[serde(skip_serializing_if = "Option::is_none")] - pub label: Option<String>, - pub created_at_unix: u64, - pub updated_at_unix: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RadrootsNostrAccountStoreState { pub version: u32, - pub default_account_id: Option<RadrootsIdentityId>, - pub accounts: Vec<RadrootsNostrAccountRecord>, -} - -#[derive(Debug, Clone)] -pub enum RadrootsNostrAccountStatus { - NotConfigured, - PublicOnly { account: RadrootsNostrAccountRecord }, - Ready { account: RadrootsNostrAccountRecord }, + pub default_account_id: Option<AccountId>, + pub accounts: Vec<Record>, } impl Default for RadrootsNostrAccountStoreState { @@ -36,24 +19,3 @@ impl Default for RadrootsNostrAccountStoreState { } } } - -impl RadrootsNostrAccountRecord { - pub fn new( - public_identity: RadrootsIdentityPublic, - label: Option<String>, - created_at_unix: u64, - ) -> Self { - let account_id = public_identity.id.clone(); - Self { - account_id, - public_identity, - label, - created_at_unix, - updated_at_unix: created_at_unix, - } - } - - pub fn touch_updated(&mut self, updated_at_unix: u64) { - self.updated_at_unix = updated_at_unix; - } -} diff --git a/crates/nostr_accounts/src/nostrdb_bridge.rs b/crates/nostr_accounts/src/nostrdb_bridge.rs @@ -9,10 +9,10 @@ pub fn radroots_nostr_accounts_register_default_secret_with_nostrdb( let Some(signer) = manager.default_signer_capability()? else { return Ok(false); }; - let Some(identity) = manager.resolve_signing_identity_for_signer(&signer)? else { + let Some(keys) = manager.resolve_signing_keys_for_signer(&signer)? else { return Ok(false); }; - Ok(nostrdb.add_giftwrap_secret_key(identity.secret_key_bytes())) + Ok(nostrdb.add_giftwrap_secret_key(keys.secret_key().to_secret_bytes())) } #[cfg(test)] @@ -20,6 +20,8 @@ mod tests { use super::*; use crate::store::RadrootsNostrFileAccountStore; use crate::vault::RadrootsNostrSecretVaultMemory; + use nostr::Keys; + use radroots_identity::{PublicIdentity, PublicKey}; use radroots_nostrdb::prelude::RadrootsNostrdbConfig; use std::sync::Arc; @@ -32,7 +34,7 @@ mod tests { let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); let manager = RadrootsNostrAccountsManager::new(store, vault).expect("manager"); manager - .generate_identity(Some("primary".into()), true) + .generate_keys(Some("primary".into()), true) .expect("generate"); let nostrdb = @@ -52,12 +54,12 @@ mod tests { )); let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); let manager = RadrootsNostrAccountsManager::new(store, vault).expect("manager"); + let keys = Keys::generate(); + let public_identity = PublicIdentity::new( + PublicKey::from_hex(&keys.public_key().to_hex()).expect("public identity"), + ); manager - .upsert_public_identity( - radroots_identity::RadrootsIdentity::generate().to_public(), - Some("watch".into()), - true, - ) + .upsert_public_identity(public_identity, Some("watch".into()), true) .expect("watch"); let nostrdb = diff --git a/crates/nostr_accounts/src/vault.rs b/crates/nostr_accounts/src/vault.rs @@ -1,4 +1,4 @@ -use radroots_identity::RadrootsIdentityId; +use radroots_identity::AccountId; #[cfg(feature = "memory-vault")] pub use radroots_secret_vault::RadrootsSecretVaultMemory as RadrootsNostrSecretVaultMemory; @@ -7,6 +7,6 @@ pub use radroots_secret_vault::RadrootsSecretVaultOsKeyring as RadrootsNostrSecr pub use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultAccessError}; #[must_use] -pub fn account_secret_slot(account_id: &RadrootsIdentityId) -> String { +pub fn account_secret_slot(account_id: &AccountId) -> String { account_id.to_string() } diff --git a/crates/nostr_connect/Cargo.toml b/crates/nostr_connect/Cargo.toml @@ -1,16 +1,19 @@ [package] name = "radroots_nostr_connect" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] +authors.workspace = true rust-version.workspace = true 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" +readme = "README.md" + +[lib] +name = "radroots_nostr_connect" [dependencies] nostr = { workspace = true, features = ["nip44"] } @@ -22,5 +25,5 @@ url = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } +[lints] +workspace = true diff --git a/crates/nostr_connect/README b/crates/nostr_connect/README.md diff --git a/crates/nostr_signer/Cargo.toml b/crates/nostr_signer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_nostr_signer" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] @@ -20,6 +20,7 @@ native = ["dep:radroots_sql_core", "radroots_sql_core/native"] hex = { workspace = true } nostr = { workspace = true } radroots_identity = { workspace = true, default-features = false, features = [ + "serde", "std", ] } radroots_nostr = { workspace = true, features = ["std"] } diff --git a/crates/nostr_signer/src/backend.rs b/crates/nostr_signer/src/backend.rs @@ -15,8 +15,8 @@ use crate::model::{ RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision, RadrootsNostrSignerWorkflowId, }; -use nostr::{Event, PublicKey, RelayUrl, UnsignedEvent}; -use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; +use nostr::{Event, Keys, PublicKey, RelayUrl, UnsignedEvent}; +use radroots_identity::{AccountId, PublicIdentity, PublicKey as IdentityPublicKey}; use radroots_nostr_connect::prelude::{ RadrootsNostrConnectMethod, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, @@ -54,11 +54,11 @@ pub enum RadrootsNostrSignerPublishTransition { } pub trait RadrootsNostrSignerBackend: Send + Sync { - fn signer_identity(&self) -> Result<Option<RadrootsIdentityPublic>, RadrootsNostrSignerError>; + fn signer_identity(&self) -> Result<Option<PublicIdentity>, RadrootsNostrSignerError>; fn set_signer_identity( &self, - signer_identity: RadrootsIdentityPublic, + signer_identity: PublicIdentity, ) -> Result<(), RadrootsNostrSignerError>; fn capabilities( @@ -232,7 +232,8 @@ pub trait RadrootsNostrSignerBackend: Send + Sync { #[derive(Clone)] pub struct RadrootsNostrEmbeddedSignerBackend { manager: RadrootsNostrSignerManager, - signer_identity: RadrootsIdentity, + signer_keys: Keys, + signer_identity: PublicIdentity, } impl RadrootsNostrSignerBackendCapabilities { @@ -313,44 +314,43 @@ impl RadrootsNostrSignerPublishTransition { impl RadrootsNostrEmbeddedSignerBackend { pub fn new( manager: RadrootsNostrSignerManager, - signer_identity: RadrootsIdentity, + signer_keys: Keys, ) -> Result<Self, RadrootsNostrSignerError> { - let public_identity = signer_identity.to_public(); + let signer_identity = public_identity_from_keys(&signer_keys)?; let existing_identity = manager.signer_identity()?; if let Some(existing_identity) = existing_identity { - if !same_public_identity_key(&existing_identity, &public_identity) { + if !same_public_identity_key(&existing_identity, &signer_identity) { return Err(RadrootsNostrSignerError::InvalidState( "embedded signer identity does not match signer manager identity".into(), )); } } else { - manager.set_signer_identity(public_identity)?; + manager.set_signer_identity(signer_identity.clone())?; } Ok(Self { manager, + signer_keys, signer_identity, }) } - pub fn new_in_memory( - signer_identity: RadrootsIdentity, - ) -> Result<Self, RadrootsNostrSignerError> { - Self::new(RadrootsNostrSignerManager::new_in_memory(), signer_identity) + pub fn new_in_memory(signer_keys: Keys) -> Result<Self, RadrootsNostrSignerError> { + Self::new(RadrootsNostrSignerManager::new_in_memory(), signer_keys) } pub fn manager(&self) -> &RadrootsNostrSignerManager { &self.manager } - pub fn local_identity(&self) -> &RadrootsIdentity { - &self.signer_identity + pub fn local_keys(&self) -> &Keys { + &self.signer_keys } fn local_signer_capability(&self) -> RadrootsNostrLocalSignerCapability { - let public_identity = self.signer_identity.to_public(); + let public_identity = self.signer_identity.clone(); RadrootsNostrLocalSignerCapability::new( - public_identity.id.clone(), + AccountId::from_public_identity(&public_identity), public_identity, RadrootsNostrLocalSignerAvailability::SecretBacked, ) @@ -358,13 +358,13 @@ impl RadrootsNostrEmbeddedSignerBackend { } impl RadrootsNostrSignerBackend for RadrootsNostrEmbeddedSignerBackend { - fn signer_identity(&self) -> Result<Option<RadrootsIdentityPublic>, RadrootsNostrSignerError> { + fn signer_identity(&self) -> Result<Option<PublicIdentity>, RadrootsNostrSignerError> { self.manager.signer_identity() } fn set_signer_identity( &self, - signer_identity: RadrootsIdentityPublic, + signer_identity: PublicIdentity, ) -> Result<(), RadrootsNostrSignerError> { self.manager.set_signer_identity(signer_identity) } @@ -622,7 +622,7 @@ impl RadrootsNostrSignerBackend for RadrootsNostrEmbeddedSignerBackend { &self, unsigned_event: UnsignedEvent, ) -> Result<RadrootsNostrSignerSignOutput, RadrootsNostrSignerError> { - let event = unsigned_event.sign_with_keys(self.signer_identity.keys())?; + let event = unsigned_event.sign_with_keys(&self.signer_keys)?; Ok(RadrootsNostrSignerSignOutput::new( RadrootsNostrSignerCapability::LocalAccount(Box::new(self.local_signer_capability())), event, @@ -630,10 +630,17 @@ impl RadrootsNostrSignerBackend for RadrootsNostrEmbeddedSignerBackend { } } -fn same_public_identity_key(left: &RadrootsIdentityPublic, right: &RadrootsIdentityPublic) -> bool { - left.id == right.id - && left.public_key_hex == right.public_key_hex - && left.public_key_npub == right.public_key_npub +fn same_public_identity_key(left: &PublicIdentity, right: &PublicIdentity) -> bool { + left.id() == right.id() && left.public_key() == right.public_key() +} + +fn public_identity_from_keys(keys: &Keys) -> Result<PublicIdentity, RadrootsNostrSignerError> { + let public_key = IdentityPublicKey::from_hex(&keys.public_key().to_hex()).map_err(|error| { + RadrootsNostrSignerError::InvalidState(format!( + "embedded signer public key is invalid: {error}" + )) + })?; + Ok(PublicIdentity::new(public_key)) } #[cfg(test)] @@ -658,11 +665,11 @@ mod tests { }; use crate::store::RadrootsNostrSignerStore; use crate::test_support::{ - fixture_bob_identity, primary_relay, secondary_relay, synthetic_public_identity, - synthetic_public_key, synthetic_secret_hex, + fixture_bob_identity, primary_relay, secondary_relay, synthetic_keys, + synthetic_public_identity, synthetic_public_key, }; - use nostr::{EventBuilder, EventId, Kind}; - use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; + use nostr::{EventBuilder, EventId, Keys, Kind}; + use radroots_identity::{PublicIdentity, PublicKey as IdentityPublicKey}; use radroots_nostr_connect::prelude::{ RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, @@ -672,9 +679,14 @@ mod tests { use std::sync::RwLock; use std::sync::atomic::{AtomicU8, Ordering}; - fn embedded_identity(index: u32) -> RadrootsIdentity { - RadrootsIdentity::from_secret_key_str(synthetic_secret_hex(index).as_str()) - .expect("identity") + fn embedded_identity(index: u32) -> Keys { + synthetic_keys(index) + } + + fn embedded_public_identity(keys: &Keys) -> PublicIdentity { + PublicIdentity::new( + IdentityPublicKey::from_hex(&keys.public_key().to_hex()).expect("identity public key"), + ) } fn expect_registration_required( @@ -720,7 +732,7 @@ mod tests { } struct StubBackend { - signer_identity: Option<RadrootsIdentityPublic>, + signer_identity: Option<PublicIdentity>, signer_identity_error: Option<&'static str>, sign_error_message: Option<&'static str>, } @@ -764,9 +776,7 @@ mod tests { } impl RadrootsNostrSignerBackend for StubBackend { - fn signer_identity( - &self, - ) -> Result<Option<RadrootsIdentityPublic>, RadrootsNostrSignerError> { + fn signer_identity(&self) -> Result<Option<PublicIdentity>, RadrootsNostrSignerError> { if let Some(message) = self.signer_identity_error { return Err(RadrootsNostrSignerError::InvalidState(message.into())); } @@ -775,7 +785,7 @@ mod tests { fn set_signer_identity( &self, - _signer_identity: RadrootsIdentityPublic, + _signer_identity: PublicIdentity, ) -> Result<(), RadrootsNostrSignerError> { unreachable!("set_signer_identity not used in tests") } @@ -1022,11 +1032,11 @@ mod tests { .signer_identity() .expect("signer identity") .expect("present"); - assert_eq!(signer_identity.id, identity.to_public().id); + assert_eq!(signer_identity, embedded_public_identity(&identity)); let capabilities = backend.capabilities().expect("capabilities"); let local = capabilities.local_signer.clone().expect("local signer"); - assert_eq!(local.public_identity.id, identity.to_public().id); + assert_eq!(local.public_identity, embedded_public_identity(&identity)); assert!(local.is_secret_backed()); assert!(capabilities.remote_sessions.is_empty()); assert_eq!(capabilities.all_signers().len(), 1); @@ -1037,9 +1047,9 @@ mod tests { .expect("stored signer identity"); assert!(same_public_identity_key( &manager_identity, - &identity.to_public() + &embedded_public_identity(&identity) )); - assert_eq!(backend.local_identity().public_key(), identity.public_key()); + assert_eq!(backend.local_keys().public_key(), identity.public_key()); } #[test] @@ -1065,7 +1075,7 @@ mod tests { fn embedded_backend_accepts_matching_manager_identity_and_setter_delegate() { let identity = embedded_identity(0x97); let manager = RadrootsNostrSignerManager::new_in_memory(); - let public_identity = identity.to_public(); + let public_identity = embedded_public_identity(&identity); manager .set_signer_identity(public_identity.clone()) .expect("prime manager identity"); @@ -1074,7 +1084,7 @@ mod tests { .expect("matching embedded backend"); let backend_trait: &dyn RadrootsNostrSignerBackend = &backend; - assert_eq!(backend.local_identity().public_key(), identity.public_key()); + assert_eq!(backend.local_keys().public_key(), identity.public_key()); assert!(same_public_identity_key( backend_trait .signer_identity() @@ -1638,7 +1648,7 @@ mod tests { assert_eq!(output.event.pubkey, identity.public_key()); let local = output.signer.local_account().expect("local signer"); - assert_eq!(local.public_identity.id, identity.to_public().id); + assert_eq!(local.public_identity, embedded_public_identity(&identity)); assert!(local.is_secret_backed()); } @@ -1712,9 +1722,7 @@ mod tests { let valid_identity = synthetic_public_identity(0xb2); assert!(same_public_identity_key(&valid_identity, &valid_identity)); - let mut valid_identity_with_different_hex = valid_identity.clone(); - valid_identity_with_different_hex.public_key_hex = - synthetic_public_identity(0xb3).public_key_hex; + let valid_identity_with_different_hex = synthetic_public_identity(0xb3); assert!(!same_public_identity_key( &valid_identity, &valid_identity_with_different_hex diff --git a/crates/nostr_signer/src/capability.rs b/crates/nostr_signer/src/capability.rs @@ -1,6 +1,6 @@ use crate::model::{RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord}; use nostr::RelayUrl; -use radroots_identity::{RadrootsIdentityId, RadrootsIdentityPublic}; +use radroots_identity::{AccountId, PublicIdentity}; use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions; use serde::{Deserialize, Serialize}; @@ -12,16 +12,16 @@ pub enum RadrootsNostrLocalSignerAvailability { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RadrootsNostrLocalSignerCapability { - pub account_id: RadrootsIdentityId, - pub public_identity: RadrootsIdentityPublic, + pub account_id: AccountId, + pub public_identity: PublicIdentity, pub availability: RadrootsNostrLocalSignerAvailability, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RadrootsNostrRemoteSessionSignerCapability { pub connection_id: RadrootsNostrSignerConnectionId, - pub signer_identity: RadrootsIdentityPublic, - pub user_identity: RadrootsIdentityPublic, + pub signer_identity: PublicIdentity, + pub user_identity: PublicIdentity, pub relays: Vec<RelayUrl>, pub permissions: RadrootsNostrConnectPermissions, } @@ -32,16 +32,14 @@ pub enum RadrootsNostrSignerCapability { RemoteSession(Box<RadrootsNostrRemoteSessionSignerCapability>), } -fn public_identity_eq(left: &RadrootsIdentityPublic, right: &RadrootsIdentityPublic) -> bool { - left.id == right.id - && left.public_key_hex == right.public_key_hex - && left.public_key_npub == right.public_key_npub +fn public_identity_eq(left: &PublicIdentity, right: &PublicIdentity) -> bool { + left == right } impl RadrootsNostrLocalSignerCapability { pub fn new( - account_id: RadrootsIdentityId, - public_identity: RadrootsIdentityPublic, + account_id: AccountId, + public_identity: PublicIdentity, availability: RadrootsNostrLocalSignerAvailability, ) -> Self { Self { @@ -59,8 +57,8 @@ impl RadrootsNostrLocalSignerCapability { impl RadrootsNostrRemoteSessionSignerCapability { pub fn new( connection_id: RadrootsNostrSignerConnectionId, - signer_identity: RadrootsIdentityPublic, - user_identity: RadrootsIdentityPublic, + signer_identity: PublicIdentity, + user_identity: PublicIdentity, ) -> Self { Self { connection_id, @@ -83,7 +81,7 @@ impl RadrootsNostrRemoteSessionSignerCapability { } impl RadrootsNostrSignerCapability { - pub fn public_identity(&self) -> &RadrootsIdentityPublic { + pub fn public_identity(&self) -> &PublicIdentity { match self { Self::LocalAccount(capability) => &capability.public_identity, Self::RemoteSession(capability) => &capability.user_identity, @@ -171,18 +169,13 @@ mod tests { fixture_alice_identity, fixture_bob_identity, fixture_carol_identity, fixture_diego_public_key, primary_relay, secondary_relay, }; - use radroots_identity::RadrootsIdentityPublic; + use radroots_identity::PublicIdentity; use radroots_nostr_connect::prelude::{ RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, }; - fn assert_public_identity_matches( - actual: &RadrootsIdentityPublic, - expected: &RadrootsIdentityPublic, - ) { - assert_eq!(actual.id, expected.id); - assert_eq!(actual.public_key_hex, expected.public_key_hex); - assert_eq!(actual.public_key_npub, expected.public_key_npub); + fn assert_public_identity_matches(actual: &PublicIdentity, expected: &PublicIdentity) { + assert_eq!(actual, expected); } #[test] @@ -190,7 +183,7 @@ mod tests { let public_identity = fixture_alice_identity(); let capability = RadrootsNostrSignerCapability::LocalAccount(Box::new( RadrootsNostrLocalSignerCapability::new( - public_identity.id.clone(), + AccountId::from_public_identity(&public_identity), public_identity.clone(), RadrootsNostrLocalSignerAvailability::SecretBacked, ), @@ -260,46 +253,37 @@ mod tests { #[test] fn capability_equality_accounts_for_identity_fields_and_variant_kind() { let alice = fixture_alice_identity(); - let mut alice_with_different_hex = alice.clone(); - alice_with_different_hex.public_key_hex = fixture_bob_identity().public_key_hex; - let mut alice_with_different_npub = alice.clone(); - alice_with_different_npub.public_key_npub = fixture_bob_identity().public_key_npub; + let bob = fixture_bob_identity(); let local = RadrootsNostrLocalSignerCapability::new( - alice.id.clone(), + AccountId::from_public_identity(&alice), alice.clone(), RadrootsNostrLocalSignerAvailability::SecretBacked, ); let local_same = RadrootsNostrLocalSignerCapability::new( - alice.id.clone(), + AccountId::from_public_identity(&alice), alice.clone(), RadrootsNostrLocalSignerAvailability::SecretBacked, ); let local_changed_account = RadrootsNostrLocalSignerCapability::new( - fixture_bob_identity().id, + AccountId::from_public_identity(&bob), alice.clone(), RadrootsNostrLocalSignerAvailability::SecretBacked, ); let local_changed_availability = RadrootsNostrLocalSignerCapability::new( - alice.id.clone(), + AccountId::from_public_identity(&alice), alice.clone(), RadrootsNostrLocalSignerAvailability::PublicOnly, ); - let local_changed_hex = RadrootsNostrLocalSignerCapability::new( - alice.id.clone(), - alice_with_different_hex, - RadrootsNostrLocalSignerAvailability::SecretBacked, - ); - let local_changed = RadrootsNostrLocalSignerCapability::new( - alice.id.clone(), - alice_with_different_npub, + let local_changed_identity = RadrootsNostrLocalSignerCapability::new( + AccountId::from_public_identity(&alice), + bob, RadrootsNostrLocalSignerAvailability::SecretBacked, ); assert_eq!(local, local_same); assert_ne!(local, local_changed_account); assert_ne!(local, local_changed_availability); - assert_ne!(local, local_changed_hex); - assert_ne!(local, local_changed); + assert_ne!(local, local_changed_identity); let remote = RadrootsNostrRemoteSessionSignerCapability::new( RadrootsNostrSignerConnectionId::new_v7(), @@ -323,16 +307,15 @@ mod tests { .into(), ); let mut remote_changed_signer = remote.clone(); - remote_changed_signer.signer_identity.public_key_hex = - fixture_alice_identity().public_key_hex; - let mut remote_changed = remote.clone(); - remote_changed.user_identity.public_key_npub = fixture_alice_identity().public_key_npub; + remote_changed_signer.signer_identity = fixture_alice_identity(); + let mut remote_changed_user = remote.clone(); + remote_changed_user.user_identity = fixture_alice_identity(); assert_eq!(remote, remote_same); assert_ne!(remote, remote_changed_connection); assert_ne!(remote, remote_changed_relays); assert_ne!(remote, remote_changed_permissions); assert_ne!(remote, remote_changed_signer); - assert_ne!(remote, remote_changed); + assert_ne!(remote, remote_changed_user); assert_eq!( RadrootsNostrSignerCapability::LocalAccount(Box::new(local.clone())), @@ -344,27 +327,16 @@ mod tests { ); assert_ne!( RadrootsNostrSignerCapability::LocalAccount(Box::new(local)), - RadrootsNostrSignerCapability::RemoteSession(Box::new(remote_changed)) + RadrootsNostrSignerCapability::RemoteSession(Box::new(remote_changed_user)) ); } #[test] - fn public_identity_eq_covers_field_level_short_circuits() { + fn public_identity_eq_compares_invariant_checked_values() { let alice = fixture_alice_identity(); let bob = fixture_bob_identity(); - let mut different_id = alice.clone(); - different_id.id = bob.id.clone(); - assert!(!public_identity_eq(&alice, &different_id)); - - let mut different_hex = alice.clone(); - different_hex.public_key_hex = bob.public_key_hex.clone(); - assert!(!public_identity_eq(&alice, &different_hex)); - - let mut different_npub = alice.clone(); - different_npub.public_key_npub = bob.public_key_npub.clone(); - assert!(!public_identity_eq(&alice, &different_npub)); - + assert!(!public_identity_eq(&alice, &bob)); assert!(public_identity_eq(&alice, &alice)); } } diff --git a/crates/nostr_signer/src/evaluation.rs b/crates/nostr_signer/src/evaluation.rs @@ -5,7 +5,7 @@ use crate::model::{ RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestId, }; use nostr::{PublicKey, RelayUrl}; -use radroots_identity::RadrootsIdentityPublic; +use radroots_identity::PublicIdentity; use radroots_nostr_connect::prelude::{ RadrootsNostrConnectClientMetadata, RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRemoteSessionCapability, @@ -69,7 +69,7 @@ pub struct RadrootsNostrSignerRequestEvaluation { impl RadrootsNostrSignerConnectProposal { pub fn into_connection_draft( self, - user_identity: RadrootsIdentityPublic, + user_identity: PublicIdentity, ) -> RadrootsNostrSignerConnectionDraft { let mut draft = RadrootsNostrSignerConnectionDraft::new(self.client_public_key, user_identity) @@ -207,14 +207,10 @@ fn sign_event_kind_suffix(value: &str) -> &str { value.strip_prefix("kind:").unwrap_or(value) } -fn identity_public_key( - identity: &RadrootsIdentityPublic, -) -> Result<PublicKey, RadrootsNostrSignerError> { - PublicKey::parse(identity.public_key_hex.as_str()) - .or_else(|_| PublicKey::from_hex(identity.public_key_hex.as_str())) - .map_err(|_| { - RadrootsNostrSignerError::InvalidState("user identity public key is invalid".into()) - }) +fn identity_public_key(identity: &PublicIdentity) -> Result<PublicKey, RadrootsNostrSignerError> { + PublicKey::from_hex(&identity.public_key().to_hex()).map_err(|_| { + RadrootsNostrSignerError::InvalidState("user identity public key is invalid".into()) + }) } #[cfg(test)] @@ -223,17 +219,11 @@ mod tests { use super::*; use crate::test_support::{ api_primary_https, fixture_alice_identity, fixture_alice_public_key, fixture_bob_identity, - fixture_carol_public_key, fixture_diego_identity, primary_relay, synthetic_public_identity, - synthetic_public_key, + fixture_carol_public_key, fixture_diego_identity, primary_relay, synthetic_public_key, }; use nostr::{PublicKey, Timestamp, UnsignedEvent}; - use radroots_identity::RadrootsIdentityPublic; use serde_json::json; - fn public_identity(index: u32) -> RadrootsIdentityPublic { - synthetic_public_identity(index) - } - fn public_key(index: u32) -> PublicKey { synthetic_public_key(index) } @@ -299,7 +289,7 @@ mod tests { match hint { RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(capability) => { let expected_public_key = - PublicKey::parse(fixture_diego_identity().public_key_hex.as_str()) + PublicKey::from_hex(&fixture_diego_identity().public_key().to_hex()) .expect("user public key"); assert_eq!( capability.user_public_key.to_hex(), @@ -561,38 +551,4 @@ mod tests { RadrootsNostrSignerRequestResponseHint::RelayList(vec![primary_relay()]) ); } - - #[test] - fn invalid_identity_public_key_returns_invalid_state() { - let mut identity = public_identity(9); - identity.public_key_hex = "invalid".into(); - - let err = identity_public_key(&identity).expect_err("invalid identity"); - assert!( - err.to_string() - .contains("user identity public key is invalid") - ); - - let mut invalid_connection = connection(); - invalid_connection.user_identity.public_key_hex = "invalid".into(); - let err = response_hint_for_request( - &invalid_connection, - &RadrootsNostrConnectRequest::GetPublicKey, - ) - .expect_err("invalid get_public_key response hint"); - assert!( - err.to_string() - .contains("user identity public key is invalid") - ); - - let err = response_hint_for_request( - &invalid_connection, - &RadrootsNostrConnectRequest::GetSessionCapability, - ) - .expect_err("invalid get_session_capability response hint"); - assert!( - err.to_string() - .contains("user identity public key is invalid") - ); - } } diff --git a/crates/nostr_signer/src/manager.rs b/crates/nostr_signer/src/manager.rs @@ -19,7 +19,7 @@ use crate::model::{ }; use crate::store::{RadrootsNostrMemorySignerStore, RadrootsNostrSignerStore}; use nostr::{PublicKey, RelayUrl}; -use radroots_identity::RadrootsIdentityPublic; +use radroots_identity::PublicIdentity; use radroots_nostr_connect::prelude::{ RadrootsNostrConnectClientMetadata, RadrootsNostrConnectMethod, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, @@ -57,9 +57,7 @@ impl RadrootsNostrSignerManager { }) } - pub fn signer_identity( - &self, - ) -> Result<Option<RadrootsIdentityPublic>, RadrootsNostrSignerError> { + pub fn signer_identity(&self) -> Result<Option<PublicIdentity>, RadrootsNostrSignerError> { let guard = self .state .read() @@ -69,7 +67,7 @@ impl RadrootsNostrSignerManager { pub fn set_signer_identity( &self, - signer_identity: RadrootsIdentityPublic, + signer_identity: PublicIdentity, ) -> Result<(), RadrootsNostrSignerError> { validate_public_identity(&signer_identity)?; self.update_state(|state| { @@ -289,11 +287,11 @@ impl RadrootsNostrSignerManager { if state.connections.iter().any(|record| { !record.is_terminal() && record.client_public_key == draft.client_public_key - && record.user_identity.id == draft.user_identity.id + && record.user_identity.id() == draft.user_identity.id() }) { return Err(RadrootsNostrSignerError::ConnectionAlreadyExists { client_public_key: draft.client_public_key.to_hex(), - user_identity_id: draft.user_identity.id.to_string(), + user_identity_id: draft.user_identity.id().to_string(), }); } @@ -1026,14 +1024,7 @@ fn ensure_no_active_publish_workflow( Ok(()) } -fn validate_public_identity( - identity: &RadrootsIdentityPublic, -) -> Result<(), RadrootsNostrSignerError> { - if identity.id.as_str() != identity.public_key_hex { - return Err(RadrootsNostrSignerError::InvalidState( - "public identity id does not match public key".into(), - )); - } +fn validate_public_identity(_identity: &PublicIdentity) -> Result<(), RadrootsNostrSignerError> { Ok(()) } @@ -1174,13 +1165,11 @@ fn request_decision( } fn parse_identity_public_key( - identity: &RadrootsIdentityPublic, + identity: &PublicIdentity, ) -> Result<PublicKey, RadrootsNostrSignerError> { - PublicKey::parse(identity.public_key_hex.as_str()) - .or_else(|_| PublicKey::from_hex(identity.public_key_hex.as_str())) - .map_err(|_| { - RadrootsNostrSignerError::InvalidState("identity public key is invalid".into()) - }) + PublicKey::from_hex(&identity.public_key().to_hex()).map_err(|_| { + RadrootsNostrSignerError::InvalidState("identity public key is invalid".into()) + }) } fn now_unix_secs() -> u64 { @@ -1226,23 +1215,16 @@ mod tests { synthetic_public_identity, synthetic_public_key, tertiary_relay, }; use nostr::{PublicKey, Timestamp, UnsignedEvent}; - use radroots_identity::RadrootsIdentityPublic; + use radroots_identity::PublicIdentity; use radroots_nostr_connect::prelude::RadrootsNostrConnectPermission; use serde_json::json; use std::sync::Arc; use std::thread; - fn public_identity(index: u32) -> RadrootsIdentityPublic { + fn public_identity(index: u32) -> PublicIdentity { synthetic_public_identity(index) } - fn invalid_public_identity(index: u32) -> RadrootsIdentityPublic { - let mut identity = public_identity(index); - identity.id = - radroots_identity::RadrootsIdentityId::parse(&public_key(0xff).to_hex()).expect("id"); - identity - } - fn public_key(index: u32) -> PublicKey { synthetic_public_key(index) } @@ -1369,10 +1351,8 @@ mod tests { .join(); } - fn assert_same_public_identity(left: &RadrootsIdentityPublic, right: &RadrootsIdentityPublic) { - assert_eq!(left.id.as_str(), right.id.as_str()); - assert_eq!(left.public_key_hex, right.public_key_hex); - assert_eq!(left.public_key_npub, right.public_key_npub); + fn assert_same_public_identity(left: &PublicIdentity, right: &PublicIdentity) { + assert_eq!(left, right); } fn assert_same_connection( @@ -1529,7 +1509,7 @@ mod tests { } #[test] - fn set_signer_identity_validates_and_persists() { + fn set_signer_identity_persists_invariant_checked_value() { let manager = RadrootsNostrSignerManager::new_in_memory(); let signer_identity = fixture_alice_identity(); manager @@ -1541,14 +1521,6 @@ mod tests { .expect("identity") .expect("loaded"); assert_same_public_identity(&loaded, &signer_identity); - - let err = manager - .set_signer_identity(invalid_public_identity(0x2)) - .expect_err("invalid identity"); - assert!( - err.to_string() - .contains("public identity id does not match public key") - ); } #[test] @@ -1673,18 +1645,6 @@ mod tests { .to_string() .contains("connect secret already in use") ); - - let invalid_user = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x13), - invalid_public_identity(0x14), - )) - .expect_err("invalid user identity"); - assert!( - invalid_user - .to_string() - .contains("public identity id does not match public key") - ); } #[test] @@ -2989,41 +2949,6 @@ mod tests { ); assert!(evaluation.connection.pending_request.is_none()); - let (invalid_identity_eval_record, invalid_identity_eval_workflow, _) = - start_auth_replay_workflow(10, "req-eval-invalid-identity"); - { - let mut state = manager.state.write().expect("write"); - let pending_request = RadrootsNostrSignerPendingRequest::new( - request_message_with_request( - "req-eval-invalid-identity", - RadrootsNostrConnectRequest::GetSessionCapability, - ), - 81, - ) - .expect("pending request"); - let workflow = state - .publish_workflows - .iter_mut() - .find(|workflow| workflow.workflow_id == invalid_identity_eval_workflow.workflow_id) - .expect("stored workflow"); - workflow.pending_request = Some(pending_request.clone()); - let record = state - .connections - .iter_mut() - .find(|record| record.connection_id == invalid_identity_eval_record.connection_id) - .expect("stored connection"); - record.pending_request = Some(pending_request); - record.user_identity.public_key_hex = "invalid".into(); - } - let invalid_identity_eval_err = manager - .evaluate_auth_replay_publish_workflow(&invalid_identity_eval_workflow.workflow_id) - .expect_err("invalid identity evaluate"); - assert!( - invalid_identity_eval_err - .to_string() - .contains("user identity public key is invalid") - ); - let (terminal_eval_record, terminal_eval_workflow, _) = start_auth_replay_workflow(2, "req-eval-terminal"); { @@ -3455,28 +3380,6 @@ mod tests { } #[test] - fn register_connection_rejects_invalid_persisted_signer_identity() { - let store = Arc::new(RadrootsNostrMemorySignerStore::new()); - let state = RadrootsNostrSignerStoreState { - signer_identity: Some(invalid_public_identity(0x54)), - ..Default::default() - }; - store.save(&state).expect("seed state"); - - let manager = RadrootsNostrSignerManager::new(store).expect("manager"); - let err = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x55), - public_identity(0x56), - )) - .expect_err("invalid signer identity"); - assert!( - err.to_string() - .contains("public identity id does not match public key") - ); - } - - #[test] fn manager_reports_poisoned_state_lock() { let manager = RadrootsNostrSignerManager::new_in_memory(); poison_manager_state(&manager); @@ -3555,8 +3458,8 @@ mod tests { .evaluate_connect_request( public_key(0x58), RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: PublicKey::parse( - signer_identity.public_key_hex.as_str(), + remote_signer_public_key: PublicKey::from_hex( + &signer_identity.public_key().to_hex(), ) .expect("signer public key"), secret: Some("secret".into()), @@ -3755,7 +3658,7 @@ mod tests { let manager = RadrootsNostrSignerManager::new_in_memory(); let signer_identity = public_identity(0x60); let signer_public_key = - PublicKey::parse(signer_identity.public_key_hex.as_str()).expect("signer public key"); + PublicKey::from_hex(&signer_identity.public_key().to_hex()).expect("signer public key"); manager .set_signer_identity(signer_identity) .expect("set signer"); @@ -3930,34 +3833,6 @@ mod tests { .to_string() .contains("different client public key") ); - - let store = Arc::new(RadrootsNostrMemorySignerStore::new()); - let mut invalid_identity = public_identity(0x69); - invalid_identity.public_key_hex = "invalid".into(); - let invalid_state = RadrootsNostrSignerStoreState { - signer_identity: Some(invalid_identity), - ..Default::default() - }; - store - .save(&invalid_state) - .expect("save invalid signer state"); - let invalid_manager = RadrootsNostrSignerManager::new(store).expect("invalid manager"); - let invalid_signer_err = invalid_manager - .evaluate_connect_request( - public_key(0x70), - RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: signer_public_key, - secret: None, - requested_permissions: RadrootsNostrConnectPermissions::default(), - client_metadata: None, - }, - ) - .expect_err("invalid signer public key"); - assert!( - invalid_signer_err - .to_string() - .contains("identity public key is invalid") - ); } #[test] @@ -4176,25 +4051,5 @@ mod tests { ) .expect_err("invalid pending request"); assert!(invalid_pending.to_string().contains("invalid request id")); - - let mut invalid_user_record = RadrootsNostrSignerConnectionRecord::new( - RadrootsNostrSignerConnectionId::new_v7(), - public_identity(0x87), - RadrootsNostrSignerConnectionDraft::new(public_key(0x88), public_identity(0x89)), - 1, - ); - invalid_user_record.status = RadrootsNostrSignerConnectionStatus::Active; - invalid_user_record.user_identity.public_key_hex = "invalid".into(); - let response_hint_err = evaluate_request_action( - &mut invalid_user_record, - &request_message_with_request("req-get", RadrootsNostrConnectRequest::GetPublicKey), - 1, - ) - .expect_err("invalid response hint"); - assert!( - response_hint_err - .to_string() - .contains("user identity public key is invalid") - ); } } diff --git a/crates/nostr_signer/src/model.rs b/crates/nostr_signer/src/model.rs @@ -1,7 +1,7 @@ use crate::error::RadrootsNostrSignerError; use hex::encode as hex_encode; use nostr::{PublicKey, RelayUrl}; -use radroots_identity::RadrootsIdentityPublic; +use radroots_identity::PublicIdentity; use radroots_nostr_connect::prelude::{ RadrootsNostrConnectClientMetadata, RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequestMessage, @@ -120,7 +120,7 @@ pub struct RadrootsNostrSignerPermissionGrant { #[derive(Debug, Clone)] pub struct RadrootsNostrSignerConnectionDraft { pub client_public_key: PublicKey, - pub user_identity: RadrootsIdentityPublic, + pub user_identity: PublicIdentity, pub connect_secret: Option<String>, pub client_metadata: Option<RadrootsNostrConnectClientMetadata>, pub requested_permissions: RadrootsNostrConnectPermissions, @@ -132,8 +132,8 @@ pub struct RadrootsNostrSignerConnectionDraft { pub struct RadrootsNostrSignerConnectionRecord { pub connection_id: RadrootsNostrSignerConnectionId, pub client_public_key: PublicKey, - pub signer_identity: RadrootsIdentityPublic, - pub user_identity: RadrootsIdentityPublic, + pub signer_identity: PublicIdentity, + pub user_identity: PublicIdentity, #[serde( default, alias = "connect_secret", @@ -197,7 +197,7 @@ pub struct RadrootsNostrSignerPublishWorkflowRecord { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RadrootsNostrSignerStoreState { pub version: u32, - pub signer_identity: Option<RadrootsIdentityPublic>, + pub signer_identity: Option<PublicIdentity>, pub connections: Vec<RadrootsNostrSignerConnectionRecord>, pub audit_records: Vec<RadrootsNostrSignerRequestAuditRecord>, #[serde(default)] @@ -456,7 +456,7 @@ impl RadrootsNostrSignerPermissionGrant { } impl RadrootsNostrSignerConnectionDraft { - pub fn new(client_public_key: PublicKey, user_identity: RadrootsIdentityPublic) -> Self { + pub fn new(client_public_key: PublicKey, user_identity: PublicIdentity) -> Self { Self { client_public_key, user_identity, @@ -506,7 +506,7 @@ impl RadrootsNostrSignerConnectionDraft { impl RadrootsNostrSignerConnectionRecord { pub fn new( connection_id: RadrootsNostrSignerConnectionId, - signer_identity: RadrootsIdentityPublic, + signer_identity: PublicIdentity, draft: RadrootsNostrSignerConnectionDraft, created_at_unix: u64, ) -> Self { @@ -771,12 +771,12 @@ mod tests { primary_relay, synthetic_public_identity, synthetic_public_key, }; use nostr::PublicKey; - use radroots_identity::RadrootsIdentityPublic; + use radroots_identity::PublicIdentity; use serde_json::json; use std::str::FromStr; use tempfile::tempdir; - fn public_identity(index: u32) -> RadrootsIdentityPublic { + fn public_identity(index: u32) -> PublicIdentity { synthetic_public_identity(index) } diff --git a/crates/nostr_signer/src/nip46.rs b/crates/nostr_signer/src/nip46.rs @@ -2,7 +2,7 @@ use nostr::{ UnsignedEvent, filter::{Alphabet, SingleLetterTag}, }; -use radroots_identity::RadrootsIdentityPublic; +use radroots_identity::PublicIdentity; use radroots_nostr::prelude::{ RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrGenericEventBuilder, RadrootsNostrKind, RadrootsNostrPublicKey, RadrootsNostrRelayUrl, RadrootsNostrTag, RadrootsNostrTimestamp, @@ -42,7 +42,7 @@ pub trait RadrootsNostrSignerNip46Signer: Clone + Send + Sync { client_public_key: &RadrootsNostrPublicKey, payload: &str, ) -> Result<String, RadrootsNostrSignerError>; - fn user_identity(&self) -> RadrootsIdentityPublic; + fn user_identity(&self) -> PublicIdentity; /// Signs a caller-supplied NIP-46 unsigned event without claiming typed /// Radroots product authoring. fn sign_user_event( @@ -206,7 +206,7 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { &self, unsigned_event: UnsignedEvent, ) -> RadrootsNostrConnectResponse { - let user_public_key = self.signer.user_identity().public_key_hex; + let user_public_key = self.signer.user_identity().public_key().to_hex(); if unsigned_event.pubkey.to_hex() != user_public_key { return RadrootsNostrConnectResponse::Error { result: None, @@ -853,8 +853,8 @@ mod tests { }; use crate::store::RadrootsNostrSignerStore; use crate::test_support::{fixture_alice_identity, fixture_carol_public_key, primary_relay}; - use nostr::{Keys, Timestamp, UnsignedEvent}; - use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; + use nostr::{Keys, SecretKey, Timestamp, UnsignedEvent}; + use radroots_identity::{PublicIdentity, PublicKey as IdentityPublicKey}; use radroots_nostr::prelude::{ RadrootsNostrEvent, RadrootsNostrGenericEventBuilder, RadrootsNostrKind, RadrootsNostrPublicKey, RadrootsNostrTagKind, @@ -872,8 +872,8 @@ mod tests { #[derive(Clone)] struct TestSigner { - signer_identity: RadrootsIdentity, - user_identity: RadrootsIdentity, + signer_identity: Keys, + user_identity: Keys, sign_events: bool, fail_crypto: bool, } @@ -948,8 +948,8 @@ mod tests { Ok(payload.to_owned()) } - fn user_identity(&self) -> RadrootsIdentityPublic { - self.user_identity.to_public() + fn user_identity(&self) -> PublicIdentity { + public_identity_from_keys(&self.user_identity) } fn sign_user_event( @@ -958,7 +958,7 @@ mod tests { ) -> Result<RadrootsNostrEvent, RadrootsNostrSignerError> { if self.sign_events { return unsigned_event - .sign_with_keys(self.user_identity.keys()) + .sign_with_keys(&self.user_identity) .map_err(|error| RadrootsNostrSignerError::Sign(error.to_string())); } Err(RadrootsNostrSignerError::Sign( @@ -1069,16 +1069,24 @@ mod tests { test_signer_with_options(false, false) } + fn keys_from_secret(secret_key_hex: &str) -> Keys { + Keys::new(SecretKey::from_hex(secret_key_hex).expect("secret key")) + } + + fn public_identity_from_keys(keys: &Keys) -> PublicIdentity { + PublicIdentity::new( + IdentityPublicKey::from_hex(&keys.public_key().to_hex()).expect("identity public key"), + ) + } + fn test_signer_with_options(sign_events: bool, fail_crypto: bool) -> TestSigner { TestSigner { - signer_identity: RadrootsIdentity::from_secret_key_str( + signer_identity: keys_from_secret( "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("signer identity"), - user_identity: RadrootsIdentity::from_secret_key_str( + ), + user_identity: keys_from_secret( "2222222222222222222222222222222222222222222222222222222222222222", - ) - .expect("user identity"), + ), sign_events, fail_crypto, } @@ -2010,17 +2018,15 @@ mod tests { backend .register_connection(RadrootsNostrSignerConnectionDraft::new( client_public_key, - test_signer().user_identity.to_public(), + test_signer().user_identity(), )) .expect("first connection"); backend .register_connection(RadrootsNostrSignerConnectionDraft::new( client_public_key, - RadrootsIdentity::from_secret_key_str( + public_identity_from_keys(&keys_from_secret( "3333333333333333333333333333333333333333333333333333333333333333", - ) - .expect("second user identity") - .to_public(), + )), )) .expect("second connection"); @@ -2119,7 +2125,7 @@ mod tests { let response = codec .sign_event_response( serde_json::from_value(serde_json::json!({ - "pubkey": fixture_alice_identity().public_key_hex, + "pubkey": fixture_alice_identity().public_key().to_hex(), "created_at": 1, "kind": 1, "tags": [], @@ -2188,8 +2194,8 @@ mod tests { | RadrootsNostrSignerAuthState::Authorized )); assert_eq!( - connection.user_identity.id, - test_signer().user_identity().id + connection.user_identity.id(), + test_signer().user_identity().id() ); } } diff --git a/crates/nostr_signer/src/store.rs b/crates/nostr_signer/src/store.rs @@ -23,7 +23,7 @@ use crate::sqlite::RadrootsNostrSignerSqliteDb; #[cfg(feature = "native")] use nostr::RelayUrl; #[cfg(feature = "native")] -use radroots_identity::RadrootsIdentityPublic; +use radroots_identity::PublicIdentity; #[cfg(feature = "native")] use radroots_nostr_connect::prelude::{ RadrootsNostrConnectClientMetadata, RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, @@ -159,7 +159,7 @@ impl RadrootsNostrSignerStore for RadrootsNostrSqliteSignerStore { signer_identity: metadata .signer_identity_json .as_deref() - .map(parse_json_field::<RadrootsIdentityPublic>) + .map(parse_json_field::<PublicIdentity>) .transpose()?, connections: Vec::new(), audit_records: Vec::new(), @@ -297,11 +297,11 @@ impl RadrootsNostrSignerStore for RadrootsNostrSqliteSignerStore { state .signer_identity .as_ref() - .map(|identity| identity.id.to_string()), + .map(|identity| identity.id().to_string()), state .signer_identity .as_ref() - .map(|identity| identity.public_key_hex.clone()), + .map(|identity| identity.public_key().to_hex()), state .signer_identity .as_ref() @@ -317,11 +317,11 @@ impl RadrootsNostrSignerStore for RadrootsNostrSqliteSignerStore { json!([ connection.connection_id.as_str(), connection.client_public_key.to_hex(), - connection.signer_identity.id.to_string(), - connection.signer_identity.public_key_hex.clone(), + connection.signer_identity.id().to_string(), + connection.signer_identity.public_key().to_hex(), serde_json::to_string(&connection.signer_identity)?, - connection.user_identity.id.to_string(), - connection.user_identity.public_key_hex.clone(), + connection.user_identity.id().to_string(), + connection.user_identity.public_key().to_hex(), serde_json::to_string(&connection.user_identity)?, connection .connect_secret_hash diff --git a/crates/nostr_signer/src/test_support.rs b/crates/nostr_signer/src/test_support.rs @@ -3,12 +3,13 @@ use crate::test_fixtures::{ FIXTURE_DIEGO, RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS, RELAY_TERTIARY_WSS, }; use nostr::{Keys, PublicKey, RelayUrl, SecretKey}; -use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; +use radroots_identity::{PublicIdentity, PublicKey as IdentityPublicKey}; -fn approved_public_identity(identity: ApprovedFixtureIdentity) -> RadrootsIdentityPublic { - RadrootsIdentity::from_secret_key_str(identity.secret_key_hex) - .expect("identity") - .to_public() +fn approved_public_identity(identity: ApprovedFixtureIdentity) -> PublicIdentity { + let public_key = approved_public_key(identity); + PublicIdentity::new( + IdentityPublicKey::from_hex(&public_key.to_hex()).expect("identity public key"), + ) } fn approved_public_key(identity: ApprovedFixtureIdentity) -> PublicKey { @@ -20,7 +21,7 @@ fn relay(url: &str) -> RelayUrl { RelayUrl::parse(url).expect("relay") } -pub(crate) fn fixture_alice_identity() -> RadrootsIdentityPublic { +pub(crate) fn fixture_alice_identity() -> PublicIdentity { approved_public_identity(FIXTURE_ALICE) } @@ -28,11 +29,11 @@ pub(crate) fn fixture_alice_public_key() -> PublicKey { approved_public_key(FIXTURE_ALICE) } -pub(crate) fn fixture_bob_identity() -> RadrootsIdentityPublic { +pub(crate) fn fixture_bob_identity() -> PublicIdentity { approved_public_identity(FIXTURE_BOB) } -pub(crate) fn fixture_carol_identity() -> RadrootsIdentityPublic { +pub(crate) fn fixture_carol_identity() -> PublicIdentity { approved_public_identity(FIXTURE_CAROL) } @@ -40,7 +41,7 @@ pub(crate) fn fixture_carol_public_key() -> PublicKey { approved_public_key(FIXTURE_CAROL) } -pub(crate) fn fixture_diego_identity() -> RadrootsIdentityPublic { +pub(crate) fn fixture_diego_identity() -> PublicIdentity { approved_public_identity(FIXTURE_DIEGO) } @@ -68,11 +69,11 @@ pub(crate) fn synthetic_secret_hex(index: u32) -> String { format!("{index:064x}") } -pub(crate) fn synthetic_public_identity(index: u32) -> RadrootsIdentityPublic { - let secret_hex = synthetic_secret_hex(index); - RadrootsIdentity::from_secret_key_str(secret_hex.as_str()) - .expect("identity") - .to_public() +pub(crate) fn synthetic_public_identity(index: u32) -> PublicIdentity { + let public_key = synthetic_public_key(index); + PublicIdentity::new( + IdentityPublicKey::from_hex(&public_key.to_hex()).expect("identity public key"), + ) } pub(crate) fn synthetic_public_key(index: u32) -> PublicKey { @@ -80,3 +81,9 @@ pub(crate) fn synthetic_public_key(index: u32) -> PublicKey { let secret = SecretKey::from_hex(secret_hex.as_str()).expect("secret"); Keys::new(secret).public_key() } + +pub(crate) fn synthetic_keys(index: u32) -> Keys { + let secret_hex = synthetic_secret_hex(index); + let secret = SecretKey::from_hex(secret_hex.as_str()).expect("secret"); + Keys::new(secret) +} diff --git a/crates/outbox/Cargo.toml b/crates/outbox/Cargo.toml @@ -51,6 +51,9 @@ tokio = { workspace = true, optional = true, features = ["time"] } url = { workspace = true, optional = true } [dev-dependencies] +radroots_identity = { workspace = true, default-features = false, features = [ + "std", +] } radroots_nostr = { workspace = true, default-features = false, features = [ "std", "events", diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.json b/crates/outbox/contracts/migration_authority_v1.manifest.json @@ -215,10 +215,10 @@ "source_files": [ { "file": { - "byte_length": 1856, + "byte_length": 1948, "hash_algorithm": "sha256_bytes_v1", "path": "crates/outbox/Cargo.toml", - "sha256": "f643aa30ee5d9ada9ef43bfeae7517ccacb234f237953a35175fc6b8a4b5bbaa" + "sha256": "b41ec65ece0ca32031906f10e25973714a8eb910f82ea59d0d54f3bbea70a866" }, "role": "outbox_package_manifest" }, @@ -278,10 +278,10 @@ }, { "file": { - "byte_length": 323369, + "byte_length": 323791, "hash_algorithm": "sha256_bytes_v1", "path": "crates/outbox/src/store.rs", - "sha256": "2cfd5994923edd45e990f56a5e021152016611ba70e297858e187bb840a17216" + "sha256": "db68c92cf2722b5deb06b0cf8e41c917db4887dca6a17451fdb7772ae87c0f14" }, "role": "store_integration" }, @@ -305,37 +305,37 @@ }, { "file": { - "byte_length": 510144, + "byte_length": 536956, "hash_algorithm": "sha256_bytes_v1", "path": "tools/xtask/src/contract.rs", - "sha256": "c08947646a9769e4278d8a044b3474026fd71b3a2315babccae992b2ae55c368" + "sha256": "226461c98773687d9e39c502785655ae8b9f831b79fbd8d824f709c13715690c" }, "role": "contract_dispatch" }, { "file": { - "byte_length": 24835, + "byte_length": 27184, "hash_algorithm": "sha256_bytes_v1", "path": "tools/xtask/src/main.rs", - "sha256": "0c75609e8a3612ff17ce8b99d6fafba84994865e1863535ac818d9a54678bf90" + "sha256": "83a8ac80c188802474e147444e7af890a188dd2103822ffbda607cd0b4b26044" }, "role": "xtask_dispatch" }, { "file": { - "byte_length": 22002, + "byte_length": 23243, "hash_algorithm": "sha256_bytes_v1", "path": "build/nix/common.nix", - "sha256": "6ba87f6039ee7a7e06c1179293bb46cf2b5137a6aaa4f0fecbe61e27caa2259e" + "sha256": "cb32dfd36ea53ab5ef6b004dd87f0b276b5a6f020c5b19a0af95b9d8d0650bb1" }, "role": "nix_feature_executor" }, { "file": { - "byte_length": 5378, + "byte_length": 10719, "hash_algorithm": "sha256_bytes_v1", "path": "build/nix/checks.nix", - "sha256": "ce66cbf81365342c87ba5b994d69c6b4d55e2f4d9c9f823b8b53575a8eebe758" + "sha256": "47c715898776c3a8ed8d93e13f86d9380df164fc3d4c2a7bad9bf04f57a200a0" }, "role": "nix_feature_check" }, @@ -350,10 +350,10 @@ }, { "file": { - "byte_length": 27103, + "byte_length": 27107, "hash_algorithm": "sha256_bytes_v1", "path": "contracts/releases/1.0.0-alpha.1.toml", - "sha256": "f12cc739f3253b6847556e5659c4cba061bf18265435b2af39f6961ba6bb86e5" + "sha256": "8168ed38dfce1c8e1b3a8262da0eb8088d50287fcae93f05cd1103fb02b446bf" }, "role": "release_record" }, diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.sha256 b/crates/outbox/contracts/migration_authority_v1.manifest.sha256 @@ -1 +1 @@ -76be4dc435bf2e8337bd02a53d96c99b8e537e231bf633d0d36b3cf3d5e4634a +75d40402cd325a7bfc4c1392a7fd252a4448d8c64cbdf2bf71943a6a26273297 diff --git a/crates/outbox/contracts/phase1_publication_v1.manifest.json b/crates/outbox/contracts/phase1_publication_v1.manifest.json @@ -48,9 +48,9 @@ "source_files": [ { "file": { - "byte_length": 1856, + "byte_length": 1948, "path": "crates/outbox/Cargo.toml", - "sha256": "f643aa30ee5d9ada9ef43bfeae7517ccacb234f237953a35175fc6b8a4b5bbaa" + "sha256": "b41ec65ece0ca32031906f10e25973714a8eb910f82ea59d0d54f3bbea70a866" }, "role": "outbox_package_manifest" }, @@ -64,9 +64,9 @@ }, { "file": { - "byte_length": 133831, + "byte_length": 133966, "path": "crates/outbox/src/phase1_publication.rs", - "sha256": "45eaf58cad4fcbba707ff91d12cb8f9c6c5ea4d0dfad85e6e3a00a403efe25d5" + "sha256": "025b9051785b1c293a604c2dea8a3cb6123f71750f4a25d337d828a0e97abab0" }, "role": "phase1_publication_runtime" }, @@ -112,25 +112,25 @@ }, { "file": { - "byte_length": 510144, + "byte_length": 536956, "path": "tools/xtask/src/contract.rs", - "sha256": "c08947646a9769e4278d8a044b3474026fd71b3a2315babccae992b2ae55c368" + "sha256": "226461c98773687d9e39c502785655ae8b9f831b79fbd8d824f709c13715690c" }, "role": "contract_dispatch" }, { "file": { - "byte_length": 24835, + "byte_length": 27184, "path": "tools/xtask/src/main.rs", - "sha256": "0c75609e8a3612ff17ce8b99d6fafba84994865e1863535ac818d9a54678bf90" + "sha256": "83a8ac80c188802474e147444e7af890a188dd2103822ffbda607cd0b4b26044" }, "role": "xtask_dispatch" }, { "file": { - "byte_length": 27103, + "byte_length": 27107, "path": "contracts/releases/1.0.0-alpha.1.toml", - "sha256": "f12cc739f3253b6847556e5659c4cba061bf18265435b2af39f6961ba6bb86e5" + "sha256": "8168ed38dfce1c8e1b3a8262da0eb8088d50287fcae93f05cd1103fb02b446bf" }, "role": "release_record" }, diff --git a/crates/outbox/contracts/phase1_publication_v1.manifest.sha256 b/crates/outbox/contracts/phase1_publication_v1.manifest.sha256 @@ -1 +1 @@ -2f61a5f5e13425b34d0dd43f70651d1f2f15c381b4143370f18eef65b3ec5ddb +56d8a705c3757357651c82a65fbf07a3afe0c465d53178775973488bf6327d71 diff --git a/crates/outbox/src/phase1_publication.rs b/crates/outbox/src/phase1_publication.rs @@ -1261,10 +1261,10 @@ impl RadrootsOutbox { let expected_event_id = blob32(&row, "expected_event_id")?; let stored_policy_digest = blob32(&row, "target_policy_digest")?; let artifact = ready_artifact.artifact(); + let expected_author_hex = artifact.expected_author().to_hex(); if artifact_digest != *artifact.artifact_digest().as_bytes() || readiness_digest != *ready_artifact.binding_digest().as_bytes() - || expected_author - != decode_hex32(artifact.expected_author().as_str(), "expected_author")? + || expected_author != decode_hex32(&expected_author_hex, "expected_author")? || expected_event_id != decode_hex32(artifact.expected_event_id().as_str(), "expected_event_id")? || stored_policy_digest != target_policy.digest @@ -1664,7 +1664,8 @@ impl RadrootsOutbox { let signed_event_id = decode_hex32(verified.signed_event().id_str(), "signed_event_id")?; let artifact = preflight.ready_artifact.artifact(); let artifact_json = artifact.to_canonical_json(); - let expected_author = decode_hex32(artifact.expected_author().as_str(), "expected_author")?; + let expected_author_hex = artifact.expected_author().to_hex(); + let expected_author = decode_hex32(&expected_author_hex, "expected_author")?; let expected_event_id = decode_hex32(artifact.expected_event_id().as_str(), "expected_event_id")?; let affected = sqlx::query( @@ -2312,7 +2313,8 @@ fn prepare_publication( let artifact = ready.artifact(); let artifact_digest = *artifact.artifact_digest().as_bytes(); let readiness_digest = *ready.binding_digest().as_bytes(); - let expected_author = decode_hex32(artifact.expected_author().as_str(), "expected_author")?; + let expected_author_hex = artifact.expected_author().to_hex(); + let expected_author = decode_hex32(&expected_author_hex, "expected_author")?; let expected_event_id = decode_hex32(artifact.expected_event_id().as_str(), "expected_event_id")?; let operation_digest = operation_digest_for( @@ -2339,7 +2341,7 @@ fn validate_signed_matches_artifact( ) -> Result<(), RadrootsPhase1PublicationError> { let artifact = ready.artifact(); let draft = artifact.draft(); - if signed.pubkey_str() != artifact.expected_author().as_str() + if signed.pubkey() != artifact.expected_author() || signed.id_str() != artifact.expected_event_id().as_str() || signed.created_at() != draft.created_at() || signed.kind() != draft.kind() diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -171,7 +171,7 @@ impl RadrootsOutbox { prepare_delivery_plan(input.draft.expected_event_id_str(), &input.delivery_plan)?; let operation_digest = operation_idempotency_digest( input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), &input.draft, ); @@ -179,7 +179,7 @@ impl RadrootsOutbox { && let Some(existing) = existing_idempotent_operation_for_pool( &self.pool, input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), idempotency_key, ) .await? @@ -187,7 +187,7 @@ impl RadrootsOutbox { { return Err(RadrootsOutboxError::IdempotencyConflict { operation_kind: input.operation_kind.clone(), - expected_pubkey: input.draft.expected_pubkey_str().to_owned(), + expected_pubkey: input.draft.expected_pubkey().to_hex().to_owned(), idempotency_key: idempotency_key.to_owned(), existing_digest: existing.operation_idempotency_digest, new_digest: operation_digest, @@ -215,14 +215,14 @@ impl RadrootsOutbox { prepare_delivery_plan(input.draft.expected_event_id_str(), &input.delivery_plan)?; let operation_digest = trade_mutation_operation_idempotency_digest( input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), &semantic, ); if let Some(existing) = existing_trade_mutation_operation_for_pool( &self.pool, input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), input.mutation_id.as_str(), ) .await? @@ -230,7 +230,7 @@ impl RadrootsOutbox { { return Err(RadrootsOutboxError::IdempotencyConflict { operation_kind: input.operation_kind.clone(), - expected_pubkey: input.draft.expected_pubkey_str().to_owned(), + expected_pubkey: input.draft.expected_pubkey().to_hex().to_owned(), idempotency_key: input.mutation_id.to_string(), existing_digest: existing.operation_idempotency_digest, new_digest: operation_digest, @@ -241,7 +241,7 @@ impl RadrootsOutbox { && let Some(existing) = existing_idempotent_operation_for_pool( &self.pool, input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), idempotency_key, ) .await? @@ -249,7 +249,7 @@ impl RadrootsOutbox { { return Err(RadrootsOutboxError::IdempotencyConflict { operation_kind: input.operation_kind.clone(), - expected_pubkey: input.draft.expected_pubkey_str().to_owned(), + expected_pubkey: input.draft.expected_pubkey().to_hex().to_owned(), idempotency_key: idempotency_key.to_owned(), existing_digest: existing.operation_idempotency_digest, new_digest: operation_digest, @@ -271,7 +271,7 @@ impl RadrootsOutbox { prepare_delivery_plan(input.draft.expected_event_id_str(), &input.delivery_plan)?; let operation_digest = operation_idempotency_digest( input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), &input.draft, ); let mut tx = self.pool.begin().await?; @@ -280,7 +280,7 @@ impl RadrootsOutbox { && let Some(existing) = existing_idempotent_operation( &mut tx, input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), idempotency_key, ) .await? @@ -288,7 +288,7 @@ impl RadrootsOutbox { if existing.operation_idempotency_digest != operation_digest { return Err(RadrootsOutboxError::IdempotencyConflict { operation_kind: input.operation_kind, - expected_pubkey: input.draft.expected_pubkey_str().to_owned(), + expected_pubkey: input.draft.expected_pubkey().to_hex().to_owned(), idempotency_key: idempotency_key.to_owned(), existing_digest: existing.operation_idempotency_digest, new_digest: operation_digest, @@ -321,7 +321,7 @@ impl RadrootsOutbox { "INSERT INTO outbox_operations(operation_kind, expected_pubkey, semantic_scope, trade_id, mutation_id, canonical_payload_sha256, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) VALUES (?, ?, 'generic_event', NULL, NULL, NULL, ?, ?, ?, ?, ?)", ) .bind(input.operation_kind.as_str()) - .bind(input.draft.expected_pubkey_str()) + .bind(input.draft.expected_pubkey().to_hex()) .bind(input.idempotency_key.as_deref()) .bind(operation_digest.as_str()) .bind(RadrootsOutboxOperationStatus::Queued.as_str()) @@ -336,7 +336,7 @@ impl RadrootsOutbox { ) .bind(operation_id) .bind(input.draft.expected_event_id_str()) - .bind(input.draft.expected_pubkey_str()) + .bind(input.draft.expected_pubkey().to_hex()) .bind(draft_json.as_str()) .bind(RadrootsOutboxEventState::DraftQueued.as_str()) .bind(input.created_at_ms) @@ -370,7 +370,7 @@ impl RadrootsOutbox { prepare_delivery_plan(input.draft.expected_event_id_str(), &input.delivery_plan)?; let operation_digest = operation_idempotency_digest( input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), &input.draft, ); let mut tx = self.pool.begin().await?; @@ -379,7 +379,7 @@ impl RadrootsOutbox { && let Some(existing) = existing_idempotent_operation( &mut tx, input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), idempotency_key, ) .await? @@ -387,7 +387,7 @@ impl RadrootsOutbox { if existing.operation_idempotency_digest != operation_digest { return Err(RadrootsOutboxError::IdempotencyConflict { operation_kind: input.operation_kind, - expected_pubkey: input.draft.expected_pubkey_str().to_owned(), + expected_pubkey: input.draft.expected_pubkey().to_hex().to_owned(), idempotency_key: idempotency_key.to_owned(), existing_digest: existing.operation_idempotency_digest, new_digest: operation_digest, @@ -426,7 +426,7 @@ impl RadrootsOutbox { "INSERT INTO outbox_operations(operation_kind, expected_pubkey, semantic_scope, trade_id, mutation_id, canonical_payload_sha256, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) VALUES (?, ?, 'generic_event', NULL, NULL, NULL, ?, ?, ?, ?, ?)", ) .bind(input.operation_kind.as_str()) - .bind(input.draft.expected_pubkey_str()) + .bind(input.draft.expected_pubkey().to_hex()) .bind(input.idempotency_key.as_deref()) .bind(operation_digest.as_str()) .bind(RadrootsOutboxOperationStatus::Queued.as_str()) @@ -442,7 +442,7 @@ impl RadrootsOutbox { ) .bind(operation_id) .bind(input.draft.expected_event_id_str()) - .bind(input.draft.expected_pubkey_str()) + .bind(input.draft.expected_pubkey().to_hex()) .bind(draft_json.as_str()) .bind(signed_event_json.as_str()) .bind(input.signed_event.signed_event().raw_json()) @@ -485,7 +485,7 @@ impl RadrootsOutbox { prepare_delivery_plan(input.draft.expected_event_id_str(), &input.delivery_plan)?; let operation_digest = trade_mutation_operation_idempotency_digest( input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), &semantic, ); let mut tx = self.pool.begin().await?; @@ -493,7 +493,7 @@ impl RadrootsOutbox { if let Some(existing) = existing_trade_mutation_operation( &mut tx, input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), input.mutation_id.as_str(), ) .await? @@ -501,7 +501,7 @@ impl RadrootsOutbox { if existing.operation_idempotency_digest != operation_digest { return Err(RadrootsOutboxError::IdempotencyConflict { operation_kind: input.operation_kind, - expected_pubkey: input.draft.expected_pubkey_str().to_owned(), + expected_pubkey: input.draft.expected_pubkey().to_hex().to_owned(), idempotency_key: input.mutation_id.to_string(), existing_digest: existing.operation_idempotency_digest, new_digest: operation_digest, @@ -534,7 +534,7 @@ impl RadrootsOutbox { && let Some(existing) = existing_idempotent_operation( &mut tx, input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), idempotency_key, ) .await? @@ -542,7 +542,7 @@ impl RadrootsOutbox { if existing.operation_idempotency_digest != operation_digest { return Err(RadrootsOutboxError::IdempotencyConflict { operation_kind: input.operation_kind, - expected_pubkey: input.draft.expected_pubkey_str().to_owned(), + expected_pubkey: input.draft.expected_pubkey().to_hex().to_owned(), idempotency_key: idempotency_key.to_owned(), existing_digest: existing.operation_idempotency_digest, new_digest: operation_digest, @@ -575,7 +575,7 @@ impl RadrootsOutbox { "INSERT INTO outbox_operations(operation_kind, expected_pubkey, semantic_scope, trade_id, mutation_id, canonical_payload_sha256, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) VALUES (?, ?, 'trade_mutation', ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(input.operation_kind.as_str()) - .bind(input.draft.expected_pubkey_str()) + .bind(input.draft.expected_pubkey().to_hex()) .bind(input.trade_id.as_str()) .bind(input.mutation_id.as_str()) .bind(input.canonical_payload_sha256.as_str()) @@ -593,7 +593,7 @@ impl RadrootsOutbox { ) .bind(operation_id) .bind(input.draft.expected_event_id_str()) - .bind(input.draft.expected_pubkey_str()) + .bind(input.draft.expected_pubkey().to_hex()) .bind(draft_json.as_str()) .bind(RadrootsOutboxEventState::DraftQueued.as_str()) .bind(input.created_at_ms) @@ -632,7 +632,7 @@ impl RadrootsOutbox { prepare_delivery_plan(input.draft.expected_event_id_str(), &input.delivery_plan)?; let operation_digest = trade_mutation_operation_idempotency_digest( input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), &semantic, ); let mut tx = self.pool.begin().await?; @@ -640,7 +640,7 @@ impl RadrootsOutbox { if let Some(existing) = existing_trade_mutation_operation( &mut tx, input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), input.mutation_id.as_str(), ) .await? @@ -648,7 +648,7 @@ impl RadrootsOutbox { if existing.operation_idempotency_digest != operation_digest { return Err(RadrootsOutboxError::IdempotencyConflict { operation_kind: input.operation_kind, - expected_pubkey: input.draft.expected_pubkey_str().to_owned(), + expected_pubkey: input.draft.expected_pubkey().to_hex().to_owned(), idempotency_key: input.mutation_id.to_string(), existing_digest: existing.operation_idempotency_digest, new_digest: operation_digest, @@ -687,7 +687,7 @@ impl RadrootsOutbox { && let Some(existing) = existing_idempotent_operation( &mut tx, input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), idempotency_key, ) .await? @@ -695,7 +695,7 @@ impl RadrootsOutbox { if existing.operation_idempotency_digest != operation_digest { return Err(RadrootsOutboxError::IdempotencyConflict { operation_kind: input.operation_kind, - expected_pubkey: input.draft.expected_pubkey_str().to_owned(), + expected_pubkey: input.draft.expected_pubkey().to_hex().to_owned(), idempotency_key: idempotency_key.to_owned(), existing_digest: existing.operation_idempotency_digest, new_digest: operation_digest, @@ -734,7 +734,7 @@ impl RadrootsOutbox { "INSERT INTO outbox_operations(operation_kind, expected_pubkey, semantic_scope, trade_id, mutation_id, canonical_payload_sha256, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) VALUES (?, ?, 'trade_mutation', ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(input.operation_kind.as_str()) - .bind(input.draft.expected_pubkey_str()) + .bind(input.draft.expected_pubkey().to_hex()) .bind(input.trade_id.as_str()) .bind(input.mutation_id.as_str()) .bind(input.canonical_payload_sha256.as_str()) @@ -753,7 +753,7 @@ impl RadrootsOutbox { ) .bind(operation_id) .bind(input.draft.expected_event_id_str()) - .bind(input.draft.expected_pubkey_str()) + .bind(input.draft.expected_pubkey().to_hex()) .bind(draft_json.as_str()) .bind(signed_event_json.as_str()) .bind(input.signed_event.signed_event().raw_json()) @@ -793,7 +793,7 @@ impl RadrootsOutbox { prepare_delivery_plan(input.draft.expected_event_id_str(), &input.delivery_plan)?; let operation_digest = operation_idempotency_digest( input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), &input.draft, ); @@ -801,7 +801,7 @@ impl RadrootsOutbox { && let Some(existing) = existing_idempotent_operation( tx, input.operation_kind.as_str(), - input.draft.expected_pubkey_str(), + &input.draft.expected_pubkey().to_hex(), idempotency_key, ) .await? @@ -809,7 +809,7 @@ impl RadrootsOutbox { if existing.operation_idempotency_digest != operation_digest { return Err(RadrootsOutboxError::IdempotencyConflict { operation_kind: input.operation_kind, - expected_pubkey: input.draft.expected_pubkey_str().to_owned(), + expected_pubkey: input.draft.expected_pubkey().to_hex().to_owned(), idempotency_key: idempotency_key.to_owned(), existing_digest: existing.operation_idempotency_digest, new_digest: operation_digest, @@ -846,7 +846,7 @@ impl RadrootsOutbox { "INSERT INTO outbox_operations(operation_kind, expected_pubkey, semantic_scope, trade_id, mutation_id, canonical_payload_sha256, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) VALUES (?, ?, 'generic_event', NULL, NULL, NULL, ?, ?, ?, ?, ?)", ) .bind(input.operation_kind.as_str()) - .bind(input.draft.expected_pubkey_str()) + .bind(input.draft.expected_pubkey().to_hex()) .bind(input.idempotency_key.as_deref()) .bind(operation_digest.as_str()) .bind(RadrootsOutboxOperationStatus::Queued.as_str()) @@ -862,7 +862,7 @@ impl RadrootsOutbox { ) .bind(operation_id) .bind(input.draft.expected_event_id_str()) - .bind(input.draft.expected_pubkey_str()) + .bind(input.draft.expected_pubkey().to_hex()) .bind(draft_json.as_str()) .bind(signed_event_json.as_str()) .bind(input.signed_event.signed_event().raw_json()) @@ -3096,7 +3096,7 @@ fn validate_trade_mutation_input( } let parsed = trade_mutation_from_canonical_content(draft.content()) .map_err(|_| RadrootsOutboxError::TradeMutationMetadataMismatch { field: "content" })?; - if parsed.author_pubkey.as_str() != draft.expected_pubkey_str() { + if parsed.author_pubkey != *draft.expected_pubkey() { return Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "author_pubkey", }); @@ -3328,7 +3328,7 @@ mod tests { use super::*; use radroots_event::ids::{ RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, - RadrootsPublicKey, RadrootsTradeId, + RadrootsTradeId, }; use radroots_event::kinds::{ KIND_CLASSIFIED_LISTING, KIND_FOLLOW, KIND_GEOCHAT, KIND_HTTP_AUTH, KIND_RELAY_AUTH, @@ -3341,6 +3341,7 @@ mod tests { RadrootsTradeEconomicsProfileV1, RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1, canonical_jcs_value, canonical_trade_mutation_content, }; + use radroots_identity::PublicKey; use radroots_nostr::prelude::{ RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, }; @@ -3349,6 +3350,8 @@ mod tests { "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + const FIXTURE_BOB_PUBLIC_KEY_HEX: &str = + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; const NOSTR_PRIMARY_WSS: &str = "wss://relay.example.com"; const NOSTR_SECONDARY_WSS: &str = "wss://relay-2.example.com"; @@ -3360,11 +3363,13 @@ mod tests { std::iter::repeat_n(character, 32).collect() } - fn public_key(character: char) -> RadrootsPublicKey { - if character == 'a' { - return RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("alice pubkey"); - } - RadrootsPublicKey::parse(hex_64(character)).expect("pubkey") + fn public_key(character: char) -> PublicKey { + let public_key_hex = match character { + 'a' => FIXTURE_ALICE_PUBLIC_KEY_HEX, + 'b' => FIXTURE_BOB_PUBLIC_KEY_HEX, + _ => panic!("unsupported fixture public key label: {character}"), + }; + PublicKey::from_hex(public_key_hex).expect("fixture pubkey") } fn generic_draft(expected_pubkey: &str, content: &str) -> RadrootsEventDraft { @@ -3500,7 +3505,7 @@ mod tests { canonical.envelope.authored_at_unix_s, tags, canonical.content.clone(), - canonical.envelope.author_pubkey.as_str(), + canonical.envelope.author_pubkey.to_hex(), ) .expect("trade mutation draft") } @@ -3895,7 +3900,7 @@ mod tests { )); let author_mismatch = - proposal_draft_with_content(canonical.content.clone(), hex_64('b').as_str()); + proposal_draft_with_content(canonical.content.clone(), FIXTURE_BOB_PUBLIC_KEY_HEX); assert!(matches!( validate_trade_mutation_input( canonical.envelope.trade_id.as_str(), diff --git a/crates/protected_store/Cargo.toml b/crates/protected_store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_protected_store" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "radroots_protocol" +publish = false +version = "0.1.0-alpha" +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Versioned wire contracts for Radroots" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots_protocol" +readme = "README.md" + +[lib] +name = "radroots_protocol" + +[features] +default = ["std", "serde"] +serde = ["dep:serde"] +std = ["serde?/std"] + +[dependencies] +serde = { workspace = true, default-features = false, features = [ + "alloc", + "derive", +], optional = true } + +[dev-dependencies] +serde_json = { workspace = true, features = ["std"] } + +[lints] +workspace = true diff --git a/crates/protocol/README.md b/crates/protocol/README.md @@ -0,0 +1,87 @@ +# radroots_protocol + +`radroots_protocol` defines the passive, versioned wire contracts shared by +Radroots processes and language bindings. It is a `no_std + alloc` foundation +crate: it validates schema identities and contract catalogs, but performs no +network, storage, signing, scheduling, or process-lifecycle work. + +The crate is pre-release and its Cargo version is frozen at `0.1.0-alpha` until +explicitly changed. Cargo package versions are independent from the version +numbers embedded in wire and schema contracts. + +## Contract surface + +| Module | Authority | +| --- | --- | +| `capability::v1` | transport capabilities, maturity, and availability | +| `error::v1` | stable, redaction-safe error reports and recovery guidance | +| `event::v1` | event-kind catalogs and the serialized trade-state vocabulary | +| `runtime::v1` | operation descriptors, risk, approval, idempotency, and effects | +| `radrootsd::transport_publish::v5` | daemon transport-publish request, job, outcome, and capability DTOs | +| `schema` | schema IDs, module generations, descriptors, and aggregate registries | + +All serialized DTOs live below an explicit generation module. The crate root +does not reexport individual DTOs, so consumers must name the contract version +they use. + +## Features + +| Feature | Default | Effect | +| --- | --- | --- | +| `std` | yes | implements standard-library error integration and enables `serde?/std` | +| `serde` | yes | derives serialization and deserialization for wire-contract types | + +Disabling default features leaves the catalog and structural validation APIs +available with `alloc` only. Enable `serde` without `std` for portable encoded +DTOs. + +## Validate the aggregate contract + +```rust +use radroots_protocol::{capability, event, runtime, schema}; + +capability::v1::validate_catalog(capability::v1::CATALOG).unwrap(); +event::v1::validate_catalog(event::v1::CATALOG).unwrap(); +event::v1::validate_trade_state_vocabulary(event::v1::TRADE_STATE_VOCABULARY) + .unwrap(); +runtime::v1::validate_catalog(runtime::v1::CATALOG).unwrap(); + +let registry = schema::protocol_v1_registry().unwrap(); +assert!(!registry.is_empty()); +``` + +For a runnable version, see +[`examples/inspect_contract.rs`](examples/inspect_contract.rs). + +## Serialization and trust boundaries + +Wire representations are stable only within their named generation. A caller +must deserialize into the intended version, run the module's structural +validation, and apply its own authorization and policy checks before acting on +the data. Successful deserialization alone does not make input trusted. + +Stable error reports intentionally carry only safe messages and bounded detail +values. Use `error::v1::ErrorReport::redacted_from_source` at sensitive +boundaries; do not copy secrets, credentials, raw upstream errors, or private +payloads into protocol DTOs. + +## Side effects, cancellation, and commit points + +This crate has no side effects, asynchronous work, cancellation mechanism, or +commit point. Runtime operation descriptors describe those properties; they do +not execute operations. The owning SDK, daemon, storage, signing, or transport +implementation must define cancellation behavior and must not report a commit +until its own durable boundary has succeeded. + +## Intended consumers + +The direct consumers are Radroots domain crates, SDK and daemon runtimes, +transport/storage/signing boundaries, generated bindings, and conformance +tools. Applications should normally enter through `radroots` or +`radroots_sdk`, using this crate directly only when implementing or inspecting +a versioned boundary. + +The package charter is the +[Release V1 specification](../../docs/specs/radroots_crates_release_v1.md). +The reviewed Rust surface is recorded in the +[public API baseline](../../docs/api/radroots_protocol.txt). diff --git a/crates/protocol/examples/inspect_contract.rs b/crates/protocol/examples/inspect_contract.rs @@ -0,0 +1,12 @@ +use radroots_protocol::{capability, event, runtime, schema}; + +fn main() -> Result<(), Box<dyn std::error::Error>> { + capability::v1::validate_catalog(capability::v1::CATALOG)?; + event::v1::validate_catalog(event::v1::CATALOG)?; + event::v1::validate_trade_state_vocabulary(event::v1::TRADE_STATE_VOCABULARY)?; + runtime::v1::validate_catalog(runtime::v1::CATALOG)?; + + let registry = schema::protocol_v1_registry()?; + println!("validated {} protocol schemas", registry.len()); + Ok(()) +} diff --git a/crates/protocol/src/capability.rs b/crates/protocol/src/capability.rs @@ -0,0 +1,4 @@ +//! Versioned capability catalog contracts. + +/// Capability contracts for generation 1. +pub mod v1; diff --git a/crates/protocol/src/capability/v1.rs b/crates/protocol/src/capability/v1.rs @@ -0,0 +1,370 @@ +//! Capability catalog contract generation 1. + +use alloc::string::{String, ToString}; +use core::fmt; + +use crate::schema::{Metadata, ModuleVersion, Registry}; + +/// Stable wire identity for a supported transport family. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum TransportKind { + /// Process-local transport. + Local, + /// Nostr relay transport. + Nostr, + /// Reticulum mesh transport. + Reticulum, +} + +impl TransportKind { + /// Returns the stable serialized identity. + pub const fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::Nostr => "nostr", + Self::Reticulum => "reticulum", + } + } + + /// Parses an exact stable transport identity. + pub fn parse(value: &str) -> Result<Self, Error> { + match value { + "local" => Ok(Self::Local), + "nostr" => Ok(Self::Nostr), + "reticulum" => Ok(Self::Reticulum), + _ => Err(Error::UnknownTransportKind { + value: value.to_string(), + }), + } + } +} + +/// Product maturity of a capability. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum Maturity { + /// Supported as a preview contract. + Preview, + /// Supported as a stable contract. + Stable, +} + +/// Current availability of a capability. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum Availability { + /// Fully available. + Available, + /// Available with reduced functionality. + Degraded, + /// Not currently available. + Unavailable, +} + +/// Validated mesh-scope identifier. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct MeshScopeId { + value: String, +} + +impl MeshScopeId { + /// Parses the existing V1 mesh-scope grammar. + pub fn parse(value: impl Into<String>) -> Result<Self, Error> { + let value = value.into(); + if value.is_empty() + || value != value.trim() + || value.chars().any(|character| { + !(character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.')) + }) + { + return Err(Error::InvalidMeshScopeId); + } + Ok(Self { value }) + } + + /// Returns the validated identifier. + pub fn as_str(&self) -> &str { + self.value.as_str() + } +} + +/// Validated Reticulum destination. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ReticulumDestination { + canonical: String, +} + +impl ReticulumDestination { + /// Parses the existing V1 Reticulum destination grammar. + pub fn parse(value: impl Into<String>) -> Result<Self, Error> { + let value = value.into(); + if value.is_empty() + || value != value.trim() + || value + .chars() + .any(|character| character.is_ascii_control() || character.is_ascii_whitespace()) + { + return Err(Error::InvalidReticulumDestination); + } + Ok(Self { canonical: value }) + } + + /// Returns the canonical destination text. + pub fn as_str(&self) -> &str { + self.canonical.as_str() + } +} + +/// Passive Reticulum target DTO. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReticulumTarget { + /// Canonical destination. + pub destination: ReticulumDestination, + /// Optional mesh scope. + pub mesh_scope: Option<MeshScopeId>, +} + +/// Passive capability descriptor DTO. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TransportDescriptor { + /// Transport family. + pub kind: TransportKind, + /// Product maturity. + pub maturity: Maturity, + /// Current availability. + pub availability: Availability, + /// Whether delivery is defined. + pub can_deliver: bool, + /// Whether fetch is defined. + pub can_fetch: bool, + /// Whether discovery is defined. + pub can_discover: bool, + /// Whether gateway forwarding is defined. + pub can_gateway_forward: bool, + /// Whether delivery receipts are observable. + pub can_observe_receipts: bool, + /// Whether Release V1 requires the transport contract. + pub required_for_v1: bool, +} + +/// Exact Release V1 transport capability catalog. +pub const CATALOG: &[TransportDescriptor] = &[ + TransportDescriptor { + kind: TransportKind::Local, + maturity: Maturity::Stable, + availability: Availability::Available, + can_deliver: true, + can_fetch: true, + can_discover: false, + can_gateway_forward: false, + can_observe_receipts: true, + required_for_v1: true, + }, + TransportDescriptor { + kind: TransportKind::Nostr, + maturity: Maturity::Stable, + availability: Availability::Available, + can_deliver: true, + can_fetch: true, + can_discover: true, + can_gateway_forward: false, + can_observe_receipts: true, + required_for_v1: true, + }, + TransportDescriptor { + kind: TransportKind::Reticulum, + maturity: Maturity::Preview, + availability: Availability::Unavailable, + can_deliver: true, + can_fetch: false, + can_discover: true, + can_gateway_forward: true, + can_observe_receipts: true, + required_for_v1: true, + }, +]; + +/// Exact schema identities retained from the predecessor package. +pub const SCHEMAS: &[Metadata] = &[ + Metadata { + type_name: "TransportKindV1", + schema_id: "radroots.protocol.transport_kind.v1", + schema_version: 1, + }, + Metadata { + type_name: "TransportCapabilityDescriptorV1", + schema_id: "radroots.protocol.transport_capability_descriptor.v1", + schema_version: 1, + }, + Metadata { + type_name: "ReticulumTargetV1", + schema_id: "radroots.protocol.reticulum_target.v1", + schema_version: 1, + }, +]; + +/// Validates catalog uniqueness and required V1 membership. +pub fn validate_catalog(descriptors: &[TransportDescriptor]) -> Result<(), Error> { + let mut seen = [false; 3]; + for descriptor in descriptors { + let index = match descriptor.kind { + TransportKind::Local => 0, + TransportKind::Nostr => 1, + TransportKind::Reticulum => 2, + }; + if seen[index] { + return Err(Error::DuplicateTransportKind { + kind: descriptor.kind, + }); + } + seen[index] = true; + } + + for (index, kind) in [ + TransportKind::Local, + TransportKind::Nostr, + TransportKind::Reticulum, + ] + .into_iter() + .enumerate() + { + if !seen[index] { + return Err(Error::MissingRequiredTransport { kind }); + } + } + Ok(()) +} + +/// Builds the validated capability schema registry. +pub fn schema_registry() -> Result<Registry, crate::schema::Error> { + Registry::try_from_metadata( + SCHEMAS + .iter() + .copied() + .map(|metadata| (metadata, ModuleVersion::CapabilityV1)), + ) +} + +/// Capability V1 validation failure. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Error { + /// The transport identity is unknown. + UnknownTransportKind { + /// Rejected transport identity. + value: String, + }, + /// A mesh-scope identifier is malformed. + InvalidMeshScopeId, + /// A Reticulum destination is malformed. + InvalidReticulumDestination, + /// A transport appears more than once in a catalog. + DuplicateTransportKind { + /// Duplicated transport family. + kind: TransportKind, + }, + /// A required V1 transport is absent. + MissingRequiredTransport { + /// Missing transport family. + kind: TransportKind, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownTransportKind { value } => { + write!(formatter, "unknown transport kind {value}") + } + Self::InvalidMeshScopeId => formatter.write_str("invalid mesh scope id"), + Self::InvalidReticulumDestination => { + formatter.write_str("invalid Reticulum destination") + } + Self::DuplicateTransportKind { kind } => { + write!(formatter, "duplicate transport kind {}", kind.as_str()) + } + Self::MissingRequiredTransport { kind } => { + write!(formatter, "missing required transport {}", kind.as_str()) + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_and_schema_registry_validate() { + validate_catalog(CATALOG).expect("catalog"); + let registry = schema_registry().expect("schema registry"); + assert_eq!(registry.len(), SCHEMAS.len()); + assert!( + registry + .descriptors() + .iter() + .all(|descriptor| descriptor.module() == ModuleVersion::CapabilityV1) + ); + } + + #[test] + fn parsers_preserve_v1_acceptance_and_diagnostics() { + for (value, expected) in [ + ("local", TransportKind::Local), + ("nostr", TransportKind::Nostr), + ("reticulum", TransportKind::Reticulum), + ] { + assert_eq!(TransportKind::parse(value), Ok(expected)); + assert_eq!(expected.as_str(), value); + } + assert_eq!( + TransportKind::parse("mesh") + .expect_err("unknown") + .to_string(), + "unknown transport kind mesh" + ); + assert_eq!( + MeshScopeId::parse("local/scope") + .expect_err("invalid scope") + .to_string(), + "invalid mesh scope id" + ); + assert_eq!( + ReticulumDestination::parse("reticulum:\nlocal") + .expect_err("invalid destination") + .to_string(), + "invalid Reticulum destination" + ); + } + + #[test] + fn catalog_rejects_duplicates_and_missing_required_transports() { + assert_eq!( + validate_catalog(&[CATALOG[0], CATALOG[0]]), + Err(Error::DuplicateTransportKind { + kind: TransportKind::Local, + }) + ); + assert_eq!( + validate_catalog(&[CATALOG[1], CATALOG[2]]), + Err(Error::MissingRequiredTransport { + kind: TransportKind::Local, + }) + ); + } +} diff --git a/crates/protocol/src/error.rs b/crates/protocol/src/error.rs @@ -0,0 +1,4 @@ +//! Versioned stable error-report contracts. + +/// Error-report contracts for generation 1. +pub mod v1; diff --git a/crates/protocol/src/error/v1.rs b/crates/protocol/src/error/v1.rs @@ -0,0 +1,885 @@ +//! Stable serialized error-report contract generation 1. +//! +//! Native crate errors preserve their source chains in their owning packages. +//! This module accepts only validated, secret-safe data at the serialization +//! boundary and cannot contain a native source error. + +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; +use core::fmt; + +use crate::{ + runtime::v1::OperationId, + schema::{Metadata, ModuleVersion, Registry}, +}; + +/// Error-report schema generation. +pub const SCHEMA_VERSION: u16 = 1; +/// Stable error-report schema identity. +pub const SCHEMA_ID: &str = "radroots.protocol.error_report.v1"; +/// Replacement used when a native source message is not explicitly safe. +pub const REDACTED_MESSAGE: &str = "[redacted]"; +const MAX_CODE_BYTES: usize = 96; +const MAX_CAPABILITY_ID_BYTES: usize = 128; +const MAX_SAFE_MESSAGE_BYTES: usize = 256; +const MAX_DETAIL_ENTRIES: usize = 32; +const MAX_DETAIL_TEXT_BYTES: usize = 128; + +/// Stable error class. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum Class { + Validation, + Contract, + Storage, + Resource, + Conflict, + Operation, + Authorization, + Signer, + Network, + Sync, + Runtime, + Projection, + Query, + Capability, + Privacy, + Security, + Maintenance, + Internal, + Unknown, +} + +/// Stable recovery action vocabulary established by the SDK surface. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum RecoveryAction { + InspectLocalStores, + InspectGeoNamesAsset, + RetryOperationWithSameIdempotencyKey, + ConfigureTransportTargets, + ConfigureGeoNamesCache, + ConfigureSigner, + FixRequest, + SelectAuthorizedActor, + CompleteSignerAuthentication, + RetryAfterTransportFailure, + RetryGeoNamesDownload, + EnableRequiredFeature, +} + +/// One generated catalog descriptor. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Descriptor { + pub code: KnownCode, + pub class: Class, + pub retryable: bool, + pub recovery_actions: &'static [RecoveryAction], +} + +macro_rules! error_catalog { + ($( $variant:ident => ($value:literal, $class:ident, $retryable:literal, [$($action:ident),* $(,)?]) ),+ $(,)?) => { + /// A code known to this protocol generation. + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] + pub enum KnownCode { + $( $variant, )+ + } + + impl KnownCode { + /// Every code defined by this generation. + pub const ALL: &'static [Self] = &[$(Self::$variant),+]; + + /// Returns the stable wire identity. + pub const fn as_str(self) -> &'static str { + match self { + $(Self::$variant => $value,)+ + } + } + + /// Parses a code known to this generation. + pub fn parse(value: &str) -> Option<Self> { + match value { + $($value => Some(Self::$variant),)+ + _ => None, + } + } + + /// Returns the generated descriptor from the same authority. + pub const fn descriptor(self) -> Descriptor { + match self { + $(Self::$variant => Descriptor { + code: Self::$variant, + class: Class::$class, + retryable: $retryable, + recovery_actions: &[$(RecoveryAction::$action),*], + },)+ + } + } + } + + /// Complete generated error catalog. + pub const CATALOG: &[Descriptor] = &[ + $(Descriptor { + code: KnownCode::$variant, + class: Class::$class, + retryable: $retryable, + recovery_actions: &[$(RecoveryAction::$action),*], + },)+ + ]; + }; +} + +error_catalog! { + InvalidArgument => ("invalid_argument", Validation, false, [FixRequest]), + UnsupportedContractVersion => ("unsupported_contract_version", Contract, false, [EnableRequiredFeature]), + UnsupportedProfileSchema => ("unsupported_profile_schema", Storage, false, [InspectLocalStores]), + SchemaTooNew => ("schema_too_new", Storage, false, [EnableRequiredFeature]), + NotFound => ("not_found", Resource, false, [FixRequest]), + AmbiguousTrade => ("ambiguous_trade", Conflict, false, [FixRequest]), + StaleListingRevision => ("stale_listing_revision", Conflict, false, [FixRequest]), + PreconditionChanged => ("precondition_changed", Conflict, true, [RetryOperationWithSameIdempotencyKey]), + RevisionRequired => ("revision_required", Conflict, false, [FixRequest]), + InventoryUnavailable => ("inventory_unavailable", Conflict, false, [RetryOperationWithSameIdempotencyKey]), + IdempotencyConflict => ("idempotency_conflict", Conflict, false, [RetryOperationWithSameIdempotencyKey]), + OperationInProgress => ("operation_in_progress", Operation, true, [RetryOperationWithSameIdempotencyKey]), + ApprovalRequired => ("approval_required", Authorization, false, [SelectAuthorizedActor]), + ApprovalInvalid => ("approval_invalid", Authorization, false, [SelectAuthorizedActor]), + ApprovalExpired => ("approval_expired", Authorization, false, [SelectAuthorizedActor]), + ApprovalReplayed => ("approval_replayed", Authorization, false, [SelectAuthorizedActor]), + AuthorizationDenied => ("authorization_denied", Authorization, false, [SelectAuthorizedActor]), + SignerCapabilityMissing => ("signer_capability_missing", Signer, false, [ConfigureSigner]), + SignerUnavailable => ("signer_unavailable", Signer, true, [ConfigureSigner]), + SignerRejected => ("signer_rejected", Signer, false, [SelectAuthorizedActor]), + SignerTimeout => ("signer_timeout", Signer, true, [RetryAfterTransportFailure]), + SignerCancelled => ("signer_cancelled", Signer, false, [ConfigureSigner]), + RelayAuthRequired => ("relay_auth_required", Network, true, [CompleteSignerAuthentication]), + RelayAuthRejected => ("relay_auth_rejected", Network, false, [CompleteSignerAuthentication]), + RelayPaymentRequired => ("relay_payment_required", Network, false, [ConfigureTransportTargets]), + RelayPolicyRestricted => ("relay_policy_restricted", Network, false, [ConfigureTransportTargets]), + RelayRateLimited => ("relay_rate_limited", Network, true, [RetryAfterTransportFailure]), + RelayPowRequired => ("relay_pow_required", Network, false, [ConfigureTransportTargets]), + TransportPartial => ("transport_partial", Network, true, [RetryAfterTransportFailure]), + TransportOperationUnavailable => ("transport_operation_unavailable", Capability, false, [ConfigureTransportTargets]), + SyncSaturated => ("sync_saturated", Sync, true, [RetryAfterTransportFailure]), + SyncPartial => ("sync_partial", Sync, true, [RetryAfterTransportFailure]), + DeadlineExceeded => ("deadline_exceeded", Runtime, true, [RetryAfterTransportFailure]), + CancelledNoCommit => ("cancelled_no_commit", Runtime, false, [RetryOperationWithSameIdempotencyKey]), + LocalCommittedDeliveryPending => ("local_committed_delivery_pending", Operation, true, [RetryAfterTransportFailure]), + DatabaseBusy => ("database_busy", Storage, true, [InspectLocalStores]), + ProfileWriterInUse => ("profile_writer_in_use", Storage, true, [InspectLocalStores]), + MaintenanceInProgress => ("maintenance_in_progress", Storage, true, [RetryOperationWithSameIdempotencyKey]), + StorageIntegrityFailed => ("storage_integrity_failed", Storage, false, [InspectLocalStores]), + StorageSpaceInsufficient => ("storage_space_insufficient", Storage, true, [InspectLocalStores]), + ProjectionStale => ("projection_stale", Projection, true, [InspectLocalStores]), + ProjectionFailed => ("projection_failed", Projection, true, [InspectLocalStores]), + ProjectionGenerationChanged => ("projection_generation_changed", Projection, true, [InspectLocalStores]), + InvalidCursor => ("invalid_cursor", Query, false, [FixRequest]), + UnsupportedCapability => ("unsupported_capability", Capability, false, [EnableRequiredFeature]), + DmRelayUnconfigured => ("dm_relay_unconfigured", Privacy, false, [ConfigureTransportTargets]), + PrivateDataUnavailable => ("private_data_unavailable", Privacy, false, [FixRequest]), + ValidationPending => ("validation_pending", Validation, true, [RetryOperationWithSameIdempotencyKey]), + ValidationExpired => ("validation_expired", Validation, false, [FixRequest]), + ValidatorSetInvalid => ("validator_set_invalid", Validation, false, [FixRequest]), + MediaPolicyDenied => ("media_policy_denied", Security, false, [FixRequest]), + BackupInvalid => ("backup_invalid", Maintenance, false, [InspectLocalStores]), + BackupAuthenticationFailed => ("backup_authentication_failed", Maintenance, false, [InspectLocalStores]), + RestoreFailed => ("restore_failed", Maintenance, true, [InspectLocalStores]), + Backpressure => ("backpressure", Runtime, true, [RetryAfterTransportFailure]), + InternalError => ("internal_error", Internal, false, [InspectLocalStores]), +} + +/// A stable code that preserves unknown future values. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct Code(String); + +impl Code { + /// Creates a code from a known catalog identity. + pub fn known(code: KnownCode) -> Self { + Self(code.as_str().to_string()) + } + + /// Parses a canonical code while preserving unknown future values. + pub fn parse(value: impl Into<String>) -> Result<Self, Error> { + let value = value.into(); + if !valid_identifier(value.as_str(), MAX_CODE_BYTES) { + return Err(Error::InvalidCode); + } + Ok(Self(value)) + } + + /// Returns the exact serialized identity. + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Resolves this identity against the current generated catalog. + pub fn known_code(&self) -> Option<KnownCode> { + KnownCode::parse(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for Code { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Code { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = <String as serde::Deserialize>::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +/// Validated optional capability identity. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct CapabilityId(String); + +impl CapabilityId { + /// Parses a canonical public capability identity. + pub fn parse(value: impl Into<String>) -> Result<Self, Error> { + let value = value.into(); + if !valid_identifier(value.as_str(), MAX_CAPABILITY_ID_BYTES) { + return Err(Error::InvalidCapabilityId); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for CapabilityId { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for CapabilityId { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = <String as serde::Deserialize>::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +/// Validated secret-safe human-readable message. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SafeMessage(String); + +impl SafeMessage { + /// Validates explicitly safe application-authored text. + pub fn parse(value: impl Into<String>) -> Result<Self, Error> { + let value = value.into(); + if value.is_empty() + || value.len() > MAX_SAFE_MESSAGE_BYTES + || value.chars().any(char::is_control) + { + return Err(Error::InvalidSafeMessage); + } + if contains_sensitive_material(value.as_str()) { + return Err(Error::SensitiveMessage); + } + Ok(Self(value)) + } + + /// Returns the mandatory redacted source-message replacement. + pub fn redacted() -> Self { + Self(REDACTED_MESSAGE.to_string()) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for SafeMessage { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for SafeMessage { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = <String as serde::Deserialize>::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +/// Stable scalar value allowed in safe structured details. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde(tag = "kind", content = "value", rename_all = "snake_case") +)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DetailValue { + Text(String), + Bool(bool), + Signed(i64), + Unsigned(u64), +} + +/// One key/value detail entry. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Detail { + pub key: String, + pub value: DetailValue, +} + +impl Detail { + /// Creates a detail. Collection validation applies the allowlist. + pub fn new(key: impl Into<String>, value: DetailValue) -> Self { + Self { + key: key.into(), + value, + } + } +} + +/// Deterministically ordered, validated safe detail collection. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde(try_from = "Vec<Detail>", into = "Vec<Detail>") +)] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SafeDetails { + entries: Vec<Detail>, +} + +impl SafeDetails { + /// Validates, sorts, and stores safe details. + pub fn try_new(entries: impl IntoIterator<Item = Detail>) -> Result<Self, Error> { + let mut entries: Vec<_> = entries.into_iter().collect(); + if entries.len() > MAX_DETAIL_ENTRIES { + return Err(Error::TooManyDetails); + } + entries.sort_by(|left, right| left.key.cmp(&right.key)); + for (index, entry) in entries.iter().enumerate() { + validate_detail(entry)?; + if index > 0 && entries[index - 1].key == entry.key { + return Err(Error::DuplicateDetailKey); + } + } + Ok(Self { entries }) + } + + pub fn entries(&self) -> &[Detail] { + self.entries.as_slice() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +impl TryFrom<Vec<Detail>> for SafeDetails { + type Error = Error; + + fn try_from(entries: Vec<Detail>) -> Result<Self, Self::Error> { + Self::try_new(entries) + } +} + +impl From<SafeDetails> for Vec<Detail> { + fn from(details: SafeDetails) -> Self { + details.entries + } +} + +/// Secret-safe serialized error boundary. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ErrorReport { + schema_version: u16, + code: Code, + class: Class, + retryable: bool, + recovery_actions: Vec<RecoveryAction>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + operation_id: Option<OperationId>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + capability_id: Option<CapabilityId>, + message: SafeMessage, + #[cfg_attr(feature = "serde", serde(default))] + details: SafeDetails, +} + +impl ErrorReport { + /// Builds a report for a known code from its generated descriptor. + pub fn known( + code: KnownCode, + operation_id: Option<OperationId>, + capability_id: Option<CapabilityId>, + message: SafeMessage, + details: SafeDetails, + ) -> Self { + let descriptor = code.descriptor(); + Self { + schema_version: SCHEMA_VERSION, + code: Code::known(code), + class: descriptor.class, + retryable: descriptor.retryable, + recovery_actions: descriptor.recovery_actions.to_vec(), + operation_id, + capability_id, + message, + details, + } + } + + /// Converts an untrusted native source failure without copying its message. + pub fn redacted_from_source( + code: KnownCode, + operation_id: Option<OperationId>, + capability_id: Option<CapabilityId>, + ) -> Self { + Self::known( + code, + operation_id, + capability_id, + SafeMessage::redacted(), + SafeDetails::default(), + ) + } + + /// Builds the fail-closed representation of an unknown future code. + pub fn unknown(code: Code) -> Result<Self, Error> { + if code.known_code().is_some() { + return Err(Error::ExpectedUnknownCode); + } + Ok(Self { + schema_version: SCHEMA_VERSION, + code, + class: Class::Unknown, + retryable: false, + recovery_actions: Vec::new(), + operation_id: None, + capability_id: None, + message: SafeMessage::redacted(), + details: SafeDetails::default(), + }) + } + + /// Validates version, catalog agreement, and unknown-code policy. + pub fn validate(&self) -> Result<(), Error> { + if self.schema_version != SCHEMA_VERSION { + return Err(Error::UnsupportedSchemaVersion { + version: self.schema_version, + }); + } + if let Some(known) = self.code.known_code() { + let descriptor = known.descriptor(); + if self.class != descriptor.class + || self.retryable != descriptor.retryable + || self.recovery_actions.as_slice() != descriptor.recovery_actions + { + return Err(Error::DescriptorMismatch { code: known }); + } + } else if self.class != Class::Unknown + || self.retryable + || !self.recovery_actions.is_empty() + || self.operation_id.is_some() + || self.capability_id.is_some() + || self.message.as_str() != REDACTED_MESSAGE + || !self.details.is_empty() + { + return Err(Error::InvalidUnknownCodePolicy); + } + SafeDetails::try_new(self.details.entries.clone())?; + Ok(()) + } + + pub const fn schema_version(&self) -> u16 { + self.schema_version + } + + pub fn code(&self) -> &Code { + &self.code + } + + pub const fn class(&self) -> Class { + self.class + } + + pub const fn retryable(&self) -> bool { + self.retryable + } + + pub fn recovery_actions(&self) -> &[RecoveryAction] { + self.recovery_actions.as_slice() + } + + pub const fn operation_id(&self) -> Option<OperationId> { + self.operation_id + } + + pub fn capability_id(&self) -> Option<&CapabilityId> { + self.capability_id.as_ref() + } + + pub fn message(&self) -> &SafeMessage { + &self.message + } + + pub fn details(&self) -> &SafeDetails { + &self.details + } +} + +#[cfg(feature = "serde")] +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct WireReport { + schema_version: u16, + code: Code, + class: Class, + retryable: bool, + recovery_actions: Vec<RecoveryAction>, + #[serde(default)] + operation_id: Option<OperationId>, + #[serde(default)] + capability_id: Option<CapabilityId>, + message: SafeMessage, + #[serde(default)] + details: SafeDetails, +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for ErrorReport { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let wire = WireReport::deserialize(deserializer)?; + let report = Self { + schema_version: wire.schema_version, + code: wire.code, + class: wire.class, + retryable: wire.retryable, + recovery_actions: wire.recovery_actions, + operation_id: wire.operation_id, + capability_id: wire.capability_id, + message: wire.message, + details: wire.details, + }; + report.validate().map_err(serde::de::Error::custom)?; + Ok(report) + } +} + +/// Exact schema metadata for generated-language authority. +pub const SCHEMAS: &[Metadata] = &[Metadata { + type_name: "ErrorReport", + schema_id: SCHEMA_ID, + schema_version: SCHEMA_VERSION, +}]; + +/// Builds the stable error schema registry. +pub fn schema_registry() -> Result<Registry, crate::schema::Error> { + Registry::try_from_metadata( + SCHEMAS + .iter() + .copied() + .map(|metadata| (metadata, ModuleVersion::ErrorV1)), + ) +} + +/// Error-report validation failure. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Error { + InvalidCode, + InvalidCapabilityId, + InvalidSafeMessage, + SensitiveMessage, + TooManyDetails, + InvalidDetailKey, + SensitiveDetailKey, + DuplicateDetailKey, + InvalidDetailText, + ExpectedUnknownCode, + UnsupportedSchemaVersion { version: u16 }, + DescriptorMismatch { code: KnownCode }, + InvalidUnknownCodePolicy, +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidCode => formatter.write_str("invalid error code"), + Self::InvalidCapabilityId => formatter.write_str("invalid capability id"), + Self::InvalidSafeMessage => formatter.write_str("invalid safe message"), + Self::SensitiveMessage => formatter.write_str("sensitive error message rejected"), + Self::TooManyDetails => formatter.write_str("too many safe detail entries"), + Self::InvalidDetailKey => formatter.write_str("invalid safe detail key"), + Self::SensitiveDetailKey => formatter.write_str("sensitive detail key rejected"), + Self::DuplicateDetailKey => formatter.write_str("duplicate safe detail key"), + Self::InvalidDetailText => formatter.write_str("invalid safe detail text"), + Self::ExpectedUnknownCode => formatter.write_str("expected an unknown error code"), + Self::UnsupportedSchemaVersion { version } => { + write!( + formatter, + "unsupported error report schema version {version}" + ) + } + Self::DescriptorMismatch { code } => { + write!(formatter, "error descriptor mismatch for {}", code.as_str()) + } + Self::InvalidUnknownCodePolicy => { + formatter.write_str("unknown error code violates fail-closed policy") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +fn validate_detail(detail: &Detail) -> Result<(), Error> { + const ALLOWED_KEYS: &[&str] = &[ + "actual", + "available_count", + "committed", + "delivery_pending", + "expected", + "field", + "index", + "limit", + "mode", + "required_count", + "retry_after_ms", + "schema_version", + "status", + "target_count", + ]; + if !ALLOWED_KEYS.contains(&detail.key.as_str()) { + if sensitive_identifier(detail.key.as_str()) { + return Err(Error::SensitiveDetailKey); + } + return Err(Error::InvalidDetailKey); + } + if let DetailValue::Text(value) = &detail.value + && (value.is_empty() + || value.len() > MAX_DETAIL_TEXT_BYTES + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'_' | b'-' | b'.') + }) + || contains_sensitive_material(value.as_str())) + { + return Err(Error::InvalidDetailText); + } + Ok(()) +} + +fn valid_identifier(value: &str, max_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= max_bytes + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-' | b'.') + }) +} + +fn sensitive_identifier(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + [ + "authorization", + "cookie", + "credential", + "mnemonic", + "password", + "private_key", + "raw_event", + "secret", + "seed", + "signature", + "token", + ] + .iter() + .any(|marker| lower.contains(marker)) +} + +fn contains_sensitive_material(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + sensitive_identifier(value) + || lower.contains("bearer ") + || lower.contains("://") + || lower.contains("sk_") + || lower.contains("nsec1") + || lower.contains("-----begin ") + || lower.contains("private key") +} + +#[cfg(test)] +mod tests { + use alloc::collections::BTreeSet; + + use super::*; + + #[test] + fn generated_catalog_is_complete_unique_and_self_consistent() { + assert_eq!(CATALOG.len(), 56); + assert_eq!(KnownCode::ALL.len(), CATALOG.len()); + let mut codes = BTreeSet::new(); + for (index, descriptor) in CATALOG.iter().enumerate() { + assert!(codes.insert(descriptor.code.as_str())); + assert_eq!(descriptor.code, KnownCode::ALL[index]); + assert_eq!(descriptor.code.descriptor(), *descriptor); + assert_eq!( + KnownCode::parse(descriptor.code.as_str()), + Some(descriptor.code) + ); + assert!(!descriptor.recovery_actions.is_empty()); + } + } + + #[cfg(feature = "serde")] + #[test] + fn known_report_round_trips_with_exact_v1_shape() { + let details = SafeDetails::try_new([ + Detail::new("retry_after_ms", DetailValue::Unsigned(250)), + Detail::new("status", DetailValue::Text("rate_limited".to_string())), + ]) + .expect("safe details"); + let report = ErrorReport::known( + KnownCode::RelayRateLimited, + Some(OperationId::SyncPush), + Some(CapabilityId::parse("nostr").expect("capability")), + SafeMessage::parse("Relay rate limit requires a later retry").expect("safe message"), + details, + ); + report.validate().expect("report"); + + let json = serde_json::to_string(&report).expect("serialize"); + let decoded: ErrorReport = serde_json::from_str(json.as_str()).expect("deserialize"); + assert_eq!(decoded, report); + assert_eq!( + serde_json::to_value(report).expect("value"), + serde_json::json!({ + "schema_version": 1, + "code": "relay_rate_limited", + "class": "network", + "retryable": true, + "recovery_actions": ["retry_after_transport_failure"], + "operation_id": "sync.push", + "capability_id": "nostr", + "message": "Relay rate limit requires a later retry", + "details": [ + {"key": "retry_after_ms", "value": {"kind": "unsigned", "value": 250}}, + {"key": "status", "value": {"kind": "text", "value": "rate_limited"}} + ] + }) + ); + } + + #[cfg(feature = "serde")] + #[test] + fn unknown_codes_are_preserved_but_fail_closed() { + let report = ErrorReport::unknown(Code::parse("future_failure").expect("code")) + .expect("unknown report"); + let json = serde_json::to_string(&report).expect("serialize"); + let decoded: ErrorReport = serde_json::from_str(json.as_str()).expect("deserialize"); + assert_eq!(decoded.code().as_str(), "future_failure"); + assert_eq!(decoded.class(), Class::Unknown); + assert!(!decoded.retryable()); + assert!(decoded.recovery_actions().is_empty()); + assert_eq!(decoded.message().as_str(), REDACTED_MESSAGE); + + let invalid = json.replace("\"unknown\"", "\"network\""); + assert!(serde_json::from_str::<ErrorReport>(invalid.as_str()).is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn unknown_fields_and_versions_fail_closed() { + let report = ErrorReport::redacted_from_source(KnownCode::InternalError, None, None); + let mut value = serde_json::to_value(report).expect("value"); + value["schema_version"] = serde_json::json!(2); + assert!(serde_json::from_value::<ErrorReport>(value.clone()).is_err()); + value["schema_version"] = serde_json::json!(1); + value + .as_object_mut() + .expect("object") + .insert("source".to_string(), serde_json::json!("native error")); + assert!(serde_json::from_value::<ErrorReport>(value).is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn native_source_messages_are_redacted_and_secrets_are_rejected() { + for source in [ + "Bearer top-secret-token", + "password=hunter2", + "nsec1privatekeymaterial", + "wss://user:password@relay.example.com?token=secret", + "-----BEGIN PRIVATE KEY-----", + "sk_live_sensitive", + ] { + assert!(SafeMessage::parse(source).is_err()); + let report = ErrorReport::redacted_from_source(KnownCode::InternalError, None, None); + let json = serde_json::to_string(&report).expect("serialize"); + assert!(!json.contains(source)); + assert!(json.contains(REDACTED_MESSAGE)); + } + + assert_eq!( + SafeDetails::try_new([Detail::new( + "access_token", + DetailValue::Text("secret".to_string()) + )]), + Err(Error::SensitiveDetailKey) + ); + } + + #[test] + fn schema_registry_dispatches_error_report_v1() { + let registry = schema_registry().expect("registry"); + assert_eq!(registry.len(), 1); + assert_eq!(registry.descriptors()[0].module(), ModuleVersion::ErrorV1); + assert_eq!(registry.descriptors()[0].id().as_str(), SCHEMA_ID); + } +} diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs @@ -0,0 +1,4 @@ +//! Versioned event wire contracts. + +/// Event wire contracts for generation 1. +pub mod v1; diff --git a/crates/protocol/src/event/v1.rs b/crates/protocol/src/event/v1.rs @@ -0,0 +1,469 @@ +//! Event catalog contract generation 1. + +use alloc::string::{String, ToString}; +use core::fmt; + +use crate::schema::{Metadata, ModuleVersion, Registry}; + +/// Stable event replacement class. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum EventClass { + /// Ordinary nonreplaceable event. + Regular, + /// Replaceable event. + Replaceable, + /// Parameterized replaceable event. + Addressable, + /// Unsigned rumor that must not be published directly. + UnsignedRumor, +} + +/// Passive event-catalog descriptor DTO. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EventDescriptor { + /// Stable catalog name. + pub name: &'static str, + /// Nostr event kind. + pub kind: u32, + /// Replacement class. + pub event_class: EventClass, + /// Stable human-readable purpose. + pub purpose: &'static str, +} + +/// Exact Release V1 event catalog. +pub const CATALOG: &[EventDescriptor] = &[ + EventDescriptor { + name: "profile", + kind: 0, + event_class: EventClass::Replaceable, + purpose: "actor public profile/supporting discovery", + }, + EventDescriptor { + name: "deletion_request", + kind: 5, + event_class: EventClass::Regular, + purpose: "best-effort NIP-09 request; no global erasure guarantee", + }, + EventDescriptor { + name: "gift_wrap", + kind: 1059, + event_class: EventClass::Regular, + purpose: "NIP-59 encrypted private delivery wrapper", + }, + EventDescriptor { + name: "trade_private_coordination_rumor", + kind: 3421, + event_class: EventClass::UnsignedRumor, + purpose: "NIP-44 encrypted buyer/seller private coordination; never relay-published directly", + }, + EventDescriptor { + name: "trade_order_request", + kind: 3422, + event_class: EventClass::Regular, + purpose: "buyer request against exact listing/quote/validator set", + }, + EventDescriptor { + name: "trade_order_decision", + kind: 3423, + event_class: EventClass::Regular, + purpose: "seller accept or decline", + }, + EventDescriptor { + name: "trade_order_cancellation", + kind: 3432, + event_class: EventClass::Regular, + purpose: "authorized predecision cancellation", + }, + EventDescriptor { + name: "trade_validation_receipt", + kind: 3440, + event_class: EventClass::Regular, + purpose: "RHI validation result bound to root/target/listing/validator set", + }, + EventDescriptor { + name: "dm_relay_list", + kind: 10050, + event_class: EventClass::Replaceable, + purpose: "recipient private-message relay advertisement", + }, + EventDescriptor { + name: "relay_auth", + kind: 22242, + event_class: EventClass::Regular, + purpose: "NIP-42 relay authentication", + }, + EventDescriptor { + name: "farm", + kind: 30340, + event_class: EventClass::Addressable, + purpose: "public farm aggregate", + }, + EventDescriptor { + name: "validator_set", + kind: 30381, + event_class: EventClass::Addressable, + purpose: "immutable one-validator set artifact signed by network authority", + }, + EventDescriptor { + name: "classified_listing", + kind: 30402, + event_class: EventClass::Addressable, + purpose: "NIP-99 classified listing", + }, +]; + +/// Event kinds rejected as retired V1 identities. +pub const RETIRED_KINDS: &[u32] = &[ + 3424, 3425, 3426, 3427, 3428, 3429, 3430, 3433, 3434, 5321, 5322, 6321, 6322, 30403, +]; + +// Private byte guards preserve fail-closed predecessor behavior without +// reintroducing retired event identities as public string surfaces. +const RETIRED_NAME_BYTES: &[&[u8]] = &[ + &[ + 108, 105, 115, 116, 105, 110, 103, 95, 100, 114, 97, 102, 116, + ], + &[116, 114, 97, 100, 101, 95, 97, 110, 115, 119, 101, 114], + &[ + 116, 114, 97, 100, 101, 95, 100, 105, 115, 99, 111, 117, 110, 116, 95, 97, 99, 99, 101, + 112, 116, + ], + &[ + 116, 114, 97, 100, 101, 95, 100, 105, 115, 99, 111, 117, 110, 116, 95, 111, 102, 102, 101, + 114, + ], + &[ + 116, 114, 97, 100, 101, 95, 100, 105, 115, 99, 111, 117, 110, 116, 95, 114, 101, 113, 117, + 101, 115, 116, + ], + &[ + 116, 114, 97, 100, 101, 95, 102, 117, 108, 102, 105, 108, 108, 109, 101, 110, 116, 95, 117, + 112, 100, 97, 116, 101, + ], + &[ + 116, 114, 97, 100, 101, 95, 108, 105, 115, 116, 105, 110, 103, 95, 118, 97, 108, 105, 100, + 97, 116, 105, 111, 110, 95, 114, 101, 113, 117, 101, 115, 116, + ], + &[ + 116, 114, 97, 100, 101, 95, 108, 105, 115, 116, 105, 110, 103, 95, 118, 97, 108, 105, 100, + 97, 116, 105, 111, 110, 95, 114, 101, 115, 117, 108, 116, + ], + &[ + 116, 114, 97, 100, 101, 95, 111, 114, 100, 101, 114, 95, 114, 101, 118, 105, 115, 105, 111, + 110, 95, 100, 101, 99, 105, 115, 105, 111, 110, + ], + &[ + 116, 114, 97, 100, 101, 95, 111, 114, 100, 101, 114, 95, 114, 101, 118, 105, 115, 105, 111, + 110, 95, 112, 114, 111, 112, 111, 115, 97, 108, + ], + &[ + 116, 114, 97, 100, 101, 95, 113, 117, 101, 115, 116, 105, 111, 110, + ], + &[116, 114, 97, 100, 101, 95, 114, 101, 99, 101, 105, 112, 116], + &[ + 116, 114, 97, 100, 101, 95, 116, 114, 97, 110, 115, 105, 116, 105, 111, 110, 95, 112, 114, + 111, 111, 102, 95, 114, 101, 113, 117, 101, 115, 116, + ], + &[ + 116, 114, 97, 100, 101, 95, 116, 114, 97, 110, 115, 105, 116, 105, 111, 110, 95, 112, 114, + 111, 111, 102, 95, 114, 101, 115, 117, 108, 116, + ], +]; + +/// Stable trade projection state serialized by the V1 contract. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum TradeState { + /// No trade state exists. + Missing, + /// A trade was requested. + Requested, + /// Parties agreed and validation remains pending. + AgreedPendingValidation, + /// The trade was committed. + Committed, + /// The trade was declined. + Declined, + /// The trade was cancelled. + Cancelled, + /// The validation window expired. + ValidationExpired, + /// The trade state is invalid. + Invalid, +} + +impl TradeState { + /// Returns the exact stable serialized identity. + pub const fn as_str(self) -> &'static str { + match self { + Self::Missing => "missing", + Self::Requested => "requested", + Self::AgreedPendingValidation => "agreed_pending_validation", + Self::Committed => "committed", + Self::Declined => "declined", + Self::Cancelled => "cancelled", + Self::ValidationExpired => "validation_expired", + Self::Invalid => "invalid", + } + } + + /// Parses a current state and rejects known retired vocabulary explicitly. + pub fn parse(value: &str) -> Result<Self, Error> { + match value { + "missing" => Ok(Self::Missing), + "requested" => Ok(Self::Requested), + "agreed_pending_validation" => Ok(Self::AgreedPendingValidation), + "committed" => Ok(Self::Committed), + "declined" => Ok(Self::Declined), + "cancelled" => Ok(Self::Cancelled), + "validation_expired" => Ok(Self::ValidationExpired), + "invalid" => Ok(Self::Invalid), + "revision_proposed" | "agreed_pending_rhi" | "pending_rhi" | "pending_validation" => { + Err(Error::RetiredTradeState { + state: value.to_string(), + }) + } + _ => Err(Error::UnknownTradeState { + value: value.to_string(), + }), + } + } +} + +/// Exact V1 trade-state vocabulary. +pub const TRADE_STATE_VOCABULARY: &[TradeState] = &[ + TradeState::Missing, + TradeState::Requested, + TradeState::AgreedPendingValidation, + TradeState::Committed, + TradeState::Declined, + TradeState::Cancelled, + TradeState::ValidationExpired, + TradeState::Invalid, +]; + +/// Exact schema identities retained from the predecessor package. +pub const SCHEMAS: &[Metadata] = &[ + Metadata { + type_name: "ProtocolEventDescriptorV1", + schema_id: "radroots.protocol.event_descriptor.v1", + schema_version: 1, + }, + Metadata { + type_name: "ProtocolTradeStateV1", + schema_id: "radroots.protocol.trade_state.v1", + schema_version: 1, + }, +]; + +/// Validates event-catalog uniqueness and retired-identity exclusion. +pub fn validate_catalog(descriptors: &[EventDescriptor]) -> Result<(), Error> { + for (index, descriptor) in descriptors.iter().enumerate() { + if RETIRED_NAME_BYTES.contains(&descriptor.name.as_bytes()) { + return Err(Error::RetiredEventName { + name: descriptor.name.to_string(), + }); + } + if RETIRED_KINDS.contains(&descriptor.kind) { + return Err(Error::RetiredEventKind { + kind: descriptor.kind, + }); + } + for prior in &descriptors[..index] { + if prior.name == descriptor.name { + return Err(Error::DuplicateEventName { + name: descriptor.name.to_string(), + }); + } + if prior.kind == descriptor.kind { + return Err(Error::DuplicateEventKind { + kind: descriptor.kind, + }); + } + } + } + Ok(()) +} + +/// Validates uniqueness of the current trade-state vocabulary. +pub fn validate_trade_state_vocabulary(states: &[TradeState]) -> Result<(), Error> { + for (index, state) in states.iter().enumerate() { + if states[..index].contains(state) { + return Err(Error::DuplicateTradeState { state: *state }); + } + } + Ok(()) +} + +/// Builds the validated event schema registry. +pub fn schema_registry() -> Result<Registry, crate::schema::Error> { + Registry::try_from_metadata( + SCHEMAS + .iter() + .copied() + .map(|metadata| (metadata, ModuleVersion::EventV1)), + ) +} + +/// Event V1 validation failure. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Error { + /// An event name appears more than once. + DuplicateEventName { + /// Duplicated name. + name: String, + }, + /// An event kind appears more than once. + DuplicateEventKind { + /// Duplicated kind. + kind: u32, + }, + /// A trade state appears more than once. + DuplicateTradeState { + /// Duplicated state. + state: TradeState, + }, + /// A retired event kind was reintroduced. + RetiredEventKind { + /// Retired kind. + kind: u32, + }, + /// A retired event name was reintroduced. + RetiredEventName { + /// Retired name. + name: String, + }, + /// A retired trade-state identity was supplied. + RetiredTradeState { + /// Retired state identity. + state: String, + }, + /// An unknown trade-state identity was supplied. + UnknownTradeState { + /// Unknown state identity. + value: String, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateEventName { name } => write!(formatter, "duplicate event name {name}"), + Self::DuplicateEventKind { kind } => write!(formatter, "duplicate event kind {kind}"), + Self::DuplicateTradeState { state } => { + write!(formatter, "duplicate trade state {}", state.as_str()) + } + Self::RetiredEventKind { kind } => write!(formatter, "retired event kind {kind}"), + Self::RetiredEventName { name } => write!(formatter, "retired event name {name}"), + Self::RetiredTradeState { state } => write!(formatter, "retired trade state {state}"), + Self::UnknownTradeState { value } => write!(formatter, "unknown trade state {value}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use super::*; + + #[test] + fn catalogs_and_schema_registry_validate() { + validate_catalog(CATALOG).expect("event catalog"); + validate_trade_state_vocabulary(TRADE_STATE_VOCABULARY).expect("trade vocabulary"); + let registry = schema_registry().expect("schema registry"); + assert_eq!(registry.len(), SCHEMAS.len()); + assert!( + registry + .descriptors() + .iter() + .all(|descriptor| descriptor.module() == ModuleVersion::EventV1) + ); + } + + #[test] + fn event_catalog_retains_exact_v1_identifiers() { + assert_eq!(CATALOG.len(), 13); + let listing = CATALOG + .iter() + .find(|event| event.kind == 30402) + .expect("classified listing"); + assert_eq!(listing.name, "classified_listing"); + assert_eq!(listing.event_class, EventClass::Addressable); + assert_eq!(listing.purpose, "NIP-99 classified listing"); + } + + #[test] + fn trade_state_vocabulary_and_parser_are_exact() { + assert_eq!( + TRADE_STATE_VOCABULARY + .iter() + .map(|state| state.as_str()) + .collect::<Vec<_>>(), + [ + "missing", + "requested", + "agreed_pending_validation", + "committed", + "declined", + "cancelled", + "validation_expired", + "invalid", + ] + ); + for state in TRADE_STATE_VOCABULARY { + assert_eq!(TradeState::parse(state.as_str()), Ok(*state)); + } + assert_eq!( + TradeState::parse("pending_rhi") + .expect_err("retired") + .to_string(), + "retired trade state pending_rhi" + ); + assert_eq!( + TradeState::parse("fulfilled") + .expect_err("unknown") + .to_string(), + "unknown trade state fulfilled" + ); + } + + #[test] + fn event_validation_rejects_retired_and_duplicate_entries() { + let first = CATALOG[0]; + let duplicate_name = EventDescriptor { + name: first.name, + kind: u32::MAX, + event_class: EventClass::Regular, + purpose: "duplicate name", + }; + assert_eq!( + validate_catalog(&[first, duplicate_name]), + Err(Error::DuplicateEventName { + name: first.name.into(), + }) + ); + let retired = EventDescriptor { + name: "synthetic_current_name", + kind: RETIRED_KINDS[0], + event_class: EventClass::Regular, + purpose: "retired", + }; + assert_eq!( + validate_catalog(&[retired]), + Err(Error::RetiredEventKind { + kind: RETIRED_KINDS[0], + }) + ); + } +} diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs @@ -0,0 +1,26 @@ +#![doc = include_str!("../README.md")] +#![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] + +extern crate alloc; + +#[cfg(all(test, not(feature = "std")))] +extern crate std; + +/// Versioned capability catalog contracts. +pub mod capability; + +/// Versioned stable error-report contracts. +pub mod error; + +/// Versioned event wire contracts. +pub mod event; + +/// Versioned daemon protocol contracts. +pub mod radrootsd; + +/// Versioned runtime operation contracts. +pub mod runtime; + +/// Schema identity and structural validation contracts. +pub mod schema; diff --git a/crates/protocol/src/radrootsd.rs b/crates/protocol/src/radrootsd.rs @@ -0,0 +1,7 @@ +//! Versioned daemon protocol contracts. + +/// Versioned transport-publish contracts. +pub mod transport_publish { + /// Transport-publish contracts for generation 5. + pub mod v5; +} diff --git a/crates/protocol/src/radrootsd/transport_publish/v5.rs b/crates/protocol/src/radrootsd/transport_publish/v5.rs @@ -0,0 +1,1273 @@ +//! Daemon transport-publish wire contract generation 5. +//! +//! The types in this module are passive serialized DTOs. Transport-native +//! targets, relay clients, delivery execution, and conversions belong to their +//! owning transport and host packages. + +use alloc::{borrow::ToOwned, collections::BTreeSet, string::String, vec, vec::Vec}; +use core::fmt; + +use crate::schema::{Descriptor as SchemaDescriptor, ModuleVersion, Registry}; + +/// Stable daemon API/schema identity. +pub const API_VERSION: &str = "radrootsd.transport_publish.v5"; +/// Stable daemon identity. +pub const DAEMON_NAME: &str = "radrootsd"; +/// Capabilities RPC method. +pub const METHOD_CAPABILITIES: &str = "transport.publish.capabilities"; +/// Event publication RPC method. +pub const METHOD_EVENT: &str = "transport.publish.event"; +/// Job lookup RPC method. +pub const METHOD_JOB_GET: &str = "transport.publish.job.get"; +/// Job listing RPC method. +pub const METHOD_JOB_LIST: &str = "transport.publish.job.list"; +/// Canonical V5 Reticulum endpoint identity. +pub const RETICULUM_ENDPOINT_URI: &str = "reticulum:local"; +/// Stable V5 Reticulum unavailability message. +pub const RETICULUM_UNAVAILABLE_MESSAGE: &str = concat!( + "Reticulum transport is configured, ", + "but this build does not implement Reticulum delivery." +); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Error { + InvalidHexField { + field: &'static str, + expected_len: usize, + }, + EmptyRawEventJson, + EmptyTag { + index: usize, + }, + EmptyIdempotencyKey, + EmptyTransportKind { + index: usize, + }, + InvalidTransportKind { + index: usize, + }, + EmptyEndpointUri { + index: usize, + }, + InvalidEndpointUri { + index: usize, + }, + EmptyTargetScope { + index: usize, + }, + InvalidTargetScope { + index: usize, + }, + EmptyTargetLabel { + index: usize, + }, + InvalidTargetLabel { + index: usize, + }, + InvalidReticulumBehavior { + index: usize, + }, + InvalidTimeoutMs, + InvalidReticulumEndpoint { + index: usize, + }, + DuplicateTarget { + index: usize, + }, + TargetLimitExceeded { + max: usize, + actual: usize, + }, + EmptyTargetSet, + InvalidQuorum, + EmptyRequiredTargetSet, + DuplicateRequiredTargetFingerprint { + index: usize, + }, + RequiredTargetNotInTargetSet { + index: usize, + }, + EmptyPrincipalId, + EmptyJobId, + InvalidJobTargetCount { + expected: usize, + actual: usize, + }, + InvalidJobAcknowledgedCount { + expected: usize, + actual: usize, + }, + InvalidJobRetryableCount { + expected: usize, + actual: usize, + }, + InvalidJobTerminalCount { + expected: usize, + actual: usize, + }, + InvalidJobTerminalState, + InvalidJobDeliverySatisfiedState, + InvalidJobCompletedAt, + InvalidJobStatusState, + InvalidExplicitTargetOutcome { + index: usize, + }, + InvalidTargetOutcomeKind { + index: usize, + }, + InvalidTargetSource { + index: usize, + }, + InvalidReticulumOutcome { + index: usize, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidHexField { + field, + expected_len, + } => write!(f, "{field} must be {expected_len} lowercase hex characters"), + Self::EmptyRawEventJson => f.write_str("raw_event_json must not be empty"), + Self::EmptyTag { index } => write!(f, "tag {index} must not be empty"), + Self::EmptyIdempotencyKey => f.write_str("idempotency key must not be empty"), + Self::EmptyTransportKind { index } => { + write!(f, "transport target {index} kind must not be empty") + } + Self::InvalidTransportKind { index } => { + write!( + f, + "transport target {index} kind must be canonical lowercase" + ) + } + Self::EmptyEndpointUri { index } => { + write!(f, "transport target {index} endpoint_uri must not be empty") + } + Self::InvalidEndpointUri { index } => { + write!(f, "transport target {index} endpoint_uri is invalid") + } + Self::EmptyTargetScope { index } => { + write!(f, "transport target {index} target_scope must not be empty") + } + Self::InvalidTargetScope { index } => { + write!(f, "transport target {index} target_scope must be canonical") + } + Self::EmptyTargetLabel { index } => { + write!(f, "transport target {index} target_label must not be empty") + } + Self::InvalidTargetLabel { index } => { + write!(f, "transport target {index} target_label is invalid") + } + Self::InvalidReticulumBehavior { index } => write!( + f, + "transport target {index} reticulum_behavior is only valid for Reticulum targets" + ), + Self::InvalidTimeoutMs => f.write_str("timeout_ms must be greater than zero"), + Self::InvalidReticulumEndpoint { index } => write!( + f, + "transport target {index} Reticulum endpoint must be {RETICULUM_ENDPOINT_URI}" + ), + Self::DuplicateTarget { index } => { + write!(f, "transport target {index} duplicates an earlier target") + } + Self::TargetLimitExceeded { max, actual } => { + write!(f, "transport target count {actual} exceeds limit {max}") + } + Self::EmptyTargetSet => f.write_str("transport publish target set must not be empty"), + Self::InvalidQuorum => f.write_str("delivery quorum must be greater than zero"), + Self::EmptyRequiredTargetSet => { + f.write_str("delivery required target set must not be empty") + } + Self::DuplicateRequiredTargetFingerprint { index } => { + write!( + f, + "delivery required target {index} duplicates an earlier fingerprint" + ) + } + Self::RequiredTargetNotInTargetSet { index } => { + write!( + f, + "delivery required target {index} is not in the target set" + ) + } + Self::EmptyPrincipalId => f.write_str("principal id must not be empty"), + Self::EmptyJobId => f.write_str("job id must not be empty"), + Self::InvalidJobTargetCount { expected, actual } => write!( + f, + "job target_count {actual} does not match {expected} target outcomes" + ), + Self::InvalidJobAcknowledgedCount { expected, actual } => write!( + f, + "job acknowledged_count {actual} does not match {expected} target outcomes" + ), + Self::InvalidJobRetryableCount { expected, actual } => write!( + f, + "job retryable_count {actual} does not match {expected} target outcomes" + ), + Self::InvalidJobTerminalCount { expected, actual } => write!( + f, + "job terminal_count {actual} does not match {expected} target outcomes" + ), + Self::InvalidJobTerminalState => f.write_str("job terminal flag does not match status"), + Self::InvalidJobDeliverySatisfiedState => { + f.write_str("job delivery_satisfied flag does not match status") + } + Self::InvalidJobCompletedAt => { + f.write_str("job completed_at_ms does not match status or request time") + } + Self::InvalidJobStatusState => f.write_str("job status does not match target outcomes"), + Self::InvalidExplicitTargetOutcome { index } => write!( + f, + "transport target outcome {index} does not match explicit target policy" + ), + Self::InvalidTargetOutcomeKind { index } => { + write!( + f, + "transport target outcome {index} kind is not valid for its transport" + ) + } + Self::InvalidTargetSource { index } => { + write!( + f, + "transport target outcome {index} source does not match transport kind" + ) + } + Self::InvalidReticulumOutcome { index } => write!( + f, + "transport target outcome {index} Reticulum must be unavailable or deferred" + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ReticulumBehavior { + #[default] + RejectDeliveryAttempts, + DeferDeliveryPlans, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Target { + pub transport_kind: String, + pub endpoint_uri: String, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub target_scope: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub target_label: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub reticulum_behavior: Option<ReticulumBehavior>, +} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NostrTargetSourcePolicy { + ExplicitOnly, + RequestThenAuthorWriteThenDaemonDefault, + AuthorWriteThenDaemonDefault, + DaemonDefaultOnly, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TargetPolicy { + ExplicitTargets { + targets: Vec<Target>, + }, + Nostr { + source_policy: NostrTargetSourcePolicy, + #[cfg_attr(feature = "serde", serde(default))] + relay_urls: Vec<String>, + }, +} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "mode", rename_all = "snake_case"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DeliveryPolicy { + Any, + All, + Quorum { quorum: usize }, + RequiredTargets { targets: Vec<TargetFingerprint> }, +} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EventRequest { + pub raw_event_json: String, + pub target_policy: TargetPolicy, + pub delivery_policy: DeliveryPolicy, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub idempotency_key: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub timeout_ms: Option<u64>, +} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JobStatus { + Accepted, + Publishing, + DeliverySatisfied, + DeliveryUnsatisfiedRetryable, + DeliveryUnsatisfiedTerminal, + DeliveryDeferred, + DeliveryDeferredUntilImplemented, + Rejected, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OutcomeKind { + Accepted, + DuplicateAccepted, + Blocked, + RateLimited, + Invalid, + PowRequired, + Restricted, + AuthRequired, + Muted, + Unsupported, + PaymentRequired, + Error, + Timeout, + ConnectionFailed, + TargetRejected, + SkippedAlreadyAccepted, + DeferredUntilImplemented, + Unknown, +} + +impl OutcomeKind { + pub fn counts_toward_accepted_delivery(self) -> bool { + matches!( + self, + Self::Accepted | Self::DuplicateAccepted | Self::SkippedAlreadyAccepted + ) + } + + pub fn is_retryable(self) -> bool { + matches!( + self, + Self::RateLimited + | Self::PowRequired + | Self::AuthRequired + | Self::Error + | Self::Timeout + | Self::ConnectionFailed + | Self::Unknown + ) + } + + pub fn is_terminal_failure(self) -> bool { + matches!( + self, + Self::Blocked + | Self::Invalid + | Self::Restricted + | Self::Muted + | Self::Unsupported + | Self::PaymentRequired + | Self::TargetRejected + ) + } + + pub fn is_deferred_until_implemented(self) -> bool { + matches!(self, Self::DeferredUntilImplemented) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TargetSource { + Request, + NostrAuthorWrite, + DaemonDefault, + Reticulum, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TargetOutcome { + pub transport_kind: String, + pub endpoint_uri: String, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub target_scope: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub target_label: Option<String>, + pub source: TargetSource, + pub attempted: bool, + pub outcome_kind: OutcomeKind, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub message: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub latency_ms: Option<u64>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Job { + pub job_id: String, + pub status: JobStatus, + pub terminal: bool, + pub delivery_satisfied: bool, + pub event_id: String, + pub pubkey: String, + pub event_kind: u32, + pub target_policy: TargetPolicy, + pub delivery_policy: DeliveryPolicy, + pub target_count: usize, + pub acknowledged_count: usize, + pub retryable_count: usize, + pub terminal_count: usize, + pub requested_at_ms: i64, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub completed_at_ms: Option<i64>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub last_error: Option<String>, + #[cfg_attr(feature = "serde", serde(default))] + pub targets: Vec<TargetOutcome>, +} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EventResponse { + pub deduplicated: bool, + pub job: Job, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Capabilities { + pub daemon: String, + pub api_version: String, + pub transports: Vec<String>, + pub methods: Vec<String>, + pub auth: AuthCapabilities, + pub publish: SurfaceCapabilities, +} + +impl Capabilities { + pub fn v5(max_event_bytes: usize, max_targets_per_request: usize) -> Self { + Self { + daemon: DAEMON_NAME.to_owned(), + api_version: API_VERSION.to_owned(), + transports: vec!["jsonrpc_http".to_owned()], + methods: vec![ + METHOD_CAPABILITIES.to_owned(), + METHOD_EVENT.to_owned(), + METHOD_JOB_GET.to_owned(), + METHOD_JOB_LIST.to_owned(), + ], + auth: AuthCapabilities { + mode: "scoped_bearer_token".to_owned(), + }, + publish: SurfaceCapabilities { + raw_event_json_ingress: true, + server_side_user_signing: false, + max_event_bytes, + max_targets_per_request, + delivery_policies: vec![ + DeliveryPolicyName::Any, + DeliveryPolicyName::Quorum, + DeliveryPolicyName::All, + DeliveryPolicyName::RequiredTargets, + ], + target_policy_modes: vec![ + TargetPolicyName::ExplicitTargets, + TargetPolicyName::Nostr, + ], + transports: vec![ + TransportCapability { + transport: "nostr".to_owned(), + configured: true, + implementation: Implementation::Real, + maturity: CapabilityMaturity::Stable, + availability: CapabilityAvailability::Available, + usable_for_delivery: true, + capabilities: OperationCapabilities { + deliver: true, + fetch: false, + discovery: false, + gateway_forwarding: false, + receipt_observation: false, + }, + reticulum_behavior: None, + message: "Nostr relay publish is available".to_owned(), + }, + TransportCapability { + transport: "reticulum".to_owned(), + configured: true, + implementation: Implementation::Real, + maturity: CapabilityMaturity::Preview, + availability: CapabilityAvailability::Unavailable, + usable_for_delivery: false, + capabilities: OperationCapabilities { + deliver: false, + fetch: false, + discovery: false, + gateway_forwarding: false, + receipt_observation: false, + }, + reticulum_behavior: Some(ReticulumBehavior::RejectDeliveryAttempts), + message: RETICULUM_UNAVAILABLE_MESSAGE.to_owned(), + }, + ], + }, + } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthCapabilities { + pub mode: String, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SurfaceCapabilities { + pub raw_event_json_ingress: bool, + pub server_side_user_signing: bool, + pub max_event_bytes: usize, + pub max_targets_per_request: usize, + pub delivery_policies: Vec<DeliveryPolicyName>, + pub target_policy_modes: Vec<TargetPolicyName>, + pub transports: Vec<TransportCapability>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Implementation { + Real, + Mock, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CapabilityMaturity { + Preview, + Stable, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CapabilityAvailability { + Available, + Degraded, + Unavailable, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TransportCapability { + pub transport: String, + pub configured: bool, + pub implementation: Implementation, + pub maturity: CapabilityMaturity, + pub availability: CapabilityAvailability, + pub usable_for_delivery: bool, + pub capabilities: OperationCapabilities, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub reticulum_behavior: Option<ReticulumBehavior>, + pub message: String, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OperationCapabilities { + pub deliver: bool, + pub fetch: bool, + pub discovery: bool, + pub gateway_forwarding: bool, + pub receipt_observation: bool, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeliveryPolicyName { + Any, + Quorum, + All, + RequiredTargets, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TargetPolicyName { + ExplicitTargets, + Nostr, +} + +/// Canonical serialized target fingerprint. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct TargetFingerprint(String); + +impl TargetFingerprint { + /// Parses the exact lowercase 64-hex wire representation. + pub fn parse(value: impl Into<String>) -> Result<Self, Error> { + let value = value.into(); + validate_lower_hex("target_fingerprint", value.as_str(), 64)?; + Ok(Self(value)) + } + + /// Returns the canonical wire representation. + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for TargetFingerprint { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for TargetFingerprint { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = <String as serde::Deserialize>::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +impl Target { + /// Creates a passive Nostr target DTO. + pub fn nostr(endpoint_uri: impl Into<String>) -> Self { + Self { + transport_kind: "nostr".to_owned(), + endpoint_uri: endpoint_uri.into(), + target_scope: None, + target_label: None, + reticulum_behavior: None, + } + } + + /// Creates the canonical passive Reticulum target DTO. + pub fn reticulum(behavior: ReticulumBehavior) -> Self { + Self { + transport_kind: "reticulum".to_owned(), + endpoint_uri: RETICULUM_ENDPOINT_URI.to_owned(), + target_scope: None, + target_label: None, + reticulum_behavior: Some(behavior), + } + } + + /// Adds a serialized mesh scope. + pub fn with_scope(mut self, target_scope: impl Into<String>) -> Self { + self.target_scope = Some(target_scope.into()); + self + } + + /// Adds a serialized display label. + pub fn with_label(mut self, target_label: impl Into<String>) -> Self { + self.target_label = Some(target_label.into()); + self + } + + fn validate_structure(&self, index: usize) -> Result<(), Error> { + match self.transport_kind.as_str() { + "" => return Err(Error::EmptyTransportKind { index }), + "local" | "nostr" | "reticulum" => {} + _ if self.transport_kind.trim().is_empty() => { + return Err(Error::EmptyTransportKind { index }); + } + _ => return Err(Error::InvalidTransportKind { index }), + } + validate_endpoint( + self.transport_kind.as_str(), + self.endpoint_uri.as_str(), + index, + )?; + validate_target_metadata( + self.target_scope.as_deref(), + self.target_label.as_deref(), + index, + )?; + if self.transport_kind != "reticulum" && self.reticulum_behavior.is_some() { + return Err(Error::InvalidReticulumBehavior { index }); + } + if self.transport_kind == "reticulum" && self.endpoint_uri != RETICULUM_ENDPOINT_URI { + return Err(Error::InvalidReticulumEndpoint { index }); + } + Ok(()) + } + + fn same_wire_identity(&self, outcome: &TargetOutcome) -> bool { + self.transport_kind == outcome.transport_kind + && self.endpoint_uri == outcome.endpoint_uri + && self.target_scope == outcome.target_scope + } +} + +impl TargetPolicy { + /// Creates an explicit-target policy. + pub fn explicit_targets(targets: Vec<Target>) -> Self { + Self::ExplicitTargets { targets } + } + + /// Creates a Nostr relay-source policy. + pub fn nostr(source_policy: NostrTargetSourcePolicy, relay_urls: Vec<String>) -> Self { + Self::Nostr { + source_policy, + relay_urls, + } + } + + /// Returns the number of request-declared targets. + pub fn request_target_count(&self) -> usize { + match self { + Self::ExplicitTargets { targets } => targets.len(), + Self::Nostr { relay_urls, .. } => relay_urls.len(), + } + } + + fn validate_structure(&self, max_targets: usize) -> Result<(), Error> { + match self { + Self::ExplicitTargets { targets } => { + validate_target_limit(targets.len(), max_targets)?; + if targets.is_empty() { + return Err(Error::EmptyTargetSet); + } + for (index, target) in targets.iter().enumerate() { + target.validate_structure(index)?; + if targets[..index].iter().any(|prior| { + prior.transport_kind == target.transport_kind + && prior.endpoint_uri == target.endpoint_uri + && prior.target_scope == target.target_scope + }) { + return Err(Error::DuplicateTarget { index }); + } + } + } + Self::Nostr { relay_urls, .. } => { + validate_target_limit(relay_urls.len(), max_targets)?; + for (index, endpoint) in relay_urls.iter().enumerate() { + validate_endpoint("nostr", endpoint, index)?; + if relay_urls[..index].contains(endpoint) { + return Err(Error::DuplicateTarget { index }); + } + } + } + } + Ok(()) + } +} + +impl DeliveryPolicy { + /// Creates a required-target policy after structural validation. + pub fn required_targets(targets: Vec<TargetFingerprint>) -> Result<Self, Error> { + validate_required_target_fingerprints(targets.as_slice())?; + Ok(Self::RequiredTargets { targets }) + } + + /// Validates quorum and required-target structure. + pub fn validate(&self) -> Result<(), Error> { + match self { + Self::Quorum { quorum: 0 } => Err(Error::InvalidQuorum), + Self::RequiredTargets { targets } => { + validate_required_target_fingerprints(targets.as_slice()) + } + Self::Any | Self::All | Self::Quorum { .. } => Ok(()), + } + } + + /// Returns the count required for delivery satisfaction. + pub fn required_target_count(&self, target_count: usize) -> usize { + match self { + Self::Any => usize::from(target_count > 0), + Self::All => target_count, + Self::Quorum { quorum } => *quorum, + Self::RequiredTargets { targets } => targets.len(), + } + } +} + +impl EventRequest { + /// Performs wire-structural validation without creating native targets. + pub fn validate(&self, max_targets: usize) -> Result<(), Error> { + if self.raw_event_json.is_empty() { + return Err(Error::EmptyRawEventJson); + } + self.target_policy.validate_structure(max_targets)?; + self.delivery_policy.validate()?; + if self + .idempotency_key + .as_ref() + .is_some_and(|key| key.trim().is_empty()) + { + return Err(Error::EmptyIdempotencyKey); + } + if self.timeout_ms == Some(0) { + return Err(Error::InvalidTimeoutMs); + } + Ok(()) + } +} + +impl Job { + /// Performs transport-neutral structural validation of a job receipt. + pub fn validate(&self) -> Result<(), Error> { + if self.job_id.trim().is_empty() { + return Err(Error::EmptyJobId); + } + validate_lower_hex("event_id", self.event_id.as_str(), 64)?; + validate_lower_hex("pubkey", self.pubkey.as_str(), 64)?; + self.target_policy.validate_structure(usize::MAX)?; + self.delivery_policy.validate()?; + if self.terminal != job_status_is_terminal(self.status) { + return Err(Error::InvalidJobTerminalState); + } + if self.delivery_satisfied != (self.status == JobStatus::DeliverySatisfied) { + return Err(Error::InvalidJobDeliverySatisfiedState); + } + let completed = job_status_has_completed_at(self.status); + if self.completed_at_ms.is_some() != completed + || self + .completed_at_ms + .is_some_and(|completed_at| completed_at < self.requested_at_ms) + { + return Err(Error::InvalidJobCompletedAt); + } + for (index, target) in self.targets.iter().enumerate() { + validate_target_outcome(target, index)?; + } + validate_explicit_outcomes(&self.target_policy, self.targets.as_slice())?; + if (!self.targets.is_empty() || completed) && self.target_count != self.targets.len() { + return Err(Error::InvalidJobTargetCount { + expected: self.targets.len(), + actual: self.target_count, + }); + } + + let acknowledged = self + .targets + .iter() + .filter(|target| target.outcome_kind.counts_toward_accepted_delivery()) + .count(); + let retryable = self + .targets + .iter() + .filter(|target| target.outcome_kind.is_retryable()) + .count(); + let terminal = self + .targets + .iter() + .filter(|target| target.outcome_kind.is_terminal_failure()) + .count(); + if self.acknowledged_count != acknowledged { + return Err(Error::InvalidJobAcknowledgedCount { + expected: acknowledged, + actual: self.acknowledged_count, + }); + } + if self.retryable_count != retryable { + return Err(Error::InvalidJobRetryableCount { + expected: retryable, + actual: self.retryable_count, + }); + } + if self.terminal_count != terminal { + return Err(Error::InvalidJobTerminalCount { + expected: terminal, + actual: self.terminal_count, + }); + } + validate_job_status(self, acknowledged, retryable, terminal) + } +} + +fn validate_endpoint(kind: &str, endpoint: &str, index: usize) -> Result<(), Error> { + if endpoint.trim().is_empty() { + return Err(Error::EmptyEndpointUri { index }); + } + if endpoint != endpoint.trim() + || endpoint + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return Err(Error::InvalidEndpointUri { index }); + } + if kind == "reticulum" && endpoint != RETICULUM_ENDPOINT_URI { + return Err(Error::InvalidReticulumEndpoint { index }); + } + if kind == "nostr" && !(endpoint.starts_with("wss://") || endpoint.starts_with("ws://")) { + return Err(Error::InvalidEndpointUri { index }); + } + Ok(()) +} + +fn validate_target_metadata( + scope: Option<&str>, + label: Option<&str>, + index: usize, +) -> Result<(), Error> { + if let Some(scope) = scope { + if scope.is_empty() { + return Err(Error::EmptyTargetScope { index }); + } + if scope != scope.trim() + || scope + .bytes() + .any(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))) + { + return Err(Error::InvalidTargetScope { index }); + } + } + if let Some(label) = label { + if label.trim().is_empty() { + return Err(Error::EmptyTargetLabel { index }); + } + if label != label.trim() || label.chars().any(char::is_control) { + return Err(Error::InvalidTargetLabel { index }); + } + } + Ok(()) +} + +fn validate_required_target_fingerprints(targets: &[TargetFingerprint]) -> Result<(), Error> { + if targets.is_empty() { + return Err(Error::EmptyRequiredTargetSet); + } + let mut seen = BTreeSet::new(); + for (index, target) in targets.iter().enumerate() { + if !seen.insert(target.as_str()) { + return Err(Error::DuplicateRequiredTargetFingerprint { index }); + } + } + Ok(()) +} + +fn validate_target_limit(target_count: usize, max_targets: usize) -> Result<(), Error> { + if target_count > max_targets { + Err(Error::TargetLimitExceeded { + max: max_targets, + actual: target_count, + }) + } else { + Ok(()) + } +} + +fn validate_lower_hex(field: &'static str, value: &str, expected_len: usize) -> Result<(), Error> { + if value.len() == expected_len + && value + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + Ok(()) + } else { + Err(Error::InvalidHexField { + field, + expected_len, + }) + } +} + +fn job_status_is_terminal(status: JobStatus) -> bool { + matches!( + status, + JobStatus::DeliverySatisfied + | JobStatus::DeliveryUnsatisfiedTerminal + | JobStatus::DeliveryDeferred + | JobStatus::DeliveryDeferredUntilImplemented + | JobStatus::Rejected + ) +} + +fn job_status_has_completed_at(status: JobStatus) -> bool { + !matches!(status, JobStatus::Accepted | JobStatus::Publishing) +} + +fn validate_target_outcome(target: &TargetOutcome, index: usize) -> Result<(), Error> { + Target { + transport_kind: target.transport_kind.clone(), + endpoint_uri: target.endpoint_uri.clone(), + target_scope: target.target_scope.clone(), + target_label: target.target_label.clone(), + reticulum_behavior: None, + } + .validate_structure(index)?; + + if target.transport_kind == "reticulum" { + if target.source != TargetSource::Reticulum + || target.attempted + || !target.outcome_kind.is_deferred_until_implemented() + { + return Err(Error::InvalidReticulumOutcome { index }); + } + } else { + if target.source == TargetSource::Reticulum { + return Err(Error::InvalidTargetSource { index }); + } + if target.outcome_kind.is_deferred_until_implemented() { + return Err(Error::InvalidTargetOutcomeKind { index }); + } + } + Ok(()) +} + +fn validate_explicit_outcomes( + policy: &TargetPolicy, + outcomes: &[TargetOutcome], +) -> Result<(), Error> { + let TargetPolicy::ExplicitTargets { targets } = policy else { + return Ok(()); + }; + if outcomes.is_empty() { + return Ok(()); + } + if targets.len() != outcomes.len() { + return Err(Error::InvalidExplicitTargetOutcome { + index: outcomes.len().min(targets.len()), + }); + } + let mut matched = vec![false; targets.len()]; + for (outcome_index, outcome) in outcomes.iter().enumerate() { + let Some(index) = targets.iter().enumerate().find_map(|(index, target)| { + (!matched[index] && target.same_wire_identity(outcome)).then_some(index) + }) else { + return Err(Error::InvalidExplicitTargetOutcome { + index: outcome_index, + }); + }; + matched[index] = true; + } + Ok(()) +} + +fn validate_job_status( + job: &Job, + acknowledged: usize, + retryable: usize, + terminal: usize, +) -> Result<(), Error> { + if matches!(job.status, JobStatus::Accepted | JobStatus::Publishing) { + return Ok(()); + } + if job.status == JobStatus::Rejected { + return (job.target_count == 0 + && job.targets.is_empty() + && acknowledged == 0 + && retryable == 0 + && terminal == 0) + .then_some(()) + .ok_or(Error::InvalidJobStatusState); + } + if job.targets.is_empty() { + return Err(Error::InvalidJobStatusState); + } + if matches!(job.delivery_policy, DeliveryPolicy::RequiredTargets { .. }) { + // Matching fingerprints to native targets is intentionally deferred to + // the transport conversion boundary. All other job invariants remain + // structural and are enforced above. + return Ok(()); + } + + let satisfied = acknowledged >= job.delivery_policy.required_target_count(job.target_count); + let deferred = job + .targets + .iter() + .any(|target| target.outcome_kind == OutcomeKind::DeferredUntilImplemented); + let matches = if satisfied { + job.status == JobStatus::DeliverySatisfied + } else if retryable > 0 { + job.status == JobStatus::DeliveryUnsatisfiedRetryable + } else if terminal > 0 { + job.status == JobStatus::DeliveryUnsatisfiedTerminal + } else if deferred { + matches!( + job.status, + JobStatus::DeliveryDeferred | JobStatus::DeliveryDeferredUntilImplemented + ) + } else { + false + }; + matches.then_some(()).ok_or(Error::InvalidJobStatusState) +} + +/// Builds the schema registry for the V5 daemon transport-publish contract. +pub fn schema_registry() -> Result<Registry, crate::schema::Error> { + Registry::try_new([SchemaDescriptor::try_new( + API_VERSION, + ModuleVersion::RadrootsdTransportPublishV5, + )?]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> EventRequest { + EventRequest { + raw_event_json: "{\"id\":\"event\"}".to_owned(), + target_policy: TargetPolicy::explicit_targets(vec![Target::nostr( + "wss://relay.example.com", + )]), + delivery_policy: DeliveryPolicy::Any, + idempotency_key: Some("idem-1".to_owned()), + timeout_ms: Some(5_000), + } + } + + fn accepted_job() -> Job { + Job { + job_id: "job-1".to_owned(), + status: JobStatus::DeliverySatisfied, + terminal: true, + delivery_satisfied: true, + event_id: "0".repeat(64), + pubkey: "1".repeat(64), + event_kind: 30_402, + target_policy: TargetPolicy::explicit_targets(vec![Target::nostr( + "wss://relay.example.com", + )]), + delivery_policy: DeliveryPolicy::Any, + target_count: 1, + acknowledged_count: 1, + retryable_count: 0, + terminal_count: 0, + requested_at_ms: 1, + completed_at_ms: Some(2), + last_error: None, + targets: vec![TargetOutcome { + transport_kind: "nostr".to_owned(), + endpoint_uri: "wss://relay.example.com".to_owned(), + target_scope: None, + target_label: None, + source: TargetSource::Request, + attempted: true, + outcome_kind: OutcomeKind::Accepted, + message: None, + latency_ms: Some(7), + }], + } + } + + #[test] + fn request_job_and_schema_registry_validate() { + request().validate(1).expect("request"); + accepted_job().validate().expect("job"); + let registry = schema_registry().expect("schema registry"); + assert_eq!(registry.len(), 1); + assert_eq!( + registry.descriptors()[0].module(), + ModuleVersion::RadrootsdTransportPublishV5 + ); + } + + #[test] + fn structural_validation_rejects_invalid_fields() { + let mut invalid_timeout = request(); + invalid_timeout.timeout_ms = Some(0); + assert_eq!(invalid_timeout.validate(1), Err(Error::InvalidTimeoutMs)); + + let mut invalid_endpoint = request(); + invalid_endpoint.target_policy = + TargetPolicy::explicit_targets(vec![Target::nostr("WSS://relay.example.com")]); + assert_eq!( + invalid_endpoint.validate(1), + Err(Error::InvalidEndpointUri { index: 0 }) + ); + + let mut job = accepted_job(); + job.event_id = "ABC".repeat(21); + assert_eq!( + job.validate(), + Err(Error::InvalidHexField { + field: "event_id", + expected_len: 64, + }) + ); + } + + #[cfg(feature = "serde")] + #[test] + fn json_vectors_preserve_v5_names_and_unknown_fields_fail_closed() { + let encoded = serde_json::to_value(request()).expect("request JSON"); + assert_eq!(encoded["target_policy"]["kind"], "explicit_targets"); + assert_eq!(encoded["delivery_policy"]["mode"], "any"); + let mut object = encoded.as_object().expect("request object").clone(); + object.insert("unknown".to_owned(), serde_json::json!(true)); + assert!(serde_json::from_value::<EventRequest>(object.into()).is_err()); + + assert_eq!( + serde_json::to_string(&ReticulumBehavior::RejectDeliveryAttempts) + .expect("behavior JSON"), + "\"reject_delivery_attempts\"" + ); + let capabilities = Capabilities::v5(1_024, 10); + assert_eq!(capabilities.api_version, API_VERSION); + assert_eq!( + capabilities.publish.transports[1].message, + RETICULUM_UNAVAILABLE_MESSAGE + ); + } +} diff --git a/crates/protocol/src/runtime.rs b/crates/protocol/src/runtime.rs @@ -0,0 +1,4 @@ +//! Versioned runtime operation contracts. + +/// Runtime operation contracts for generation 1. +pub mod v1; diff --git a/crates/protocol/src/runtime/v1.rs b/crates/protocol/src/runtime/v1.rs @@ -0,0 +1,1180 @@ +//! Runtime operation descriptor contract generation 1. +//! +//! This module owns passive, serialized operation identities and policy +//! descriptors. Native SDK operation implementations and host execution state +//! deliberately remain outside the wire-contract package. + +use alloc::{ + collections::BTreeSet, + format, + string::{String, ToString}, + vec::Vec, +}; +use core::fmt; + +use crate::{ + capability::v1::{Maturity, TransportKind}, + schema::{Descriptor as SchemaDescriptor, ModuleVersion, Registry}, +}; + +/// Schema generation shared by every runtime operation request and receipt. +pub const OPERATION_SCHEMA_VERSION: u16 = 1; + +macro_rules! operation_ids { + ($( $variant:ident => $value:literal ),+ $(,)?) => { + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub enum OperationId { + $( $variant, )+ + } + + impl OperationId { + pub fn as_str(self) -> &'static str { + match self { + $( Self::$variant => $value, )+ + } + } + + pub fn parse(value: &str) -> Result<Self, Error> { + match value { + $( $value => Ok(Self::$variant), )+ + _ => Err(Error::UnknownOperationId { + operation_id: value.to_string(), + }), + } + } + + pub fn request_schema_id(self) -> &'static str { + match self { + $( Self::$variant => concat!("radroots.runtime.", $value, ".request.v1"), )+ + } + } + + pub fn receipt_schema_id(self) -> &'static str { + match self { + $( Self::$variant => concat!("radroots.runtime.", $value, ".receipt.v1"), )+ + } + } + } + + #[cfg(feature = "serde")] + impl serde::Serialize for OperationId { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } + } + + #[cfg(feature = "serde")] + impl<'de> serde::Deserialize<'de> for OperationId { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = <String as serde::Deserialize>::deserialize(deserializer)?; + Self::parse(value.as_str()).map_err(serde::de::Error::custom) + } + } + }; +} + +operation_ids! { + ProfileInspect => "profile.inspect", + ProfileReset => "profile.reset", + AccountCreate => "account.create", + AccountImport => "account.import", + AccountSelect => "account.select", + AccountList => "account.list", + AccountRemove => "account.remove", + SignerStatus => "signer.status", + StoreInspect => "store.inspect", + StoreBackup => "store.backup", + StoreRestore => "store.restore", + FarmCreate => "farm.create", + FarmUpdate => "farm.update", + FarmPublish => "farm.publish", + FarmGet => "farm.get", + FarmList => "farm.list", + ListingCreate => "listing.create", + ListingUpdate => "listing.update", + ListingPublish => "listing.publish", + ListingPause => "listing.pause", + ListingWithdraw => "listing.withdraw", + ListingGet => "listing.get", + ListingList => "listing.list", + MarketPull => "market.pull", + MarketSearch => "market.search", + MarketGet => "market.get", + BasketCreate => "basket.create", + BasketGet => "basket.get", + BasketList => "basket.list", + BasketItemAdd => "basket.item.add", + BasketItemUpdate => "basket.item.update", + BasketItemRemove => "basket.item.remove", + BasketQuote => "basket.quote", + TradeProposalSubmit => "trade.proposal.submit", + TradeRevisionPropose => "trade.revision.propose", + TradeCandidateDecide => "trade.candidate.decide", + TradeCancellationSubmit => "trade.cancellation.submit", + TradeOperationResume => "trade.operation.resume", + TradeGet => "trade.get", + TradeList => "trade.list", + TradeEvidenceRefresh => "trade.evidence.refresh", + TradeEvidenceInspect => "trade.evidence.inspect", + TradePrivateArtifactSeal => "trade.private_artifact.seal", + TradePrivateArtifactOpen => "trade.private_artifact.open", + TradePrivateArtifactDelete => "trade.private_artifact.delete", + ValidationStatus => "validation.status", + SyncStatus => "sync.status", + SyncPull => "sync.pull", + SyncPush => "sync.push", + HealthInspect => "health.inspect", + TransportCapabilityList => "transport.capability.list", + TransportConfigInspect => "transport.config.inspect", + TransportConfigUpdate => "transport.config.update", + TransportStatusInspect => "transport.status.inspect", + TransportDeliveryInspect => "transport.delivery.inspect", + TransportDeliveryRetry => "transport.delivery.retry", + DiagnosticsInspect => "diagnostics.inspect", +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Mutability { + Read, + Mutation, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Risk { + Low, + Medium, + High, + Critical, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ApprovalRequirement { + None, + ConditionalOrRequiredByMode, + Required, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SignerRequirement { + None, + Required, + ConditionalRelayAuth, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IdempotencyPolicy { + Forbidden, + RequiredUuidV7, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DryRunSupport { + NotApplicable, + PureLocalPlan, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeadlinePolicy { + DefaultBounded, + OperationDeclared, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum PrivacyEffect { + None, + PublicEvent, + PrivateCoordination, + PrivateStore, + BackupRestore, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ProjectionEffect { + None, + ReadsProjection, + WritesProjection, + MayUpdateProjection, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct TransportRoute { + pub local: bool, + pub nostr: bool, + pub reticulum: bool, + pub deliver: bool, + pub fetch: bool, + pub synchronize: bool, + pub diagnostics: bool, +} + +impl TransportRoute { + pub const fn none() -> Self { + Self { + local: false, + nostr: false, + reticulum: false, + deliver: false, + fetch: false, + synchronize: false, + diagnostics: false, + } + } + + pub const fn local() -> Self { + Self { + local: true, + nostr: false, + reticulum: false, + deliver: false, + fetch: false, + synchronize: false, + diagnostics: false, + } + } + + pub const fn delivery() -> Self { + Self { + local: false, + nostr: true, + reticulum: true, + deliver: true, + fetch: false, + synchronize: false, + diagnostics: false, + } + } + + pub const fn fetch() -> Self { + Self { + local: false, + nostr: true, + reticulum: true, + deliver: false, + fetch: true, + synchronize: true, + diagnostics: false, + } + } + + pub const fn diagnostics() -> Self { + Self { + local: true, + nostr: true, + reticulum: true, + deliver: false, + fetch: false, + synchronize: false, + diagnostics: true, + } + } + + pub fn includes_transport(self, kind: TransportKind) -> bool { + match kind { + TransportKind::Local => self.local, + TransportKind::Nostr => self.nostr, + TransportKind::Reticulum => self.reticulum, + } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OperationDescriptor { + pub operation_id: OperationId, + pub schema_version: u16, + pub mutability: Mutability, + pub risk: Risk, + pub approval: ApprovalRequirement, + pub signer: SignerRequirement, + pub transport_capability: TransportRoute, + pub idempotency: IdempotencyPolicy, + pub dry_run: DryRunSupport, + pub deadline: DeadlinePolicy, + pub privacy: PrivacyEffect, + pub projection: ProjectionEffect, + pub maturity: Maturity, +} + +impl OperationDescriptor { + pub fn request_schema_id(self) -> &'static str { + self.operation_id.request_schema_id() + } + + pub fn receipt_schema_id(self) -> &'static str { + self.operation_id.receipt_schema_id() + } +} + +struct DescriptorSpec { + operation_id: OperationId, + mutability: Mutability, + risk: Risk, + approval: ApprovalRequirement, + signer: SignerRequirement, + transport_capability: TransportRoute, + idempotency: IdempotencyPolicy, + dry_run: DryRunSupport, + privacy: PrivacyEffect, + projection: ProjectionEffect, +} + +const fn read( + operation_id: OperationId, + risk: Risk, + transport_capability: TransportRoute, + privacy: PrivacyEffect, + projection: ProjectionEffect, +) -> OperationDescriptor { + descriptor(DescriptorSpec { + operation_id, + mutability: Mutability::Read, + risk, + approval: ApprovalRequirement::None, + signer: SignerRequirement::None, + transport_capability, + idempotency: IdempotencyPolicy::Forbidden, + dry_run: DryRunSupport::NotApplicable, + privacy, + projection, + }) +} + +const fn mutation( + operation_id: OperationId, + risk: Risk, + approval: ApprovalRequirement, + signer: SignerRequirement, + transport_capability: TransportRoute, + privacy: PrivacyEffect, + projection: ProjectionEffect, +) -> OperationDescriptor { + descriptor(DescriptorSpec { + operation_id, + mutability: Mutability::Mutation, + risk, + approval, + signer, + transport_capability, + idempotency: IdempotencyPolicy::RequiredUuidV7, + dry_run: DryRunSupport::PureLocalPlan, + privacy, + projection, + }) +} + +const fn descriptor(spec: DescriptorSpec) -> OperationDescriptor { + OperationDescriptor { + operation_id: spec.operation_id, + schema_version: OPERATION_SCHEMA_VERSION, + mutability: spec.mutability, + risk: spec.risk, + approval: spec.approval, + signer: spec.signer, + transport_capability: spec.transport_capability, + idempotency: spec.idempotency, + dry_run: spec.dry_run, + deadline: DeadlinePolicy::DefaultBounded, + privacy: spec.privacy, + projection: spec.projection, + maturity: Maturity::Stable, + } +} + +pub const CATALOG: &[OperationDescriptor] = &[ + read( + OperationId::ProfileInspect, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::ProfileReset, + Risk::Critical, + ApprovalRequirement::Required, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + mutation( + OperationId::AccountCreate, + Risk::High, + ApprovalRequirement::ConditionalOrRequiredByMode, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + mutation( + OperationId::AccountImport, + Risk::High, + ApprovalRequirement::ConditionalOrRequiredByMode, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + mutation( + OperationId::AccountSelect, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + read( + OperationId::AccountList, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::AccountRemove, + Risk::Critical, + ApprovalRequirement::Required, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + read( + OperationId::SignerStatus, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::None, + ProjectionEffect::None, + ), + read( + OperationId::StoreInspect, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::StoreBackup, + Risk::High, + ApprovalRequirement::ConditionalOrRequiredByMode, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::BackupRestore, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::StoreRestore, + Risk::Critical, + ApprovalRequirement::Required, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::BackupRestore, + ProjectionEffect::WritesProjection, + ), + mutation( + OperationId::FarmCreate, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + mutation( + OperationId::FarmUpdate, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + mutation( + OperationId::FarmPublish, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::Required, + TransportRoute::delivery(), + PrivacyEffect::PublicEvent, + ProjectionEffect::MayUpdateProjection, + ), + read( + OperationId::FarmGet, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::None, + ProjectionEffect::ReadsProjection, + ), + read( + OperationId::FarmList, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::None, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::ListingCreate, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + mutation( + OperationId::ListingUpdate, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + mutation( + OperationId::ListingPublish, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::Required, + TransportRoute::delivery(), + PrivacyEffect::PublicEvent, + ProjectionEffect::MayUpdateProjection, + ), + mutation( + OperationId::ListingPause, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::Required, + TransportRoute::delivery(), + PrivacyEffect::PublicEvent, + ProjectionEffect::MayUpdateProjection, + ), + mutation( + OperationId::ListingWithdraw, + Risk::High, + ApprovalRequirement::ConditionalOrRequiredByMode, + SignerRequirement::Required, + TransportRoute::delivery(), + PrivacyEffect::PublicEvent, + ProjectionEffect::MayUpdateProjection, + ), + read( + OperationId::ListingGet, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::None, + ProjectionEffect::ReadsProjection, + ), + read( + OperationId::ListingList, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::None, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::MarketPull, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::ConditionalRelayAuth, + TransportRoute::fetch(), + PrivacyEffect::None, + ProjectionEffect::MayUpdateProjection, + ), + read( + OperationId::MarketSearch, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::None, + ProjectionEffect::ReadsProjection, + ), + read( + OperationId::MarketGet, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::None, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::BasketCreate, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + read( + OperationId::BasketGet, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::ReadsProjection, + ), + read( + OperationId::BasketList, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::BasketItemAdd, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + mutation( + OperationId::BasketItemUpdate, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + mutation( + OperationId::BasketItemRemove, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + mutation( + OperationId::BasketQuote, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + mutation( + OperationId::TradeProposalSubmit, + Risk::High, + ApprovalRequirement::ConditionalOrRequiredByMode, + SignerRequirement::Required, + TransportRoute::delivery(), + PrivacyEffect::PrivateCoordination, + ProjectionEffect::MayUpdateProjection, + ), + mutation( + OperationId::TradeRevisionPropose, + Risk::High, + ApprovalRequirement::ConditionalOrRequiredByMode, + SignerRequirement::Required, + TransportRoute::delivery(), + PrivacyEffect::PrivateCoordination, + ProjectionEffect::MayUpdateProjection, + ), + mutation( + OperationId::TradeCandidateDecide, + Risk::High, + ApprovalRequirement::ConditionalOrRequiredByMode, + SignerRequirement::Required, + TransportRoute::delivery(), + PrivacyEffect::PrivateCoordination, + ProjectionEffect::MayUpdateProjection, + ), + mutation( + OperationId::TradeCancellationSubmit, + Risk::High, + ApprovalRequirement::ConditionalOrRequiredByMode, + SignerRequirement::Required, + TransportRoute::delivery(), + PrivacyEffect::PrivateCoordination, + ProjectionEffect::MayUpdateProjection, + ), + mutation( + OperationId::TradeOperationResume, + Risk::High, + ApprovalRequirement::ConditionalOrRequiredByMode, + SignerRequirement::Required, + TransportRoute::delivery(), + PrivacyEffect::PrivateCoordination, + ProjectionEffect::MayUpdateProjection, + ), + read( + OperationId::TradeGet, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::PrivateCoordination, + ProjectionEffect::ReadsProjection, + ), + read( + OperationId::TradeList, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::PrivateCoordination, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::TradeEvidenceRefresh, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateCoordination, + ProjectionEffect::WritesProjection, + ), + read( + OperationId::TradeEvidenceInspect, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::PrivateCoordination, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::TradePrivateArtifactSeal, + Risk::High, + ApprovalRequirement::ConditionalOrRequiredByMode, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateCoordination, + ProjectionEffect::WritesProjection, + ), + read( + OperationId::TradePrivateArtifactOpen, + Risk::High, + TransportRoute::local(), + PrivacyEffect::PrivateCoordination, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::TradePrivateArtifactDelete, + Risk::Critical, + ApprovalRequirement::Required, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateCoordination, + ProjectionEffect::WritesProjection, + ), + read( + OperationId::ValidationStatus, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::None, + ProjectionEffect::ReadsProjection, + ), + read( + OperationId::SyncStatus, + Risk::Low, + TransportRoute::diagnostics(), + PrivacyEffect::None, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::SyncPull, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::ConditionalRelayAuth, + TransportRoute::fetch(), + PrivacyEffect::None, + ProjectionEffect::MayUpdateProjection, + ), + mutation( + OperationId::SyncPush, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::ConditionalRelayAuth, + TransportRoute::delivery(), + PrivacyEffect::PublicEvent, + ProjectionEffect::MayUpdateProjection, + ), + read( + OperationId::HealthInspect, + Risk::Low, + TransportRoute::diagnostics(), + PrivacyEffect::None, + ProjectionEffect::None, + ), + read( + OperationId::TransportCapabilityList, + Risk::Low, + TransportRoute::diagnostics(), + PrivacyEffect::None, + ProjectionEffect::None, + ), + read( + OperationId::TransportConfigInspect, + Risk::Low, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::TransportConfigUpdate, + Risk::High, + ApprovalRequirement::ConditionalOrRequiredByMode, + SignerRequirement::None, + TransportRoute::local(), + PrivacyEffect::PrivateStore, + ProjectionEffect::WritesProjection, + ), + read( + OperationId::TransportStatusInspect, + Risk::Low, + TransportRoute::diagnostics(), + PrivacyEffect::None, + ProjectionEffect::ReadsProjection, + ), + read( + OperationId::TransportDeliveryInspect, + Risk::Low, + TransportRoute::diagnostics(), + PrivacyEffect::None, + ProjectionEffect::ReadsProjection, + ), + mutation( + OperationId::TransportDeliveryRetry, + Risk::Medium, + ApprovalRequirement::None, + SignerRequirement::ConditionalRelayAuth, + TransportRoute::delivery(), + PrivacyEffect::PublicEvent, + ProjectionEffect::MayUpdateProjection, + ), + read( + OperationId::DiagnosticsInspect, + Risk::Low, + TransportRoute::diagnostics(), + PrivacyEffect::None, + ProjectionEffect::None, + ), +]; + +/// Returns the descriptor for an exact operation identity. +pub fn operation_descriptor(operation_id: OperationId) -> Result<OperationDescriptor, Error> { + CATALOG + .iter() + .copied() + .find(|descriptor| descriptor.operation_id == operation_id) + .ok_or_else(|| Error::UnknownOperationId { + operation_id: operation_id.as_str().to_string(), + }) +} + +/// Validates uniqueness, schema generation, and descriptor policy invariants. +pub fn validate_catalog(descriptors: &[OperationDescriptor]) -> Result<(), Error> { + let mut operation_ids = BTreeSet::new(); + for descriptor in descriptors { + if !operation_ids.insert(descriptor.operation_id) { + return Err(Error::DuplicateOperationId { + operation_id: descriptor.operation_id, + }); + } + match (descriptor.mutability, descriptor.idempotency) { + (Mutability::Read, IdempotencyPolicy::Forbidden) + | (Mutability::Mutation, IdempotencyPolicy::RequiredUuidV7) => {} + _ => { + return Err(Error::CatalogInvalid { + message: format!( + "operation {} has invalid idempotency policy", + descriptor.operation_id.as_str() + ), + }); + } + } + if descriptor.schema_version != OPERATION_SCHEMA_VERSION { + return Err(Error::UnsupportedOperationSchemaVersion { + operation_id: descriptor.operation_id, + version: descriptor.schema_version, + }); + } + } + + for required in [ + OperationId::TransportCapabilityList, + OperationId::TransportConfigInspect, + OperationId::TransportConfigUpdate, + OperationId::TransportStatusInspect, + OperationId::TransportDeliveryInspect, + OperationId::TransportDeliveryRetry, + OperationId::SyncStatus, + OperationId::SyncPull, + OperationId::SyncPush, + OperationId::DiagnosticsInspect, + ] { + if !operation_ids.contains(&required) { + return Err(Error::MissingRequiredOperation { + operation_id: required, + }); + } + } + + for delivery in [ + OperationId::FarmPublish, + OperationId::ListingPublish, + OperationId::ListingPause, + OperationId::ListingWithdraw, + OperationId::TradeProposalSubmit, + OperationId::TradeRevisionPropose, + OperationId::TradeCandidateDecide, + OperationId::TradeCancellationSubmit, + OperationId::TradeOperationResume, + OperationId::SyncPush, + OperationId::TransportDeliveryRetry, + ] { + let descriptor = descriptors + .iter() + .find(|descriptor| descriptor.operation_id == delivery) + .ok_or(Error::MissingRequiredOperation { + operation_id: delivery, + })?; + if !descriptor.transport_capability.deliver + || !descriptor + .transport_capability + .includes_transport(TransportKind::Nostr) + || !descriptor + .transport_capability + .includes_transport(TransportKind::Reticulum) + { + return Err(Error::CatalogInvalid { + message: format!( + "operation {} must use Nostr and Reticulum delivery capability", + descriptor.operation_id.as_str() + ), + }); + } + } + Ok(()) +} + +/// Builds the registry for every exact request and receipt schema identity. +pub fn schema_registry() -> Result<Registry, crate::schema::Error> { + let mut schemas = Vec::with_capacity(CATALOG.len() * 2); + for descriptor in CATALOG { + schemas.push(SchemaDescriptor::try_new( + descriptor.request_schema_id(), + ModuleVersion::RuntimeV1, + )?); + schemas.push(SchemaDescriptor::try_new( + descriptor.receipt_schema_id(), + ModuleVersion::RuntimeV1, + )?); + } + Registry::try_new(schemas) +} + +/// Runtime operation catalog validation failure. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Error { + /// An operation identity appears more than once. + DuplicateOperationId { + /// Duplicated operation identity. + operation_id: OperationId, + }, + /// A required operation is missing from a candidate catalog. + MissingRequiredOperation { + /// Missing operation identity. + operation_id: OperationId, + }, + /// An operation identity is unknown. + UnknownOperationId { + /// Rejected identity. + operation_id: String, + }, + /// An operation declares a non-V1 schema generation. + UnsupportedOperationSchemaVersion { + /// Operation with the incompatible version. + operation_id: OperationId, + /// Rejected version. + version: u16, + }, + /// A descriptor invariant is violated. + CatalogInvalid { + /// Secret-safe validation diagnostic. + message: String, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateOperationId { operation_id } => { + write!( + formatter, + "duplicate operation id {}", + operation_id.as_str() + ) + } + Self::MissingRequiredOperation { operation_id } => { + write!( + formatter, + "missing required operation {}", + operation_id.as_str() + ) + } + Self::UnknownOperationId { operation_id } => { + write!(formatter, "unknown operation id {operation_id}") + } + Self::UnsupportedOperationSchemaVersion { + operation_id, + version, + } => write!( + formatter, + "unsupported operation schema version {version} for {}", + operation_id.as_str() + ), + Self::CatalogInvalid { message } => formatter.write_str(message), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_is_unique_and_preserves_every_v1_operation() { + validate_catalog(CATALOG).expect("runtime operation catalog"); + assert_eq!(CATALOG.len(), 57); + + let identities = CATALOG + .iter() + .map(|descriptor| descriptor.operation_id.as_str()) + .collect::<BTreeSet<_>>(); + assert_eq!(identities.len(), CATALOG.len()); + for descriptor in CATALOG { + assert_eq!( + OperationId::parse(descriptor.operation_id.as_str()), + Ok(descriptor.operation_id) + ); + } + } + + #[test] + fn parser_is_exact_and_rejects_retired_preview_names() { + assert_eq!( + OperationId::parse("trade.proposal.submit"), + Ok(OperationId::TradeProposalSubmit) + ); + assert_eq!( + OperationId::parse("runtime.unknown") + .expect_err("unknown") + .to_string(), + "unknown operation id runtime.unknown" + ); + for value in [ + ["sync.try_reticulum", "_preview_now"].concat(), + ["transport.reticulum", "_preview.status"].concat(), + ["transport.", "hybrid", ".publish"].concat(), + ["radrootsd.", "proxy", ".publish"].concat(), + ] { + assert!(OperationId::parse(value.as_str()).is_err()); + } + } + + #[test] + fn schema_ids_and_registry_preserve_v1_vectors() { + let profile = operation_descriptor(OperationId::ProfileInspect).expect("profile"); + assert_eq!( + profile.request_schema_id(), + "radroots.runtime.profile.inspect.request.v1" + ); + assert_eq!( + profile.receipt_schema_id(), + "radroots.runtime.profile.inspect.receipt.v1" + ); + + let registry = schema_registry().expect("runtime schema registry"); + assert_eq!(registry.len(), CATALOG.len() * 2); + assert!( + registry + .descriptors() + .iter() + .all(|descriptor| descriptor.module() == ModuleVersion::RuntimeV1) + ); + } + + #[test] + fn validation_rejects_duplicates_and_policy_drift() { + assert_eq!( + validate_catalog(&[CATALOG[0], CATALOG[0]]), + Err(Error::DuplicateOperationId { + operation_id: OperationId::ProfileInspect, + }) + ); + + let mut invalid = CATALOG.to_vec(); + invalid[0].idempotency = IdempotencyPolicy::RequiredUuidV7; + assert_eq!( + validate_catalog(invalid.as_slice()), + Err(Error::CatalogInvalid { + message: "operation profile.inspect has invalid idempotency policy".into(), + }) + ); + } + + #[cfg(feature = "serde")] + #[test] + fn serialized_descriptor_vector_is_exact() { + let descriptor = operation_descriptor(OperationId::ProfileInspect).expect("profile"); + assert_eq!( + serde_json::to_value(descriptor).expect("descriptor JSON"), + serde_json::json!({ + "operation_id": "profile.inspect", + "schema_version": 1, + "mutability": "read", + "risk": "low", + "approval": "none", + "signer": "none", + "transport_capability": { + "local": true, + "nostr": false, + "reticulum": false, + "deliver": false, + "fetch": false, + "synchronize": false, + "diagnostics": false + }, + "idempotency": "forbidden", + "dry_run": "not_applicable", + "deadline": "default_bounded", + "privacy": "private_store", + "projection": "reads_projection", + "maturity": "stable" + }) + ); + assert_eq!( + serde_json::to_string(&OperationId::ProfileInspect).expect("operation JSON"), + "\"profile.inspect\"" + ); + } +} diff --git a/crates/protocol/src/schema.rs b/crates/protocol/src/schema.rs @@ -0,0 +1,551 @@ +//! Validated schema identities and deterministic module dispatch. + +use alloc::{string::String, vec::Vec}; +use core::{fmt, str::FromStr}; + +/// Maximum UTF-8 byte length accepted for a schema identifier. +pub const MAX_SCHEMA_ID_BYTES: usize = 255; + +/// Passive metadata that preserves an externally governed schema identity. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Metadata { + /// Historical generated type name bound by the schema contract. + pub type_name: &'static str, + /// Canonical schema identifier. + pub schema_id: &'static str, + /// Declared schema generation. + pub schema_version: u16, +} + +/// A canonical, version-suffixed schema identifier. +/// +/// Schema identifiers contain one or more dot-separated namespace segments +/// followed by a positive canonical version segment such as `v1`. Namespace +/// segments begin with a lowercase ASCII letter and contain only lowercase +/// ASCII letters, digits, and underscores. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct SchemaId { + value: String, + version: u16, +} + +impl SchemaId { + /// Parses and validates a schema identifier. + pub fn parse(value: impl Into<String>) -> Result<Self, Error> { + let value = value.into(); + if value.is_empty() { + return Err(Error::EmptySchemaId); + } + if value.len() > MAX_SCHEMA_ID_BYTES { + return Err(Error::SchemaIdTooLong { + actual: value.len(), + max: MAX_SCHEMA_ID_BYTES, + }); + } + + let (namespace, version_segment) = value + .rsplit_once('.') + .ok_or(Error::MissingSchemaNamespace)?; + if namespace.is_empty() { + return Err(Error::MissingSchemaNamespace); + } + for (index, segment) in namespace.split('.').enumerate() { + if !valid_namespace_segment(segment) { + return Err(Error::InvalidSchemaNamespaceSegment { index }); + } + } + + let version = parse_version_segment(version_segment)?; + Ok(Self { value, version }) + } + + /// Returns the canonical identifier text. + pub fn as_str(&self) -> &str { + self.value.as_str() + } + + /// Returns the positive schema generation encoded by the final segment. + pub const fn version(&self) -> u16 { + self.version + } +} + +impl AsRef<str> for SchemaId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for SchemaId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for SchemaId { + type Err = Error; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +impl TryFrom<&str> for SchemaId { + type Error = Error; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + Self::parse(value) + } +} + +impl TryFrom<String> for SchemaId { + type Error = Error; + + fn try_from(value: String) -> Result<Self, Self::Error> { + Self::parse(value) + } +} + +/// A supported versioned module in the protocol package. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] +pub enum ModuleVersion { + /// `capability::v1`. + CapabilityV1, + /// `error::v1`. + ErrorV1, + /// `event::v1`. + EventV1, + /// `runtime::v1`. + RuntimeV1, + /// `radrootsd::transport_publish::v5`. + RadrootsdTransportPublishV5, +} + +impl ModuleVersion { + /// Every module generation supported by this package version. + pub const ALL: [Self; 5] = [ + Self::CapabilityV1, + Self::ErrorV1, + Self::EventV1, + Self::RuntimeV1, + Self::RadrootsdTransportPublishV5, + ]; + + /// Returns the stable Rust module path relative to `radroots_protocol`. + pub const fn path(self) -> &'static str { + match self { + Self::CapabilityV1 => "capability::v1", + Self::ErrorV1 => "error::v1", + Self::EventV1 => "event::v1", + Self::RuntimeV1 => "runtime::v1", + Self::RadrootsdTransportPublishV5 => "radrootsd::transport_publish::v5", + } + } + + /// Returns the explicit contract generation for the module. + pub const fn generation(self) -> u16 { + match self { + Self::CapabilityV1 | Self::ErrorV1 | Self::EventV1 | Self::RuntimeV1 => 1, + Self::RadrootsdTransportPublishV5 => 5, + } + } +} + +/// One validated schema-to-module registration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Descriptor { + id: SchemaId, + module: ModuleVersion, +} + +impl Descriptor { + /// Creates a registration after validating its schema identifier. + pub fn try_new(id: impl Into<String>, module: ModuleVersion) -> Result<Self, Error> { + Ok(Self { + id: SchemaId::parse(id)?, + module, + }) + } + + /// Returns the schema identifier. + pub const fn id(&self) -> &SchemaId { + &self.id + } + + /// Returns the module generation that owns the schema. + pub const fn module(&self) -> ModuleVersion { + self.module + } +} + +/// A canonical registry of unique schema identifiers. +/// +/// Construction sorts descriptors by schema identifier so iteration and +/// lookup remain deterministic regardless of input order. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Registry { + descriptors: Vec<Descriptor>, +} + +impl Registry { + /// Builds a canonical registry and rejects duplicate schema identifiers. + pub fn try_new(descriptors: impl IntoIterator<Item = Descriptor>) -> Result<Self, Error> { + let mut descriptors: Vec<_> = descriptors.into_iter().collect(); + descriptors.sort_by(|left, right| left.id.cmp(&right.id)); + + for adjacent in descriptors.windows(2) { + if adjacent[0].id == adjacent[1].id { + return Err(Error::DuplicateSchemaId { + schema_id: adjacent[0].id.as_str().into(), + }); + } + } + + Ok(Self { descriptors }) + } + + /// Builds a registry from governed schema metadata and module ownership. + pub fn try_from_metadata( + entries: impl IntoIterator<Item = (Metadata, ModuleVersion)>, + ) -> Result<Self, Error> { + let descriptors = entries + .into_iter() + .map(|(metadata, module)| { + let descriptor = Descriptor::try_new(metadata.schema_id, module)?; + let encoded = descriptor.id().version(); + if metadata.schema_version != encoded { + return Err(Error::SchemaVersionMismatch { + schema_id: metadata.schema_id.into(), + declared: metadata.schema_version, + encoded, + }); + } + Ok(descriptor) + }) + .collect::<Result<Vec<_>, _>>()?; + Self::try_new(descriptors) + } + + /// Returns the canonical descriptor sequence. + pub fn descriptors(&self) -> &[Descriptor] { + self.descriptors.as_slice() + } + + /// Returns the number of registered schemas. + pub fn len(&self) -> usize { + self.descriptors.len() + } + + /// Reports whether the registry contains no schemas. + pub fn is_empty(&self) -> bool { + self.descriptors.is_empty() + } + + /// Returns the descriptor for an exact schema identifier. + pub fn descriptor(&self, id: &SchemaId) -> Option<&Descriptor> { + self.descriptors + .binary_search_by(|descriptor| descriptor.id.cmp(id)) + .ok() + .map(|index| &self.descriptors[index]) + } + + /// Dispatches an exact schema identifier to its owning module generation. + pub fn module_for(&self, id: &SchemaId) -> Option<ModuleVersion> { + self.descriptor(id).map(Descriptor::module) + } +} + +/// Builds the complete protocol V1 schema registry currently owned here. +pub fn protocol_v1_registry() -> Result<Registry, Error> { + let capability = crate::capability::v1::SCHEMAS + .iter() + .copied() + .map(|metadata| (metadata, ModuleVersion::CapabilityV1)); + let event = crate::event::v1::SCHEMAS + .iter() + .copied() + .map(|metadata| (metadata, ModuleVersion::EventV1)); + let error = crate::error::v1::SCHEMAS + .iter() + .copied() + .map(|metadata| (metadata, ModuleVersion::ErrorV1)); + let mut descriptors = Registry::try_from_metadata(capability.chain(event).chain(error))? + .descriptors() + .to_vec(); + descriptors.extend( + crate::runtime::v1::schema_registry()? + .descriptors() + .iter() + .cloned(), + ); + descriptors.extend( + crate::radrootsd::transport_publish::v5::schema_registry()? + .descriptors() + .iter() + .cloned(), + ); + Registry::try_new(descriptors) +} + +/// Schema identity or registry validation failure. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Error { + /// The schema identifier is empty. + EmptySchemaId, + /// The schema identifier exceeds the byte-length limit. + SchemaIdTooLong { + /// Actual UTF-8 byte length. + actual: usize, + /// Maximum accepted UTF-8 byte length. + max: usize, + }, + /// The schema identifier does not include a namespace before its version. + MissingSchemaNamespace, + /// A namespace segment is empty or noncanonical. + InvalidSchemaNamespaceSegment { + /// Zero-based namespace segment index. + index: usize, + }, + /// The final segment is not a canonical positive `vN` generation. + InvalidSchemaVersion, + /// Metadata declares a generation different from the schema ID suffix. + SchemaVersionMismatch { + /// Canonical schema identifier. + schema_id: String, + /// Generation stored in metadata. + declared: u16, + /// Generation encoded in the schema ID. + encoded: u16, + }, + /// The registry contains an identifier more than once. + DuplicateSchemaId { + /// Duplicated canonical schema identifier. + schema_id: String, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptySchemaId => formatter.write_str("schema id must not be empty"), + Self::SchemaIdTooLong { actual, max } => { + write!(formatter, "schema id length {actual} exceeds {max} bytes") + } + Self::MissingSchemaNamespace => { + formatter.write_str("schema id must contain a namespace and version") + } + Self::InvalidSchemaNamespaceSegment { index } => { + write!(formatter, "schema id namespace segment {index} is invalid") + } + Self::InvalidSchemaVersion => { + formatter.write_str("schema id version must be canonical positive vN") + } + Self::SchemaVersionMismatch { + schema_id, + declared, + encoded, + } => write!( + formatter, + "schema id {schema_id} encodes v{encoded} but metadata declares v{declared}" + ), + Self::DuplicateSchemaId { schema_id } => { + write!(formatter, "duplicate schema id {schema_id}") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +fn valid_namespace_segment(segment: &str) -> bool { + let mut bytes = segment.bytes(); + matches!(bytes.next(), Some(b'a'..=b'z')) + && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +fn parse_version_segment(segment: &str) -> Result<u16, Error> { + let Some(digits) = segment.strip_prefix('v') else { + return Err(Error::InvalidSchemaVersion); + }; + if digits.is_empty() + || digits.starts_with('0') + || !digits.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(Error::InvalidSchemaVersion); + } + digits + .parse::<u16>() + .ok() + .filter(|version| *version > 0) + .ok_or(Error::InvalidSchemaVersion) +} + +#[cfg(test)] +mod tests { + use alloc::{string::ToString, vec}; + use std::collections::BTreeSet; + + use super::*; + + #[test] + fn schema_id_parsing_accepts_existing_contract_shape() { + let id = SchemaId::parse("radroots.protocol.transport_kind.v1").expect("schema id"); + assert_eq!(id.as_str(), "radroots.protocol.transport_kind.v1"); + assert_eq!(id.version(), 1); + assert_eq!(id.to_string(), id.as_str()); + assert_eq!(id, id.as_str().parse().expect("FromStr schema id")); + } + + #[test] + fn schema_id_parsing_rejects_every_noncanonical_shape() { + let too_long = alloc::format!("{}.v1", "a".repeat(MAX_SCHEMA_ID_BYTES)); + for (value, expected) in [ + ("", Error::EmptySchemaId), + ("v1", Error::MissingSchemaNamespace), + (".v1", Error::MissingSchemaNamespace), + ( + "radroots..event.v1", + Error::InvalidSchemaNamespaceSegment { index: 1 }, + ), + ( + "Radroots.protocol.event.v1", + Error::InvalidSchemaNamespaceSegment { index: 0 }, + ), + ( + "radroots.protocol.event-name.v1", + Error::InvalidSchemaNamespaceSegment { index: 2 }, + ), + ("radroots.protocol.event", Error::InvalidSchemaVersion), + ("radroots.protocol.event.v0", Error::InvalidSchemaVersion), + ("radroots.protocol.event.v01", Error::InvalidSchemaVersion), + ( + "radroots.protocol.event.v65536", + Error::InvalidSchemaVersion, + ), + ] { + assert_eq!(SchemaId::parse(value), Err(expected), "{value}"); + } + assert_eq!( + SchemaId::parse(too_long), + Err(Error::SchemaIdTooLong { + actual: MAX_SCHEMA_ID_BYTES + 3, + max: MAX_SCHEMA_ID_BYTES, + }) + ); + } + + #[test] + fn module_inventory_has_unique_paths_and_explicit_generations() { + let paths = ModuleVersion::ALL + .into_iter() + .map(ModuleVersion::path) + .collect::<BTreeSet<_>>(); + assert_eq!(paths.len(), ModuleVersion::ALL.len()); + assert_eq!(ModuleVersion::CapabilityV1.generation(), 1); + assert_eq!(ModuleVersion::ErrorV1.generation(), 1); + assert_eq!(ModuleVersion::EventV1.generation(), 1); + assert_eq!(ModuleVersion::RuntimeV1.generation(), 1); + assert_eq!(ModuleVersion::RadrootsdTransportPublishV5.generation(), 5); + } + + #[test] + fn registry_is_canonical_unique_and_dispatches_exact_ids() { + let event = Descriptor::try_new( + "radroots.protocol.event_descriptor.v1", + ModuleVersion::EventV1, + ) + .expect("event descriptor"); + let capability = Descriptor::try_new( + "radroots.protocol.transport_kind.v1", + ModuleVersion::CapabilityV1, + ) + .expect("capability descriptor"); + let registry = Registry::try_new(vec![event, capability.clone()]).expect("registry"); + + assert_eq!(registry.len(), 2); + assert!(!registry.is_empty()); + assert_eq!( + registry + .descriptors() + .iter() + .map(|descriptor| descriptor.id().as_str()) + .collect::<Vec<_>>(), + vec![ + "radroots.protocol.event_descriptor.v1", + "radroots.protocol.transport_kind.v1", + ] + ); + assert_eq!( + registry.module_for(capability.id()), + Some(ModuleVersion::CapabilityV1) + ); + let unknown = SchemaId::parse("radroots.protocol.unknown.v1").expect("unknown id"); + assert_eq!(registry.module_for(&unknown), None); + } + + #[test] + fn registry_rejects_duplicate_schema_ids() { + let first = Descriptor::try_new( + "radroots.protocol.event_descriptor.v1", + ModuleVersion::EventV1, + ) + .expect("first descriptor"); + let second = Descriptor::try_new( + "radroots.protocol.event_descriptor.v1", + ModuleVersion::CapabilityV1, + ) + .expect("second descriptor"); + assert_eq!( + Registry::try_new(vec![first, second]), + Err(Error::DuplicateSchemaId { + schema_id: "radroots.protocol.event_descriptor.v1".into(), + }) + ); + } + + #[test] + fn metadata_registry_rejects_version_mismatch() { + let metadata = Metadata { + type_name: "EventDescriptor", + schema_id: "radroots.protocol.event_descriptor.v1", + schema_version: 2, + }; + assert_eq!( + Registry::try_from_metadata([(metadata, ModuleVersion::EventV1)]), + Err(Error::SchemaVersionMismatch { + schema_id: metadata.schema_id.into(), + declared: 2, + encoded: 1, + }) + ); + } + + #[test] + fn protocol_v1_registry_dispatches_all_migrated_schemas() { + let registry = protocol_v1_registry().expect("protocol V1 registry"); + assert_eq!(registry.len(), 7 + crate::runtime::v1::CATALOG.len() * 2); + for descriptor in registry.descriptors() { + let expected = if descriptor.id().as_str() + == crate::radrootsd::transport_publish::v5::API_VERSION + { + ModuleVersion::RadrootsdTransportPublishV5 + } else if descriptor.id().as_str() == crate::error::v1::SCHEMA_ID { + ModuleVersion::ErrorV1 + } else if descriptor.id().as_str().starts_with("radroots.runtime.") { + ModuleVersion::RuntimeV1 + } else if descriptor.id().as_str().contains("event_descriptor") + || descriptor.id().as_str().contains("trade_state") + { + ModuleVersion::EventV1 + } else { + ModuleVersion::CapabilityV1 + }; + assert_eq!(registry.module_for(descriptor.id()), Some(expected)); + } + } +} diff --git a/crates/protocol/tests/conformance.rs b/crates/protocol/tests/conformance.rs @@ -0,0 +1,130 @@ +#![cfg(feature = "serde")] + +use std::collections::BTreeSet; + +use radroots_protocol::{ + capability::v1::{ReticulumTarget, TransportKind}, + error::v1::ErrorReport, + event::v1::{EventClass, TradeState}, + radrootsd::transport_publish::v5::{DeliveryPolicy, TargetPolicy}, + runtime::v1::{OperationId, Risk, TransportRoute}, + schema::{ModuleVersion, protocol_v1_registry}, +}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; + +const WIRE_VECTORS: &str = + include_str!("../../../contracts/conformance/vectors/protocol/wire_values.v1.json"); +const GENERATED_INVENTORY: &str = + include_str!("../../../contracts/codegen/protocol_v1.inventory.json"); + +#[test] +fn language_neutral_wire_vectors_are_unique_and_executable() { + let document: Value = serde_json::from_str(WIRE_VECTORS).expect("protocol wire vectors"); + assert_eq!(document["suite"], "protocol_wire_values_v1"); + assert_eq!(document["contract_version"], "1.0.0"); + let vectors = document["vectors"].as_array().expect("vector array"); + let mut ids = BTreeSet::new(); + for vector in vectors { + let id = vector["id"].as_str().expect("vector id"); + assert!(ids.insert(id), "duplicate protocol vector id `{id}`"); + let kind = vector["kind"].as_str().expect("vector kind"); + let input = vector["input"].clone(); + let result = match kind { + "protocol.capability.transport_kind" => execute::<TransportKind>(input), + "protocol.capability.reticulum_target" => execute::<ReticulumTarget>(input), + "protocol.event.event_class" => execute::<EventClass>(input), + "protocol.event.trade_state" => execute::<TradeState>(input), + "protocol.runtime.operation_id" => execute::<OperationId>(input), + "protocol.runtime.risk" => execute::<Risk>(input), + "protocol.runtime.transport_route" => execute::<TransportRoute>(input), + "protocol.radrootsd.target_policy" => execute::<TargetPolicy>(input), + "protocol.radrootsd.delivery_policy" => execute::<DeliveryPolicy>(input), + "protocol.error.report" => execute::<ErrorReport>(input), + other => panic!("unimplemented protocol vector kind `{other}`"), + }; + match ( + vector.get("expected"), + vector.get("expected_error_contains"), + result, + ) { + (Some(expected), None, Ok(actual)) => assert_eq!(&actual, expected, "vector `{id}`"), + (None, Some(expected), Err(error)) => assert!( + error.contains(expected.as_str().expect("error fragment")), + "vector `{id}` expected `{expected}`, found `{error}`" + ), + (Some(_), None, Err(error)) => panic!("vector `{id}` unexpectedly failed: {error}"), + (None, Some(_), Ok(actual)) => { + panic!("vector `{id}` unexpectedly succeeded: {actual}") + } + _ => panic!("vector `{id}` has an invalid expectation shape"), + } + } + assert_eq!(ids.len(), 13); +} + +#[test] +fn generated_inventory_is_complete_unique_and_matches_the_schema_registry() { + let inventory: Value = + serde_json::from_str(GENERATED_INVENTORY).expect("generated protocol inventory"); + assert_eq!(inventory["schema_version"], 1); + assert_eq!(inventory["package"], "radroots_protocol"); + + let sources = inventory["sources"].as_array().expect("sources"); + let actual_modules = sources + .iter() + .map(|source| source["module"].as_str().expect("source module")) + .collect::<BTreeSet<_>>(); + let expected_modules = ModuleVersion::ALL + .iter() + .map(|module| module.path()) + .collect::<BTreeSet<_>>(); + assert_eq!(actual_modules, expected_modules); + + let type_paths = sources + .iter() + .flat_map(|source| source["types"].as_array().expect("source types")) + .map(|item| item["rust_path"].as_str().expect("Rust path")) + .collect::<Vec<_>>(); + assert_eq!(type_paths.len(), 54); + assert_eq!( + type_paths.iter().copied().collect::<BTreeSet<_>>().len(), + 54 + ); + assert!(type_paths.contains(&"radroots_protocol::runtime::v1::OperationId")); + + let inventory_schemas = inventory["schemas"] + .as_array() + .expect("schemas") + .iter() + .map(|schema| { + ( + schema["schema_id"].as_str().expect("schema id").to_owned(), + schema["module"].as_str().expect("schema module").to_owned(), + schema["generation"].as_u64().expect("generation") as u16, + ) + }) + .collect::<BTreeSet<_>>(); + let registry_schemas = protocol_v1_registry() + .expect("protocol registry") + .descriptors() + .iter() + .map(|descriptor| { + ( + descriptor.id().as_str().to_owned(), + descriptor.module().path().to_owned(), + descriptor.module().generation(), + ) + }) + .collect::<BTreeSet<_>>(); + assert_eq!(inventory_schemas.len(), 121); + assert_eq!(inventory_schemas, registry_schemas); +} + +fn execute<T>(input: Value) -> Result<Value, String> +where + T: DeserializeOwned + Serialize, +{ + let decoded: T = serde_json::from_value(input).map_err(|error| error.to_string())?; + serde_json::to_value(decoded).map_err(|error| error.to_string()) +} diff --git a/crates/protocol/tests/package_boundary.rs b/crates/protocol/tests/package_boundary.rs @@ -0,0 +1,125 @@ +use std::collections::BTreeSet; + +const MANIFEST: &str = include_str!("../Cargo.toml"); +const ROOT: &str = include_str!("../src/lib.rs"); +const CAPABILITY: &str = include_str!("../src/capability.rs"); +const ERROR: &str = include_str!("../src/error.rs"); +const EVENT: &str = include_str!("../src/event.rs"); +const RADROOTSD: &str = include_str!("../src/radrootsd.rs"); +const RUNTIME: &str = include_str!("../src/runtime.rs"); +const VERSIONED_SOURCES: &[&str] = &[ + include_str!("../src/capability/v1.rs"), + include_str!("../src/error/v1.rs"), + include_str!("../src/event/v1.rs"), + include_str!("../src/radrootsd/transport_publish/v5.rs"), + include_str!("../src/runtime/v1.rs"), + include_str!("../src/schema.rs"), +]; + +#[test] +fn manifest_has_final_identity_features_and_no_radroots_dependencies() { + assert!(MANIFEST.contains("name = \"radroots_protocol\"")); + assert!(MANIFEST.contains("version = \"0.1.0-alpha\"")); + assert!(MANIFEST.contains("publish = false")); + assert!(MANIFEST.contains("[lib]\nname = \"radroots_protocol\"")); + assert!(MANIFEST.contains("default = [\"std\", \"serde\"]")); + assert_eq!( + table_keys(MANIFEST, "[features]"), + BTreeSet::from(["default", "serde", "std"]) + ); + assert_eq!( + table_keys(MANIFEST, "[dependencies]"), + BTreeSet::from(["serde"]) + ); + assert_eq!( + table_keys(MANIFEST, "[dev-dependencies]"), + BTreeSet::from(["serde_json"]) + ); +} + +#[test] +fn crate_root_exposes_only_the_approved_versioned_skeleton() { + assert!(ROOT.contains("#![cfg_attr(not(feature = \"std\"), no_std)]")); + assert_eq!( + root_declarations("pub mod "), + BTreeSet::from([ + "capability", + "error", + "event", + "radrootsd", + "runtime", + "schema" + ]) + ); + for source in [CAPABILITY, EVENT] { + assert!(source.lines().any(|line| line.trim() == "pub mod v1;")); + } + assert!(ERROR.lines().any(|line| line.trim() == "pub mod v1;")); + assert!(RUNTIME.lines().any(|line| line.trim() == "pub mod v1;")); + assert!(RADROOTSD.contains("pub mod transport_publish {")); + assert!(RADROOTSD.lines().any(|line| line.trim() == "pub mod v5;")); + assert!( + !ROOT + .lines() + .map(str::trim) + .any(|line| line.starts_with("pub use ")) + ); +} + +#[test] +fn public_contract_sources_exclude_runtime_dependencies_and_wrapper_traits() { + assert!(ROOT.contains("#![forbid(unsafe_code)]")); + for source in VERSIONED_SOURCES { + for forbidden in [ + "dto_bindgen", + "wasm_bindgen", + "uniffi", + "tokio::", + "sqlx::", + "reqwest::", + "nostr::", + "std::fs", + "std::net", + "std::process", + "impl core::ops::Deref", + "impl std::ops::Deref", + "unsafe fn", + "unsafe impl", + "unsafe {", + ] { + assert!( + !source.contains(forbidden), + "protocol contract source contains forbidden surface `{forbidden}`" + ); + } + } +} + +fn table_keys<'a>(manifest: &'a str, heading: &str) -> BTreeSet<&'a str> { + let Some((_, table)) = manifest.split_once(heading) else { + return BTreeSet::new(); + }; + table + .lines() + .skip(1) + .take_while(|line| !line.trim_start().starts_with('[')) + .filter_map(|line| { + let line = line.trim(); + (line + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte == b'_') + && !line.starts_with('#')) + .then(|| line.split_once('=').map(|(key, _)| key.trim())) + .flatten() + }) + .collect() +} + +fn root_declarations(prefix: &str) -> BTreeSet<&str> { + ROOT.lines() + .map(str::trim) + .filter_map(|line| line.strip_prefix(prefix)) + .filter_map(|name| name.strip_suffix(';')) + .collect() +} diff --git a/crates/protocol_contract_v1/Cargo.toml b/crates/protocol_contract_v1/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_protocol_contract_v1" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] @@ -14,13 +14,18 @@ readme = "README" [features] default = ["std", "serde"] -dto-bindgen = ["std", "serde", "dep:dto_bindgen"] serde = ["dep:serde"] std = [] [dependencies] -dto_bindgen = { workspace = true, optional = true } serde = { workspace = true, default-features = false, features = [ "alloc", "derive", ], optional = true } + +[dev-dependencies] +radroots_protocol = { workspace = true, default-features = false, features = [ + "serde", + "std", +] } +serde_json = { workspace = true, features = ["std"] } diff --git a/crates/protocol_contract_v1/README b/crates/protocol_contract_v1/README @@ -2,6 +2,15 @@ This is the README for `radroots_protocol_contract_v1`, which contains the source-owned Protocol Contract V1 descriptors for public Radroots event catalogs and transport capability vocabulary. +## Quarantine status + +This package is a retired, `publish = false` compatibility shim. New code must +use `radroots_protocol::capability::v1` and `radroots_protocol::event::v1`. +It remains only for the SDK runtime compatibility package and the separately +versioned CLI consumer discovered during release-v1 migration. Step 270 removes +the final CLI dependency and this package; it must not become a registry +identity or receive new behavior before then. + ## Copyright Except as otherwise noted, all files in the `radroots_protocol_contract_v1` distribution are diff --git a/crates/protocol_contract_v1/src/lib.rs b/crates/protocol_contract_v1/src/lib.rs @@ -9,18 +9,12 @@ use alloc::string::{String, ToString}; pub const PROTOCOL_CONTRACT_NAME_V1: &str = "radroots.protocol"; pub const PROTOCOL_CONTRACT_VERSION_V1: u16 = 1; -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[cfg_attr(feature = "dto-bindgen", dto(as = "string_enum"))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum TransportKindV1 { - #[cfg_attr(feature = "dto-bindgen", dto(rename = "local"))] Local, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "nostr"))] Nostr, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "reticulum"))] Reticulum, } @@ -45,36 +39,23 @@ impl TransportKindV1 { } } -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[cfg_attr(feature = "dto-bindgen", dto(as = "string_enum"))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum CapabilityMaturityV1 { - #[cfg_attr(feature = "dto-bindgen", dto(rename = "preview"))] Preview, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "stable"))] Stable, } -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[cfg_attr(feature = "dto-bindgen", dto(as = "string_enum"))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum CapabilityAvailabilityV1 { - #[cfg_attr(feature = "dto-bindgen", dto(rename = "available"))] Available, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "degraded"))] Degraded, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "unavailable"))] Unavailable, } -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -101,8 +82,6 @@ impl MeshScopeIdV1 { } } -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -129,8 +108,6 @@ impl ReticulumDestinationV1 { } } -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Debug, PartialEq, Eq)] @@ -139,8 +116,6 @@ pub struct ReticulumTargetV1 { pub mesh_scope: Option<MeshScopeIdV1>, } -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -192,20 +167,13 @@ pub const TRANSPORT_CAPABILITY_CATALOG_V1: &[TransportCapabilityDescriptorV1] = }, ]; -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[cfg_attr(feature = "dto-bindgen", dto(as = "string_enum"))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ProtocolEventClassV1 { - #[cfg_attr(feature = "dto-bindgen", dto(rename = "regular"))] Regular, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "replaceable"))] Replaceable, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "addressable"))] Addressable, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "unsigned_rumor"))] UnsignedRumor, } @@ -321,28 +289,17 @@ pub const RETIRED_PROTOCOL_EVENT_NAMES_V1: &[&str] = &[ "trade_transition_proof_result", ]; -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[cfg_attr(feature = "dto-bindgen", dto(as = "string_enum"))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ProtocolTradeStateV1 { - #[cfg_attr(feature = "dto-bindgen", dto(rename = "missing"))] Missing, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "requested"))] Requested, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "agreed_pending_validation"))] AgreedPendingValidation, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "committed"))] Committed, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "declined"))] Declined, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "cancelled"))] Cancelled, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "validation_expired"))] ValidationExpired, - #[cfg_attr(feature = "dto-bindgen", dto(rename = "invalid"))] Invalid, } @@ -559,22 +516,6 @@ fn validate_schema_metadata( Ok(()) } -#[cfg(feature = "dto-bindgen")] -pub fn dto_roots() -> alloc::vec::Vec<dto_bindgen::export::RootDescriptor> { - use dto_bindgen::export::RootDescriptor; - alloc::vec![ - RootDescriptor::new::<TransportKindV1>(), - RootDescriptor::new::<CapabilityMaturityV1>(), - RootDescriptor::new::<CapabilityAvailabilityV1>(), - RootDescriptor::new::<MeshScopeIdV1>(), - RootDescriptor::new::<ReticulumDestinationV1>(), - RootDescriptor::new::<ReticulumTargetV1>(), - RootDescriptor::new::<TransportCapabilityDescriptorV1>(), - RootDescriptor::new::<ProtocolEventClassV1>(), - RootDescriptor::new::<ProtocolTradeStateV1>(), - ] -} - #[cfg(test)] mod tests { use super::*; @@ -750,6 +691,18 @@ mod tests { .to_string(), alloc::format!("retired event name {name}") ); + let successor = radroots_protocol::event::v1::EventDescriptor { + name, + kind: u32::MAX, + event_class: radroots_protocol::event::v1::EventClass::Regular, + purpose: "retired", + }; + assert_eq!( + radroots_protocol::event::v1::validate_catalog(&[successor]) + .expect_err("successor retired event name") + .to_string(), + alloc::format!("retired event name {name}") + ); } for kind in RETIRED_PROTOCOL_EVENT_KINDS_V1 { @@ -765,6 +718,18 @@ mod tests { .to_string(), alloc::format!("retired event kind {kind}") ); + let successor = radroots_protocol::event::v1::EventDescriptor { + name: "synthetic_current_name", + kind: *kind, + event_class: radroots_protocol::event::v1::EventClass::Regular, + purpose: "retired", + }; + assert_eq!( + radroots_protocol::event::v1::validate_catalog(&[successor]) + .expect_err("successor retired event kind") + .to_string(), + alloc::format!("retired event kind {kind}") + ); } } diff --git a/crates/protocol_contract_v1/tests/successor_equivalence.rs b/crates/protocol_contract_v1/tests/successor_equivalence.rs @@ -0,0 +1,110 @@ +use std::collections::BTreeMap; + +use radroots_protocol::{ + capability::v1 as capability, + event::v1 as event, + schema::{ModuleVersion, SchemaId, protocol_v1_registry}, +}; +use radroots_protocol_contract_v1 as predecessor; + +#[test] +fn capability_and_event_catalog_json_is_byte_identical() { + assert_eq!( + serde_json::to_vec(predecessor::TRANSPORT_CAPABILITY_CATALOG_V1) + .expect("predecessor capability JSON"), + serde_json::to_vec(capability::CATALOG).expect("successor capability JSON") + ); + assert_eq!( + serde_json::to_vec(predecessor::PROTOCOL_EVENT_CATALOG_V1).expect("predecessor event JSON"), + serde_json::to_vec(event::CATALOG).expect("successor event JSON") + ); + assert_eq!( + serde_json::to_vec(predecessor::PROTOCOL_TRADE_STATE_VOCABULARY_V1) + .expect("predecessor trade state JSON"), + serde_json::to_vec(event::TRADE_STATE_VOCABULARY).expect("successor trade state JSON") + ); +} + +#[test] +fn capability_value_json_is_byte_identical() { + for (predecessor, successor) in [ + ( + predecessor::TransportKindV1::Local, + capability::TransportKind::Local, + ), + ( + predecessor::TransportKindV1::Nostr, + capability::TransportKind::Nostr, + ), + ( + predecessor::TransportKindV1::Reticulum, + capability::TransportKind::Reticulum, + ), + ] { + assert_eq!( + serde_json::to_vec(&predecessor).expect("predecessor transport JSON"), + serde_json::to_vec(&successor).expect("successor transport JSON") + ); + } + + let predecessor = predecessor::ReticulumTargetV1 { + destination: predecessor::ReticulumDestinationV1::parse("reticulum:local") + .expect("predecessor destination"), + mesh_scope: Some( + predecessor::MeshScopeIdV1::parse("local_preview").expect("predecessor scope"), + ), + }; + let successor = capability::ReticulumTarget { + destination: capability::ReticulumDestination::parse("reticulum:local") + .expect("successor destination"), + mesh_scope: Some(capability::MeshScopeId::parse("local_preview").expect("successor scope")), + }; + assert_eq!( + serde_json::to_vec(&predecessor).expect("predecessor target JSON"), + serde_json::to_vec(&successor).expect("successor target JSON") + ); +} + +#[test] +fn schema_metadata_bytes_and_dispatch_are_preserved() { + let predecessor = predecessor::PROTOCOL_SCHEMA_METADATA_V1 + .iter() + .map(|metadata| { + ( + metadata.schema_id, + serde_json::to_vec(metadata).expect("predecessor metadata JSON"), + ) + }) + .collect::<BTreeMap<_, _>>(); + let successor = capability::SCHEMAS + .iter() + .chain(event::SCHEMAS) + .map(|metadata| { + ( + metadata.schema_id, + serde_json::to_vec(metadata).expect("successor metadata JSON"), + ) + }) + .collect::<BTreeMap<_, _>>(); + assert_eq!(successor, predecessor); + + let registry = protocol_v1_registry().expect("successor schema registry"); + for schema_id in predecessor.keys() { + let schema_id = SchemaId::parse(*schema_id).expect("predecessor schema id"); + let module = registry.module_for(&schema_id).expect("registered module"); + assert!(matches!( + module, + ModuleVersion::CapabilityV1 | ModuleVersion::EventV1 + )); + } +} + +#[test] +fn predecessor_and_successor_validation_are_green() { + predecessor::validate_protocol_contract_v1().expect("predecessor contract"); + capability::validate_catalog(capability::CATALOG).expect("successor capability catalog"); + event::validate_catalog(event::CATALOG).expect("successor event catalog"); + event::validate_trade_state_vocabulary(event::TRADE_STATE_VOCABULARY) + .expect("successor trade vocabulary"); + protocol_v1_registry().expect("successor schema registry"); +} diff --git a/crates/replica_schema/Cargo.toml b/crates/replica_schema/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_replica_schema" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/replica_store/Cargo.toml b/crates/replica_store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_replica_store" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/replica_sync/Cargo.toml b/crates/replica_sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_replica_sync" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/replica_sync/src/ingest.rs b/crates/replica_sync/src/ingest.rs @@ -11,7 +11,7 @@ use base64::Engine; #[cfg(feature = "std")] use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use radroots_core::RadrootsCoreDecimal; +use radroots_core::Decimal; #[cfg(test)] use radroots_event::RadrootsEventEnvelopeParts; use radroots_event::contract::RadrootsEventClass; @@ -253,7 +253,7 @@ fn ingest_profile_event( ) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> { let data_result = profile_decode::data_from_event( event.id_str().to_owned(), - event.author_str().to_owned(), + event.author().to_hex().to_owned(), event.created_at_u64(), event.kind_u32(), event.content().to_owned(), @@ -355,7 +355,7 @@ fn ingest_farm_event( created_at: None, updated_at: None, d_tag: Some(farm.d_tag.clone()), - pubkey: Some(event.author_str().to_owned()), + pubkey: Some(event.author().to_hex().to_owned()), name: None, about: None, website: None, @@ -379,7 +379,7 @@ fn ingest_farm_event( let farm_id = if let Some(row) = existing.results.first() { let fields = IFarmFieldsPartial { d_tag: Some(Value::from(farm.d_tag.clone())), - pubkey: Some(Value::from(event.author_str().to_owned())), + pubkey: Some(Value::from(event.author().to_hex().to_owned())), name: Some(Value::from(farm.name.clone())), about: to_value_opt(farm.about.clone()), website: to_value_opt(farm.website.clone()), @@ -402,7 +402,7 @@ fn ingest_farm_event( } else { let fields = IFarmFields { d_tag: farm.d_tag.clone(), - pubkey: event.author_str().to_owned(), + pubkey: event.author().to_hex().to_owned(), name: farm.name.clone(), about: farm.about.clone(), website: farm.website.clone(), @@ -622,7 +622,7 @@ fn ingest_list_set_event( if !decision.apply { return Ok(RadrootsReplicaIngestOutcome::Skipped); } - upsert_member_claims(exec, event.author_str(), &list_set)?; + upsert_member_claims(exec, &event.author().to_hex(), &list_set)?; upsert_event_head(exec, &decision)?; return Ok(RadrootsReplicaIngestOutcome::Applied); } @@ -642,7 +642,7 @@ fn ingest_list_set_event( if !decision.apply { return Ok(RadrootsReplicaIngestOutcome::Skipped); } - let farm = find_farm_by_ref(exec, event.author_str(), &farm_d_tag)?; + let farm = find_farm_by_ref(exec, &event.author().to_hex(), &farm_d_tag)?; upsert_farm_members(exec, &farm.id, role, &list_set)?; upsert_event_head(exec, &decision)?; return Ok(RadrootsReplicaIngestOutcome::Applied); @@ -667,8 +667,9 @@ fn trade_product_fields_from_listing( listing_addr: &str, ) -> Result<ITradeProductFields, RadrootsReplicaEventsError> { let bin = primary_listing_bin(listing)?; - let qty_amt = decimal_to_f64(&bin.quantity.amount, "listing primary bin quantity")?; - let qty_amt_exact = bin.quantity.amount.to_string(); + let quantity_amount = bin.quantity.amount(); + let qty_amt = decimal_to_f64(&quantity_amount, "listing primary bin quantity")?; + let qty_amt_exact = quantity_amount.to_string(); let qty_avail = listing .inventory_available .as_ref() @@ -677,30 +678,30 @@ fn trade_product_fields_from_listing( let price_source = bin .display_price .as_ref() - .unwrap_or(&bin.price_per_canonical_unit.amount); - let Some(price_amt) = price_source.amount.to_f64_lossy() else { + .unwrap_or(bin.price_per_canonical_unit.amount()); + let Some(price_amt) = price_source.amount().to_f64_lossy() else { return Err(RadrootsReplicaEventsError::InvalidData( "listing price amount out of range".to_string(), )); }; - let price_amt_exact = price_source.amount.to_string(); - let price_currency = price_source.currency.as_str().to_string(); + let price_amt_exact = price_source.amount().to_string(); + let price_currency = price_source.currency().as_str().to_string(); let price_qty_amt = if bin.display_price.is_some() { 1.0 } else { decimal_to_f64( - &bin.price_per_canonical_unit.quantity.amount, + &bin.price_per_canonical_unit.quantity().amount(), "listing price quantity", )? }; let price_qty_amt_exact = if bin.display_price.is_some() { "1".to_string() } else { - bin.price_per_canonical_unit.quantity.amount.to_string() + bin.price_per_canonical_unit.quantity().amount().to_string() }; let price_qty_unit = bin .display_price_unit - .unwrap_or(bin.price_per_canonical_unit.quantity.unit) + .unwrap_or(bin.price_per_canonical_unit.quantity().unit()) .to_string(); Ok(ITradeProductFields { @@ -719,11 +720,11 @@ fn trade_product_fields_from_listing( .unwrap_or_default(), qty_amt, qty_amt_exact, - qty_unit: bin.quantity.unit.to_string(), + qty_unit: bin.quantity.unit().to_string(), qty_label: bin .display_label .clone() - .or_else(|| bin.quantity.label.clone()), + .or_else(|| bin.quantity.label().map(ToOwned::to_owned)), qty_avail, price_amt, price_amt_exact, @@ -770,10 +771,7 @@ fn primary_listing_bin( }) } -fn decimal_to_i64( - value: &RadrootsCoreDecimal, - field: &str, -) -> Result<i64, RadrootsReplicaEventsError> { +fn decimal_to_i64(value: &Decimal, field: &str) -> Result<i64, RadrootsReplicaEventsError> { let value = decimal_to_u64(value, field)?; match i64::try_from(value) { Ok(value) => Ok(value), @@ -783,10 +781,7 @@ fn decimal_to_i64( } } -fn decimal_to_f64( - value: &RadrootsCoreDecimal, - field: &str, -) -> Result<f64, RadrootsReplicaEventsError> { +fn decimal_to_f64(value: &Decimal, field: &str) -> Result<f64, RadrootsReplicaEventsError> { match value.to_f64_lossy() { Some(value) => Ok(value), None => Err(RadrootsReplicaEventsError::InvalidData(format!( @@ -795,10 +790,7 @@ fn decimal_to_f64( } } -fn decimal_to_u64( - value: &RadrootsCoreDecimal, - field: &str, -) -> Result<u64, RadrootsReplicaEventsError> { +fn decimal_to_u64(value: &Decimal, field: &str) -> Result<u64, RadrootsReplicaEventsError> { match value.to_u64_exact() { Some(value) => Ok(value), None => Err(RadrootsReplicaEventsError::InvalidData(format!( @@ -1028,7 +1020,7 @@ fn event_head_decision( apply: false, key: String::new(), kind: event.kind_u32(), - pubkey: event.author_str().to_owned(), + pubkey: event.author().to_hex().to_owned(), d_tag: String::new(), last_event_id: event.id_str().to_owned(), last_created_at: event.created_at_u64(), @@ -1631,10 +1623,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, - RadrootsCoreUnit, - }; + use radroots_core::{Currency, Money, Quantity, QuantityPrice, Unit}; use radroots_event::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}; use radroots_event::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; use radroots_event::kinds::{KIND_LIST_SET_FOLLOW, KIND_LIST_SET_GENERIC}; @@ -1667,6 +1656,14 @@ mod tests { use radroots_sql_core::{ExecOutcome, SqlExecutor, SqlxSqliteExecutor}; use radroots_test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_ALICE_SECRET_KEY_HEX}; + fn test_public_key_hex(seed: u8) -> String { + let secret_key = format!("{:064x}", u16::from(seed) + 1); + Keys::parse(&secret_key) + .expect("deterministic test secret key") + .public_key() + .to_hex() + } + fn test_event_envelope( id: u64, author: &str, @@ -1695,7 +1692,7 @@ mod tests { ) -> RadrootsEventEnvelope { RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: event.id_str().to_owned(), - author: event.author_str().to_owned(), + author: event.author().to_hex().to_owned(), created_at: event.created_at_u64(), kind, tags, @@ -2129,11 +2126,11 @@ mod tests { ) } - fn listing_decimal(raw: &str) -> RadrootsCoreDecimal { + fn listing_decimal(raw: &str) -> Decimal { raw.parse().expect("decimal") } - fn listing_currency() -> RadrootsCoreCurrency { + fn listing_currency() -> Currency { "USD".parse().expect("currency") } @@ -2142,7 +2139,7 @@ mod tests { d_tag: "AAAAAAAAAAAAAAAAAAAAAA".parse().expect("d tag"), published_at: Some(1), farm: RadrootsFarmRef { - pubkey: "c".repeat(64), + pubkey: test_public_key_hex(0xc), d_tag: "AAAAAAAAAAAAAAAAAAAAAZ".to_string(), }, product: RadrootsOperationalListingProduct { @@ -2159,12 +2156,14 @@ mod tests { primary_bin_id: "bin-a".parse().expect("primary bin id"), bins: vec![RadrootsOperationalListingBin { bin_id: "bin-a".parse().expect("bin id"), - quantity: RadrootsCoreQuantity::new(listing_decimal("12"), RadrootsCoreUnit::Each) + quantity: Quantity::try_new(listing_decimal("12"), Unit::Each) + .unwrap() .with_label("unit label"), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(listing_decimal("6"), listing_currency()), - RadrootsCoreQuantity::new(listing_decimal("1"), RadrootsCoreUnit::Each), - ), + price_per_canonical_unit: QuantityPrice::try_new( + Money::try_new(listing_decimal("6"), listing_currency()).unwrap(), + Quantity::try_new(listing_decimal("1"), Unit::Each).unwrap(), + ) + .unwrap(), display_amount: None, display_unit: None, display_label: None, @@ -2219,7 +2218,7 @@ mod tests { updated_at: "2026-01-01T00:00:00Z".to_string(), key: "profile".to_string(), kind: KIND_PROFILE, - pubkey: "a".repeat(64), + pubkey: test_public_key_hex(0xa), d_tag: String::new(), last_event_id: "not-an-event-id".to_string(), last_created_at: 1, @@ -2227,7 +2226,7 @@ mod tests { }; let coordinate = RadrootsEventHeadCoordinate::Replaceable { kind: KIND_PROFILE, - pubkey: "a".repeat(64).parse().expect("pubkey"), + pubkey: test_public_key_hex(0xa).parse().expect("pubkey"), }; let err = current_event_head_from_row(&row, &coordinate) @@ -2241,7 +2240,7 @@ mod tests { exec, &IFarmFields { d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - pubkey: "f".repeat(64), + pubkey: test_public_key_hex(0xf), name: "farm".to_string(), about: None, website: None, @@ -2337,7 +2336,7 @@ mod tests { exec, &IFarmMemberFields { farm_id: farm_row.id.clone(), - member_pubkey: "6".repeat(64), + member_pubkey: test_public_key_hex(0x6), role: "member".to_string(), }, ) @@ -2345,7 +2344,7 @@ mod tests { let _ = farm_member_claim::create( exec, &IFarmMemberClaimFields { - member_pubkey: "6".repeat(64), + member_pubkey: test_public_key_hex(0x6), farm_pubkey: farm_row.pubkey.clone(), }, ) @@ -2368,7 +2367,7 @@ mod tests { }; let event = test_event_envelope( 1, - &"a".repeat(64), + &test_public_key_hex(0xa), 1, KIND_LIST_SET_FOLLOW, Vec::new(), @@ -2413,7 +2412,14 @@ mod tests { commit_err: None, rollback_count: Arc::new(AtomicUsize::new(0)), }; - let unsupported = test_event_envelope(2, &"a".repeat(64), 2, 42, Vec::new(), String::new()); + let unsupported = test_event_envelope( + 2, + &test_public_key_hex(0xa), + 2, + 42, + Vec::new(), + String::new(), + ); let err = radroots_replica_ingest_event_with_factory( &rollback_executor, &unsupported, @@ -2454,7 +2460,7 @@ mod tests { }; let event = test_event_envelope( 3, - &"a".repeat(64), + &test_public_key_hex(0xa), 3, KIND_CALENDAR, Vec::new(), @@ -2480,7 +2486,7 @@ mod tests { let factory = RadrootsReplicaDefaultIdFactory; assert_eq!(factory.new_d_tag().len(), 22); - let profile_pubkey = "9".repeat(64); + let profile_pubkey = test_public_key_hex(0x9); let profile = profile_event( 10, &profile_pubkey, @@ -2539,7 +2545,7 @@ mod tests { let decision = event_head_decision(&exec, &profile_same_time_lower_id).expect("decision"); assert!(decision.apply); - let farm_pubkey = "f".repeat(64); + let farm_pubkey = test_public_key_hex(0xf); let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA"; let farm = farm_event( 20, @@ -2628,12 +2634,15 @@ mod tests { RadrootsReplicaIngestOutcome::Skipped ); - let members = farm_list_sets::farm_members_list_set(farm_d_tag, vec!["6".repeat(64)]) - .expect("members"); + let members = + farm_list_sets::farm_members_list_set(farm_d_tag, vec![test_public_key_hex(0x6)]) + .expect("members"); let owners = - farm_list_sets::farm_owners_list_set(farm_d_tag, vec!["8".repeat(64)]).expect("owners"); - let workers = farm_list_sets::farm_workers_list_set(farm_d_tag, vec!["0".repeat(64)]) - .expect("workers"); + farm_list_sets::farm_owners_list_set(farm_d_tag, vec![test_public_key_hex(0x8)]) + .expect("owners"); + let workers = + farm_list_sets::farm_workers_list_set(farm_d_tag, vec![test_public_key_hex(0x0)]) + .expect("workers"); let plots = farm_list_sets::farm_plots_list_set( farm_d_tag, &farm_pubkey, @@ -3222,7 +3231,7 @@ mod tests { &exec, &IFarmFields { d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - pubkey: "f".repeat(64), + pubkey: test_public_key_hex(0xf), name: "farm-none".to_string(), about: None, website: None, @@ -3323,7 +3332,7 @@ mod tests { ); let members_list_set = - farm_list_sets::farm_members_list_set(&farm_d_tag, vec!["7".repeat(64)]) + farm_list_sets::farm_members_list_set(&farm_d_tag, vec![test_public_key_hex(0x7)]) .expect("members"); assert!( upsert_farm_members(&exec, &farm_id, ListSetRole::Members, &members_list_set).is_ok() @@ -3334,7 +3343,7 @@ mod tests { err: SqlError::NotFound("farm_member".to_string()), }; let not_found_members_list_set = - farm_list_sets::farm_members_list_set(&farm_d_tag, vec!["a".repeat(64)]) + farm_list_sets::farm_members_list_set(&farm_d_tag, vec![test_public_key_hex(0xa)]) .expect("not found members"); assert!( upsert_farm_members( @@ -3356,18 +3365,24 @@ mod tests { ); let member_claims = - farm_list_sets::member_of_farms_list_set(vec!["3".repeat(64)]).expect("claims"); - assert!(upsert_member_claims(&exec, &"6".repeat(64), &member_claims).is_ok()); + farm_list_sets::member_of_farms_list_set(vec![test_public_key_hex(0x3)]) + .expect("claims"); + assert!(upsert_member_claims(&exec, &test_public_key_hex(0x6), &member_claims).is_ok()); let not_found_claims = DeleteErrorExecutor { inner: &exec, table_name: "farm_member_claim", err: SqlError::NotFound("farm_member_claim".to_string()), }; let not_found_member_claims = - farm_list_sets::member_of_farms_list_set(vec!["2".repeat(64)]).expect("claims nf"); + farm_list_sets::member_of_farms_list_set(vec![test_public_key_hex(0x2)]) + .expect("claims nf"); assert!( - upsert_member_claims(&not_found_claims, &"6".repeat(64), &not_found_member_claims) - .is_ok() + upsert_member_claims( + &not_found_claims, + &test_public_key_hex(0x6), + &not_found_member_claims + ) + .is_ok() ); assert!(not_found_claims.begin().is_ok()); assert!(not_found_claims.commit().is_ok()); @@ -3441,7 +3456,10 @@ mod tests { table_name: "farm_member_claim", err: SqlError::Internal, }; - assert!(upsert_member_claims(&internal_claims, &"6".repeat(64), &member_claims).is_err()); + assert!( + upsert_member_claims(&internal_claims, &test_public_key_hex(0x6), &member_claims) + .is_err() + ); } #[test] @@ -3461,8 +3479,8 @@ mod tests { migrations::run_all_up(&exec).expect("migrations"); let pass = PassExecutor { inner: &exec }; - let profile_pubkey = "9".repeat(64); - let farm_pubkey = "f".repeat(64); + let profile_pubkey = test_public_key_hex(0x9); + let farm_pubkey = test_public_key_hex(0xf); let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA"; let plot_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; @@ -3528,7 +3546,8 @@ mod tests { ); let members = - farm_list_sets::farm_members_list_set(farm_d_tag, vec!["6".repeat(64)]).expect("list"); + farm_list_sets::farm_members_list_set(farm_d_tag, vec![test_public_key_hex(0x6)]) + .expect("list"); let members_event = list_set_event(503, &farm_pubkey, 53, KIND_LIST_SET_GENERIC, &members); assert_eq!( ingest_list_set_event(&pass, &members_event).expect("members list set"), @@ -3554,7 +3573,7 @@ mod tests { }, RadrootsListEntry { tag: "p".to_string(), - values: vec!["6".repeat(64)], + values: vec![test_public_key_hex(0x6)], }, ], title: None, @@ -3611,7 +3630,7 @@ mod tests { let exec = SqlxSqliteExecutor::open_memory().expect("db"); migrations::run_all_up(&exec).expect("migrations"); - let farm_pubkey = "f".repeat(64); + let farm_pubkey = test_public_key_hex(0xf); let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA"; let farm_create = farm_event( 600, @@ -3702,7 +3721,7 @@ mod tests { rollback_count: Arc::new(AtomicUsize::new(0)), }; - let profile_pubkey = "9".repeat(64); + let profile_pubkey = test_public_key_hex(0x9); let profile_event_row = profile_event( 700, &profile_pubkey, @@ -3728,7 +3747,7 @@ mod tests { RadrootsReplicaIngestOutcome::Skipped ); - let farm_pubkey = "f".repeat(64); + let farm_pubkey = test_public_key_hex(0xf); let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA"; let farm_event_row = farm_event( 701, @@ -3803,14 +3822,17 @@ mod tests { .is_ok() ); let members_list = - farm_list_sets::farm_members_list_set(farm_d_tag, vec!["6".repeat(64)]).expect("list"); + farm_list_sets::farm_members_list_set(farm_d_tag, vec![test_public_key_hex(0x6)]) + .expect("list"); assert!( upsert_farm_members(&pass_txn, &farm_row.id, ListSetRole::Members, &members_list) .is_ok() ); let member_of_list = farm_list_sets::member_of_farms_list_set(vec![farm_pubkey.clone()]).expect("member_of"); - assert!(upsert_member_claims(&pass_txn, &"6".repeat(64), &member_of_list).is_ok()); + assert!( + upsert_member_claims(&pass_txn, &test_public_key_hex(0x6), &member_of_list).is_ok() + ); let rollback_count = Arc::new(AtomicUsize::new(0)); let txn = TxnExecutor { @@ -3854,7 +3876,7 @@ mod tests { .is_err() ); assert!(upsert_farm_members(&txn, "farm-id", ListSetRole::Members, &members_list).is_err()); - assert!(upsert_member_claims(&txn, &"6".repeat(64), &member_of_list).is_err()); + assert!(upsert_member_claims(&txn, &test_public_key_hex(0x6), &member_of_list).is_err()); } #[test] @@ -3879,10 +3901,10 @@ mod tests { let _ = pass_through.rollback(); let _ = pass_through.commit(); - let farm_pubkey = "f".repeat(64); + let farm_pubkey = test_public_key_hex(0xf); let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA"; let plot_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; - let profile_pubkey = "9".repeat(64); + let profile_pubkey = test_public_key_hex(0x9); let profile = profile_event( 800, @@ -3926,7 +3948,7 @@ mod tests { }; let profile_new = profile_event( 802, - &"7".repeat(64), + &test_public_key_hex(0x7), 82, Some(RadrootsProfileType::Individual), "profile-new", @@ -3940,7 +3962,7 @@ mod tests { }; let profile_state_event = profile_event( 803, - &"c".repeat(64), + &test_public_key_hex(0xc), 83, Some(RadrootsProfileType::Individual), "profile-state", @@ -3977,7 +3999,7 @@ mod tests { }; let farm_query_event = farm_event( 811, - &"a".repeat(64), + &test_public_key_hex(0xa), 91, farm_d_tag, "farm-query", @@ -4009,7 +4031,7 @@ mod tests { }; let farm_create = farm_event( 813, - &"c".repeat(64), + &test_public_key_hex(0xc), 93, farm_d_tag, "farm-create", @@ -4025,7 +4047,7 @@ mod tests { }; let farm_tag_event = farm_event( 814, - &"d".repeat(64), + &test_public_key_hex(0xd), 94, farm_d_tag, "farm-tag", @@ -4041,7 +4063,7 @@ mod tests { }; let farm_gcs_event = farm_event( 815, - &"0".repeat(64), + &test_public_key_hex(0x0), 95, farm_d_tag, "farm-gcs", @@ -4066,7 +4088,7 @@ mod tests { }; let farm_rel_event = farm_event( 816, - &"b".repeat(64), + &test_public_key_hex(0xb), 96, farm_d_tag, "farm-rel", @@ -4091,7 +4113,7 @@ mod tests { }; let farm_state_event = farm_event( 817, - &"0".repeat(64), + &test_public_key_hex(0x0), 97, farm_d_tag, "farm-state", @@ -4102,7 +4124,7 @@ mod tests { let farm_public_location = farm_event( 818, - &"1".repeat(64), + &test_public_key_hex(0x1), 98, farm_d_tag, "farm-public-location", @@ -4123,7 +4145,7 @@ mod tests { let farm_public_locality = farm_event( 819, - &"2".repeat(64), + &test_public_key_hex(0x2), 99, farm_d_tag, "farm-public-locality", @@ -4336,8 +4358,9 @@ mod tests { ); assert!(ingest_list_set_event(&exec, &list_decode_fail).is_err()); - let members_list = farm_list_sets::farm_members_list_set(farm_d_tag, vec!["6".repeat(64)]) - .expect("members list"); + let members_list = + farm_list_sets::farm_members_list_set(farm_d_tag, vec![test_public_key_hex(0x6)]) + .expect("members list"); let member_event = list_set_event(831, &farm_pubkey, 109, KIND_LIST_SET_GENERIC, &members_list); let list_decision_fail = QueryFailExecutor { @@ -4349,8 +4372,13 @@ mod tests { let member_of = farm_list_sets::member_of_farms_list_set(vec![farm_pubkey.clone()]).expect("member-of"); - let member_of_event = - list_set_event(832, &"6".repeat(64), 110, KIND_LIST_SET_GENERIC, &member_of); + let member_of_event = list_set_event( + 832, + &test_public_key_hex(0x6), + 110, + KIND_LIST_SET_GENERIC, + &member_of, + ); let claims_fail = QueryFailExecutor { inner: &exec, needle: "farm_member_claim", @@ -4381,10 +4409,11 @@ mod tests { assert!(ingest_list_set_event(&plots_state_fail, &plots_event).is_err()); let missing_farm_members = - farm_list_sets::farm_members_list_set(farm_d_tag, vec!["7".repeat(64)]).expect("list"); + farm_list_sets::farm_members_list_set(farm_d_tag, vec![test_public_key_hex(0x7)]) + .expect("list"); let missing_farm_event = list_set_event( 834, - &"3".repeat(64), + &test_public_key_hex(0x3), 112, KIND_LIST_SET_GENERIC, &missing_farm_members, @@ -4423,7 +4452,7 @@ mod tests { }; let profile_update = profile_event( 808, - &"9".repeat(64), + &test_public_key_hex(0x9), 101, Some(RadrootsProfileType::Individual), "profile-update-error", @@ -4438,7 +4467,7 @@ mod tests { let profile = profile_event( 900, - &"e".repeat(64), + &test_public_key_hex(0xe), 120, Some(RadrootsProfileType::Individual), "profile-state-insert", @@ -4452,7 +4481,7 @@ mod tests { let farm_state = farm_event( 901, - &"a".repeat(64), + &test_public_key_hex(0xa), 121, "AAAAAAAAAAAAAAAAAAAAAQ", "farm-state-insert", @@ -4476,8 +4505,9 @@ mod tests { ); assert!(ingest_plot_event(&state_insert_fail, &plot_state, &FixedFactory).is_err()); - let members_set = farm_list_sets::farm_members_list_set(&farm_d_tag, vec!["7".repeat(64)]) - .expect("members"); + let members_set = + farm_list_sets::farm_members_list_set(&farm_d_tag, vec![test_public_key_hex(0x7)]) + .expect("members"); let members_event = list_set_event(903, &farm_pubkey, 123, KIND_LIST_SET_GENERIC, &members_set); assert!(ingest_list_set_event(&state_insert_fail, &members_event).is_err()); @@ -4495,7 +4525,7 @@ mod tests { farm_list_sets::member_of_farms_list_set(vec![farm_pubkey.clone()]).expect("member_of"); let member_of_event = list_set_event( 905, - &"7".repeat(64), + &test_public_key_hex(0x7), 125, KIND_LIST_SET_GENERIC, &member_of_set, @@ -4608,7 +4638,12 @@ mod tests { err: SqlError::Internal, }; assert!( - upsert_member_claims(&claims_insert_fail, &"7".repeat(64), &member_of_set).is_err() + upsert_member_claims( + &claims_insert_fail, + &test_public_key_hex(0x7), + &member_of_set + ) + .is_err() ); super::failpoints::set_gcs_point_serialize_error(); @@ -4625,7 +4660,7 @@ mod tests { let exec = SqlxSqliteExecutor::open_memory().expect("db"); let (farm_id, farm_pubkey, _, _) = seed_rows(&exec); - let member_pubkey = "6".repeat(64); + let member_pubkey = test_public_key_hex(0x6); let member_list_set = RadrootsListSet { d_tag: "farm:AAAAAAAAAAAAAAAAAAAAAQ:members".to_string(), content: String::new(), @@ -4670,7 +4705,7 @@ mod tests { upsert_farm_members(&exec, &farm_id, ListSetRole::Plots, &member_list_set) .expect("plots is no-op"); - let claimant_pubkey = "7".repeat(64); + let claimant_pubkey = test_public_key_hex(0x7); let claims_list_set = RadrootsListSet { d_tag: "member_of.farms".to_string(), content: String::new(), @@ -4722,7 +4757,7 @@ mod tests { 220, &plot_d_tag, RadrootsFarmRef { - pubkey: "1".repeat(64), + pubkey: test_public_key_hex(0x1), d_tag: farm_d_tag.clone(), }, "plot-missing-farm", @@ -4736,7 +4771,7 @@ mod tests { bad_member_of.entries[0].tag = "x".to_string(); let bad_member_of_event = list_set_event( 951, - &"7".repeat(64), + &test_public_key_hex(0x7), 221, KIND_LIST_SET_GENERIC, &bad_member_of, @@ -4755,7 +4790,7 @@ mod tests { assert!(ingest_list_set_event(&exec, &bad_plots_event).is_err()); let mut bad_members = - farm_list_sets::farm_members_list_set(&farm_d_tag, vec!["6".repeat(64)]) + farm_list_sets::farm_members_list_set(&farm_d_tag, vec![test_public_key_hex(0x6)]) .expect("members"); bad_members.entries[0].tag = "a".to_string(); let bad_members_event = diff --git a/crates/replica_sync/tests/ingest_roundtrip.rs b/crates/replica_sync/tests/ingest_roundtrip.rs @@ -1,3 +1,4 @@ +use nostr::Keys; use radroots_event::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}; use radroots_event::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; use radroots_event::kinds::{ @@ -45,6 +46,14 @@ use radroots_sql_core::error::SqlError; use radroots_sql_core::{ExecOutcome, SqlExecutor}; use std::panic; +fn test_public_key_hex(seed: u8) -> String { + let secret_key = format!("{:064x}", u16::from(seed) + 1); + Keys::parse(&secret_key) + .expect("deterministic test secret key") + .public_key() + .to_hex() +} + fn unwrap_sql<T>(result: Result<T, ReplicaSchemaError<SqlError>>, label: &str) -> T { match result { Ok(value) => value, @@ -199,7 +208,7 @@ fn seed_source( ) { migrations::run_all_up(exec).expect("migrations"); - let farm_pubkey = "f".repeat(64); + let farm_pubkey = test_public_key_hex(0xf); let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA".to_string(); let farm_fields = IFarmFields { d_tag: farm_d_tag.clone(), @@ -383,7 +392,7 @@ fn seed_source( "plot_tag", ); - let owner_pubkey = "8".repeat(64); + let owner_pubkey = test_public_key_hex(0x8); let _ = unwrap_sql( farm_member::create( exec, @@ -546,7 +555,7 @@ fn ingest_rejects_unsupported_kind() { migrations::run_all_up(&exec).expect("migrations"); let event = event_with_parts( 1, - &"a".repeat(64), + &test_public_key_hex(0xa), 1_720_000_001, 42, String::new(), @@ -560,7 +569,7 @@ fn ingest_rejects_unsupported_kind() { fn ingest_reports_transaction_boundary_errors() { let exec = SqlxSqliteExecutor::open_memory().expect("db"); migrations::run_all_up(&exec).expect("migrations"); - let author = "a".repeat(64); + let author = test_public_key_hex(0xa); let profile = profile_event( 9_001, &author, @@ -580,7 +589,7 @@ fn ingest_reports_transaction_boundary_errors() { fn ingest_reports_delete_internal_errors() { let exec = SqlxSqliteExecutor::open_memory().expect("db"); migrations::run_all_up(&exec).expect("migrations"); - let farm_pubkey = "f".repeat(64); + let farm_pubkey = test_public_key_hex(0xf); let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA"; let create_event = farm_event( @@ -619,7 +628,7 @@ fn ingest_reports_parse_and_state_error_paths_for_all_kinds() { let exec = SqlxSqliteExecutor::open_memory().expect("db"); migrations::run_all_up(&exec).expect("migrations"); - let profile_pubkey = "a".repeat(64); + let profile_pubkey = test_public_key_hex(0xa); let profile_ok = profile_event( 9_201, &profile_pubkey, @@ -637,7 +646,7 @@ fn ingest_reports_parse_and_state_error_paths_for_all_kinds() { ); assert!(radroots_replica_ingest_event(&exec, &profile_parse_error).is_err()); - let farm_pubkey = "b".repeat(64); + let farm_pubkey = test_public_key_hex(0xb); let farm_seed_d_tag = "AAAAAAAAAAAAAAAAAAAAAA"; let farm_seed = farm_event( 9_203, @@ -723,7 +732,7 @@ fn ingest_reports_parse_and_state_error_paths_for_all_kinds() { }; let profile_insert_state_error = profile_event( 9_209, - &"c".repeat(64), + &test_public_key_hex(0xc), 18, Some(RadrootsProfileType::Individual), "profile-state-insert", @@ -777,7 +786,7 @@ fn ingest_reports_query_fail_paths_for_profile_farm_plot_and_list_sets() { ); }; - let profile_pubkey = "d".repeat(64); + let profile_pubkey = test_public_key_hex(0xd); let profile_create = profile_event( 9_301, &profile_pubkey, @@ -800,7 +809,7 @@ fn ingest_reports_query_fail_paths_for_profile_farm_plot_and_list_sets() { ); assert_query_fail("update nostr_profile", &profile_update); - let farm_pubkey = "e".repeat(64); + let farm_pubkey = test_public_key_hex(0xe); let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA"; let farm_create = farm_event( 9_303, @@ -890,7 +899,8 @@ fn ingest_reports_query_fail_paths_for_profile_farm_plot_and_list_sets() { assert_query_fail("insert into farm_member_claim", &member_of_event); let members_set = - farm_list_sets::farm_members_list_set(farm_d_tag, vec!["6".repeat(64)]).expect("members"); + farm_list_sets::farm_members_list_set(farm_d_tag, vec![test_public_key_hex(0x6)]) + .expect("members"); let members_event = list_set_event(9_308, &farm_pubkey, 17, KIND_LIST_SET_GENERIC, &members_set); assert_query_fail("insert into farm_member", &members_event); @@ -1081,7 +1091,7 @@ fn ingest_event_paths_cover_profile_farm_plot_and_list_set_variants() { let exec = SqlxSqliteExecutor::open_memory().expect("db"); migrations::run_all_up(&exec).expect("migrations"); - let profile_pubkey = "9".repeat(64); + let profile_pubkey = test_public_key_hex(0x9); let profile_create = profile_event( 101, &profile_pubkey, @@ -1138,12 +1148,24 @@ fn ingest_event_paths_cover_profile_farm_plot_and_list_set_variants() { assert!(err.to_string().contains("profile_type required")); let profile_types = [ - (RadrootsProfileType::Farm, "f".repeat(64), "farm-profile"), - (RadrootsProfileType::Coop, "c".repeat(64), "coop-profile"), - (RadrootsProfileType::Any, "a".repeat(64), "any-profile"), + ( + RadrootsProfileType::Farm, + test_public_key_hex(0xf), + "farm-profile", + ), + ( + RadrootsProfileType::Coop, + test_public_key_hex(0xc), + "coop-profile", + ), + ( + RadrootsProfileType::Any, + test_public_key_hex(0xa), + "any-profile", + ), ( RadrootsProfileType::Radrootsd, - "d".repeat(64), + test_public_key_hex(0xd), "radrootsd-profile", ), ]; @@ -1161,7 +1183,7 @@ fn ingest_event_paths_cover_profile_farm_plot_and_list_set_variants() { ); } - let farm_pubkey = "e".repeat(64); + let farm_pubkey = test_public_key_hex(0xe); let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA"; let farm_location = RadrootsFarmPublicLocation { primary: "farm-primary".to_string(), @@ -1374,7 +1396,7 @@ fn ingest_event_paths_cover_profile_farm_plot_and_list_set_variants() { 201, "AAAAAAAAAAAAAAAAAAAAAg", RadrootsFarmRef { - pubkey: "3".repeat(64), + pubkey: test_public_key_hex(0x3), d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), }, "plot-missing-farm", @@ -1648,9 +1670,11 @@ fn ingest_event_paths_cover_profile_farm_plot_and_list_set_variants() { .contains("must only include p tags") ); - let members_valid = - farm_list_sets::farm_members_list_set(farm_d_tag, vec!["6".repeat(64), "6".repeat(64)]) - .expect("members list"); + let members_valid = farm_list_sets::farm_members_list_set( + farm_d_tag, + vec![test_public_key_hex(0x6), test_public_key_hex(0x6)], + ) + .expect("members list"); let members_event = list_set_event( 406, &farm_pubkey, @@ -1682,14 +1706,16 @@ fn ingest_event_paths_cover_profile_farm_plot_and_list_set_variants() { RadrootsReplicaIngestOutcome::Applied ); let owners_valid = - farm_list_sets::farm_owners_list_set(farm_d_tag, vec!["8".repeat(64)]).expect("owners"); + farm_list_sets::farm_owners_list_set(farm_d_tag, vec![test_public_key_hex(0x8)]) + .expect("owners"); let owners_event = list_set_event(407, &farm_pubkey, 307, KIND_LIST_SET_GENERIC, &owners_valid); assert_eq!( radroots_replica_ingest_event(&exec, &owners_event).expect("owners apply"), RadrootsReplicaIngestOutcome::Applied ); let workers_valid = - farm_list_sets::farm_workers_list_set(farm_d_tag, vec!["0".repeat(64)]).expect("workers"); + farm_list_sets::farm_workers_list_set(farm_d_tag, vec![test_public_key_hex(0x0)]) + .expect("workers"); let workers_event = list_set_event( 408, &farm_pubkey, @@ -1891,7 +1917,7 @@ fn sync_all_rejects_invalid_selectors_and_resolves_unique_pair() { assert!(missing_id_err.to_string().contains("farm not found")); let duplicate_d_tag = "AAAAAAAAAAAAAAAAAAAAAA".to_string(); - let duplicate_pubkey = "e".repeat(64); + let duplicate_pubkey = test_public_key_hex(0xe); let fields = IFarmFields { d_tag: duplicate_d_tag.clone(), pubkey: duplicate_pubkey.clone(), @@ -1928,7 +1954,7 @@ fn sync_emit_handles_invalid_geojson_and_unknown_profile_type() { let exec = SqlxSqliteExecutor::open_memory().expect("db"); migrations::run_all_up(&exec).expect("migrations"); - let farm_pubkey = "0".repeat(64); + let farm_pubkey = test_public_key_hex(0x0); let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA".to_string(); let farm_row = unwrap_sql( farm::create( @@ -2021,7 +2047,7 @@ fn sync_emit_handles_invalid_geojson_and_unknown_profile_type() { "plot gcs", ); - let member_pubkey = "6".repeat(64); + let member_pubkey = test_public_key_hex(0x6); let _ = unwrap_sql( farm_member::create( &exec, @@ -2121,7 +2147,7 @@ fn sync_emit_reports_encode_error_for_invalid_farm_record() { &exec, &IFarmFields { d_tag: String::new(), - pubkey: "f".repeat(64), + pubkey: test_public_key_hex(0xf), name: "invalid farm".to_string(), about: None, website: None, diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_runtime" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/runtime_distribution/Cargo.toml b/crates/runtime_distribution/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_runtime_distribution" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/runtime_manager/Cargo.toml b/crates/runtime_manager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_runtime_manager" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/runtime_paths/Cargo.toml b/crates/runtime_paths/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_runtime_paths" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/runtime_store/Cargo.toml b/crates/runtime_store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_runtime_store" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/secret_vault/Cargo.toml b/crates/secret_vault/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_secret_vault" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "radroots_secrets" +description = "Secret material and protected-storage abstractions for Radroots" +version = "0.1.0-alpha" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +readme = "README.md" +publish = false + +[lib] +name = "radroots_secrets" + +[lints] +workspace = true diff --git a/crates/secrets/README.md b/crates/secrets/README.md @@ -0,0 +1,7 @@ +# radroots_secrets + +Secret material and protected-storage abstractions for Radroots. + +This package root is established for the Release V1 refactor. Secret types, +providers, and wrapping policy are migrated in the subsequent secrets +checkpoints. diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs @@ -0,0 +1 @@ +//! Secret material and protected-storage abstractions for Radroots. diff --git a/crates/signing/Cargo.toml b/crates/signing/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "radroots_signing" +description = "Signing abstractions and authorization policy for Radroots" +version = "0.1.0-alpha" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +readme = "README.md" +publish = false + +[lib] +name = "radroots_signing" + +[lints] +workspace = true diff --git a/crates/signing/README.md b/crates/signing/README.md @@ -0,0 +1,6 @@ +# radroots_signing + +Signing abstractions and authorization policy for Radroots. + +This package root is established for the Release V1 refactor. Its signer SPI +and policy surface are migrated in the subsequent signing checkpoints. diff --git a/crates/signing/src/lib.rs b/crates/signing/src/lib.rs @@ -0,0 +1 @@ +//! Signing abstractions and authorization policy for Radroots. diff --git a/crates/sql_core/Cargo.toml b/crates/sql_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_sql_core" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "radroots_storage" +description = "Backend-neutral persistence abstractions for Radroots" +version = "0.1.0-alpha" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +readme = "README.md" +publish = false + +[lib] +name = "radroots_storage" + +[lints] +workspace = true diff --git a/crates/storage/README.md b/crates/storage/README.md @@ -0,0 +1,7 @@ +# radroots_storage + +Backend-neutral persistence abstractions for Radroots. + +This package root is established for the Release V1 refactor. Storage SPIs, +models, and atomic operation contracts are migrated in the subsequent storage +checkpoints. diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs @@ -0,0 +1 @@ +//! Backend-neutral persistence abstractions for Radroots. diff --git a/crates/storage_sqlite/Cargo.toml b/crates/storage_sqlite/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "radroots_storage_sqlite" +description = "SQLite storage backend for Radroots" +version = "0.1.0-alpha" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +readme = "README.md" +publish = false + +[lib] +name = "radroots_storage_sqlite" + +[lints] +workspace = true diff --git a/crates/storage_sqlite/README.md b/crates/storage_sqlite/README.md @@ -0,0 +1,7 @@ +# radroots_storage_sqlite + +SQLite storage backend for Radroots. + +This package root is established for the Release V1 refactor. Its backend +implementation and migration authority are introduced in the subsequent +SQLite storage checkpoints. diff --git a/crates/storage_sqlite/src/lib.rs b/crates/storage_sqlite/src/lib.rs @@ -0,0 +1 @@ +//! SQLite storage backend for Radroots. diff --git a/crates/sync/Cargo.toml b/crates/sync/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "radroots_sync" +description = "Synchronization orchestration for Radroots" +version = "0.1.0-alpha" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +readme = "README.md" +publish = false + +[lib] +name = "radroots_sync" + +[lints] +workspace = true diff --git a/crates/sync/README.md b/crates/sync/README.md @@ -0,0 +1,6 @@ +# radroots_sync + +Synchronization orchestration for Radroots. + +This package root is established for the Release V1 refactor. Sync planning, +execution, and reporting are migrated in the subsequent sync checkpoints. diff --git a/crates/sync/src/lib.rs b/crates/sync/src/lib.rs @@ -0,0 +1 @@ +//! Synchronization orchestration for Radroots. diff --git a/crates/trade/Cargo.toml b/crates/trade/Cargo.toml @@ -1,16 +1,19 @@ [package] name = "radroots_trade" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] +authors.workspace = true rust-version.workspace = true license.workspace = true description = "Trade domain model for Radroots" repository.workspace = true homepage.workspace = true documentation = "https://docs.rs/radroots_trade" -readme = "README" +readme = "README.md" + +[lib] +name = "radroots_trade" [features] default = ["std", "serde", "serde_json"] @@ -19,7 +22,6 @@ dto-bindgen = [ "serde_json", "dep:dto_bindgen", "dep:dto_bindgen_core", - "radroots_core/dto-bindgen", "radroots_event/dto-bindgen", ] std = [ @@ -27,12 +29,14 @@ std = [ "radroots_event/std", "radroots_event_codec/std", "radroots_event_codec/nostr", + "radroots_identity/std", ] serde = [ "dep:serde", "radroots_core/serde", "radroots_event/serde", "radroots_event_codec/serde", + "radroots_identity/serde", ] serde_json = [ "serde", @@ -47,6 +51,8 @@ serde_json = [ radroots_core = { workspace = true, default-features = false } radroots_event = { workspace = true, default-features = false } radroots_event_codec = { workspace = true, default-features = false } +radroots_identity = { workspace = true, default-features = false } +radroots_event_store = { workspace = true, optional = true, default-features = false } base64 = { workspace = true, optional = true } dto_bindgen = { workspace = true, optional = true } dto_bindgen_core = { workspace = true, optional = true } @@ -74,5 +80,5 @@ sqlx = { workspace = true, default-features = false, features = [ ] } tokio = { workspace = true, features = ["macros", "rt"] } -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } +[lints] +workspace = true diff --git a/crates/trade/README b/crates/trade/README.md diff --git a/crates/trade/src/dto.rs b/crates/trade/src/dto.rs @@ -89,28 +89,28 @@ impl Dto for RadrootsOperationalListingTradeProjection { .with_field(field( "bin_quantity", "bin_quantity", - ts_ref("RadrootsCoreQuantity"), + ts_ref("Quantity"), "crates/trade/src/operational_listing/validation.rs", 33, )) .with_field(field( "unit", "unit", - ts_ref("RadrootsCoreUnit"), + ts_ref("Unit"), "crates/trade/src/operational_listing/validation.rs", 34, )) .with_field(field( "unit_price", "unit_price", - ts_ref("RadrootsCoreMoney"), + ts_ref("Money"), "crates/trade/src/operational_listing/validation.rs", 35, )) .with_field(field( "inventory_available", "inventory_available", - ts_ref("RadrootsCoreDecimal"), + ts_ref("Decimal"), "crates/trade/src/operational_listing/validation.rs", 36, )) @@ -182,28 +182,28 @@ fn operational_listing_total_like( .with_field(field( "price_amount", "price_amount", - ts_ref("RadrootsCoreMoney"), + ts_ref("Money"), file, line + 1, )) .with_field(field( "price_currency", "price_currency", - ts_ref("RadrootsCoreCurrency"), + ts_ref("Currency"), file, line + 2, )) .with_field(field( "quantity_amount", "quantity_amount", - ts_ref("RadrootsCoreDecimal"), + ts_ref("Decimal"), file, line + 3, )) .with_field(field( "quantity_unit", "quantity_unit", - ts_ref("RadrootsCoreUnit"), + ts_ref("Unit"), file, line + 4, )); @@ -285,7 +285,7 @@ mod tests { assert_eq!( typescript_override_target(field_ty(listing, "inventory_available")), - Some("RadrootsCoreDecimal") + Some("Decimal") ); assert_eq!( typescript_override_target(field_ty(listing, "listing")), @@ -293,7 +293,7 @@ mod tests { ); assert_eq!( typescript_override_target(field_ty(subtotal, "price_currency")), - Some("RadrootsCoreCurrency") + Some("Currency") ); } diff --git a/crates/trade/src/identity.rs b/crates/trade/src/identity.rs @@ -4,8 +4,8 @@ use core::str::FromStr; use radroots_event::ids::{ RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsIdParseError, RadrootsOrderId, - RadrootsPublicKey, }; +use radroots_identity::PublicKey; #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] @@ -66,8 +66,10 @@ pub struct RadrootsTradeLocator { pub trade_id: RadrootsTradeId, pub root_event_id: Option<RadrootsEventId>, pub listing_addr: Option<RadrootsClassifiedListingAddress>, - pub buyer_pubkey: Option<RadrootsPublicKey>, - pub seller_pubkey: Option<RadrootsPublicKey>, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub buyer_pubkey: Option<PublicKey>, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub seller_pubkey: Option<PublicKey>, } impl RadrootsTradeLocator { @@ -99,12 +101,12 @@ impl RadrootsTradeLocator { self } - pub fn with_buyer_pubkey(mut self, buyer_pubkey: RadrootsPublicKey) -> Self { + pub fn with_buyer_pubkey(mut self, buyer_pubkey: PublicKey) -> Self { self.buyer_pubkey = Some(buyer_pubkey); self } - pub fn with_seller_pubkey(mut self, seller_pubkey: RadrootsPublicKey) -> Self { + pub fn with_seller_pubkey(mut self, seller_pubkey: PublicKey) -> Self { self.seller_pubkey = Some(seller_pubkey); self } @@ -118,8 +120,10 @@ pub struct RadrootsTradeLocatorCandidate { pub trade_id: RadrootsTradeId, pub root_event_id: RadrootsEventId, pub listing_addr: RadrootsClassifiedListingAddress, - pub buyer_pubkey: RadrootsPublicKey, - pub seller_pubkey: RadrootsPublicKey, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub buyer_pubkey: PublicKey, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub seller_pubkey: PublicKey, } impl RadrootsTradeLocatorCandidate { @@ -128,8 +132,8 @@ impl RadrootsTradeLocatorCandidate { trade_id: self.trade_id.clone(), root_event_id: Some(self.root_event_id.clone()), listing_addr: Some(self.listing_addr.clone()), - buyer_pubkey: Some(self.buyer_pubkey.clone()), - seller_pubkey: Some(self.seller_pubkey.clone()), + buyer_pubkey: Some(self.buyer_pubkey), + seller_pubkey: Some(self.seller_pubkey), } } } @@ -138,9 +142,10 @@ impl RadrootsTradeLocatorCandidate { mod tests { use super::*; use radroots_event::kinds::KIND_CLASSIFIED_LISTING; + use radroots_test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_BOB_PUBLIC_KEY_HEX}; - const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const BUYER: &str = FIXTURE_BOB_PUBLIC_KEY_HEX; + const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX; fn event_id(raw: u8) -> RadrootsEventId { RadrootsEventId::parse(format!("{raw:064x}")).expect("event id") @@ -150,8 +155,8 @@ mod tests { RadrootsOrderId::parse("order-1").expect("order id") } - fn public_key(raw: &str) -> RadrootsPublicKey { - RadrootsPublicKey::parse(raw).expect("public key") + fn public_key(raw: &str) -> PublicKey { + PublicKey::from_hex(raw).expect("public key") } fn listing_addr() -> RadrootsClassifiedListingAddress { diff --git a/crates/trade/src/operational_listing/draft.rs b/crates/trade/src/operational_listing/draft.rs @@ -8,17 +8,15 @@ use core::fmt; use alloc::{format, string::ToString, vec::Vec}; #[cfg(feature = "std")] -use std::{string::ToString, vec::Vec}; +use std::vec::Vec; use radroots_event::{ - ids::{ - RadrootsClassifiedListingAddress, RadrootsIdParseError, RadrootsInventoryBinId, - RadrootsPublicKey, - }, + ids::{RadrootsClassifiedListingAddress, RadrootsIdParseError, RadrootsInventoryBinId}, kinds::KIND_CLASSIFIED_LISTING, operational_listing::RadrootsOperationalListing, trade_validation::RadrootsOperationalListingValidationError, }; +use radroots_identity::{Error as PublicKeyError, PublicKey}; use super::validation::validate_operational_listing_model; @@ -38,16 +36,16 @@ impl RadrootsOperationalListingEditDocumentV1 { #[derive(Clone, Debug)] pub struct RadrootsOperationalListingCanonicalEdit { listing: RadrootsOperationalListing, - seller_pubkey: RadrootsPublicKey, + seller_pubkey: PublicKey, public_listing_addr: RadrootsClassifiedListingAddress, } impl RadrootsOperationalListingCanonicalEdit { pub fn new( mut listing: RadrootsOperationalListing, - seller_pubkey: RadrootsPublicKey, + seller_pubkey: PublicKey, ) -> Result<Self, RadrootsOperationalListingEditError> { - let farm_pubkey = RadrootsPublicKey::parse(listing.farm.pubkey.as_str()) + let farm_pubkey = PublicKey::from_hex(listing.farm.pubkey.as_str()) .map_err(RadrootsOperationalListingEditError::InvalidFarmPubkey)?; if farm_pubkey != seller_pubkey { return Err(RadrootsOperationalListingEditError::FarmPubkeyMismatch { @@ -55,7 +53,7 @@ impl RadrootsOperationalListingCanonicalEdit { actual_pubkey: farm_pubkey, }); } - listing.farm.pubkey = farm_pubkey.as_str().to_string(); + listing.farm.pubkey = farm_pubkey.to_hex(); validate_listing_bins(&listing)?; let listing = validate_operational_listing_model(listing, &seller_pubkey) .map_err(RadrootsOperationalListingEditError::InvalidModel)? @@ -78,7 +76,7 @@ impl RadrootsOperationalListingCanonicalEdit { &self.listing } - pub fn seller_pubkey(&self) -> &RadrootsPublicKey { + pub fn seller_pubkey(&self) -> &PublicKey { &self.seller_pubkey } @@ -89,12 +87,12 @@ impl RadrootsOperationalListingCanonicalEdit { #[derive(Clone, Debug, PartialEq, Eq)] pub enum RadrootsOperationalListingEditError { - InvalidFarmPubkey(RadrootsIdParseError), + InvalidFarmPubkey(PublicKeyError), InvalidClassifiedListingAddress(RadrootsIdParseError), InvalidModel(RadrootsOperationalListingValidationError), FarmPubkeyMismatch { - expected_pubkey: RadrootsPublicKey, - actual_pubkey: RadrootsPublicKey, + expected_pubkey: PublicKey, + actual_pubkey: PublicKey, }, MissingPrimaryBin { primary_bin_id: RadrootsInventoryBinId, @@ -156,23 +154,23 @@ fn validate_listing_bins( fn listing_addr( kind: u32, - seller_pubkey: &RadrootsPublicKey, + seller_pubkey: &PublicKey, d_tag: &str, ) -> RadrootsClassifiedListingAddress { - RadrootsClassifiedListingAddress::parse(format!("{kind}:{}:{d_tag}", seller_pubkey.as_str())) + RadrootsClassifiedListingAddress::parse(format!("{kind}:{seller_pubkey}:{d_tag}")) .expect("typed listing identity must form a listing address") } /// Canonicalizes an operational listing edit after the caller has authorized /// `authorized_seller_pubkey` for the seller-owned listing boundary. pub fn canonicalize_operational_listing_edit( - authorized_seller_pubkey: &RadrootsPublicKey, + authorized_seller_pubkey: &PublicKey, mut document: RadrootsOperationalListingEditDocumentV1, ) -> Result<RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditError> { - let seller_pubkey = authorized_seller_pubkey.clone(); + let seller_pubkey = *authorized_seller_pubkey; let farm_pubkey = document.listing.farm.pubkey.as_str(); if farm_pubkey.is_empty() { - document.listing.farm.pubkey = seller_pubkey.as_str().to_string(); + document.listing.farm.pubkey = seller_pubkey.to_hex(); } RadrootsOperationalListingCanonicalEdit::new(document.listing, seller_pubkey) @@ -180,16 +178,10 @@ pub fn canonicalize_operational_listing_edit( #[cfg(test)] mod tests { - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, - }; + use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; use radroots_event::{ farm::RadrootsFarmRef, - ids::{ - RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsInventoryBinId, - RadrootsPublicKey, - }, + ids::{RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsInventoryBinId}, kinds::KIND_CLASSIFIED_LISTING, operational_listing::{ RadrootsOperationalListing, RadrootsOperationalListingAvailability, @@ -199,14 +191,16 @@ mod tests { }, trade_validation::RadrootsOperationalListingValidationError, }; + use radroots_identity::PublicKey; + use radroots_test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_BOB_PUBLIC_KEY_HEX}; use super::{ RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1, RadrootsOperationalListingEditError, canonicalize_operational_listing_edit, }; - const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX; + const OTHER: &str = FIXTURE_BOB_PUBLIC_KEY_HEX; fn d_tag(raw: &str) -> RadrootsDTag { RadrootsDTag::parse(raw).expect("d tag") @@ -238,20 +232,12 @@ mod tests { primary_bin_id: bin_id("bin-1"), bins: vec![RadrootsOperationalListingBin { bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice { - amount: RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(20u32), - RadrootsCoreCurrency::USD, - ), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::MassG, - ), - }, + quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(), + price_per_canonical_unit: QuantityPrice::try_new( + Money::try_new(Decimal::from(20u32), Currency::USD).unwrap(), + Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(), + ) + .unwrap(), display_amount: None, display_unit: None, display_label: None, @@ -261,7 +247,7 @@ mod tests { resource_area: None, plot: None, discounts: None, - inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + inventory_available: Some(Decimal::from(5u32)), availability: Some(RadrootsOperationalListingAvailability::Status { status: RadrootsOperationalListingStatus::Active, }), @@ -277,8 +263,8 @@ mod tests { } } - fn seller_pubkey() -> RadrootsPublicKey { - RadrootsPublicKey::parse(SELLER).expect("seller") + fn seller_pubkey() -> PublicKey { + PublicKey::from_hex(SELLER).expect("seller") } #[test] @@ -300,18 +286,17 @@ mod tests { let canonical = canonicalize_operational_listing_edit(&seller_pubkey(), document) .expect("canonical draft"); - assert_eq!(canonical.seller_pubkey().as_str(), SELLER); + assert_eq!(canonical.seller_pubkey().to_hex(), SELLER); assert_eq!(canonical.listing().product.title, "Coffee"); } #[test] fn canonical_draft_carries_seller_listing_and_addresses() { - let seller_pubkey = RadrootsPublicKey::parse(SELLER).expect("seller"); + let seller_pubkey = PublicKey::from_hex(SELLER).expect("seller"); let listing = listing(); - let canonical = - RadrootsOperationalListingCanonicalEdit::new(listing, seller_pubkey.clone()) - .expect("canonical"); + let canonical = RadrootsOperationalListingCanonicalEdit::new(listing, seller_pubkey) + .expect("canonical"); assert_eq!(canonical.seller_pubkey(), &seller_pubkey); assert_eq!( @@ -325,7 +310,7 @@ mod tests { fn listing_edit_error_variants_are_precise() { assert!(matches!( RadrootsOperationalListingEditError::InvalidFarmPubkey( - RadrootsPublicKey::parse("bad").unwrap_err() + PublicKey::from_hex("bad").unwrap_err() ), RadrootsOperationalListingEditError::InvalidFarmPubkey(_) )); @@ -346,7 +331,7 @@ mod tests { let canonical = canonicalize_operational_listing_edit(&seller_pubkey(), document) .expect("canonical draft"); - assert_eq!(canonical.seller_pubkey().as_str(), SELLER); + assert_eq!(canonical.seller_pubkey().to_hex(), SELLER); assert_eq!( canonical.public_listing_addr().as_str(), format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") @@ -389,7 +374,7 @@ mod tests { let error = RadrootsOperationalListingCanonicalEdit::new( listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), + PublicKey::from_hex(SELLER).expect("seller"), ) .unwrap_err(); @@ -406,7 +391,7 @@ mod tests { let error = RadrootsOperationalListingCanonicalEdit::new( listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), + PublicKey::from_hex(SELLER).expect("seller"), ) .unwrap_err(); @@ -423,7 +408,7 @@ mod tests { let error = RadrootsOperationalListingCanonicalEdit::new( listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), + PublicKey::from_hex(SELLER).expect("seller"), ) .unwrap_err(); @@ -456,7 +441,7 @@ mod tests { let error = RadrootsOperationalListingCanonicalEdit::new( listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), + PublicKey::from_hex(SELLER).expect("seller"), ) .unwrap_err(); @@ -491,7 +476,7 @@ mod tests { let error = RadrootsOperationalListingCanonicalEdit::new( listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), + PublicKey::from_hex(SELLER).expect("seller"), ) .unwrap_err(); @@ -508,12 +493,12 @@ mod tests { let mut invalid_quantity_listing = listing(); let mut secondary_bin = invalid_quantity_listing.bins[0].clone(); secondary_bin.bin_id = bin_id("bin-2"); - secondary_bin.quantity.amount = "-1".parse().expect("negative decimal"); + secondary_bin.quantity = Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(); invalid_quantity_listing.bins.push(secondary_bin); let error = RadrootsOperationalListingCanonicalEdit::new( invalid_quantity_listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), + PublicKey::from_hex(SELLER).expect("seller"), ) .unwrap_err(); @@ -527,12 +512,16 @@ mod tests { let mut mismatched_unit_listing = listing(); let mut secondary_bin = mismatched_unit_listing.bins[0].clone(); secondary_bin.bin_id = bin_id("bin-2"); - secondary_bin.price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::Each; + secondary_bin.price_per_canonical_unit = QuantityPrice::try_new( + secondary_bin.price_per_canonical_unit.amount().clone(), + Quantity::try_new(Decimal::ONE, Unit::Each).unwrap(), + ) + .unwrap(); mismatched_unit_listing.bins.push(secondary_bin); let error = RadrootsOperationalListingCanonicalEdit::new( mismatched_unit_listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), + PublicKey::from_hex(SELLER).expect("seller"), ) .unwrap_err(); @@ -551,7 +540,7 @@ mod tests { let error = RadrootsOperationalListingCanonicalEdit::new( listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), + PublicKey::from_hex(SELLER).expect("seller"), ) .unwrap_err(); diff --git a/crates/trade/src/operational_listing/mod.rs b/crates/trade/src/operational_listing/mod.rs @@ -8,11 +8,12 @@ use radroots_event::{ RadrootsEventEnvelope, ids::{ RadrootsAddressableCoordinateParts, RadrootsClassifiedListingAddress, RadrootsDTag, - RadrootsIdParseError, RadrootsPublicKey, + RadrootsIdParseError, }, operational_listing::{RadrootsOperationalListing, RadrootsOperationalListingParseError}, }; use radroots_event_codec::operational_listing::decode::operational_listing_from_nostr_event; +use radroots_identity::PublicKey; pub use self::draft::{ RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1, @@ -32,7 +33,7 @@ pub use self::validation::{ pub struct RadrootsClassifiedListingAddressParts { pub address: RadrootsClassifiedListingAddress, pub kind: u32, - pub seller_pubkey: RadrootsPublicKey, + pub seller_pubkey: PublicKey, pub listing_id: RadrootsDTag, } @@ -46,7 +47,7 @@ impl RadrootsClassifiedListingAddressParts { pub struct RadrootsPublicClassifiedListingAddress { pub address: RadrootsClassifiedListingAddress, pub kind: u32, - pub seller_pubkey: RadrootsPublicKey, + pub seller_pubkey: PublicKey, pub listing_id: RadrootsDTag, } @@ -186,9 +187,9 @@ mod tests { assert_eq!(listing.address.as_str(), raw); assert_eq!(public.address.as_str(), raw); assert_eq!(typed.address.as_str(), raw); - assert_eq!(listing.seller_pubkey.as_str(), SELLER); - assert_eq!(public.seller_pubkey.as_str(), SELLER); - assert_eq!(typed.seller_pubkey.as_str(), SELLER); + assert_eq!(listing.seller_pubkey.to_hex(), SELLER); + assert_eq!(public.seller_pubkey.to_hex(), SELLER); + assert_eq!(typed.seller_pubkey.to_hex(), SELLER); } #[test] @@ -198,7 +199,7 @@ mod tests { assert_eq!(parsed.address.as_str(), raw); assert_eq!(parsed.kind, KIND_CLASSIFIED_LISTING); - assert_eq!(parsed.seller_pubkey.as_str(), SELLER); + assert_eq!(parsed.seller_pubkey.to_hex(), SELLER); assert_eq!(parsed.listing_id.as_str(), "listing-1"); } diff --git a/crates/trade/src/operational_listing/model.rs b/crates/trade/src/operational_listing/model.rs @@ -1,17 +1,17 @@ #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsOperationalListingSubtotal { - pub price_amount: radroots_core::RadrootsCoreMoney, - pub price_currency: radroots_core::RadrootsCoreCurrency, - pub quantity_amount: radroots_core::RadrootsCoreDecimal, - pub quantity_unit: radroots_core::RadrootsCoreUnit, + pub price_amount: radroots_core::Money, + pub price_currency: radroots_core::Currency, + pub quantity_amount: radroots_core::Decimal, + pub quantity_unit: radroots_core::Unit, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsOperationalListingTotal { - pub price_amount: radroots_core::RadrootsCoreMoney, - pub price_currency: radroots_core::RadrootsCoreCurrency, - pub quantity_amount: radroots_core::RadrootsCoreDecimal, - pub quantity_unit: radroots_core::RadrootsCoreUnit, + pub price_amount: radroots_core::Money, + pub price_currency: radroots_core::Currency, + pub quantity_amount: radroots_core::Decimal, + pub quantity_unit: radroots_core::Unit, } diff --git a/crates/trade/src/operational_listing/mutation.rs b/crates/trade/src/operational_listing/mutation.rs @@ -162,24 +162,18 @@ pub fn build_operational_listing_mutation_draft( created_at, parts.tags, parts.content, - draft.seller_pubkey().as_str(), + draft.seller_pubkey().to_hex(), ) .map_err(RadrootsOperationalListingMutationError::FrozenDraft) } #[cfg(test)] mod tests { - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, - }; + use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; use radroots_event::{ contract::validate_event_contract_shape, farm::RadrootsFarmRef, - ids::{ - RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsInventoryBinId, - RadrootsPublicKey, - }, + ids::{RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsInventoryBinId}, kinds::KIND_CLASSIFIED_LISTING, operational_listing::{ RadrootsOperationalListing, RadrootsOperationalListingAvailability, @@ -190,6 +184,7 @@ mod tests { resource_area::RadrootsResourceAreaRef, }; use radroots_event_codec::verification::verify_nip01_event; + use radroots_identity::PublicKey; use radroots_nostr::prelude::{ RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, }; @@ -236,20 +231,12 @@ mod tests { primary_bin_id: bin_id("bin-1"), bins: vec![RadrootsOperationalListingBin { bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice { - amount: RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(20u32), - RadrootsCoreCurrency::USD, - ), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::MassG, - ), - }, + quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(), + price_per_canonical_unit: QuantityPrice::try_new( + Money::try_new(Decimal::from(20u32), Currency::USD).unwrap(), + Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(), + ) + .unwrap(), display_amount: None, display_unit: None, display_label: None, @@ -259,7 +246,7 @@ mod tests { resource_area: None, plot: None, discounts: None, - inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + inventory_available: Some(Decimal::from(5u32)), availability: Some(RadrootsOperationalListingAvailability::Status { status: RadrootsOperationalListingStatus::Active, }), @@ -278,7 +265,7 @@ mod tests { fn canonical_draft() -> RadrootsOperationalListingCanonicalEdit { RadrootsOperationalListingCanonicalEdit::new( listing(), - RadrootsPublicKey::parse(SELLER).expect("seller"), + PublicKey::from_hex(SELLER).expect("seller"), ) .expect("canonical listing edit") } @@ -316,7 +303,7 @@ mod tests { .canonical_draft() .expect("draft") .seller_pubkey() - .as_str(), + .to_hex(), SELLER ); assert_eq!( @@ -324,7 +311,7 @@ mod tests { .canonical_draft() .expect("draft") .seller_pubkey() - .as_str(), + .to_hex(), SELLER ); assert_eq!( @@ -332,7 +319,7 @@ mod tests { .canonical_draft() .expect("draft") .seller_pubkey() - .as_str(), + .to_hex(), SELLER ); assert_eq!( @@ -404,7 +391,7 @@ mod tests { publish_draft.contract_id(), OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID ); - assert_eq!(publish_draft.expected_pubkey_str(), SELLER); + assert_eq!(publish_draft.expected_pubkey().to_hex(), SELLER); assert_eq!(publish_draft.created_at_u64(), 1_700_000_000); assert_eq!(publish_draft.content(), "# Coffee\n\nSingle origin coffee"); assert_eq!(update_draft.kind_u32(), KIND_CLASSIFIED_LISTING); @@ -412,7 +399,7 @@ mod tests { update_draft.contract_id(), OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID ); - assert_eq!(update_draft.expected_pubkey_str(), SELLER); + assert_eq!(update_draft.expected_pubkey().to_hex(), SELLER); } #[test] @@ -449,7 +436,7 @@ mod tests { }); let draft = RadrootsOperationalListingCanonicalEdit::new( listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), + PublicKey::from_hex(SELLER).expect("seller"), ) .expect("canonical listing edit"); let publish = RadrootsOperationalListingMutation::publish(draft); diff --git a/crates/trade/src/operational_listing/price_ext.rs b/crates/trade/src/operational_listing/price_ext.rs @@ -1,84 +1,57 @@ use crate::operational_listing::model::{ RadrootsOperationalListingSubtotal, RadrootsOperationalListingTotal, }; -use radroots_core::{ - RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreQuantityPriceError, - RadrootsCoreQuantityPriceOps, -}; +use radroots_core::pricing::{Error as PricingError, QuantityPriceOps}; +use radroots_core::{Decimal, Quantity}; use radroots_event::operational_listing::RadrootsOperationalListingBin; -pub trait BinPricingExt { - fn subtotal_for_count(&self, bin_count: u32) -> RadrootsOperationalListingSubtotal; - fn total_for_count(&self, bin_count: u32) -> RadrootsOperationalListingTotal; -} - pub trait BinPricingTryExt { fn try_subtotal_for_count( &self, bin_count: u32, - ) -> Result<RadrootsOperationalListingSubtotal, RadrootsCoreQuantityPriceError>; + ) -> Result<RadrootsOperationalListingSubtotal, PricingError>; fn try_total_for_count( &self, bin_count: u32, - ) -> Result<RadrootsOperationalListingTotal, RadrootsCoreQuantityPriceError>; + ) -> Result<RadrootsOperationalListingTotal, PricingError>; } #[inline] -fn effective_quantity(bin: &RadrootsOperationalListingBin, bin_count: u32) -> RadrootsCoreQuantity { - let amount = bin.quantity.amount * RadrootsCoreDecimal::from(bin_count); - RadrootsCoreQuantity::new(amount, bin.quantity.unit) -} - -impl BinPricingExt for RadrootsOperationalListingBin { - fn subtotal_for_count(&self, bin_count: u32) -> RadrootsOperationalListingSubtotal { - let effective_qty = effective_quantity(self, bin_count); - let money = self - .price_per_canonical_unit - .cost_for_rounded(&effective_qty); - let currency = money.currency; - - RadrootsOperationalListingSubtotal { - price_amount: money, - price_currency: currency, - quantity_amount: effective_qty.amount, - quantity_unit: effective_qty.unit, - } - } - - fn total_for_count(&self, bin_count: u32) -> RadrootsOperationalListingTotal { - let sub = self.subtotal_for_count(bin_count); - RadrootsOperationalListingTotal { - price_amount: sub.price_amount, - price_currency: sub.price_currency, - quantity_amount: sub.quantity_amount, - quantity_unit: sub.quantity_unit, - } - } +fn effective_quantity( + bin: &RadrootsOperationalListingBin, + bin_count: u32, +) -> Result<Quantity, PricingError> { + let amount = bin + .quantity + .amount() + .checked_mul(Decimal::from(bin_count)) + .map_err(|_| PricingError::ArithmeticOverflow)?; + Quantity::try_new(amount, bin.quantity.unit()).map_err(|_| PricingError::ArithmeticOverflow) } impl BinPricingTryExt for RadrootsOperationalListingBin { fn try_subtotal_for_count( &self, bin_count: u32, - ) -> Result<RadrootsOperationalListingSubtotal, RadrootsCoreQuantityPriceError> { - let effective_qty = effective_quantity(self, bin_count); + ) -> Result<RadrootsOperationalListingSubtotal, PricingError> { + let effective_qty = effective_quantity(self, bin_count)?; let money = self .price_per_canonical_unit .try_cost_for_rounded(&effective_qty)?; - let currency = money.currency; + let currency = money.currency(); Ok(RadrootsOperationalListingSubtotal { price_amount: money, price_currency: currency, - quantity_amount: effective_qty.amount, - quantity_unit: effective_qty.unit, + quantity_amount: effective_qty.amount(), + quantity_unit: effective_qty.unit(), }) } fn try_total_for_count( &self, bin_count: u32, - ) -> Result<RadrootsOperationalListingTotal, RadrootsCoreQuantityPriceError> { + ) -> Result<RadrootsOperationalListingTotal, PricingError> { let sub = self.try_subtotal_for_count(bin_count)?; Ok(RadrootsOperationalListingTotal { price_amount: sub.price_amount, @@ -91,11 +64,9 @@ impl BinPricingTryExt for RadrootsOperationalListingBin { #[cfg(test)] mod tests { - use super::{BinPricingExt, BinPricingTryExt}; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreUnit, - }; + use super::BinPricingTryExt; + use radroots_core::pricing::Error as PricingError; + use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; use radroots_event::ids::RadrootsInventoryBinId; use radroots_event::operational_listing::RadrootsOperationalListingBin; @@ -106,14 +77,12 @@ mod tests { fn valid_bin() -> RadrootsOperationalListingBin { RadrootsOperationalListingBin { bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(2u32), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(5u32), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG), - ), + quantity: Quantity::try_new(Decimal::from(2u32), Unit::MassG).unwrap(), + price_per_canonical_unit: QuantityPrice::try_new( + Money::try_new(Decimal::from(5u32), Currency::USD).unwrap(), + Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(), + ) + .unwrap(), display_amount: None, display_unit: None, display_label: None, @@ -126,14 +95,12 @@ mod tests { fn try_subtotal_for_rejects_unit_mismatch() { let bin = RadrootsOperationalListingBin { bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each), - ), + quantity: Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(), + price_per_canonical_unit: QuantityPrice::try_new( + Money::try_new(Decimal::from(10u32), Currency::USD).unwrap(), + Quantity::try_new(Decimal::from(1u32), Unit::Each).unwrap(), + ) + .unwrap(), display_amount: None, display_unit: None, display_label: None, @@ -144,9 +111,9 @@ mod tests { let err = bin.try_subtotal_for_count(1).unwrap_err(); assert_eq!( err, - RadrootsCoreQuantityPriceError::UnitMismatch { - have: RadrootsCoreUnit::MassG, - want: RadrootsCoreUnit::Each, + PricingError::UnitMismatch { + have: Unit::MassG, + want: Unit::Each, } ); } @@ -154,16 +121,13 @@ mod tests { #[test] fn subtotal_and_total_for_count_follow_effective_quantity() { let bin = valid_bin(); - let subtotal = bin.subtotal_for_count(3); - let total = bin.total_for_count(3); + let subtotal = bin.try_subtotal_for_count(3).unwrap(); + let total = bin.try_total_for_count(3).unwrap(); - assert_eq!(subtotal.quantity_amount, RadrootsCoreDecimal::from(6u32)); - assert_eq!(subtotal.quantity_unit, RadrootsCoreUnit::MassG); - assert_eq!( - subtotal.price_amount.amount, - RadrootsCoreDecimal::from(30u32) - ); - assert_eq!(subtotal.price_currency, RadrootsCoreCurrency::USD); + assert_eq!(subtotal.quantity_amount, Decimal::from(6u32)); + assert_eq!(subtotal.quantity_unit, Unit::MassG); + assert_eq!(subtotal.price_amount.amount(), Decimal::from(30u32)); + assert_eq!(subtotal.price_currency, Currency::USD); assert_eq!(total.quantity_amount, subtotal.quantity_amount); assert_eq!(total.quantity_unit, subtotal.quantity_unit); @@ -172,16 +136,13 @@ mod tests { } #[test] - fn try_subtotal_and_try_total_match_non_fallible_paths() { + fn try_subtotal_and_try_total_match() { let bin = valid_bin(); let subtotal = bin.try_subtotal_for_count(4).expect("subtotal"); let total = bin.try_total_for_count(4).expect("total"); - assert_eq!(subtotal.quantity_amount, RadrootsCoreDecimal::from(8u32)); - assert_eq!( - subtotal.price_amount.amount, - RadrootsCoreDecimal::from(40u32) - ); + assert_eq!(subtotal.quantity_amount, Decimal::from(8u32)); + assert_eq!(subtotal.price_amount.amount(), Decimal::from(40u32)); assert_eq!(total.quantity_amount, subtotal.quantity_amount); assert_eq!(total.price_amount, subtotal.price_amount); } @@ -190,14 +151,12 @@ mod tests { fn try_total_for_count_propagates_subtotal_errors() { let bin = RadrootsOperationalListingBin { bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each), - ), + quantity: Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(), + price_per_canonical_unit: QuantityPrice::try_new( + Money::try_new(Decimal::from(10u32), Currency::USD).unwrap(), + Quantity::try_new(Decimal::from(1u32), Unit::Each).unwrap(), + ) + .unwrap(), display_amount: None, display_unit: None, display_label: None, @@ -208,9 +167,9 @@ mod tests { let err = bin.try_total_for_count(1).unwrap_err(); assert_eq!( err, - RadrootsCoreQuantityPriceError::UnitMismatch { - have: RadrootsCoreUnit::MassG, - want: RadrootsCoreUnit::Each, + PricingError::UnitMismatch { + have: Unit::MassG, + want: Unit::Each, } ); } diff --git a/crates/trade/src/operational_listing/validation.rs b/crates/trade/src/operational_listing/validation.rs @@ -6,12 +6,10 @@ use alloc::{ string::{String, ToString}, }; -use radroots_core::{ - RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreUnit, -}; +use radroots_core::{Decimal, Money, Quantity, Unit}; use radroots_event::{ classified_listing::{RadrootsClassifiedListingPartition, classify_classified_listing_tags}, - ids::{RadrootsClassifiedListingAddress, RadrootsPublicKey}, + ids::RadrootsClassifiedListingAddress, kinds::{KIND_CLASSIFIED_LISTING, is_classified_listing_kind}, location::{has_textual_locality, is_public_geohash5}, operational_listing::{ @@ -21,6 +19,7 @@ use radroots_event::{ }, trade_validation::RadrootsOperationalListingValidationError as OperationalListingValidationError, }; +use radroots_identity::PublicKey; use radroots_event_codec::{ operational_listing::decode::operational_listing_from_nostr_event, @@ -37,10 +36,10 @@ pub struct RadrootsOperationalListingTradeProjection { pub description: String, pub product_type: String, pub primary_bin_id: String, - pub bin_quantity: RadrootsCoreQuantity, - pub unit: RadrootsCoreUnit, - pub unit_price: RadrootsCoreMoney, - pub inventory_available: RadrootsCoreDecimal, + pub bin_quantity: Quantity, + pub unit: Unit, + pub unit_price: Money, + pub inventory_available: Decimal, pub availability: RadrootsOperationalListingAvailability, pub location: RadrootsOperationalListingPublicLocation, pub delivery_method: RadrootsOperationalListingDeliveryMethod, @@ -88,17 +87,14 @@ pub fn validate_operational_listing_event( /// [`validate_operational_listing_event`] instead. pub fn validate_operational_listing_model( listing: RadrootsOperationalListing, - seller_pubkey: &RadrootsPublicKey, + seller_pubkey: &PublicKey, ) -> Result<RadrootsOperationalListingTradeProjection, OperationalListingValidationError> { let listing_id = listing.d_tag.trim().to_string(); - if listing.farm.pubkey != seller_pubkey.as_str() { + if listing.farm.pubkey != seller_pubkey.to_hex() { return Err(OperationalListingValidationError::InvalidSeller); } - let listing_addr_raw = format!( - "{KIND_CLASSIFIED_LISTING}:{}:{listing_id}", - seller_pubkey.as_str() - ); + let listing_addr_raw = format!("{KIND_CLASSIFIED_LISTING}:{}:{listing_id}", seller_pubkey); let listing_addr = RadrootsClassifiedListingAddress::parse(&listing_addr_raw) .expect("validated listing identity must form a listing address"); @@ -184,14 +180,14 @@ pub fn validate_operational_listing_model( Ok(RadrootsOperationalListingTradeProjection { listing_id, listing_addr, - seller_pubkey: seller_pubkey.as_str().to_string(), + seller_pubkey: seller_pubkey.to_hex(), title, description, product_type, primary_bin_id: primary_bin_id.clone(), bin_quantity: primary_bin.quantity.clone(), - unit: primary_bin.quantity.unit, - unit_price: primary_bin.price_per_canonical_unit.amount.clone(), + unit: primary_bin.quantity.unit(), + unit_price: primary_bin.price_per_canonical_unit.amount().clone(), inventory_available, availability, location, @@ -203,16 +199,16 @@ pub fn validate_operational_listing_model( fn validate_listing_bin( bin: &RadrootsOperationalListingBin, ) -> Result<(), OperationalListingValidationError> { - if bin.quantity.amount.is_sign_negative() || !bin.quantity.is_canonical() { + if bin.quantity.amount().is_sign_negative() || !bin.quantity.is_canonical() { return Err(OperationalListingValidationError::InvalidBin); } if !bin.price_per_canonical_unit.is_price_per_canonical_unit() || bin .price_per_canonical_unit - .amount - .amount + .amount() + .amount() .is_sign_negative() - || bin.price_per_canonical_unit.quantity.unit != bin.quantity.unit + || bin.price_per_canonical_unit.quantity().unit() != bin.quantity.unit() { return Err(OperationalListingValidationError::InvalidPrice); } @@ -239,14 +235,11 @@ mod tests { validate_operational_listing_model, }; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, - }; + use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeParts, farm::RadrootsFarmRef, - ids::{RadrootsDTag, RadrootsInventoryBinId, RadrootsPublicKey}, + ids::{RadrootsDTag, RadrootsInventoryBinId}, kinds::KIND_CLASSIFIED_LISTING, operational_listing::{ RadrootsOperationalListing, RadrootsOperationalListingAvailability, @@ -255,6 +248,7 @@ mod tests { }, }; use radroots_event_codec::verification::{RadrootsSignatureVerifiedEvent, verify_nip01_event}; + use radroots_identity::PublicKey; use radroots_nostr::prelude::radroots_event_from_nostr; use radroots_test_fixtures::{ FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_ALICE_SECRET_KEY_HEX, FIXTURE_BOB_PUBLIC_KEY_HEX, @@ -272,12 +266,12 @@ mod tests { RadrootsInventoryBinId::parse(raw).expect("bin id") } - fn seller_pubkey() -> RadrootsPublicKey { - RadrootsPublicKey::parse(SELLER).expect("seller pubkey") + fn seller_pubkey() -> PublicKey { + PublicKey::from_hex(SELLER).expect("seller pubkey") } - fn other_seller_pubkey() -> RadrootsPublicKey { - RadrootsPublicKey::parse(OTHER_SELLER).expect("other seller pubkey") + fn other_seller_pubkey() -> PublicKey { + PublicKey::from_hex(OTHER_SELLER).expect("other seller pubkey") } fn base_listing() -> RadrootsOperationalListing { @@ -302,20 +296,12 @@ mod tests { primary_bin_id: bin_id("bin-1"), bins: vec![RadrootsOperationalListingBin { bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice { - amount: RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(20u32), - RadrootsCoreCurrency::USD, - ), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::MassG, - ), - }, + quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(), + price_per_canonical_unit: QuantityPrice::try_new( + Money::try_new(Decimal::from(20u32), Currency::USD).unwrap(), + Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(), + ) + .unwrap(), display_amount: None, display_unit: None, display_label: None, @@ -325,7 +311,7 @@ mod tests { resource_area: None, plot: None, discounts: None, - inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + inventory_available: Some(Decimal::from(5u32)), availability: Some(RadrootsOperationalListingAvailability::Status { status: radroots_event::operational_listing::RadrootsOperationalListingStatus::Active, @@ -366,22 +352,22 @@ mod tests { tags.push(vec![ "radroots:bin".into(), bin.bin_id.to_string(), - bin.quantity.amount.to_string(), - bin.quantity.unit.code().to_string(), + bin.quantity.amount().to_string(), + bin.quantity.unit().code().to_string(), ]); tags.push(vec![ "radroots:price".into(), bin.bin_id.to_string(), - bin.price_per_canonical_unit.amount.amount.to_string(), + bin.price_per_canonical_unit.amount().amount().to_string(), bin.price_per_canonical_unit - .amount - .currency + .amount() + .currency() .as_str() .to_string(), - bin.price_per_canonical_unit.quantity.amount.to_string(), + bin.price_per_canonical_unit.quantity().amount().to_string(), bin.price_per_canonical_unit - .quantity - .unit + .quantity() + .unit() .code() .to_string(), ]); @@ -503,19 +489,6 @@ mod tests { listing } - fn assert_model_and_signed_event_error( - listing: RadrootsOperationalListing, - expected: OperationalListingValidationError, - ) { - let event_error = validate_operational_listing_event(&base_event(&listing)) - .expect_err("signed event semantic error"); - let model_error = validate_operational_listing_model(listing, &seller_pubkey()) - .expect_err("model semantic error"); - - assert_eq!(event_error, expected); - assert_eq!(model_error, event_error); - } - #[test] fn validate_listing_ok() { let listing = base_listing(); @@ -560,18 +533,6 @@ mod tests { let model_error = validate_operational_listing_model(listing, &other_seller_pubkey()) .expect_err("model seller error"); assert_eq!(model_error, event_error); - - let mut listing = listing_with_secondary_bin(); - listing.bins[1].quantity.amount = "-1".parse().expect("negative decimal"); - assert_model_and_signed_event_error(listing, OperationalListingValidationError::InvalidBin); - - let mut listing = listing_with_secondary_bin(); - listing.bins[1].price_per_canonical_unit.amount.amount = - "-1".parse().expect("negative decimal"); - assert_model_and_signed_event_error( - listing, - OperationalListingValidationError::InvalidPrice, - ); } #[test] @@ -620,11 +581,7 @@ mod tests { #[test] fn model_validation_rejects_invalid_secondary_bin_quantities() { assert_secondary_bin_model_error( - |bin| bin.quantity.amount = "-1".parse().expect("negative decimal"), - OperationalListingValidationError::InvalidBin, - ); - assert_secondary_bin_model_error( - |bin| bin.quantity.unit = RadrootsCoreUnit::MassKg, + |bin| bin.quantity = Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(), OperationalListingValidationError::InvalidBin, ); } @@ -633,26 +590,31 @@ mod tests { fn model_validation_rejects_invalid_secondary_bin_prices() { assert_secondary_bin_model_error( |bin| { - bin.price_per_canonical_unit.quantity.amount = RadrootsCoreDecimal::from(2u32); + bin.price_per_canonical_unit = QuantityPrice::try_new( + bin.price_per_canonical_unit.amount().clone(), + Quantity::try_new(Decimal::from(2u32), Unit::MassG).unwrap(), + ) + .unwrap(); }, OperationalListingValidationError::InvalidPrice, ); assert_secondary_bin_model_error( |bin| { - bin.price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::MassKg; + bin.price_per_canonical_unit = QuantityPrice::try_new( + bin.price_per_canonical_unit.amount().clone(), + Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(), + ) + .unwrap(); }, OperationalListingValidationError::InvalidPrice, ); assert_secondary_bin_model_error( |bin| { - bin.price_per_canonical_unit.amount.amount = - "-1".parse().expect("negative decimal"); - }, - OperationalListingValidationError::InvalidPrice, - ); - assert_secondary_bin_model_error( - |bin| { - bin.price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::Each; + bin.price_per_canonical_unit = QuantityPrice::try_new( + bin.price_per_canonical_unit.amount().clone(), + Quantity::try_new(Decimal::ONE, Unit::Each).unwrap(), + ) + .unwrap(); }, OperationalListingValidationError::InvalidPrice, ); @@ -775,7 +737,7 @@ mod tests { let event = verified.into_event(); let tampered = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: event.id_str().to_owned(), - author: event.author_str().to_owned(), + author: event.author().to_hex().to_owned(), created_at: event.created_at_u64(), kind: event.kind_u32(), tags: event.tags_as_vec(), @@ -838,16 +800,9 @@ mod tests { } #[test] - fn validate_listing_rejects_negative_quantity() { - let mut listing = base_listing(); - listing.bins[0].quantity.amount = "-1".parse().unwrap(); - assert_validation_err(listing, OperationalListingValidationError::InvalidBin); - } - - #[test] fn validate_listing_rejects_non_canonical_quantity() { let mut listing = base_listing(); - listing.bins[0].quantity.unit = RadrootsCoreUnit::MassKg; + listing.bins[0].quantity = Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(); assert_validation_err( listing, OperationalListingValidationError::ParseError { @@ -859,7 +814,11 @@ mod tests { #[test] fn validate_listing_rejects_non_canonical_price_quantity() { let mut listing = base_listing(); - listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::MassKg; + listing.bins[0].price_per_canonical_unit = QuantityPrice::try_new( + listing.bins[0].price_per_canonical_unit.amount().clone(), + Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(), + ) + .unwrap(); assert_validation_err( listing, OperationalListingValidationError::ParseError { @@ -869,16 +828,13 @@ mod tests { } #[test] - fn validate_listing_rejects_negative_price_amount() { - let mut listing = base_listing(); - listing.bins[0].price_per_canonical_unit.amount.amount = "-1".parse().unwrap(); - assert_validation_err(listing, OperationalListingValidationError::InvalidPrice); - } - - #[test] fn validate_listing_rejects_price_unit_mismatch() { let mut listing = base_listing(); - listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::Each; + listing.bins[0].price_per_canonical_unit = QuantityPrice::try_new( + listing.bins[0].price_per_canonical_unit.amount().clone(), + Quantity::try_new(Decimal::ONE, Unit::Each).unwrap(), + ) + .unwrap(); assert_validation_err( listing, OperationalListingValidationError::ParseError { diff --git a/crates/trade/src/validation_receipt.rs b/crates/trade/src/validation_receipt.rs @@ -14,11 +14,12 @@ use core::fmt; use base64::Engine as _; use radroots_event::{ RadrootsEventEnvelope, - ids::{RadrootsAddressableCoordinate, RadrootsAddressableCoordinateParts, RadrootsPublicKey}, + ids::{RadrootsAddressableCoordinate, RadrootsAddressableCoordinateParts}, kinds::{KIND_TRADE_VALIDATION_RECEIPT, KIND_VALIDATOR_SET}, tags::{TAG_A, TAG_D}, wire::RadrootsNip01EventWireParts, }; +use radroots_identity::PublicKey; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -41,7 +42,7 @@ pub const VALIDATOR_SET_V1_THRESHOLD: u8 = 1; #[serde(deny_unknown_fields)] pub struct RadrootsValidatorSetV1 { pub set_id: String, - pub validator_pubkey: RadrootsPublicKey, + pub validator_pubkey: PublicKey, pub threshold: u8, pub valid_from: u64, pub valid_until: u64, @@ -55,7 +56,7 @@ pub struct RadrootsVerifiedValidatorSetV1 { pub set: RadrootsValidatorSetV1, pub event_id: String, pub address: RadrootsAddressableCoordinate, - pub authority_pubkey: RadrootsPublicKey, + pub authority_pubkey: PublicKey, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -207,7 +208,7 @@ impl RadrootsTradeValidationTrustPolicy { self } - pub fn trusts_validator_pubkey(&self, pubkey: &RadrootsPublicKey) -> bool { + pub fn trusts_validator_pubkey(&self, pubkey: &PublicKey) -> bool { self.validator_set .as_ref() .is_some_and(|validator_set| validator_set.validator_pubkey == *pubkey) @@ -456,7 +457,7 @@ impl RadrootsValidatorSetV1 { } pub fn validator_set_address( - authority_pubkey: &RadrootsPublicKey, + authority_pubkey: &PublicKey, set_id: &str, ) -> Result<RadrootsAddressableCoordinate, RadrootsValidationReceiptError> { validate_uuidv7(set_id, "validator_set.set_id")?; @@ -514,7 +515,7 @@ pub fn validator_set_from_event( pub fn verify_validator_set_event( event: &RadrootsEventEnvelope, - expected_author: Option<&RadrootsPublicKey>, + expected_author: Option<&PublicKey>, ) -> Result<RadrootsVerifiedValidatorSetV1, RadrootsValidationReceiptError> { if event.kind_u32() != KIND_VALIDATOR_SET { return Err(RadrootsValidationReceiptError::InvalidKind { @@ -542,7 +543,7 @@ pub fn verify_validator_set_event( set: validator_set, event_id: event.id_str().to_owned(), address, - authority_pubkey: event.author().clone(), + authority_pubkey: *event.author(), }) } @@ -1176,10 +1177,14 @@ mod tests { }; use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeParts, - ids::{RadrootsAddressableCoordinate, RadrootsPublicKey}, + ids::RadrootsAddressableCoordinate, kinds::{KIND_TRADE_VALIDATION_RECEIPT, KIND_VALIDATOR_SET}, tags::TAG_D, }; + use radroots_identity::PublicKey; + use radroots_test_fixtures::{ + FIXTURE_BOB_PUBLIC_KEY_HEX, FIXTURE_CAROL_PUBLIC_KEY_HEX, FIXTURE_DIEGO_PUBLIC_KEY_HEX, + }; #[test] fn validation_receipt_error_messages_are_stable() { @@ -1246,12 +1251,12 @@ mod tests { "018f3d99-7d35-7c0c-8a0f-7f3b645abcde".to_string() } - fn validator_set_author() -> RadrootsPublicKey { - RadrootsPublicKey::parse(event_id('d')).expect("validator set author") + fn validator_set_author() -> PublicKey { + PublicKey::from_hex(FIXTURE_DIEGO_PUBLIC_KEY_HEX).expect("validator set author") } - fn validator_set_pubkey() -> RadrootsPublicKey { - RadrootsPublicKey::parse(event_id('e')).expect("validator pubkey") + fn validator_set_pubkey() -> PublicKey { + PublicKey::from_hex(FIXTURE_CAROL_PUBLIC_KEY_HEX).expect("validator pubkey") } fn validator_set_addr() -> radroots_event::ids::RadrootsAddressableCoordinate { @@ -1354,7 +1359,7 @@ mod tests { ) -> RadrootsEventEnvelope { RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: event_id('7'), - author: validator_set_author().as_str().to_string(), + author: validator_set_author().to_hex(), created_at: 1_700_000_001, kind, tags, @@ -1466,7 +1471,7 @@ mod tests { #[test] fn validation_trust_policy_builders_preserve_explicit_settings() { let trusted = validator_set_pubkey(); - let other = RadrootsPublicKey::parse(event_id('b')).unwrap(); + let other = PublicKey::from_hex(FIXTURE_BOB_PUBLIC_KEY_HEX).expect("other validator"); let policy = RadrootsTradeValidationTrustPolicy::production() .with_validator_set(sample_validator_set(), validator_set_addr(), event_id('8')) .with_require_cryptographic_proof(false); @@ -1505,7 +1510,7 @@ mod tests { let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: event_id('7'), - author: validator_set_author().as_str().to_string(), + author: validator_set_author().to_hex(), created_at: 1_700_000_001, kind: parts.kind, tags: parts.tags, diff --git a/crates/trade/src/workflow.rs b/crates/trade/src/workflow.rs @@ -15,8 +15,8 @@ use std::{ use radroots_event::{ ids::{ - RadrootsDTag, RadrootsEventId, RadrootsPublicKey, RadrootsTradeCandidateId, - RadrootsTradeId, RadrootsTradeMutationId, + RadrootsDTag, RadrootsEventId, RadrootsTradeCandidateId, RadrootsTradeId, + RadrootsTradeMutationId, }, trade::{ RADROOTS_TRADE_SCHEMA_VERSION, RadrootsSellerReservationAssertionV1, @@ -24,6 +24,7 @@ use radroots_event::{ RadrootsTradeMutationEnvelopeV1, }, }; +use radroots_identity::PublicKey; #[cfg(feature = "serde_json")] use sha2::{Digest, Sha256}; @@ -202,8 +203,10 @@ pub struct RadrootsTradeProjectionV1 { pub reducer_version: u16, pub trade_id: RadrootsTradeId, pub root_mutation_id: Option<RadrootsTradeMutationId>, - pub buyer_pubkey: Option<RadrootsPublicKey>, - pub seller_pubkey: Option<RadrootsPublicKey>, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub buyer_pubkey: Option<PublicKey>, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub seller_pubkey: Option<PublicKey>, pub farm_id: Option<RadrootsDTag>, pub negotiation_state: RadrootsTradeNegotiationStateV1, pub agreement_state: RadrootsTradeAgreementStateV1, @@ -295,8 +298,10 @@ pub struct RadrootsTradeAgreementClaimV1 { pub claim_mutation_id: RadrootsTradeMutationId, pub proposal_mutation_id: RadrootsTradeMutationId, pub candidate_id: RadrootsTradeCandidateId, - pub candidate_author_pubkey: RadrootsPublicKey, - pub accepted_by_pubkey: RadrootsPublicKey, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub candidate_author_pubkey: PublicKey, + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] + pub accepted_by_pubkey: PublicKey, pub reservation_commitment: String, } @@ -373,7 +378,7 @@ pub enum RadrootsTradeReducerIssueV1 { #[derive(Clone, Debug, PartialEq, Eq)] struct CandidateRecord { proposal_mutation_id: RadrootsTradeMutationId, - author_pubkey: RadrootsPublicKey, + author_pubkey: PublicKey, candidate: RadrootsTradeCandidateTermsV1, } @@ -471,7 +476,7 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra mutation_id.clone(), CandidateRecord { proposal_mutation_id: mutation_id.clone(), - author_pubkey: mutation.author_pubkey.clone(), + author_pubkey: mutation.author_pubkey, candidate: candidate.clone(), }, ); @@ -539,8 +544,8 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra .as_ref() .and_then(|root_id| mutations.get(root_id)) .expect("root proposal selected from the validated mutation map"); - projection.buyer_pubkey = Some(root.buyer_pubkey.clone()); - projection.seller_pubkey = Some(root.seller_pubkey.clone()); + projection.buyer_pubkey = Some(root.buyer_pubkey); + projection.seller_pubkey = Some(root.seller_pubkey); projection.farm_id = Some(root.farm_id.clone()); } @@ -709,8 +714,8 @@ fn apply_decision( claim_mutation_id: mutation_id.clone(), proposal_mutation_id: candidate_record.proposal_mutation_id.clone(), candidate_id: candidate_id.clone(), - candidate_author_pubkey: candidate_record.author_pubkey.clone(), - accepted_by_pubkey: mutation.author_pubkey.clone(), + candidate_author_pubkey: candidate_record.author_pubkey, + accepted_by_pubkey: mutation.author_pubkey, reservation_commitment: reservation.assertion_commitment.clone(), }, ); @@ -723,11 +728,11 @@ fn apply_decision( fn candidate_record_author_counterparty( candidate_record: &CandidateRecord, mutation: &RadrootsTradeMutationEnvelopeV1, -) -> RadrootsPublicKey { +) -> PublicKey { if candidate_record.author_pubkey == mutation.buyer_pubkey { - mutation.seller_pubkey.clone() + mutation.seller_pubkey } else { - mutation.buyer_pubkey.clone() + mutation.buyer_pubkey } } @@ -1086,6 +1091,7 @@ mod tests { RadrootsTradePrivateTermsRefV1, canonical_trade_mutation_content, }, }; + use radroots_test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_BOB_PUBLIC_KEY_HEX}; fn hex_64(character: char) -> String { core::iter::repeat_n(character, 64).collect() @@ -1095,8 +1101,13 @@ mod tests { core::iter::repeat_n(character, 32).collect() } - fn pubkey(character: char) -> RadrootsPublicKey { - RadrootsPublicKey::parse(hex_64(character)).unwrap() + fn pubkey(character: char) -> PublicKey { + let public_key_hex = match character { + 'a' => FIXTURE_ALICE_PUBLIC_KEY_HEX, + 'b' => FIXTURE_BOB_PUBLIC_KEY_HEX, + _ => panic!("unsupported fixture public key label: {character}"), + }; + PublicKey::from_hex(public_key_hex).expect("fixture pubkey") } fn event_id(character: char) -> RadrootsEventId { @@ -1128,7 +1139,7 @@ mod tests { line_id: dtag(&format!("line-{line_suffix}")), listing_addr: RadrootsClassifiedListingAddress::parse(format!( "30402:{}:listing-{line_suffix}", - hex_64('b') + FIXTURE_BOB_PUBLIC_KEY_HEX )) .unwrap(), listing_event_id: event_id('c'), @@ -1206,7 +1217,7 @@ mod tests { ) -> RadrootsSellerReservationAssertionV1 { RadrootsSellerReservationAssertionV1 { reservation_id: dtag(&format!("reservation-{marker}")), - inventory_authority_id: candidate.seller_pubkey.clone(), + inventory_authority_id: candidate.seller_pubkey, inventory_epoch: 42, candidate_id: candidate.candidate_id.clone().unwrap(), commitments: candidate @@ -1717,8 +1728,8 @@ mod tests { ))); let mut wrong_author = accepted_decision(&root, '2'); - wrong_author.author_pubkey = wrong_author.buyer_pubkey.clone(); - wrong_author.counterparty_pubkey = wrong_author.seller_pubkey.clone(); + wrong_author.author_pubkey = wrong_author.buyer_pubkey; + wrong_author.counterparty_pubkey = wrong_author.seller_pubkey; let wrong_author = recanonicalize(wrong_author); let projection = reduce(vec![root.clone(), wrong_author]); assert!(projection.issues.iter().any(|issue| matches!( @@ -1935,8 +1946,8 @@ mod tests { fn reducer_rejects_self_identified_decision_author_bypass() { let root = proposal(); let mut decision = accepted_decision(&root, '7'); - decision.author_pubkey = decision.buyer_pubkey.clone(); - decision.counterparty_pubkey = decision.buyer_pubkey.clone(); + decision.author_pubkey = decision.buyer_pubkey; + decision.counterparty_pubkey = decision.buyer_pubkey; let decision = recanonicalize(decision); let projection = reduce(vec![root, decision]); @@ -1998,7 +2009,7 @@ mod tests { }; let mut candidate_record = CandidateRecord { proposal_mutation_id: root_id(&root), - author_pubkey: root.buyer_pubkey.clone(), + author_pubkey: root.buyer_pubkey, candidate, }; let decision = accepted_decision(&root, '8'); @@ -2006,7 +2017,7 @@ mod tests { candidate_record_author_counterparty(&candidate_record, &decision), decision.seller_pubkey ); - candidate_record.author_pubkey = decision.seller_pubkey.clone(); + candidate_record.author_pubkey = decision.seller_pubkey; assert_eq!( candidate_record_author_counterparty(&candidate_record, &decision), decision.buyer_pubkey @@ -2064,8 +2075,8 @@ mod tests { claim_mutation_id, proposal_mutation_id: root_id(&root), candidate_id: candidate_id.clone(), - candidate_author_pubkey: root.buyer_pubkey.clone(), - accepted_by_pubkey: root.seller_pubkey.clone(), + candidate_author_pubkey: root.buyer_pubkey, + accepted_by_pubkey: root.seller_pubkey, reservation_commitment: hex_64('a'), }; let claims = BTreeMap::from([ @@ -2133,7 +2144,7 @@ mod tests { root_id(&root), CandidateRecord { proposal_mutation_id: root_id(&root), - author_pubkey: root.author_pubkey.clone(), + author_pubkey: root.author_pubkey, candidate: candidate.clone(), }, )]); @@ -2153,7 +2164,7 @@ mod tests { root_id(&root), CandidateRecord { proposal_mutation_id: root_id(&root), - author_pubkey: root.author_pubkey.clone(), + author_pubkey: root.author_pubkey, candidate: disabled_candidate, }, )]); diff --git a/crates/trade/tests/operational_listing_conformance.rs b/crates/trade/tests/operational_listing_conformance.rs @@ -157,7 +157,7 @@ fn address_valid(vector: &Vector) { let actual = json!({ "address": parsed.address.as_str(), "kind": parsed.kind, - "seller_pubkey": parsed.seller_pubkey.as_str(), + "seller_pubkey": parsed.seller_pubkey.to_hex(), "listing_id": parsed.listing_id.as_str(), }); assert_eq!(actual, vector.expected, "{}", vector.id); @@ -190,6 +190,7 @@ fn address_error_value(error: &RadrootsIdParseError) -> Value { "actual": actual, }), RadrootsIdParseError::InvalidCharacter => json!({ "kind": "invalid_character" }), + RadrootsIdParseError::InvalidPublicKey => json!({ "kind": "invalid_public_key" }), RadrootsIdParseError::UnexpectedKind { expected, actual } => json!({ "kind": "unexpected_kind", "expected": expected, @@ -276,7 +277,7 @@ fn validation_valid(vector: &Vector, event: &RadrootsSignatureVerifiedEvent) { .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); assert_eq!( projection.listing.farm.pubkey, - event.event().author_str(), + event.event().author().to_hex(), "{} decoded farm author drift", vector.id ); diff --git a/crates/transport/Cargo.toml b/crates/transport/Cargo.toml @@ -1,16 +1,19 @@ [package] name = "radroots_transport" -publish = ["crates-io"] +publish = false version = "0.1.0-alpha" edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] +authors.workspace = true rust-version.workspace = true license.workspace = true description = "Transport-neutral delivery model for Radroots" repository.workspace = true homepage.workspace = true documentation = "https://docs.rs/radroots_transport" -readme = "README" +readme = "README.md" + +[lib] +name = "radroots_transport" [features] default = ["serde"] @@ -24,5 +27,5 @@ sha2 = { workspace = true, default-features = false } futures = { workspace = true } serde_json = { workspace = true, features = ["std"] } -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } +[lints] +workspace = true diff --git a/crates/transport/README b/crates/transport/README.md diff --git a/crates/transport_nostr/Cargo.toml b/crates/transport_nostr/Cargo.toml @@ -3,13 +3,16 @@ name = "radroots_transport_nostr" publish = false version = "0.1.0-alpha" edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] +authors.workspace = true rust-version.workspace = true license.workspace = true description = "Nostr transport layer for Radroots" repository.workspace = true homepage.workspace = true -readme = "README" +readme = "README.md" + +[lib] +name = "radroots_transport_nostr" [features] default = ["std", "client", "storage", "runtime-tokio"] @@ -79,6 +82,7 @@ url = { workspace = true } radroots_authority = { workspace = true, features = ["local_signer"] } radroots_blossom = { workspace = true, features = ["serde", "std"] } radroots_event_codec = { workspace = true, features = ["serde_json"] } +radroots_identity = { workspace = true, features = ["std"] } sha2 = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } @@ -93,5 +97,5 @@ name = "transport" path = "tests/transport.rs" required-features = ["runtime-tokio"] -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } +[lints] +workspace = true diff --git a/crates/transport_nostr/README b/crates/transport_nostr/README.md diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs @@ -1034,11 +1034,11 @@ fn transport_failure_target_receipts( .iter() .cloned() .map(|target| { - RadrootsTransportTargetReceipt::skipped( + Ok(RadrootsTransportTargetReceipt::attempted( target, RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::ConnectionFailed) .try_with_message(message.to_owned())?, - ) + )) }) .collect() } @@ -1415,7 +1415,7 @@ fn ensure_raw_event_matches_signed_event( ( "pubkey", event.pubkey.to_hex(), - signed_event.pubkey_str().to_owned(), + signed_event.pubkey().to_hex().to_owned(), ), ( "created_at", diff --git a/crates/transport_nostr/tests/phase1_outbox_publication.rs b/crates/transport_nostr/tests/phase1_outbox_publication.rs @@ -7,8 +7,8 @@ use radroots_authority::{ RadrootsPhase1PublicationSigner, RadrootsSignerError, sign_authorized_phase1_publication, }; use radroots_blossom::{ - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomByteVerifiedDescriptor, - RadrootsBlossomMediaType, RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomSha256, + BlobDescriptor, BlobUrl, ByteVerifiedDescriptor, MediaType, PublicationReadinessEvidence, + Sha256, }; use radroots_event::{ RadrootsAuthoredImage, @@ -24,7 +24,7 @@ use radroots_event::{ RadrootsFoodPrice, RadrootsFoodPublishedAt, RadrootsFoodQuantity, RadrootsFoodText, RadrootsFoodUnit, }, - ids::{RadrootsEventId, RadrootsPublicKey}, + ids::RadrootsEventId, post::{ RadrootsAuthoredAsk, RadrootsAuthoredPhotoUpdate, RadrootsAuthoredPostImage, RadrootsAuthoredUpdate, RadrootsPostImageDimensions, @@ -37,6 +37,7 @@ use radroots_event_codec::wire::publication::{ allowlist::allow_phase1_publication_artifact, bind_phase1_publication_media_readiness, }; use radroots_event_store::RadrootsEventStore; +use radroots_identity::PublicKey; use radroots_nostr::prelude::{RadrootsNostrKeys, RadrootsNostrSecretKey}; use radroots_outbox::{ RadrootsOutbox, RadrootsPhase1PublicationTargetPolicy, RadrootsPhase1PublicationTargetState, @@ -54,7 +55,7 @@ use radroots_transport_nostr::{ phase1_publication_delivery_request, repair_phase1_publication_observation, }; use serde::Serialize; -use sha2::{Digest, Sha256}; +use sha2::{Digest, Sha256 as Sha2_256}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -83,7 +84,7 @@ impl CountingPhase1Signer { } impl RadrootsEventSigner for CountingPhase1Signer { - fn pubkey(&self) -> &RadrootsPublicKey { + fn pubkey(&self) -> &PublicKey { self.inner.pubkey() } @@ -99,7 +100,7 @@ impl RadrootsPhase1PublicationSigner for CountingPhase1Signer { fn sign_phase1_publication_draft( &self, draft: &RadrootsPhase1PublicationDraft, - expected_pubkey: &RadrootsPublicKey, + expected_pubkey: &PublicKey, expected_event_id: &RadrootsEventId, ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { self.invocations @@ -677,7 +678,7 @@ fn all_artifacts() -> Vec<RadrootsPhase1PublicationArtifact> { fn authored_post_image(bytes: &[u8]) -> RadrootsAuthoredPostImage { let image = authored_image(bytes, "media.example", "webp", "image/webp"); let hash = image.descriptor().sha256(); - let fallback = RadrootsBlossomBlobUrl::parse(&format!("https://backup.example/{hash}.webp")) + let fallback = BlobUrl::parse(&format!("https://backup.example/{hash}.webp")) .unwrap() .approve() .unwrap(); @@ -731,11 +732,11 @@ fn verified_descriptor( host: &str, extension: &str, media_type: &str, -) -> RadrootsBlossomByteVerifiedDescriptor { - let sha256 = RadrootsBlossomSha256::digest(bytes); - let media_type = RadrootsBlossomMediaType::parse(media_type).unwrap(); - RadrootsBlossomBlobDescriptor::new( - RadrootsBlossomBlobUrl::parse(&format!("https://{host}/{sha256}.{extension}")).unwrap(), +) -> ByteVerifiedDescriptor { + let sha256 = Sha256::digest(bytes); + let media_type = MediaType::parse(media_type).unwrap(); + BlobDescriptor::new( + BlobUrl::parse(&format!("https://{host}/{sha256}.{extension}")).unwrap(), sha256, u64::try_from(bytes.len()).unwrap(), media_type.clone(), @@ -785,7 +786,7 @@ struct EvidenceWire<'a> { fn evidence_for_reference( reference: &RadrootsPhase1PublicationMediaReference, dimensions: (u32, u32), -) -> RadrootsBlossomPublicationReadinessEvidence { +) -> PublicationReadinessEvidence { let media_type = reference.media_type().as_str(); let (raster_format, format_code) = match media_type { "image/jpeg" => ("jpeg", 1), @@ -812,10 +813,7 @@ fn evidence_for_reference( uploaded, evidence_digest: evidence_digest(reference, format_code, dimensions, uploaded), }; - RadrootsBlossomPublicationReadinessEvidence::from_canonical_json( - &serde_json::to_vec(&wire).unwrap(), - ) - .unwrap() + PublicationReadinessEvidence::from_canonical_json(&serde_json::to_vec(&wire).unwrap()).unwrap() } fn evidence_digest( @@ -824,7 +822,7 @@ fn evidence_digest( dimensions: (u32, u32), uploaded: u64, ) -> String { - let mut hasher = Sha256::new(); + let mut hasher = Sha2_256::new(); hasher.update(b"radroots.blossom.publication-readiness-evidence.v1\0"); hasher.update(1_u16.to_be_bytes()); update_length_prefixed(&mut hasher, reference.url().as_str().as_bytes()); @@ -841,7 +839,7 @@ fn evidence_digest( hex::encode(hasher.finalize()) } -fn update_length_prefixed(hasher: &mut Sha256, bytes: &[u8]) { +fn update_length_prefixed(hasher: &mut Sha2_256, bytes: &[u8]) { hasher.update(u64::try_from(bytes.len()).unwrap().to_be_bytes()); hasher.update(bytes); } diff --git a/crates/transport_publish_protocol/Cargo.toml b/crates/transport_publish_protocol/Cargo.toml @@ -21,4 +21,8 @@ radroots_transport = { workspace = true, default-features = false } serde = { workspace = true, optional = true, features = ["alloc", "derive"] } [dev-dependencies] +radroots_protocol = { workspace = true, default-features = false, features = [ + "serde", + "std", +] } serde_json = { workspace = true, features = ["std"] } diff --git a/crates/transport_publish_protocol/README b/crates/transport_publish_protocol/README @@ -1,3 +1,11 @@ # radroots_transport_publish_protocol +## Quarantine status + +This package is a retired, `publish = false` compatibility shim. New code must +use `radroots_protocol::radrootsd::transport_publish::v5`. It remains only for +the CLI and radrootsd consumers discovered during release-v1 migration. Step +286 removes the final radrootsd dependency and this package; it must not become +a registry identity or receive new behavior before then. + Shared Radrootsd transport publish JSON-RPC wire contracts. diff --git a/crates/transport_publish_protocol/tests/successor_equivalence.rs b/crates/transport_publish_protocol/tests/successor_equivalence.rs @@ -0,0 +1,115 @@ +use serde::{Serialize, de::DeserializeOwned}; + +use radroots_protocol::radrootsd::transport_publish::v5 as successor; +use radroots_transport::RadrootsTransportTarget; +use radroots_transport_publish_protocol as predecessor; + +fn assert_json_equivalent<Old, New>(old: &Old) -> New +where + Old: Serialize, + New: DeserializeOwned + Serialize, +{ + let old_json = serde_json::to_vec(old).expect("predecessor JSON"); + let new: New = serde_json::from_slice(old_json.as_slice()).expect("successor decode"); + let new_json = serde_json::to_vec(&new).expect("successor JSON"); + assert_eq!(new_json, old_json); + new +} + +fn request() -> predecessor::TransportPublishEventRequest { + predecessor::TransportPublishEventRequest { + raw_event_json: "{\"id\":\"event\"}".to_owned(), + target_policy: predecessor::TransportPublishTargetPolicy::explicit_targets(vec![ + predecessor::TransportPublishTarget::nostr("wss://relay.example.com"), + ]), + delivery_policy: predecessor::TransportPublishDeliveryPolicy::Any, + idempotency_key: Some("idem-1".to_owned()), + timeout_ms: Some(5_000), + } +} + +fn accepted_job() -> predecessor::TransportPublishJobView { + predecessor::TransportPublishJobView { + job_id: "job-1".to_owned(), + status: predecessor::TransportPublishJobStatus::DeliverySatisfied, + terminal: true, + delivery_satisfied: true, + event_id: "0".repeat(64), + pubkey: "1".repeat(64), + event_kind: 30_402, + target_policy: predecessor::TransportPublishTargetPolicy::explicit_targets(vec![ + predecessor::TransportPublishTarget::nostr("wss://relay.example.com"), + ]), + delivery_policy: predecessor::TransportPublishDeliveryPolicy::Any, + target_count: 1, + acknowledged_count: 1, + retryable_count: 0, + terminal_count: 0, + requested_at_ms: 1, + completed_at_ms: Some(2), + last_error: None, + targets: vec![predecessor::TransportPublishTargetOutcome { + transport_kind: "nostr".to_owned(), + endpoint_uri: "wss://relay.example.com".to_owned(), + target_scope: None, + target_label: None, + source: predecessor::TransportPublishTargetSource::Request, + attempted: true, + outcome_kind: predecessor::TransportPublishOutcomeKind::Accepted, + message: None, + latency_ms: Some(7), + }], + } +} + +#[test] +fn request_response_job_and_capability_json_are_byte_identical() { + let request = request(); + request.validate(1).expect("predecessor request"); + let successor_request = assert_json_equivalent::<_, successor::EventRequest>(&request); + successor_request.validate(1).expect("successor request"); + + let job = accepted_job(); + job.validate().expect("predecessor job"); + let response = predecessor::TransportPublishEventResponse { + deduplicated: false, + job, + }; + let successor_response = assert_json_equivalent::<_, successor::EventResponse>(&response); + successor_response.job.validate().expect("successor job"); + + let capabilities = predecessor::TransportPublishCapabilities::v5(1_024, 10); + let successor_capabilities = + assert_json_equivalent::<_, successor::Capabilities>(&capabilities); + assert_eq!(successor_capabilities.api_version, successor::API_VERSION); +} + +#[test] +fn required_target_fingerprint_json_is_byte_identical() { + let target = RadrootsTransportTarget::nostr_relay("wss://relay.example.com") + .expect("native Nostr target"); + let predecessor = predecessor::TransportPublishDeliveryPolicy::required_targets(vec![ + target.fingerprint().clone(), + ]) + .expect("predecessor policy"); + + let successor = assert_json_equivalent::<_, successor::DeliveryPolicy>(&predecessor); + successor.validate().expect("successor policy"); +} + +#[test] +fn successor_rejects_unknown_and_noncanonical_wire_fields() { + let mut value = serde_json::to_value(request()).expect("request value"); + value + .as_object_mut() + .expect("request object") + .insert("unknown".to_owned(), serde_json::json!(true)); + assert!(serde_json::from_value::<successor::EventRequest>(value).is_err()); + + assert!( + serde_json::from_str::<successor::DeliveryPolicy>( + r#"{"mode":"required_targets","targets":["ABCDEF"]}"#, + ) + .is_err() + ); +} diff --git a/docs/api/README.md b/docs/api/README.md @@ -0,0 +1,36 @@ +# Public API baselines + +This directory records reviewed pre-release public API surfaces for publishable +Radroots packages. Baselines are generated with `cargo-public-api` `0.52.0`, +`nightly-2026-07-16` for rustdoc JSON, and the package's complete public feature +set. Package verification remains governed by the workspace's pinned stable +toolchain. + +Regenerate a package baseline from the workspace root inside the canonical +development shell with: + +```sh +RUSTC="$(rustup which --toolchain nightly-2026-07-16 rustc)" \ +RUSTDOC="$(rustup which --toolchain nightly-2026-07-16 rustdoc)" \ +cargo public-api --manifest-path crates/<crate>/Cargo.toml \ + --all-features -sss \ + > docs/api/<package>.txt +``` + +Install the exact tool version, when it is not already available, with: + +```sh +cargo install cargo-public-api --version 0.52.0 --locked +rustup toolchain install nightly-2026-07-16 --profile minimal +``` + +Review baseline changes together with the package charter and intended SemVer +impact. A generated listing is evidence of the Rust surface, not authority to +expand a package beyond its charter. + +| Package | Baseline | Charter | +| --- | --- | --- | +| `radroots_core` | [`radroots_core.txt`](radroots_core.txt) | [release V1 specification](../specs/radroots_crates_release_v1.md) | +| `radroots_identity` | [`radroots_identity.txt`](radroots_identity.txt) | [release V1 specification](../specs/radroots_crates_release_v1.md) | +| `radroots_blossom` | [`radroots_blossom.txt`](radroots_blossom.txt) | [release V1 specification](../specs/radroots_crates_release_v1.md) | +| `radroots_protocol` | [`radroots_protocol.txt`](radroots_protocol.txt) | [release V1 specification](../specs/radroots_crates_release_v1.md) | diff --git a/docs/api/radroots-core.txt b/docs/api/radroots-core.txt @@ -0,0 +1,521 @@ +pub mod radroots_core +pub mod radroots_core::currency +#[non_exhaustive] pub enum radroots_core::currency::ParseError +pub radroots_core::currency::ParseError::InvalidFormat +impl core::convert::From<radroots_core::currency::ParseError> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::currency::ParseError) -> Self +impl core::error::Error for radroots_core::currency::ParseError +impl core::fmt::Display for radroots_core::currency::ParseError +pub fn radroots_core::currency::ParseError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_core::currency::Currency(_) +impl radroots_core::currency::Currency +pub const radroots_core::currency::Currency::AUD: radroots_core::currency::Currency +pub const radroots_core::currency::Currency::CAD: radroots_core::currency::Currency +pub const radroots_core::currency::Currency::EUR: radroots_core::currency::Currency +pub const radroots_core::currency::Currency::GBP: radroots_core::currency::Currency +pub const radroots_core::currency::Currency::JPY: radroots_core::currency::Currency +pub const radroots_core::currency::Currency::USD: radroots_core::currency::Currency +pub const fn radroots_core::currency::Currency::as_bytes(&self) -> &[u8; 3] +pub fn radroots_core::currency::Currency::as_str(&self) -> &str +pub const fn radroots_core::currency::Currency::from_const([u8; 3]) -> core::result::Result<Self, radroots_core::currency::ParseError> +pub fn radroots_core::currency::Currency::from_str_upper(&str) -> core::result::Result<Self, radroots_core::currency::ParseError> +pub const fn radroots_core::currency::Currency::minor_unit_exponent(&self) -> u32 +impl core::convert::TryFrom<&str> for radroots_core::currency::Currency +pub type radroots_core::currency::Currency::Error = radroots_core::currency::ParseError +pub fn radroots_core::currency::Currency::try_from(&str) -> core::result::Result<Self, Self::Error> +impl core::fmt::Debug for radroots_core::currency::Currency +pub fn radroots_core::currency::Currency::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for radroots_core::currency::Currency +pub fn radroots_core::currency::Currency::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_core::currency::Currency +pub type radroots_core::currency::Currency::Err = radroots_core::currency::ParseError +pub fn radroots_core::currency::Currency::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_core::currency::Currency +pub fn radroots_core::currency::Currency::serialize<S: serde_core::ser::Serializer>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_core::currency::Currency +pub fn radroots_core::currency::Currency::deserialize<D: serde_core::de::Deserializer<'de>>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> +pub mod radroots_core::decimal +#[non_exhaustive] pub enum radroots_core::decimal::Error +pub radroots_core::decimal::Error::ArithmeticOverflow +pub radroots_core::decimal::Error::DivisionByZero +pub radroots_core::decimal::Error::InvalidFormat +pub radroots_core::decimal::Error::OutOfRange +pub radroots_core::decimal::Error::PrecisionLoss +pub radroots_core::decimal::Error::ScaleOutOfRange +impl core::convert::From<radroots_core::decimal::Error> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::decimal::Error) -> Self +impl core::convert::From<radroots_core::decimal::Error> for radroots_core::money::Error +pub fn radroots_core::money::Error::from(radroots_core::decimal::Error) -> Self +impl core::convert::From<radroots_core::decimal::Error> for radroots_core::quantity::Error +pub fn radroots_core::quantity::Error::from(radroots_core::decimal::Error) -> Self +impl core::error::Error for radroots_core::decimal::Error +impl core::fmt::Display for radroots_core::decimal::Error +pub fn radroots_core::decimal::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_core::decimal::Decimal(_) +impl radroots_core::decimal::Decimal +pub const radroots_core::decimal::Decimal::MAX: Self +pub const radroots_core::decimal::Decimal::MAX_SCALE: u32 +pub const radroots_core::decimal::Decimal::MIN: Self +pub const radroots_core::decimal::Decimal::ONE: Self +pub const radroots_core::decimal::Decimal::ZERO: Self +pub fn radroots_core::decimal::Decimal::checked_add(self, Self) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::decimal::Decimal::checked_div(self, Self) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::decimal::Decimal::checked_mul(self, Self) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::decimal::Decimal::checked_sub(self, Self) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::decimal::Decimal::from_f64_display(f64) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::decimal::Decimal::from_str_exact(&str) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::decimal::Decimal::is_sign_negative(&self) -> bool +pub fn radroots_core::decimal::Decimal::is_zero(&self) -> bool +pub fn radroots_core::decimal::Decimal::normalize(&self) -> Self +pub fn radroots_core::decimal::Decimal::rescale(&mut self, u32) +pub fn radroots_core::decimal::Decimal::scale(&self) -> u32 +pub fn radroots_core::decimal::Decimal::to_f64_lossy(&self) -> core::option::Option<f64> +pub fn radroots_core::decimal::Decimal::to_u64_exact(&self) -> core::option::Option<u64> +pub fn radroots_core::decimal::Decimal::try_rescale_exact(&mut self, u32) -> core::result::Result<(), radroots_core::decimal::Error> +impl core::convert::From<i32> for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::from(i32) -> Self +impl core::convert::From<i64> for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::from(i64) -> Self +impl core::convert::From<u32> for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::from(u32) -> Self +impl core::convert::From<u64> for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::from(u64) -> Self +impl core::fmt::Display for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_core::decimal::Decimal +pub type radroots_core::decimal::Decimal::Err = radroots_core::decimal::Error +pub fn radroots_core::decimal::Decimal::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::serialize<S: serde_core::ser::Serializer>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::deserialize<D: serde_core::de::Deserializer<'de>>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> +pub mod radroots_core::money +#[non_exhaustive] pub enum radroots_core::money::Error +pub radroots_core::money::Error::AmountOverflow +pub radroots_core::money::Error::ArithmeticOverflow +pub radroots_core::money::Error::CurrencyMismatch +pub radroots_core::money::Error::DivisionByZero +pub radroots_core::money::Error::NegativeAmount +pub radroots_core::money::Error::NotWholeMinorUnits +pub radroots_core::money::Error::PrecisionLoss +pub radroots_core::money::Error::ScaleOutOfRange +impl core::convert::From<radroots_core::decimal::Error> for radroots_core::money::Error +pub fn radroots_core::money::Error::from(radroots_core::decimal::Error) -> Self +impl core::convert::From<radroots_core::money::Error> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::money::Error) -> Self +impl core::error::Error for radroots_core::money::Error +impl core::fmt::Display for radroots_core::money::Error +pub fn radroots_core::money::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_core::money::Money +impl radroots_core::money::Money +pub const fn radroots_core::money::Money::amount(&self) -> radroots_core::decimal::Decimal +pub fn radroots_core::money::Money::checked_add(&self, &Self) -> core::result::Result<Self, radroots_core::money::Error> +pub fn radroots_core::money::Money::checked_div_decimal(&self, radroots_core::decimal::Decimal) -> core::result::Result<Self, radroots_core::money::Error> +pub fn radroots_core::money::Money::checked_mul_decimal(&self, radroots_core::decimal::Decimal) -> core::result::Result<Self, radroots_core::money::Error> +pub fn radroots_core::money::Money::checked_sub(&self, &Self) -> core::result::Result<Self, radroots_core::money::Error> +pub const fn radroots_core::money::Money::currency(&self) -> radroots_core::currency::Currency +pub fn radroots_core::money::Money::from_minor_units_u32(u32, radroots_core::currency::Currency) -> Self +pub fn radroots_core::money::Money::from_minor_units_u64(u64, radroots_core::currency::Currency) -> Self +pub fn radroots_core::money::Money::is_zero(&self) -> bool +pub fn radroots_core::money::Money::quantize_to_currency(self) -> Self +pub fn radroots_core::money::Money::to_minor_units_u32_exact(&self) -> core::result::Result<u32, radroots_core::money::Error> +pub fn radroots_core::money::Money::to_minor_units_u32_rounded(&self) -> core::result::Result<u32, radroots_core::money::Error> +pub fn radroots_core::money::Money::to_minor_units_u64_exact(&self) -> core::result::Result<u64, radroots_core::money::Error> +pub fn radroots_core::money::Money::to_minor_units_u64_rounded(&self) -> core::result::Result<u64, radroots_core::money::Error> +pub fn radroots_core::money::Money::try_new(radroots_core::decimal::Decimal, radroots_core::currency::Currency) -> core::result::Result<Self, radroots_core::money::Error> +pub fn radroots_core::money::Money::try_with_scale_exact(self, u32) -> core::result::Result<Self, radroots_core::money::Error> +pub fn radroots_core::money::Money::zero(radroots_core::currency::Currency) -> Self +impl core::fmt::Display for radroots_core::money::Money +pub fn radroots_core::money::Money::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl<'de> serde_core::de::Deserialize<'de> for radroots_core::money::Money +pub fn radroots_core::money::Money::deserialize<D: serde_core::de::Deserializer<'de>>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> +pub mod radroots_core::percent +#[non_exhaustive] pub enum radroots_core::percent::ParseError +pub radroots_core::percent::ParseError::InvalidNumber +impl core::convert::From<radroots_core::percent::ParseError> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::percent::ParseError) -> Self +impl core::error::Error for radroots_core::percent::ParseError +impl core::fmt::Display for radroots_core::percent::ParseError +pub fn radroots_core::percent::ParseError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_core::percent::Percent +impl radroots_core::percent::Percent +pub fn radroots_core::percent::Percent::new(radroots_core::decimal::Decimal) -> Self +pub fn radroots_core::percent::Percent::try_from_ratio(radroots_core::decimal::Decimal) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::percent::Percent::try_of_money(&self, &radroots_core::money::Money) -> core::result::Result<radroots_core::money::Money, radroots_core::money::Error> +pub fn radroots_core::percent::Percent::try_of_money_quantized(&self, &radroots_core::money::Money) -> core::result::Result<radroots_core::money::Money, radroots_core::money::Error> +pub fn radroots_core::percent::Percent::try_to_ratio(&self) -> core::result::Result<radroots_core::decimal::Decimal, radroots_core::decimal::Error> +pub const fn radroots_core::percent::Percent::value(&self) -> radroots_core::decimal::Decimal +impl core::fmt::Display for radroots_core::percent::Percent +pub fn radroots_core::percent::Percent::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_core::percent::Percent +pub type radroots_core::percent::Percent::Err = radroots_core::percent::ParseError +pub fn radroots_core::percent::Percent::from_str(&str) -> core::result::Result<Self, Self::Err> +pub mod radroots_core::pricing +#[non_exhaustive] pub enum radroots_core::pricing::DiscountError +pub radroots_core::pricing::DiscountError::NegativeThreshold +pub radroots_core::pricing::DiscountError::NegativeValue +impl core::convert::From<radroots_core::pricing::DiscountError> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::pricing::DiscountError) -> Self +impl core::error::Error for radroots_core::pricing::DiscountError +impl core::fmt::Display for radroots_core::pricing::DiscountError +pub fn radroots_core::pricing::DiscountError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub enum radroots_core::pricing::DiscountScope +pub radroots_core::pricing::DiscountScope::Bin +pub radroots_core::pricing::DiscountScope::OrderTotal +pub enum radroots_core::pricing::DiscountThreshold +pub radroots_core::pricing::DiscountThreshold::BinCount +pub radroots_core::pricing::DiscountThreshold::BinCount::bin_id: alloc::string::String +pub radroots_core::pricing::DiscountThreshold::BinCount::min: u32 +pub radroots_core::pricing::DiscountThreshold::OrderQuantity +pub radroots_core::pricing::DiscountThreshold::OrderQuantity::min: radroots_core::quantity::Quantity +pub enum radroots_core::pricing::DiscountValue +pub radroots_core::pricing::DiscountValue::MoneyPerBin(radroots_core::money::Money) +pub radroots_core::pricing::DiscountValue::Percent(radroots_core::percent::Percent) +pub enum radroots_core::pricing::Error +pub radroots_core::pricing::Error::ArithmeticOverflow +pub radroots_core::pricing::Error::NegativePrice +pub radroots_core::pricing::Error::NegativeRequestedQuantity +pub radroots_core::pricing::Error::NonConvertibleUnits +pub radroots_core::pricing::Error::NonConvertibleUnits::from: radroots_core::unit::Unit +pub radroots_core::pricing::Error::NonConvertibleUnits::to: radroots_core::unit::Unit +pub radroots_core::pricing::Error::PerQuantityNegative +pub radroots_core::pricing::Error::PerQuantityZero +pub radroots_core::pricing::Error::UnitMismatch +pub radroots_core::pricing::Error::UnitMismatch::have: radroots_core::unit::Unit +pub radroots_core::pricing::Error::UnitMismatch::want: radroots_core::unit::Unit +impl core::convert::From<radroots_core::pricing::Error> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::pricing::Error) -> Self +impl core::error::Error for radroots_core::pricing::Error +impl core::fmt::Display for radroots_core::pricing::Error +pub fn radroots_core::pricing::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_core::pricing::Discount +impl radroots_core::pricing::Discount +pub fn radroots_core::pricing::Discount::is_non_negative(&self) -> bool +pub fn radroots_core::pricing::Discount::scope(&self) -> &radroots_core::pricing::DiscountScope +pub fn radroots_core::pricing::Discount::threshold(&self) -> &radroots_core::pricing::DiscountThreshold +pub fn radroots_core::pricing::Discount::try_new(radroots_core::pricing::DiscountScope, radroots_core::pricing::DiscountThreshold, radroots_core::pricing::DiscountValue) -> core::result::Result<Self, radroots_core::pricing::DiscountError> +pub fn radroots_core::pricing::Discount::validate(&self) -> core::result::Result<(), radroots_core::pricing::DiscountError> +pub fn radroots_core::pricing::Discount::value(&self) -> &radroots_core::pricing::DiscountValue +impl<'de> serde_core::de::Deserialize<'de> for radroots_core::pricing::Discount +pub fn radroots_core::pricing::Discount::deserialize<D: serde_core::de::Deserializer<'de>>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> +pub struct radroots_core::pricing::QuantityPrice +impl radroots_core::QuantityPrice +pub fn radroots_core::QuantityPrice::amount(&self) -> &radroots_core::money::Money +pub fn radroots_core::QuantityPrice::is_price_per_canonical_unit(&self) -> bool +pub fn radroots_core::QuantityPrice::quantity(&self) -> &radroots_core::quantity::Quantity +pub fn radroots_core::QuantityPrice::try_cost_for_amount_in(&self, radroots_core::decimal::Decimal, radroots_core::unit::Unit) -> core::result::Result<radroots_core::money::Money, radroots_core::pricing::Error> +pub fn radroots_core::QuantityPrice::try_cost_for_quantity_in(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::money::Money, radroots_core::pricing::Error> +pub fn radroots_core::QuantityPrice::try_new(radroots_core::money::Money, radroots_core::quantity::Quantity) -> core::result::Result<Self, radroots_core::pricing::Error> +pub fn radroots_core::QuantityPrice::try_to_canonical_unit_price(&self) -> core::result::Result<radroots_core::QuantityPrice, radroots_core::pricing::Error> +pub fn radroots_core::QuantityPrice::try_to_unit_price(&self, radroots_core::unit::Unit) -> core::result::Result<radroots_core::QuantityPrice, radroots_core::pricing::Error> +pub fn radroots_core::QuantityPrice::validate(&self) -> core::result::Result<(), radroots_core::pricing::Error> +impl radroots_core::pricing::QuantityPriceOps for radroots_core::QuantityPrice +pub fn radroots_core::QuantityPrice::try_cost_for(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::money::Money, radroots_core::pricing::Error> +pub fn radroots_core::QuantityPrice::try_cost_for_rounded(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::money::Money, radroots_core::pricing::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_core::QuantityPrice +pub fn radroots_core::QuantityPrice::deserialize<D: serde_core::de::Deserializer<'de>>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> +pub trait radroots_core::pricing::QuantityPriceOps: radroots_core::quantity_price::sealed::Sealed +pub fn radroots_core::pricing::QuantityPriceOps::try_cost_for(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::money::Money, radroots_core::pricing::Error> +pub fn radroots_core::pricing::QuantityPriceOps::try_cost_for_rounded(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::money::Money, radroots_core::pricing::Error> +impl radroots_core::pricing::QuantityPriceOps for radroots_core::QuantityPrice +pub fn radroots_core::QuantityPrice::try_cost_for(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::money::Money, radroots_core::pricing::Error> +pub fn radroots_core::QuantityPrice::try_cost_for_rounded(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::money::Money, radroots_core::pricing::Error> +pub mod radroots_core::quantity +#[non_exhaustive] pub enum radroots_core::quantity::Error +pub radroots_core::quantity::Error::ArithmeticOverflow +pub radroots_core::quantity::Error::DivisionByZero +pub radroots_core::quantity::Error::NegativeAmount +pub radroots_core::quantity::Error::UnitMismatch +impl core::convert::From<radroots_core::decimal::Error> for radroots_core::quantity::Error +pub fn radroots_core::quantity::Error::from(radroots_core::decimal::Error) -> Self +impl core::convert::From<radroots_core::quantity::Error> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::quantity::Error) -> Self +impl core::error::Error for radroots_core::quantity::Error +impl core::fmt::Display for radroots_core::quantity::Error +pub fn radroots_core::quantity::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_core::quantity::Quantity +impl radroots_core::quantity::Quantity +pub const fn radroots_core::quantity::Quantity::amount(&self) -> radroots_core::decimal::Decimal +pub fn radroots_core::quantity::Quantity::canonical_unit(&self) -> radroots_core::unit::Unit +pub fn radroots_core::quantity::Quantity::checked_add(&self, &radroots_core::quantity::Quantity) -> core::option::Option<radroots_core::quantity::Quantity> +pub fn radroots_core::quantity::Quantity::checked_div_decimal(&self, radroots_core::decimal::Decimal) -> core::result::Result<radroots_core::quantity::Quantity, radroots_core::quantity::Error> +pub fn radroots_core::quantity::Quantity::checked_mul_decimal(&self, radroots_core::decimal::Decimal) -> core::result::Result<radroots_core::quantity::Quantity, radroots_core::quantity::Error> +pub fn radroots_core::quantity::Quantity::checked_sub(&self, &radroots_core::quantity::Quantity) -> core::option::Option<radroots_core::quantity::Quantity> +pub fn radroots_core::quantity::Quantity::clear_label(self) -> Self +pub fn radroots_core::quantity::Quantity::is_canonical(&self) -> bool +pub fn radroots_core::quantity::Quantity::is_zero(&self) -> bool +pub fn radroots_core::quantity::Quantity::label(&self) -> core::option::Option<&str> +pub fn radroots_core::quantity::Quantity::to_canonical(&self) -> core::result::Result<radroots_core::quantity::Quantity, radroots_core::unit::ConvertError> +pub fn radroots_core::quantity::Quantity::try_add(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::quantity::Quantity, radroots_core::quantity::Error> +pub fn radroots_core::quantity::Quantity::try_convert_to(&self, radroots_core::unit::Unit) -> core::result::Result<radroots_core::quantity::Quantity, radroots_core::unit::ConvertError> +pub fn radroots_core::quantity::Quantity::try_new(radroots_core::decimal::Decimal, radroots_core::unit::Unit) -> core::result::Result<Self, radroots_core::quantity::Error> +pub fn radroots_core::quantity::Quantity::try_sub(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::quantity::Quantity, radroots_core::quantity::Error> +pub const fn radroots_core::quantity::Quantity::unit(&self) -> radroots_core::unit::Unit +pub fn radroots_core::quantity::Quantity::with_label<S: core::convert::Into<alloc::string::String>>(self, S) -> Self +pub fn radroots_core::quantity::Quantity::with_optional_label<S: core::convert::Into<alloc::string::String>>(self, core::option::Option<S>) -> Self +pub fn radroots_core::quantity::Quantity::zero(radroots_core::unit::Unit) -> Self +impl core::fmt::Display for radroots_core::quantity::Quantity +pub fn radroots_core::quantity::Quantity::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl<'de> serde_core::de::Deserialize<'de> for radroots_core::quantity::Quantity +pub fn radroots_core::quantity::Quantity::deserialize<D: serde_core::de::Deserializer<'de>>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> +pub mod radroots_core::unit +pub enum radroots_core::unit::ConvertError +pub radroots_core::unit::ConvertError::ArithmeticOverflow +pub radroots_core::unit::ConvertError::ArithmeticOverflow::from: radroots_core::unit::Unit +pub radroots_core::unit::ConvertError::ArithmeticOverflow::to: radroots_core::unit::Unit +pub radroots_core::unit::ConvertError::NotConvertibleUnits +pub radroots_core::unit::ConvertError::NotConvertibleUnits::from: radroots_core::unit::Unit +pub radroots_core::unit::ConvertError::NotConvertibleUnits::to: radroots_core::unit::Unit +pub radroots_core::unit::ConvertError::NotMassUnit +pub radroots_core::unit::ConvertError::NotMassUnit::from: radroots_core::unit::Unit +pub radroots_core::unit::ConvertError::NotMassUnit::to: radroots_core::unit::Unit +pub radroots_core::unit::ConvertError::NotVolumeUnit +pub radroots_core::unit::ConvertError::NotVolumeUnit::from: radroots_core::unit::Unit +pub radroots_core::unit::ConvertError::NotVolumeUnit::to: radroots_core::unit::Unit +impl core::convert::From<radroots_core::unit::ConvertError> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::unit::ConvertError) -> Self +impl core::error::Error for radroots_core::unit::ConvertError +impl core::fmt::Display for radroots_core::unit::ConvertError +pub fn radroots_core::unit::ConvertError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub enum radroots_core::unit::ParseError +pub radroots_core::unit::ParseError::NotAMassUnit +pub radroots_core::unit::ParseError::NotAVolumeUnit +pub radroots_core::unit::ParseError::UnknownUnit +impl core::convert::From<radroots_core::unit::ParseError> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::unit::ParseError) -> Self +impl core::error::Error for radroots_core::unit::ParseError +impl core::fmt::Display for radroots_core::unit::ParseError +pub fn radroots_core::unit::ParseError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub enum radroots_core::unit::Unit +pub radroots_core::unit::Unit::Each +pub radroots_core::unit::Unit::MassG +pub radroots_core::unit::Unit::MassKg +pub radroots_core::unit::Unit::MassLb +pub radroots_core::unit::Unit::MassOz +pub radroots_core::unit::Unit::VolumeL +pub radroots_core::unit::Unit::VolumeMl +impl radroots_core::unit::Unit +pub fn radroots_core::unit::Unit::canonical_unit(&self) -> Self +pub fn radroots_core::unit::Unit::code(&self) -> &'static str +pub fn radroots_core::unit::Unit::dimension(&self) -> radroots_core::unit::UnitDimension +pub fn radroots_core::unit::Unit::is_count(&self) -> bool +pub fn radroots_core::unit::Unit::is_mass(&self) -> bool +pub fn radroots_core::unit::Unit::is_volume(&self) -> bool +pub fn radroots_core::unit::Unit::same_dimension(Self, Self) -> bool +impl core::fmt::Display for radroots_core::unit::Unit +pub fn radroots_core::unit::Unit::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_core::unit::Unit +pub type radroots_core::unit::Unit::Err = radroots_core::unit::ParseError +pub fn radroots_core::unit::Unit::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_core::unit::Unit +pub fn radroots_core::unit::Unit::serialize<S: serde_core::ser::Serializer>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_core::unit::Unit +pub fn radroots_core::unit::Unit::deserialize<D: serde_core::de::Deserializer<'de>>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> +pub enum radroots_core::unit::UnitDimension +pub radroots_core::unit::UnitDimension::Count +pub radroots_core::unit::UnitDimension::Mass +pub radroots_core::unit::UnitDimension::Volume +pub fn radroots_core::unit::convert_mass_decimal(radroots_core::decimal::Decimal, radroots_core::unit::Unit, radroots_core::unit::Unit) -> core::result::Result<radroots_core::decimal::Decimal, radroots_core::unit::ConvertError> +pub fn radroots_core::unit::convert_unit_decimal(radroots_core::decimal::Decimal, radroots_core::unit::Unit, radroots_core::unit::Unit) -> core::result::Result<radroots_core::decimal::Decimal, radroots_core::unit::ConvertError> +pub fn radroots_core::unit::convert_volume_decimal(radroots_core::decimal::Decimal, radroots_core::unit::Unit, radroots_core::unit::Unit) -> core::result::Result<radroots_core::decimal::Decimal, radroots_core::unit::ConvertError> +pub fn radroots_core::unit::parse_mass_unit(&str) -> core::result::Result<radroots_core::unit::Unit, radroots_core::unit::ParseError> +pub fn radroots_core::unit::parse_volume_unit(&str) -> core::result::Result<radroots_core::unit::Unit, radroots_core::unit::ParseError> +#[non_exhaustive] pub enum radroots_core::Error +pub radroots_core::Error::Currency(radroots_core::currency::ParseError) +pub radroots_core::Error::Decimal(radroots_core::decimal::Error) +pub radroots_core::Error::Discount(radroots_core::pricing::DiscountError) +pub radroots_core::Error::Money(radroots_core::money::Error) +pub radroots_core::Error::Percent(radroots_core::percent::ParseError) +pub radroots_core::Error::Pricing(radroots_core::pricing::Error) +pub radroots_core::Error::Quantity(radroots_core::quantity::Error) +pub radroots_core::Error::UnitConversion(radroots_core::unit::ConvertError) +pub radroots_core::Error::UnitParse(radroots_core::unit::ParseError) +impl core::convert::From<radroots_core::currency::ParseError> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::currency::ParseError) -> Self +impl core::convert::From<radroots_core::decimal::Error> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::decimal::Error) -> Self +impl core::convert::From<radroots_core::money::Error> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::money::Error) -> Self +impl core::convert::From<radroots_core::percent::ParseError> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::percent::ParseError) -> Self +impl core::convert::From<radroots_core::pricing::DiscountError> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::pricing::DiscountError) -> Self +impl core::convert::From<radroots_core::pricing::Error> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::pricing::Error) -> Self +impl core::convert::From<radroots_core::quantity::Error> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::quantity::Error) -> Self +impl core::convert::From<radroots_core::unit::ConvertError> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::unit::ConvertError) -> Self +impl core::convert::From<radroots_core::unit::ParseError> for radroots_core::Error +pub fn radroots_core::Error::from(radroots_core::unit::ParseError) -> Self +impl core::error::Error for radroots_core::Error +pub fn radroots_core::Error::source(&self) -> core::option::Option<&(dyn core::error::Error + 'static)> +impl core::fmt::Display for radroots_core::Error +pub fn radroots_core::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub enum radroots_core::Unit +pub radroots_core::Unit::Each +pub radroots_core::Unit::MassG +pub radroots_core::Unit::MassKg +pub radroots_core::Unit::MassLb +pub radroots_core::Unit::MassOz +pub radroots_core::Unit::VolumeL +pub radroots_core::Unit::VolumeMl +impl radroots_core::unit::Unit +pub fn radroots_core::unit::Unit::canonical_unit(&self) -> Self +pub fn radroots_core::unit::Unit::code(&self) -> &'static str +pub fn radroots_core::unit::Unit::dimension(&self) -> radroots_core::unit::UnitDimension +pub fn radroots_core::unit::Unit::is_count(&self) -> bool +pub fn radroots_core::unit::Unit::is_mass(&self) -> bool +pub fn radroots_core::unit::Unit::is_volume(&self) -> bool +pub fn radroots_core::unit::Unit::same_dimension(Self, Self) -> bool +impl core::fmt::Display for radroots_core::unit::Unit +pub fn radroots_core::unit::Unit::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_core::unit::Unit +pub type radroots_core::unit::Unit::Err = radroots_core::unit::ParseError +pub fn radroots_core::unit::Unit::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_core::unit::Unit +pub fn radroots_core::unit::Unit::serialize<S: serde_core::ser::Serializer>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_core::unit::Unit +pub fn radroots_core::unit::Unit::deserialize<D: serde_core::de::Deserializer<'de>>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> +pub struct radroots_core::Currency(_) +impl radroots_core::currency::Currency +pub const radroots_core::currency::Currency::AUD: radroots_core::currency::Currency +pub const radroots_core::currency::Currency::CAD: radroots_core::currency::Currency +pub const radroots_core::currency::Currency::EUR: radroots_core::currency::Currency +pub const radroots_core::currency::Currency::GBP: radroots_core::currency::Currency +pub const radroots_core::currency::Currency::JPY: radroots_core::currency::Currency +pub const radroots_core::currency::Currency::USD: radroots_core::currency::Currency +pub const fn radroots_core::currency::Currency::as_bytes(&self) -> &[u8; 3] +pub fn radroots_core::currency::Currency::as_str(&self) -> &str +pub const fn radroots_core::currency::Currency::from_const([u8; 3]) -> core::result::Result<Self, radroots_core::currency::ParseError> +pub fn radroots_core::currency::Currency::from_str_upper(&str) -> core::result::Result<Self, radroots_core::currency::ParseError> +pub const fn radroots_core::currency::Currency::minor_unit_exponent(&self) -> u32 +impl core::convert::TryFrom<&str> for radroots_core::currency::Currency +pub type radroots_core::currency::Currency::Error = radroots_core::currency::ParseError +pub fn radroots_core::currency::Currency::try_from(&str) -> core::result::Result<Self, Self::Error> +impl core::fmt::Debug for radroots_core::currency::Currency +pub fn radroots_core::currency::Currency::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for radroots_core::currency::Currency +pub fn radroots_core::currency::Currency::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_core::currency::Currency +pub type radroots_core::currency::Currency::Err = radroots_core::currency::ParseError +pub fn radroots_core::currency::Currency::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_core::currency::Currency +pub fn radroots_core::currency::Currency::serialize<S: serde_core::ser::Serializer>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_core::currency::Currency +pub fn radroots_core::currency::Currency::deserialize<D: serde_core::de::Deserializer<'de>>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> +pub struct radroots_core::Decimal(_) +impl radroots_core::decimal::Decimal +pub const radroots_core::decimal::Decimal::MAX: Self +pub const radroots_core::decimal::Decimal::MAX_SCALE: u32 +pub const radroots_core::decimal::Decimal::MIN: Self +pub const radroots_core::decimal::Decimal::ONE: Self +pub const radroots_core::decimal::Decimal::ZERO: Self +pub fn radroots_core::decimal::Decimal::checked_add(self, Self) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::decimal::Decimal::checked_div(self, Self) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::decimal::Decimal::checked_mul(self, Self) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::decimal::Decimal::checked_sub(self, Self) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::decimal::Decimal::from_f64_display(f64) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::decimal::Decimal::from_str_exact(&str) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::decimal::Decimal::is_sign_negative(&self) -> bool +pub fn radroots_core::decimal::Decimal::is_zero(&self) -> bool +pub fn radroots_core::decimal::Decimal::normalize(&self) -> Self +pub fn radroots_core::decimal::Decimal::rescale(&mut self, u32) +pub fn radroots_core::decimal::Decimal::scale(&self) -> u32 +pub fn radroots_core::decimal::Decimal::to_f64_lossy(&self) -> core::option::Option<f64> +pub fn radroots_core::decimal::Decimal::to_u64_exact(&self) -> core::option::Option<u64> +pub fn radroots_core::decimal::Decimal::try_rescale_exact(&mut self, u32) -> core::result::Result<(), radroots_core::decimal::Error> +impl core::convert::From<i32> for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::from(i32) -> Self +impl core::convert::From<i64> for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::from(i64) -> Self +impl core::convert::From<u32> for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::from(u32) -> Self +impl core::convert::From<u64> for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::from(u64) -> Self +impl core::fmt::Display for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_core::decimal::Decimal +pub type radroots_core::decimal::Decimal::Err = radroots_core::decimal::Error +pub fn radroots_core::decimal::Decimal::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::serialize<S: serde_core::ser::Serializer>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_core::decimal::Decimal +pub fn radroots_core::decimal::Decimal::deserialize<D: serde_core::de::Deserializer<'de>>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> +pub struct radroots_core::Money +impl radroots_core::money::Money +pub const fn radroots_core::money::Money::amount(&self) -> radroots_core::decimal::Decimal +pub fn radroots_core::money::Money::checked_add(&self, &Self) -> core::result::Result<Self, radroots_core::money::Error> +pub fn radroots_core::money::Money::checked_div_decimal(&self, radroots_core::decimal::Decimal) -> core::result::Result<Self, radroots_core::money::Error> +pub fn radroots_core::money::Money::checked_mul_decimal(&self, radroots_core::decimal::Decimal) -> core::result::Result<Self, radroots_core::money::Error> +pub fn radroots_core::money::Money::checked_sub(&self, &Self) -> core::result::Result<Self, radroots_core::money::Error> +pub const fn radroots_core::money::Money::currency(&self) -> radroots_core::currency::Currency +pub fn radroots_core::money::Money::from_minor_units_u32(u32, radroots_core::currency::Currency) -> Self +pub fn radroots_core::money::Money::from_minor_units_u64(u64, radroots_core::currency::Currency) -> Self +pub fn radroots_core::money::Money::is_zero(&self) -> bool +pub fn radroots_core::money::Money::quantize_to_currency(self) -> Self +pub fn radroots_core::money::Money::to_minor_units_u32_exact(&self) -> core::result::Result<u32, radroots_core::money::Error> +pub fn radroots_core::money::Money::to_minor_units_u32_rounded(&self) -> core::result::Result<u32, radroots_core::money::Error> +pub fn radroots_core::money::Money::to_minor_units_u64_exact(&self) -> core::result::Result<u64, radroots_core::money::Error> +pub fn radroots_core::money::Money::to_minor_units_u64_rounded(&self) -> core::result::Result<u64, radroots_core::money::Error> +pub fn radroots_core::money::Money::try_new(radroots_core::decimal::Decimal, radroots_core::currency::Currency) -> core::result::Result<Self, radroots_core::money::Error> +pub fn radroots_core::money::Money::try_with_scale_exact(self, u32) -> core::result::Result<Self, radroots_core::money::Error> +pub fn radroots_core::money::Money::zero(radroots_core::currency::Currency) -> Self +impl core::fmt::Display for radroots_core::money::Money +pub fn radroots_core::money::Money::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl<'de> serde_core::de::Deserialize<'de> for radroots_core::money::Money +pub fn radroots_core::money::Money::deserialize<D: serde_core::de::Deserializer<'de>>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> +pub struct radroots_core::Percent +impl radroots_core::percent::Percent +pub fn radroots_core::percent::Percent::new(radroots_core::decimal::Decimal) -> Self +pub fn radroots_core::percent::Percent::try_from_ratio(radroots_core::decimal::Decimal) -> core::result::Result<Self, radroots_core::decimal::Error> +pub fn radroots_core::percent::Percent::try_of_money(&self, &radroots_core::money::Money) -> core::result::Result<radroots_core::money::Money, radroots_core::money::Error> +pub fn radroots_core::percent::Percent::try_of_money_quantized(&self, &radroots_core::money::Money) -> core::result::Result<radroots_core::money::Money, radroots_core::money::Error> +pub fn radroots_core::percent::Percent::try_to_ratio(&self) -> core::result::Result<radroots_core::decimal::Decimal, radroots_core::decimal::Error> +pub const fn radroots_core::percent::Percent::value(&self) -> radroots_core::decimal::Decimal +impl core::fmt::Display for radroots_core::percent::Percent +pub fn radroots_core::percent::Percent::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_core::percent::Percent +pub type radroots_core::percent::Percent::Err = radroots_core::percent::ParseError +pub fn radroots_core::percent::Percent::from_str(&str) -> core::result::Result<Self, Self::Err> +pub struct radroots_core::Quantity +impl radroots_core::quantity::Quantity +pub const fn radroots_core::quantity::Quantity::amount(&self) -> radroots_core::decimal::Decimal +pub fn radroots_core::quantity::Quantity::canonical_unit(&self) -> radroots_core::unit::Unit +pub fn radroots_core::quantity::Quantity::checked_add(&self, &radroots_core::quantity::Quantity) -> core::option::Option<radroots_core::quantity::Quantity> +pub fn radroots_core::quantity::Quantity::checked_div_decimal(&self, radroots_core::decimal::Decimal) -> core::result::Result<radroots_core::quantity::Quantity, radroots_core::quantity::Error> +pub fn radroots_core::quantity::Quantity::checked_mul_decimal(&self, radroots_core::decimal::Decimal) -> core::result::Result<radroots_core::quantity::Quantity, radroots_core::quantity::Error> +pub fn radroots_core::quantity::Quantity::checked_sub(&self, &radroots_core::quantity::Quantity) -> core::option::Option<radroots_core::quantity::Quantity> +pub fn radroots_core::quantity::Quantity::clear_label(self) -> Self +pub fn radroots_core::quantity::Quantity::is_canonical(&self) -> bool +pub fn radroots_core::quantity::Quantity::is_zero(&self) -> bool +pub fn radroots_core::quantity::Quantity::label(&self) -> core::option::Option<&str> +pub fn radroots_core::quantity::Quantity::to_canonical(&self) -> core::result::Result<radroots_core::quantity::Quantity, radroots_core::unit::ConvertError> +pub fn radroots_core::quantity::Quantity::try_add(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::quantity::Quantity, radroots_core::quantity::Error> +pub fn radroots_core::quantity::Quantity::try_convert_to(&self, radroots_core::unit::Unit) -> core::result::Result<radroots_core::quantity::Quantity, radroots_core::unit::ConvertError> +pub fn radroots_core::quantity::Quantity::try_new(radroots_core::decimal::Decimal, radroots_core::unit::Unit) -> core::result::Result<Self, radroots_core::quantity::Error> +pub fn radroots_core::quantity::Quantity::try_sub(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::quantity::Quantity, radroots_core::quantity::Error> +pub const fn radroots_core::quantity::Quantity::unit(&self) -> radroots_core::unit::Unit +pub fn radroots_core::quantity::Quantity::with_label<S: core::convert::Into<alloc::string::String>>(self, S) -> Self +pub fn radroots_core::quantity::Quantity::with_optional_label<S: core::convert::Into<alloc::string::String>>(self, core::option::Option<S>) -> Self +pub fn radroots_core::quantity::Quantity::zero(radroots_core::unit::Unit) -> Self +impl core::fmt::Display for radroots_core::quantity::Quantity +pub fn radroots_core::quantity::Quantity::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl<'de> serde_core::de::Deserialize<'de> for radroots_core::quantity::Quantity +pub fn radroots_core::quantity::Quantity::deserialize<D: serde_core::de::Deserializer<'de>>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> +pub struct radroots_core::QuantityPrice +impl radroots_core::QuantityPrice +pub fn radroots_core::QuantityPrice::amount(&self) -> &radroots_core::money::Money +pub fn radroots_core::QuantityPrice::is_price_per_canonical_unit(&self) -> bool +pub fn radroots_core::QuantityPrice::quantity(&self) -> &radroots_core::quantity::Quantity +pub fn radroots_core::QuantityPrice::try_cost_for_amount_in(&self, radroots_core::decimal::Decimal, radroots_core::unit::Unit) -> core::result::Result<radroots_core::money::Money, radroots_core::pricing::Error> +pub fn radroots_core::QuantityPrice::try_cost_for_quantity_in(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::money::Money, radroots_core::pricing::Error> +pub fn radroots_core::QuantityPrice::try_new(radroots_core::money::Money, radroots_core::quantity::Quantity) -> core::result::Result<Self, radroots_core::pricing::Error> +pub fn radroots_core::QuantityPrice::try_to_canonical_unit_price(&self) -> core::result::Result<radroots_core::QuantityPrice, radroots_core::pricing::Error> +pub fn radroots_core::QuantityPrice::try_to_unit_price(&self, radroots_core::unit::Unit) -> core::result::Result<radroots_core::QuantityPrice, radroots_core::pricing::Error> +pub fn radroots_core::QuantityPrice::validate(&self) -> core::result::Result<(), radroots_core::pricing::Error> +impl radroots_core::pricing::QuantityPriceOps for radroots_core::QuantityPrice +pub fn radroots_core::QuantityPrice::try_cost_for(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::money::Money, radroots_core::pricing::Error> +pub fn radroots_core::QuantityPrice::try_cost_for_rounded(&self, &radroots_core::quantity::Quantity) -> core::result::Result<radroots_core::money::Money, radroots_core::pricing::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_core::QuantityPrice +pub fn radroots_core::QuantityPrice::deserialize<D: serde_core::de::Deserializer<'de>>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> diff --git a/docs/api/radroots_blossom.txt b/docs/api/radroots_blossom.txt @@ -0,0 +1,348 @@ +pub mod radroots_blossom +pub mod radroots_blossom::authorization +pub enum radroots_blossom::authorization::AuthorizationAction +pub radroots_blossom::authorization::AuthorizationAction::Delete +pub radroots_blossom::authorization::AuthorizationAction::Get +pub radroots_blossom::authorization::AuthorizationAction::List +pub radroots_blossom::authorization::AuthorizationAction::Media +pub radroots_blossom::authorization::AuthorizationAction::Upload +impl radroots_blossom::authorization::AuthorizationAction +pub const fn radroots_blossom::authorization::AuthorizationAction::as_str(self) -> &'static str +pub fn radroots_blossom::authorization::AuthorizationAction::parse(&str) -> core::result::Result<Self, radroots_blossom::Error> +impl core::fmt::Display for radroots_blossom::authorization::AuthorizationAction +pub fn radroots_blossom::authorization::AuthorizationAction::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_blossom::authorization::AuthorizationAction +pub type radroots_blossom::authorization::AuthorizationAction::Err = radroots_blossom::Error +pub fn radroots_blossom::authorization::AuthorizationAction::from_str(&str) -> core::result::Result<Self, Self::Err> +pub enum radroots_blossom::authorization::AuthorizationTarget +pub radroots_blossom::authorization::AuthorizationTarget::DeleteBlob(radroots_blossom::hash::Sha256) +pub radroots_blossom::authorization::AuthorizationTarget::GetBlob(radroots_blossom::hash::Sha256) +pub radroots_blossom::authorization::AuthorizationTarget::List +pub radroots_blossom::authorization::AuthorizationTarget::Media(radroots_blossom::hash::Sha256) +pub radroots_blossom::authorization::AuthorizationTarget::Mirror(radroots_blossom::hash::Sha256) +pub radroots_blossom::authorization::AuthorizationTarget::Upload(radroots_blossom::hash::Sha256) +impl radroots_blossom::authorization::AuthorizationTarget +pub const fn radroots_blossom::authorization::AuthorizationTarget::action(self) -> radroots_blossom::authorization::AuthorizationAction +pub const fn radroots_blossom::authorization::AuthorizationTarget::implied_hash(self) -> core::option::Option<radroots_blossom::hash::Sha256> +pub enum radroots_blossom::authorization::ServerScopeRequirement +pub radroots_blossom::authorization::ServerScopeRequirement::OptionalAnyMatch +pub radroots_blossom::authorization::ServerScopeRequirement::RequiredAnyMatch +pub struct radroots_blossom::authorization::AuthoredUploadClaim +impl radroots_blossom::authorization::AuthoredUploadClaim +pub fn radroots_blossom::authorization::AuthoredUploadClaim::content(&self) -> &radroots_blossom::authorization::AuthorizationContent +pub const fn radroots_blossom::authorization::AuthoredUploadClaim::created_at(&self) -> u64 +pub const fn radroots_blossom::authorization::AuthoredUploadClaim::expiration(&self) -> u64 +pub fn radroots_blossom::authorization::AuthoredUploadClaim::into_wire_parts(self) -> radroots_blossom::authorization::AuthorizationWireParts +pub const fn radroots_blossom::authorization::AuthoredUploadClaim::lifetime_seconds(&self) -> u64 +pub fn radroots_blossom::authorization::AuthoredUploadClaim::new(radroots_blossom::authorization::AuthorizationContent, radroots_blossom::authorization::ServerDomain, radroots_blossom::hash::Sha256, u64, u64) -> core::result::Result<Self, radroots_blossom::Error> +pub fn radroots_blossom::authorization::AuthoredUploadClaim::server_domain(&self) -> &radroots_blossom::authorization::ServerDomain +pub const fn radroots_blossom::authorization::AuthoredUploadClaim::sha256(&self) -> radroots_blossom::hash::Sha256 +pub fn radroots_blossom::authorization::AuthoredUploadClaim::wire_parts(&self) -> &radroots_blossom::authorization::AuthorizationWireParts +pub struct radroots_blossom::authorization::AuthorizationClaim +impl radroots_blossom::authorization::AuthorizationClaim +pub const fn radroots_blossom::authorization::AuthorizationClaim::action(&self) -> radroots_blossom::authorization::AuthorizationAction +pub fn radroots_blossom::authorization::AuthorizationClaim::content(&self) -> &radroots_blossom::authorization::AuthorizationContent +pub const fn radroots_blossom::authorization::AuthorizationClaim::created_at(&self) -> u64 +pub const fn radroots_blossom::authorization::AuthorizationClaim::expiration(&self) -> u64 +pub fn radroots_blossom::authorization::AuthorizationClaim::hashes(&self) -> &[radroots_blossom::hash::Sha256] +pub fn radroots_blossom::authorization::AuthorizationClaim::parse(&str, u64, &[alloc::vec::Vec<alloc::string::String>]) -> core::result::Result<Self, radroots_blossom::Error> +pub fn radroots_blossom::authorization::AuthorizationClaim::server_domains(&self) -> &[radroots_blossom::authorization::ServerDomain] +pub fn radroots_blossom::authorization::AuthorizationClaim::validate(&self, &radroots_blossom::authorization::AuthorizationValidation) -> core::result::Result<radroots_blossom::authorization::ValidatedAuthorizationClaim, radroots_blossom::Error> +pub struct radroots_blossom::authorization::AuthorizationContent(_) +impl radroots_blossom::authorization::AuthorizationContent +pub fn radroots_blossom::authorization::AuthorizationContent::as_str(&self) -> &str +pub fn radroots_blossom::authorization::AuthorizationContent::parse(&str) -> core::result::Result<Self, radroots_blossom::Error> +impl core::fmt::Display for radroots_blossom::authorization::AuthorizationContent +pub fn radroots_blossom::authorization::AuthorizationContent::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_blossom::authorization::AuthorizationContent +pub type radroots_blossom::authorization::AuthorizationContent::Err = radroots_blossom::Error +pub fn radroots_blossom::authorization::AuthorizationContent::from_str(&str) -> core::result::Result<Self, Self::Err> +pub struct radroots_blossom::authorization::AuthorizationValidation +impl radroots_blossom::authorization::AuthorizationValidation +pub fn radroots_blossom::authorization::AuthorizationValidation::bud11(radroots_blossom::authorization::AuthorizationTarget, radroots_blossom::authorization::ServerDomain, u64) -> Self +pub const fn radroots_blossom::authorization::AuthorizationValidation::max_created_age_seconds(&self) -> core::option::Option<u64> +pub const fn radroots_blossom::authorization::AuthorizationValidation::max_lifetime_seconds(&self) -> core::option::Option<u64> +pub fn radroots_blossom::authorization::AuthorizationValidation::new(radroots_blossom::authorization::AuthorizationTarget, radroots_blossom::authorization::ServerDomain, radroots_blossom::authorization::ServerScopeRequirement, u64, u64) -> core::result::Result<Self, radroots_blossom::Error> +pub const fn radroots_blossom::authorization::AuthorizationValidation::now(&self) -> u64 +pub const fn radroots_blossom::authorization::AuthorizationValidation::server_scope_requirement(&self) -> radroots_blossom::authorization::ServerScopeRequirement +pub const fn radroots_blossom::authorization::AuthorizationValidation::target(&self) -> radroots_blossom::authorization::AuthorizationTarget +pub fn radroots_blossom::authorization::AuthorizationValidation::target_server(&self) -> &radroots_blossom::authorization::ServerDomain +pub struct radroots_blossom::authorization::AuthorizationWireParts +impl radroots_blossom::authorization::AuthorizationWireParts +pub fn radroots_blossom::authorization::AuthorizationWireParts::content(&self) -> &str +pub const fn radroots_blossom::authorization::AuthorizationWireParts::created_at(&self) -> u64 +pub fn radroots_blossom::authorization::AuthorizationWireParts::into_parts(self) -> (u16, u64, alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>>, alloc::string::String) +pub const fn radroots_blossom::authorization::AuthorizationWireParts::kind(&self) -> u16 +pub fn radroots_blossom::authorization::AuthorizationWireParts::tags(&self) -> &[alloc::vec::Vec<alloc::string::String>] +pub struct radroots_blossom::authorization::ServerDomain(_) +impl radroots_blossom::authorization::ServerDomain +pub fn radroots_blossom::authorization::ServerDomain::as_str(&self) -> &str +pub fn radroots_blossom::authorization::ServerDomain::parse(&str) -> core::result::Result<Self, radroots_blossom::Error> +impl core::fmt::Display for radroots_blossom::authorization::ServerDomain +pub fn radroots_blossom::authorization::ServerDomain::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_blossom::authorization::ServerDomain +pub type radroots_blossom::authorization::ServerDomain::Err = radroots_blossom::Error +pub fn radroots_blossom::authorization::ServerDomain::from_str(&str) -> core::result::Result<Self, Self::Err> +pub struct radroots_blossom::authorization::ValidatedAuthorizationClaim +impl radroots_blossom::authorization::ValidatedAuthorizationClaim +pub const fn radroots_blossom::authorization::ValidatedAuthorizationClaim::action(&self) -> radroots_blossom::authorization::AuthorizationAction +pub fn radroots_blossom::authorization::ValidatedAuthorizationClaim::claim(&self) -> &radroots_blossom::authorization::AuthorizationClaim +pub fn radroots_blossom::authorization::ValidatedAuthorizationClaim::content(&self) -> &radroots_blossom::authorization::AuthorizationContent +pub const fn radroots_blossom::authorization::ValidatedAuthorizationClaim::created_at(&self) -> u64 +pub const fn radroots_blossom::authorization::ValidatedAuthorizationClaim::expiration(&self) -> u64 +pub fn radroots_blossom::authorization::ValidatedAuthorizationClaim::hashes(&self) -> &[radroots_blossom::hash::Sha256] +pub fn radroots_blossom::authorization::ValidatedAuthorizationClaim::server_domains(&self) -> &[radroots_blossom::authorization::ServerDomain] +pub const fn radroots_blossom::authorization::ValidatedAuthorizationClaim::target(&self) -> radroots_blossom::authorization::AuthorizationTarget +pub fn radroots_blossom::authorization::ValidatedAuthorizationClaim::target_server(&self) -> &radroots_blossom::authorization::ServerDomain +pub const radroots_blossom::authorization::RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND: u16 +pub const radroots_blossom::authorization::RADROOTS_BLOSSOM_AUTH_CONTENT_MAX_BYTES: usize +pub const radroots_blossom::authorization::RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS: u64 +pub const radroots_blossom::authorization::RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS: u64 +pub mod radroots_blossom::descriptor +pub struct radroots_blossom::descriptor::ApprovedDescriptor +impl radroots_blossom::descriptor::ApprovedDescriptor +pub fn radroots_blossom::descriptor::ApprovedDescriptor::descriptor(&self) -> &radroots_blossom::descriptor::BlobDescriptor +pub fn radroots_blossom::descriptor::ApprovedDescriptor::into_descriptor(self) -> radroots_blossom::descriptor::BlobDescriptor +pub fn radroots_blossom::descriptor::ApprovedDescriptor::url(&self) -> &radroots_blossom::url::ApprovedBlobUrl +pub fn radroots_blossom::descriptor::ApprovedDescriptor::verify_bytes(self, &[u8], &radroots_blossom::media_type::MediaType) -> core::result::Result<radroots_blossom::descriptor::ByteVerifiedDescriptor, radroots_blossom::Error> +pub fn radroots_blossom::descriptor::ApprovedDescriptor::verify_commitment(self, &radroots_blossom::descriptor::ByteCommitment) -> core::result::Result<radroots_blossom::descriptor::ByteVerifiedDescriptor, radroots_blossom::Error> +pub struct radroots_blossom::descriptor::BlobDescriptor +impl radroots_blossom::descriptor::BlobDescriptor +pub fn radroots_blossom::descriptor::BlobDescriptor::approve_reference(self) -> core::result::Result<radroots_blossom::descriptor::ApprovedDescriptor, radroots_blossom::Error> +pub fn radroots_blossom::descriptor::BlobDescriptor::media_type(&self) -> &radroots_blossom::media_type::MediaType +pub fn radroots_blossom::descriptor::BlobDescriptor::new(radroots_blossom::url::BlobUrl, radroots_blossom::hash::Sha256, u64, radroots_blossom::media_type::MediaType, u64) -> core::result::Result<Self, radroots_blossom::Error> +pub const fn radroots_blossom::descriptor::BlobDescriptor::sha256(&self) -> radroots_blossom::hash::Sha256 +pub const fn radroots_blossom::descriptor::BlobDescriptor::size(&self) -> u64 +pub const fn radroots_blossom::descriptor::BlobDescriptor::uploaded(&self) -> u64 +pub fn radroots_blossom::descriptor::BlobDescriptor::url(&self) -> &radroots_blossom::url::BlobUrl +impl serde_core::ser::Serialize for radroots_blossom::descriptor::BlobDescriptor +pub fn radroots_blossom::descriptor::BlobDescriptor::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_blossom::descriptor::BlobDescriptor +pub fn radroots_blossom::descriptor::BlobDescriptor::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_blossom::descriptor::ByteCommitment +impl radroots_blossom::descriptor::ByteCommitment +pub fn radroots_blossom::descriptor::ByteCommitment::from_bytes(&[u8], radroots_blossom::media_type::MediaType) -> Self +pub fn radroots_blossom::descriptor::ByteCommitment::media_type(&self) -> &radroots_blossom::media_type::MediaType +pub const fn radroots_blossom::descriptor::ByteCommitment::sha256(&self) -> radroots_blossom::hash::Sha256 +pub const fn radroots_blossom::descriptor::ByteCommitment::size(&self) -> u64 +pub struct radroots_blossom::descriptor::ByteVerifiedDescriptor(_) +impl radroots_blossom::descriptor::ByteVerifiedDescriptor +pub fn radroots_blossom::descriptor::ByteVerifiedDescriptor::descriptor(&self) -> &radroots_blossom::descriptor::BlobDescriptor +pub fn radroots_blossom::descriptor::ByteVerifiedDescriptor::into_descriptor(self) -> radroots_blossom::descriptor::BlobDescriptor +pub fn radroots_blossom::descriptor::ByteVerifiedDescriptor::media_type(&self) -> &radroots_blossom::media_type::MediaType +pub const fn radroots_blossom::descriptor::ByteVerifiedDescriptor::sha256(&self) -> radroots_blossom::hash::Sha256 +pub const fn radroots_blossom::descriptor::ByteVerifiedDescriptor::size(&self) -> u64 +pub fn radroots_blossom::descriptor::ByteVerifiedDescriptor::url(&self) -> &radroots_blossom::url::ApprovedBlobUrl +pub mod radroots_blossom::hash +pub struct radroots_blossom::hash::FileExtension(_) +impl radroots_blossom::hash::FileExtension +pub fn radroots_blossom::hash::FileExtension::as_str(&self) -> &str +pub fn radroots_blossom::hash::FileExtension::parse(&str) -> core::result::Result<Self, radroots_blossom::Error> +impl core::fmt::Display for radroots_blossom::hash::FileExtension +pub fn radroots_blossom::hash::FileExtension::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_blossom::hash::FileExtension +pub type radroots_blossom::hash::FileExtension::Err = radroots_blossom::Error +pub fn radroots_blossom::hash::FileExtension::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_blossom::hash::FileExtension +pub fn radroots_blossom::hash::FileExtension::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_blossom::hash::FileExtension +pub fn radroots_blossom::hash::FileExtension::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_blossom::hash::HashPath +impl radroots_blossom::hash::HashPath +pub fn radroots_blossom::hash::HashPath::extension(&self) -> core::option::Option<&radroots_blossom::hash::FileExtension> +pub const fn radroots_blossom::hash::HashPath::hash(&self) -> radroots_blossom::hash::Sha256 +pub fn radroots_blossom::hash::HashPath::new(radroots_blossom::hash::Sha256, core::option::Option<radroots_blossom::hash::FileExtension>) -> Self +pub fn radroots_blossom::hash::HashPath::parse(&str) -> core::result::Result<Self, radroots_blossom::Error> +pub fn radroots_blossom::hash::HashPath::to_path(&self) -> alloc::string::String +impl core::fmt::Display for radroots_blossom::hash::HashPath +pub fn radroots_blossom::hash::HashPath::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_blossom::hash::HashPath +pub type radroots_blossom::hash::HashPath::Err = radroots_blossom::Error +pub fn radroots_blossom::hash::HashPath::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_blossom::hash::HashPath +pub fn radroots_blossom::hash::HashPath::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_blossom::hash::HashPath +pub fn radroots_blossom::hash::HashPath::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_blossom::hash::Sha256(_) +impl radroots_blossom::hash::Sha256 +pub const fn radroots_blossom::hash::Sha256::as_bytes(&self) -> &[u8; 32] +pub fn radroots_blossom::hash::Sha256::digest(&[u8]) -> Self +pub const fn radroots_blossom::hash::Sha256::from_bytes([u8; 32]) -> Self +pub fn radroots_blossom::hash::Sha256::from_hex(&str) -> core::result::Result<Self, radroots_blossom::Error> +pub fn radroots_blossom::hash::Sha256::to_hex(self) -> alloc::string::String +impl core::fmt::Display for radroots_blossom::hash::Sha256 +pub fn radroots_blossom::hash::Sha256::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_blossom::hash::Sha256 +pub type radroots_blossom::hash::Sha256::Err = radroots_blossom::Error +pub fn radroots_blossom::hash::Sha256::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_blossom::hash::Sha256 +pub fn radroots_blossom::hash::Sha256::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_blossom::hash::Sha256 +pub fn radroots_blossom::hash::Sha256::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub mod radroots_blossom::media_type +pub struct radroots_blossom::media_type::MediaType(_) +impl radroots_blossom::media_type::MediaType +pub fn radroots_blossom::media_type::MediaType::as_str(&self) -> &str +pub fn radroots_blossom::media_type::MediaType::parse(&str) -> core::result::Result<Self, radroots_blossom::Error> +impl core::fmt::Display for radroots_blossom::media_type::MediaType +pub fn radroots_blossom::media_type::MediaType::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_blossom::media_type::MediaType +pub type radroots_blossom::media_type::MediaType::Err = radroots_blossom::Error +pub fn radroots_blossom::media_type::MediaType::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_blossom::media_type::MediaType +pub fn radroots_blossom::media_type::MediaType::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_blossom::media_type::MediaType +pub fn radroots_blossom::media_type::MediaType::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub mod radroots_blossom::url +pub struct radroots_blossom::url::ApprovedBlobUrl(_) +impl radroots_blossom::url::ApprovedBlobUrl +pub fn radroots_blossom::url::ApprovedBlobUrl::as_blob_url(&self) -> &radroots_blossom::url::BlobUrl +pub fn radroots_blossom::url::ApprovedBlobUrl::as_str(&self) -> &str +pub fn radroots_blossom::url::ApprovedBlobUrl::into_blob_url(self) -> radroots_blossom::url::BlobUrl +impl core::fmt::Display for radroots_blossom::url::ApprovedBlobUrl +pub fn radroots_blossom::url::ApprovedBlobUrl::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_blossom::url::BlobUrl +impl radroots_blossom::url::BlobUrl +pub fn radroots_blossom::url::BlobUrl::approve(self) -> core::result::Result<radroots_blossom::url::ApprovedBlobUrl, radroots_blossom::Error> +pub fn radroots_blossom::url::BlobUrl::as_str(&self) -> &str +pub fn radroots_blossom::url::BlobUrl::hash_path(&self) -> &radroots_blossom::hash::HashPath +pub fn radroots_blossom::url::BlobUrl::host(&self) -> &str +pub fn radroots_blossom::url::BlobUrl::is_https(&self) -> bool +pub fn radroots_blossom::url::BlobUrl::is_loopback_http(&self) -> bool +pub fn radroots_blossom::url::BlobUrl::parse(&str) -> core::result::Result<Self, radroots_blossom::Error> +pub fn radroots_blossom::url::BlobUrl::port(&self) -> core::option::Option<u16> +pub fn radroots_blossom::url::BlobUrl::scheme(&self) -> &str +impl core::fmt::Display for radroots_blossom::url::BlobUrl +pub fn radroots_blossom::url::BlobUrl::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_blossom::url::BlobUrl +pub type radroots_blossom::url::BlobUrl::Err = radroots_blossom::Error +pub fn radroots_blossom::url::BlobUrl::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_blossom::url::BlobUrl +pub fn radroots_blossom::url::BlobUrl::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_blossom::url::BlobUrl +pub fn radroots_blossom::url::BlobUrl::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +#[non_exhaustive] pub enum radroots_blossom::Error +pub radroots_blossom::Error::AuthorizationActionMismatch +pub radroots_blossom::Error::AuthorizationCreatedInFuture +pub radroots_blossom::Error::AuthorizationExpired +pub radroots_blossom::Error::AuthorizationHashMismatch +pub radroots_blossom::Error::AuthorizationHashRequired +pub radroots_blossom::Error::AuthorizationServerMismatch +pub radroots_blossom::Error::AuthorizationServerRequired +pub radroots_blossom::Error::AuthorizationStale +pub radroots_blossom::Error::AuthorizationTimestampOverflow +pub radroots_blossom::Error::BlobHashMismatch +pub radroots_blossom::Error::BlobMediaTypeMismatch +pub radroots_blossom::Error::BlobSizeMismatch +pub radroots_blossom::Error::BlobSizeMismatch::actual: u64 +pub radroots_blossom::Error::BlobSizeMismatch::expected: u64 +pub radroots_blossom::Error::BlobUrlCredentialsForbidden +pub radroots_blossom::Error::BlobUrlFragmentForbidden +pub radroots_blossom::Error::BlobUrlQueryForbidden +pub radroots_blossom::Error::DescriptorExtensionRequired +pub radroots_blossom::Error::DescriptorHashMismatch +pub radroots_blossom::Error::DuplicateAuthorizationActionTag +pub radroots_blossom::Error::DuplicateAuthorizationExpirationTag +pub radroots_blossom::Error::InsecureBlobUrl +pub radroots_blossom::Error::InvalidAuthorizationAction +pub radroots_blossom::Error::InvalidAuthorizationContent +pub radroots_blossom::Error::InvalidAuthorizationCreatedAge +pub radroots_blossom::Error::InvalidAuthorizationLifetime +pub radroots_blossom::Error::InvalidAuthorizationServerDomain +pub radroots_blossom::Error::InvalidBlobUrl +pub radroots_blossom::Error::InvalidFileExtension +pub radroots_blossom::Error::InvalidHashPath +pub radroots_blossom::Error::InvalidMediaType +pub radroots_blossom::Error::InvalidSha256 +pub radroots_blossom::Error::MalformedAuthorizationActionTag +pub radroots_blossom::Error::MalformedAuthorizationExpirationTag +pub radroots_blossom::Error::MalformedAuthorizationHashTag +pub radroots_blossom::Error::MalformedAuthorizationServerTag +pub radroots_blossom::Error::MissingAuthorizationActionTag +pub radroots_blossom::Error::MissingAuthorizationExpirationTag +pub radroots_blossom::Error::UnsupportedBlobUrlScheme +impl radroots_blossom::Error +pub const fn radroots_blossom::Error::code(&self) -> &'static str +impl core::error::Error for radroots_blossom::Error +impl core::fmt::Display for radroots_blossom::Error +pub fn radroots_blossom::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_blossom::AuthorizationClaim +impl radroots_blossom::authorization::AuthorizationClaim +pub const fn radroots_blossom::authorization::AuthorizationClaim::action(&self) -> radroots_blossom::authorization::AuthorizationAction +pub fn radroots_blossom::authorization::AuthorizationClaim::content(&self) -> &radroots_blossom::authorization::AuthorizationContent +pub const fn radroots_blossom::authorization::AuthorizationClaim::created_at(&self) -> u64 +pub const fn radroots_blossom::authorization::AuthorizationClaim::expiration(&self) -> u64 +pub fn radroots_blossom::authorization::AuthorizationClaim::hashes(&self) -> &[radroots_blossom::hash::Sha256] +pub fn radroots_blossom::authorization::AuthorizationClaim::parse(&str, u64, &[alloc::vec::Vec<alloc::string::String>]) -> core::result::Result<Self, radroots_blossom::Error> +pub fn radroots_blossom::authorization::AuthorizationClaim::server_domains(&self) -> &[radroots_blossom::authorization::ServerDomain] +pub fn radroots_blossom::authorization::AuthorizationClaim::validate(&self, &radroots_blossom::authorization::AuthorizationValidation) -> core::result::Result<radroots_blossom::authorization::ValidatedAuthorizationClaim, radroots_blossom::Error> +pub struct radroots_blossom::BlobDescriptor +impl radroots_blossom::descriptor::BlobDescriptor +pub fn radroots_blossom::descriptor::BlobDescriptor::approve_reference(self) -> core::result::Result<radroots_blossom::descriptor::ApprovedDescriptor, radroots_blossom::Error> +pub fn radroots_blossom::descriptor::BlobDescriptor::media_type(&self) -> &radroots_blossom::media_type::MediaType +pub fn radroots_blossom::descriptor::BlobDescriptor::new(radroots_blossom::url::BlobUrl, radroots_blossom::hash::Sha256, u64, radroots_blossom::media_type::MediaType, u64) -> core::result::Result<Self, radroots_blossom::Error> +pub const fn radroots_blossom::descriptor::BlobDescriptor::sha256(&self) -> radroots_blossom::hash::Sha256 +pub const fn radroots_blossom::descriptor::BlobDescriptor::size(&self) -> u64 +pub const fn radroots_blossom::descriptor::BlobDescriptor::uploaded(&self) -> u64 +pub fn radroots_blossom::descriptor::BlobDescriptor::url(&self) -> &radroots_blossom::url::BlobUrl +impl serde_core::ser::Serialize for radroots_blossom::descriptor::BlobDescriptor +pub fn radroots_blossom::descriptor::BlobDescriptor::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_blossom::descriptor::BlobDescriptor +pub fn radroots_blossom::descriptor::BlobDescriptor::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_blossom::BlobUrl +impl radroots_blossom::url::BlobUrl +pub fn radroots_blossom::url::BlobUrl::approve(self) -> core::result::Result<radroots_blossom::url::ApprovedBlobUrl, radroots_blossom::Error> +pub fn radroots_blossom::url::BlobUrl::as_str(&self) -> &str +pub fn radroots_blossom::url::BlobUrl::hash_path(&self) -> &radroots_blossom::hash::HashPath +pub fn radroots_blossom::url::BlobUrl::host(&self) -> &str +pub fn radroots_blossom::url::BlobUrl::is_https(&self) -> bool +pub fn radroots_blossom::url::BlobUrl::is_loopback_http(&self) -> bool +pub fn radroots_blossom::url::BlobUrl::parse(&str) -> core::result::Result<Self, radroots_blossom::Error> +pub fn radroots_blossom::url::BlobUrl::port(&self) -> core::option::Option<u16> +pub fn radroots_blossom::url::BlobUrl::scheme(&self) -> &str +impl core::fmt::Display for radroots_blossom::url::BlobUrl +pub fn radroots_blossom::url::BlobUrl::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_blossom::url::BlobUrl +pub type radroots_blossom::url::BlobUrl::Err = radroots_blossom::Error +pub fn radroots_blossom::url::BlobUrl::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_blossom::url::BlobUrl +pub fn radroots_blossom::url::BlobUrl::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_blossom::url::BlobUrl +pub fn radroots_blossom::url::BlobUrl::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_blossom::ByteVerifiedDescriptor(_) +impl radroots_blossom::descriptor::ByteVerifiedDescriptor +pub fn radroots_blossom::descriptor::ByteVerifiedDescriptor::descriptor(&self) -> &radroots_blossom::descriptor::BlobDescriptor +pub fn radroots_blossom::descriptor::ByteVerifiedDescriptor::into_descriptor(self) -> radroots_blossom::descriptor::BlobDescriptor +pub fn radroots_blossom::descriptor::ByteVerifiedDescriptor::media_type(&self) -> &radroots_blossom::media_type::MediaType +pub const fn radroots_blossom::descriptor::ByteVerifiedDescriptor::sha256(&self) -> radroots_blossom::hash::Sha256 +pub const fn radroots_blossom::descriptor::ByteVerifiedDescriptor::size(&self) -> u64 +pub fn radroots_blossom::descriptor::ByteVerifiedDescriptor::url(&self) -> &radroots_blossom::url::ApprovedBlobUrl +pub struct radroots_blossom::MediaType(_) +impl radroots_blossom::media_type::MediaType +pub fn radroots_blossom::media_type::MediaType::as_str(&self) -> &str +pub fn radroots_blossom::media_type::MediaType::parse(&str) -> core::result::Result<Self, radroots_blossom::Error> +impl core::fmt::Display for radroots_blossom::media_type::MediaType +pub fn radroots_blossom::media_type::MediaType::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_blossom::media_type::MediaType +pub type radroots_blossom::media_type::MediaType::Err = radroots_blossom::Error +pub fn radroots_blossom::media_type::MediaType::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_blossom::media_type::MediaType +pub fn radroots_blossom::media_type::MediaType::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_blossom::media_type::MediaType +pub fn radroots_blossom::media_type::MediaType::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_blossom::Sha256(_) +impl radroots_blossom::hash::Sha256 +pub const fn radroots_blossom::hash::Sha256::as_bytes(&self) -> &[u8; 32] +pub fn radroots_blossom::hash::Sha256::digest(&[u8]) -> Self +pub const fn radroots_blossom::hash::Sha256::from_bytes([u8; 32]) -> Self +pub fn radroots_blossom::hash::Sha256::from_hex(&str) -> core::result::Result<Self, radroots_blossom::Error> +pub fn radroots_blossom::hash::Sha256::to_hex(self) -> alloc::string::String +impl core::fmt::Display for radroots_blossom::hash::Sha256 +pub fn radroots_blossom::hash::Sha256::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_blossom::hash::Sha256 +pub type radroots_blossom::hash::Sha256::Err = radroots_blossom::Error +pub fn radroots_blossom::hash::Sha256::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_blossom::hash::Sha256 +pub fn radroots_blossom::hash::Sha256::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_blossom::hash::Sha256 +pub fn radroots_blossom::hash::Sha256::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> diff --git a/docs/api/radroots_identity.txt b/docs/api/radroots_identity.txt @@ -0,0 +1,378 @@ +pub mod radroots_identity +pub mod radroots_identity::account +#[non_exhaustive] pub enum radroots_identity::account::Status +pub radroots_identity::account::Status::NotConfigured +pub radroots_identity::account::Status::PublicOnly +pub radroots_identity::account::Status::PublicOnly::account: radroots_identity::account::Record +pub radroots_identity::account::Status::Ready +pub radroots_identity::account::Status::Ready::account: radroots_identity::account::Record +impl radroots_identity::account::Status +pub const fn radroots_identity::account::Status::account(&self) -> core::option::Option<&radroots_identity::account::Record> +pub const fn radroots_identity::account::Status::is_ready(&self) -> bool +#[repr(transparent)] pub struct radroots_identity::account::AccountId(_) +impl radroots_identity::account::AccountId +pub const radroots_identity::account::AccountId::BYTE_LENGTH: usize +pub const radroots_identity::account::AccountId::HEX_LENGTH: usize +pub const fn radroots_identity::account::AccountId::as_bytes(&self) -> &[u8; 32] +pub fn radroots_identity::account::AccountId::from_bytes([u8; 32]) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::account::AccountId::from_hex(&str) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::account::AccountId::from_slice(&[u8]) -> core::result::Result<Self, radroots_identity::Error> +pub const fn radroots_identity::account::AccountId::into_bytes(self) -> [u8; 32] +pub fn radroots_identity::account::AccountId::to_hex(self) -> alloc::string::String +impl radroots_identity::account::AccountId +pub const fn radroots_identity::account::AccountId::from_identity_id(radroots_identity::key::IdentityId) -> Self +pub const fn radroots_identity::account::AccountId::from_public_identity(&radroots_identity::profile::PublicIdentity) -> Self +impl core::convert::AsRef<[u8]> for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::as_ref(&self) -> &[u8] +impl core::convert::From<&radroots_identity::profile::PublicIdentity> for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::from(&radroots_identity::profile::PublicIdentity) -> Self +impl core::convert::From<radroots_identity::key::IdentityId> for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::from(radroots_identity::key::IdentityId) -> Self +impl core::convert::TryFrom<&[u8]> for radroots_identity::account::AccountId +pub type radroots_identity::account::AccountId::Error = radroots_identity::Error +pub fn radroots_identity::account::AccountId::try_from(&[u8]) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<&str> for radroots_identity::account::AccountId +pub type radroots_identity::account::AccountId::Error = radroots_identity::Error +pub fn radroots_identity::account::AccountId::try_from(&str) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<[u8; 32]> for radroots_identity::account::AccountId +pub type radroots_identity::account::AccountId::Error = radroots_identity::Error +pub fn radroots_identity::account::AccountId::try_from([u8; 32]) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<alloc::string::String> for radroots_identity::account::AccountId +pub type radroots_identity::account::AccountId::Error = radroots_identity::Error +pub fn radroots_identity::account::AccountId::try_from(alloc::string::String) -> core::result::Result<Self, Self::Error> +impl core::fmt::Debug for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_identity::account::AccountId +pub type radroots_identity::account::AccountId::Err = radroots_identity::Error +pub fn radroots_identity::account::AccountId::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_identity::account::Record +impl radroots_identity::account::Record +pub const fn radroots_identity::account::Record::created_at_unix(&self) -> u64 +pub const fn radroots_identity::account::Record::id(&self) -> radroots_identity::account::AccountId +pub fn radroots_identity::account::Record::label(&self) -> core::option::Option<&str> +pub fn radroots_identity::account::Record::new(radroots_identity::profile::PublicIdentity, core::option::Option<alloc::string::String>, u64) -> Self +pub const fn radroots_identity::account::Record::public_identity(&self) -> &radroots_identity::profile::PublicIdentity +pub fn radroots_identity::account::Record::set_label(&mut self, core::option::Option<alloc::string::String>) +pub fn radroots_identity::account::Record::touch_updated(&mut self, u64) -> core::result::Result<(), radroots_identity::Error> +pub fn radroots_identity::account::Record::try_from_parts(radroots_identity::account::AccountId, radroots_identity::profile::PublicIdentity, core::option::Option<alloc::string::String>, u64, u64) -> core::result::Result<Self, radroots_identity::Error> +pub const fn radroots_identity::account::Record::updated_at_unix(&self) -> u64 +impl<'de> serde_core::de::Deserialize<'de> for radroots_identity::account::Record +pub fn radroots_identity::account::Record::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub mod radroots_identity::key +#[repr(transparent)] pub struct radroots_identity::key::IdentityId(_) +impl radroots_identity::key::IdentityId +pub const radroots_identity::key::IdentityId::BYTE_LENGTH: usize +pub const radroots_identity::key::IdentityId::HEX_LENGTH: usize +pub const fn radroots_identity::key::IdentityId::as_bytes(&self) -> &[u8; 32] +pub fn radroots_identity::key::IdentityId::from_bytes([u8; 32]) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::key::IdentityId::from_hex(&str) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::key::IdentityId::from_slice(&[u8]) -> core::result::Result<Self, radroots_identity::Error> +pub const fn radroots_identity::key::IdentityId::into_bytes(self) -> [u8; 32] +pub fn radroots_identity::key::IdentityId::to_hex(self) -> alloc::string::String +impl radroots_identity::key::IdentityId +pub const fn radroots_identity::key::IdentityId::from_public_key(radroots_identity::key::PublicKey) -> Self +impl core::convert::AsRef<[u8]> for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::as_ref(&self) -> &[u8] +impl core::convert::From<radroots_identity::key::IdentityId> for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::from(radroots_identity::key::IdentityId) -> Self +impl core::convert::From<radroots_identity::key::PublicKey> for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::from(radroots_identity::key::PublicKey) -> Self +impl core::convert::TryFrom<&[u8]> for radroots_identity::key::IdentityId +pub type radroots_identity::key::IdentityId::Error = radroots_identity::Error +pub fn radroots_identity::key::IdentityId::try_from(&[u8]) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<&str> for radroots_identity::key::IdentityId +pub type radroots_identity::key::IdentityId::Error = radroots_identity::Error +pub fn radroots_identity::key::IdentityId::try_from(&str) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<[u8; 32]> for radroots_identity::key::IdentityId +pub type radroots_identity::key::IdentityId::Error = radroots_identity::Error +pub fn radroots_identity::key::IdentityId::try_from([u8; 32]) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<alloc::string::String> for radroots_identity::key::IdentityId +pub type radroots_identity::key::IdentityId::Error = radroots_identity::Error +pub fn radroots_identity::key::IdentityId::try_from(alloc::string::String) -> core::result::Result<Self, Self::Error> +impl core::fmt::Debug for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_identity::key::IdentityId +pub type radroots_identity::key::IdentityId::Err = radroots_identity::Error +pub fn radroots_identity::key::IdentityId::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +#[repr(transparent)] pub struct radroots_identity::key::PublicKey(_) +impl radroots_identity::key::PublicKey +pub const radroots_identity::key::PublicKey::BYTE_LENGTH: usize +pub const radroots_identity::key::PublicKey::HEX_LENGTH: usize +pub const fn radroots_identity::key::PublicKey::as_bytes(&self) -> &[u8; 32] +pub fn radroots_identity::key::PublicKey::from_bytes([u8; 32]) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::key::PublicKey::from_hex(&str) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::key::PublicKey::from_slice(&[u8]) -> core::result::Result<Self, radroots_identity::Error> +pub const fn radroots_identity::key::PublicKey::into_bytes(self) -> [u8; 32] +pub fn radroots_identity::key::PublicKey::to_hex(self) -> alloc::string::String +impl core::convert::AsRef<[u8]> for radroots_identity::key::PublicKey +pub fn radroots_identity::key::PublicKey::as_ref(&self) -> &[u8] +impl core::convert::From<radroots_identity::key::PublicKey> for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::from(radroots_identity::key::PublicKey) -> Self +impl core::convert::TryFrom<&[u8]> for radroots_identity::key::PublicKey +pub type radroots_identity::key::PublicKey::Error = radroots_identity::Error +pub fn radroots_identity::key::PublicKey::try_from(&[u8]) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<&str> for radroots_identity::key::PublicKey +pub type radroots_identity::key::PublicKey::Error = radroots_identity::Error +pub fn radroots_identity::key::PublicKey::try_from(&str) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<[u8; 32]> for radroots_identity::key::PublicKey +pub type radroots_identity::key::PublicKey::Error = radroots_identity::Error +pub fn radroots_identity::key::PublicKey::try_from([u8; 32]) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<alloc::string::String> for radroots_identity::key::PublicKey +pub type radroots_identity::key::PublicKey::Error = radroots_identity::Error +pub fn radroots_identity::key::PublicKey::try_from(alloc::string::String) -> core::result::Result<Self, Self::Error> +impl core::fmt::Debug for radroots_identity::key::PublicKey +pub fn radroots_identity::key::PublicKey::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for radroots_identity::key::PublicKey +pub fn radroots_identity::key::PublicKey::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_identity::key::PublicKey +pub type radroots_identity::key::PublicKey::Err = radroots_identity::Error +pub fn radroots_identity::key::PublicKey::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_identity::key::PublicKey +pub fn radroots_identity::key::PublicKey::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_identity::key::PublicKey +pub fn radroots_identity::key::PublicKey::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub mod radroots_identity::profile +pub struct radroots_identity::profile::Profile +impl radroots_identity::profile::Profile +pub const fn radroots_identity::profile::Profile::is_empty(&self) -> bool +pub const fn radroots_identity::profile::Profile::new() -> Self +pub fn radroots_identity::profile::Profile::username(&self) -> core::option::Option<&radroots_identity::username::Username> +pub fn radroots_identity::profile::Profile::with_username(self, radroots_identity::username::Username) -> Self +pub struct radroots_identity::profile::PublicIdentity +impl radroots_identity::profile::PublicIdentity +pub const fn radroots_identity::profile::PublicIdentity::id(&self) -> radroots_identity::key::IdentityId +pub const fn radroots_identity::profile::PublicIdentity::new(radroots_identity::key::PublicKey) -> Self +pub fn radroots_identity::profile::PublicIdentity::profile(&self) -> core::option::Option<&radroots_identity::profile::Profile> +pub const fn radroots_identity::profile::PublicIdentity::public_key(&self) -> radroots_identity::key::PublicKey +pub fn radroots_identity::profile::PublicIdentity::try_from_parts(radroots_identity::key::IdentityId, radroots_identity::key::PublicKey, core::option::Option<radroots_identity::profile::Profile>) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::profile::PublicIdentity::with_profile(self, radroots_identity::profile::Profile) -> Self +impl core::convert::From<&radroots_identity::profile::PublicIdentity> for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::from(&radroots_identity::profile::PublicIdentity) -> Self +impl<'de> serde_core::de::Deserialize<'de> for radroots_identity::profile::PublicIdentity +pub fn radroots_identity::profile::PublicIdentity::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub mod radroots_identity::username +#[repr(transparent)] pub struct radroots_identity::username::Username(_) +impl radroots_identity::username::Username +pub fn radroots_identity::username::Username::as_str(&self) -> &str +pub fn radroots_identity::username::Username::into_string(self) -> alloc::string::String +pub fn radroots_identity::username::Username::parse(&str) -> core::result::Result<Self, radroots_identity::Error> +impl core::convert::AsRef<str> for radroots_identity::username::Username +pub fn radroots_identity::username::Username::as_ref(&self) -> &str +impl core::convert::TryFrom<&str> for radroots_identity::username::Username +pub type radroots_identity::username::Username::Error = radroots_identity::Error +pub fn radroots_identity::username::Username::try_from(&str) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<alloc::string::String> for radroots_identity::username::Username +pub type radroots_identity::username::Username::Error = radroots_identity::Error +pub fn radroots_identity::username::Username::try_from(alloc::string::String) -> core::result::Result<Self, Self::Error> +impl core::fmt::Debug for radroots_identity::username::Username +pub fn radroots_identity::username::Username::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for radroots_identity::username::Username +pub fn radroots_identity::username::Username::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_identity::username::Username +pub type radroots_identity::username::Username::Err = radroots_identity::Error +pub fn radroots_identity::username::Username::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_identity::username::Username +pub fn radroots_identity::username::Username::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_identity::username::Username +pub fn radroots_identity::username::Username::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub const radroots_identity::username::MAX_LENGTH: usize +pub const radroots_identity::username::MIN_LENGTH: usize +#[non_exhaustive] pub enum radroots_identity::Error +pub radroots_identity::Error::AccountIdMismatch +pub radroots_identity::Error::AccountUpdateRegressed +pub radroots_identity::Error::AccountUpdateRegressed::current_updated_at_unix: u64 +pub radroots_identity::Error::AccountUpdateRegressed::proposed_updated_at_unix: u64 +pub radroots_identity::Error::AccountUpdatedBeforeCreated +pub radroots_identity::Error::AccountUpdatedBeforeCreated::created_at_unix: u64 +pub radroots_identity::Error::AccountUpdatedBeforeCreated::updated_at_unix: u64 +pub radroots_identity::Error::IdentityIdMismatch +pub radroots_identity::Error::InvalidByteLength +pub radroots_identity::Error::InvalidByteLength::actual: usize +pub radroots_identity::Error::InvalidByteLength::expected: usize +pub radroots_identity::Error::InvalidHexCharacter +pub radroots_identity::Error::InvalidHexCharacter::index: usize +pub radroots_identity::Error::InvalidHexLength +pub radroots_identity::Error::InvalidHexLength::actual: usize +pub radroots_identity::Error::InvalidHexLength::expected: usize +pub radroots_identity::Error::InvalidPublicKeyBytes +pub radroots_identity::Error::InvalidUsernameCharacter +pub radroots_identity::Error::InvalidUsernameCharacter::index: usize +pub radroots_identity::Error::InvalidUsernameDotPlacement +pub radroots_identity::Error::InvalidUsernameLength +pub radroots_identity::Error::InvalidUsernameLength::actual: usize +pub radroots_identity::Error::InvalidUsernameLength::max: usize +pub radroots_identity::Error::InvalidUsernameLength::min: usize +#[repr(transparent)] pub struct radroots_identity::AccountId(_) +impl radroots_identity::account::AccountId +pub const radroots_identity::account::AccountId::BYTE_LENGTH: usize +pub const radroots_identity::account::AccountId::HEX_LENGTH: usize +pub const fn radroots_identity::account::AccountId::as_bytes(&self) -> &[u8; 32] +pub fn radroots_identity::account::AccountId::from_bytes([u8; 32]) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::account::AccountId::from_hex(&str) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::account::AccountId::from_slice(&[u8]) -> core::result::Result<Self, radroots_identity::Error> +pub const fn radroots_identity::account::AccountId::into_bytes(self) -> [u8; 32] +pub fn radroots_identity::account::AccountId::to_hex(self) -> alloc::string::String +impl radroots_identity::account::AccountId +pub const fn radroots_identity::account::AccountId::from_identity_id(radroots_identity::key::IdentityId) -> Self +pub const fn radroots_identity::account::AccountId::from_public_identity(&radroots_identity::profile::PublicIdentity) -> Self +impl core::convert::AsRef<[u8]> for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::as_ref(&self) -> &[u8] +impl core::convert::From<&radroots_identity::profile::PublicIdentity> for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::from(&radroots_identity::profile::PublicIdentity) -> Self +impl core::convert::From<radroots_identity::key::IdentityId> for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::from(radroots_identity::key::IdentityId) -> Self +impl core::convert::TryFrom<&[u8]> for radroots_identity::account::AccountId +pub type radroots_identity::account::AccountId::Error = radroots_identity::Error +pub fn radroots_identity::account::AccountId::try_from(&[u8]) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<&str> for radroots_identity::account::AccountId +pub type radroots_identity::account::AccountId::Error = radroots_identity::Error +pub fn radroots_identity::account::AccountId::try_from(&str) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<[u8; 32]> for radroots_identity::account::AccountId +pub type radroots_identity::account::AccountId::Error = radroots_identity::Error +pub fn radroots_identity::account::AccountId::try_from([u8; 32]) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<alloc::string::String> for radroots_identity::account::AccountId +pub type radroots_identity::account::AccountId::Error = radroots_identity::Error +pub fn radroots_identity::account::AccountId::try_from(alloc::string::String) -> core::result::Result<Self, Self::Error> +impl core::fmt::Debug for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_identity::account::AccountId +pub type radroots_identity::account::AccountId::Err = radroots_identity::Error +pub fn radroots_identity::account::AccountId::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +#[repr(transparent)] pub struct radroots_identity::IdentityId(_) +impl radroots_identity::key::IdentityId +pub const radroots_identity::key::IdentityId::BYTE_LENGTH: usize +pub const radroots_identity::key::IdentityId::HEX_LENGTH: usize +pub const fn radroots_identity::key::IdentityId::as_bytes(&self) -> &[u8; 32] +pub fn radroots_identity::key::IdentityId::from_bytes([u8; 32]) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::key::IdentityId::from_hex(&str) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::key::IdentityId::from_slice(&[u8]) -> core::result::Result<Self, radroots_identity::Error> +pub const fn radroots_identity::key::IdentityId::into_bytes(self) -> [u8; 32] +pub fn radroots_identity::key::IdentityId::to_hex(self) -> alloc::string::String +impl radroots_identity::key::IdentityId +pub const fn radroots_identity::key::IdentityId::from_public_key(radroots_identity::key::PublicKey) -> Self +impl core::convert::AsRef<[u8]> for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::as_ref(&self) -> &[u8] +impl core::convert::From<radroots_identity::key::IdentityId> for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::from(radroots_identity::key::IdentityId) -> Self +impl core::convert::From<radroots_identity::key::PublicKey> for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::from(radroots_identity::key::PublicKey) -> Self +impl core::convert::TryFrom<&[u8]> for radroots_identity::key::IdentityId +pub type radroots_identity::key::IdentityId::Error = radroots_identity::Error +pub fn radroots_identity::key::IdentityId::try_from(&[u8]) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<&str> for radroots_identity::key::IdentityId +pub type radroots_identity::key::IdentityId::Error = radroots_identity::Error +pub fn radroots_identity::key::IdentityId::try_from(&str) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<[u8; 32]> for radroots_identity::key::IdentityId +pub type radroots_identity::key::IdentityId::Error = radroots_identity::Error +pub fn radroots_identity::key::IdentityId::try_from([u8; 32]) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<alloc::string::String> for radroots_identity::key::IdentityId +pub type radroots_identity::key::IdentityId::Error = radroots_identity::Error +pub fn radroots_identity::key::IdentityId::try_from(alloc::string::String) -> core::result::Result<Self, Self::Error> +impl core::fmt::Debug for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_identity::key::IdentityId +pub type radroots_identity::key::IdentityId::Err = radroots_identity::Error +pub fn radroots_identity::key::IdentityId::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_identity::Profile +impl radroots_identity::profile::Profile +pub const fn radroots_identity::profile::Profile::is_empty(&self) -> bool +pub const fn radroots_identity::profile::Profile::new() -> Self +pub fn radroots_identity::profile::Profile::username(&self) -> core::option::Option<&radroots_identity::username::Username> +pub fn radroots_identity::profile::Profile::with_username(self, radroots_identity::username::Username) -> Self +pub struct radroots_identity::PublicIdentity +impl radroots_identity::profile::PublicIdentity +pub const fn radroots_identity::profile::PublicIdentity::id(&self) -> radroots_identity::key::IdentityId +pub const fn radroots_identity::profile::PublicIdentity::new(radroots_identity::key::PublicKey) -> Self +pub fn radroots_identity::profile::PublicIdentity::profile(&self) -> core::option::Option<&radroots_identity::profile::Profile> +pub const fn radroots_identity::profile::PublicIdentity::public_key(&self) -> radroots_identity::key::PublicKey +pub fn radroots_identity::profile::PublicIdentity::try_from_parts(radroots_identity::key::IdentityId, radroots_identity::key::PublicKey, core::option::Option<radroots_identity::profile::Profile>) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::profile::PublicIdentity::with_profile(self, radroots_identity::profile::Profile) -> Self +impl core::convert::From<&radroots_identity::profile::PublicIdentity> for radroots_identity::account::AccountId +pub fn radroots_identity::account::AccountId::from(&radroots_identity::profile::PublicIdentity) -> Self +impl<'de> serde_core::de::Deserialize<'de> for radroots_identity::profile::PublicIdentity +pub fn radroots_identity::profile::PublicIdentity::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +#[repr(transparent)] pub struct radroots_identity::PublicKey(_) +impl radroots_identity::key::PublicKey +pub const radroots_identity::key::PublicKey::BYTE_LENGTH: usize +pub const radroots_identity::key::PublicKey::HEX_LENGTH: usize +pub const fn radroots_identity::key::PublicKey::as_bytes(&self) -> &[u8; 32] +pub fn radroots_identity::key::PublicKey::from_bytes([u8; 32]) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::key::PublicKey::from_hex(&str) -> core::result::Result<Self, radroots_identity::Error> +pub fn radroots_identity::key::PublicKey::from_slice(&[u8]) -> core::result::Result<Self, radroots_identity::Error> +pub const fn radroots_identity::key::PublicKey::into_bytes(self) -> [u8; 32] +pub fn radroots_identity::key::PublicKey::to_hex(self) -> alloc::string::String +impl core::convert::AsRef<[u8]> for radroots_identity::key::PublicKey +pub fn radroots_identity::key::PublicKey::as_ref(&self) -> &[u8] +impl core::convert::From<radroots_identity::key::PublicKey> for radroots_identity::key::IdentityId +pub fn radroots_identity::key::IdentityId::from(radroots_identity::key::PublicKey) -> Self +impl core::convert::TryFrom<&[u8]> for radroots_identity::key::PublicKey +pub type radroots_identity::key::PublicKey::Error = radroots_identity::Error +pub fn radroots_identity::key::PublicKey::try_from(&[u8]) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<&str> for radroots_identity::key::PublicKey +pub type radroots_identity::key::PublicKey::Error = radroots_identity::Error +pub fn radroots_identity::key::PublicKey::try_from(&str) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<[u8; 32]> for radroots_identity::key::PublicKey +pub type radroots_identity::key::PublicKey::Error = radroots_identity::Error +pub fn radroots_identity::key::PublicKey::try_from([u8; 32]) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<alloc::string::String> for radroots_identity::key::PublicKey +pub type radroots_identity::key::PublicKey::Error = radroots_identity::Error +pub fn radroots_identity::key::PublicKey::try_from(alloc::string::String) -> core::result::Result<Self, Self::Error> +impl core::fmt::Debug for radroots_identity::key::PublicKey +pub fn radroots_identity::key::PublicKey::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for radroots_identity::key::PublicKey +pub fn radroots_identity::key::PublicKey::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_identity::key::PublicKey +pub type radroots_identity::key::PublicKey::Err = radroots_identity::Error +pub fn radroots_identity::key::PublicKey::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_identity::key::PublicKey +pub fn radroots_identity::key::PublicKey::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_identity::key::PublicKey +pub fn radroots_identity::key::PublicKey::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +#[repr(transparent)] pub struct radroots_identity::Username(_) +impl radroots_identity::username::Username +pub fn radroots_identity::username::Username::as_str(&self) -> &str +pub fn radroots_identity::username::Username::into_string(self) -> alloc::string::String +pub fn radroots_identity::username::Username::parse(&str) -> core::result::Result<Self, radroots_identity::Error> +impl core::convert::AsRef<str> for radroots_identity::username::Username +pub fn radroots_identity::username::Username::as_ref(&self) -> &str +impl core::convert::TryFrom<&str> for radroots_identity::username::Username +pub type radroots_identity::username::Username::Error = radroots_identity::Error +pub fn radroots_identity::username::Username::try_from(&str) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<alloc::string::String> for radroots_identity::username::Username +pub type radroots_identity::username::Username::Error = radroots_identity::Error +pub fn radroots_identity::username::Username::try_from(alloc::string::String) -> core::result::Result<Self, Self::Error> +impl core::fmt::Debug for radroots_identity::username::Username +pub fn radroots_identity::username::Username::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for radroots_identity::username::Username +pub fn radroots_identity::username::Username::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_identity::username::Username +pub type radroots_identity::username::Username::Err = radroots_identity::Error +pub fn radroots_identity::username::Username::from_str(&str) -> core::result::Result<Self, Self::Err> +impl serde_core::ser::Serialize for radroots_identity::username::Username +pub fn radroots_identity::username::Username::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_identity::username::Username +pub fn radroots_identity::username::Username::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> diff --git a/docs/api/radroots_protocol.txt b/docs/api/radroots_protocol.txt @@ -0,0 +1,780 @@ +pub mod radroots_protocol +pub mod radroots_protocol::capability +pub mod radroots_protocol::capability::v1 +pub enum radroots_protocol::capability::v1::Availability +pub radroots_protocol::capability::v1::Availability::Available +pub radroots_protocol::capability::v1::Availability::Degraded +pub radroots_protocol::capability::v1::Availability::Unavailable +#[non_exhaustive] pub enum radroots_protocol::capability::v1::Error +pub radroots_protocol::capability::v1::Error::DuplicateTransportKind +pub radroots_protocol::capability::v1::Error::DuplicateTransportKind::kind: radroots_protocol::capability::v1::TransportKind +pub radroots_protocol::capability::v1::Error::InvalidMeshScopeId +pub radroots_protocol::capability::v1::Error::InvalidReticulumDestination +pub radroots_protocol::capability::v1::Error::MissingRequiredTransport +pub radroots_protocol::capability::v1::Error::MissingRequiredTransport::kind: radroots_protocol::capability::v1::TransportKind +pub radroots_protocol::capability::v1::Error::UnknownTransportKind +pub radroots_protocol::capability::v1::Error::UnknownTransportKind::value: alloc::string::String +impl core::error::Error for radroots_protocol::capability::v1::Error +impl core::fmt::Display for radroots_protocol::capability::v1::Error +pub fn radroots_protocol::capability::v1::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub enum radroots_protocol::capability::v1::Maturity +pub radroots_protocol::capability::v1::Maturity::Preview +pub radroots_protocol::capability::v1::Maturity::Stable +pub enum radroots_protocol::capability::v1::TransportKind +pub radroots_protocol::capability::v1::TransportKind::Local +pub radroots_protocol::capability::v1::TransportKind::Nostr +pub radroots_protocol::capability::v1::TransportKind::Reticulum +impl radroots_protocol::capability::v1::TransportKind +pub const fn radroots_protocol::capability::v1::TransportKind::as_str(self) -> &'static str +pub fn radroots_protocol::capability::v1::TransportKind::parse(&str) -> core::result::Result<Self, radroots_protocol::capability::v1::Error> +pub struct radroots_protocol::capability::v1::MeshScopeId +impl radroots_protocol::capability::v1::MeshScopeId +pub fn radroots_protocol::capability::v1::MeshScopeId::as_str(&self) -> &str +pub fn radroots_protocol::capability::v1::MeshScopeId::parse(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_protocol::capability::v1::Error> +pub struct radroots_protocol::capability::v1::ReticulumDestination +impl radroots_protocol::capability::v1::ReticulumDestination +pub fn radroots_protocol::capability::v1::ReticulumDestination::as_str(&self) -> &str +pub fn radroots_protocol::capability::v1::ReticulumDestination::parse(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_protocol::capability::v1::Error> +pub struct radroots_protocol::capability::v1::ReticulumTarget +pub radroots_protocol::capability::v1::ReticulumTarget::destination: radroots_protocol::capability::v1::ReticulumDestination +pub radroots_protocol::capability::v1::ReticulumTarget::mesh_scope: core::option::Option<radroots_protocol::capability::v1::MeshScopeId> +pub struct radroots_protocol::capability::v1::TransportDescriptor +pub radroots_protocol::capability::v1::TransportDescriptor::availability: radroots_protocol::capability::v1::Availability +pub radroots_protocol::capability::v1::TransportDescriptor::can_deliver: bool +pub radroots_protocol::capability::v1::TransportDescriptor::can_discover: bool +pub radroots_protocol::capability::v1::TransportDescriptor::can_fetch: bool +pub radroots_protocol::capability::v1::TransportDescriptor::can_gateway_forward: bool +pub radroots_protocol::capability::v1::TransportDescriptor::can_observe_receipts: bool +pub radroots_protocol::capability::v1::TransportDescriptor::kind: radroots_protocol::capability::v1::TransportKind +pub radroots_protocol::capability::v1::TransportDescriptor::maturity: radroots_protocol::capability::v1::Maturity +pub radroots_protocol::capability::v1::TransportDescriptor::required_for_v1: bool +pub const radroots_protocol::capability::v1::CATALOG: &[radroots_protocol::capability::v1::TransportDescriptor] +pub const radroots_protocol::capability::v1::SCHEMAS: &[radroots_protocol::schema::Metadata] +pub fn radroots_protocol::capability::v1::schema_registry() -> core::result::Result<radroots_protocol::schema::Registry, radroots_protocol::schema::Error> +pub fn radroots_protocol::capability::v1::validate_catalog(&[radroots_protocol::capability::v1::TransportDescriptor]) -> core::result::Result<(), radroots_protocol::capability::v1::Error> +pub mod radroots_protocol::error +pub mod radroots_protocol::error::v1 +pub enum radroots_protocol::error::v1::Class +pub radroots_protocol::error::v1::Class::Authorization +pub radroots_protocol::error::v1::Class::Capability +pub radroots_protocol::error::v1::Class::Conflict +pub radroots_protocol::error::v1::Class::Contract +pub radroots_protocol::error::v1::Class::Internal +pub radroots_protocol::error::v1::Class::Maintenance +pub radroots_protocol::error::v1::Class::Network +pub radroots_protocol::error::v1::Class::Operation +pub radroots_protocol::error::v1::Class::Privacy +pub radroots_protocol::error::v1::Class::Projection +pub radroots_protocol::error::v1::Class::Query +pub radroots_protocol::error::v1::Class::Resource +pub radroots_protocol::error::v1::Class::Runtime +pub radroots_protocol::error::v1::Class::Security +pub radroots_protocol::error::v1::Class::Signer +pub radroots_protocol::error::v1::Class::Storage +pub radroots_protocol::error::v1::Class::Sync +pub radroots_protocol::error::v1::Class::Unknown +pub radroots_protocol::error::v1::Class::Validation +pub enum radroots_protocol::error::v1::DetailValue +pub radroots_protocol::error::v1::DetailValue::Bool(bool) +pub radroots_protocol::error::v1::DetailValue::Signed(i64) +pub radroots_protocol::error::v1::DetailValue::Text(alloc::string::String) +pub radroots_protocol::error::v1::DetailValue::Unsigned(u64) +#[non_exhaustive] pub enum radroots_protocol::error::v1::Error +pub radroots_protocol::error::v1::Error::DescriptorMismatch +pub radroots_protocol::error::v1::Error::DescriptorMismatch::code: radroots_protocol::error::v1::KnownCode +pub radroots_protocol::error::v1::Error::DuplicateDetailKey +pub radroots_protocol::error::v1::Error::ExpectedUnknownCode +pub radroots_protocol::error::v1::Error::InvalidCapabilityId +pub radroots_protocol::error::v1::Error::InvalidCode +pub radroots_protocol::error::v1::Error::InvalidDetailKey +pub radroots_protocol::error::v1::Error::InvalidDetailText +pub radroots_protocol::error::v1::Error::InvalidSafeMessage +pub radroots_protocol::error::v1::Error::InvalidUnknownCodePolicy +pub radroots_protocol::error::v1::Error::SensitiveDetailKey +pub radroots_protocol::error::v1::Error::SensitiveMessage +pub radroots_protocol::error::v1::Error::TooManyDetails +pub radroots_protocol::error::v1::Error::UnsupportedSchemaVersion +pub radroots_protocol::error::v1::Error::UnsupportedSchemaVersion::version: u16 +impl core::error::Error for radroots_protocol::error::v1::Error +impl core::fmt::Display for radroots_protocol::error::v1::Error +pub fn radroots_protocol::error::v1::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub enum radroots_protocol::error::v1::KnownCode +pub radroots_protocol::error::v1::KnownCode::AmbiguousTrade +pub radroots_protocol::error::v1::KnownCode::ApprovalExpired +pub radroots_protocol::error::v1::KnownCode::ApprovalInvalid +pub radroots_protocol::error::v1::KnownCode::ApprovalReplayed +pub radroots_protocol::error::v1::KnownCode::ApprovalRequired +pub radroots_protocol::error::v1::KnownCode::AuthorizationDenied +pub radroots_protocol::error::v1::KnownCode::Backpressure +pub radroots_protocol::error::v1::KnownCode::BackupAuthenticationFailed +pub radroots_protocol::error::v1::KnownCode::BackupInvalid +pub radroots_protocol::error::v1::KnownCode::CancelledNoCommit +pub radroots_protocol::error::v1::KnownCode::DatabaseBusy +pub radroots_protocol::error::v1::KnownCode::DeadlineExceeded +pub radroots_protocol::error::v1::KnownCode::DmRelayUnconfigured +pub radroots_protocol::error::v1::KnownCode::IdempotencyConflict +pub radroots_protocol::error::v1::KnownCode::InternalError +pub radroots_protocol::error::v1::KnownCode::InvalidArgument +pub radroots_protocol::error::v1::KnownCode::InvalidCursor +pub radroots_protocol::error::v1::KnownCode::InventoryUnavailable +pub radroots_protocol::error::v1::KnownCode::LocalCommittedDeliveryPending +pub radroots_protocol::error::v1::KnownCode::MaintenanceInProgress +pub radroots_protocol::error::v1::KnownCode::MediaPolicyDenied +pub radroots_protocol::error::v1::KnownCode::NotFound +pub radroots_protocol::error::v1::KnownCode::OperationInProgress +pub radroots_protocol::error::v1::KnownCode::PreconditionChanged +pub radroots_protocol::error::v1::KnownCode::PrivateDataUnavailable +pub radroots_protocol::error::v1::KnownCode::ProfileWriterInUse +pub radroots_protocol::error::v1::KnownCode::ProjectionFailed +pub radroots_protocol::error::v1::KnownCode::ProjectionGenerationChanged +pub radroots_protocol::error::v1::KnownCode::ProjectionStale +pub radroots_protocol::error::v1::KnownCode::RelayAuthRejected +pub radroots_protocol::error::v1::KnownCode::RelayAuthRequired +pub radroots_protocol::error::v1::KnownCode::RelayPaymentRequired +pub radroots_protocol::error::v1::KnownCode::RelayPolicyRestricted +pub radroots_protocol::error::v1::KnownCode::RelayPowRequired +pub radroots_protocol::error::v1::KnownCode::RelayRateLimited +pub radroots_protocol::error::v1::KnownCode::RestoreFailed +pub radroots_protocol::error::v1::KnownCode::RevisionRequired +pub radroots_protocol::error::v1::KnownCode::SchemaTooNew +pub radroots_protocol::error::v1::KnownCode::SignerCancelled +pub radroots_protocol::error::v1::KnownCode::SignerCapabilityMissing +pub radroots_protocol::error::v1::KnownCode::SignerRejected +pub radroots_protocol::error::v1::KnownCode::SignerTimeout +pub radroots_protocol::error::v1::KnownCode::SignerUnavailable +pub radroots_protocol::error::v1::KnownCode::StaleListingRevision +pub radroots_protocol::error::v1::KnownCode::StorageIntegrityFailed +pub radroots_protocol::error::v1::KnownCode::StorageSpaceInsufficient +pub radroots_protocol::error::v1::KnownCode::SyncPartial +pub radroots_protocol::error::v1::KnownCode::SyncSaturated +pub radroots_protocol::error::v1::KnownCode::TransportOperationUnavailable +pub radroots_protocol::error::v1::KnownCode::TransportPartial +pub radroots_protocol::error::v1::KnownCode::UnsupportedCapability +pub radroots_protocol::error::v1::KnownCode::UnsupportedContractVersion +pub radroots_protocol::error::v1::KnownCode::UnsupportedProfileSchema +pub radroots_protocol::error::v1::KnownCode::ValidationExpired +pub radroots_protocol::error::v1::KnownCode::ValidationPending +pub radroots_protocol::error::v1::KnownCode::ValidatorSetInvalid +impl radroots_protocol::error::v1::KnownCode +pub const radroots_protocol::error::v1::KnownCode::ALL: &'static [Self] +pub const fn radroots_protocol::error::v1::KnownCode::as_str(self) -> &'static str +pub const fn radroots_protocol::error::v1::KnownCode::descriptor(self) -> radroots_protocol::error::v1::Descriptor +pub fn radroots_protocol::error::v1::KnownCode::parse(&str) -> core::option::Option<Self> +pub enum radroots_protocol::error::v1::RecoveryAction +pub radroots_protocol::error::v1::RecoveryAction::CompleteSignerAuthentication +pub radroots_protocol::error::v1::RecoveryAction::ConfigureGeoNamesCache +pub radroots_protocol::error::v1::RecoveryAction::ConfigureSigner +pub radroots_protocol::error::v1::RecoveryAction::ConfigureTransportTargets +pub radroots_protocol::error::v1::RecoveryAction::EnableRequiredFeature +pub radroots_protocol::error::v1::RecoveryAction::FixRequest +pub radroots_protocol::error::v1::RecoveryAction::InspectGeoNamesAsset +pub radroots_protocol::error::v1::RecoveryAction::InspectLocalStores +pub radroots_protocol::error::v1::RecoveryAction::RetryAfterTransportFailure +pub radroots_protocol::error::v1::RecoveryAction::RetryGeoNamesDownload +pub radroots_protocol::error::v1::RecoveryAction::RetryOperationWithSameIdempotencyKey +pub radroots_protocol::error::v1::RecoveryAction::SelectAuthorizedActor +pub struct radroots_protocol::error::v1::CapabilityId(_) +impl radroots_protocol::error::v1::CapabilityId +pub fn radroots_protocol::error::v1::CapabilityId::as_str(&self) -> &str +pub fn radroots_protocol::error::v1::CapabilityId::parse(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_protocol::error::v1::Error> +impl serde_core::ser::Serialize for radroots_protocol::error::v1::CapabilityId +pub fn radroots_protocol::error::v1::CapabilityId::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_protocol::error::v1::CapabilityId +pub fn radroots_protocol::error::v1::CapabilityId::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_protocol::error::v1::Code(_) +impl radroots_protocol::error::v1::Code +pub fn radroots_protocol::error::v1::Code::as_str(&self) -> &str +pub fn radroots_protocol::error::v1::Code::known(radroots_protocol::error::v1::KnownCode) -> Self +pub fn radroots_protocol::error::v1::Code::known_code(&self) -> core::option::Option<radroots_protocol::error::v1::KnownCode> +pub fn radroots_protocol::error::v1::Code::parse(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_protocol::error::v1::Error> +impl serde_core::ser::Serialize for radroots_protocol::error::v1::Code +pub fn radroots_protocol::error::v1::Code::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_protocol::error::v1::Code +pub fn radroots_protocol::error::v1::Code::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_protocol::error::v1::Descriptor +pub radroots_protocol::error::v1::Descriptor::class: radroots_protocol::error::v1::Class +pub radroots_protocol::error::v1::Descriptor::code: radroots_protocol::error::v1::KnownCode +pub radroots_protocol::error::v1::Descriptor::recovery_actions: &'static [radroots_protocol::error::v1::RecoveryAction] +pub radroots_protocol::error::v1::Descriptor::retryable: bool +pub struct radroots_protocol::error::v1::Detail +pub radroots_protocol::error::v1::Detail::key: alloc::string::String +pub radroots_protocol::error::v1::Detail::value: radroots_protocol::error::v1::DetailValue +impl radroots_protocol::error::v1::Detail +pub fn radroots_protocol::error::v1::Detail::new(impl core::convert::Into<alloc::string::String>, radroots_protocol::error::v1::DetailValue) -> Self +pub struct radroots_protocol::error::v1::ErrorReport +impl radroots_protocol::error::v1::ErrorReport +pub fn radroots_protocol::error::v1::ErrorReport::capability_id(&self) -> core::option::Option<&radroots_protocol::error::v1::CapabilityId> +pub const fn radroots_protocol::error::v1::ErrorReport::class(&self) -> radroots_protocol::error::v1::Class +pub fn radroots_protocol::error::v1::ErrorReport::code(&self) -> &radroots_protocol::error::v1::Code +pub fn radroots_protocol::error::v1::ErrorReport::details(&self) -> &radroots_protocol::error::v1::SafeDetails +pub fn radroots_protocol::error::v1::ErrorReport::known(radroots_protocol::error::v1::KnownCode, core::option::Option<radroots_protocol::runtime::v1::OperationId>, core::option::Option<radroots_protocol::error::v1::CapabilityId>, radroots_protocol::error::v1::SafeMessage, radroots_protocol::error::v1::SafeDetails) -> Self +pub fn radroots_protocol::error::v1::ErrorReport::message(&self) -> &radroots_protocol::error::v1::SafeMessage +pub const fn radroots_protocol::error::v1::ErrorReport::operation_id(&self) -> core::option::Option<radroots_protocol::runtime::v1::OperationId> +pub fn radroots_protocol::error::v1::ErrorReport::recovery_actions(&self) -> &[radroots_protocol::error::v1::RecoveryAction] +pub fn radroots_protocol::error::v1::ErrorReport::redacted_from_source(radroots_protocol::error::v1::KnownCode, core::option::Option<radroots_protocol::runtime::v1::OperationId>, core::option::Option<radroots_protocol::error::v1::CapabilityId>) -> Self +pub const fn radroots_protocol::error::v1::ErrorReport::retryable(&self) -> bool +pub const fn radroots_protocol::error::v1::ErrorReport::schema_version(&self) -> u16 +pub fn radroots_protocol::error::v1::ErrorReport::unknown(radroots_protocol::error::v1::Code) -> core::result::Result<Self, radroots_protocol::error::v1::Error> +pub fn radroots_protocol::error::v1::ErrorReport::validate(&self) -> core::result::Result<(), radroots_protocol::error::v1::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_protocol::error::v1::ErrorReport +pub fn radroots_protocol::error::v1::ErrorReport::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_protocol::error::v1::SafeDetails +impl radroots_protocol::error::v1::SafeDetails +pub fn radroots_protocol::error::v1::SafeDetails::entries(&self) -> &[radroots_protocol::error::v1::Detail] +pub fn radroots_protocol::error::v1::SafeDetails::is_empty(&self) -> bool +pub fn radroots_protocol::error::v1::SafeDetails::try_new(impl core::iter::traits::collect::IntoIterator<Item = radroots_protocol::error::v1::Detail>) -> core::result::Result<Self, radroots_protocol::error::v1::Error> +impl core::convert::From<radroots_protocol::error::v1::SafeDetails> for alloc::vec::Vec<radroots_protocol::error::v1::Detail> +pub fn alloc::vec::Vec<radroots_protocol::error::v1::Detail>::from(radroots_protocol::error::v1::SafeDetails) -> Self +impl core::convert::TryFrom<alloc::vec::Vec<radroots_protocol::error::v1::Detail>> for radroots_protocol::error::v1::SafeDetails +pub type radroots_protocol::error::v1::SafeDetails::Error = radroots_protocol::error::v1::Error +pub fn radroots_protocol::error::v1::SafeDetails::try_from(alloc::vec::Vec<radroots_protocol::error::v1::Detail>) -> core::result::Result<Self, Self::Error> +pub struct radroots_protocol::error::v1::SafeMessage(_) +impl radroots_protocol::error::v1::SafeMessage +pub fn radroots_protocol::error::v1::SafeMessage::as_str(&self) -> &str +pub fn radroots_protocol::error::v1::SafeMessage::parse(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_protocol::error::v1::Error> +pub fn radroots_protocol::error::v1::SafeMessage::redacted() -> Self +impl serde_core::ser::Serialize for radroots_protocol::error::v1::SafeMessage +pub fn radroots_protocol::error::v1::SafeMessage::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_protocol::error::v1::SafeMessage +pub fn radroots_protocol::error::v1::SafeMessage::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub const radroots_protocol::error::v1::CATALOG: &[radroots_protocol::error::v1::Descriptor] +pub const radroots_protocol::error::v1::REDACTED_MESSAGE: &str +pub const radroots_protocol::error::v1::SCHEMAS: &[radroots_protocol::schema::Metadata] +pub const radroots_protocol::error::v1::SCHEMA_ID: &str +pub const radroots_protocol::error::v1::SCHEMA_VERSION: u16 +pub fn radroots_protocol::error::v1::schema_registry() -> core::result::Result<radroots_protocol::schema::Registry, radroots_protocol::schema::Error> +pub mod radroots_protocol::event +pub mod radroots_protocol::event::v1 +#[non_exhaustive] pub enum radroots_protocol::event::v1::Error +pub radroots_protocol::event::v1::Error::DuplicateEventKind +pub radroots_protocol::event::v1::Error::DuplicateEventKind::kind: u32 +pub radroots_protocol::event::v1::Error::DuplicateEventName +pub radroots_protocol::event::v1::Error::DuplicateEventName::name: alloc::string::String +pub radroots_protocol::event::v1::Error::DuplicateTradeState +pub radroots_protocol::event::v1::Error::DuplicateTradeState::state: radroots_protocol::event::v1::TradeState +pub radroots_protocol::event::v1::Error::RetiredEventKind +pub radroots_protocol::event::v1::Error::RetiredEventKind::kind: u32 +pub radroots_protocol::event::v1::Error::RetiredEventName +pub radroots_protocol::event::v1::Error::RetiredEventName::name: alloc::string::String +pub radroots_protocol::event::v1::Error::RetiredTradeState +pub radroots_protocol::event::v1::Error::RetiredTradeState::state: alloc::string::String +pub radroots_protocol::event::v1::Error::UnknownTradeState +pub radroots_protocol::event::v1::Error::UnknownTradeState::value: alloc::string::String +impl core::error::Error for radroots_protocol::event::v1::Error +impl core::fmt::Display for radroots_protocol::event::v1::Error +pub fn radroots_protocol::event::v1::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub enum radroots_protocol::event::v1::EventClass +pub radroots_protocol::event::v1::EventClass::Addressable +pub radroots_protocol::event::v1::EventClass::Regular +pub radroots_protocol::event::v1::EventClass::Replaceable +pub radroots_protocol::event::v1::EventClass::UnsignedRumor +pub enum radroots_protocol::event::v1::TradeState +pub radroots_protocol::event::v1::TradeState::AgreedPendingValidation +pub radroots_protocol::event::v1::TradeState::Cancelled +pub radroots_protocol::event::v1::TradeState::Committed +pub radroots_protocol::event::v1::TradeState::Declined +pub radroots_protocol::event::v1::TradeState::Invalid +pub radroots_protocol::event::v1::TradeState::Missing +pub radroots_protocol::event::v1::TradeState::Requested +pub radroots_protocol::event::v1::TradeState::ValidationExpired +impl radroots_protocol::event::v1::TradeState +pub const fn radroots_protocol::event::v1::TradeState::as_str(self) -> &'static str +pub fn radroots_protocol::event::v1::TradeState::parse(&str) -> core::result::Result<Self, radroots_protocol::event::v1::Error> +pub struct radroots_protocol::event::v1::EventDescriptor +pub radroots_protocol::event::v1::EventDescriptor::event_class: radroots_protocol::event::v1::EventClass +pub radroots_protocol::event::v1::EventDescriptor::kind: u32 +pub radroots_protocol::event::v1::EventDescriptor::name: &'static str +pub radroots_protocol::event::v1::EventDescriptor::purpose: &'static str +pub const radroots_protocol::event::v1::CATALOG: &[radroots_protocol::event::v1::EventDescriptor] +pub const radroots_protocol::event::v1::RETIRED_KINDS: &[u32] +pub const radroots_protocol::event::v1::SCHEMAS: &[radroots_protocol::schema::Metadata] +pub const radroots_protocol::event::v1::TRADE_STATE_VOCABULARY: &[radroots_protocol::event::v1::TradeState] +pub fn radroots_protocol::event::v1::schema_registry() -> core::result::Result<radroots_protocol::schema::Registry, radroots_protocol::schema::Error> +pub fn radroots_protocol::event::v1::validate_catalog(&[radroots_protocol::event::v1::EventDescriptor]) -> core::result::Result<(), radroots_protocol::event::v1::Error> +pub fn radroots_protocol::event::v1::validate_trade_state_vocabulary(&[radroots_protocol::event::v1::TradeState]) -> core::result::Result<(), radroots_protocol::event::v1::Error> +pub mod radroots_protocol::radrootsd +pub mod radroots_protocol::radrootsd::transport_publish +pub mod radroots_protocol::radrootsd::transport_publish::v5 +pub enum radroots_protocol::radrootsd::transport_publish::v5::CapabilityAvailability +pub radroots_protocol::radrootsd::transport_publish::v5::CapabilityAvailability::Available +pub radroots_protocol::radrootsd::transport_publish::v5::CapabilityAvailability::Degraded +pub radroots_protocol::radrootsd::transport_publish::v5::CapabilityAvailability::Unavailable +pub enum radroots_protocol::radrootsd::transport_publish::v5::CapabilityMaturity +pub radroots_protocol::radrootsd::transport_publish::v5::CapabilityMaturity::Preview +pub radroots_protocol::radrootsd::transport_publish::v5::CapabilityMaturity::Stable +pub enum radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy +pub radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy::All +pub radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy::Any +pub radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy::Quorum +pub radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy::Quorum::quorum: usize +pub radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy::RequiredTargets +pub radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy::RequiredTargets::targets: alloc::vec::Vec<radroots_protocol::radrootsd::transport_publish::v5::TargetFingerprint> +impl radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy +pub fn radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy::required_target_count(&self, usize) -> usize +pub fn radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy::required_targets(alloc::vec::Vec<radroots_protocol::radrootsd::transport_publish::v5::TargetFingerprint>) -> core::result::Result<Self, radroots_protocol::radrootsd::transport_publish::v5::Error> +pub fn radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy::validate(&self) -> core::result::Result<(), radroots_protocol::radrootsd::transport_publish::v5::Error> +pub enum radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicyName +pub radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicyName::All +pub radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicyName::Any +pub radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicyName::Quorum +pub radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicyName::RequiredTargets +pub enum radroots_protocol::radrootsd::transport_publish::v5::Error +pub radroots_protocol::radrootsd::transport_publish::v5::Error::DuplicateRequiredTargetFingerprint +pub radroots_protocol::radrootsd::transport_publish::v5::Error::DuplicateRequiredTargetFingerprint::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::DuplicateTarget +pub radroots_protocol::radrootsd::transport_publish::v5::Error::DuplicateTarget::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyEndpointUri +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyEndpointUri::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyIdempotencyKey +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyJobId +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyPrincipalId +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyRawEventJson +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyRequiredTargetSet +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyTag +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyTag::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyTargetLabel +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyTargetLabel::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyTargetScope +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyTargetScope::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyTargetSet +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyTransportKind +pub radroots_protocol::radrootsd::transport_publish::v5::Error::EmptyTransportKind::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidEndpointUri +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidEndpointUri::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidExplicitTargetOutcome +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidExplicitTargetOutcome::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidHexField +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidHexField::expected_len: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidHexField::field: &'static str +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobAcknowledgedCount +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobAcknowledgedCount::actual: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobAcknowledgedCount::expected: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobCompletedAt +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobDeliverySatisfiedState +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobRetryableCount +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobRetryableCount::actual: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobRetryableCount::expected: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobStatusState +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobTargetCount +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobTargetCount::actual: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobTargetCount::expected: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobTerminalCount +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobTerminalCount::actual: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobTerminalCount::expected: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidJobTerminalState +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidQuorum +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidReticulumBehavior +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidReticulumBehavior::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidReticulumEndpoint +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidReticulumEndpoint::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidReticulumOutcome +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidReticulumOutcome::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidTargetLabel +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidTargetLabel::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidTargetOutcomeKind +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidTargetOutcomeKind::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidTargetScope +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidTargetScope::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidTargetSource +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidTargetSource::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidTimeoutMs +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidTransportKind +pub radroots_protocol::radrootsd::transport_publish::v5::Error::InvalidTransportKind::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::RequiredTargetNotInTargetSet +pub radroots_protocol::radrootsd::transport_publish::v5::Error::RequiredTargetNotInTargetSet::index: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::TargetLimitExceeded +pub radroots_protocol::radrootsd::transport_publish::v5::Error::TargetLimitExceeded::actual: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Error::TargetLimitExceeded::max: usize +impl core::error::Error for radroots_protocol::radrootsd::transport_publish::v5::Error +impl core::fmt::Display for radroots_protocol::radrootsd::transport_publish::v5::Error +pub fn radroots_protocol::radrootsd::transport_publish::v5::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub enum radroots_protocol::radrootsd::transport_publish::v5::Implementation +pub radroots_protocol::radrootsd::transport_publish::v5::Implementation::Mock +pub radroots_protocol::radrootsd::transport_publish::v5::Implementation::Real +pub enum radroots_protocol::radrootsd::transport_publish::v5::JobStatus +pub radroots_protocol::radrootsd::transport_publish::v5::JobStatus::Accepted +pub radroots_protocol::radrootsd::transport_publish::v5::JobStatus::DeliveryDeferred +pub radroots_protocol::radrootsd::transport_publish::v5::JobStatus::DeliveryDeferredUntilImplemented +pub radroots_protocol::radrootsd::transport_publish::v5::JobStatus::DeliverySatisfied +pub radroots_protocol::radrootsd::transport_publish::v5::JobStatus::DeliveryUnsatisfiedRetryable +pub radroots_protocol::radrootsd::transport_publish::v5::JobStatus::DeliveryUnsatisfiedTerminal +pub radroots_protocol::radrootsd::transport_publish::v5::JobStatus::Publishing +pub radroots_protocol::radrootsd::transport_publish::v5::JobStatus::Rejected +pub enum radroots_protocol::radrootsd::transport_publish::v5::NostrTargetSourcePolicy +pub radroots_protocol::radrootsd::transport_publish::v5::NostrTargetSourcePolicy::AuthorWriteThenDaemonDefault +pub radroots_protocol::radrootsd::transport_publish::v5::NostrTargetSourcePolicy::DaemonDefaultOnly +pub radroots_protocol::radrootsd::transport_publish::v5::NostrTargetSourcePolicy::ExplicitOnly +pub radroots_protocol::radrootsd::transport_publish::v5::NostrTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault +pub enum radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::Accepted +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::AuthRequired +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::Blocked +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::ConnectionFailed +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::DeferredUntilImplemented +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::DuplicateAccepted +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::Error +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::Invalid +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::Muted +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::PaymentRequired +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::PowRequired +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::RateLimited +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::Restricted +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::SkippedAlreadyAccepted +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::TargetRejected +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::Timeout +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::Unknown +pub radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::Unsupported +impl radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind +pub fn radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::counts_toward_accepted_delivery(self) -> bool +pub fn radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::is_deferred_until_implemented(self) -> bool +pub fn radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::is_retryable(self) -> bool +pub fn radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind::is_terminal_failure(self) -> bool +pub enum radroots_protocol::radrootsd::transport_publish::v5::ReticulumBehavior +pub radroots_protocol::radrootsd::transport_publish::v5::ReticulumBehavior::DeferDeliveryPlans +pub radroots_protocol::radrootsd::transport_publish::v5::ReticulumBehavior::RejectDeliveryAttempts +pub enum radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy +pub radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy::ExplicitTargets +pub radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy::ExplicitTargets::targets: alloc::vec::Vec<radroots_protocol::radrootsd::transport_publish::v5::Target> +pub radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy::Nostr +pub radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy::Nostr::relay_urls: alloc::vec::Vec<alloc::string::String> +pub radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy::Nostr::source_policy: radroots_protocol::radrootsd::transport_publish::v5::NostrTargetSourcePolicy +impl radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy +pub fn radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy::explicit_targets(alloc::vec::Vec<radroots_protocol::radrootsd::transport_publish::v5::Target>) -> Self +pub fn radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy::nostr(radroots_protocol::radrootsd::transport_publish::v5::NostrTargetSourcePolicy, alloc::vec::Vec<alloc::string::String>) -> Self +pub fn radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy::request_target_count(&self) -> usize +pub enum radroots_protocol::radrootsd::transport_publish::v5::TargetPolicyName +pub radroots_protocol::radrootsd::transport_publish::v5::TargetPolicyName::ExplicitTargets +pub radroots_protocol::radrootsd::transport_publish::v5::TargetPolicyName::Nostr +pub enum radroots_protocol::radrootsd::transport_publish::v5::TargetSource +pub radroots_protocol::radrootsd::transport_publish::v5::TargetSource::DaemonDefault +pub radroots_protocol::radrootsd::transport_publish::v5::TargetSource::NostrAuthorWrite +pub radroots_protocol::radrootsd::transport_publish::v5::TargetSource::Request +pub radroots_protocol::radrootsd::transport_publish::v5::TargetSource::Reticulum +pub struct radroots_protocol::radrootsd::transport_publish::v5::AuthCapabilities +pub radroots_protocol::radrootsd::transport_publish::v5::AuthCapabilities::mode: alloc::string::String +pub struct radroots_protocol::radrootsd::transport_publish::v5::Capabilities +pub radroots_protocol::radrootsd::transport_publish::v5::Capabilities::api_version: alloc::string::String +pub radroots_protocol::radrootsd::transport_publish::v5::Capabilities::auth: radroots_protocol::radrootsd::transport_publish::v5::AuthCapabilities +pub radroots_protocol::radrootsd::transport_publish::v5::Capabilities::daemon: alloc::string::String +pub radroots_protocol::radrootsd::transport_publish::v5::Capabilities::methods: alloc::vec::Vec<alloc::string::String> +pub radroots_protocol::radrootsd::transport_publish::v5::Capabilities::publish: radroots_protocol::radrootsd::transport_publish::v5::SurfaceCapabilities +pub radroots_protocol::radrootsd::transport_publish::v5::Capabilities::transports: alloc::vec::Vec<alloc::string::String> +impl radroots_protocol::radrootsd::transport_publish::v5::Capabilities +pub fn radroots_protocol::radrootsd::transport_publish::v5::Capabilities::v5(usize, usize) -> Self +pub struct radroots_protocol::radrootsd::transport_publish::v5::EventRequest +pub radroots_protocol::radrootsd::transport_publish::v5::EventRequest::delivery_policy: radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy +pub radroots_protocol::radrootsd::transport_publish::v5::EventRequest::idempotency_key: core::option::Option<alloc::string::String> +pub radroots_protocol::radrootsd::transport_publish::v5::EventRequest::raw_event_json: alloc::string::String +pub radroots_protocol::radrootsd::transport_publish::v5::EventRequest::target_policy: radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy +pub radroots_protocol::radrootsd::transport_publish::v5::EventRequest::timeout_ms: core::option::Option<u64> +impl radroots_protocol::radrootsd::transport_publish::v5::EventRequest +pub fn radroots_protocol::radrootsd::transport_publish::v5::EventRequest::validate(&self, usize) -> core::result::Result<(), radroots_protocol::radrootsd::transport_publish::v5::Error> +pub struct radroots_protocol::radrootsd::transport_publish::v5::EventResponse +pub radroots_protocol::radrootsd::transport_publish::v5::EventResponse::deduplicated: bool +pub radroots_protocol::radrootsd::transport_publish::v5::EventResponse::job: radroots_protocol::radrootsd::transport_publish::v5::Job +pub struct radroots_protocol::radrootsd::transport_publish::v5::Job +pub radroots_protocol::radrootsd::transport_publish::v5::Job::acknowledged_count: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Job::completed_at_ms: core::option::Option<i64> +pub radroots_protocol::radrootsd::transport_publish::v5::Job::delivery_policy: radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy +pub radroots_protocol::radrootsd::transport_publish::v5::Job::delivery_satisfied: bool +pub radroots_protocol::radrootsd::transport_publish::v5::Job::event_id: alloc::string::String +pub radroots_protocol::radrootsd::transport_publish::v5::Job::event_kind: u32 +pub radroots_protocol::radrootsd::transport_publish::v5::Job::job_id: alloc::string::String +pub radroots_protocol::radrootsd::transport_publish::v5::Job::last_error: core::option::Option<alloc::string::String> +pub radroots_protocol::radrootsd::transport_publish::v5::Job::pubkey: alloc::string::String +pub radroots_protocol::radrootsd::transport_publish::v5::Job::requested_at_ms: i64 +pub radroots_protocol::radrootsd::transport_publish::v5::Job::retryable_count: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Job::status: radroots_protocol::radrootsd::transport_publish::v5::JobStatus +pub radroots_protocol::radrootsd::transport_publish::v5::Job::target_count: usize +pub radroots_protocol::radrootsd::transport_publish::v5::Job::target_policy: radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy +pub radroots_protocol::radrootsd::transport_publish::v5::Job::targets: alloc::vec::Vec<radroots_protocol::radrootsd::transport_publish::v5::TargetOutcome> +pub radroots_protocol::radrootsd::transport_publish::v5::Job::terminal: bool +pub radroots_protocol::radrootsd::transport_publish::v5::Job::terminal_count: usize +impl radroots_protocol::radrootsd::transport_publish::v5::Job +pub fn radroots_protocol::radrootsd::transport_publish::v5::Job::validate(&self) -> core::result::Result<(), radroots_protocol::radrootsd::transport_publish::v5::Error> +pub struct radroots_protocol::radrootsd::transport_publish::v5::OperationCapabilities +pub radroots_protocol::radrootsd::transport_publish::v5::OperationCapabilities::deliver: bool +pub radroots_protocol::radrootsd::transport_publish::v5::OperationCapabilities::discovery: bool +pub radroots_protocol::radrootsd::transport_publish::v5::OperationCapabilities::fetch: bool +pub radroots_protocol::radrootsd::transport_publish::v5::OperationCapabilities::gateway_forwarding: bool +pub radroots_protocol::radrootsd::transport_publish::v5::OperationCapabilities::receipt_observation: bool +pub struct radroots_protocol::radrootsd::transport_publish::v5::SurfaceCapabilities +pub radroots_protocol::radrootsd::transport_publish::v5::SurfaceCapabilities::delivery_policies: alloc::vec::Vec<radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicyName> +pub radroots_protocol::radrootsd::transport_publish::v5::SurfaceCapabilities::max_event_bytes: usize +pub radroots_protocol::radrootsd::transport_publish::v5::SurfaceCapabilities::max_targets_per_request: usize +pub radroots_protocol::radrootsd::transport_publish::v5::SurfaceCapabilities::raw_event_json_ingress: bool +pub radroots_protocol::radrootsd::transport_publish::v5::SurfaceCapabilities::server_side_user_signing: bool +pub radroots_protocol::radrootsd::transport_publish::v5::SurfaceCapabilities::target_policy_modes: alloc::vec::Vec<radroots_protocol::radrootsd::transport_publish::v5::TargetPolicyName> +pub radroots_protocol::radrootsd::transport_publish::v5::SurfaceCapabilities::transports: alloc::vec::Vec<radroots_protocol::radrootsd::transport_publish::v5::TransportCapability> +pub struct radroots_protocol::radrootsd::transport_publish::v5::Target +pub radroots_protocol::radrootsd::transport_publish::v5::Target::endpoint_uri: alloc::string::String +pub radroots_protocol::radrootsd::transport_publish::v5::Target::reticulum_behavior: core::option::Option<radroots_protocol::radrootsd::transport_publish::v5::ReticulumBehavior> +pub radroots_protocol::radrootsd::transport_publish::v5::Target::target_label: core::option::Option<alloc::string::String> +pub radroots_protocol::radrootsd::transport_publish::v5::Target::target_scope: core::option::Option<alloc::string::String> +pub radroots_protocol::radrootsd::transport_publish::v5::Target::transport_kind: alloc::string::String +impl radroots_protocol::radrootsd::transport_publish::v5::Target +pub fn radroots_protocol::radrootsd::transport_publish::v5::Target::nostr(impl core::convert::Into<alloc::string::String>) -> Self +pub fn radroots_protocol::radrootsd::transport_publish::v5::Target::reticulum(radroots_protocol::radrootsd::transport_publish::v5::ReticulumBehavior) -> Self +pub fn radroots_protocol::radrootsd::transport_publish::v5::Target::with_label(self, impl core::convert::Into<alloc::string::String>) -> Self +pub fn radroots_protocol::radrootsd::transport_publish::v5::Target::with_scope(self, impl core::convert::Into<alloc::string::String>) -> Self +pub struct radroots_protocol::radrootsd::transport_publish::v5::TargetFingerprint(_) +impl radroots_protocol::radrootsd::transport_publish::v5::TargetFingerprint +pub fn radroots_protocol::radrootsd::transport_publish::v5::TargetFingerprint::as_str(&self) -> &str +pub fn radroots_protocol::radrootsd::transport_publish::v5::TargetFingerprint::parse(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_protocol::radrootsd::transport_publish::v5::Error> +impl serde_core::ser::Serialize for radroots_protocol::radrootsd::transport_publish::v5::TargetFingerprint +pub fn radroots_protocol::radrootsd::transport_publish::v5::TargetFingerprint::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_protocol::radrootsd::transport_publish::v5::TargetFingerprint +pub fn radroots_protocol::radrootsd::transport_publish::v5::TargetFingerprint::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_protocol::radrootsd::transport_publish::v5::TargetOutcome +pub radroots_protocol::radrootsd::transport_publish::v5::TargetOutcome::attempted: bool +pub radroots_protocol::radrootsd::transport_publish::v5::TargetOutcome::endpoint_uri: alloc::string::String +pub radroots_protocol::radrootsd::transport_publish::v5::TargetOutcome::latency_ms: core::option::Option<u64> +pub radroots_protocol::radrootsd::transport_publish::v5::TargetOutcome::message: core::option::Option<alloc::string::String> +pub radroots_protocol::radrootsd::transport_publish::v5::TargetOutcome::outcome_kind: radroots_protocol::radrootsd::transport_publish::v5::OutcomeKind +pub radroots_protocol::radrootsd::transport_publish::v5::TargetOutcome::source: radroots_protocol::radrootsd::transport_publish::v5::TargetSource +pub radroots_protocol::radrootsd::transport_publish::v5::TargetOutcome::target_label: core::option::Option<alloc::string::String> +pub radroots_protocol::radrootsd::transport_publish::v5::TargetOutcome::target_scope: core::option::Option<alloc::string::String> +pub radroots_protocol::radrootsd::transport_publish::v5::TargetOutcome::transport_kind: alloc::string::String +pub struct radroots_protocol::radrootsd::transport_publish::v5::TransportCapability +pub radroots_protocol::radrootsd::transport_publish::v5::TransportCapability::availability: radroots_protocol::radrootsd::transport_publish::v5::CapabilityAvailability +pub radroots_protocol::radrootsd::transport_publish::v5::TransportCapability::capabilities: radroots_protocol::radrootsd::transport_publish::v5::OperationCapabilities +pub radroots_protocol::radrootsd::transport_publish::v5::TransportCapability::configured: bool +pub radroots_protocol::radrootsd::transport_publish::v5::TransportCapability::implementation: radroots_protocol::radrootsd::transport_publish::v5::Implementation +pub radroots_protocol::radrootsd::transport_publish::v5::TransportCapability::maturity: radroots_protocol::radrootsd::transport_publish::v5::CapabilityMaturity +pub radroots_protocol::radrootsd::transport_publish::v5::TransportCapability::message: alloc::string::String +pub radroots_protocol::radrootsd::transport_publish::v5::TransportCapability::reticulum_behavior: core::option::Option<radroots_protocol::radrootsd::transport_publish::v5::ReticulumBehavior> +pub radroots_protocol::radrootsd::transport_publish::v5::TransportCapability::transport: alloc::string::String +pub radroots_protocol::radrootsd::transport_publish::v5::TransportCapability::usable_for_delivery: bool +pub const radroots_protocol::radrootsd::transport_publish::v5::API_VERSION: &str +pub const radroots_protocol::radrootsd::transport_publish::v5::DAEMON_NAME: &str +pub const radroots_protocol::radrootsd::transport_publish::v5::METHOD_CAPABILITIES: &str +pub const radroots_protocol::radrootsd::transport_publish::v5::METHOD_EVENT: &str +pub const radroots_protocol::radrootsd::transport_publish::v5::METHOD_JOB_GET: &str +pub const radroots_protocol::radrootsd::transport_publish::v5::METHOD_JOB_LIST: &str +pub const radroots_protocol::radrootsd::transport_publish::v5::RETICULUM_ENDPOINT_URI: &str +pub const radroots_protocol::radrootsd::transport_publish::v5::RETICULUM_UNAVAILABLE_MESSAGE: &str +pub fn radroots_protocol::radrootsd::transport_publish::v5::schema_registry() -> core::result::Result<radroots_protocol::schema::Registry, radroots_protocol::schema::Error> +pub mod radroots_protocol::runtime +pub mod radroots_protocol::runtime::v1 +pub enum radroots_protocol::runtime::v1::ApprovalRequirement +pub radroots_protocol::runtime::v1::ApprovalRequirement::ConditionalOrRequiredByMode +pub radroots_protocol::runtime::v1::ApprovalRequirement::None +pub radroots_protocol::runtime::v1::ApprovalRequirement::Required +pub enum radroots_protocol::runtime::v1::DeadlinePolicy +pub radroots_protocol::runtime::v1::DeadlinePolicy::DefaultBounded +pub radroots_protocol::runtime::v1::DeadlinePolicy::OperationDeclared +pub enum radroots_protocol::runtime::v1::DryRunSupport +pub radroots_protocol::runtime::v1::DryRunSupport::NotApplicable +pub radroots_protocol::runtime::v1::DryRunSupport::PureLocalPlan +#[non_exhaustive] pub enum radroots_protocol::runtime::v1::Error +pub radroots_protocol::runtime::v1::Error::CatalogInvalid +pub radroots_protocol::runtime::v1::Error::CatalogInvalid::message: alloc::string::String +pub radroots_protocol::runtime::v1::Error::DuplicateOperationId +pub radroots_protocol::runtime::v1::Error::DuplicateOperationId::operation_id: radroots_protocol::runtime::v1::OperationId +pub radroots_protocol::runtime::v1::Error::MissingRequiredOperation +pub radroots_protocol::runtime::v1::Error::MissingRequiredOperation::operation_id: radroots_protocol::runtime::v1::OperationId +pub radroots_protocol::runtime::v1::Error::UnknownOperationId +pub radroots_protocol::runtime::v1::Error::UnknownOperationId::operation_id: alloc::string::String +pub radroots_protocol::runtime::v1::Error::UnsupportedOperationSchemaVersion +pub radroots_protocol::runtime::v1::Error::UnsupportedOperationSchemaVersion::operation_id: radroots_protocol::runtime::v1::OperationId +pub radroots_protocol::runtime::v1::Error::UnsupportedOperationSchemaVersion::version: u16 +impl core::error::Error for radroots_protocol::runtime::v1::Error +impl core::fmt::Display for radroots_protocol::runtime::v1::Error +pub fn radroots_protocol::runtime::v1::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub enum radroots_protocol::runtime::v1::IdempotencyPolicy +pub radroots_protocol::runtime::v1::IdempotencyPolicy::Forbidden +pub radroots_protocol::runtime::v1::IdempotencyPolicy::RequiredUuidV7 +pub enum radroots_protocol::runtime::v1::Mutability +pub radroots_protocol::runtime::v1::Mutability::Mutation +pub radroots_protocol::runtime::v1::Mutability::Read +pub enum radroots_protocol::runtime::v1::OperationId +pub radroots_protocol::runtime::v1::OperationId::AccountCreate +pub radroots_protocol::runtime::v1::OperationId::AccountImport +pub radroots_protocol::runtime::v1::OperationId::AccountList +pub radroots_protocol::runtime::v1::OperationId::AccountRemove +pub radroots_protocol::runtime::v1::OperationId::AccountSelect +pub radroots_protocol::runtime::v1::OperationId::BasketCreate +pub radroots_protocol::runtime::v1::OperationId::BasketGet +pub radroots_protocol::runtime::v1::OperationId::BasketItemAdd +pub radroots_protocol::runtime::v1::OperationId::BasketItemRemove +pub radroots_protocol::runtime::v1::OperationId::BasketItemUpdate +pub radroots_protocol::runtime::v1::OperationId::BasketList +pub radroots_protocol::runtime::v1::OperationId::BasketQuote +pub radroots_protocol::runtime::v1::OperationId::DiagnosticsInspect +pub radroots_protocol::runtime::v1::OperationId::FarmCreate +pub radroots_protocol::runtime::v1::OperationId::FarmGet +pub radroots_protocol::runtime::v1::OperationId::FarmList +pub radroots_protocol::runtime::v1::OperationId::FarmPublish +pub radroots_protocol::runtime::v1::OperationId::FarmUpdate +pub radroots_protocol::runtime::v1::OperationId::HealthInspect +pub radroots_protocol::runtime::v1::OperationId::ListingCreate +pub radroots_protocol::runtime::v1::OperationId::ListingGet +pub radroots_protocol::runtime::v1::OperationId::ListingList +pub radroots_protocol::runtime::v1::OperationId::ListingPause +pub radroots_protocol::runtime::v1::OperationId::ListingPublish +pub radroots_protocol::runtime::v1::OperationId::ListingUpdate +pub radroots_protocol::runtime::v1::OperationId::ListingWithdraw +pub radroots_protocol::runtime::v1::OperationId::MarketGet +pub radroots_protocol::runtime::v1::OperationId::MarketPull +pub radroots_protocol::runtime::v1::OperationId::MarketSearch +pub radroots_protocol::runtime::v1::OperationId::ProfileInspect +pub radroots_protocol::runtime::v1::OperationId::ProfileReset +pub radroots_protocol::runtime::v1::OperationId::SignerStatus +pub radroots_protocol::runtime::v1::OperationId::StoreBackup +pub radroots_protocol::runtime::v1::OperationId::StoreInspect +pub radroots_protocol::runtime::v1::OperationId::StoreRestore +pub radroots_protocol::runtime::v1::OperationId::SyncPull +pub radroots_protocol::runtime::v1::OperationId::SyncPush +pub radroots_protocol::runtime::v1::OperationId::SyncStatus +pub radroots_protocol::runtime::v1::OperationId::TradeCancellationSubmit +pub radroots_protocol::runtime::v1::OperationId::TradeCandidateDecide +pub radroots_protocol::runtime::v1::OperationId::TradeEvidenceInspect +pub radroots_protocol::runtime::v1::OperationId::TradeEvidenceRefresh +pub radroots_protocol::runtime::v1::OperationId::TradeGet +pub radroots_protocol::runtime::v1::OperationId::TradeList +pub radroots_protocol::runtime::v1::OperationId::TradeOperationResume +pub radroots_protocol::runtime::v1::OperationId::TradePrivateArtifactDelete +pub radroots_protocol::runtime::v1::OperationId::TradePrivateArtifactOpen +pub radroots_protocol::runtime::v1::OperationId::TradePrivateArtifactSeal +pub radroots_protocol::runtime::v1::OperationId::TradeProposalSubmit +pub radroots_protocol::runtime::v1::OperationId::TradeRevisionPropose +pub radroots_protocol::runtime::v1::OperationId::TransportCapabilityList +pub radroots_protocol::runtime::v1::OperationId::TransportConfigInspect +pub radroots_protocol::runtime::v1::OperationId::TransportConfigUpdate +pub radroots_protocol::runtime::v1::OperationId::TransportDeliveryInspect +pub radroots_protocol::runtime::v1::OperationId::TransportDeliveryRetry +pub radroots_protocol::runtime::v1::OperationId::TransportStatusInspect +pub radroots_protocol::runtime::v1::OperationId::ValidationStatus +impl radroots_protocol::runtime::v1::OperationId +pub fn radroots_protocol::runtime::v1::OperationId::as_str(self) -> &'static str +pub fn radroots_protocol::runtime::v1::OperationId::parse(&str) -> core::result::Result<Self, radroots_protocol::runtime::v1::Error> +pub fn radroots_protocol::runtime::v1::OperationId::receipt_schema_id(self) -> &'static str +pub fn radroots_protocol::runtime::v1::OperationId::request_schema_id(self) -> &'static str +impl serde_core::ser::Serialize for radroots_protocol::runtime::v1::OperationId +pub fn radroots_protocol::runtime::v1::OperationId::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_protocol::runtime::v1::OperationId +pub fn radroots_protocol::runtime::v1::OperationId::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub enum radroots_protocol::runtime::v1::PrivacyEffect +pub radroots_protocol::runtime::v1::PrivacyEffect::BackupRestore +pub radroots_protocol::runtime::v1::PrivacyEffect::None +pub radroots_protocol::runtime::v1::PrivacyEffect::PrivateCoordination +pub radroots_protocol::runtime::v1::PrivacyEffect::PrivateStore +pub radroots_protocol::runtime::v1::PrivacyEffect::PublicEvent +pub enum radroots_protocol::runtime::v1::ProjectionEffect +pub radroots_protocol::runtime::v1::ProjectionEffect::MayUpdateProjection +pub radroots_protocol::runtime::v1::ProjectionEffect::None +pub radroots_protocol::runtime::v1::ProjectionEffect::ReadsProjection +pub radroots_protocol::runtime::v1::ProjectionEffect::WritesProjection +pub enum radroots_protocol::runtime::v1::Risk +pub radroots_protocol::runtime::v1::Risk::Critical +pub radroots_protocol::runtime::v1::Risk::High +pub radroots_protocol::runtime::v1::Risk::Low +pub radroots_protocol::runtime::v1::Risk::Medium +pub enum radroots_protocol::runtime::v1::SignerRequirement +pub radroots_protocol::runtime::v1::SignerRequirement::ConditionalRelayAuth +pub radroots_protocol::runtime::v1::SignerRequirement::None +pub radroots_protocol::runtime::v1::SignerRequirement::Required +pub struct radroots_protocol::runtime::v1::OperationDescriptor +pub radroots_protocol::runtime::v1::OperationDescriptor::approval: radroots_protocol::runtime::v1::ApprovalRequirement +pub radroots_protocol::runtime::v1::OperationDescriptor::deadline: radroots_protocol::runtime::v1::DeadlinePolicy +pub radroots_protocol::runtime::v1::OperationDescriptor::dry_run: radroots_protocol::runtime::v1::DryRunSupport +pub radroots_protocol::runtime::v1::OperationDescriptor::idempotency: radroots_protocol::runtime::v1::IdempotencyPolicy +pub radroots_protocol::runtime::v1::OperationDescriptor::maturity: radroots_protocol::capability::v1::Maturity +pub radroots_protocol::runtime::v1::OperationDescriptor::mutability: radroots_protocol::runtime::v1::Mutability +pub radroots_protocol::runtime::v1::OperationDescriptor::operation_id: radroots_protocol::runtime::v1::OperationId +pub radroots_protocol::runtime::v1::OperationDescriptor::privacy: radroots_protocol::runtime::v1::PrivacyEffect +pub radroots_protocol::runtime::v1::OperationDescriptor::projection: radroots_protocol::runtime::v1::ProjectionEffect +pub radroots_protocol::runtime::v1::OperationDescriptor::risk: radroots_protocol::runtime::v1::Risk +pub radroots_protocol::runtime::v1::OperationDescriptor::schema_version: u16 +pub radroots_protocol::runtime::v1::OperationDescriptor::signer: radroots_protocol::runtime::v1::SignerRequirement +pub radroots_protocol::runtime::v1::OperationDescriptor::transport_capability: radroots_protocol::runtime::v1::TransportRoute +impl radroots_protocol::runtime::v1::OperationDescriptor +pub fn radroots_protocol::runtime::v1::OperationDescriptor::receipt_schema_id(self) -> &'static str +pub fn radroots_protocol::runtime::v1::OperationDescriptor::request_schema_id(self) -> &'static str +pub struct radroots_protocol::runtime::v1::TransportRoute +pub radroots_protocol::runtime::v1::TransportRoute::deliver: bool +pub radroots_protocol::runtime::v1::TransportRoute::diagnostics: bool +pub radroots_protocol::runtime::v1::TransportRoute::fetch: bool +pub radroots_protocol::runtime::v1::TransportRoute::local: bool +pub radroots_protocol::runtime::v1::TransportRoute::nostr: bool +pub radroots_protocol::runtime::v1::TransportRoute::reticulum: bool +pub radroots_protocol::runtime::v1::TransportRoute::synchronize: bool +impl radroots_protocol::runtime::v1::TransportRoute +pub const fn radroots_protocol::runtime::v1::TransportRoute::delivery() -> Self +pub const fn radroots_protocol::runtime::v1::TransportRoute::diagnostics() -> Self +pub const fn radroots_protocol::runtime::v1::TransportRoute::fetch() -> Self +pub fn radroots_protocol::runtime::v1::TransportRoute::includes_transport(self, radroots_protocol::capability::v1::TransportKind) -> bool +pub const fn radroots_protocol::runtime::v1::TransportRoute::local() -> Self +pub const fn radroots_protocol::runtime::v1::TransportRoute::none() -> Self +pub const radroots_protocol::runtime::v1::CATALOG: &[radroots_protocol::runtime::v1::OperationDescriptor] +pub const radroots_protocol::runtime::v1::OPERATION_SCHEMA_VERSION: u16 +pub fn radroots_protocol::runtime::v1::operation_descriptor(radroots_protocol::runtime::v1::OperationId) -> core::result::Result<radroots_protocol::runtime::v1::OperationDescriptor, radroots_protocol::runtime::v1::Error> +pub fn radroots_protocol::runtime::v1::schema_registry() -> core::result::Result<radroots_protocol::schema::Registry, radroots_protocol::schema::Error> +pub fn radroots_protocol::runtime::v1::validate_catalog(&[radroots_protocol::runtime::v1::OperationDescriptor]) -> core::result::Result<(), radroots_protocol::runtime::v1::Error> +pub mod radroots_protocol::schema +#[non_exhaustive] pub enum radroots_protocol::schema::Error +pub radroots_protocol::schema::Error::DuplicateSchemaId +pub radroots_protocol::schema::Error::DuplicateSchemaId::schema_id: alloc::string::String +pub radroots_protocol::schema::Error::EmptySchemaId +pub radroots_protocol::schema::Error::InvalidSchemaNamespaceSegment +pub radroots_protocol::schema::Error::InvalidSchemaNamespaceSegment::index: usize +pub radroots_protocol::schema::Error::InvalidSchemaVersion +pub radroots_protocol::schema::Error::MissingSchemaNamespace +pub radroots_protocol::schema::Error::SchemaIdTooLong +pub radroots_protocol::schema::Error::SchemaIdTooLong::actual: usize +pub radroots_protocol::schema::Error::SchemaIdTooLong::max: usize +pub radroots_protocol::schema::Error::SchemaVersionMismatch +pub radroots_protocol::schema::Error::SchemaVersionMismatch::declared: u16 +pub radroots_protocol::schema::Error::SchemaVersionMismatch::encoded: u16 +pub radroots_protocol::schema::Error::SchemaVersionMismatch::schema_id: alloc::string::String +impl core::error::Error for radroots_protocol::schema::Error +impl core::fmt::Display for radroots_protocol::schema::Error +pub fn radroots_protocol::schema::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +#[non_exhaustive] pub enum radroots_protocol::schema::ModuleVersion +pub radroots_protocol::schema::ModuleVersion::CapabilityV1 +pub radroots_protocol::schema::ModuleVersion::ErrorV1 +pub radroots_protocol::schema::ModuleVersion::EventV1 +pub radroots_protocol::schema::ModuleVersion::RadrootsdTransportPublishV5 +pub radroots_protocol::schema::ModuleVersion::RuntimeV1 +impl radroots_protocol::schema::ModuleVersion +pub const radroots_protocol::schema::ModuleVersion::ALL: [Self; 5] +pub const fn radroots_protocol::schema::ModuleVersion::generation(self) -> u16 +pub const fn radroots_protocol::schema::ModuleVersion::path(self) -> &'static str +pub struct radroots_protocol::schema::Descriptor +impl radroots_protocol::schema::Descriptor +pub const fn radroots_protocol::schema::Descriptor::id(&self) -> &radroots_protocol::schema::SchemaId +pub const fn radroots_protocol::schema::Descriptor::module(&self) -> radroots_protocol::schema::ModuleVersion +pub fn radroots_protocol::schema::Descriptor::try_new(impl core::convert::Into<alloc::string::String>, radroots_protocol::schema::ModuleVersion) -> core::result::Result<Self, radroots_protocol::schema::Error> +pub struct radroots_protocol::schema::Metadata +pub radroots_protocol::schema::Metadata::schema_id: &'static str +pub radroots_protocol::schema::Metadata::schema_version: u16 +pub radroots_protocol::schema::Metadata::type_name: &'static str +pub struct radroots_protocol::schema::Registry +impl radroots_protocol::schema::Registry +pub fn radroots_protocol::schema::Registry::descriptor(&self, &radroots_protocol::schema::SchemaId) -> core::option::Option<&radroots_protocol::schema::Descriptor> +pub fn radroots_protocol::schema::Registry::descriptors(&self) -> &[radroots_protocol::schema::Descriptor] +pub fn radroots_protocol::schema::Registry::is_empty(&self) -> bool +pub fn radroots_protocol::schema::Registry::len(&self) -> usize +pub fn radroots_protocol::schema::Registry::module_for(&self, &radroots_protocol::schema::SchemaId) -> core::option::Option<radroots_protocol::schema::ModuleVersion> +pub fn radroots_protocol::schema::Registry::try_from_metadata(impl core::iter::traits::collect::IntoIterator<Item = (radroots_protocol::schema::Metadata, radroots_protocol::schema::ModuleVersion)>) -> core::result::Result<Self, radroots_protocol::schema::Error> +pub fn radroots_protocol::schema::Registry::try_new(impl core::iter::traits::collect::IntoIterator<Item = radroots_protocol::schema::Descriptor>) -> core::result::Result<Self, radroots_protocol::schema::Error> +pub struct radroots_protocol::schema::SchemaId +impl radroots_protocol::schema::SchemaId +pub fn radroots_protocol::schema::SchemaId::as_str(&self) -> &str +pub fn radroots_protocol::schema::SchemaId::parse(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_protocol::schema::Error> +pub const fn radroots_protocol::schema::SchemaId::version(&self) -> u16 +impl core::convert::AsRef<str> for radroots_protocol::schema::SchemaId +pub fn radroots_protocol::schema::SchemaId::as_ref(&self) -> &str +impl core::convert::TryFrom<&str> for radroots_protocol::schema::SchemaId +pub type radroots_protocol::schema::SchemaId::Error = radroots_protocol::schema::Error +pub fn radroots_protocol::schema::SchemaId::try_from(&str) -> core::result::Result<Self, Self::Error> +impl core::convert::TryFrom<alloc::string::String> for radroots_protocol::schema::SchemaId +pub type radroots_protocol::schema::SchemaId::Error = radroots_protocol::schema::Error +pub fn radroots_protocol::schema::SchemaId::try_from(alloc::string::String) -> core::result::Result<Self, Self::Error> +impl core::fmt::Display for radroots_protocol::schema::SchemaId +pub fn radroots_protocol::schema::SchemaId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::str::traits::FromStr for radroots_protocol::schema::SchemaId +pub type radroots_protocol::schema::SchemaId::Err = radroots_protocol::schema::Error +pub fn radroots_protocol::schema::SchemaId::from_str(&str) -> core::result::Result<Self, Self::Err> +pub const radroots_protocol::schema::MAX_SCHEMA_ID_BYTES: usize +pub fn radroots_protocol::schema::protocol_v1_registry() -> core::result::Result<radroots_protocol::schema::Registry, radroots_protocol::schema::Error> diff --git a/docs/decisions/0001-public-api-leakage-migration-baseline.md b/docs/decisions/0001-public-api-leakage-migration-baseline.md @@ -0,0 +1,36 @@ +# ADR 0001: Public API leakage migration baseline + +Status: accepted for the crates release V1 migration +Date: 2026-07-27 + +## Context + +The Release V1 architecture forbids generic public packages from exposing +SQLx, Tokio, Reqwest, Nostr SDK, keyring, or platform-specific implementation +types. The existing identity, Nostr, and Nostr Connect packages predate that +boundary and still expose a finite set of upstream Nostr types while +publication remains frozen. + +## Decision + +The synchronized API-boundary contract records only the reviewed findings +under exception IDs RCRV1-API-001, RCRV1-API-002, RCRV1-API-003, +RCRV1-API-004, RCRV1-API-005, RCRV1-API-006, RCRV1-API-007, and +RCRV1-API-008. + +Every exception is package-, source-, item-, forbidden-root-, and +observed-path-specific. New items, new upstream paths, SQLx, Tokio, Reqwest, +keyring, platform-specific types, or broader aliases remain forbidden. The +exceptions authorize no publication. + +The identity exceptions must be removed by the Step 042 conformance gate, the +Nostr SDK exceptions by Step 124, and the Nostr Connect exceptions by Step +140. The owning package refactors may remove them earlier. + +## Consequences + +The architecture command fails closed when an ADR is missing, an exception is +expired or broadened, or a new public implementation type appears. Concrete +implementation crates may use third-party types internally, but those types do +not become public API unless the synchronized contract explicitly permits the +package and path. diff --git a/docs/engineering/ci.md b/docs/engineering/ci.md @@ -0,0 +1,24 @@ +# Architecture continuous integration + +The pull-request architecture lane is a thin GitHub adapter over the +forge-agnostic repository command: + +```sh +nix run .#architecture +``` + +The command validates the synchronized release specification, workspace and +package metadata, production dependency paths, the Cargo-resolved package-tier +graph, public API implementation leakage, contract artifacts, DTO roots, and +generated-manifest freshness. The same command is exposed as the +`architecture` flake check, so `nix flake check` includes the lane. + +The workflow grants only read access to repository contents. Action +dependencies are pinned to full commit identifiers. Its Nix store cache is +content-addressed from the current source and lock inputs; generated files are +read from the checkout on every run and are never restored from a workflow +cache. + +Repository administrators may require the `Architecture / architecture` +status after this commit is publicly reachable. Changing branch protection or +other repository administration remains a separate authorized operation. diff --git a/docs/implementation/DEPENDENCY_RESOLUTION.md b/docs/implementation/DEPENDENCY_RESOLUTION.md @@ -0,0 +1,14 @@ +# Dependency resolution + +This standalone repository owns its `Cargo.lock`. Under `RCRV1-DEV-001`, the +release-v1 refactor does not combine it with the SDK lockfile or make either +repository depend on the other's workspace state. + +Step 017 verified the current lock checksum as +`4462008577c9b46a97a01acce7efd34c95e98e46bc54468cb278f066f7943726`. +Repeated `cargo metadata --locked --no-deps --format-version 1` and the full +repository contract lane leave it unchanged. + +Dependency changes must use repository-owned extbuild commands, preserve +`--locked` zero-diff validation, and update this evidence when the resolved +graph intentionally changes. diff --git a/docs/implementation/DEVIATIONS.md b/docs/implementation/DEVIATIONS.md @@ -0,0 +1,46 @@ +# Implementation deviations + +The machine-readable authority is [`deviations.toml`](deviations.toml). +Repository checks validate it on every architecture and full check lane. This +ledger records evidence-based changes to implementation planning; it does not +silently change `radroots.crates.release.v1`. + +## Active records + +| ID | Affected steps | Approved disposition | +| --- | --- | --- | +| `RCRV1-DEV-001` | 015-023 | Preserve the existing standalone `lib` and `sdk` repositories; replace repository import/unification with independent qualification. | +| `RCRV1-DEV-002` | 249 | Pull only the facade scaffold forward to immediately after Step 014 in `sdk`; do not repeat it later. | +| `RCRV1-DEV-004` | 098, 155, 225, 260, 268, 294, 298-299, 301-304, 314 | Enforce a temporary 90% four-dimension coverage baseline during heavy development; restore 100% only through a future explicit contract update. | +| `RCRV1-DEV-005` | 013, 019-026, 027-315 | Pin every Rust crate and internal Radroots dependency in `radrootslabs/lib` to exactly `0.1.0-alpha` until further explicit authority. | +| `RCRV1-DEV-006` | 046-048, 050-053 | Retain the exact non-default Phase 1 publication-readiness/raster extension temporarily while keeping external I/O and application policy forbidden; resolve ownership before publication. | + +## Record template + +Add one `[[deviation]]` table to `deviations.toml`: + +```toml +[[deviation]] +id = "RCRV1-DEV-NNN" +date = "YYYY-MM-DD" +status = "active" # active | closed | superseded +approval = "Explicit approving decision." +affected_steps = ["NNN"] +spec_anchors = ["docs/specs/<durable-spec>#<anchor>"] +source_evidence = ["Committed source evidence."] +replacement_action = "Smallest safe disposition." +verification = ["Command or review evidence."] +unresolved_risk = "none, or a concrete bounded risk" +normative_architecture_change = false +adr_required = false +closure_evidence = [] # omit while active; required when closed or superseded +``` + +Every field is mandatory except `closure_evidence` on active records. Spec +anchors must resolve inside `docs/specs/`; affected steps must be three-digit +IDs in 001-315. A normative architecture change needs explicit approval and +the appropriate ADR decision before the record can be accepted. + +Do not silently skip, merge, reorder, or broaden implementation steps. Keep a +red checkpoint uncommitted and mark the next step blocked until its evidence or +approval is complete. diff --git a/docs/implementation/HISTORY_PRESERVATION.md b/docs/implementation/HISTORY_PRESERVATION.md @@ -0,0 +1,25 @@ +# Standalone history preservation + +Step 015 is satisfied under `RCRV1-DEV-001` without importing or combining +repository history. This repository remains the independent source authority +for the 17 lower release-v1 packages. + +## Verified checkpoint + +- repository: `git@github.com:radrootslabs/lib.git` +- reviewed baseline: `466f3cc36739179bc17edb9db796530729ba5219` +- verified candidate parent: `fab75a9d3950b92ed70e7e3d8cec0d55d1caf34b` +- baseline relationship: the reviewed baseline is an ancestor of the verified + candidate parent +- submodules: none +- import, subtree, filter-repo, history merge, repository rename, or archive: + not required and not performed + +`git log --follow` retains representative history for +`crates/core/Cargo.toml` and `tools/xtask/src/main.rs`. `git fsck --full` +completed successfully with no corrupt or missing reachable objects. It +reported only unreachable dangling objects retained by Git; those are not +part of the release candidate and were not pruned or modified. + +The next workspace steps must preserve this repository, its lockfile, and its +release boundary independently from `radrootslabs/sdk`. diff --git a/docs/implementation/PUBLICATION_FREEZE.md b/docs/implementation/PUBLICATION_FREEZE.md @@ -0,0 +1,24 @@ +# Crates.io publication freeze + +Crates.io publication is frozen for the complete release-v1 crate refactor. +Every workspace package, including packages intended to become public, must +set: + +```toml +publish = false +``` + +`contracts/releases/publish_policy.toml` is the machine authority for this +freeze. Repository contract and release-preflight validation reject a missing +publication-control section, an unexpected registry, a different enablement +checkpoint, or any package that is implicitly or explicitly publishable. + +The only planned exception is release plan Step 305, after the final package +inventory, resolved public dependency graph, API surface, target matrix, and +security gates are green. That checkpoint may set `publication.frozen = false` +and enable exactly the approved release packages for package-validation +staging. It does not authorize upload or any crates.io mutation. + +Changing the freeze requires an independently reviewed release-control commit. +Actual publication, tag creation, registry ownership changes, and +trusted-publisher changes always require separate explicit authorization. diff --git a/docs/implementation/STEP_REPORT_TEMPLATE.md b/docs/implementation/STEP_REPORT_TEMPLATE.md @@ -0,0 +1,58 @@ +# Commit-step report template + +Complete this record in the owning rolling-commit document after verification +and before the next handoff step begins. + +```text +Step: +Title: +Repository: +Branch: +Commit SHA: + +Spec anchors: +- ... + +Files changed: +- ... + +Behavior implemented: +- ... + +Tests and verification: +- command: + result: +- command: + result: + +Self-review: +- public API review: +- architecture-boundary review: +- error/secret review: +- feature/target review: +- documentation review: +- generated/lockfile diff review: + +Deviations: +- none +or +- RCRV1-DEV-NNN and evidence + +Unresolved issues: +- none +or +- ... + +Known pre-existing failures: +- none +or +- command, exact failure, evidence, and why it is outside this step + +Next-step safety: +- SAFE / BLOCKED +- reason: +``` + +A step is not complete without its commit SHA, exact command outcomes, +self-review, deviation disposition, and next-step safety decision. A blocked +step does not authorize later work. diff --git a/docs/implementation/TRACEABILITY.md b/docs/implementation/TRACEABILITY.md @@ -0,0 +1,21 @@ +# Release-v1 requirement traceability + +This matrix maps durable architecture requirements to implementation ownership +and verification. It adds no product requirements; the synchronized +`docs/specs/` bundle remains normative. + +| Durable requirement | Owning package or control | Handoff steps | Required evidence | +| --- | --- | --- | --- | +| Exactly 19 public packages with a 17/2 repository split | release policy and architecture catalog | 013, 015-026, 304-305 | Cargo-resolved graph report and exact allowlist validation | +| Every `radrootslabs/lib` Rust crate remains in the frozen `0.1.0-alpha` cohort | library repository version authority and architecture validators | 013, 019-315 | exact workspace package, dependency requirement, lockfile, and synchronized-spec validation | +| Public-only identity and separated signing/secrets | `radroots_identity`, `radroots_signing`, `radroots_secrets` | 052-054, 099-111, 147-155 | public API, feature, dependency, and redaction tests | +| One canonical `TradeId` | `radroots_event`, `radroots_trade` | 073-098 | compile/API inventory and trade conformance | +| Version-neutral protocol ownership | `radroots_protocol` and private generators | 055-064, 261-268 | contract vectors and generated freshness | +| Independent transport source/sink with extensible identity | `radroots_transport` and adapters | 112-134, 190-207 | transport conformance and forward-compatibility fixtures | +| Storage SPI with SQLite backend | `radroots_storage`, `radroots_storage_sqlite` | 156-189 | backend conformance, migration, recovery, and leakage gates | +| Shared sync engine and explicit lifecycle | `radroots_sync` | 208-225 | pull/push, idempotency, cancellation, and close tests | +| Safe SDK defaults and curated facade | `radroots_sdk`, `radroots` | 226-260 | clean-project package smoke tests and compile-time surface guards | +| Preview and implementation packages remain private | release policy and graph validator | 013, 023-026, 304-305 | private-closure and forbidden-edge fixtures | +| Package-realistic reproducible release | release tooling in both repositories | 295-315 | locked zero-diff package, extracted, local-registry, target, and coverage gates | +| Every first-party consumer migrates | downstream cutover matrix | 269-294 | discovered consumer inventory and canary results | +| Deviations remain explicit and reviewable | `docs/implementation/deviations.toml` | 014 and every affected step | `cargo xtask architecture` plus step report evidence | diff --git a/docs/implementation/deviations.toml b/docs/implementation/deviations.toml @@ -0,0 +1,157 @@ +schema_version = 1 +architecture_id = "radroots.crates.release.v1" + +[[deviation]] +id = "RCRV1-DEV-001" +date = "2026-07-27" +status = "active" +approval = "Explicit user correction dated 2026-07-27." +affected_steps = [ + "015", + "016", + "017", + "018", + "019", + "020", + "021", + "022", + "023", + "026", +] +spec_anchors = [ + "docs/specs/radroots_crates_release_v1.md#repository-ownership", + "docs/specs/radroots_crates_release_v1.toml#repository_policy", +] +source_evidence = [ + "The final v1 specification allocates 17 public packages to radrootslabs/lib and 2 to radrootslabs/sdk.", + "Both existing repositories have independent histories, workspaces, lockfiles, remotes, and standalone release boundaries.", +] +replacement_action = "Retain the two existing standalone repositories; replace import and monorepo-unification work with independent workspace, lockfile, metadata, dependency, and release qualification." +verification = [ + "Both repository-local architecture validators resolve every spec anchor.", + "The synchronized architecture catalog enforces the exact 17/2 ownership partition.", + "Each standalone repository owns a required architecture CI adapter over its repository-local command surface.", +] +unresolved_risk = "Parent gitlinks cannot advance until the new standalone commits are public-remote reachable under separate authorization." +normative_architecture_change = false +adr_required = false + +[[deviation]] +id = "RCRV1-DEV-005" +date = "2026-07-28" +status = "active" +approval = "Explicit user Rust version-policy update dated 2026-07-28 17:47 UTC." +affected_steps = [ + "013", + "019", + "020", + "021", + "022", + "023", + "024", + "025", + "026", + "305", +] +spec_anchors = [ + "docs/specs/radroots_crates_release_v1.md#221-initial-versions", + "docs/specs/radroots_crates_release_v1.toml#repositories.lib", +] +source_evidence = [ + "The prior 1.0.0 and mixed 0.1.0 prerelease crate cohorts were explicitly declared incorrect.", + "The user requires every Rust crate in radrootslabs/lib to remain exactly 0.1.0-alpha until further explicit notice.", +] +replacement_action = "Pin every workspace package, lockfile entry, and internal Radroots dependency requirement in radrootslabs/lib to 0.1.0-alpha; preserve independent protocol and sibling-repository versions; reject library cohort drift until new explicit authority is recorded." +verification = [ + "Repository architecture validation rejects any workspace package version other than 0.1.0-alpha.", + "Internal Radroots dependency requirements resolve exactly to =0.1.0-alpha.", + "Synchronized release specifications record the library cohort independently from the SDK repository version.", +] +unresolved_risk = "The prerelease cohort intentionally prevents independent library package version advancement until a future explicit policy change." +normative_architecture_change = false +adr_required = false + +[[deviation]] +id = "RCRV1-DEV-006" +date = "2026-07-28" +status = "active" +approval = "Concurrent Phase 1 foundational-events pfc-all authority and explicit instruction to preserve the radroots.crates.release.v1 refactor dated 2026-07-28." +affected_steps = ["046", "047", "048", "050", "051", "052", "053"] +spec_anchors = [ + "docs/specs/radroots_crates_release_v1.md#radroots_blossom", + "docs/specs/radroots_crates_release_v1.toml#package.radroots_blossom", +] +source_evidence = [ + "The independently governed Phase 1 operation blossom.verify_publication_readiness requires publication-readiness evidence and complete static JPEG, PNG, and WebP validation in radroots_blossom.", + "The final crate-surface specification retains radroots_blossom as a protocol-only crate with only std and serde, while the Phase 1 candidate predates that final boundary and is already consumed by event, outbox, and transport conformance.", +] +replacement_action = "Retain publication_readiness and the non-default raster-decode feature as an exact temporary Phase 1 extension; permit only in-memory decoding and keep external I/O, clients, scheduling, caches, filesystem access, and application media policy forbidden. Resolve the ownership mismatch in a later governed RCLD before publication." +verification = [ + "The Blossom package-boundary suite locks the exact temporary feature, dependency, module, root-export, and forbidden-external-I/O surface.", + "The no-default protocol lane remains portable and independent of raster decoder dependencies.", + "The decoder security, deterministic fuzz smoke, resource-matrix, iOS compile-link, contract, and release-preflight lanes remain mandatory while the extension exists.", +] +unresolved_risk = "The Phase 1 readiness extension does not match the final radroots_blossom publication surface and therefore must remain non-publishable until a later RCLD relocates it or explicit authority amends the synchronized release specification." +normative_architecture_change = false +adr_required = false + +[[deviation]] +id = "RCRV1-DEV-002" +date = "2026-07-27" +status = "active" +approval = "Explicit user correction dated 2026-07-27." +affected_steps = ["249"] +spec_anchors = [ + "docs/specs/radroots_crates_release_v1.md#radroots", + "docs/specs/radroots_crates_release_v1.toml#repositories.sdk", +] +source_evidence = [ + "The final v1 specification assigns the radroots facade to the existing sdk repository.", + "The approved sequence requires radroots to be the first crate-surface mutation after architecture controls are green.", +] +replacement_action = "Scaffold radroots in the sdk repository immediately after Step 014, then execute Steps 250-260 in their original order without repeating the scaffold portion of Step 249." +verification = [ + "The sdk release policy reserves radroots as an approved local package while publication remains frozen.", + "The facade scaffold checkpoint must add radroots only to the sdk workspace and architecture policy.", +] +unresolved_risk = "The facade remains non-publishable until the package-realistic Step 305 enablement gate." +normative_architecture_change = false +adr_required = false + +[[deviation]] +id = "RCRV1-DEV-004" +date = "2026-07-28" +status = "active" +approval = "Explicit user coverage-policy update dated 2026-07-28 17:15 UTC." +affected_steps = [ + "098", + "155", + "225", + "260", + "268", + "294", + "298", + "299", + "301", + "302", + "303", + "304", + "314", +] +spec_anchors = [ + "docs/specs/radroots_crates_release_v1.md#245-coverage-policy", + "docs/specs/radroots_crates_release_v1.toml#quality_policy.coverage", +] +source_evidence = [ + "The oss/lib codebase is under heavy development during the multi-RCLD refactor.", + "The user explicitly replaced the active 100% coverage requirement with a uniform 90% module requirement.", +] +replacement_action = "Enforce 90% executable-line, function, region, and branch coverage for every required oss/lib crate; retain only no-branch-record exceptions; defer restoration of 100% until an explicit future contract update after refactor stabilization." +verification = [ + "Contract validation rejects any base coverage dimension other than 90% or disabled required branches.", + "Coverage policy-gate tests prove values below 90% fail and values at or above 90% pass.", + "The required-crate inventory remains complete and crate-specific numeric thresholds below 90% remain forbidden.", +] +unresolved_risk = "A 90% development gate admits untested paths that a later 100% gate would reject; the final restoration remains intentionally unscheduled pending explicit authority." +normative_architecture_change = false +adr_required = false diff --git a/docs/migration/identity.md b/docs/migration/identity.md @@ -0,0 +1,25 @@ +# Identity, signing, and secret ownership migration + +`radroots_identity` now owns public values only: `PublicKey`, `IdentityId`, +`AccountId`, `PublicIdentity`, `Profile`, and `Username`. The removed +`RadrootsIdentity` API, raw secret bytes, key generation, nsec encoding, NIP-49 +encryption/decryption, and encrypted identity files have no compatibility +aliases in this package. + +The approved destination boundaries are: + +- `radroots_nostr::key` for explicit Nostr key parsing, nsec/NIP-49 conversion, + and host-requested local Nostr key creation; +- `radroots_signing` for the signer SPI, requests, receipts, authorization, and + actor provenance, without owning raw secret bytes; +- `radroots_nostr::signing` for concrete local Nostr signing adapters; +- `radroots_secrets::{reference, provider, envelope, wrapping}` for secret + references, providers, wrapping, and versioned encrypted envelopes; +- host storage adapters composed from `radroots_secrets` and + `radroots_storage_sqlite` for durable secret persistence. + +Those destination APIs are introduced by their ordered release checkpoints. +Until then, callers must not recreate secret ownership or persistence in +`radroots_identity` or add a compatibility shim. The former identity/profile +file helpers, default paths, runtime-path resolution, and encrypted storage +APIs have been removed from this package. diff --git a/docs/specs/README.md b/docs/specs/README.md @@ -0,0 +1,40 @@ +# Release specification index + +This directory carries the coordinated `radroots.crates.release.v1` contract +for the two existing standalone Rust repositories. + +The `radrootslabs/lib` repository owns packages 1-17, from `radroots_core` +through `radroots_geonames`. The `radrootslabs/sdk` repository owns packages +18-19, `radroots_sdk` and `radroots`. No third Rust repository is part of this +release architecture. + +## Files + +- `radroots_crates_release_v1.md` is the normative architecture and + publication specification. +- `radroots_crates_release_v1.toml` is the machine-readable package, + dependency, feature, and repository-allocation catalog. +- `radroots_crates_release_v1_inventory.csv` is the reviewable package + inventory. +- `radroots_crates_release_v1.dot` is the reviewable dependency and repository + ownership graph. +- `radroots_crates_release_v1.sha256` pins the synchronized contract artifact + contents. + +## Precedence and change control + +Repository instruction files govern how work is performed. Within this +release contract, the Markdown specification is normative, the TOML catalog +is its executable representation, and the CSV and DOT files are review aids. +Current code and tests are implementation evidence, not authority to silently +change the package architecture. + +The four contract artifacts and their hashes MUST match the copies in +`radrootslabs/sdk`. A change to package identity, ownership, dependency +direction, or release policy MUST update both repositories together and MUST +fail validation if the copies diverge. + +During migration, package manifests remain non-publishable until their +package-realistic release gates pass. Cross-repository dependencies use +registry versions in release candidates; a sibling checkout is never a +production dependency. diff --git a/docs/specs/radroots_crates_release_v1.dot b/docs/specs/radroots_crates_release_v1.dot @@ -0,0 +1,102 @@ +digraph radroots_crates_release_v1 { + rankdir="BT"; + graph [label="Radroots Crates Release V1", labelloc="t", fontsize=20]; + node [fontname="Helvetica"]; + "radroots_core" [shape=ellipse]; + "radroots_identity" [shape=ellipse]; + "radroots_blossom" [shape=ellipse]; + "radroots_protocol" [shape=component]; + "radroots_event" [shape=box]; + "radroots_event_codec" [shape=box]; + "radroots_trade" [shape=box]; + "radroots_signing" [shape=hexagon]; + "radroots_transport" [shape=hexagon]; + "radroots_nostr" [shape=component]; + "radroots_nostr_connect" [shape=component]; + "radroots_secrets" [shape=hexagon]; + "radroots_storage" [shape=hexagon]; + "radroots_storage_sqlite" [shape=cylinder]; + "radroots_transport_nostr" [shape=component]; + "radroots_sync" [shape=box3d]; + "radroots_geonames" [shape=component]; + "radroots_sdk" [shape=doubleoctagon]; + "radroots" [shape=doubleoctagon]; + subgraph cluster_lib { + label="radrootslabs/lib (packages 1-17)"; + "radroots_core"; "radroots_identity"; "radroots_blossom"; + "radroots_protocol"; "radroots_event"; "radroots_event_codec"; + "radroots_trade"; "radroots_signing"; "radroots_transport"; + "radroots_nostr"; "radroots_nostr_connect"; "radroots_secrets"; + "radroots_storage"; "radroots_storage_sqlite"; + "radroots_transport_nostr"; "radroots_sync"; "radroots_geonames"; + } + subgraph cluster_sdk { + label="radrootslabs/sdk (packages 18-19)"; + "radroots_sdk"; "radroots"; + } + "radroots_event" -> "radroots_blossom" [style=solid, label="required"]; + "radroots_event" -> "radroots_core" [style=solid, label="required"]; + "radroots_event" -> "radroots_identity" [style=solid, label="required"]; + "radroots_event" -> "radroots_protocol" [style=solid, label="required"]; + "radroots_event_codec" -> "radroots_blossom" [style=solid, label="required"]; + "radroots_event_codec" -> "radroots_event" [style=solid, label="required"]; + "radroots_event_codec" -> "radroots_protocol" [style=solid, label="required"]; + "radroots_trade" -> "radroots_core" [style=solid, label="required"]; + "radroots_trade" -> "radroots_event" [style=solid, label="required"]; + "radroots_trade" -> "radroots_identity" [style=solid, label="required"]; + "radroots_signing" -> "radroots_event" [style=solid, label="required"]; + "radroots_signing" -> "radroots_identity" [style=solid, label="required"]; + "radroots_signing" -> "radroots_protocol" [style=solid, label="required"]; + "radroots_transport" -> "radroots_event" [style=solid, label="required"]; + "radroots_transport" -> "radroots_identity" [style=solid, label="required"]; + "radroots_transport" -> "radroots_protocol" [style=solid, label="required"]; + "radroots_nostr" -> "radroots_blossom" [style=dashed, label="optional"]; + "radroots_nostr" -> "radroots_signing" [style=dashed, label="optional"]; + "radroots_nostr" -> "radroots_event" [style=solid, label="required"]; + "radroots_nostr" -> "radroots_event_codec" [style=solid, label="required"]; + "radroots_nostr" -> "radroots_identity" [style=solid, label="required"]; + "radroots_nostr_connect" -> "radroots_event" [style=solid, label="required"]; + "radroots_nostr_connect" -> "radroots_identity" [style=solid, label="required"]; + "radroots_nostr_connect" -> "radroots_nostr" [style=solid, label="required"]; + "radroots_nostr_connect" -> "radroots_protocol" [style=solid, label="required"]; + "radroots_storage" -> "radroots_event" [style=solid, label="required"]; + "radroots_storage" -> "radroots_protocol" [style=solid, label="required"]; + "radroots_storage" -> "radroots_trade" [style=solid, label="required"]; + "radroots_storage" -> "radroots_transport" [style=solid, label="required"]; + "radroots_storage_sqlite" -> "radroots_event_codec" [style=solid, label="required"]; + "radroots_storage_sqlite" -> "radroots_secrets" [style=solid, label="required"]; + "radroots_storage_sqlite" -> "radroots_storage" [style=solid, label="required"]; + "radroots_transport_nostr" -> "radroots_event_codec" [style=solid, label="required"]; + "radroots_transport_nostr" -> "radroots_nostr" [style=solid, label="required"]; + "radroots_transport_nostr" -> "radroots_protocol" [style=solid, label="required"]; + "radroots_transport_nostr" -> "radroots_transport" [style=solid, label="required"]; + "radroots_sync" -> "radroots_event" [style=solid, label="required"]; + "radroots_sync" -> "radroots_event_codec" [style=solid, label="required"]; + "radroots_sync" -> "radroots_protocol" [style=solid, label="required"]; + "radroots_sync" -> "radroots_signing" [style=solid, label="required"]; + "radroots_sync" -> "radroots_storage" [style=solid, label="required"]; + "radroots_sync" -> "radroots_trade" [style=solid, label="required"]; + "radroots_sync" -> "radroots_transport" [style=solid, label="required"]; + "radroots_sdk" -> "radroots_geonames" [style=dashed, label="optional"]; + "radroots_sdk" -> "radroots_nostr" [style=dashed, label="optional"]; + "radroots_sdk" -> "radroots_nostr_connect" [style=dashed, label="optional"]; + "radroots_sdk" -> "radroots_secrets" [style=dashed, label="optional"]; + "radroots_sdk" -> "radroots_storage_sqlite" [style=dashed, label="optional"]; + "radroots_sdk" -> "radroots_sync" [style=dashed, label="optional"]; + "radroots_sdk" -> "radroots_transport_nostr" [style=dashed, label="optional"]; + "radroots_sdk" -> "radroots_core" [style=solid, label="required"]; + "radroots_sdk" -> "radroots_event" [style=solid, label="required"]; + "radroots_sdk" -> "radroots_event_codec" [style=solid, label="required"]; + "radroots_sdk" -> "radroots_identity" [style=solid, label="required"]; + "radroots_sdk" -> "radroots_protocol" [style=solid, label="required"]; + "radroots_sdk" -> "radroots_signing" [style=solid, label="required"]; + "radroots_sdk" -> "radroots_storage" [style=solid, label="required"]; + "radroots_sdk" -> "radroots_trade" [style=solid, label="required"]; + "radroots_sdk" -> "radroots_transport" [style=solid, label="required"]; + "radroots" -> "radroots_core" [style=solid, label="required"]; + "radroots" -> "radroots_event" [style=solid, label="required"]; + "radroots" -> "radroots_identity" [style=solid, label="required"]; + "radroots" -> "radroots_sdk" [style=solid, label="required"]; + "radroots" -> "radroots_trade" [style=solid, label="required"]; + "radroots" -> "radroots_transport" [style=solid, label="required"]; +} diff --git a/docs/specs/radroots_crates_release_v1.md b/docs/specs/radroots_crates_release_v1.md @@ -0,0 +1,1589 @@ +# Radroots Crates Release V1 + +**Normative identifier:** `radroots.crates.release.v1` +**Document status:** Final pressure-tested architecture and publication specification +**Date:** 2026-07-26 +**Coverage amendment:** 2026-07-28 temporary heavy-development baseline +**Source snapshots reviewed:** +- `radrootslabs/lib@466f3cc36739179bc17edb9db796530729ba5219` +- `radrootslabs/sdk@fd8384aee348034e0c8ea17a868fe7f094770050` + +**Repository allocation:** the existing `radrootslabs/lib` and +`radrootslabs/sdk` repositories remain independent. The first 17 packages are +owned by `lib`; `radroots_sdk` and `radroots` are owned by `sdk`. No third Rust +repository is created for release V1. + +**Registry context supplied by the project:** no Radroots crate is currently published on crates.io. Deleted experimental publications create no compatibility, pluralization, deprecation, or version-continuity requirement. + +## 1. Normative language and status + +The words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are normative. + +This specification freezes the **package identities, ownership boundaries, naming model, dependency direction, and release gates** for the first durable Radroots crates.io release. + +It does not claim that the current source tree is already publishable. Publication remains blocked until the refactor is implemented and every acceptance gate in this document passes against packaged artifacts. + +## 2. Final executive decision + +Radroots SHALL publish exactly **19 durable package identities** for release V1: + +1. `radroots_core` +2. `radroots_identity` +3. `radroots_blossom` +4. `radroots_protocol` +5. `radroots_event` +6. `radroots_event_codec` +7. `radroots_trade` +8. `radroots_signing` +9. `radroots_transport` +10. `radroots_nostr` +11. `radroots_nostr_connect` +12. `radroots_secrets` +13. `radroots_storage` +14. `radroots_storage_sqlite` +15. `radroots_transport_nostr` +16. `radroots_sync` +17. `radroots_geonames` +18. `radroots_sdk` +19. `radroots` + +This is neither the current workspace publication list nor a collapse into `radroots_sdk`. + +The package family is implemented across the two existing standalone +repositories. `radrootslabs/lib` owns packages 1-17, and `radrootslabs/sdk` +owns packages 18-19. Cross-repository dependencies resolve through registry +versions; neither repository depends on a sibling checkout. + +The architecture is a layered network/protocol stack: + +```text +portable values / identity / protocol contracts + ↓ +event model and deterministic domain algorithms + ↓ +signing, transport, secrets, and storage SPIs + ↓ +concrete native backends and network adapters + ↓ +local-first synchronization engine + ↓ +advanced SDK + ↓ +ordinary-user façade +``` + +## 3. Final pressure-test changes from the directionally approved draft + +The final review makes the following deliberate changes: + +1. **`radroots_contracts` becomes `radroots_protocol`.** The package is a durable, versioned wire/operation protocol boundary rather than a general-purpose “contracts” bucket. +2. **`radroots_store` and `radroots_store_sqlite` become `radroots_storage` and `radroots_storage_sqlite`.** “Storage” is unambiguous in an agricultural marketplace and describes the package family more accurately than “store.” +3. **`radroots_nostr_connect` remains independent.** It is a bidirectional security protocol with URIs, permissions, client/server state, and independent SDK/Myc consumers. It is not merely a convenience NIP module. +4. **Actor ownership is refined.** Public keys/accounts live in identity; event author roles live in the event contract model; actor provenance, authorization, and signer behavior live in signing. +5. **Trade identity is made singular.** The conflicting `TradeId`/`OrderId` definitions MUST be replaced by one canonical protocol `TradeId` and a separately named business `OrderId`. +6. **Public codegen features are removed.** `dto-bindgen`, binding generation, WASM wrappers, and fixture switches remain private build/test concerns. +7. **The workspace moves to Cargo resolver 3.** A virtual Rust 2024 workspace MUST explicitly set `resolver = "3"`. +8. **Release V1 uses MSRV 1.97.1.** The patch release is selected rather than 1.97.0 because it contains a compiler miscompilation fix. +9. **Lower crates do not remain permanently lockstep.** The current + `radrootslabs/lib` development cohort is frozen at `0.1.0-alpha`, while + `radroots` and `radroots_sdk` remain an exact `0.1.0` lockstep pair. Lower + packages may follow independent SemVer only after explicit future authority + ends the temporary library version freeze. + +## 4. Non-negotiable architecture invariants + +1. Every public package MUST have a durable name and at least one named direct consumer besides `radroots`. +2. Every dependency edge MUST point downward in the architecture. +3. Domain and protocol packages MUST NOT depend on storage, networking, process lifecycle, or host UI. +4. Generic SPIs MUST NOT expose concrete SQLite, Tokio, Reqwest, Nostr SDK, keyring, or OS-specific types. +5. Adapters and backends MUST implement SPIs; SPIs MUST NOT depend on adapters or backends. +6. Synchronization MUST orchestrate sources, sinks, signing, and storage without owning an executor or scheduler. +7. `radroots_sdk` MUST compose lower packages and own client-level commit semantics; it MUST NOT become a dumping ground for code that has an independent durable boundary. +8. `radroots` MUST provide meaningful curation and documentation; it MUST NOT be `pub use radroots_sdk::*`. +9. Version generations MUST live in modules and schema IDs, never in package names. +10. Preview implementation code MAY remain in the monorepo but MUST NOT appear in a published dependency or feature until registry-ready. +11. No public package may have a normal, optional, build, or target-specific dependency on a private Radroots package. +12. No first-party consumer may rely on production sibling paths after cutover. + +## 5. Final public package inventory + +### 5.1 Repository ownership + +- `https://github.com/radrootslabs/lib` owns `radroots_core` through + `radroots_geonames` (packages 1-17). +- `https://github.com/radrootslabs/sdk` owns `radroots_sdk` and `radroots` + (packages 18-19). +- Both repositories retain their existing histories and remain independently + buildable, testable, packageable, and releasable. +- The two repositories carry synchronized copies of this release-family + contract. A coordinated release MUST reject any content-hash or package + allocation mismatch between those copies. + +| Order | Package | Rust crate path | Tier | Permanent responsibility | +|---:|---|---|---|---| +| 1 | `radroots_core` | `radroots_core` | foundation | Foundational value objects and deterministic invariants: decimal, currency, money, percentage, quantity, units, and pricing. | +| 2 | `radroots_identity` | `radroots_identity` | foundation | Public identity and account value types: canonical public keys, identity IDs, account IDs, public profiles, and usernames. | +| 3 | `radroots_blossom` | `radroots_blossom` | protocol primitive | Portable Blossom protocol primitives: canonical blob URLs, hashes, media descriptors, byte-verification typestates, and authorization claims. | +| 4 | `radroots_protocol` | `radroots_protocol` | versioned wire contract | Versioned cross-process and cross-language schemas, capability catalogs, operation descriptors, stable error reports, schema IDs, and structural validation. | +| 5 | `radroots_event` | `radroots_event` | domain protocol model | Canonical Radroots event-domain models, validated event identifiers, tags, event contracts, authoring drafts, signed/verified typestates, and NIP-01 wire-neutral representations. | +| 6 | `radroots_event_codec` | `radroots_event_codec` | deterministic algorithm | Deterministic canonical encoding, decoding, ID/signature verification, contract validation, admission, and manifest generation for Radroots events. | +| 7 | `radroots_trade` | `radroots_trade` | domain algorithm | Trade validation, evidence models, deterministic reduction, conflict analysis, and side-effect-free workflow plans over the canonical event trade model. | +| 8 | `radroots_signing` | `radroots_signing` | host SPI | Object-safe author/signing SPI, actor provenance, authorization checks, requests, receipts, progress, capabilities, and normalized signing errors. | +| 9 | `radroots_transport` | `radroots_transport` | network SPI | Transport-neutral target identities, capability/status models, source and sink SPIs, delivery/fetch policies, bounded requests, provenance, and normalized outcomes. | +| 10 | `radroots_nostr` | `radroots_nostr` | protocol adapter | Portable conversion between Radroots native event/identity types and Nostr protocol types, typed NIP helpers, and concrete local signing adapters; no live relay client. | +| 11 | `radroots_nostr_connect` | `radroots_nostr_connect` | security protocol | Nostr Connect/NIP-46 URIs, methods, permissions, requests, responses, client/server state machines, timeout-independent protocol validation, and normalized errors. | +| 12 | `radroots_secrets` | `radroots_secrets` | security SPI | Secret references, provider and key-wrapping SPIs, versioned encrypted envelopes, zeroization-safe secret handling, and explicit memory/file/keyring adapters. | +| 13 | `radroots_storage` | `radroots_storage` | storage SPI | Backend-neutral canonical event, operation journal, outbox, transport evidence, projection, private-artifact metadata, backup, status, and atomic commit interfaces, plus an in-memory reference backend. | +| 14 | `radroots_storage_sqlite` | `radroots_storage_sqlite` | native storage backend | SQLite implementation of the storage SPIs with schema migration, WAL, locking, integrity, backup/restore, crash recovery, and encrypted private storage. | +| 15 | `radroots_transport_nostr` | `radroots_transport_nostr` | native network adapter | Concrete Nostr EventSource/EventSink implementation: relay URL policy, connection, NIP-42 authentication, bounded fetch pages, delivery, status, and relay-outcome normalization. | +| 16 | `radroots_sync` | `radroots_sync` | local-first orchestration | Shared pull, verification, canonical admission, duplicate handling, projection refresh, outbox signing/delivery, status, and retry-decision orchestration without owning scheduling. | +| 17 | `radroots_geonames` | `radroots_geonames` | concrete data provider | GeoNames asset specification, authenticated/integrity-checked acquisition, database lifecycle, and forward/reverse locality lookup using provider-owned types. | +| 18 | `radroots_sdk` | `radroots_sdk` | advanced front door | Host-neutral asynchronous client engine, product operations, capability reporting, explicit storage/signing/transport composition, diagnostics, backup/restore, and safe commit semantics. | +| 19 | `radroots` | `radroots` | ordinary-user front door | Canonical Rust onboarding package with curated modules, safe defaults, stable convenience builders, domain aggregation, examples, and primary documentation. | + +## 6. Detailed package specifications + + ### 1. `radroots_core` + + **Rust crate path:** `radroots_core` + **Tier:** foundation + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** no_std + alloc; std feature + **Direct intended consumers:** radroots_event, radroots_trade, radroots_sdk, radroots + + **Normative responsibility.** Foundational value objects and deterministic invariants: decimal, currency, money, percentage, quantity, units, and pricing. + + **Required Radroots dependencies:** None + **Optional Radroots dependencies:** None + **Default features:** `std`, `serde` + **Complete public feature vocabulary:** `std`, `serde` + + **Public modules** + + - `currency` +- `decimal` +- `money` +- `percent` +- `pricing` +- `quantity` +- `unit` + + **Permitted root exports:** `Currency`, `Decimal`, `Money`, `Percent`, `Quantity`, `QuantityPrice`, `Unit`, `Error` + + **Explicitly forbidden.** Identifiers, identities, event kinds, networking, persistence, clocks, filesystem paths, process behavior, or application configuration. + + + ### 2. `radroots_identity` + + **Rust crate path:** `radroots_identity` + **Tier:** foundation + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** no_std + alloc; std feature + **Direct intended consumers:** radroots_event, radroots_signing, radroots_transport, radroots_nostr, radroots_nostr_connect, radroots_sdk, services + + **Normative responsibility.** Public identity and account value types: canonical public keys, identity IDs, account IDs, public profiles, and usernames. + + **Required Radroots dependencies:** None + **Optional Radroots dependencies:** None + **Default features:** `std`, `serde` + **Complete public feature vocabulary:** `std`, `serde` + + **Public modules** + + - `account` +- `key` +- `profile` +- `username` + + **Permitted root exports:** `AccountId`, `IdentityId`, `PublicIdentity`, `PublicKey`, `Profile`, `Username`, `Error` + + **Explicitly forbidden.** Secret keys, key generation, NIP-49 encryption, keyrings, files, SQLite, runtime paths, upstream nostr::Event values, signer sessions, or host account selection. + + + ### 3. `radroots_blossom` + + **Rust crate path:** `radroots_blossom` + **Tier:** protocol primitive + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** no_std + alloc; std feature + **Direct intended consumers:** radroots_event, radroots_event_codec, radroots_nostr, media clients + + **Normative responsibility.** Portable Blossom protocol primitives: canonical blob URLs, hashes, media descriptors, byte-verification typestates, and authorization claims. + + **Required Radroots dependencies:** None + **Optional Radroots dependencies:** None + **Default features:** `std`, `serde` + **Complete public feature vocabulary:** `std`, `serde` + + **Public modules** + + - `authorization` +- `descriptor` +- `hash` +- `media_type` +- `url` + + **Permitted root exports:** `BlobUrl`, `Sha256`, `MediaType`, `BlobDescriptor`, `ByteVerifiedDescriptor`, `AuthorizationClaim`, `Error` + + **Explicitly forbidden.** HTTP clients, upload scheduling, cache management, filesystem traversal, application media policy, or global authentication state. + + + ### 4. `radroots_protocol` + + **Rust crate path:** `radroots_protocol` + **Tier:** versioned wire contract + **API maturity at first publish:** durable identity; independently versioned contract modules + **Platform contract:** no_std + alloc; std feature + **Direct intended consumers:** radroots_event, radroots_signing, radroots_transport, radroots_storage, radroots_sync, radroots_sdk, radrootsd, bindings + + **Normative responsibility.** Versioned cross-process and cross-language schemas, capability catalogs, operation descriptors, stable error reports, schema IDs, and structural validation. + + **Required Radroots dependencies:** None + **Optional Radroots dependencies:** None + **Default features:** `std`, `serde` + **Complete public feature vocabulary:** `std`, `serde` + + **Public modules** + + - `capability::v1` +- `error::v1` +- `event::v1` +- `runtime::v1` +- `radrootsd::transport_publish::v5` +- `schema` + + **Permitted root exports:** No broad root exports. + + **Explicitly forbidden.** Native clients, storage, network I/O, executor/runtime ownership, domain reducers, upstream dependency types, unversioned serialized DTOs, or package names containing protocol generations. + + + ### 5. `radroots_event` + + **Rust crate path:** `radroots_event` + **Tier:** domain protocol model + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** no_std + alloc; std feature + **Direct intended consumers:** radroots_event_codec, radroots_trade, radroots_signing, radroots_transport, radroots_storage, radroots_sync, radroots_nostr, radroots_sdk, indexers + + **Normative responsibility.** Canonical Radroots event-domain models, validated event identifiers, tags, event contracts, authoring drafts, signed/verified typestates, and NIP-01 wire-neutral representations. + + **Required Radroots dependencies:** `radroots_core`, `radroots_identity`, `radroots_blossom`, `radroots_protocol` + **Optional Radroots dependencies:** None + **Default features:** `std`, `serde` + **Complete public feature vocabulary:** `std`, `serde`, `knowledge` + + **Public modules** + + - `admission` +- `calendar` +- `contract` +- `draft` +- `envelope` +- `farm` +- `food` +- `id` +- `knowledge` +- `listing` +- `media` +- `post` +- `profile` +- `social` +- `tag` +- `trade` +- `wire` + + **Permitted root exports:** `Event`, `EventDraft`, `SignedEvent`, `VerifiedEvent`, `EventId`, `EventKind`, `EventTag`, `Error` + + **Explicitly forbidden.** Live Nostr clients, relay pools, signing backends, SQLite, outbox claims, retry scheduling, application state, or duplicate trade/order identifier concepts. + + + ### 6. `radroots_event_codec` + + **Rust crate path:** `radroots_event_codec` + **Tier:** deterministic algorithm + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** no_std + alloc where selected features permit; std feature + **Direct intended consumers:** radroots_nostr, radroots_storage_sqlite, radroots_transport_nostr, radroots_sync, radroots_sdk, bindings + + **Normative responsibility.** Deterministic canonical encoding, decoding, ID/signature verification, contract validation, admission, and manifest generation for Radroots events. + + **Required Radroots dependencies:** `radroots_event`, `radroots_blossom`, `radroots_protocol` + **Optional Radroots dependencies:** None + **Default features:** `std`, `json` + **Complete public feature vocabulary:** `std`, `serde`, `json`, `knowledge`, `manifests` + + **Public modules** + + - `admission` +- `canonical` +- `decode` +- `encode` +- `manifest` +- `verify` + + **Permitted root exports:** `Codec`, `DecodeError`, `EncodeError`, `VerificationError` + + **Explicitly forbidden.** nostr-sdk clients, relay networking, persistence, background work, upstream client errors, or host configuration. + + + ### 7. `radroots_trade` + + **Rust crate path:** `radroots_trade` + **Tier:** domain algorithm + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** no_std + alloc for model/reducer; std feature + **Direct intended consumers:** radroots_storage, radroots_sync, radroots_sdk, RHI, applications + + **Normative responsibility.** Trade validation, evidence models, deterministic reduction, conflict analysis, and side-effect-free workflow plans over the canonical event trade model. + + **Required Radroots dependencies:** `radroots_core`, `radroots_identity`, `radroots_event` + **Optional Radroots dependencies:** None + **Default features:** `std`, `serde`, `json` + **Complete public feature vocabulary:** `std`, `serde`, `json` + + **Public modules** + + - `evidence` +- `model` +- `reducer` +- `validation` +- `workflow` + + **Permitted root exports:** `Projection`, `ReductionInput`, `ReducerIssue`, `WorkflowPlan`, `ValidationError`, `Error` + + **Explicitly forbidden.** A second TradeId definition, actor authorization, signers, event-store access, SQLx, filesystem state, transport delivery, outbox mutation, or process scheduling. + + + ### 8. `radroots_signing` + + **Rust crate path:** `radroots_signing` + **Tier:** host SPI + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** no_std + alloc core; std feature + **Direct intended consumers:** radroots_nostr, radroots_sync, radroots_sdk, CLI, Studio, FFI hosts + + **Normative responsibility.** Object-safe author/signing SPI, actor provenance, authorization checks, requests, receipts, progress, capabilities, and normalized signing errors. + + **Required Radroots dependencies:** `radroots_identity`, `radroots_event`, `radroots_protocol` + **Optional Radroots dependencies:** None + **Default features:** `std`, `serde` + **Complete public feature vocabulary:** `std`, `serde` + + **Public modules** + + - `actor` +- `capability` +- `error` +- `request` +- `receipt` +- `signer` +- `status` + + **Permitted root exports:** `Actor`, `Signer`, `SignRequest`, `SignReceipt`, `SignerStatus`, `Error` + + **Explicitly forbidden.** Raw secret-key ownership, keyrings, relay networking, NIP-46 session persistence, SQL, UI prompts, or executor creation. + + + ### 9. `radroots_transport` + + **Rust crate path:** `radroots_transport` + **Tier:** network SPI + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** no_std + alloc data model; std feature + **Direct intended consumers:** radroots_storage, radroots_transport_nostr, radroots_sync, radroots_sdk, services, future adapters + + **Normative responsibility.** Transport-neutral target identities, capability/status models, source and sink SPIs, delivery/fetch policies, bounded requests, provenance, and normalized outcomes. + + **Required Radroots dependencies:** `radroots_identity`, `radroots_event`, `radroots_protocol` + **Optional Radroots dependencies:** None + **Default features:** `std`, `serde` + **Complete public feature vocabulary:** `std`, `serde` + + **Public modules** + + - `capability` +- `endpoint` +- `error` +- `outcome` +- `policy` +- `sink` +- `source` +- `target` + + **Permitted root exports:** `TransportId`, `Target`, `TargetSet`, `EventSource`, `EventSink`, `DeliveryRequest`, `DeliveryReceipt`, `FetchRequest`, `FetchPage`, `Error` + + **Explicitly forbidden.** Closed enums that prevent new transports, Reticulum-specific constants, Nostr URLs at the generic root, storage/outbox access, retries, scheduler ownership, or silent fallback. + + + ### 10. `radroots_nostr` + + **Rust crate path:** `radroots_nostr` + **Tier:** protocol adapter + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** no_std + alloc for conversion surface; std feature + **Direct intended consumers:** radroots_nostr_connect, radroots_transport_nostr, radroots_sdk, Myc, radrootsd + + **Normative responsibility.** Portable conversion between Radroots native event/identity types and Nostr protocol types, typed NIP helpers, and concrete local signing adapters; no live relay client. + + **Required Radroots dependencies:** `radroots_identity`, `radroots_event`, `radroots_event_codec` + **Optional Radroots dependencies:** `radroots_signing`, `radroots_blossom` + **Default features:** `std`, `events` + **Complete public feature vocabulary:** `std`, `events`, `signing`, `nip17`, `blossom` + + **Public modules** + + - `blossom` +- `event` +- `filter` +- `key` +- `nip17` +- `signing` +- `tag` + + **Permitted root exports:** `Error` + + **Explicitly forbidden.** nostr-sdk relay pools, reqwest clients, runtime ownership, broad aliases of upstream nostr types at the root, account persistence, or outbox orchestration. + + + ### 11. `radroots_nostr_connect` + + **Rust crate path:** `radroots_nostr_connect` + **Tier:** security protocol + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** std for v1; protocol data kept portable + **Direct intended consumers:** radroots_sdk, Myc, remote signer tooling + + **Normative responsibility.** Nostr Connect/NIP-46 URIs, methods, permissions, requests, responses, client/server state machines, timeout-independent protocol validation, and normalized errors. + + **Required Radroots dependencies:** `radroots_identity`, `radroots_event`, `radroots_nostr`, `radroots_protocol` + **Optional Radroots dependencies:** None + **Default features:** `serde` + **Complete public feature vocabulary:** `serde` + + **Public modules** + + - `client` +- `error` +- `message` +- `method` +- `permission` +- `server` +- `uri` + + **Permitted root exports:** `Client`, `Server`, `Method`, `Permission`, `Request`, `Response`, `BunkerUri`, `ClientUri`, `Error` + + **Explicitly forbidden.** Relay-pool implementation, secret persistence, approval UI, global sessions, Tokio runtime ownership, or Myc-specific service storage. + + + ### 12. `radroots_secrets` + + **Rust crate path:** `radroots_secrets` + **Tier:** security SPI + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** no_std + alloc core; std adapters + **Direct intended consumers:** radroots_storage_sqlite, radroots_sdk, signing hosts, services + + **Normative responsibility.** Secret references, provider and key-wrapping SPIs, versioned encrypted envelopes, zeroization-safe secret handling, and explicit memory/file/keyring adapters. + + **Required Radroots dependencies:** None + **Optional Radroots dependencies:** None + **Default features:** `std`, `serde` + **Complete public feature vocabulary:** `std`, `serde`, `memory`, `file`, `keyring` + + **Public modules** + + - `envelope` +- `error` +- `id` +- `provider` +- `wrapping` +- `memory` +- `file` +- `keyring` + + **Permitted root exports:** `SecretId`, `SecretRef`, `SecretProvider`, `KeyWrapping`, `EncryptedEnvelope`, `Error` + + **Explicitly forbidden.** Public secret bytes, Clone/Debug/Serialize for secret-bearing values, identity profiles, domain tables, arbitrary key/value storage, hidden key generation, or process-global vaults. + + + ### 13. `radroots_storage` + + **Rust crate path:** `radroots_storage` + **Tier:** storage SPI + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** std for v1 + **Direct intended consumers:** radroots_storage_sqlite, radroots_sync, radroots_sdk, indexers, tests, future backends + + **Normative responsibility.** Backend-neutral canonical event, operation journal, outbox, transport evidence, projection, private-artifact metadata, backup, status, and atomic commit interfaces, plus an in-memory reference backend. + + **Required Radroots dependencies:** `radroots_event`, `radroots_trade`, `radroots_transport`, `radroots_protocol` + **Optional Radroots dependencies:** None + **Default features:** `memory`, `serde` + **Complete public feature vocabulary:** `memory`, `serde` + + **Public modules** + + - `atomic` +- `backup` +- `event` +- `journal` +- `memory` +- `outbox` +- `private_artifact` +- `projection` +- `status` + + **Permitted root exports:** `Storage`, `EventStore`, `Journal`, `Outbox`, `ProjectionStore`, `BackupSource`, `StorageStatus`, `Error` + + **Explicitly forbidden.** SQL text, SQLx pools or transactions, filesystem paths, application UI state, concrete retry loops, Nostr clients, or unconstrained raw key/value escape hatches. + + + ### 14. `radroots_storage_sqlite` + + **Rust crate path:** `radroots_storage_sqlite` + **Tier:** native storage backend + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** std-only native backend + **Direct intended consumers:** radroots_sdk, CLI, Studio, mobile/FFI + + **Normative responsibility.** SQLite implementation of the storage SPIs with schema migration, WAL, locking, integrity, backup/restore, crash recovery, and encrypted private storage. + + **Required Radroots dependencies:** `radroots_storage`, `radroots_event_codec`, `radroots_secrets` + **Optional Radroots dependencies:** None + **Default features:** None + **Complete public feature vocabulary:** None + + **Public modules** + + - `backup` +- `config` +- `integrity` +- `lock` +- `migration` +- `open` +- `status` + + **Permitted root exports:** `SqliteStorage`, `OpenOptions`, `OpenMode`, `Paths`, `Error` + + **Explicitly forbidden.** Public SqlitePool/Connection/Transaction handles, caller-supplied arbitrary SQL, Studio state, global connection pools, runtime installation, or silent schema downgrade. + + + ### 15. `radroots_transport_nostr` + + **Rust crate path:** `radroots_transport_nostr` + **Tier:** native network adapter + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** std-only; Tokio implementation detail in v1 + **Direct intended consumers:** radroots_sdk, CLI, radrootsd, advanced hosts + + **Normative responsibility.** Concrete Nostr EventSource/EventSink implementation: relay URL policy, connection, NIP-42 authentication, bounded fetch pages, delivery, status, and relay-outcome normalization. + + **Required Radroots dependencies:** `radroots_transport`, `radroots_nostr`, `radroots_event_codec`, `radroots_protocol` + **Optional Radroots dependencies:** None + **Default features:** None + **Complete public feature vocabulary:** None + + **Public modules** + + - `auth` +- `client` +- `relay` +- `sink` +- `source` +- `status` + + **Permitted root exports:** `NostrTransport`, `Config`, `RelayUrl`, `RelayUrlPolicy`, `Error` + + **Explicitly forbidden.** Event-store ingestion, outbox claiming, retry scheduling, projection refresh, global relay clients, direct SQL, or transport fallback. + + + ### 16. `radroots_sync` + + **Rust crate path:** `radroots_sync` + **Tier:** local-first orchestration + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** std-only in v1; executor-neutral public API + **Direct intended consumers:** radroots_sdk, CLI, Studio, mobile/FFI, advanced hosts + + **Normative responsibility.** Shared pull, verification, canonical admission, duplicate handling, projection refresh, outbox signing/delivery, status, and retry-decision orchestration without owning scheduling. + + **Required Radroots dependencies:** `radroots_event`, `radroots_event_codec`, `radroots_signing`, `radroots_transport`, `radroots_storage`, `radroots_trade`, `radroots_protocol` + **Optional Radroots dependencies:** None + **Default features:** `serde` + **Complete public feature vocabulary:** `serde` + + **Public modules** + + - `ingest` +- `policy` +- `projection` +- `pull` +- `push` +- `status` + + **Permitted root exports:** `Engine`, `PullRequest`, `PullReceipt`, `PushRequest`, `PushReceipt`, `SyncStatus`, `Error` + + **Explicitly forbidden.** Creating an executor, spawning hidden workers, installing timers globally, owning process lifecycle, storing UI state, or transport-specific branches outside adapters. + + + ### 17. `radroots_geonames` + + **Rust crate path:** `radroots_geonames` + **Tier:** concrete data provider + **API maturity at first publish:** durable identity; pre-1.0 API + **Platform contract:** std-only + **Direct intended consumers:** radroots_sdk, CLI, geocoding applications + + **Normative responsibility.** GeoNames asset specification, authenticated/integrity-checked acquisition, database lifecycle, and forward/reverse locality lookup using provider-owned types. + + **Required Radroots dependencies:** None + **Optional Radroots dependencies:** None + **Default features:** None + **Complete public feature vocabulary:** None + + **Public modules** + + - `asset` +- `database` +- `download` +- `model` +- `query` + + **Permitted root exports:** `Geocoder`, `AssetSpec`, `AssetStatus`, `Query`, `Candidate`, `Point`, `Error` + + **Explicitly forbidden.** Generic multi-provider abstraction before a second provider exists, runtime-path policy, hidden downloads, SDK configuration types, SQLx/reqwest types in the public API, or test-fixture features. + + + ### 18. `radroots_sdk` + + **Rust crate path:** `radroots_sdk` + **Tier:** advanced front door + **API maturity at first publish:** durable identity; lockstep pre-1.0 API with radroots + **Platform contract:** std-only native engine + **Direct intended consumers:** CLI, Studio, FFI/mobile, advanced native applications + + **Normative responsibility.** Host-neutral asynchronous client engine, product operations, capability reporting, explicit storage/signing/transport composition, diagnostics, backup/restore, and safe commit semantics. + + **Required Radroots dependencies:** `radroots_core`, `radroots_identity`, `radroots_protocol`, `radroots_event`, `radroots_event_codec`, `radroots_trade`, `radroots_signing`, `radroots_transport`, `radroots_storage` + **Optional Radroots dependencies:** `radroots_secrets`, `radroots_storage_sqlite`, `radroots_nostr`, `radroots_nostr_connect`, `radroots_transport_nostr`, `radroots_sync`, `radroots_geonames` + **Default features:** `memory` + **Complete public feature vocabulary:** `memory`, `sqlite`, `sync`, `nostr`, `nip46`, `local-signing`, `radrootsd`, `geonames`, `knowledge`, `native`, `full` + + **Public modules** + + - `capability` +- `client` +- `diagnostics` +- `error` +- `farm` +- `listing` +- `signing` +- `storage` +- `sync` +- `trade` +- `transport` + + **Permitted root exports:** `Client`, `ClientBuilder`, `Error`, `Result` + + **Explicitly forbidden.** Global runtimes or subscribers, hidden workers, process signals, CLI parsing, UI state, Studio databases, raw SQLx/upstream client types, broad wildcard reexports, or a nominal no_std claim. + + + ### 19. `radroots` + + **Rust crate path:** `radroots` + **Tier:** ordinary-user front door + **API maturity at first publish:** durable identity; lockstep pre-1.0 API with radroots_sdk + **Platform contract:** std-only + **Direct intended consumers:** ordinary Rust applications, examples, documentation + + **Normative responsibility.** Canonical Rust onboarding package with curated modules, safe defaults, stable convenience builders, domain aggregation, examples, and primary documentation. + + **Required Radroots dependencies:** `radroots_sdk`, `radroots_core`, `radroots_identity`, `radroots_event`, `radroots_trade`, `radroots_transport` + **Optional Radroots dependencies:** None + **Default features:** `client` + **Complete public feature vocabulary:** `client`, `native`, `nostr`, `nip46`, `radrootsd`, `geonames`, `knowledge`, `full` + + **Public modules** + + - `client` +- `event` +- `farm` +- `identity` +- `knowledge` +- `listing` +- `signing` +- `storage` +- `sync` +- `trade` +- `transport` + + **Permitted root exports:** `Client`, `ClientBuilder`, `Error`, `Result` + + **Explicitly forbidden.** A public radroots::sdk namespace, wildcard reexport of radroots_sdk, duplicate engine implementation, CLI binary, hidden network/filesystem/keychain side effects, or exposure of every lower-crate symbol. + + +## 7. Normative dependency graph + +### 7.1 Direct Radroots edges + +| Dependency | Dependent | Edge | +|---|---|---| +| `radroots_blossom` | `radroots_event` | required | +| `radroots_core` | `radroots_event` | required | +| `radroots_identity` | `radroots_event` | required | +| `radroots_protocol` | `radroots_event` | required | +| `radroots_blossom` | `radroots_event_codec` | required | +| `radroots_event` | `radroots_event_codec` | required | +| `radroots_protocol` | `radroots_event_codec` | required | +| `radroots_core` | `radroots_trade` | required | +| `radroots_event` | `radroots_trade` | required | +| `radroots_identity` | `radroots_trade` | required | +| `radroots_event` | `radroots_signing` | required | +| `radroots_identity` | `radroots_signing` | required | +| `radroots_protocol` | `radroots_signing` | required | +| `radroots_event` | `radroots_transport` | required | +| `radroots_identity` | `radroots_transport` | required | +| `radroots_protocol` | `radroots_transport` | required | +| `radroots_blossom` | `radroots_nostr` | optional | +| `radroots_signing` | `radroots_nostr` | optional | +| `radroots_event` | `radroots_nostr` | required | +| `radroots_event_codec` | `radroots_nostr` | required | +| `radroots_identity` | `radroots_nostr` | required | +| `radroots_event` | `radroots_nostr_connect` | required | +| `radroots_identity` | `radroots_nostr_connect` | required | +| `radroots_nostr` | `radroots_nostr_connect` | required | +| `radroots_protocol` | `radroots_nostr_connect` | required | +| `radroots_event` | `radroots_storage` | required | +| `radroots_protocol` | `radroots_storage` | required | +| `radroots_trade` | `radroots_storage` | required | +| `radroots_transport` | `radroots_storage` | required | +| `radroots_event_codec` | `radroots_storage_sqlite` | required | +| `radroots_secrets` | `radroots_storage_sqlite` | required | +| `radroots_storage` | `radroots_storage_sqlite` | required | +| `radroots_event_codec` | `radroots_transport_nostr` | required | +| `radroots_nostr` | `radroots_transport_nostr` | required | +| `radroots_protocol` | `radroots_transport_nostr` | required | +| `radroots_transport` | `radroots_transport_nostr` | required | +| `radroots_event` | `radroots_sync` | required | +| `radroots_event_codec` | `radroots_sync` | required | +| `radroots_protocol` | `radroots_sync` | required | +| `radroots_signing` | `radroots_sync` | required | +| `radroots_storage` | `radroots_sync` | required | +| `radroots_trade` | `radroots_sync` | required | +| `radroots_transport` | `radroots_sync` | required | +| `radroots_geonames` | `radroots_sdk` | optional | +| `radroots_nostr` | `radroots_sdk` | optional | +| `radroots_nostr_connect` | `radroots_sdk` | optional | +| `radroots_secrets` | `radroots_sdk` | optional | +| `radroots_storage_sqlite` | `radroots_sdk` | optional | +| `radroots_sync` | `radroots_sdk` | optional | +| `radroots_transport_nostr` | `radroots_sdk` | optional | +| `radroots_core` | `radroots_sdk` | required | +| `radroots_event` | `radroots_sdk` | required | +| `radroots_event_codec` | `radroots_sdk` | required | +| `radroots_identity` | `radroots_sdk` | required | +| `radroots_protocol` | `radroots_sdk` | required | +| `radroots_signing` | `radroots_sdk` | required | +| `radroots_storage` | `radroots_sdk` | required | +| `radroots_trade` | `radroots_sdk` | required | +| `radroots_transport` | `radroots_sdk` | required | +| `radroots_core` | `radroots` | required | +| `radroots_event` | `radroots` | required | +| `radroots_identity` | `radroots` | required | +| `radroots_sdk` | `radroots` | required | +| `radroots_trade` | `radroots` | required | +| `radroots_transport` | `radroots` | required | + +### 7.2 Architectural graph + +```text +radroots_core radroots_identity radroots_blossom + \ | / + \ | / + +---------- radroots_protocol --------+ + | + radroots_event + / | \ + / | \ + radroots_event_codec | radroots_trade + | + +----------+-----------+ + | | | + radroots_signing radroots_transport radroots_storage + | | | + | radroots_nostr +--> radroots_storage_sqlite + | | + | radroots_nostr_connect + | | + +---- radroots_transport_nostr + | + radroots_sync + +radroots_geonames -------------------------+ + | +all selected lower packages ----------> radroots_sdk ---> radroots +``` + +This diagram is explanatory. The release tool MUST use Cargo-resolved metadata as authority. + +## 8. Type ownership and canonical paths + +| Concept | Canonical owning crate | Rule | +|---|---|---| +| Decimal, money, quantity, unit, pricing | `radroots_core` | No duplicate wrapper in SDK or trade. | +| Public key, identity ID, account ID, username | `radroots_identity` | Secret material is forbidden here. | +| Event ID, event signature, coordinate, D-tag, event kind | `radroots_event` | Store bytes/newtypes, not unvalidated Strings. | +| Event contract author role | `radroots_event::contract` | This is an event-authoring rule, not an account property. | +| Actor provenance and author context | `radroots_signing` | Combines identity with event author roles at the signing boundary. | +| Canonical protocol TradeId/CandidateId/MutationId | `radroots_event::trade` | Exactly one definition. | +| Human/business OrderId | `radroots_trade` | MUST NOT be aliased or wrapped as TradeId. | +| Runtime/wire DTO generations | `radroots_protocol` | Native packages convert at boundaries. | +| Secret references and encrypted envelopes | `radroots_secrets` | No domain-specific tables. | +| Transport ID, target, capability, outcome | `radroots_transport` | TransportId is extensible, not a closed enum. | +| Native event/outbox/journal/projection storage | `radroots_storage` | Backend-neutral interfaces only. | +| SQLite schema and connection behavior | `radroots_storage_sqlite` | SQLx remains private. | +| Relay URL and Nostr network status | `radroots_transport_nostr` | Protocol conversions remain in `radroots_nostr`. | +| Pull/push/ingest/projection orchestration | `radroots_sync` | No host scheduling. | +| Client-level requests, plans, receipts, diagnostics | `radroots_sdk` | Use lower canonical types rather than duplicate wrappers. | + +## 9. Rust API and naming law + +### 9.1 Packages, crates, modules, and types + +- Cargo package names MUST use lowercase snake case: `radroots_event_codec`. +- Rust crate paths MUST use the same lowercase snake case: `radroots_event_codec`. +- Modules MUST use singular snake-case nouns unless the concept is inherently plural. +- Types and traits MUST use `UpperCamelCase`; functions and methods MUST use `snake_case`. +- Items MUST NOT repeat their crate or module name: + - `radroots_core::Money`, not `RadrootsCoreMoney`. + - `radroots_sdk::Error`, not `RadrootsSdkError`. + - `radroots_transport::Target`, not `RadrootsTransportTarget`. +- Protocol schema IDs retain the `radroots.*` namespace. +- Generated Swift/Kotlin types MAY retain a `Radroots` prefix where the target language lacks module-level namespacing. + +### 9.2 Public surface discipline + +- Crate roots MUST be small. +- Wildcard reexports and `pub use models::*` are forbidden. +- Lower crates MUST NOT publish a broad `prelude` in V1. +- Every native public struct MUST have private fields unless it is an intentionally passive versioned DTO in `radroots_protocol`. +- Evolvable enums and reports SHOULD be `#[non_exhaustive]`. +- Semantic IDs MUST NOT implement `Deref<Target = str>`. +- IDs SHOULD store canonical bytes or validated compact representations; string encoding belongs at boundaries. +- Use `FromStr`, `TryFrom`, `AsRef`, `Display`, and explicit `into_string`/`to_hex` methods. +- No root-level aliases to upstream `nostr`, `nostr_sdk`, `sqlx`, `reqwest`, `tokio`, or keyring types. +- Public functions MUST NOT panic for untrusted input. +- Builders/plans/receipts SHOULD be `#[must_use]`. +- Constructors with more than three independent options SHOULD use builders or option structs. +- Empty request structs are forbidden when an idiomatic no-argument method conveys the same operation. + +### 9.3 Trait classification + +Every public trait MUST be marked in documentation as one of: + +1. **Host SPI** — downstream implementation is supported. +2. **Sealed extension** — downstream calls are supported; implementation is not. +3. **Internal** — not public. + +Host SPIs MUST: + +- be dyn-compatible where runtime injection is needed; +- be `Send + Sync` for the native SDK; +- return boxed futures or equivalent dyn-compatible futures; +- define cancellation and deadline behavior; +- define error normalization; +- avoid associated types that leak backend implementations; +- not expose private or third-party implementation types. + +The Rust SPI is native. Browser and generated-language transports use `radroots_protocol` DTOs and language-native interfaces rather than weakening native `Send + Sync` guarantees. + +## 10. Feature law + +1. Features MUST be additive and safe under unification. +2. Features MUST describe user-visible capabilities, not implementation assembly. +3. Optional dependencies MUST be referenced with `dep:` so implementation names do not become accidental features. +4. Default features MUST remain safe for the life of a compatible release line. +5. Enabling a feature MUST NOT itself: + - access a network; + - create files; + - read a keyring; + - generate keys; + - contact a daemon; + - install logging; + - start workers. +6. Mutually exclusive backend features are forbidden; incompatible backends belong in separate packages. +7. Public crates MUST NOT expose `dto-bindgen`, fixtures, coverage, codegen, migration-forge, or internal runtime features. +8. `std` is the only valid feature name for standard-library support. +9. Public feature removal is a breaking change. +10. `--all-features` MUST build on every declared target for which the package claims support. + +### 10.1 `radroots_sdk` + +```toml +[features] +default = ["memory"] + +# Safe, in-process storage; no files or network. +memory = ["radroots_storage/memory"] + +# Explicit native capabilities. +sqlite = ["dep:radroots_storage_sqlite"] +sync = ["dep:radroots_sync"] +nostr = [ + "sync", + "dep:radroots_nostr", + "dep:radroots_transport_nostr", +] +nip46 = [ + "nostr", + "dep:radroots_nostr_connect", +] +local-signing = [ + "dep:radroots_secrets", + "radroots_nostr/signing", +] +radrootsd = [ + "sync", + "dep:reqwest", +] +geonames = ["dep:radroots_geonames"] +knowledge = [ + "radroots_event/knowledge", + "radroots_event_codec/knowledge", +] + +native = ["sqlite", "sync", "local-signing"] +full = [ + "native", + "nostr", + "nip46", + "radrootsd", + "geonames", + "knowledge", +] +``` + +### 10.2 `radroots` + +```toml +[features] +default = ["client"] + +client = ["radroots_sdk/default"] +native = ["client", "radroots_sdk/native"] +nostr = ["client", "radroots_sdk/nostr"] +nip46 = ["nostr", "radroots_sdk/nip46"] +radrootsd = ["client", "radroots_sdk/radrootsd"] +geonames = ["client", "radroots_sdk/geonames"] +knowledge = ["client", "radroots_sdk/knowledge"] +full = ["radroots_sdk/full"] +``` + +Reticulum, mesh, Simplex, NostrDB, replica, and SP1 feature names MUST NOT appear in published V1 manifests. + +## 11. Network and transport SPI + +### 11.1 Separate source and sink contracts + +One monolithic transport trait is rejected. The final SPI provides independent contracts: + +```rust +pub trait EventSource: Send + Sync { + fn status(&self) -> BoxFuture<'_, Result<SourceStatus, Error>>; + fn fetch(&self, request: FetchRequest) + -> BoxFuture<'_, Result<FetchPage, Error>>; +} + +pub trait EventSink: Send + Sync { + fn status(&self) -> BoxFuture<'_, Result<SinkStatus, Error>>; + fn deliver(&self, request: DeliveryRequest) + -> BoxFuture<'_, Result<DeliveryReceipt, Error>>; +} +``` + +A transport MAY implement either or both. + +### 11.2 Extensible transport identity + +`TransportId` MUST be a validated newtype with built-in constants such as `NOSTR`, `RETICULUM`, `LOCAL`, and `RADROOTSD`. It MUST NOT be a closed enum that forces a breaking release for every new transport. + +### 11.3 Bounded and explicit operation semantics + +- Fetch is paginated or streaming and always bounded. +- Every request carries an operation/request ID. +- Deadlines are explicit; no adapter owns a global timeout. +- Delivery satisfaction policy is explicit. +- Partial success is represented per target. +- Retryability is data, not an implicit loop. +- Authentication challenges are explicit outcomes. +- No transport silently falls back to another. +- Provenance records source, endpoint fingerprint, observed time, and adapter. +- Adapter errors are normalized while preserving a non-secret source chain. +- Target URIs and relay URLs are validated before connection. +- SSRF-sensitive schemes and private-network policies are explicit. +- TLS verification is enabled by default and cannot be silently disabled. +- Payload, target, tag, response, and page limits are constants covered by tests. + +## 12. Storage architecture + +### 12.1 Logical ownership + +`radroots_storage` owns interfaces for: + +- canonical event admission and queries; +- operation journal; +- outbox and delivery evidence; +- projection checkpoints and invalidation; +- private-artifact metadata; +- backup/restore contracts; +- storage status and integrity; +- atomic workflow commits. + +`radroots_storage_sqlite` implements them. + +### 12.2 Native layout + +The SQLite V1 layout SHALL use: + +```text +runtime.sqlite + canonical event source + event admission/visibility + operation journal + outbox and delivery evidence + projection metadata and checkpoints + +private.sqlite + encrypted signing references + private farm coordinates + private trade artifacts + NIP-46 private session material where host policy permits + +host-owned databases + UI preferences + Studio state + application presentation caches +``` + +`studio.sqlite` is removed from the SDK. + +### 12.3 Correctness requirements + +- One runtime database permits atomic event/journal/outbox transitions. +- Cross-database workflows use explicit staged commits and recovery markers. +- Public API exposes high-level atomic operations, not raw SQL transactions. +- No public `pool()` escape hatch. +- Open modes: read-only, read-write-existing, create. +- Explicit asynchronous `close()` and shutdown status. +- Advisory/process locking is mandatory for writable file stores. +- Concurrent readers are supported; writer policy is explicit. +- Migrations are transactional and forward-only by default. +- Downgrade requires an explicit offline export/import path. +- WAL and busy timeout are configured and reported. +- Backup uses a versioned manifest, per-member hashes, path/symlink validation, and atomic finalization. +- Restore uses staging, verification, and atomic replacement. +- Crash/failure injection covers every durable commit point. +- Secret material inclusion in backup is explicit and policy-controlled. + +## 13. Identity, signing, and secret boundaries + +### 13.1 Identity + +`radroots_identity` contains no private key and no upstream Nostr event object. `PublicKey` is a validated canonical Radroots author key. NIP-19/npub conversion lives in `radroots_nostr`. + +### 13.2 Signing + +`radroots_signing::Signer` signs a frozen canonical `EventDraft`. The signing layer: + +- authorizes actor role and expected public key before invoking a signer; +- verifies the signer result matches the exact draft; +- supports local and remote implementations; +- exposes capability and progress data; +- defines cancellation before and after remote request publication; +- never logs or serializes private material. + +Concrete local Nostr signing lives in `radroots_nostr`; NIP-46 protocol state lives in `radroots_nostr_connect`; host composition lives in `radroots_sdk`. + +### 13.3 Secrets + +Secret-bearing values: + +- MUST NOT implement ordinary `Debug`; +- MUST NOT implement `Serialize`; +- MUST NOT implement `Clone` unless the clone is a reference/handle; +- MUST zeroize owned plaintext where technically possible; +- MUST expose only redacted diagnostics; +- MUST use typed `SecretRef` handles across storage boundaries. + +## 14. Event and trade model corrections + +1. `radroots_event` remains the canonical owner of event-bound identifiers and trade wire identities. +2. `radroots_trade` MUST delete its conflicting `TradeId(OrderId)` definition. +3. `TradeId` and `OrderId` MUST remain semantically distinct. +4. `radroots_trade` consumes canonical event trade models and owns reducers, evidence, validation, and workflow plans. +5. Trade MUST NOT depend on authority, storage, SQLx, Nostr clients, or transports. +6. Event codec MUST own wire conversion; trade reducers operate on validated native inputs. +7. Typed authoring policy MUST reject reserved event kinds before any signer is consulted. +8. Native event typestates distinguish raw, ID-verified, signature-verified, contract-validated, admitted, and visible events. + +## 15. Error model + +Each crate owns a native `Error` with preserved sources. `radroots_protocol::error::v1::ErrorReport` is the serialized boundary. + +One generated authority MUST define: + +- stable code; +- class; +- retryability; +- recovery actions; +- capability ID; +- operation ID; +- safe structured details; +- redaction behavior. + +Hand-maintained duplicate match tables and a separate unsynchronized catalog are forbidden. + +Third-party errors MUST NOT appear as public variants. Sensitive source messages MUST be redacted before entering a protocol report or tracing field. + +## 16. SDK and façade rules + +### 16.1 `radroots_sdk` + +- `Client` is `Clone + Send + Sync`. +- The host owns the executor and scheduling. +- The SDK starts no unbounded or hidden worker. +- Background processing requires an explicit returned worker/driver handle. +- Dropping a future before/after commit has documented effects. +- `Client::close()` is explicit and asynchronous where storage is active. +- Product writes retain prepare → authorize/sign → durable enqueue → optional deliver semantics. +- Lower canonical types are reused rather than copied into `Sdk*` wrappers. +- No `RadrootsSdk*` prefixes inside the crate. +- Root exports are only `Client`, `ClientBuilder`, `Error`, and `Result`. + +### 16.2 `radroots` + +- Primary documentation and examples use `radroots`. +- The façade adds curated modules, convenience constructors, safe defaults, and domain aggregation. +- Advanced hosts use `radroots_sdk` directly. +- There is no `radroots::sdk` public namespace. +- The façade does not expose implementation crates accidentally. + +## 17. Public code generation and cross-language policy + +- Rust binding, WASM, UniFFI, Swift, Kotlin, TypeScript, and codegen crates remain `publish = false`. +- Public runtime crates have no codegen feature or codegen dependency. +- Versioned language DTOs derive from `radroots_protocol`. +- Deterministic event algorithms derive from `radroots_event_codec`. +- Generated artifacts are checked in or generated reproducibly and carry source hashes. +- Rust native module structure is not mechanically mirrored into other languages. +- Language runtimes own networking, scheduling, keychain prompts, and UI lifecycle where appropriate. + +## 18. Private and deferred packages + +The following remain private in release V1: + +```text +replica schema/store/sync family +Reticulum adapter +mesh protocol/agent/client family +SimpleX protocol/crypto/store/runtime family +NostrDB adapter +SP1 guest/host +runtime paths/manager/distribution helpers +radrootsd SDK adapter implementation +FFI, binding, WASM, and generated-package build crates +fixtures, conformance runners, fuzz targets, and xtask +``` + +Private preview code remains tested. It may become public only after passing the new-package admission rule. + +## 19. New-package admission rule + +After release V1, a new `radroots_*` package requires an ADR proving: + +1. a durable domain/protocol/SPI/backend boundary; +2. at least two meaningful direct consumers, or one unavoidable platform/backend isolation boundary; +3. an independently supportable SemVer surface; +4. a publishable resolved dependency closure; +5. a name expected to survive five years; +6. why a module or feature is insufficient; +7. documentation, conformance tests, ownership, security review, and release automation. + +Names containing `common`, `utils`, `types`, `models`, `preview`, `unstable`, `v1`, `v2`, `manager`, or a second `core` are presumptively rejected. + +## 20. Current-to-target migration map + +| Current package/family | Final owner | Required action | +|---|---|---| +| `radroots_core` | `radroots_core` | Retain the snake-case package name and remove RadrootsCore type prefixes. | +| `radroots_identity` | `radroots_identity + radroots_signing + radroots_secrets + radroots_storage` | Keep only public identity/account concepts in identity; move secrets, signers, and persistence. | +| `radroots_blossom` | `radroots_blossom` | Retain portable protocol primitives. | +| `radroots_protocol_contract_v1` | `radroots_protocol::event::v1 / capability::v1` | Retired, non-publishable compatibility package after merge; final removal at Step 270 after the CLI cutover. | +| `radroots_runtime_contract_v1` | `radroots_protocol::runtime::v1` | Retired, non-publishable SDK compatibility package after merge; final removal at Step 270 after the CLI cutover. | +| `radroots_transport_publish_protocol` | `radroots_protocol::radrootsd::transport_publish::v5` | Retired, non-publishable compatibility package after merge; final removal at Step 286 after the radrootsd cutover. | +| `radroots_event` | `radroots_event` | Retain singular package; narrow to canonical event-domain model. | +| `radroots_event_codec` | `radroots_event_codec` | Retain; remove live Nostr/upstream client responsibilities. | +| `radroots_event_index` | `radroots_storage::projection/index` | Merge; current checkpoint/manifest model is not an independent indexing engine. | +| `radroots_trade` | `radroots_trade` | Retain algorithms; remove authority, storage, SQL, transport, and duplicate TradeId. | +| `radroots_authority` | `radroots_identity + radroots_event::contract + radroots_signing` | Split account/public-key ownership, author-role contracts, and signing/authorization SPI. | +| `radroots_transport` | `radroots_transport` | Retain and redesign as extensible source/sink SPI. | +| `radroots_transport_nostr` | `radroots_transport_nostr` | Retain adapter; remove storage and sync orchestration. | +| `radroots_transport_reticulum` | `private preview` | Withhold until a real adapter passes the transport conformance suite. | +| `radroots_nostr` | `radroots_nostr` | Retain protocol conversion; remove live relay client and broad upstream aliases. | +| `radroots_nostr_connect` | `radroots_nostr_connect` | Retain as independent bidirectional NIP-46 protocol boundary. | +| `radroots_nostr_accounts` | `radroots_identity + radroots_secrets + radroots_storage + radroots_sdk` | Split mixed account, vault, persistence, and manager responsibilities. | +| `radroots_nostr_signer` | `radroots_signing + radroots_nostr_connect + Myc-private state` | Do not publish current service-state package. | +| `radroots_nostr_runtime` | `radroots_transport_nostr + radroots_sync` | Merge live relay runtime into adapter/orchestration layers. | +| `radroots_nostrdb` | `private; possible future radroots_storage_nostrdb` | Withhold until the storage SPI and external consumers justify a backend package. | +| `radroots_event_store` | `radroots_storage + radroots_storage_sqlite` | Split backend-neutral contracts from SQLite implementation. | +| `radroots_outbox` | `radroots_storage + radroots_storage_sqlite` | Merge as one persistence capability with atomic operation commits. | +| `radroots_runtime_store` | `radroots_storage or host-private state` | Retire broad name and classify each table by owner. | +| `radroots_sql_core` | `radroots_storage_sqlite private internals` | Remove raw SQL/JSON executor from public API. | +| `radroots_secret_vault` | `radroots_secrets` | Merge provider/wrapping SPI. | +| `radroots_protected_store` | `radroots_secrets` | Merge encrypted-envelope semantics. | +| `radroots_geocoder` | `radroots_geonames` | Rename to the actual concrete provider. | +| `radroots_runtime` | `radroots_sync + radroots_storage + host-private tooling` | Dismantle mixed config/signals/logging/queue/transport package. | +| `radroots_log` | `no replacement package` | Libraries emit tracing; hosts install subscribers. | +| `radroots_net` | `radroots_transport + radroots_sync + radroots_sdk` | Retire broad duplicated network/runtime package. | +| `radroots_runtime_paths` | `private host utility` | Do not place host path policy in the SDK registry closure. | +| `radroots_runtime_manager` | `private host tooling` | Runtime installation and process lifecycle remain host-owned. | +| `radroots_runtime_distribution` | `private host tooling` | Artifact distribution is not a public SDK dependency. | +| `radroots_replica_*` | `private/deferred` | Preserve and redesign; no public names until generated CRUD/raw SQL surfaces are replaced. | +| `radroots_mesh_*` | `private preview` | Preserve; publish only a real adapter or protocol with external consumers. | +| `radroots_simplex_*` | `private preview` | Preserve internal decomposition; no crates.io commitment in release v1. | +| `radroots_trade_sp1_*` | `private build/preview` | Keep specialized guest/host packages private. | +| `binding, WASM, FFI, codegen, fixtures, xtask` | `private build/test packages` | Publish generated language artifacts, not Rust build machinery. | + +## 21. Workspace and manifest policy + +```toml +[workspace] +resolver = "3" + +[workspace.package] +edition = "2024" +rust-version = "1.97.1" +license = "MIT OR Apache-2.0" +homepage = "https://radroots.org" +``` + +Each standalone workspace sets its own repository metadata: + +```toml +# radrootslabs/lib +[workspace.package] +repository = "https://github.com/radrootslabs/lib" +``` + +```toml +# radrootslabs/sdk +[workspace.package] +repository = "https://github.com/radrootslabs/sdk" +``` + +Every public package MUST define: + +```toml +[package] +name = "radroots_..." +version = "<repository-governed exact version>" +publish = ["crates-io"] +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +readme = "README.md" +documentation = "https://docs.rs/radroots_..." +``` + +Additional rules: + +- Use an explicit `include` whitelist. +- Include both license files. +- Every same-repository public dependency uses `path + version`. +- Every dependency from `radrootslabs/sdk` to a package owned by + `radrootslabs/lib` uses a registry version and MUST NOT use a sibling path or + Git override in a release candidate. +- No Git dependency exists in a published normal/build/target/optional closure. +- Public packages avoid build scripts; generated source is checked and freshness-tested. +- Workspace lints forbid unsafe code by default. +- Public API crates deny broken rustdoc links. +- Package metadata lists only accurate keywords/categories. +- docs.rs metadata selects an intentional feature set instead of blindly enabling platform-incompatible features. + +## 22. Versioning and release policy + +### 22.1 Initial versions + +Every Rust crate in `radrootslabs/lib`, including private build, test, preview, +and support crates, is pinned to exactly `0.1.0-alpha`. Every same-repository +dependency requirement is pinned to exactly `=0.1.0-alpha`. This temporary +cohort is frozen until an explicit future authority changes it; neither normal +development nor release preparation may bump it implicitly. + +Every Rust crate in `radrootslabs/sdk`, including bindings, WASM wrappers, +runtime contracts, build tools, `radroots_sdk`, and `radroots`, is likewise +pinned to exactly `0.1.0-alpha`; every internal Radroots dependency uses the +exact `=0.1.0-alpha` requirement. Cross-repository dependencies use that same +frozen cohort without creating a path dependency between the standalone +repositories. This document's “V1” is the architecture specification version, +not a claim that Rust APIs are already 1.0-stable. + +Cargo package versions are independent from versioned wire, conformance, +database, and operation contracts. Changing the library crate cohort MUST NOT +rewrite authenticated historical protocol artifacts or derive Cargo SemVer +from a protocol contract version. + +### 22.2 SemVer groups + +- `radroots` and `radroots_sdk` release in lockstep and `radroots` uses `=X.Y.Z` for the SDK. +- Lower packages version independently after the first release. +- Lower dependencies use ordinary compatible requirements at the actual minimum supported version. +- Exact requirements are reserved for genuinely inseparable package pairs. +- Wire/event/runtime/storage/backup/generated-schema versions are independent of Cargo package versions. +- Every package gets its own changelog section and public API baseline. +- The repository maintains a tested compatibility manifest for the current SDK release. + +### 22.3 Breaking changes before 1.0 + +For `0.y.z` packages: + +- breaking API changes increment `y`; +- compatible fixes/features increment `z`; +- package names and responsibility charters remain permanent; +- moving a public type between packages is breaking and requires an ADR; +- first-party consumers migrate in the same coordinated cutover. + +## 23. Indicative publication order + +The actual order is computed from `cargo metadata`; the expected order is: + +```text +radroots_core +radroots_identity +radroots_blossom +radroots_protocol +radroots_secrets +radroots_geonames +radroots_event +radroots_event_codec +radroots_trade +radroots_signing +radroots_transport +radroots_nostr +radroots_nostr_connect +radroots_storage +radroots_storage_sqlite +radroots_transport_nostr +radroots_sync +radroots_sdk +radroots +``` + +Actual publication waits for each dependency to appear in the crates.io index before publishing dependents. + +## 24. Required CI and release gates + +### 24.1 Workspace + +- `cargo fmt --all -- --check` +- `cargo check --workspace --all-targets` +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- `cargo test --workspace --all-targets` +- `cargo doc --workspace --no-deps` +- doctests +- generated-output freshness +- protocol/contract/conformance validation +- forbidden identifier and dependency checks + +### 24.2 Feature/target matrix + +For every public package: + +- no default features; +- default features; +- each public feature independently; +- supported feature bundles; +- all features; +- MSRV 1.97.1; +- current stable; +- Linux, macOS, Windows; +- declared no_std target; +- `wasm32-unknown-unknown` where supported; +- native package targets; +- minimal and latest compatible dependencies. + +### 24.3 Public API + +- `cargo-semver-checks`; +- public API baseline; +- rustdoc with warnings denied; +- no undocumented public item exceptions without review; +- examples compile from packaged artifacts; +- no duplicate canonical type paths except deliberate façade reexports; +- no third-party type leakage in generic packages. + +### 24.4 Reliability and security + +- fuzz event, protocol, NIP-46, URL, manifest, backup, and restore parsers; +- malformed and oversized network payloads; +- cancellation before and after commit; +- idempotency replay and conflict; +- outbox claim expiry; +- partial delivery and retry; +- signer timeout/wrong response/auth challenge; +- storage multi-reader/writer/locking; +- migration and corruption failure; +- crash recovery at every commit point; +- backup/restore interruption, traversal, symlink, and hash mismatch; +- projection invalidation/rebuild; +- secret redaction; +- dependency audit, license policy, provenance, SBOM, and advisory checks. + +### 24.5 Coverage policy + +During the active heavy-development refactor, every required workspace crate +owned by `radrootslabs/lib` MUST maintain at least 90% executable-line, +function, region, and branch coverage. Branch measurement remains required +except for a machine-recorded temporary exception where the coverage tool +emits no branch records. Crate-specific numeric thresholds below 90% are +forbidden. The +machine-readable authority for the active gate is `contracts/coverage.toml` in +`radrootslabs/lib`. + +This amendment does not alter the independently governed `radrootslabs/sdk` +coverage contract. The 90% baseline is temporary development policy, not +permission to remove meaningful tests or weaken package-specific conformance +requirements. The 100% threshold is deferred until this refactor stabilizes +and may be reinstated only through an explicit future contract and +specification update. + +### 24.6 Package-realistic release validation + +For every public package: + +1. Resolve the graph with `cargo metadata`. +2. Reject all public-to-private edges across normal, optional, build, target, and reachable-feature dependencies. +3. Run `cargo package --locked`. +4. Inspect the normalized manifest. +5. Inspect `cargo package --list`. +6. Extract the `.crate`. +7. Build, test, and document the extracted package. +8. Run `cargo publish --dry-run --locked`. +9. Publish to an ephemeral/local registry. +10. Build clean external projects against registry artifacts. +11. Build CLI, Studio, FFI/mobile, web packages, services, and indexers against package artifacts. +12. Confirm no sibling path or Git override remains. + +## 25. Source-evidence record + +| Repository | Reviewed SHA | Source | Pressure-test finding | +|---|---|---|---| +| `radrootslabs/lib` | `466f3cc36739179bc17edb9db796530729ba5219` | `Cargo.toml` | Workspace currently contains 49+ packages, uses edition 2024 with resolver 2, and centralizes underscore-named path dependencies. | +| `radrootslabs/lib` | `466f3cc36739179bc17edb9db796530729ba5219` | `contracts/releases/publish_policy.toml` | Current public classification contains broad runtime/storage/tooling crates while SDK-required packages remain internal. | +| `radrootslabs/sdk` | `fd8384aee348034e0c8ea17a868fe7f094770050` | `crates/sdk/Cargo.toml` | SDK feature graph names implementation assembly and directly references private authority, event-store, outbox, transport, and adapter crates. | +| `radrootslabs/lib` | `466f3cc36739179bc17edb9db796530729ba5219` | `crates/authority/src/{actor,authorization,signer}.rs` | Current authority package combines account provenance, event contract roles, signing SPI, authorization, and concrete local signing. | +| `radrootslabs/lib` | `466f3cc36739179bc17edb9db796530729ba5219` | `crates/event/src/ids.rs` | Semantic identifiers are stored as Strings, implement Deref<str>, and include trade/account/network concepts in one module. | +| `radrootslabs/lib` | `466f3cc36739179bc17edb9db796530729ba5219` | `crates/trade/src/identity.rs` | A second TradeId wraps OrderId, conflicting with the canonical event TradeId concept. | +| `radrootslabs/lib` | `466f3cc36739179bc17edb9db796530729ba5219` | `crates/identity/src/identity.rs` | Identity owns raw secret keys, upstream Nostr event values, file formats, generation, and secret export methods. | +| `radrootslabs/lib` | `466f3cc36739179bc17edb9db796530729ba5219` | `crates/transport/src/{kind,transport}.rs` | TransportKind is a closed enum and one monolithic trait requires both fetch and deliver. | +| `radrootslabs/lib` | `466f3cc36739179bc17edb9db796530729ba5219` | `crates/transport_nostr/{Cargo.toml,src/lib.rs}` | Nostr adapter currently couples relay transport to event-store and outbox persistence. | +| `radrootslabs/lib` | `466f3cc36739179bc17edb9db796530729ba5219` | `crates/event_store/src/store.rs and crates/outbox/src/store.rs` | Concrete stores expose SQLx pools/transactions and split related runtime state across separately opened stores. | +| `radrootslabs/lib` | `466f3cc36739179bc17edb9db796530729ba5219` | `crates/runtime/src/lib.rs and crates/log/src/init.rs` | Runtime package combines host concerns; logging installs process-global subscriber state. | +| `radrootslabs/sdk` | `fd8384aee348034e0c8ea17a868fe7f094770050` | `crates/sdk/src/{lib,runtime,studio_store,error}.rs` | SDK root broadly reexports implementation-shaped types, owns studio.sqlite, exposes many public fields, and duplicates error metadata. | + +## 26. Rejected alternatives + +### Publish the current public list + +Rejected because it permanently exposes host tooling and still omits SDK-required private dependencies. + +### Publish every current dependency unchanged + +Rejected because temporary implementation boundaries would become permanent package identities. + +### Collapse lower crates into `radroots_sdk` + +Rejected because domain, protocol, SPI, backend, and adapter packages have independent consumers and semver responsibilities. + +### One `radroots_runtime` package + +Rejected because runtime configuration, process lifecycle, paths, logging, queues, storage, and networking are not one coherent library boundary. + +### Separate packages for every domain or NIP + +Rejected because it creates package proliferation. Only independently substantial protocols with direct consumers—such as Nostr Connect—receive a package. + +### Versioned package names + +Rejected. Versions belong in modules and schema IDs. + +### Public preview placeholder packages + +Rejected. Preview code remains private until the implementation is real and conformance-tested. + +### Public codegen features + +Rejected. Code generation is a private build concern and must not enlarge the runtime registry closure. + +## 27. Cutover sequence + +1. Freeze publication and record this specification as an ADR. +2. Preserve the independent `radrootslabs/lib` and `radrootslabs/sdk` + histories; record the 17/2 package allocation and synchronized contract + hashes without forming or importing a third repository. +3. Switch workspace to resolver 3 and MSRV 1.97.1. +4. Create final snake-case package manifests with `publish = false` during migration. +5. Refactor identity/public-key ownership and remove all secret material. +6. Split authority among identity, event contracts, and signing. +7. Remove the duplicate TradeId and make trade algorithm-only. +8. Create `radroots_protocol`. +9. Create `radroots_secrets`. +10. Create `radroots_storage` and `radroots_storage_sqlite`; migrate event/outbox/journal/private storage. +11. Remove Studio state from SDK storage. +12. Redesign `radroots_transport`; separate source/sink. +13. Narrow `radroots_nostr` and `radroots_transport_nostr`. +14. Refactor and retain `radroots_nostr_connect`. +15. Create `radroots_sync`. +16. Rename/refocus GeoNames. +17. Refactor SDK root, features, errors, lifecycle, and commit semantics. +18. Add the curated `radroots` façade. +19. Migrate every first-party consumer. +20. Run package-realistic validation. +21. Change only the 19 final packages to `publish = ["crates-io"]`. +22. Publish in Cargo-derived order after explicit authorization. + +## 28. Completion and publication decision + +The package identities and boundaries in this specification are final for release V1. + +Publication is authorized only when all of the following are simultaneously true: + +- all 19 packages implement their charters; +- no forbidden responsibility remains; +- Cargo-resolved closure contains only public Radroots packages; +- all feature/target/package/downstream gates pass; +- naming availability and ownership are confirmed; +- current-source licensing and contributor provenance are cleared; +- generated cross-language contracts are coherent; +- no first-party host remains on legacy or sibling-path APIs; +- the actual `.crate` archives have been inspected and tested. + +Until then, the correct status is: + +```text +Architecture: FINAL +Implementation: REQUIRED +Publication: BLOCKED +``` + +## 29. Final reaffirmation + +This is the final recommended Radroots crates surface. + +It preserves real modularity without turning every current workspace folder into a permanent public package. It establishes stable identities for foundational values, identity, event protocol, trade algorithms, signing, transport, secrets, storage, Nostr, synchronization, a concrete geodata provider, the advanced SDK, and the ordinary-user façade. + +It intentionally withholds host utilities, preview transports, generated CRUD/replica code, experimental messaging/proof systems, codegen, FFI machinery, and test support. + +No additional public crate is required for release V1, and no package in the 19-package family is present merely to satisfy Cargo. Each has a durable responsibility, named consumers, a one-way dependency position, and a credible independent SemVer surface. diff --git a/docs/specs/radroots_crates_release_v1.sha256 b/docs/specs/radroots_crates_release_v1.sha256 @@ -0,0 +1,4 @@ +5a11c6ad90cf03162ca2ce4d1692192d01fa57a31c03fd07d61f70093da5e703 radroots_crates_release_v1.md +7db533f32c70306b29adea35f85686e67287ae33abd33d1013a61590449f1296 radroots_crates_release_v1.toml +5020875c2cda4b2c9568c8b3f0fad5cd96756c3c779a72481a9652e558f77891 radroots_crates_release_v1_inventory.csv +d47de10be596a4d33fee102a4f0617f66700b49515a75a1f42d62c9710043059 radroots_crates_release_v1.dot diff --git a/docs/specs/radroots_crates_release_v1.toml b/docs/specs/radroots_crates_release_v1.toml @@ -0,0 +1,664 @@ +spec_id = "radroots.crates.release.v1" +status = "final_architecture" +date = "2026-07-26" +package_count = 19 +edition = "2024" +resolver = "3" +rust_version = "1.97.1" +license = "MIT OR Apache-2.0" +canonical_repositories = [ + "https://github.com/radrootslabs/lib", + "https://github.com/radrootslabs/sdk", +] + +[repositories.lib] +url = "https://github.com/radrootslabs/lib" +version = "0.1.0-alpha" +packages = [ + "radroots_core", + "radroots_identity", + "radroots_blossom", + "radroots_protocol", + "radroots_event", + "radroots_event_codec", + "radroots_trade", + "radroots_signing", + "radroots_transport", + "radroots_nostr", + "radroots_nostr_connect", + "radroots_secrets", + "radroots_storage", + "radroots_storage_sqlite", + "radroots_transport_nostr", + "radroots_sync", + "radroots_geonames", +] + +[repositories.sdk] +url = "https://github.com/radrootslabs/sdk" +version = "0.1.0-alpha" +packages = ["radroots_sdk", "radroots"] + +[repository_policy] +new_repository_forbidden = true +history_merge_forbidden = true +cross_repository_dependency = "registry_version" +synchronized_contract_copies_required = true + +[release_policy] +sdk_facade_lockstep = true +lower_packages_independent_semver = true +public_codegen_features_forbidden = true +public_to_private_edges_forbidden = true +same_repository_path_and_version_required = true +package_realistic_validation_required = true +preview_packages_withheld = true + +[quality_policy.coverage] +status = "temporary_heavy_development" +repository = "radrootslabs/lib" +scope = "all_required_workspace_crates" +minimum_percent = 90.0 +metrics = ["executable_lines", "functions", "regions", "branches"] +require_measurable_branches = true +numeric_overrides_below_minimum_forbidden = true +reinstatement = "future_explicit_contract_and_spec_update" + +[[package]] +name = "radroots_core" +crate = "radroots_core" +publish_order_hint = 1 +tier = "foundation" +platform = "no_std + alloc; std feature" +responsibility = "Foundational value objects and deterministic invariants: decimal, currency, money, percentage, quantity, units, and pricing." +default_features = ["std", "serde"] +features = ["std", "serde"] +required_radroots_dependencies = [] +optional_radroots_dependencies = [] +modules = [ + "currency", + "decimal", + "money", + "percent", + "pricing", + "quantity", + "unit", +] +root_exports = [ + "Currency", + "Decimal", + "Money", + "Percent", + "Quantity", + "QuantityPrice", + "Unit", + "Error", +] +forbidden = "Identifiers, identities, event kinds, networking, persistence, clocks, filesystem paths, process behavior, or application configuration." + +[[package]] +name = "radroots_identity" +crate = "radroots_identity" +publish_order_hint = 2 +tier = "foundation" +platform = "no_std + alloc; std feature" +responsibility = "Public identity and account value types: canonical public keys, identity IDs, account IDs, public profiles, and usernames." +default_features = ["std", "serde"] +features = ["std", "serde"] +required_radroots_dependencies = [] +optional_radroots_dependencies = [] +modules = ["account", "key", "profile", "username"] +root_exports = [ + "AccountId", + "IdentityId", + "PublicIdentity", + "PublicKey", + "Profile", + "Username", + "Error", +] +forbidden = "Secret keys, key generation, NIP-49 encryption, keyrings, files, SQLite, runtime paths, upstream nostr::Event values, signer sessions, or host account selection." + +[[package]] +name = "radroots_blossom" +crate = "radroots_blossom" +publish_order_hint = 3 +tier = "protocol primitive" +platform = "no_std + alloc; std feature" +responsibility = "Portable Blossom protocol primitives: canonical blob URLs, hashes, media descriptors, byte-verification typestates, and authorization claims." +default_features = ["std", "serde"] +features = ["std", "serde"] +required_radroots_dependencies = [] +optional_radroots_dependencies = [] +modules = ["authorization", "descriptor", "hash", "media_type", "url"] +root_exports = [ + "BlobUrl", + "Sha256", + "MediaType", + "BlobDescriptor", + "ByteVerifiedDescriptor", + "AuthorizationClaim", + "Error", +] +forbidden = "HTTP clients, upload scheduling, cache management, filesystem traversal, application media policy, or global authentication state." + +[[package]] +name = "radroots_protocol" +crate = "radroots_protocol" +publish_order_hint = 4 +tier = "versioned wire contract" +platform = "no_std + alloc; std feature" +responsibility = "Versioned cross-process and cross-language schemas, capability catalogs, operation descriptors, stable error reports, schema IDs, and structural validation." +default_features = ["std", "serde"] +features = ["std", "serde"] +required_radroots_dependencies = [] +optional_radroots_dependencies = [] +modules = [ + "capability::v1", + "error::v1", + "event::v1", + "runtime::v1", + "radrootsd::transport_publish::v5", + "schema", +] +root_exports = [] +forbidden = "Native clients, storage, network I/O, executor/runtime ownership, domain reducers, upstream dependency types, unversioned serialized DTOs, or package names containing protocol generations." + +[[package]] +name = "radroots_event" +crate = "radroots_event" +publish_order_hint = 5 +tier = "domain protocol model" +platform = "no_std + alloc; std feature" +responsibility = "Canonical Radroots event-domain models, validated event identifiers, tags, event contracts, authoring drafts, signed/verified typestates, and NIP-01 wire-neutral representations." +default_features = ["std", "serde"] +features = ["std", "serde", "knowledge"] +required_radroots_dependencies = [ + "radroots_core", + "radroots_identity", + "radroots_blossom", + "radroots_protocol", +] +optional_radroots_dependencies = [] +modules = [ + "admission", + "calendar", + "contract", + "draft", + "envelope", + "farm", + "food", + "id", + "knowledge", + "listing", + "media", + "post", + "profile", + "social", + "tag", + "trade", + "wire", +] +root_exports = [ + "Event", + "EventDraft", + "SignedEvent", + "VerifiedEvent", + "EventId", + "EventKind", + "EventTag", + "Error", +] +forbidden = "Live Nostr clients, relay pools, signing backends, SQLite, outbox claims, retry scheduling, application state, or duplicate trade/order identifier concepts." + +[[package]] +name = "radroots_event_codec" +crate = "radroots_event_codec" +publish_order_hint = 6 +tier = "deterministic algorithm" +platform = "no_std + alloc where selected features permit; std feature" +responsibility = "Deterministic canonical encoding, decoding, ID/signature verification, contract validation, admission, and manifest generation for Radroots events." +default_features = ["std", "json"] +features = ["std", "serde", "json", "knowledge", "manifests"] +required_radroots_dependencies = [ + "radroots_event", + "radroots_blossom", + "radroots_protocol", +] +optional_radroots_dependencies = [] +modules = ["admission", "canonical", "decode", "encode", "manifest", "verify"] +root_exports = ["Codec", "DecodeError", "EncodeError", "VerificationError"] +forbidden = "nostr-sdk clients, relay networking, persistence, background work, upstream client errors, or host configuration." + +[[package]] +name = "radroots_trade" +crate = "radroots_trade" +publish_order_hint = 7 +tier = "domain algorithm" +platform = "no_std + alloc for model/reducer; std feature" +responsibility = "Trade validation, evidence models, deterministic reduction, conflict analysis, and side-effect-free workflow plans over the canonical event trade model." +default_features = ["std", "serde", "json"] +features = ["std", "serde", "json"] +required_radroots_dependencies = [ + "radroots_core", + "radroots_identity", + "radroots_event", +] +optional_radroots_dependencies = [] +modules = ["evidence", "model", "reducer", "validation", "workflow"] +root_exports = [ + "Projection", + "ReductionInput", + "ReducerIssue", + "WorkflowPlan", + "ValidationError", + "Error", +] +forbidden = "A second TradeId definition, actor authorization, signers, event-store access, SQLx, filesystem state, transport delivery, outbox mutation, or process scheduling." + +[[package]] +name = "radroots_signing" +crate = "radroots_signing" +publish_order_hint = 8 +tier = "host SPI" +platform = "no_std + alloc core; std feature" +responsibility = "Object-safe author/signing SPI, actor provenance, authorization checks, requests, receipts, progress, capabilities, and normalized signing errors." +default_features = ["std", "serde"] +features = ["std", "serde"] +required_radroots_dependencies = [ + "radroots_identity", + "radroots_event", + "radroots_protocol", +] +optional_radroots_dependencies = [] +modules = [ + "actor", + "capability", + "error", + "request", + "receipt", + "signer", + "status", +] +root_exports = [ + "Actor", + "Signer", + "SignRequest", + "SignReceipt", + "SignerStatus", + "Error", +] +forbidden = "Raw secret-key ownership, keyrings, relay networking, NIP-46 session persistence, SQL, UI prompts, or executor creation." + +[[package]] +name = "radroots_transport" +crate = "radroots_transport" +publish_order_hint = 9 +tier = "network SPI" +platform = "no_std + alloc data model; std feature" +responsibility = "Transport-neutral target identities, capability/status models, source and sink SPIs, delivery/fetch policies, bounded requests, provenance, and normalized outcomes." +default_features = ["std", "serde"] +features = ["std", "serde"] +required_radroots_dependencies = [ + "radroots_identity", + "radroots_event", + "radroots_protocol", +] +optional_radroots_dependencies = [] +modules = [ + "capability", + "endpoint", + "error", + "outcome", + "policy", + "sink", + "source", + "target", +] +root_exports = [ + "TransportId", + "Target", + "TargetSet", + "EventSource", + "EventSink", + "DeliveryRequest", + "DeliveryReceipt", + "FetchRequest", + "FetchPage", + "Error", +] +forbidden = "Closed enums that prevent new transports, Reticulum-specific constants, Nostr URLs at the generic root, storage/outbox access, retries, scheduler ownership, or silent fallback." + +[[package]] +name = "radroots_nostr" +crate = "radroots_nostr" +publish_order_hint = 10 +tier = "protocol adapter" +platform = "no_std + alloc for conversion surface; std feature" +responsibility = "Portable conversion between Radroots native event/identity types and Nostr protocol types, typed NIP helpers, and concrete local signing adapters; no live relay client." +default_features = ["std", "events"] +features = ["std", "events", "signing", "nip17", "blossom"] +required_radroots_dependencies = [ + "radroots_identity", + "radroots_event", + "radroots_event_codec", +] +optional_radroots_dependencies = ["radroots_signing", "radroots_blossom"] +modules = ["blossom", "event", "filter", "key", "nip17", "signing", "tag"] +root_exports = ["Error"] +forbidden = "nostr-sdk relay pools, reqwest clients, runtime ownership, broad aliases of upstream nostr types at the root, account persistence, or outbox orchestration." + +[[package]] +name = "radroots_nostr_connect" +crate = "radroots_nostr_connect" +publish_order_hint = 11 +tier = "security protocol" +platform = "std for v1; protocol data kept portable" +responsibility = "Nostr Connect/NIP-46 URIs, methods, permissions, requests, responses, client/server state machines, timeout-independent protocol validation, and normalized errors." +default_features = ["serde"] +features = ["serde"] +required_radroots_dependencies = [ + "radroots_identity", + "radroots_event", + "radroots_nostr", + "radroots_protocol", +] +optional_radroots_dependencies = [] +modules = [ + "client", + "error", + "message", + "method", + "permission", + "server", + "uri", +] +root_exports = [ + "Client", + "Server", + "Method", + "Permission", + "Request", + "Response", + "BunkerUri", + "ClientUri", + "Error", +] +forbidden = "Relay-pool implementation, secret persistence, approval UI, global sessions, Tokio runtime ownership, or Myc-specific service storage." + +[[package]] +name = "radroots_secrets" +crate = "radroots_secrets" +publish_order_hint = 12 +tier = "security SPI" +platform = "no_std + alloc core; std adapters" +responsibility = "Secret references, provider and key-wrapping SPIs, versioned encrypted envelopes, zeroization-safe secret handling, and explicit memory/file/keyring adapters." +default_features = ["std", "serde"] +features = ["std", "serde", "memory", "file", "keyring"] +required_radroots_dependencies = [] +optional_radroots_dependencies = [] +modules = [ + "envelope", + "error", + "id", + "provider", + "wrapping", + "memory", + "file", + "keyring", +] +root_exports = [ + "SecretId", + "SecretRef", + "SecretProvider", + "KeyWrapping", + "EncryptedEnvelope", + "Error", +] +forbidden = "Public secret bytes, Clone/Debug/Serialize for secret-bearing values, identity profiles, domain tables, arbitrary key/value storage, hidden key generation, or process-global vaults." + +[[package]] +name = "radroots_storage" +crate = "radroots_storage" +publish_order_hint = 13 +tier = "storage SPI" +platform = "std for v1" +responsibility = "Backend-neutral canonical event, operation journal, outbox, transport evidence, projection, private-artifact metadata, backup, status, and atomic commit interfaces, plus an in-memory reference backend." +default_features = ["memory", "serde"] +features = ["memory", "serde"] +required_radroots_dependencies = [ + "radroots_event", + "radroots_trade", + "radroots_transport", + "radroots_protocol", +] +optional_radroots_dependencies = [] +modules = [ + "atomic", + "backup", + "event", + "journal", + "memory", + "outbox", + "private_artifact", + "projection", + "status", +] +root_exports = [ + "Storage", + "EventStore", + "Journal", + "Outbox", + "ProjectionStore", + "BackupSource", + "StorageStatus", + "Error", +] +forbidden = "SQL text, SQLx pools or transactions, filesystem paths, application UI state, concrete retry loops, Nostr clients, or unconstrained raw key/value escape hatches." + +[[package]] +name = "radroots_storage_sqlite" +crate = "radroots_storage_sqlite" +publish_order_hint = 14 +tier = "native storage backend" +platform = "std-only native backend" +responsibility = "SQLite implementation of the storage SPIs with schema migration, WAL, locking, integrity, backup/restore, crash recovery, and encrypted private storage." +default_features = [] +features = [] +required_radroots_dependencies = [ + "radroots_storage", + "radroots_event_codec", + "radroots_secrets", +] +optional_radroots_dependencies = [] +modules = [ + "backup", + "config", + "integrity", + "lock", + "migration", + "open", + "status", +] +root_exports = ["SqliteStorage", "OpenOptions", "OpenMode", "Paths", "Error"] +forbidden = "Public SqlitePool/Connection/Transaction handles, caller-supplied arbitrary SQL, Studio state, global connection pools, runtime installation, or silent schema downgrade." + +[[package]] +name = "radroots_transport_nostr" +crate = "radroots_transport_nostr" +publish_order_hint = 15 +tier = "native network adapter" +platform = "std-only; Tokio implementation detail in v1" +responsibility = "Concrete Nostr EventSource/EventSink implementation: relay URL policy, connection, NIP-42 authentication, bounded fetch pages, delivery, status, and relay-outcome normalization." +default_features = [] +features = [] +required_radroots_dependencies = [ + "radroots_transport", + "radroots_nostr", + "radroots_event_codec", + "radroots_protocol", +] +optional_radroots_dependencies = [] +modules = ["auth", "client", "relay", "sink", "source", "status"] +root_exports = [ + "NostrTransport", + "Config", + "RelayUrl", + "RelayUrlPolicy", + "Error", +] +forbidden = "Event-store ingestion, outbox claiming, retry scheduling, projection refresh, global relay clients, direct SQL, or transport fallback." + +[[package]] +name = "radroots_sync" +crate = "radroots_sync" +publish_order_hint = 16 +tier = "local-first orchestration" +platform = "std-only in v1; executor-neutral public API" +responsibility = "Shared pull, verification, canonical admission, duplicate handling, projection refresh, outbox signing/delivery, status, and retry-decision orchestration without owning scheduling." +default_features = ["serde"] +features = ["serde"] +required_radroots_dependencies = [ + "radroots_event", + "radroots_event_codec", + "radroots_signing", + "radroots_transport", + "radroots_storage", + "radroots_trade", + "radroots_protocol", +] +optional_radroots_dependencies = [] +modules = ["ingest", "policy", "projection", "pull", "push", "status"] +root_exports = [ + "Engine", + "PullRequest", + "PullReceipt", + "PushRequest", + "PushReceipt", + "SyncStatus", + "Error", +] +forbidden = "Creating an executor, spawning hidden workers, installing timers globally, owning process lifecycle, storing UI state, or transport-specific branches outside adapters." + +[[package]] +name = "radroots_geonames" +crate = "radroots_geonames" +publish_order_hint = 17 +tier = "concrete data provider" +platform = "std-only" +responsibility = "GeoNames asset specification, authenticated/integrity-checked acquisition, database lifecycle, and forward/reverse locality lookup using provider-owned types." +default_features = [] +features = [] +required_radroots_dependencies = [] +optional_radroots_dependencies = [] +modules = ["asset", "database", "download", "model", "query"] +root_exports = [ + "Geocoder", + "AssetSpec", + "AssetStatus", + "Query", + "Candidate", + "Point", + "Error", +] +forbidden = "Generic multi-provider abstraction before a second provider exists, runtime-path policy, hidden downloads, SDK configuration types, SQLx/reqwest types in the public API, or test-fixture features." + +[[package]] +name = "radroots_sdk" +crate = "radroots_sdk" +publish_order_hint = 18 +tier = "advanced front door" +platform = "std-only native engine" +responsibility = "Host-neutral asynchronous client engine, product operations, capability reporting, explicit storage/signing/transport composition, diagnostics, backup/restore, and safe commit semantics." +default_features = ["memory"] +features = [ + "memory", + "sqlite", + "sync", + "nostr", + "nip46", + "local-signing", + "radrootsd", + "geonames", + "knowledge", + "native", + "full", +] +required_radroots_dependencies = [ + "radroots_core", + "radroots_identity", + "radroots_protocol", + "radroots_event", + "radroots_event_codec", + "radroots_trade", + "radroots_signing", + "radroots_transport", + "radroots_storage", +] +optional_radroots_dependencies = [ + "radroots_secrets", + "radroots_storage_sqlite", + "radroots_nostr", + "radroots_nostr_connect", + "radroots_transport_nostr", + "radroots_sync", + "radroots_geonames", +] +modules = [ + "capability", + "client", + "diagnostics", + "error", + "farm", + "listing", + "signing", + "storage", + "sync", + "trade", + "transport", +] +root_exports = ["Client", "ClientBuilder", "Error", "Result"] +forbidden = "Global runtimes or subscribers, hidden workers, process signals, CLI parsing, UI state, Studio databases, raw SQLx/upstream client types, broad wildcard reexports, or a nominal no_std claim." + +[[package]] +name = "radroots" +crate = "radroots" +publish_order_hint = 19 +tier = "ordinary-user front door" +platform = "std-only" +responsibility = "Canonical Rust onboarding package with curated modules, safe defaults, stable convenience builders, domain aggregation, examples, and primary documentation." +default_features = ["client"] +features = [ + "client", + "native", + "nostr", + "nip46", + "radrootsd", + "geonames", + "knowledge", + "full", +] +required_radroots_dependencies = [ + "radroots_sdk", + "radroots_core", + "radroots_identity", + "radroots_event", + "radroots_trade", + "radroots_transport", +] +optional_radroots_dependencies = [] +modules = [ + "client", + "event", + "farm", + "identity", + "knowledge", + "listing", + "signing", + "storage", + "sync", + "trade", + "transport", +] +root_exports = ["Client", "ClientBuilder", "Error", "Result"] +forbidden = "A public radroots::sdk namespace, wildcard reexport of radroots_sdk, duplicate engine implementation, CLI binary, hidden network/filesystem/keychain side effects, or exposure of every lower-crate symbol." diff --git a/docs/specs/radroots_crates_release_v1_inventory.csv b/docs/specs/radroots_crates_release_v1_inventory.csv @@ -0,0 +1,20 @@ +publish_order_hint,package,crate,repository,tier,platform,initial_version,default_features,features,required_radroots_dependencies,optional_radroots_dependencies,direct_consumers,responsibility,forbidden +1,radroots_core,radroots_core,lib,foundation,no_std + alloc; std feature,0.1.0-alpha,std;serde,std;serde,,,radroots_event;radroots_trade;radroots_sdk;radroots,"Foundational value objects and deterministic invariants: decimal, currency, money, percentage, quantity, units, and pricing.","Identifiers, identities, event kinds, networking, persistence, clocks, filesystem paths, process behavior, or application configuration." +2,radroots_identity,radroots_identity,lib,foundation,no_std + alloc; std feature,0.1.0-alpha,std;serde,std;serde,,,radroots_event;radroots_signing;radroots_transport;radroots_nostr;radroots_nostr_connect;radroots_sdk;services,"Public identity and account value types: canonical public keys, identity IDs, account IDs, public profiles, and usernames.","Secret keys, key generation, NIP-49 encryption, keyrings, files, SQLite, runtime paths, upstream nostr::Event values, signer sessions, or host account selection." +3,radroots_blossom,radroots_blossom,lib,protocol primitive,no_std + alloc; std feature,0.1.0-alpha,std;serde,std;serde,,,radroots_event;radroots_event_codec;radroots_nostr;media clients,"Portable Blossom protocol primitives: canonical blob URLs, hashes, media descriptors, byte-verification typestates, and authorization claims.","HTTP clients, upload scheduling, cache management, filesystem traversal, application media policy, or global authentication state." +4,radroots_protocol,radroots_protocol,lib,versioned wire contract,no_std + alloc; std feature,0.1.0-alpha,std;serde,std;serde,,,radroots_event;radroots_signing;radroots_transport;radroots_storage;radroots_sync;radroots_sdk;radrootsd;bindings,"Versioned cross-process and cross-language schemas, capability catalogs, operation descriptors, stable error reports, schema IDs, and structural validation.","Native clients, storage, network I/O, executor/runtime ownership, domain reducers, upstream dependency types, unversioned serialized DTOs, or package names containing protocol generations." +5,radroots_event,radroots_event,lib,domain protocol model,no_std + alloc; std feature,0.1.0-alpha,std;serde,std;serde;knowledge,radroots_core;radroots_identity;radroots_blossom;radroots_protocol,,radroots_event_codec;radroots_trade;radroots_signing;radroots_transport;radroots_storage;radroots_sync;radroots_nostr;radroots_sdk;indexers,"Canonical Radroots event-domain models, validated event identifiers, tags, event contracts, authoring drafts, signed/verified typestates, and NIP-01 wire-neutral representations.","Live Nostr clients, relay pools, signing backends, SQLite, outbox claims, retry scheduling, application state, or duplicate trade/order identifier concepts." +6,radroots_event_codec,radroots_event_codec,lib,deterministic algorithm,no_std + alloc where selected features permit; std feature,0.1.0-alpha,std;json,std;serde;json;knowledge;manifests,radroots_event;radroots_blossom;radroots_protocol,,radroots_nostr;radroots_storage_sqlite;radroots_transport_nostr;radroots_sync;radroots_sdk;bindings,"Deterministic canonical encoding, decoding, ID/signature verification, contract validation, admission, and manifest generation for Radroots events.","nostr-sdk clients, relay networking, persistence, background work, upstream client errors, or host configuration." +7,radroots_trade,radroots_trade,lib,domain algorithm,no_std + alloc for model/reducer; std feature,0.1.0-alpha,std;serde;json,std;serde;json,radroots_core;radroots_identity;radroots_event,,radroots_storage;radroots_sync;radroots_sdk;RHI;applications,"Trade validation, evidence models, deterministic reduction, conflict analysis, and side-effect-free workflow plans over the canonical event trade model.","A second TradeId definition, actor authorization, signers, event-store access, SQLx, filesystem state, transport delivery, outbox mutation, or process scheduling." +8,radroots_signing,radroots_signing,lib,host SPI,no_std + alloc core; std feature,0.1.0-alpha,std;serde,std;serde,radroots_identity;radroots_event;radroots_protocol,,radroots_nostr;radroots_sync;radroots_sdk;CLI;Studio;FFI hosts,"Object-safe author/signing SPI, actor provenance, authorization checks, requests, receipts, progress, capabilities, and normalized signing errors.","Raw secret-key ownership, keyrings, relay networking, NIP-46 session persistence, SQL, UI prompts, or executor creation." +9,radroots_transport,radroots_transport,lib,network SPI,no_std + alloc data model; std feature,0.1.0-alpha,std;serde,std;serde,radroots_identity;radroots_event;radroots_protocol,,radroots_storage;radroots_transport_nostr;radroots_sync;radroots_sdk;services;future adapters,"Transport-neutral target identities, capability/status models, source and sink SPIs, delivery/fetch policies, bounded requests, provenance, and normalized outcomes.","Closed enums that prevent new transports, Reticulum-specific constants, Nostr URLs at the generic root, storage/outbox access, retries, scheduler ownership, or silent fallback." +10,radroots_nostr,radroots_nostr,lib,protocol adapter,no_std + alloc for conversion surface; std feature,0.1.0-alpha,std;events,std;events;signing;nip17;blossom,radroots_identity;radroots_event;radroots_event_codec,radroots_signing;radroots_blossom,radroots_nostr_connect;radroots_transport_nostr;radroots_sdk;Myc;radrootsd,"Portable conversion between Radroots native event/identity types and Nostr protocol types, typed NIP helpers, and concrete local signing adapters; no live relay client.","nostr-sdk relay pools, reqwest clients, runtime ownership, broad aliases of upstream nostr types at the root, account persistence, or outbox orchestration." +11,radroots_nostr_connect,radroots_nostr_connect,lib,security protocol,std for v1; protocol data kept portable,0.1.0-alpha,serde,serde,radroots_identity;radroots_event;radroots_nostr;radroots_protocol,,radroots_sdk;Myc;remote signer tooling,"Nostr Connect/NIP-46 URIs, methods, permissions, requests, responses, client/server state machines, timeout-independent protocol validation, and normalized errors.","Relay-pool implementation, secret persistence, approval UI, global sessions, Tokio runtime ownership, or Myc-specific service storage." +12,radroots_secrets,radroots_secrets,lib,security SPI,no_std + alloc core; std adapters,0.1.0-alpha,std;serde,std;serde;memory;file;keyring,,,radroots_storage_sqlite;radroots_sdk;signing hosts;services,"Secret references, provider and key-wrapping SPIs, versioned encrypted envelopes, zeroization-safe secret handling, and explicit memory/file/keyring adapters.","Public secret bytes, Clone/Debug/Serialize for secret-bearing values, identity profiles, domain tables, arbitrary key/value storage, hidden key generation, or process-global vaults." +13,radroots_storage,radroots_storage,lib,storage SPI,std for v1,0.1.0-alpha,memory;serde,memory;serde,radroots_event;radroots_trade;radroots_transport;radroots_protocol,,radroots_storage_sqlite;radroots_sync;radroots_sdk;indexers;tests;future backends,"Backend-neutral canonical event, operation journal, outbox, transport evidence, projection, private-artifact metadata, backup, status, and atomic commit interfaces, plus an in-memory reference backend.","SQL text, SQLx pools or transactions, filesystem paths, application UI state, concrete retry loops, Nostr clients, or unconstrained raw key/value escape hatches." +14,radroots_storage_sqlite,radroots_storage_sqlite,lib,native storage backend,std-only native backend,0.1.0-alpha,,,radroots_storage;radroots_event_codec;radroots_secrets,,radroots_sdk;CLI;Studio;mobile/FFI,"SQLite implementation of the storage SPIs with schema migration, WAL, locking, integrity, backup/restore, crash recovery, and encrypted private storage.","Public SqlitePool/Connection/Transaction handles, caller-supplied arbitrary SQL, Studio state, global connection pools, runtime installation, or silent schema downgrade." +15,radroots_transport_nostr,radroots_transport_nostr,lib,native network adapter,std-only; Tokio implementation detail in v1,0.1.0-alpha,,,radroots_transport;radroots_nostr;radroots_event_codec;radroots_protocol,,radroots_sdk;CLI;radrootsd;advanced hosts,"Concrete Nostr EventSource/EventSink implementation: relay URL policy, connection, NIP-42 authentication, bounded fetch pages, delivery, status, and relay-outcome normalization.","Event-store ingestion, outbox claiming, retry scheduling, projection refresh, global relay clients, direct SQL, or transport fallback." +16,radroots_sync,radroots_sync,lib,local-first orchestration,std-only in v1; executor-neutral public API,0.1.0-alpha,serde,serde,radroots_event;radroots_event_codec;radroots_signing;radroots_transport;radroots_storage;radroots_trade;radroots_protocol,,radroots_sdk;CLI;Studio;mobile/FFI;advanced hosts,"Shared pull, verification, canonical admission, duplicate handling, projection refresh, outbox signing/delivery, status, and retry-decision orchestration without owning scheduling.","Creating an executor, spawning hidden workers, installing timers globally, owning process lifecycle, storing UI state, or transport-specific branches outside adapters." +17,radroots_geonames,radroots_geonames,lib,concrete data provider,std-only,0.1.0-alpha,,,,,radroots_sdk;CLI;geocoding applications,"GeoNames asset specification, authenticated/integrity-checked acquisition, database lifecycle, and forward/reverse locality lookup using provider-owned types.","Generic multi-provider abstraction before a second provider exists, runtime-path policy, hidden downloads, SDK configuration types, SQLx/reqwest types in the public API, or test-fixture features." +18,radroots_sdk,radroots_sdk,sdk,advanced front door,std-only native engine,0.1.0-alpha,memory,memory;sqlite;sync;nostr;nip46;local-signing;radrootsd;geonames;knowledge;native;full,radroots_core;radroots_identity;radroots_protocol;radroots_event;radroots_event_codec;radroots_trade;radroots_signing;radroots_transport;radroots_storage,radroots_secrets;radroots_storage_sqlite;radroots_nostr;radroots_nostr_connect;radroots_transport_nostr;radroots_sync;radroots_geonames,CLI;Studio;FFI/mobile;advanced native applications,"Host-neutral asynchronous client engine, product operations, capability reporting, explicit storage/signing/transport composition, diagnostics, backup/restore, and safe commit semantics.","Global runtimes or subscribers, hidden workers, process signals, CLI parsing, UI state, Studio databases, raw SQLx/upstream client types, broad wildcard reexports, or a nominal no_std claim." +19,radroots,radroots,sdk,ordinary-user front door,std-only,0.1.0-alpha,client,client;native;nostr;nip46;radrootsd;geonames;knowledge;full,radroots_sdk;radroots_core;radroots_identity;radroots_event;radroots_trade;radroots_transport,,ordinary Rust applications;examples;documentation,"Canonical Rust onboarding package with curated modules, safe defaults, stable convenience builders, domain aggregation, examples, and primary documentation.","A public radroots::sdk namespace, wildcard reexport of radroots_sdk, duplicate engine implementation, CLI binary, hidden network/filesystem/keychain side effects, or exposure of every lower-crate symbol." diff --git a/flake.lock b/flake.lock @@ -80,11 +80,11 @@ ] }, "locked": { - "lastModified": 1784178955, - "narHash": "sha256-YtXHVFBzzqSidcEhPhfTcF2yzzu0p0yA7x07LOR+LaY=", + "lastModified": 1785131767, + "narHash": "sha256-VNbQv2P0zgaNh96mT4LrnX7hdXgiC5nBH+uvyrrVX7U=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "1785b85aeccca381caf8777133410f577b8f2f59", + "rev": "c67ce00525464a710971351c183ce67acb6ca827", "type": "github" }, "original": { diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock @@ -452,7 +452,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "radroots-fuzz" -version = "0.0.0" +version = "0.1.0-alpha" dependencies = [ "libfuzzer-sys", "radroots_blossom", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots-fuzz" -version = "0.0.0" +version = "0.1.0-alpha" publish = false edition = "2024" diff --git a/fuzz/fuzz_targets/common.rs b/fuzz/fuzz_targets/common.rs @@ -1,23 +1,22 @@ use radroots_blossom::{ - RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, RadrootsBlossomAuthoredRasterDimensions, - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomBud01GetObservation, - RadrootsBlossomBud01HeadObservation, RadrootsBlossomBud02UploadObservation, - RadrootsBlossomMediaType, RadrootsBlossomSha256, verify_publication_readiness, + AuthoredRasterDimensions, BlobDescriptor, BlobUrl, Bud01GetObservation, Bud01HeadObservation, + Bud02UploadObservation, MediaType, RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, Sha256, + verify_publication_readiness, }; pub(crate) fn exercise(input: &[u8], media_type: &str, extension: &str) { if input.is_empty() || input.len() as u64 > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES { return; } - let hash = RadrootsBlossomSha256::digest(input); + let hash = Sha256::digest(input); let url = format!("https://cdn.example/{hash}.{extension}"); - let Ok(media_type) = RadrootsBlossomMediaType::parse(media_type) else { + let Ok(media_type) = MediaType::parse(media_type) else { return; }; - let Ok(url) = RadrootsBlossomBlobUrl::parse(&url) else { + let Ok(url) = BlobUrl::parse(&url) else { return; }; - let Ok(descriptor) = RadrootsBlossomBlobDescriptor::new( + let Ok(descriptor) = BlobDescriptor::new( url.clone(), hash, input.len() as u64, @@ -33,13 +32,13 @@ pub(crate) fn exercise(input: &[u8], media_type: &str, extension: &str) { else { return; }; - let Ok(upload) = RadrootsBlossomBud02UploadObservation::new(201, descriptor) else { + let Ok(upload) = Bud02UploadObservation::new(201, descriptor) else { return; }; let Ok(approved_url) = url.approve() else { return; }; - let Ok(head) = RadrootsBlossomBud01HeadObservation::new( + let Ok(head) = Bud01HeadObservation::new( 200, approved_url.clone(), input.len() as u64, @@ -47,7 +46,7 @@ pub(crate) fn exercise(input: &[u8], media_type: &str, extension: &str) { ) else { return; }; - let Ok(get) = RadrootsBlossomBud01GetObservation::from_complete_body( + let Ok(get) = Bud01GetObservation::from_complete_body( 200, approved_url, input.len() as u64, @@ -59,7 +58,7 @@ pub(crate) fn exercise(input: &[u8], media_type: &str, extension: &str) { let _ = verify_publication_readiness( &authored, input, - RadrootsBlossomAuthoredRasterDimensions::Unspecified, + AuthoredRasterDimensions::Unspecified, &upload, &head, &get, diff --git a/rust-toolchain.toml b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.97.0" +channel = "1.97.1" components = ["clippy", "rust-analyzer", "rust-src", "rustfmt"] targets = ["wasm32-unknown-unknown"] diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml @@ -30,7 +30,7 @@ quote = { workspace = true } radroots_event_codec = { workspace = true, features = [ "contract-manifest", ], default-features = false } -radroots_protocol_contract_v1 = { workspace = true, features = ["std"] } +radroots_protocol = { workspace = true, features = ["std"] } semver = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/tools/xtask/src/architecture.rs b/tools/xtask/src/architecture.rs @@ -0,0 +1,1457 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Component, Path}, +}; + +use serde::Deserialize; + +mod api_leakage; +mod core_contract; +mod dependency_boundary; + +const DEVIATIONS_RELATIVE: &str = "docs/implementation/deviations.toml"; +const ARCHITECTURE_RELATIVE: &str = "docs/specs/radroots_crates_release_v1.toml"; +const ARCHITECTURE_ID: &str = "radroots.crates.release.v1"; +const PUBLIC_HOMEPAGE: &str = "https://radroots.org"; +const PUBLIC_README: &str = "README.md"; +const PUBLIC_AUTHORS: &[&str] = &["Tyson Lupul <tyson@radroots.org>"]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeviationLedger { + schema_version: u16, + architecture_id: String, + deviation: Vec<DeviationRecord>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeviationRecord { + id: String, + date: String, + status: String, + approval: String, + affected_steps: Vec<String>, + spec_anchors: Vec<String>, + source_evidence: Vec<String>, + replacement_action: String, + verification: Vec<String>, + unresolved_risk: String, + normative_architecture_change: bool, + adr_required: bool, + #[serde(default)] + closure_evidence: Vec<String>, +} + +#[derive(Debug, Deserialize)] +struct ArchitectureIdentity { + spec_id: String, + edition: String, + resolver: String, + rust_version: String, + license: String, + canonical_repositories: Vec<String>, + repositories: BTreeMap<String, ArchitectureRepository>, + package: Vec<ArchitecturePackage>, +} + +#[derive(Debug, Deserialize)] +struct ArchitectureRepository { + url: String, + version: String, + packages: Vec<String>, +} + +#[derive(Debug, Deserialize)] +struct ArchitecturePackage { + name: String, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceManifest { + workspace: WorkspaceMembers, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceMembershipManifest { + workspace: WorkspaceMembership, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceMembership { + members: Vec<String>, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceMembers { + members: Vec<String>, + resolver: String, + package: WorkspacePackage, + metadata: WorkspaceMetadata, + lints: WorkspaceLints, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceLints { + rust: WorkspaceRustLints, + rustdoc: WorkspaceRustdocLints, + clippy: WorkspaceClippyLints, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceRustLints { + unsafe_code: String, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceRustdocLints { + broken_intra_doc_links: String, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceClippyLints { + dbg_macro: String, + todo: String, + unimplemented: String, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceMetadata { + radroots: RadrootsWorkspaceMetadata, +} + +#[derive(Debug, Deserialize)] +struct RadrootsWorkspaceMetadata { + #[serde(rename = "public-package")] + public_package: PublicPackageMetadata, +} + +#[derive(Debug, Deserialize)] +struct PublicPackageMetadata { + version: String, + authors: Vec<String>, + readme: String, +} + +#[derive(Debug, Deserialize)] +struct WorkspacePackage { + version: String, + edition: String, + #[serde(rename = "rust-version")] + rust_version: String, + license: String, + repository: String, + homepage: String, + readme: String, + authors: Vec<String>, +} + +pub fn validate(workspace_root: &Path) -> Result<(), String> { + validate_workspace_members(workspace_root)?; + let architecture_path = workspace_root.join(ARCHITECTURE_RELATIVE); + let architecture_raw = fs::read_to_string(&architecture_path) + .map_err(|error| format!("read {}: {error}", architecture_path.display()))?; + let architecture = toml::from_str::<ArchitectureIdentity>(&architecture_raw) + .map_err(|error| format!("parse {}: {error}", architecture_path.display()))?; + + let architecture_packages = architecture + .package + .iter() + .map(|package| package.name.clone()) + .collect::<BTreeSet<_>>(); + core_contract::validate(workspace_root)?; + api_leakage::validate_policy_catalog(workspace_root, &architecture_packages)?; + dependency_boundary::validate_policy_catalog(workspace_root, &architecture_packages)?; + validate_workspace_toolchain(workspace_root, &architecture)?; + validate_public_package_metadata(workspace_root, &architecture)?; + validate_no_production_sibling_paths(workspace_root)?; + validate_public_dependency_versions(workspace_root, &architecture)?; + + let ledger_path = workspace_root.join(DEVIATIONS_RELATIVE); + let ledger_raw = fs::read_to_string(&ledger_path) + .map_err(|error| format!("read {}: {error}", ledger_path.display()))?; + validate_ledger(workspace_root, &architecture.spec_id, &ledger_raw) +} + +pub fn validate_dependency_boundaries(workspace_root: &Path) -> Result<(), String> { + validate(workspace_root)?; + dependency_boundary::validate_resolved_boundaries(workspace_root) +} + +pub fn validate_api_boundaries(workspace_root: &Path) -> Result<(), String> { + validate(workspace_root)?; + api_leakage::validate_public_api(workspace_root) +} + +pub fn validate_ci(workspace_root: &Path) -> Result<(), String> { + validate(workspace_root)?; + dependency_boundary::validate_resolved_boundaries(workspace_root)?; + api_leakage::validate_public_api(workspace_root) +} + +fn validate_workspace_toolchain( + workspace_root: &Path, + architecture: &ArchitectureIdentity, +) -> Result<(), String> { + let manifest_path = workspace_root.join("Cargo.toml"); + let manifest_raw = fs::read_to_string(&manifest_path) + .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; + let manifest = toml::from_str::<WorkspaceManifest>(&manifest_raw) + .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; + if manifest.workspace.resolver != architecture.resolver || architecture.resolver != "3" { + return Err(format!( + "workspace resolver {} must match architecture resolver {}", + manifest.workspace.resolver, architecture.resolver + )); + } + if manifest.workspace.package.rust_version != architecture.rust_version + || architecture.rust_version != "1.97.1" + { + return Err(format!( + "workspace rust-version {} must match architecture rust_version {}", + manifest.workspace.package.rust_version, architecture.rust_version + )); + } + let workspace_package = &manifest.workspace.package; + let public_metadata = &manifest.workspace.metadata.radroots.public_package; + let repository = architecture + .repositories + .values() + .find(|repository| repository.url == workspace_package.repository) + .ok_or_else(|| "workspace repository has no architecture allocation".to_owned())?; + if public_metadata.version != repository.version { + return Err(format!( + "public package version source {} must match repository version {}", + public_metadata.version, repository.version + )); + } + if public_metadata.authors != PUBLIC_AUTHORS { + return Err("public package authors source must match the workspace convention".to_owned()); + } + if public_metadata.readme != PUBLIC_README { + return Err(format!( + "public package readme source must be {PUBLIC_README}" + )); + } + let lints = &manifest.workspace.lints; + if lints.rust.unsafe_code != "forbid" + || lints.rustdoc.broken_intra_doc_links != "deny" + || lints.clippy.dbg_macro != "deny" + || lints.clippy.todo != "deny" + || lints.clippy.unimplemented != "deny" + { + return Err( + "workspace lints must forbid unsafe code and deny the approved rustdoc/Clippy baseline" + .to_owned(), + ); + } + if workspace_package.edition != architecture.edition || architecture.edition != "2024" { + return Err(format!( + "workspace edition {} must match architecture edition {}", + workspace_package.edition, architecture.edition + )); + } + if workspace_package.license != architecture.license { + return Err(format!( + "workspace license {} must match architecture license {}", + workspace_package.license, architecture.license + )); + } + if !architecture + .canonical_repositories + .contains(&workspace_package.repository) + { + return Err(format!( + "workspace repository {} is not canonical", + workspace_package.repository + )); + } + if workspace_package.homepage != PUBLIC_HOMEPAGE { + return Err(format!( + "workspace homepage {} must be {PUBLIC_HOMEPAGE}", + workspace_package.homepage + )); + } + if workspace_package.authors != PUBLIC_AUTHORS { + return Err("workspace authors must match the public package convention".to_owned()); + } + if !workspace_root.join(&workspace_package.readme).is_file() { + return Err(format!( + "workspace readme {} does not exist", + workspace_package.readme + )); + } + for license in ["LICENSE-MIT", "LICENSE-APACHE"] { + if !workspace_root.join(license).is_file() { + return Err(format!("workspace is missing {license}")); + } + } + let toolchain_path = workspace_root.join("rust-toolchain.toml"); + let toolchain_raw = fs::read_to_string(&toolchain_path) + .map_err(|error| format!("read {}: {error}", toolchain_path.display()))?; + let toolchain = toolchain_raw + .parse::<toml::Value>() + .map_err(|error| format!("parse {}: {error}", toolchain_path.display()))?; + let channel = toolchain + .get("toolchain") + .and_then(|value| value.get("channel")) + .and_then(toml::Value::as_str); + if channel != Some(architecture.rust_version.as_str()) { + return Err(format!( + "rust-toolchain.toml channel must match architecture rust_version {}", + architecture.rust_version + )); + } + Ok(()) +} + +fn validate_public_package_metadata( + workspace_root: &Path, + architecture: &ArchitectureIdentity, +) -> Result<(), String> { + let workspace_raw = fs::read_to_string(workspace_root.join("Cargo.toml")) + .map_err(|error| format!("read workspace Cargo.toml: {error}"))?; + let workspace = toml::from_str::<WorkspaceManifest>(&workspace_raw) + .map_err(|error| format!("parse workspace Cargo.toml: {error}"))?; + let repository = architecture + .repositories + .values() + .find(|repository| repository.url == workspace.workspace.package.repository) + .ok_or_else(|| "workspace repository has no architecture allocation".to_owned())?; + let local_packages = repository.packages.iter().cloned().collect::<BTreeSet<_>>(); + let all_packages = architecture + .package + .iter() + .map(|package| package.name.as_str()) + .collect::<BTreeSet<_>>(); + let mut found_local_packages = BTreeSet::new(); + + for member in &workspace.workspace.members { + let manifest_path = workspace_root.join(member).join("Cargo.toml"); + let raw = fs::read_to_string(&manifest_path) + .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; + let manifest = raw + .parse::<toml::Value>() + .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; + let package = manifest + .get("package") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{} is missing [package]", manifest_path.display()))?; + let name = package + .get("name") + .and_then(toml::Value::as_str) + .ok_or_else(|| format!("{} is missing package.name", manifest_path.display()))?; + if !all_packages.contains(name) { + continue; + } + if !local_packages.contains(name) { + return Err(format!( + "public package {name} belongs to a different canonical repository" + )); + } + found_local_packages.insert(name.to_owned()); + validate_public_manifest_field( + package, + "version", + &workspace.workspace.package.version, + &repository.version, + name, + )?; + validate_public_manifest_field( + package, + "edition", + &workspace.workspace.package.edition, + &architecture.edition, + name, + )?; + validate_public_manifest_field( + package, + "rust-version", + &workspace.workspace.package.rust_version, + &architecture.rust_version, + name, + )?; + validate_public_manifest_field( + package, + "license", + &workspace.workspace.package.license, + &architecture.license, + name, + )?; + validate_public_manifest_field( + package, + "repository", + &workspace.workspace.package.repository, + &repository.url, + name, + )?; + validate_public_manifest_field( + package, + "homepage", + &workspace.workspace.package.homepage, + PUBLIC_HOMEPAGE, + name, + )?; + let authors = resolve_public_authors(package, &workspace.workspace.package.authors) + .ok_or_else(|| format!("public package {name} must declare authors"))?; + if authors != PUBLIC_AUTHORS { + return Err(format!( + "public package {name} authors must match the workspace convention" + )); + } + let readme = package.get("readme").and_then(toml::Value::as_str); + if readme != Some(PUBLIC_README) + || !workspace_root.join(member).join(PUBLIC_README).is_file() + { + return Err(format!( + "public package {name} must use an existing crate-local {PUBLIC_README}" + )); + } + if package.get("publish").and_then(toml::Value::as_bool) != Some(false) { + return Err(format!( + "public package {name} must remain publish = false during migration" + )); + } + let inherits_lints = manifest + .get("lints") + .and_then(toml::Value::as_table) + .is_some_and(|lints| { + lints.len() == 1 + && lints.get("workspace").and_then(toml::Value::as_bool) == Some(true) + }); + if !inherits_lints { + return Err(format!( + "public package {name} must inherit the workspace lint policy" + )); + } + } + if found_local_packages != local_packages { + let missing = local_packages + .difference(&found_local_packages) + .cloned() + .collect::<Vec<_>>() + .join(", "); + let extra = found_local_packages + .difference(&local_packages) + .cloned() + .collect::<Vec<_>>() + .join(", "); + return Err(format!( + "workspace public package inventory is missing: {missing}; workspace public package inventory has unallocated packages: {extra}" + )); + } + Ok(()) +} + +fn validate_public_dependency_versions( + workspace_root: &Path, + architecture: &ArchitectureIdentity, +) -> Result<(), String> { + let workspace_path = workspace_root.join("Cargo.toml"); + let workspace_raw = fs::read_to_string(&workspace_path) + .map_err(|error| format!("read {}: {error}", workspace_path.display()))?; + let workspace = toml::from_str::<WorkspaceManifest>(&workspace_raw) + .map_err(|error| format!("parse {}: {error}", workspace_path.display()))?; + let workspace_value = workspace_raw + .parse::<toml::Value>() + .map_err(|error| format!("parse {}: {error}", workspace_path.display()))?; + let workspace_dependencies = workspace_value + .get("workspace") + .and_then(toml::Value::as_table) + .and_then(|workspace| workspace.get("dependencies")) + .and_then(toml::Value::as_table); + let repository = architecture + .repositories + .values() + .find(|repository| repository.url == workspace.workspace.package.repository) + .ok_or_else(|| "workspace repository has no architecture allocation".to_owned())?; + let local_packages = repository + .packages + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + let public_packages = architecture + .package + .iter() + .map(|package| package.name.as_str()) + .collect::<BTreeSet<_>>(); + let package_versions = architecture + .repositories + .values() + .flat_map(|repository| { + repository + .packages + .iter() + .map(|package| (package.as_str(), repository.version.as_str())) + }) + .collect::<BTreeMap<_, _>>(); + + for member in &workspace.workspace.members { + let manifest_path = workspace_root.join(member).join("Cargo.toml"); + let raw = fs::read_to_string(&manifest_path) + .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; + let manifest = raw + .parse::<toml::Value>() + .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; + let package_name = manifest + .get("package") + .and_then(toml::Value::as_table) + .and_then(|package| package.get("name")) + .and_then(toml::Value::as_str) + .ok_or_else(|| format!("{} is missing package.name", manifest_path.display()))?; + if !local_packages.contains(package_name) { + continue; + } + let policy = PublicDependencyPolicy { + workspace_root, + workspace_dependencies, + public_packages: &public_packages, + local_packages: &local_packages, + package_versions: &package_versions, + }; + validate_public_dependency_sections(member, package_name, &manifest, &policy)?; + } + Ok(()) +} + +struct PublicDependencyPolicy<'a> { + workspace_root: &'a Path, + workspace_dependencies: Option<&'a toml::value::Table>, + public_packages: &'a BTreeSet<&'a str>, + local_packages: &'a BTreeSet<&'a str>, + package_versions: &'a BTreeMap<&'a str, &'a str>, +} + +fn validate_public_dependency_sections( + member: &str, + owner: &str, + manifest: &toml::Value, + policy: &PublicDependencyPolicy<'_>, +) -> Result<(), String> { + let manifest_table = manifest + .as_table() + .ok_or_else(|| format!("{member}/Cargo.toml must be a TOML table"))?; + for section in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(dependencies) = manifest_table.get(section).and_then(toml::Value::as_table) { + validate_public_dependency_table(member, owner, section, dependencies, policy)?; + } + } + if let Some(targets) = manifest_table.get("target").and_then(toml::Value::as_table) { + for (target, target_value) in targets { + let Some(target_table) = target_value.as_table() else { + continue; + }; + for section in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(dependencies) = + target_table.get(section).and_then(toml::Value::as_table) + { + validate_public_dependency_table( + member, + owner, + &format!("target.{target}.{section}"), + dependencies, + policy, + )?; + } + } + } + } + Ok(()) +} + +fn validate_public_dependency_table( + member: &str, + owner: &str, + section: &str, + dependencies: &toml::value::Table, + policy: &PublicDependencyPolicy<'_>, +) -> Result<(), String> { + for (dependency_key, declaration) in dependencies { + let inherits_workspace = declaration + .as_table() + .and_then(|table| table.get("workspace")) + .and_then(toml::Value::as_bool) + == Some(true); + let resolved = if inherits_workspace { + policy + .workspace_dependencies + .and_then(|dependencies| dependencies.get(dependency_key)) + .ok_or_else(|| { + format!( + "public package {owner} {section}.{dependency_key} inherits a missing workspace dependency" + ) + })? + } else { + declaration + }; + let resolved_table = resolved.as_table(); + let dependency_path = resolved_table + .and_then(|table| table.get("path")) + .and_then(toml::Value::as_str); + let declared_package = resolved_table + .and_then(|table| table.get("package")) + .and_then(toml::Value::as_str); + let path_base = if inherits_workspace { + policy.workspace_root.to_path_buf() + } else { + policy.workspace_root.join(member) + }; + let path_package = if declared_package.is_none() { + dependency_path + .map(|path| dependency_package_name(&path_base.join(path))) + .transpose()? + } else { + None + }; + let dependency_name = declared_package + .or(path_package.as_deref()) + .unwrap_or(dependency_key.as_str()); + if !policy.public_packages.contains(dependency_name) { + continue; + } + let version = match resolved { + toml::Value::String(version) => Some(version.as_str()), + toml::Value::Table(table) => table.get("version").and_then(toml::Value::as_str), + _ => None, + }; + let governed_version = policy + .package_versions + .get(dependency_name) + .ok_or_else(|| { + format!("public dependency {dependency_name} has no repository version authority") + })?; + let exact_version = format!("={governed_version}"); + if policy.local_packages.contains(dependency_name) + && (dependency_path.is_none() || version != Some(exact_version.as_str())) + { + return Err(format!( + "public package {owner} {section}.{dependency_key} dependency on {dependency_name} must declare path and exact version {exact_version}" + )); + } + if !policy.local_packages.contains(dependency_name) + && (dependency_path.is_some() || version != Some(exact_version.as_str())) + { + return Err(format!( + "public package {owner} {section}.{dependency_key} cross-repository dependency on {dependency_name} must declare exact registry version {exact_version} without a path" + )); + } + } + Ok(()) +} + +fn validate_no_production_sibling_paths(workspace_root: &Path) -> Result<(), String> { + let canonical_root = fs::canonicalize(workspace_root) + .map_err(|error| format!("canonicalize {}: {error}", workspace_root.display()))?; + let workspace_path = workspace_root.join("Cargo.toml"); + let workspace_raw = fs::read_to_string(&workspace_path) + .map_err(|error| format!("read {}: {error}", workspace_path.display()))?; + let workspace = toml::from_str::<WorkspaceManifest>(&workspace_raw) + .map_err(|error| format!("parse {}: {error}", workspace_path.display()))?; + let workspace_value = workspace_raw + .parse::<toml::Value>() + .map_err(|error| format!("parse {}: {error}", workspace_path.display()))?; + if let Some(dependencies) = workspace_value + .get("workspace") + .and_then(toml::Value::as_table) + .and_then(|workspace| workspace.get("dependencies")) + .and_then(toml::Value::as_table) + { + validate_dependency_path_table( + &canonical_root, + &canonical_root, + "workspace", + "workspace.dependencies", + dependencies, + )?; + } + for member in &workspace.workspace.members { + let manifest_path = workspace_root.join(member).join("Cargo.toml"); + let raw = fs::read_to_string(&manifest_path) + .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; + let manifest = raw + .parse::<toml::Value>() + .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; + validate_manifest_dependency_paths( + &canonical_root, + &canonical_root.join(member), + member, + &manifest, + )?; + } + Ok(()) +} + +fn validate_manifest_dependency_paths( + workspace_root: &Path, + package_root: &Path, + owner: &str, + manifest: &toml::Value, +) -> Result<(), String> { + let Some(manifest_table) = manifest.as_table() else { + return Err(format!("{owner}/Cargo.toml must be a TOML table")); + }; + for section in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(dependencies) = manifest_table.get(section).and_then(toml::Value::as_table) { + validate_dependency_path_table( + workspace_root, + package_root, + owner, + section, + dependencies, + )?; + } + } + if let Some(targets) = manifest_table.get("target").and_then(toml::Value::as_table) { + for (target, target_value) in targets { + let Some(target_table) = target_value.as_table() else { + continue; + }; + for section in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(dependencies) = + target_table.get(section).and_then(toml::Value::as_table) + { + validate_dependency_path_table( + workspace_root, + package_root, + owner, + &format!("target.{target}.{section}"), + dependencies, + )?; + } + } + } + } + Ok(()) +} + +fn validate_dependency_path_table( + workspace_root: &Path, + path_base: &Path, + owner: &str, + section: &str, + dependencies: &toml::value::Table, +) -> Result<(), String> { + for (dependency, declaration) in dependencies { + let Some(path) = declaration + .as_table() + .and_then(|table| table.get("path")) + .and_then(toml::Value::as_str) + else { + continue; + }; + let resolved = fs::canonicalize(path_base.join(path)).map_err(|error| { + format!( + "resolve production dependency {owner} {section}.{dependency} path {path}: {error}" + ) + })?; + if !resolved.starts_with(workspace_root) { + return Err(format!( + "production dependency {owner} {section}.{dependency} path {path} escapes the repository; use a registry version or an external local-development override" + )); + } + } + Ok(()) +} + +fn dependency_package_name(package_root: &Path) -> Result<String, String> { + let manifest_path = package_root.join("Cargo.toml"); + let raw = fs::read_to_string(&manifest_path).map_err(|error| { + format!( + "read dependency manifest {}: {error}", + manifest_path.display() + ) + })?; + let manifest = raw.parse::<toml::Value>().map_err(|error| { + format!( + "parse dependency manifest {}: {error}", + manifest_path.display() + ) + })?; + manifest + .get("package") + .and_then(toml::Value::as_table) + .and_then(|package| package.get("name")) + .and_then(toml::Value::as_str) + .map(str::to_owned) + .ok_or_else(|| format!("{} is missing package.name", manifest_path.display())) +} + +fn validate_public_manifest_field( + package: &toml::value::Table, + key: &str, + workspace_value: &str, + expected: &str, + package_name: &str, +) -> Result<(), String> { + let value = resolve_public_string(package, key, workspace_value) + .ok_or_else(|| format!("public package {package_name} must declare {key}"))?; + if value != expected { + return Err(format!( + "public package {package_name} {key} {value} must be {expected}" + )); + } + Ok(()) +} + +fn resolve_public_string<'a>( + package: &'a toml::value::Table, + key: &str, + workspace_value: &'a str, +) -> Option<&'a str> { + match package.get(key)? { + toml::Value::String(value) => Some(value), + toml::Value::Table(value) + if value.get("workspace").and_then(toml::Value::as_bool) == Some(true) => + { + Some(workspace_value) + } + _ => None, + } +} + +fn resolve_public_authors<'a>( + package: &'a toml::value::Table, + workspace_authors: &'a [String], +) -> Option<Vec<&'a str>> { + match package.get("authors")? { + toml::Value::Array(values) => values.iter().map(toml::Value::as_str).collect(), + toml::Value::Table(value) + if value.get("workspace").and_then(toml::Value::as_bool) == Some(true) => + { + Some(workspace_authors.iter().map(String::as_str).collect()) + } + _ => None, + } +} + +fn validate_workspace_members(workspace_root: &Path) -> Result<(), String> { + let manifest_path = workspace_root.join("Cargo.toml"); + let manifest_raw = fs::read_to_string(&manifest_path) + .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; + let manifest = toml::from_str::<WorkspaceMembershipManifest>(&manifest_raw) + .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; + let manifest_value = manifest_raw + .parse::<toml::Value>() + .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; + validate_manifest_dependency_keys(&manifest_value, "Cargo.toml")?; + let declared = manifest + .workspace + .members + .into_iter() + .collect::<BTreeSet<_>>(); + let mut discovered = BTreeSet::new(); + for root in ["crates", "tools"] { + let directory = workspace_root.join(root); + for entry in fs::read_dir(&directory) + .map_err(|error| format!("read {}: {error}", directory.display()))? + { + let entry = + entry.map_err(|error| format!("read {} entry: {error}", directory.display()))?; + if entry + .file_type() + .map_err(|error| format!("read {} type: {error}", entry.path().display()))? + .is_dir() + && entry.path().join("Cargo.toml").is_file() + { + let directory_name = entry.file_name().to_string_lossy().into_owned(); + require_lower_snake_case( + &directory_name, + &format!("{root}/{directory_name} crate directory"), + )?; + let package_manifest_path = entry.path().join("Cargo.toml"); + let package_manifest_raw = + fs::read_to_string(&package_manifest_path).map_err(|error| { + format!("read {}: {error}", package_manifest_path.display()) + })?; + let package_manifest = + package_manifest_raw + .parse::<toml::Value>() + .map_err(|error| { + format!("parse {}: {error}", package_manifest_path.display()) + })?; + let package_name = package_manifest + .get("package") + .and_then(toml::Value::as_table) + .and_then(|package| package.get("name")) + .and_then(toml::Value::as_str) + .ok_or_else(|| { + format!( + "{} is missing package.name", + package_manifest_path.display() + ) + })?; + require_lower_snake_case( + package_name, + &format!("{} package.name", package_manifest_path.display()), + )?; + validate_manifest_dependency_keys( + &package_manifest, + &package_manifest_path.display().to_string(), + )?; + discovered.insert(format!("{root}/{directory_name}")); + } + } + } + if declared != discovered { + let missing = discovered + .difference(&declared) + .cloned() + .collect::<Vec<_>>() + .join(", "); + let unknown = declared + .difference(&discovered) + .cloned() + .collect::<Vec<_>>() + .join(", "); + return Err(format!( + "workspace membership is missing local package roots: {missing}; workspace membership has unknown package roots: {unknown}" + )); + } + Ok(()) +} + +fn validate_manifest_dependency_keys(manifest: &toml::Value, label: &str) -> Result<(), String> { + let Some(manifest) = manifest.as_table() else { + return Err(format!("{label} must be a TOML table")); + }; + for section in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(dependencies) = manifest.get(section).and_then(toml::Value::as_table) { + validate_dependency_keys(dependencies, label, section)?; + } + } + if let Some(workspace_dependencies) = manifest + .get("workspace") + .and_then(toml::Value::as_table) + .and_then(|workspace| workspace.get("dependencies")) + .and_then(toml::Value::as_table) + { + validate_dependency_keys(workspace_dependencies, label, "workspace.dependencies")?; + } + if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) { + for (target, target_value) in targets { + let Some(target_table) = target_value.as_table() else { + continue; + }; + for section in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(dependencies) = + target_table.get(section).and_then(toml::Value::as_table) + { + validate_dependency_keys( + dependencies, + label, + &format!("target.{target}.{section}"), + )?; + } + } + } + } + Ok(()) +} + +fn validate_dependency_keys( + dependencies: &toml::value::Table, + label: &str, + section: &str, +) -> Result<(), String> { + for key in dependencies + .keys() + .filter(|key| key.starts_with("radroots")) + { + require_lower_snake_case(key, &format!("{label} {section} dependency key"))?; + } + Ok(()) +} + +fn require_lower_snake_case(value: &str, label: &str) -> Result<(), String> { + if is_lower_snake_case(value) { + Ok(()) + } else { + Err(format!("{label} `{value}` must use lowercase snake case")) + } +} + +fn is_lower_snake_case(value: &str) -> bool { + !value.is_empty() + && value.split('_').all(|segment| { + segment + .as_bytes() + .first() + .is_some_and(u8::is_ascii_lowercase) + && segment + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) +} + +fn validate_ledger( + workspace_root: &Path, + expected_architecture_id: &str, + raw: &str, +) -> Result<(), String> { + let ledger = toml::from_str::<DeviationLedger>(raw) + .map_err(|error| format!("parse {DEVIATIONS_RELATIVE}: {error}"))?; + if ledger.schema_version != 1 { + return Err("deviation ledger schema_version must be 1".to_owned()); + } + if ledger.architecture_id != expected_architecture_id + || ledger.architecture_id != ARCHITECTURE_ID + { + return Err(format!( + "deviation ledger architecture_id {} must match {}", + ledger.architecture_id, expected_architecture_id + )); + } + + let mut ids = BTreeSet::new(); + for record in &ledger.deviation { + validate_record(workspace_root, record)?; + if !ids.insert(record.id.as_str()) { + return Err(format!("duplicate deviation id {}", record.id)); + } + } + Ok(()) +} + +fn validate_record(workspace_root: &Path, record: &DeviationRecord) -> Result<(), String> { + if !is_deviation_id(&record.id) { + return Err(format!("deviation id {} must use RCRV1-DEV-NNN", record.id)); + } + if !is_iso_date(&record.date) { + return Err(format!("deviation {} date must use YYYY-MM-DD", record.id)); + } + if !matches!(record.status.as_str(), "active" | "closed" | "superseded") { + return Err(format!( + "deviation {} status must be active, closed, or superseded", + record.id + )); + } + require_text(&record.id, "approval", &record.approval)?; + require_text(&record.id, "replacement_action", &record.replacement_action)?; + require_text(&record.id, "unresolved_risk", &record.unresolved_risk)?; + require_nonempty_list(&record.id, "affected_steps", &record.affected_steps)?; + require_nonempty_list(&record.id, "spec_anchors", &record.spec_anchors)?; + require_nonempty_list(&record.id, "source_evidence", &record.source_evidence)?; + require_nonempty_list(&record.id, "verification", &record.verification)?; + + for step in &record.affected_steps { + let valid = step.len() == 3 + && step.bytes().all(|byte| byte.is_ascii_digit()) + && step + .parse::<u16>() + .is_ok_and(|value| (1..=315).contains(&value)); + if !valid { + return Err(format!( + "deviation {} affected step {} must be in 001..315", + record.id, step + )); + } + } + for anchor in &record.spec_anchors { + validate_spec_anchor(workspace_root, &record.id, anchor)?; + } + if record.status == "active" && !record.closure_evidence.is_empty() { + return Err(format!( + "active deviation {} must not carry closure_evidence", + record.id + )); + } + if record.status != "active" { + require_nonempty_list(&record.id, "closure_evidence", &record.closure_evidence)?; + } + + let _ = (record.normative_architecture_change, record.adr_required); + Ok(()) +} + +fn validate_spec_anchor( + workspace_root: &Path, + deviation_id: &str, + anchor: &str, +) -> Result<(), String> { + let (relative, fragment) = anchor + .split_once('#') + .map_or((anchor, None), |(path, fragment)| (path, Some(fragment))); + if relative.trim().is_empty() || fragment.is_some_and(|value| value.trim().is_empty()) { + return Err(format!( + "deviation {deviation_id} has invalid spec anchor {anchor}" + )); + } + let path = Path::new(relative); + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(format!( + "deviation {deviation_id} spec anchor must be repository-relative: {anchor}" + )); + } + if !relative.starts_with("docs/specs/") || !workspace_root.join(path).is_file() { + return Err(format!( + "deviation {deviation_id} spec anchor does not resolve to a local spec: {anchor}" + )); + } + Ok(()) +} + +fn require_text(deviation_id: &str, field: &str, value: &str) -> Result<(), String> { + if value.trim().is_empty() { + return Err(format!( + "deviation {deviation_id} field {field} must not be empty" + )); + } + Ok(()) +} + +fn require_nonempty_list(deviation_id: &str, field: &str, values: &[String]) -> Result<(), String> { + if values.is_empty() || values.iter().any(|value| value.trim().is_empty()) { + return Err(format!( + "deviation {deviation_id} field {field} must contain non-empty values" + )); + } + Ok(()) +} + +fn is_deviation_id(value: &str) -> bool { + value + .strip_prefix("RCRV1-DEV-") + .is_some_and(|suffix| suffix.len() == 3 && suffix.bytes().all(|byte| byte.is_ascii_digit())) +} + +fn is_iso_date(value: &str) -> bool { + value.len() == 10 + && value.as_bytes()[4] == b'-' + && value.as_bytes()[7] == b'-' + && value + .bytes() + .enumerate() + .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeMap, + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::{ + ArchitectureIdentity, ArchitecturePackage, ArchitectureRepository, validate_ledger, + validate_no_production_sibling_paths, validate_public_dependency_versions, + validate_public_package_metadata, validate_workspace_members, validate_workspace_toolchain, + }; + + fn test_root(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!("radroots_architecture_{label}_{nonce}")); + fs::create_dir_all(root.join("docs/specs")).expect("create spec root"); + fs::write( + root.join("docs/specs/radroots_crates_release_v1.md"), + "# Architecture\n", + ) + .expect("write spec"); + root + } + + fn complete_ledger() -> &'static str { + r#"schema_version = 1 +architecture_id = "radroots.crates.release.v1" + +[[deviation]] +id = "RCRV1-DEV-001" +date = "2026-07-27" +status = "active" +approval = "Explicit user correction dated 2026-07-27." +affected_steps = ["015", "016"] +spec_anchors = ["docs/specs/radroots_crates_release_v1.md#repository-topology"] +source_evidence = ["The approved architecture assigns packages to the existing lib and sdk repositories."] +replacement_action = "Keep both standalone repositories and verify them independently." +verification = ["Repository-local architecture validation passes."] +unresolved_risk = "Remote publication remains separately authorized." +normative_architecture_change = false +adr_required = false +"# + } + + fn architecture() -> ArchitectureIdentity { + ArchitectureIdentity { + spec_id: "radroots.crates.release.v1".to_string(), + edition: "2024".to_string(), + resolver: "3".to_string(), + rust_version: "1.97.1".to_string(), + license: "MIT OR Apache-2.0".to_string(), + canonical_repositories: vec!["https://github.com/radrootslabs/sdk".to_string()], + repositories: BTreeMap::from([( + "sdk".to_string(), + ArchitectureRepository { + url: "https://github.com/radrootslabs/sdk".to_string(), + version: "0.1.0".to_string(), + packages: vec!["radroots".to_string()], + }, + )]), + package: vec![ArchitecturePackage { + name: "radroots".to_string(), + }], + } + } + + fn complete_workspace_manifest(members: &str) -> String { + format!( + "[workspace]\nmembers = [{members}]\nresolver = \"3\"\n\n[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\nrust-version = \"1.97.1\"\nlicense = \"MIT OR Apache-2.0\"\nrepository = \"https://github.com/radrootslabs/sdk\"\nhomepage = \"https://radroots.org\"\nreadme = \"README\"\nauthors = [\"Tyson Lupul <tyson@radroots.org>\"]\n\n[workspace.metadata.radroots.public-package]\nversion = \"0.1.0\"\nauthors = [\"Tyson Lupul <tyson@radroots.org>\"]\nreadme = \"README.md\"\n\n[workspace.lints.rust]\nunsafe_code = \"forbid\"\n\n[workspace.lints.rustdoc]\nbroken_intra_doc_links = \"deny\"\n\n[workspace.lints.clippy]\ndbg_macro = \"deny\"\ntodo = \"deny\"\nunimplemented = \"deny\"\n" + ) + } + + #[test] + fn accepts_complete_active_deviation() { + let root = test_root("complete"); + validate_ledger(&root, "radroots.crates.release.v1", complete_ledger()) + .expect("complete deviation"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_incomplete_active_deviation() { + let root = test_root("incomplete"); + let incomplete = complete_ledger().replace( + "spec_anchors = [\"docs/specs/radroots_crates_release_v1.md#repository-topology\"]", + "spec_anchors = []", + ); + let error = validate_ledger(&root, "radroots.crates.release.v1", &incomplete) + .expect_err("missing anchor must fail"); + assert!(error.contains("field spec_anchors must contain non-empty values")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn workspace_membership_requires_every_local_package_root() { + let root = test_root("workspace_members"); + for path in ["crates/a", "tools/xtask"] { + fs::create_dir_all(root.join(path)).expect("create package root"); + fs::write( + root.join(path).join("Cargo.toml"), + "[package]\nname = \"fixture\"\n", + ) + .expect("write package manifest"); + } + fs::write( + root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/a\", \"tools/xtask\"]\nresolver = \"3\"\n\n[workspace.package]\nrust-version = \"1.97.1\"\n", + ) + .expect("write complete workspace"); + validate_workspace_members(&root).expect("complete membership"); + + fs::write( + root.join("Cargo.toml"), + "[workspace]\nmembers = [\"tools/xtask\"]\nresolver = \"3\"\n\n[workspace.package]\nrust-version = \"1.97.1\"\n", + ) + .expect("write incomplete workspace"); + let error = validate_workspace_members(&root).expect_err("missing member must fail"); + assert!(error.contains("missing local package roots: crates/a")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn workspace_identity_rejects_kebab_case_crate_names_and_dependency_keys() { + let root = test_root("workspace_snake_case_identity"); + fs::create_dir_all(root.join("crates/radroots_probe")).expect("create package root"); + fs::create_dir_all(root.join("tools/xtask")).expect("create xtask root"); + fs::write( + root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/radroots_probe\", \"tools/xtask\"]\nresolver = \"3\"\n", + ) + .expect("write workspace manifest"); + fs::write( + root.join("crates/radroots_probe/Cargo.toml"), + "[package]\nname = \"radroots-probe\"\n", + ) + .expect("write kebab package manifest"); + fs::write( + root.join("tools/xtask/Cargo.toml"), + "[package]\nname = \"xtask\"\n", + ) + .expect("write xtask manifest"); + + let package_error = validate_workspace_members(&root) + .expect_err("kebab-case Cargo package identity must fail"); + assert!(package_error.contains("package.name")); + assert!(package_error.contains("lowercase snake case")); + + fs::write( + root.join("crates/radroots_probe/Cargo.toml"), + "[package]\nname = \"radroots_probe\"\n", + ) + .expect("write snake package manifest"); + fs::write( + root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/radroots_probe\", \"tools/xtask\"]\nresolver = \"3\"\n\n[workspace.dependencies]\nradroots-probe = \"0.1.0\"\n", + ) + .expect("write kebab dependency key"); + + let dependency_error = validate_workspace_members(&root) + .expect_err("kebab-case Radroots dependency key must fail"); + assert!(dependency_error.contains("workspace.dependencies dependency key")); + assert!(dependency_error.contains("lowercase snake case")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn workspace_toolchain_requires_exact_resolver_and_rust_version() { + let root = test_root("workspace_toolchain"); + fs::write(root.join("Cargo.toml"), complete_workspace_manifest("")) + .expect("write workspace manifest"); + for path in ["README", "LICENSE-MIT", "LICENSE-APACHE"] { + fs::write(root.join(path), "fixture\n").expect("write workspace metadata file"); + } + fs::write( + root.join("rust-toolchain.toml"), + "[toolchain]\nchannel = \"1.97.1\"\n", + ) + .expect("write toolchain"); + let architecture = architecture(); + validate_workspace_toolchain(&root, &architecture).expect("exact toolchain policy"); + + fs::write( + root.join("rust-toolchain.toml"), + "[toolchain]\nchannel = \"1.97.0\"\n", + ) + .expect("write mismatched toolchain"); + let error = validate_workspace_toolchain(&root, &architecture) + .expect_err("mismatched toolchain must fail"); + assert!(error.contains("channel must match")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn public_package_metadata_requires_canonical_inheritance() { + let root = test_root("public_package_metadata"); + fs::create_dir_all(root.join("crates/radroots")).expect("create public package"); + fs::write( + root.join("Cargo.toml"), + complete_workspace_manifest("\"crates/radroots\""), + ) + .expect("write workspace manifest"); + let manifest = "[package]\nname = \"radroots\"\nversion.workspace = true\nedition.workspace = true\nrust-version.workspace = true\nlicense.workspace = true\nrepository.workspace = true\nhomepage.workspace = true\nauthors.workspace = true\nreadme = \"README.md\"\npublish = false\n\n[lints]\nworkspace = true\n"; + fs::write(root.join("crates/radroots/Cargo.toml"), manifest) + .expect("write public manifest"); + fs::write(root.join("crates/radroots/README.md"), "fixture\n") + .expect("write public readme"); + validate_public_package_metadata(&root, &architecture()).expect("canonical metadata"); + + fs::write( + root.join("crates/radroots/Cargo.toml"), + manifest.replace("[lints]\nworkspace = true\n", ""), + ) + .expect("write incomplete public manifest"); + let error = validate_public_package_metadata(&root, &architecture()) + .expect_err("missing lint inheritance must fail"); + assert!(error.contains("must inherit the workspace lint policy")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn public_package_inventory_requires_every_local_package() { + let root = test_root("public_package_inventory"); + fs::write(root.join("Cargo.toml"), complete_workspace_manifest("")) + .expect("write workspace manifest"); + let error = validate_public_package_metadata(&root, &architecture()) + .expect_err("missing local public package must fail"); + assert!(error.contains("workspace public package inventory is missing: radroots")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn public_dependencies_require_path_and_exact_version() { + let root = test_root("public_dependency_version"); + fs::create_dir_all(root.join("crates/radroots")).expect("create public package"); + fs::create_dir_all(root.join("crates/dependency")).expect("create dependency package"); + fs::write( + root.join("Cargo.toml"), + format!( + "{}\n[workspace.dependencies]\nradroots_core = {{ package = \"radroots_core\", path = \"crates/dependency\", version = \"=0.1.0\" }}\n", + complete_workspace_manifest("\"crates/radroots\"") + ), + ) + .expect("write workspace manifest"); + fs::write( + root.join("crates/radroots/Cargo.toml"), + "[package]\nname = \"radroots\"\nversion = \"0.1.0\"\n\n[dependencies]\nradroots_core = { workspace = true }\n", + ) + .expect("write public manifest"); + fs::write( + root.join("crates/dependency/Cargo.toml"), + "[package]\nname = \"radroots_core\"\nversion = \"0.1.0\"\n", + ) + .expect("write dependency manifest"); + let mut architecture = architecture(); + architecture.package.push(ArchitecturePackage { + name: "radroots_core".to_owned(), + }); + architecture + .repositories + .get_mut("sdk") + .expect("sdk repository") + .packages + .push("radroots_core".to_owned()); + validate_public_dependency_versions(&root, &architecture) + .expect("path plus exact version public dependency"); + + let workspace_path = root.join("Cargo.toml"); + let workspace = fs::read_to_string(&workspace_path).expect("read workspace manifest"); + fs::write( + &workspace_path, + workspace.replace(", version = \"=0.1.0\"", ""), + ) + .expect("write path-only dependency"); + let error = validate_public_dependency_versions(&root, &architecture) + .expect_err("path-only public dependency must fail"); + assert!(error.contains("must declare path and exact version =0.1.0")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn production_dependencies_reject_sibling_paths() { + let root = test_root("production_sibling_path"); + let sibling = root.with_extension("sibling"); + fs::create_dir_all(root.join("crates/radroots")).expect("create public package"); + fs::create_dir_all(root.join("crates/dependency")).expect("create local dependency"); + fs::create_dir_all(&sibling).expect("create sibling dependency"); + fs::write( + root.join("Cargo.toml"), + complete_workspace_manifest("\"crates/radroots\""), + ) + .expect("write workspace manifest"); + let sibling_path = sibling.to_string_lossy(); + fs::write( + root.join("crates/radroots/Cargo.toml"), + format!( + "[package]\nname = \"radroots\"\nversion = \"0.1.0\"\n\n[dependencies]\nprobe = {{ path = \"{sibling_path}\" }}\n" + ), + ) + .expect("write sibling dependency"); + let error = validate_no_production_sibling_paths(&root) + .expect_err("sibling production path must fail"); + assert!(error.contains("escapes the repository")); + + fs::write( + root.join("crates/radroots/Cargo.toml"), + "[package]\nname = \"radroots\"\nversion = \"0.1.0\"\n\n[dependencies]\nprobe = { path = \"../dependency\" }\n", + ) + .expect("write local dependency"); + fs::create_dir_all(root.join(".cargo")).expect("create cargo config directory"); + fs::write( + root.join(".cargo/config.toml"), + format!("[patch.crates-io]\nprobe = {{ path = \"{sibling_path}\" }}\n"), + ) + .expect("write development override"); + validate_no_production_sibling_paths(&root).expect("in-repository path"); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(sibling); + } +} diff --git a/tools/xtask/src/architecture/api_leakage.rs b/tools/xtask/src/architecture/api_leakage.rs @@ -0,0 +1,1433 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Component, Path, PathBuf}, + process::Command, +}; + +use serde::Deserialize; +use syn::{ + Expr, ExprLit, Fields, ForeignItem, ImplItem, Item, Lit, Meta, Path as SynPath, TraitItem, + Type, UseTree, Visibility, visit::Visit, +}; + +const API_BOUNDARIES_RELATIVE: &str = "contracts/releases/api_boundaries.toml"; +const SPEC_ID: &str = "radroots.crates.release.v1"; +const POLICY_SCHEMA_VERSION: u16 = 1; +const CURRENT_STEP: u16 = 25; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ApiBoundaryPolicy { + schema_version: u16, + spec_id: String, + forbidden_public_paths: Vec<String>, + package: Vec<ApiPackagePolicy>, + #[serde(default)] + exception: Vec<ApiException>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ApiPackagePolicy { + name: String, + allowed_public_paths: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ApiException { + id: String, + package: String, + source: String, + forbidden_path: String, + items: Vec<String>, + observed_paths: Vec<String>, + adr: String, + removal_step: u16, + rationale: String, +} + +#[derive(Debug, Deserialize)] +struct CargoMetadata { + packages: Vec<CargoPackage>, + workspace_members: Vec<String>, +} + +#[derive(Debug, Deserialize)] +struct CargoPackage { + id: String, + name: String, + manifest_path: String, + targets: Vec<CargoTarget>, +} + +#[derive(Debug, Deserialize)] +struct CargoTarget { + kind: Vec<String>, + src_path: String, +} + +struct ModuleUnit { + module: Vec<String>, + parent: Option<Vec<String>>, + declared_public: bool, + initially_effective: bool, + source_relative: String, + items: Vec<Item>, +} + +#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)] +struct ApiFinding { + package: String, + source: String, + item: String, + forbidden_path: String, + observed_path: String, +} + +#[derive(Debug)] +enum InternalExport { + Module(Vec<String>), + Item(Vec<String>, String), + External, +} + +pub(super) fn validate_policy_catalog( + workspace_root: &Path, + expected_packages: &BTreeSet<String>, +) -> Result<(), String> { + let policy = load_policy(workspace_root)?; + validate_policy(workspace_root, &policy, expected_packages) +} + +pub(super) fn validate_public_api(workspace_root: &Path) -> Result<(), String> { + let policy = load_policy(workspace_root)?; + let expected_packages = policy + .package + .iter() + .map(|package| package.name.clone()) + .collect::<BTreeSet<_>>(); + validate_policy(workspace_root, &policy, &expected_packages)?; + let metadata = load_metadata(workspace_root)?; + let findings = scan_workspace(workspace_root, &policy, &metadata)?; + let unapproved = findings + .into_iter() + .filter(|finding| !exception_matches(&policy.exception, finding)) + .collect::<Vec<_>>(); + if unapproved.is_empty() { + Ok(()) + } else { + Err(format!( + "forbidden public implementation type leakage:\n{}", + unapproved + .iter() + .map(format_finding) + .collect::<Vec<_>>() + .join("\n") + )) + } +} + +fn load_policy(workspace_root: &Path) -> Result<ApiBoundaryPolicy, String> { + let path = workspace_root.join(API_BOUNDARIES_RELATIVE); + let raw = + fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; + toml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display())) +} + +fn validate_policy( + workspace_root: &Path, + policy: &ApiBoundaryPolicy, + expected_packages: &BTreeSet<String>, +) -> Result<(), String> { + if policy.schema_version != POLICY_SCHEMA_VERSION { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} schema_version must be {POLICY_SCHEMA_VERSION}" + )); + } + if policy.spec_id != SPEC_ID { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} spec_id must be {SPEC_ID}" + )); + } + + let forbidden = sorted_unique( + "forbidden_public_paths", + &policy.forbidden_public_paths, + false, + )?; + if forbidden.len() != policy.forbidden_public_paths.len() { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} forbidden_public_paths must be unique" + )); + } + for required in [ + "keyring", + "nostr_sdk", + "reqwest", + "sqlx", + "std::os", + "tokio", + ] { + if !forbidden.contains(required) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} must forbid public {required} paths" + )); + } + } + + let mut package_names = BTreeSet::new(); + for package in &policy.package { + if !package_names.insert(package.name.as_str()) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} package {} is declared more than once", + package.name + )); + } + let allowed = sorted_unique( + &format!("package {} allowed_public_paths", package.name), + &package.allowed_public_paths, + true, + )?; + for path in allowed { + if !forbidden.contains(path) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} package {} allows {path}, which is not forbidden globally", + package.name + )); + } + } + } + let actual_packages = package_names + .into_iter() + .map(str::to_owned) + .collect::<BTreeSet<_>>(); + if &actual_packages != expected_packages { + return Err(package_set_mismatch(expected_packages, &actual_packages)); + } + + let package_policy = policy + .package + .iter() + .map(|package| (package.name.as_str(), package)) + .collect::<BTreeMap<_, _>>(); + let mut exception_ids = BTreeSet::new(); + let mut exception_keys = BTreeSet::new(); + for exception in &policy.exception { + if !exception_ids.insert(exception.id.as_str()) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception id {} is duplicated", + exception.id + )); + } + if !valid_exception_id(&exception.id) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception id {} must match RCRV1-API-NNN", + exception.id + )); + } + let package = package_policy + .get(exception.package.as_str()) + .ok_or_else(|| { + format!( + "{API_BOUNDARIES_RELATIVE} exception {} names unknown package {}", + exception.id, exception.package + ) + })?; + if !forbidden.contains(exception.forbidden_path.as_str()) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} names unknown forbidden path {}", + exception.id, exception.forbidden_path + )); + } + if package + .allowed_public_paths + .iter() + .any(|allowed| allowed == &exception.forbidden_path) + { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} is redundant with package allowance {}", + exception.id, exception.forbidden_path + )); + } + validate_relative_source(&exception.id, &exception.source)?; + let items = sorted_unique( + &format!("exception {} items", exception.id), + &exception.items, + false, + )?; + if items.len() != exception.items.len() { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} items must be unique", + exception.id + )); + } + let observed_paths = sorted_unique( + &format!("exception {} observed_paths", exception.id), + &exception.observed_paths, + false, + )?; + if observed_paths.len() != exception.observed_paths.len() + || observed_paths.iter().any(|path| { + !(path == &exception.forbidden_path + || path + .strip_prefix(&exception.forbidden_path) + .is_some_and(|suffix| suffix.starts_with("::"))) + }) + { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} observed_paths must be sorted, unique, and rooted at {}", + exception.id, exception.forbidden_path + )); + } + if exception.removal_step <= CURRENT_STEP { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} expired at Step {}", + exception.id, exception.removal_step + )); + } + if exception.rationale.trim().is_empty() { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} must include a rationale", + exception.id + )); + } + validate_adr(workspace_root, exception)?; + for item in &exception.items { + let key = ( + exception.package.as_str(), + exception.source.as_str(), + item.as_str(), + exception.forbidden_path.as_str(), + ); + if !exception_keys.insert(key) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} duplicates an item-scoped allowance", + exception.id + )); + } + } + } + Ok(()) +} + +fn sorted_unique<'a>( + label: &str, + values: &'a [String], + allow_empty: bool, +) -> Result<BTreeSet<&'a str>, String> { + if !allow_empty && values.is_empty() { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} {label} must not be empty" + )); + } + let unique = values.iter().map(String::as_str).collect::<BTreeSet<_>>(); + if unique.iter().copied().ne(values.iter().map(String::as_str)) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} {label} must be sorted and unique" + )); + } + Ok(unique) +} + +fn valid_exception_id(id: &str) -> bool { + id.strip_prefix("RCRV1-API-") + .is_some_and(|suffix| suffix.len() == 3 && suffix.bytes().all(|byte| byte.is_ascii_digit())) +} + +fn validate_relative_source(id: &str, source: &str) -> Result<(), String> { + let path = Path::new(source); + if source.is_empty() + || path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || path.extension().and_then(|extension| extension.to_str()) != Some("rs") + { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {id} source must be a normalized relative Rust path" + )); + } + Ok(()) +} + +fn validate_adr(workspace_root: &Path, exception: &ApiException) -> Result<(), String> { + let path = Path::new(&exception.adr); + if path.is_absolute() + || !exception.adr.starts_with("docs/decisions/") + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || path.extension().and_then(|extension| extension.to_str()) != Some("md") + { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} ADR must be a normalized docs/decisions/*.md path", + exception.id + )); + } + let full = workspace_root.join(path); + let raw = fs::read_to_string(&full).map_err(|error| { + format!( + "{API_BOUNDARIES_RELATIVE} exception {} ADR {} is not readable: {error}", + exception.id, + full.display() + ) + })?; + if !raw.contains(&exception.id) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} ADR {} must cite the exception id", + exception.id, exception.adr + )); + } + Ok(()) +} + +fn package_set_mismatch(expected: &BTreeSet<String>, actual: &BTreeSet<String>) -> String { + let missing = expected + .difference(actual) + .cloned() + .collect::<Vec<_>>() + .join(", "); + let extra = actual + .difference(expected) + .cloned() + .collect::<Vec<_>>() + .join(", "); + format!( + "{API_BOUNDARIES_RELATIVE} package catalog mismatch; missing: {missing}; extra: {extra}" + ) +} + +fn load_metadata(workspace_root: &Path) -> Result<CargoMetadata, String> { + let output = Command::new("cargo") + .args(["metadata", "--format-version", "1", "--locked", "--no-deps"]) + .current_dir(workspace_root) + .output() + .map_err(|error| format!("run cargo metadata: {error}"))?; + if !output.status.success() { + return Err(format!( + "cargo metadata failed while checking public API boundaries: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("parse cargo metadata for public API boundaries: {error}")) +} + +fn scan_workspace( + _workspace_root: &Path, + policy: &ApiBoundaryPolicy, + metadata: &CargoMetadata, +) -> Result<Vec<ApiFinding>, String> { + let workspace_members = metadata + .workspace_members + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + let package_policy = policy + .package + .iter() + .map(|package| (package.name.as_str(), package)) + .collect::<BTreeMap<_, _>>(); + let forbidden = policy + .forbidden_public_paths + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + let mut findings = BTreeSet::new(); + + for package in &metadata.packages { + if !workspace_members.contains(package.id.as_str()) { + continue; + } + let Some(package_policy) = package_policy.get(package.name.as_str()).copied() else { + continue; + }; + let manifest_path = Path::new(&package.manifest_path); + let package_root = manifest_path.parent().ok_or_else(|| { + format!( + "package {} manifest has no parent: {}", + package.name, package.manifest_path + ) + })?; + let allowed = package_policy + .allowed_public_paths + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + let library_targets = package + .targets + .iter() + .filter(|target| { + target + .kind + .iter() + .any(|kind| kind == "lib" || kind == "proc-macro") + }) + .collect::<Vec<_>>(); + if library_targets.len() != 1 { + return Err(format!( + "public package {} must expose exactly one library target for API leakage analysis", + package.name + )); + } + let source_path = Path::new(&library_targets[0].src_path); + let source_module_directory = module_directory(source_path)?; + let mut units = BTreeMap::new(); + collect_module( + package_root, + Vec::new(), + None, + true, + true, + source_path, + &source_module_directory, + None, + &mut units, + )?; + findings.extend(scan_package_units( + &package.name, + &units, + &forbidden, + &allowed, + )?); + } + + Ok(findings.into_iter().collect()) +} + +#[allow(clippy::too_many_arguments)] +fn collect_module( + package_root: &Path, + module: Vec<String>, + parent: Option<Vec<String>>, + declared_public: bool, + initially_effective: bool, + source_path: &Path, + module_directory: &Path, + inline_items: Option<Vec<Item>>, + units: &mut BTreeMap<Vec<String>, ModuleUnit>, +) -> Result<(), String> { + if units.contains_key(&module) { + return Ok(()); + } + let (items, source_relative) = if let Some(items) = inline_items { + let relative = source_path + .strip_prefix(package_root) + .map_err(|_| { + format!( + "module source {} escapes package {}", + source_path.display(), + package_root.display() + ) + })? + .to_string_lossy() + .replace('\\', "/"); + (items, relative) + } else { + let raw = fs::read_to_string(source_path) + .map_err(|error| format!("read {}: {error}", source_path.display()))?; + let file = syn::parse_file(&raw) + .map_err(|error| format!("parse {}: {error}", source_path.display()))?; + let relative = source_path + .strip_prefix(package_root) + .map_err(|_| { + format!( + "module source {} escapes package {}", + source_path.display(), + package_root.display() + ) + })? + .to_string_lossy() + .replace('\\', "/"); + (file.items, relative) + }; + + let children = items + .iter() + .filter_map(|item| match item { + Item::Mod(item_mod) => Some(item_mod.clone()), + _ => None, + }) + .collect::<Vec<_>>(); + units.insert( + module.clone(), + ModuleUnit { + module: module.clone(), + parent, + declared_public, + initially_effective, + source_relative, + items, + }, + ); + + for child in children { + let mut child_module = module.clone(); + child_module.push(child.ident.to_string()); + let child_public = is_public(&child.vis); + let child_effective = initially_effective && child_public; + if let Some((_, inline)) = child.content { + collect_module( + package_root, + child_module, + Some(module.clone()), + child_public, + child_effective, + source_path, + &module_directory.join(child.ident.to_string()), + Some(inline), + units, + )?; + } else { + let child_path = module_source_path(source_path, module_directory, &child)?; + collect_module( + package_root, + child_module, + Some(module.clone()), + child_public, + child_effective, + &child_path, + &module_directory.join(child.ident.to_string()), + None, + units, + )?; + } + } + Ok(()) +} + +fn module_directory(source_path: &Path) -> Result<PathBuf, String> { + let parent = source_path + .parent() + .ok_or_else(|| format!("module source has no parent: {}", source_path.display()))?; + let file_name = source_path.file_name().and_then(|name| name.to_str()); + if matches!(file_name, Some("lib.rs" | "main.rs" | "mod.rs")) { + Ok(parent.to_path_buf()) + } else { + Ok(parent.join( + source_path + .file_stem() + .ok_or_else(|| format!("module source has no stem: {}", source_path.display()))?, + )) + } +} + +fn module_source_path( + source_path: &Path, + module_directory: &Path, + item_mod: &syn::ItemMod, +) -> Result<PathBuf, String> { + if let Some(path) = path_attribute(item_mod) { + return Ok(source_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(path)); + } + let name = item_mod.ident.to_string(); + let flat = module_directory.join(format!("{name}.rs")); + let nested = module_directory.join(&name).join("mod.rs"); + match (flat.is_file(), nested.is_file()) { + (true, false) => Ok(flat), + (false, true) => Ok(nested), + (true, true) => Err(format!( + "module {} is ambiguous between {} and {}", + item_mod.ident, + flat.display(), + nested.display() + )), + (false, false) => Err(format!( + "module {} source is missing under {}", + item_mod.ident, + module_directory.display() + )), + } +} + +fn path_attribute(item_mod: &syn::ItemMod) -> Option<String> { + item_mod.attrs.iter().find_map(|attribute| { + if !attribute.path().is_ident("path") { + return None; + } + let Meta::NameValue(value) = &attribute.meta else { + return None; + }; + let Expr::Lit(ExprLit { + lit: Lit::Str(path), + .. + }) = &value.value + else { + return None; + }; + Some(path.value()) + }) +} + +fn scan_package_units( + package: &str, + units: &BTreeMap<Vec<String>, ModuleUnit>, + forbidden: &BTreeSet<&str>, + allowed: &BTreeSet<&str>, +) -> Result<Vec<ApiFinding>, String> { + let mut effective = units + .values() + .filter(|unit| unit.initially_effective) + .map(|unit| unit.module.clone()) + .collect::<BTreeSet<_>>(); + let mut exported_items = BTreeSet::new(); + let mut changed = true; + while changed { + changed = propagate_public_modules(units, &mut effective); + let scopes = effective.iter().cloned().collect::<Vec<_>>(); + for scope in scopes { + let unit = units + .get(&scope) + .ok_or_else(|| format!("missing module unit {}", module_label(&scope)))?; + for item_use in unit.items.iter().filter_map(|item| match item { + Item::Use(item_use) if is_public(&item_use.vis) => Some(item_use), + _ => None, + }) { + for (segments, glob) in flatten_use_tree(&item_use.tree) { + match resolve_internal_export(&scope, &segments, glob, units) { + InternalExport::Module(module) => { + changed |= effective.insert(module); + } + InternalExport::Item(module, item) => { + changed |= exported_items.insert((module, item)); + } + InternalExport::External => {} + } + } + } + } + } + propagate_public_modules(units, &mut effective); + + let mut findings = BTreeSet::new(); + for unit in units.values() { + let imports = import_map(&unit.items); + let unit_effective = effective.contains(&unit.module); + let explicitly_exported = exported_items + .iter() + .filter(|(module, _)| module == &unit.module) + .map(|(_, item)| item.as_str()) + .collect::<BTreeSet<_>>(); + + for item in &unit.items { + if let Item::Use(item_use) = item { + if unit_effective && is_public(&item_use.vis) { + scan_public_use( + package, + unit, + item_use, + &imports, + forbidden, + allowed, + &mut findings, + ); + } + continue; + } + if let Item::Impl(item_impl) = item { + let Some(self_name) = impl_self_name(&item_impl.self_ty) else { + continue; + }; + if unit_effective || explicitly_exported.contains(self_name.as_str()) { + scan_impl( + package, + unit, + item_impl, + &self_name, + &imports, + forbidden, + allowed, + &mut findings, + ); + } + continue; + } + let Some(name) = item_name(item) else { + continue; + }; + if (unit_effective && item_is_public(item)) + || explicitly_exported.contains(name.as_str()) + { + scan_item( + package, + unit, + item, + &imports, + forbidden, + allowed, + &mut findings, + ); + } + } + } + Ok(findings.into_iter().collect()) +} + +fn propagate_public_modules( + units: &BTreeMap<Vec<String>, ModuleUnit>, + effective: &mut BTreeSet<Vec<String>>, +) -> bool { + let mut changed = false; + loop { + let before = effective.len(); + for unit in units.values() { + if unit.declared_public + && unit + .parent + .as_ref() + .is_some_and(|parent| effective.contains(parent)) + { + effective.insert(unit.module.clone()); + } + } + if effective.len() == before { + break; + } + changed = true; + } + changed +} + +fn flatten_use_tree(tree: &UseTree) -> Vec<(Vec<String>, bool)> { + fn visit(tree: &UseTree, prefix: &mut Vec<String>, output: &mut Vec<(Vec<String>, bool)>) { + match tree { + UseTree::Path(path) => { + prefix.push(path.ident.to_string()); + visit(&path.tree, prefix, output); + prefix.pop(); + } + UseTree::Name(name) => { + if name.ident == "self" { + output.push((prefix.clone(), false)); + } else { + let mut path = prefix.clone(); + path.push(name.ident.to_string()); + output.push((path, false)); + } + } + UseTree::Rename(rename) => { + let mut path = prefix.clone(); + path.push(rename.ident.to_string()); + output.push((path, false)); + } + UseTree::Glob(_) => output.push((prefix.clone(), true)), + UseTree::Group(group) => { + for item in &group.items { + visit(item, prefix, output); + } + } + } + } + + let mut output = Vec::new(); + visit(tree, &mut Vec::new(), &mut output); + output +} + +fn resolve_internal_export( + current: &[String], + segments: &[String], + glob: bool, + units: &BTreeMap<Vec<String>, ModuleUnit>, +) -> InternalExport { + if segments.is_empty() { + return InternalExport::External; + } + let candidates = internal_candidates(current, segments); + for candidate in candidates { + if glob && units.contains_key(&candidate) { + return InternalExport::Module(candidate); + } + if units.contains_key(&candidate) { + return InternalExport::Module(candidate); + } + if let Some((item, module)) = candidate.split_last() { + let module = module.to_vec(); + if units.contains_key(&module) { + return InternalExport::Item(module, item.clone()); + } + } + } + InternalExport::External +} + +fn internal_candidates(current: &[String], segments: &[String]) -> Vec<Vec<String>> { + let mut candidates = Vec::new(); + match segments.first().map(String::as_str) { + Some("crate") => candidates.push(segments[1..].to_vec()), + Some("self") => { + let mut path = current.to_vec(); + path.extend_from_slice(&segments[1..]); + candidates.push(path); + } + Some("super") => { + let mut base = current.to_vec(); + let mut index = 0; + while segments.get(index).map(String::as_str) == Some("super") { + base.pop(); + index += 1; + } + base.extend_from_slice(&segments[index..]); + candidates.push(base); + } + _ => { + candidates.push(segments.to_vec()); + let mut local = current.to_vec(); + local.extend_from_slice(segments); + if !candidates.contains(&local) { + candidates.push(local); + } + } + } + candidates +} + +fn import_map(items: &[Item]) -> BTreeMap<String, Vec<String>> { + fn visit(tree: &UseTree, prefix: &mut Vec<String>, output: &mut BTreeMap<String, Vec<String>>) { + match tree { + UseTree::Path(path) => { + prefix.push(path.ident.to_string()); + visit(&path.tree, prefix, output); + prefix.pop(); + } + UseTree::Name(name) => { + if name.ident == "self" { + if let Some(local) = prefix.last() { + output.insert(local.clone(), prefix.clone()); + } + } else { + let mut path = prefix.clone(); + path.push(name.ident.to_string()); + output.insert(name.ident.to_string(), path); + } + } + UseTree::Rename(rename) => { + let mut path = prefix.clone(); + path.push(rename.ident.to_string()); + output.insert(rename.rename.to_string(), path); + } + UseTree::Glob(_) => {} + UseTree::Group(group) => { + for item in &group.items { + visit(item, prefix, output); + } + } + } + } + + let mut output = BTreeMap::new(); + for item_use in items.iter().filter_map(|item| match item { + Item::Use(item_use) => Some(item_use), + _ => None, + }) { + visit(&item_use.tree, &mut Vec::new(), &mut output); + } + output +} + +#[allow(clippy::too_many_arguments)] +fn scan_item( + package: &str, + unit: &ModuleUnit, + item: &Item, + imports: &BTreeMap<String, Vec<String>>, + forbidden: &BTreeSet<&str>, + allowed: &BTreeSet<&str>, + findings: &mut BTreeSet<ApiFinding>, +) { + let name = item_name(item).unwrap_or_else(|| "<item>".to_owned()); + let label = qualified_item(&unit.module, &name); + let mut visitor = + LeakageVisitor::new(package, unit, label, imports, forbidden, allowed, findings); + match item { + Item::Const(item) => visitor.visit_type(&item.ty), + Item::Enum(item) => { + visitor.visit_generics(&item.generics); + for variant in &item.variants { + visit_fields(&mut visitor, &variant.fields, true); + } + } + Item::Fn(item) => visitor.visit_signature(&item.sig), + Item::ForeignMod(item) => { + for foreign in &item.items { + if let ForeignItem::Fn(function) = foreign { + visitor.visit_signature(&function.sig); + } + } + } + Item::Static(item) => visitor.visit_type(&item.ty), + Item::Struct(item) => { + visitor.visit_generics(&item.generics); + visit_fields(&mut visitor, &item.fields, false); + } + Item::Trait(item) => { + visitor.visit_generics(&item.generics); + for bound in &item.supertraits { + visitor.visit_type_param_bound(bound); + } + for trait_item in &item.items { + match trait_item { + TraitItem::Const(item) => visitor.visit_type(&item.ty), + TraitItem::Fn(item) => visitor.visit_signature(&item.sig), + TraitItem::Type(item) => { + visitor.visit_generics(&item.generics); + for bound in &item.bounds { + visitor.visit_type_param_bound(bound); + } + if let Some((_, ty)) = &item.default { + visitor.visit_type(ty); + } + } + _ => {} + } + } + } + Item::TraitAlias(item) => { + visitor.visit_generics(&item.generics); + for bound in &item.bounds { + visitor.visit_type_param_bound(bound); + } + } + Item::Type(item) => { + visitor.visit_generics(&item.generics); + visitor.visit_type(&item.ty); + } + Item::Union(item) => { + visitor.visit_generics(&item.generics); + for field in &item.fields.named { + if is_public(&field.vis) { + visitor.visit_type(&field.ty); + } + } + } + _ => {} + } +} + +fn visit_fields(visitor: &mut LeakageVisitor<'_>, fields: &Fields, all_visible: bool) { + for field in fields { + if all_visible || is_public(&field.vis) { + visitor.visit_type(&field.ty); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn scan_impl( + package: &str, + unit: &ModuleUnit, + item_impl: &syn::ItemImpl, + self_name: &str, + imports: &BTreeMap<String, Vec<String>>, + forbidden: &BTreeSet<&str>, + allowed: &BTreeSet<&str>, + findings: &mut BTreeSet<ApiFinding>, +) { + let trait_impl = item_impl.trait_.is_some(); + for item in &item_impl.items { + let (name, is_exposed) = match item { + ImplItem::Const(item) => (item.ident.to_string(), trait_impl || is_public(&item.vis)), + ImplItem::Fn(item) => ( + item.sig.ident.to_string(), + trait_impl || is_public(&item.vis), + ), + ImplItem::Type(item) => (item.ident.to_string(), trait_impl || is_public(&item.vis)), + _ => continue, + }; + if !is_exposed { + continue; + } + let label = format!("{}::{name}", qualified_item(&unit.module, self_name)); + let mut visitor = + LeakageVisitor::new(package, unit, label, imports, forbidden, allowed, findings); + if let Some((_, trait_path, _)) = &item_impl.trait_ { + visitor.visit_path(trait_path); + } + match item { + ImplItem::Const(item) => visitor.visit_type(&item.ty), + ImplItem::Fn(item) => visitor.visit_signature(&item.sig), + ImplItem::Type(item) => { + visitor.visit_generics(&item.generics); + visitor.visit_type(&item.ty); + } + _ => {} + } + } +} + +#[allow(clippy::too_many_arguments)] +fn scan_public_use( + package: &str, + unit: &ModuleUnit, + item_use: &syn::ItemUse, + imports: &BTreeMap<String, Vec<String>>, + forbidden: &BTreeSet<&str>, + allowed: &BTreeSet<&str>, + findings: &mut BTreeSet<ApiFinding>, +) { + for (segments, _) in flatten_use_tree(&item_use.tree) { + if segments.is_empty() { + continue; + } + let label = format!("{}::pub use", module_label(&unit.module)); + record_path( + package, unit, &label, &segments, imports, forbidden, allowed, findings, + ); + } +} + +struct LeakageVisitor<'a> { + package: &'a str, + unit: &'a ModuleUnit, + item: String, + imports: &'a BTreeMap<String, Vec<String>>, + forbidden: &'a BTreeSet<&'a str>, + allowed: &'a BTreeSet<&'a str>, + findings: &'a mut BTreeSet<ApiFinding>, +} + +impl<'a> LeakageVisitor<'a> { + #[allow(clippy::too_many_arguments)] + fn new( + package: &'a str, + unit: &'a ModuleUnit, + item: String, + imports: &'a BTreeMap<String, Vec<String>>, + forbidden: &'a BTreeSet<&'a str>, + allowed: &'a BTreeSet<&'a str>, + findings: &'a mut BTreeSet<ApiFinding>, + ) -> Self { + Self { + package, + unit, + item, + imports, + forbidden, + allowed, + findings, + } + } +} + +impl<'ast> Visit<'ast> for LeakageVisitor<'_> { + fn visit_path(&mut self, path: &'ast SynPath) { + let segments = path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::<Vec<_>>(); + record_path( + self.package, + self.unit, + &self.item, + &segments, + self.imports, + self.forbidden, + self.allowed, + self.findings, + ); + syn::visit::visit_path(self, path); + } +} + +#[allow(clippy::too_many_arguments)] +fn record_path( + package: &str, + unit: &ModuleUnit, + item: &str, + segments: &[String], + imports: &BTreeMap<String, Vec<String>>, + forbidden: &BTreeSet<&str>, + allowed: &BTreeSet<&str>, + findings: &mut BTreeSet<ApiFinding>, +) { + if segments.is_empty() { + return; + } + let resolved = if let Some(imported) = imports.get(&segments[0]) { + let mut resolved = imported.clone(); + resolved.extend_from_slice(&segments[1..]); + resolved + } else { + segments.to_vec() + }; + let observed = resolved.join("::"); + for forbidden_path in forbidden { + if allowed.contains(forbidden_path) + || !(observed == *forbidden_path + || observed + .strip_prefix(forbidden_path) + .is_some_and(|suffix| suffix.starts_with("::"))) + { + continue; + } + findings.insert(ApiFinding { + package: package.to_owned(), + source: unit.source_relative.clone(), + item: item.to_owned(), + forbidden_path: (*forbidden_path).to_owned(), + observed_path: observed.clone(), + }); + } +} + +fn exception_matches(exceptions: &[ApiException], finding: &ApiFinding) -> bool { + exceptions.iter().any(|exception| { + exception.package == finding.package + && exception.source == finding.source + && exception.forbidden_path == finding.forbidden_path + && exception.items.binary_search(&finding.item).is_ok() + && exception + .observed_paths + .binary_search(&finding.observed_path) + .is_ok() + }) +} + +fn format_finding(finding: &ApiFinding) -> String { + format!( + "package={} source={} item={} forbidden_path={} observed_path={}", + finding.package, + finding.source, + finding.item, + finding.forbidden_path, + finding.observed_path + ) +} + +fn item_is_public(item: &Item) -> bool { + match item { + Item::Const(item) => is_public(&item.vis), + Item::Enum(item) => is_public(&item.vis), + Item::Fn(item) => is_public(&item.vis), + Item::ForeignMod(_) => true, + Item::Static(item) => is_public(&item.vis), + Item::Struct(item) => is_public(&item.vis), + Item::Trait(item) => is_public(&item.vis), + Item::TraitAlias(item) => is_public(&item.vis), + Item::Type(item) => is_public(&item.vis), + Item::Union(item) => is_public(&item.vis), + _ => false, + } +} + +fn item_name(item: &Item) -> Option<String> { + match item { + Item::Const(item) => Some(item.ident.to_string()), + Item::Enum(item) => Some(item.ident.to_string()), + Item::Fn(item) => Some(item.sig.ident.to_string()), + Item::Static(item) => Some(item.ident.to_string()), + Item::Struct(item) => Some(item.ident.to_string()), + Item::Trait(item) => Some(item.ident.to_string()), + Item::TraitAlias(item) => Some(item.ident.to_string()), + Item::Type(item) => Some(item.ident.to_string()), + Item::Union(item) => Some(item.ident.to_string()), + _ => None, + } +} + +fn impl_self_name(self_ty: &Type) -> Option<String> { + let Type::Path(path) = self_ty else { + return None; + }; + path.path + .segments + .last() + .map(|segment| segment.ident.to_string()) +} + +fn is_public(visibility: &Visibility) -> bool { + matches!(visibility, Visibility::Public(_)) +} + +fn qualified_item(module: &[String], item: &str) -> String { + if module.is_empty() { + item.to_owned() + } else { + format!("{}::{item}", module.join("::")) + } +} + +fn module_label(module: &[String]) -> String { + if module.is_empty() { + "crate".to_owned() + } else { + module.join("::") + } +} + +#[cfg(test)] +mod tests { + use super::{ + ApiBoundaryPolicy, ApiException, CargoMetadata, exception_matches, scan_workspace, + validate_policy, + }; + use serde::Deserialize; + use std::{collections::BTreeSet, fs}; + + const POLICY: &str = include_str!("../../../../contracts/releases/api_boundaries.toml"); + const ARCHITECTURE: &str = + include_str!("../../../../docs/specs/radroots_crates_release_v1.toml"); + const GENERIC_SQLX: &str = include_str!("../../tests/fixtures/api-leakage/generic-sqlx.rs"); + const GENERIC_RENAMED_TOKIO: &str = + include_str!("../../tests/fixtures/api-leakage/generic-renamed-tokio.rs"); + const ALLOWED_ADAPTER: &str = + include_str!("../../tests/fixtures/api-leakage/allowed-concrete-adapter.rs"); + const PRIVATE_IMPLEMENTATION: &str = + include_str!("../../tests/fixtures/api-leakage/private-implementation.rs"); + const ADR_EXCEPTION: &str = include_str!("../../tests/fixtures/api-leakage/adr-exception.rs"); + + #[derive(Deserialize)] + struct ArchitectureCatalog { + package: Vec<ArchitecturePackage>, + } + + #[derive(Deserialize)] + struct ArchitecturePackage { + name: String, + } + + fn policy() -> ApiBoundaryPolicy { + toml::from_str(POLICY).expect("API boundary policy") + } + + fn expected_packages() -> BTreeSet<String> { + toml::from_str::<ArchitectureCatalog>(ARCHITECTURE) + .expect("architecture") + .package + .into_iter() + .map(|package| package.name) + .collect() + } + + fn fixture_metadata(root: &std::path::Path, package: &str, source: &str) -> CargoMetadata { + let package_root = root.join(package); + fs::create_dir_all(package_root.join("src")).expect("create fixture source"); + fs::write( + package_root.join("Cargo.toml"), + "[package]\nname='fixture'\n", + ) + .expect("write fixture manifest"); + fs::write(package_root.join("src/lib.rs"), source).expect("write fixture source"); + let id = format!("fixture#{package}@0.1.0"); + CargoMetadata { + workspace_members: vec![id.clone()], + packages: vec![super::CargoPackage { + id, + name: package.to_owned(), + manifest_path: package_root + .join("Cargo.toml") + .to_string_lossy() + .into_owned(), + targets: vec![super::CargoTarget { + kind: vec!["lib".to_owned()], + src_path: package_root + .join("src/lib.rs") + .to_string_lossy() + .into_owned(), + }], + }], + } + } + + fn scan_fixture(package: &str, source: &str) -> Vec<super::ApiFinding> { + let root = tempfile::TempDir::new().expect("fixture root"); + scan_workspace( + root.path(), + &policy(), + &fixture_metadata(root.path(), package, source), + ) + .expect("scan fixture") + } + + fn write_baseline_adr(root: &std::path::Path) { + fs::create_dir_all(root.join("docs/decisions")).expect("baseline ADR directory"); + fs::write( + root.join("docs/decisions/0001-public-api-leakage-migration-baseline.md"), + "RCRV1-API-001 RCRV1-API-002 RCRV1-API-003 RCRV1-API-004 RCRV1-API-005 RCRV1-API-006 RCRV1-API-007 RCRV1-API-008\n", + ) + .expect("baseline ADR"); + } + + #[test] + fn policy_covers_exact_architecture_catalog() { + let root = tempfile::TempDir::new().expect("policy root"); + write_baseline_adr(root.path()); + validate_policy(root.path(), &policy(), &expected_packages()) + .expect("policy without exceptions"); + } + + #[test] + fn generic_sqlx_public_field_is_forbidden() { + let findings = scan_fixture("radroots_storage", GENERIC_SQLX); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].item, "StorageHandle"); + assert_eq!(findings[0].forbidden_path, "sqlx"); + assert_eq!(findings[0].observed_path, "sqlx::SqlitePool"); + } + + #[test] + fn renamed_tokio_return_type_is_resolved() { + let findings = scan_fixture("radroots_sync", GENERIC_RENAMED_TOKIO); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].item, "executor"); + assert_eq!(findings[0].forbidden_path, "tokio"); + assert_eq!(findings[0].observed_path, "tokio::runtime::Handle"); + } + + #[test] + fn specified_nostr_adapter_may_expose_protocol_types() { + let findings = scan_fixture("radroots_nostr", ALLOWED_ADAPTER); + assert!(findings.is_empty()); + } + + #[test] + fn private_implementation_types_are_not_public_api() { + let findings = scan_fixture("radroots_sdk", PRIVATE_IMPLEMENTATION); + assert!(findings.is_empty()); + } + + #[test] + fn exact_item_exception_requires_resolving_adr() { + let root = tempfile::TempDir::new().expect("exception root"); + write_baseline_adr(root.path()); + fs::create_dir_all(root.path().join("docs/decisions")).expect("ADR directory"); + fs::write( + root.path().join("docs/decisions/0001-test.md"), + "# Test\n\nRCRV1-API-999\n", + ) + .expect("ADR"); + let mut policy = policy(); + policy.exception.push(ApiException { + id: "RCRV1-API-999".to_owned(), + package: "radroots_sdk".to_owned(), + source: "src/lib.rs".to_owned(), + forbidden_path: "reqwest".to_owned(), + items: vec!["temporary_client".to_owned()], + observed_paths: vec!["reqwest::Client".to_owned()], + adr: "docs/decisions/0001-test.md".to_owned(), + removal_step: 226, + rationale: "test-only exception".to_owned(), + }); + validate_policy(root.path(), &policy, &expected_packages()).expect("resolving ADR"); + let finding = scan_workspace( + root.path(), + &policy, + &fixture_metadata(root.path(), "radroots_sdk", ADR_EXCEPTION), + ) + .expect("scan exception fixture") + .pop() + .expect("finding"); + assert!(exception_matches(&policy.exception, &finding)); + + fs::remove_file(root.path().join("docs/decisions/0001-test.md")).expect("remove ADR"); + let error = validate_policy(root.path(), &policy, &expected_packages()) + .expect_err("missing ADR must fail"); + assert!(error.contains("is not readable")); + } +} diff --git a/tools/xtask/src/architecture/core_contract.rs b/tools/xtask/src/architecture/core_contract.rs @@ -0,0 +1,591 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, +}; + +use syn::{Item, UseTree, Visibility}; + +const ARCHITECTURE_RELATIVE: &str = "docs/specs/radroots_crates_release_v1.toml"; +const CORE_MANIFEST_RELATIVE: &str = "crates/core/Cargo.toml"; +const CORE_LIB_RELATIVE: &str = "crates/core/src/lib.rs"; + +const CORE_RUNTIME_DEPENDENCIES: [&str; 2] = ["rust_decimal", "serde"]; +const CORE_DEV_DEPENDENCIES: [&str; 3] = ["dto_bindgen", "rust_decimal", "serde_json"]; +const CORE_ROOT_EXPORTS: [(&str, &str); 8] = [ + ("Currency", "currency::Currency"), + ("Decimal", "decimal::Decimal"), + ("Error", "error::Error"), + ("Money", "money::Money"), + ("Percent", "percent::Percent"), + ("Quantity", "quantity::Quantity"), + ("QuantityPrice", "pricing::QuantityPrice"), + ("Unit", "unit::Unit"), +]; +pub(super) fn validate(workspace_root: &Path) -> Result<(), String> { + let architecture = read_toml(workspace_root, ARCHITECTURE_RELATIVE)?; + let core_spec = architecture + .get("package") + .and_then(toml::Value::as_array) + .and_then(|packages| { + packages.iter().find(|package| { + package.get("name").and_then(toml::Value::as_str) == Some("radroots_core") + }) + }) + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{ARCHITECTURE_RELATIVE} is missing radroots_core"))?; + + let manifest = read_toml(workspace_root, CORE_MANIFEST_RELATIVE)?; + validate_manifest(core_spec, &manifest)?; + validate_crate_root(workspace_root, core_spec) +} + +fn validate_manifest(core_spec: &toml::value::Table, manifest: &toml::Value) -> Result<(), String> { + let manifest = manifest + .as_table() + .ok_or_else(|| format!("{CORE_MANIFEST_RELATIVE} must be a TOML table"))?; + let package = manifest + .get("package") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{CORE_MANIFEST_RELATIVE} is missing [package]"))?; + if package.get("name").and_then(toml::Value::as_str) != Some("radroots_core") { + return Err(format!( + "{CORE_MANIFEST_RELATIVE} package.name must be radroots_core" + )); + } + let library = manifest + .get("lib") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{CORE_MANIFEST_RELATIVE} is missing [lib]"))?; + if library.get("name").and_then(toml::Value::as_str) != Some("radroots_core") + || library.keys().map(String::as_str).collect::<BTreeSet<_>>() != BTreeSet::from(["name"]) + { + return Err(format!( + "{CORE_MANIFEST_RELATIVE} must use the conventional radroots_core library target" + )); + } + + let features = manifest + .get("features") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{CORE_MANIFEST_RELATIVE} is missing [features]"))?; + let expected_features = spec_strings(core_spec, "features")?; + let actual_features = features + .keys() + .filter(|name| name.as_str() != "default") + .cloned() + .collect::<BTreeSet<_>>(); + if actual_features != expected_features { + return Err(format!( + "radroots_core public features must be exactly {expected_features:?}, found {actual_features:?}" + )); + } + let expected_default = spec_strings(core_spec, "default_features")?; + let actual_default = value_strings( + features.get("default"), + &format!("{CORE_MANIFEST_RELATIVE} features.default"), + )?; + if actual_default != expected_default { + return Err(format!( + "radroots_core default features must be exactly {expected_default:?}, found {actual_default:?}" + )); + } + let std_enables = value_strings( + features.get("std"), + &format!("{CORE_MANIFEST_RELATIVE} features.std"), + )?; + if !std_enables.is_empty() { + return Err("radroots_core std must remain an empty additive marker".to_owned()); + } + let serde_enables = value_strings( + features.get("serde"), + &format!("{CORE_MANIFEST_RELATIVE} features.serde"), + )?; + let expected_serde = BTreeSet::from(["dep:serde".to_owned(), "rust_decimal/serde".to_owned()]); + if serde_enables != expected_serde { + return Err(format!( + "radroots_core serde feature must enable {expected_serde:?}, found {serde_enables:?}" + )); + } + + validate_dependency_names( + manifest, + "dependencies", + BTreeSet::from(CORE_RUNTIME_DEPENDENCIES.map(str::to_owned)), + )?; + validate_dependency_names( + manifest, + "dev-dependencies", + BTreeSet::from(CORE_DEV_DEPENDENCIES.map(str::to_owned)), + )?; + reject_nonempty_dependency_section(manifest, "build-dependencies")?; + if manifest + .get("target") + .and_then(toml::Value::as_table) + .is_some_and(|targets| { + targets.values().any(|target| { + target.as_table().is_some_and(|table| { + ["dependencies", "dev-dependencies", "build-dependencies"] + .iter() + .any(|section| { + table + .get(*section) + .and_then(toml::Value::as_table) + .is_some_and(|dependencies| !dependencies.is_empty()) + }) + }) + }) + }) + { + return Err("radroots_core must not declare target-specific dependencies".to_owned()); + } + + let dependencies = manifest + .get("dependencies") + .and_then(toml::Value::as_table) + .expect("validated dependencies"); + validate_dependency_shape(dependencies, "rust_decimal", false, false, &[])?; + validate_dependency_shape(dependencies, "serde", true, false, &["alloc", "derive"]) +} + +fn validate_crate_root( + workspace_root: &Path, + core_spec: &toml::value::Table, +) -> Result<(), String> { + let path = workspace_root.join(CORE_LIB_RELATIVE); + let raw = + fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; + let file = + syn::parse_file(&raw).map_err(|error| format!("parse {CORE_LIB_RELATIVE}: {error}"))?; + let mut modules = BTreeSet::new(); + let mut exports = BTreeMap::new(); + for item in &file.items { + match item { + Item::Mod(item) if matches!(item.vis, Visibility::Public(_)) => { + modules.insert(item.ident.to_string()); + } + Item::Use(item) if matches!(item.vis, Visibility::Public(_)) => { + collect_use_exports(&item.tree, &mut Vec::new(), &mut exports)?; + } + Item::Macro(item) + if item + .attrs + .iter() + .any(|attribute| attribute.path().is_ident("macro_export")) => + { + return Err(format!( + "{CORE_LIB_RELATIVE} must not declare a root macro export" + )); + } + item if is_public(item) => { + return Err(format!( + "{CORE_LIB_RELATIVE} contains an unsupported public root item: {}", + public_item_kind(item) + )); + } + _ => {} + } + } + + let expected_modules = spec_strings(core_spec, "modules")?; + if modules != expected_modules { + return Err(format!( + "radroots_core public modules must be exactly {expected_modules:?}, found {modules:?}" + )); + } + let specified_exports = spec_strings(core_spec, "root_exports")?; + let canonical_exports = CORE_ROOT_EXPORTS + .iter() + .map(|(name, _)| (*name).to_owned()) + .collect::<BTreeSet<_>>(); + if specified_exports != canonical_exports { + return Err(format!( + "radroots_core canonical root exports must be exactly {canonical_exports:?}, found {specified_exports:?}" + )); + } + let expected_exports = CORE_ROOT_EXPORTS + .into_iter() + .map(|(name, source)| (name.to_owned(), source.to_owned())) + .collect::<BTreeMap<_, _>>(); + if exports != expected_exports { + return Err(format!( + "radroots_core root exports must match the canonical contract; expected {expected_exports:?}, found {exports:?}" + )); + } + Ok(()) +} + +fn validate_dependency_names( + manifest: &toml::value::Table, + section: &str, + expected: BTreeSet<String>, +) -> Result<(), String> { + let actual = manifest + .get(section) + .and_then(toml::Value::as_table) + .map(|dependencies| dependencies.keys().cloned().collect::<BTreeSet<_>>()) + .unwrap_or_default(); + if actual != expected { + return Err(format!( + "radroots_core {section} must be exactly {expected:?}, found {actual:?}" + )); + } + Ok(()) +} + +fn reject_nonempty_dependency_section( + manifest: &toml::value::Table, + section: &str, +) -> Result<(), String> { + if manifest + .get(section) + .and_then(toml::Value::as_table) + .is_some_and(|dependencies| !dependencies.is_empty()) + { + return Err(format!("radroots_core must not declare {section}")); + } + Ok(()) +} + +fn validate_dependency_shape( + dependencies: &toml::value::Table, + name: &str, + optional: bool, + default_features: bool, + expected_features: &[&str], +) -> Result<(), String> { + let dependency = dependencies + .get(name) + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("radroots_core dependency {name} must use a dependency table"))?; + if dependency.get("workspace").and_then(toml::Value::as_bool) != Some(true) + || dependency + .get("optional") + .and_then(toml::Value::as_bool) + .unwrap_or(false) + != optional + || dependency + .get("default-features") + .and_then(toml::Value::as_bool) + .unwrap_or(true) + != default_features + { + return Err(format!( + "radroots_core dependency {name} has a noncanonical workspace/optional/default-features shape" + )); + } + let mut expected_keys = BTreeSet::from(["workspace", "default-features"]); + if optional { + expected_keys.insert("optional"); + } + if !expected_features.is_empty() { + expected_keys.insert("features"); + } + let actual_keys = dependency + .keys() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + if actual_keys != expected_keys { + return Err(format!( + "radroots_core dependency {name} keys must be exactly {expected_keys:?}, found {actual_keys:?}" + )); + } + let actual_features = match dependency.get("features") { + Some(features) => value_strings( + Some(features), + &format!("{CORE_MANIFEST_RELATIVE} dependencies.{name}.features"), + )?, + None => BTreeSet::new(), + }; + let expected_features = expected_features + .iter() + .map(|feature| (*feature).to_owned()) + .collect::<BTreeSet<_>>(); + if actual_features != expected_features { + return Err(format!( + "radroots_core dependency {name} features must be {expected_features:?}, found {actual_features:?}" + )); + } + Ok(()) +} + +fn spec_strings(package: &toml::value::Table, field: &str) -> Result<BTreeSet<String>, String> { + value_strings( + package.get(field), + &format!("{ARCHITECTURE_RELATIVE} radroots_core.{field}"), + ) +} + +fn value_strings(value: Option<&toml::Value>, label: &str) -> Result<BTreeSet<String>, String> { + value + .and_then(toml::Value::as_array) + .ok_or_else(|| format!("{label} must be an array"))? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| format!("{label} must contain strings")) + }) + .collect() +} + +fn collect_use_exports( + tree: &UseTree, + prefix: &mut Vec<String>, + exports: &mut BTreeMap<String, String>, +) -> Result<(), String> { + match tree { + UseTree::Path(path) => { + prefix.push(path.ident.to_string()); + collect_use_exports(&path.tree, prefix, exports)?; + prefix.pop(); + } + UseTree::Name(name) => { + let source_name = name.ident.to_string(); + let export_name = if source_name == "self" { + prefix.last().cloned().ok_or_else(|| { + format!("{CORE_LIB_RELATIVE} contains an invalid root self re-export") + })? + } else { + source_name.clone() + }; + let source = if source_name == "self" { + prefix.join("::") + } else { + qualified_source(prefix, &source_name) + }; + insert_export(exports, export_name, source)?; + } + UseTree::Rename(rename) if rename.rename != "_" => { + insert_export( + exports, + rename.rename.to_string(), + qualified_source(prefix, &rename.ident.to_string()), + )?; + } + UseTree::Rename(_) => {} + UseTree::Glob(_) => { + return Err(format!( + "{CORE_LIB_RELATIVE} must not use public glob re-exports" + )); + } + UseTree::Group(group) => { + for item in &group.items { + collect_use_exports(item, prefix, exports)?; + } + } + } + Ok(()) +} + +fn qualified_source(prefix: &[String], name: &str) -> String { + if prefix.is_empty() { + name.to_owned() + } else { + format!("{}::{name}", prefix.join("::")) + } +} + +fn insert_export( + exports: &mut BTreeMap<String, String>, + name: String, + source: String, +) -> Result<(), String> { + if let Some(previous) = exports.insert(name.clone(), source.clone()) { + return Err(format!( + "{CORE_LIB_RELATIVE} exports {name} more than once from {previous} and {source}" + )); + } + Ok(()) +} + +fn is_public(item: &Item) -> bool { + match item { + Item::Const(item) => matches!(item.vis, Visibility::Public(_)), + Item::Enum(item) => matches!(item.vis, Visibility::Public(_)), + Item::ExternCrate(item) => matches!(item.vis, Visibility::Public(_)), + Item::Fn(item) => matches!(item.vis, Visibility::Public(_)), + Item::Static(item) => matches!(item.vis, Visibility::Public(_)), + Item::Struct(item) => matches!(item.vis, Visibility::Public(_)), + Item::Trait(item) => matches!(item.vis, Visibility::Public(_)), + Item::TraitAlias(item) => matches!(item.vis, Visibility::Public(_)), + Item::Type(item) => matches!(item.vis, Visibility::Public(_)), + Item::Union(item) => matches!(item.vis, Visibility::Public(_)), + _ => false, + } +} + +fn public_item_kind(item: &Item) -> &'static str { + match item { + Item::Const(_) => "const", + Item::Enum(_) => "enum", + Item::ExternCrate(_) => "extern crate", + Item::Fn(_) => "function", + Item::Static(_) => "static", + Item::Struct(_) => "struct", + Item::Trait(_) => "trait", + Item::TraitAlias(_) => "trait alias", + Item::Type(_) => "type alias", + Item::Union(_) => "union", + _ => "item", + } +} + +fn read_toml(workspace_root: &Path, relative: &str) -> Result<toml::Value, String> { + let path = workspace_path(workspace_root, relative); + let raw = + fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; + raw.parse::<toml::Value>() + .map_err(|error| format!("parse {}: {error}", path.display())) +} + +fn workspace_path(workspace_root: &Path, relative: &str) -> PathBuf { + workspace_root.join(relative) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::validate; + + const SPEC: &str = r#" +[[package]] +name = "radroots_core" +features = ["std", "serde"] +default_features = ["std", "serde"] +modules = ["currency", "decimal", "money", "percent", "pricing", "quantity", "unit"] +root_exports = ["Currency", "Decimal", "Money", "Percent", "Quantity", "QuantityPrice", "Unit", "Error"] +"#; + + const MANIFEST: &str = r#" +[package] +name = "radroots_core" + +[lib] +name = "radroots_core" + +[features] +default = ["std", "serde"] +std = [] +serde = ["dep:serde", "rust_decimal/serde"] + +[dependencies] +rust_decimal = { workspace = true, default-features = false } +serde = { workspace = true, default-features = false, features = ["alloc", "derive"], optional = true } + +[dev-dependencies] +dto_bindgen = { workspace = true } +rust_decimal = { workspace = true } +serde_json = { workspace = true } +"#; + + fn fixture() -> tempfile::TempDir { + let root = tempdir().expect("temporary workspace"); + fs::create_dir_all(root.path().join("docs/specs")).expect("spec directory"); + fs::create_dir_all(root.path().join("crates/core/src")).expect("core directory"); + fs::write( + root.path() + .join("docs/specs/radroots_crates_release_v1.toml"), + SPEC, + ) + .expect("architecture spec"); + fs::write(root.path().join("crates/core/Cargo.toml"), MANIFEST).expect("core manifest"); + let exports = super::CORE_ROOT_EXPORTS + .into_iter() + .map(|(_, source)| format!("pub use {source};")) + .collect::<Vec<_>>() + .join(" "); + fs::write( + root.path().join("crates/core/src/lib.rs"), + format!( + "pub mod currency; pub mod decimal; pub mod money; pub mod percent; pub mod pricing; pub mod quantity; pub mod unit; {exports}" + ), + ) + .expect("core root"); + root + } + + #[test] + fn accepts_exact_core_contract() { + let root = fixture(); + validate(root.path()).expect("exact core contract"); + } + + #[test] + fn rejects_public_codegen_feature_and_runtime_dependency() { + let root = fixture(); + let manifest_path = root.path().join("crates/core/Cargo.toml"); + let manifest = fs::read_to_string(&manifest_path).expect("manifest"); + fs::write( + &manifest_path, + manifest.replace("std = []", "std = []\ndto-bindgen = [\"dep:dto_bindgen\"]"), + ) + .expect("mutated manifest"); + let error = validate(root.path()).expect_err("codegen feature must fail"); + assert!(error.contains("public features must be exactly"), "{error}"); + + fs::write( + &manifest_path, + MANIFEST.replace( + "[dev-dependencies]", + "tokio = { workspace = true }\n\n[dev-dependencies]", + ), + ) + .expect("runtime dependency manifest"); + let error = validate(root.path()).expect_err("runtime dependency must fail"); + assert!(error.contains("dependencies must be exactly"), "{error}"); + + fs::write( + &manifest_path, + MANIFEST.replace( + "rust_decimal = { workspace = true, default-features = false }", + "rust_decimal = { workspace = true, default-features = false, features = [] }", + ), + ) + .expect("noncanonical dependency shape"); + let error = validate(root.path()).expect_err("extra dependency keys must fail"); + assert!(error.contains("keys must be exactly"), "{error}"); + } + + #[test] + fn rejects_extra_public_module_or_export() { + let root = fixture(); + let lib_path = root.path().join("crates/core/src/lib.rs"); + let source = fs::read_to_string(&lib_path).expect("core root"); + fs::write(&lib_path, format!("{source} pub mod serde_ext;")).expect("extra module source"); + let error = validate(root.path()).expect_err("extra module must fail"); + assert!(error.contains("public modules must be exactly"), "{error}"); + + fs::write(&lib_path, format!("{source} pub use fixture::Clock;")) + .expect("extra export source"); + let error = validate(root.path()).expect_err("extra export must fail"); + assert!(error.contains("root exports must match"), "{error}"); + } + + #[test] + fn rejects_rebound_duplicate_and_glob_exports() { + let root = fixture(); + let lib_path = root.path().join("crates/core/src/lib.rs"); + let source = fs::read_to_string(&lib_path).expect("core root"); + fs::write( + &lib_path, + source.replace("pub use currency::Currency;", "pub use money::Currency;"), + ) + .expect("rebound export source"); + let error = validate(root.path()).expect_err("rebound export must fail"); + assert!(error.contains("root exports must match"), "{error}"); + + fs::write(&lib_path, format!("{source} pub use currency::*;")).expect("glob export source"); + let error = validate(root.path()).expect_err("glob export must fail"); + assert!(error.contains("must not use public glob"), "{error}"); + + fs::write(&lib_path, format!("{source} pub use currency::Currency;")) + .expect("duplicate export source"); + let error = validate(root.path()).expect_err("duplicate export must fail"); + assert!(error.contains("exports Currency more than once"), "{error}"); + } +} diff --git a/tools/xtask/src/architecture/dependency_boundary.rs b/tools/xtask/src/architecture/dependency_boundary.rs @@ -0,0 +1,718 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, + process::Command, +}; + +use serde::Deserialize; + +const PACKAGE_TIERS_RELATIVE: &str = "contracts/releases/package_tiers.toml"; +const SPEC_ID: &str = "radroots.crates.release.v1"; +const DIRECTION: &str = "same_or_lower"; +const POLICY_SCHEMA_VERSION: u16 = 1; +const CURRENT_STEP: u16 = 24; +const EXPECTED_TIERS: &[(&str, u8)] = &[ + ("foundation", 0), + ("domain", 1), + ("spi", 2), + ("adapter", 3), + ("orchestration", 4), + ("sdk", 5), + ("facade", 6), +]; +const EXPECTED_DEPENDENCY_KINDS: &[&str] = &["build", "dev", "normal"]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DependencyBoundaryPolicy { + schema_version: u16, + spec_id: String, + direction: String, + enforced_dependency_kinds: Vec<String>, + tier: Vec<PackageTier>, + #[serde(default)] + temporary_exception: Vec<TemporaryException>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PackageTier { + id: String, + rank: u8, + packages: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct TemporaryException { + owner: String, + dependency: String, + kind: String, + target: String, + features: Vec<String>, + uses_default_features: bool, + removal_step: u16, + rationale: String, +} + +#[derive(Debug, Deserialize)] +struct CargoMetadata { + packages: Vec<CargoPackage>, + resolve: Option<CargoResolve>, +} + +#[derive(Debug, Deserialize)] +struct CargoPackage { + id: String, + name: String, + #[serde(default)] + dependencies: Vec<CargoDependency>, +} + +#[derive(Debug, Deserialize)] +struct CargoDependency { + name: String, + #[serde(default)] + kind: Option<String>, + #[serde(default)] + features: Vec<String>, + #[serde(default)] + target: Option<String>, + #[serde(default = "default_true")] + uses_default_features: bool, +} + +#[derive(Debug, Deserialize)] +struct CargoResolve { + nodes: Vec<CargoNode>, +} + +#[derive(Debug, Deserialize)] +struct CargoNode { + id: String, + #[serde(default)] + deps: Vec<CargoNodeDependency>, +} + +#[derive(Debug, Deserialize)] +struct CargoNodeDependency { + name: String, + pkg: String, + #[serde(default)] + dep_kinds: Vec<CargoDependencyKind>, +} + +#[derive(Debug, Deserialize)] +struct CargoDependencyKind { + #[serde(default)] + kind: Option<String>, + #[serde(default)] + target: Option<String>, +} + +#[derive(Clone, Copy)] +struct PackagePlacement<'a> { + tier: &'a str, + rank: u8, +} + +fn default_true() -> bool { + true +} + +pub(super) fn validate_policy_catalog( + workspace_root: &Path, + expected_packages: &BTreeSet<String>, +) -> Result<(), String> { + let policy = load_policy(workspace_root)?; + validate_policy(&policy, expected_packages).map(|_| ()) +} + +pub(super) fn validate_resolved_boundaries(workspace_root: &Path) -> Result<(), String> { + let policy = load_policy(workspace_root)?; + let expected_packages = policy + .tier + .iter() + .flat_map(|tier| tier.packages.iter().cloned()) + .collect::<BTreeSet<_>>(); + let placements = validate_policy(&policy, &expected_packages)?; + let metadata = load_metadata(workspace_root)?; + validate_metadata(&policy, &placements, &metadata) +} + +fn load_policy(workspace_root: &Path) -> Result<DependencyBoundaryPolicy, String> { + let path = workspace_root.join(PACKAGE_TIERS_RELATIVE); + let raw = + fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; + toml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display())) +} + +fn validate_policy<'a>( + policy: &'a DependencyBoundaryPolicy, + expected_packages: &BTreeSet<String>, +) -> Result<BTreeMap<&'a str, PackagePlacement<'a>>, String> { + if policy.schema_version != POLICY_SCHEMA_VERSION { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} schema_version must be {POLICY_SCHEMA_VERSION}" + )); + } + if policy.spec_id != SPEC_ID { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} spec_id must be {SPEC_ID}" + )); + } + if policy.direction != DIRECTION { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} direction must be {DIRECTION}" + )); + } + + let actual_kinds = unique_strings( + "enforced_dependency_kinds", + &policy.enforced_dependency_kinds, + )?; + let expected_kinds = EXPECTED_DEPENDENCY_KINDS + .iter() + .copied() + .collect::<BTreeSet<_>>(); + if actual_kinds != expected_kinds { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} enforced_dependency_kinds must be build, dev, normal" + )); + } + + let actual_tiers = policy + .tier + .iter() + .map(|tier| (tier.id.as_str(), tier.rank)) + .collect::<Vec<_>>(); + if actual_tiers != EXPECTED_TIERS { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} tiers must match the approved ordered architecture" + )); + } + + let mut placements = BTreeMap::new(); + for tier in &policy.tier { + if tier.packages.is_empty() { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} tier {} must allocate at least one package", + tier.id + )); + } + for package in &tier.packages { + if placements + .insert( + package.as_str(), + PackagePlacement { + tier: &tier.id, + rank: tier.rank, + }, + ) + .is_some() + { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} package {package} is allocated more than once" + )); + } + } + } + + let actual_packages = placements + .keys() + .map(|package| (*package).to_owned()) + .collect::<BTreeSet<_>>(); + if &actual_packages != expected_packages { + return Err(package_set_mismatch(expected_packages, &actual_packages)); + } + + let mut exception_keys = BTreeSet::new(); + for exception in &policy.temporary_exception { + let owner = placements.get(exception.owner.as_str()).ok_or_else(|| { + format!( + "{PACKAGE_TIERS_RELATIVE} exception owner {} is not an approved package", + exception.owner + ) + })?; + let dependency = placements + .get(exception.dependency.as_str()) + .ok_or_else(|| { + format!( + "{PACKAGE_TIERS_RELATIVE} exception dependency {} is not an approved package", + exception.dependency + ) + })?; + if dependency.rank <= owner.rank { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} exception {} -> {} does not describe an upward edge", + exception.owner, exception.dependency + )); + } + if !expected_kinds.contains(exception.kind.as_str()) { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} exception {} -> {} uses unknown dependency kind {}", + exception.owner, exception.dependency, exception.kind + )); + } + if exception.target.trim().is_empty() { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} exception {} -> {} must name its exact target", + exception.owner, exception.dependency + )); + } + let normalized_features = normalized_features(&exception.features); + if normalized_features.len() != exception.features.len() + || normalized_features.iter().ne(exception.features.iter()) + { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} exception {} -> {} features must be sorted and unique", + exception.owner, exception.dependency + )); + } + if exception.removal_step <= CURRENT_STEP { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} exception {} -> {} expired at Step {}", + exception.owner, exception.dependency, exception.removal_step + )); + } + if exception.rationale.trim().is_empty() { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} exception {} -> {} must include a rationale", + exception.owner, exception.dependency + )); + } + let key = ( + exception.owner.as_str(), + exception.dependency.as_str(), + exception.kind.as_str(), + exception.target.as_str(), + exception.features.clone(), + exception.uses_default_features, + ); + if !exception_keys.insert(key) { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} contains a duplicate exception for {} -> {}", + exception.owner, exception.dependency + )); + } + } + + Ok(placements) +} + +fn unique_strings<'a>(label: &str, values: &'a [String]) -> Result<BTreeSet<&'a str>, String> { + let unique = values.iter().map(String::as_str).collect::<BTreeSet<_>>(); + if unique.len() != values.len() { + return Err(format!( + "{PACKAGE_TIERS_RELATIVE} {label} must not contain duplicates" + )); + } + Ok(unique) +} + +fn package_set_mismatch(expected: &BTreeSet<String>, actual: &BTreeSet<String>) -> String { + let missing = expected + .difference(actual) + .cloned() + .collect::<Vec<_>>() + .join(", "); + let extra = actual + .difference(expected) + .cloned() + .collect::<Vec<_>>() + .join(", "); + format!("{PACKAGE_TIERS_RELATIVE} package catalog mismatch; missing: {missing}; extra: {extra}") +} + +fn load_metadata(workspace_root: &Path) -> Result<CargoMetadata, String> { + let output = Command::new("cargo") + .args([ + "metadata", + "--format-version", + "1", + "--locked", + "--all-features", + ]) + .current_dir(workspace_root) + .output() + .map_err(|error| format!("run cargo metadata: {error}"))?; + if !output.status.success() { + return Err(format!( + "cargo metadata failed while checking dependency boundaries: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("parse cargo metadata for dependency boundaries: {error}")) +} + +fn validate_metadata( + policy: &DependencyBoundaryPolicy, + placements: &BTreeMap<&str, PackagePlacement<'_>>, + metadata: &CargoMetadata, +) -> Result<(), String> { + let resolve = metadata + .resolve + .as_ref() + .ok_or_else(|| "cargo metadata did not include a resolved dependency graph".to_owned())?; + let mut packages_by_id = BTreeMap::new(); + for package in &metadata.packages { + if packages_by_id + .insert(package.id.as_str(), package) + .is_some() + { + return Err(format!( + "cargo metadata contains duplicate package id {}", + package.id + )); + } + } + + let mut nodes_by_id = BTreeMap::new(); + for node in &resolve.nodes { + if nodes_by_id.insert(node.id.as_str(), node).is_some() { + return Err(format!( + "cargo metadata contains duplicate resolve node {}", + node.id + )); + } + } + + let enforced_kinds = policy + .enforced_dependency_kinds + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + let mut violations = Vec::new(); + + for node in &resolve.nodes { + let Some(owner_package) = packages_by_id.get(node.id.as_str()).copied() else { + return Err(format!( + "cargo metadata resolve node {} has no package record", + node.id + )); + }; + let Some(owner_placement) = placements.get(owner_package.name.as_str()).copied() else { + continue; + }; + + for resolved_dependency in &node.deps { + let dependency_package = packages_by_id + .get(resolved_dependency.pkg.as_str()) + .copied() + .ok_or_else(|| { + format!( + "cargo metadata resolved dependency {} has no package record", + resolved_dependency.pkg + ) + })?; + let Some(dependency_placement) = + placements.get(dependency_package.name.as_str()).copied() + else { + continue; + }; + if resolved_dependency.dep_kinds.is_empty() { + return Err(format!( + "cargo metadata edge {} -> {} has no dependency kind", + owner_package.name, dependency_package.name + )); + } + + for dependency_kind in &resolved_dependency.dep_kinds { + let kind = normalized_kind(dependency_kind.kind.as_deref()); + if !enforced_kinds.contains(kind) { + continue; + } + let declaration = + resolve_declaration(owner_package, dependency_package, dependency_kind)?; + if dependency_placement.rank <= owner_placement.rank { + continue; + } + if exception_matches( + &policy.temporary_exception, + owner_package, + dependency_package, + dependency_kind, + declaration, + ) { + continue; + } + violations.push(format_violation( + owner_package, + owner_placement, + dependency_package, + dependency_placement, + resolved_dependency, + dependency_kind, + declaration, + )); + } + } + } + + if violations.is_empty() { + Ok(()) + } else { + violations.sort(); + Err(format!( + "forbidden package dependency direction(s):\n{}", + violations.join("\n") + )) + } +} + +fn resolve_declaration<'a>( + owner: &'a CargoPackage, + dependency: &CargoPackage, + dependency_kind: &CargoDependencyKind, +) -> Result<&'a CargoDependency, String> { + let kind = normalized_kind(dependency_kind.kind.as_deref()); + let candidates = owner + .dependencies + .iter() + .filter(|candidate| { + candidate.name == dependency.name + && normalized_kind(candidate.kind.as_deref()) == kind + && candidate.target == dependency_kind.target + }) + .collect::<Vec<_>>(); + match candidates.as_slice() { + [declaration] => Ok(*declaration), + [] => Err(format!( + "cargo metadata edge {} -> {} kind={kind} target={} has no matching package dependency declaration", + owner.name, + dependency.name, + target_label(dependency_kind.target.as_deref()) + )), + _ => Err(format!( + "cargo metadata edge {} -> {} kind={kind} target={} has ambiguous package dependency declarations", + owner.name, + dependency.name, + target_label(dependency_kind.target.as_deref()) + )), + } +} + +fn exception_matches( + exceptions: &[TemporaryException], + owner: &CargoPackage, + dependency: &CargoPackage, + dependency_kind: &CargoDependencyKind, + declaration: &CargoDependency, +) -> bool { + let kind = normalized_kind(dependency_kind.kind.as_deref()); + let target = target_label(dependency_kind.target.as_deref()); + let features = normalized_features(&declaration.features); + exceptions.iter().any(|exception| { + exception.owner == owner.name + && exception.dependency == dependency.name + && exception.kind == kind + && exception.target == target + && exception.features == features + && exception.uses_default_features == declaration.uses_default_features + }) +} + +fn format_violation( + owner: &CargoPackage, + owner_placement: PackagePlacement<'_>, + dependency: &CargoPackage, + dependency_placement: PackagePlacement<'_>, + resolved_dependency: &CargoNodeDependency, + dependency_kind: &CargoDependencyKind, + declaration: &CargoDependency, +) -> String { + let features = normalized_features(&declaration.features); + format!( + "{} (tier={} rank={}) -> {} (tier={} rank={}); owner_id={}; dependency_id={}; alias={}; kind={}; target={}; features=[{}]; default_features={}", + owner.name, + owner_placement.tier, + owner_placement.rank, + dependency.name, + dependency_placement.tier, + dependency_placement.rank, + owner.id, + dependency.id, + resolved_dependency.name, + normalized_kind(dependency_kind.kind.as_deref()), + target_label(dependency_kind.target.as_deref()), + features.join(","), + declaration.uses_default_features, + ) +} + +fn normalized_kind(kind: Option<&str>) -> &str { + kind.unwrap_or("normal") +} + +fn target_label(target: Option<&str>) -> &str { + target.unwrap_or("all") +} + +fn normalized_features(features: &[String]) -> Vec<String> { + features + .iter() + .cloned() + .collect::<BTreeSet<_>>() + .into_iter() + .collect() +} + +#[cfg(test)] +mod tests { + use super::{ + CargoMetadata, DependencyBoundaryPolicy, exception_matches, validate_metadata, + validate_policy, + }; + use serde::Deserialize; + use std::collections::BTreeSet; + + const POLICY: &str = include_str!("../../../../contracts/releases/package_tiers.toml"); + const ARCHITECTURE: &str = + include_str!("../../../../docs/specs/radroots_crates_release_v1.toml"); + const DOMAIN_TO_STORAGE: &str = + include_str!("../../tests/fixtures/dependency-boundaries/domain-to-storage.json"); + const SPI_TO_ADAPTER: &str = + include_str!("../../tests/fixtures/dependency-boundaries/spi-to-adapter.json"); + const SERVICE_TO_SDK: &str = + include_str!("../../tests/fixtures/dependency-boundaries/service-to-sdk.json"); + const VALID_DOWNWARD: &str = + include_str!("../../tests/fixtures/dependency-boundaries/valid-downward.json"); + + #[derive(Deserialize)] + struct ArchitectureCatalog { + package: Vec<ArchitecturePackage>, + } + + #[derive(Deserialize)] + struct ArchitecturePackage { + name: String, + } + + fn policy() -> DependencyBoundaryPolicy { + toml::from_str(POLICY).expect("package tier policy") + } + + fn metadata(raw: &str) -> CargoMetadata { + serde_json::from_str(raw).expect("Cargo metadata fixture") + } + + fn placements<'a>( + policy: &'a DependencyBoundaryPolicy, + ) -> std::collections::BTreeMap<&'a str, super::PackagePlacement<'a>> { + let architecture = + toml::from_str::<ArchitectureCatalog>(ARCHITECTURE).expect("architecture catalog"); + let expected = architecture + .package + .into_iter() + .map(|package| package.name) + .collect::<BTreeSet<_>>(); + validate_policy(policy, &expected).expect("valid package tier policy") + } + + #[test] + fn policy_covers_the_exact_architecture_catalog() { + let policy = policy(); + let placements = placements(&policy); + assert_eq!(placements.len(), 19); + } + + #[test] + fn domain_to_storage_reports_resolved_alias_kind_target_and_features() { + let policy = policy(); + let error = validate_metadata(&policy, &placements(&policy), &metadata(DOMAIN_TO_STORAGE)) + .expect_err("domain must not depend on storage"); + assert!(error.contains("radroots_event (tier=domain rank=1)")); + assert!(error.contains("radroots_storage (tier=spi rank=2)")); + assert!(error.contains("alias=storage_alias")); + assert!(error.contains("kind=normal")); + assert!(error.contains("target=cfg(unix)")); + assert!(error.contains("features=[memory]")); + } + + #[test] + fn spi_to_adapter_reports_build_and_target_specific_edge() { + let policy = policy(); + let error = validate_metadata(&policy, &placements(&policy), &metadata(SPI_TO_ADAPTER)) + .expect_err("SPI must not depend on adapter"); + assert!(error.contains("radroots_transport (tier=spi rank=2)")); + assert!(error.contains("radroots_nostr (tier=adapter rank=3)")); + assert!(error.contains("alias=protocol_adapter")); + assert!(error.contains("kind=build")); + assert!(error.contains("target=cfg(target_arch = \"wasm32\")")); + assert!(error.contains("features=[events]")); + } + + #[test] + fn service_orchestration_to_sdk_is_forbidden() { + let policy = policy(); + let error = validate_metadata(&policy, &placements(&policy), &metadata(SERVICE_TO_SDK)) + .expect_err("service orchestration must not depend on SDK"); + assert!(error.contains("radroots_sync (tier=orchestration rank=4)")); + assert!(error.contains("radroots_sdk (tier=sdk rank=5)")); + assert!(error.contains("alias=client_engine")); + } + + #[test] + fn valid_downward_dev_target_edge_passes() { + let policy = policy(); + validate_metadata(&policy, &placements(&policy), &metadata(VALID_DOWNWARD)) + .expect("adapter dev dependency on SPI points downward"); + } + + #[test] + fn migration_exception_is_exact_about_features() { + let policy = policy(); + let owner = super::CargoPackage { + id: "owner".to_owned(), + name: "radroots_trade".to_owned(), + dependencies: Vec::new(), + }; + let dependency = super::CargoPackage { + id: "dependency".to_owned(), + name: "radroots_nostr".to_owned(), + dependencies: Vec::new(), + }; + let kind = super::CargoDependencyKind { + kind: Some("dev".to_owned()), + target: None, + }; + let exact = super::CargoDependency { + name: "radroots_nostr".to_owned(), + kind: Some("dev".to_owned()), + features: vec!["std".to_owned(), "events".to_owned()], + target: None, + uses_default_features: false, + }; + assert!(exception_matches( + &policy.temporary_exception, + &owner, + &dependency, + &kind, + &exact + )); + + let broadened = super::CargoDependency { + features: vec!["client".to_owned(), "events".to_owned(), "std".to_owned()], + ..exact + }; + assert!(!exception_matches( + &policy.temporary_exception, + &owner, + &dependency, + &kind, + &broadened + )); + } + + #[test] + fn metadata_without_resolve_graph_fails_closed() { + let policy = policy(); + let mut fixture = metadata(VALID_DOWNWARD); + fixture.resolve = None; + let error = validate_metadata(&policy, &placements(&policy), &fixture) + .expect_err("missing resolved graph must fail"); + assert!(error.contains("did not include a resolved dependency graph")); + } +} diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -94,8 +94,9 @@ pub(crate) fn validate_artifact_contracts(workspace_root: &Path) -> Result<(), S feature_support::validate_feature_support(workspace_root)?; validate_event_contract_registry_v7_inventory(workspace_root)?; validate_nip09_reconciliation_manifest(workspace_root)?; - validate_food_availability_projection_manifest(workspace_root)?; validate_source_maintenance_manifest(workspace_root)?; + // The active publication chain validates the immutable FoodAvailability + // predecessor while explicitly authenticating its superseded executor. phase1_publication_artifact::validate_immutable_raw_source_rebuild_predecessor(workspace_root)?; validate_immutable_phase1_publication_artifact_predecessor(workspace_root)?; validate_release_provenance_schema(workspace_root)?; @@ -346,7 +347,6 @@ const KNOWLEDGE_BETA_CONTRACT_IDS: [&str; 3] = [ const EVENT_BOUNDARY_MATRIX_ENV: &str = "RADROOTS_EVENT_BOUNDARY_MATRIX"; const COVERAGE_REQUIRED_THRESHOLD: f64 = 90.0; const COVERAGE_REQUIRED_THRESHOLD_LABEL: &str = "90/90/90/90"; -#[cfg_attr(not(test), allow(dead_code))] const COVERAGE_REPORT_EPSILON: f64 = 0.000_001; const DTO_TOOLING_DEPENDENCIES: [&str; 4] = [ "dto_bindgen", @@ -373,7 +373,7 @@ const RETIRED_OPERATION_EVENT_NAMES: [&str; 15] = [ ]; const REQUIRED_CALENDAR_PUBLIC_TYPES: [&str; 34] = [ "RadrootsNip01EventWireParts", - "RadrootsBlossomBlobUrl", + "BlobUrl", "RadrootsAuthoredImage", "RadrootsAuthoredImageError", "RadrootsIanaTimeZoneId", @@ -408,8 +408,8 @@ const REQUIRED_CALENDAR_PUBLIC_TYPES: [&str; 34] = [ "RadrootsAdmittedCalendarEventRsvp", ]; const REQUIRED_POST_PUBLIC_TYPES: [&str; 34] = [ - "RadrootsBlossomApprovedBlobUrl", - "RadrootsBlossomByteVerifiedDescriptor", + "ApprovedBlobUrl", + "ByteVerifiedDescriptor", "RadrootsNip01EventWireParts", "RadrootsEventEnvelope", "RadrootsSignatureVerifiedEvent", @@ -444,7 +444,7 @@ const REQUIRED_POST_PUBLIC_TYPES: [&str; 34] = [ "RadrootsNip10ReplyAdmissionError", ]; const REQUIRED_FOOD_AVAILABILITY_PUBLIC_TYPES: [&str; 31] = [ - "RadrootsBlossomByteVerifiedDescriptor", + "ByteVerifiedDescriptor", "RadrootsNip01EventWireParts", "RadrootsEventEnvelope", "RadrootsSignatureVerifiedEvent", @@ -649,7 +649,7 @@ const CALENDAR_OPERATION_EXPECTATIONS: [CalendarOperationExpectation; 12] = [ "crates/event_codec/src/calendar/decode.rs", ], rust_types: &[ - "radroots_blossom::RadrootsBlossomBlobUrl", + "radroots_blossom::BlobUrl", "radroots_event::calendar::RadrootsAdmittedCalendar", "radroots_event::calendar::RadrootsCalendarAdmissionError", "radroots_event::calendar::RadrootsCalendarEventReference", @@ -762,8 +762,8 @@ const POST_OPERATION_EXPECTATIONS: [PostOperationExpectation; 8] = [ "crates/event_codec/src/post/authored.rs", ], rust_types: &[ - "radroots_blossom::RadrootsBlossomApprovedBlobUrl", - "radroots_blossom::RadrootsBlossomByteVerifiedDescriptor", + "radroots_blossom::ApprovedBlobUrl", + "radroots_blossom::ByteVerifiedDescriptor", "radroots_event::media::RadrootsAuthoredImage", "radroots_event::post::RadrootsAuthoredPhotoUpdate", "radroots_event::post::RadrootsAuthoredPostError", @@ -1199,7 +1199,7 @@ const FOOD_AVAILABILITY_OPERATION_EXPECTATIONS: [FoodAvailabilityOperationExpect "crates/event_codec/src/food_availability/authored.rs", ], rust_types: &[ - "radroots_blossom::RadrootsBlossomByteVerifiedDescriptor", + "radroots_blossom::ByteVerifiedDescriptor", "radroots_event::food_availability::RadrootsFoodAvailabilityDetails", "radroots_event::food_availability::RadrootsFoodAvailabilityDetailsParts", "radroots_event::food_availability::RadrootsFoodAvailabilityError", @@ -3242,7 +3242,16 @@ const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 44] = [ struct ReleaseContractFile { schema: ReleasePolicySchema, release: ReleaseSection, + #[serde(default)] + publication: Option<PublicationControl>, + #[serde(default)] + workspace_classification: Option<WorkspaceReleaseClassification>, + #[serde(default)] classification: ReleaseClassification, + #[serde(default)] + publish: Option<ReleaseCrateSet>, + #[serde(default)] + internal: Option<ReleaseCrateSet>, publish_order: ReleaseCrateSet, } @@ -3252,13 +3261,18 @@ struct ReleasePolicySchema { version: u32, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct ReleaseClassification { + #[serde(default)] public: Vec<String>, + #[serde(default)] internal: Vec<String>, + #[serde(default)] deferred: Vec<String>, + #[serde(default)] retired: Vec<String>, + #[serde(default)] yank_only: Vec<String>, } @@ -3269,7 +3283,60 @@ struct ReleaseSection { } #[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +struct PublicationControl { + frozen: bool, + registry: String, + final_enablement_step: u16, + #[serde(default)] + spec_id: String, + #[serde(default)] + approved_packages: Vec<String>, + #[serde(default)] + local_packages: Vec<String>, + #[serde(default)] + external_packages: Vec<String>, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceReleaseClassification { + #[serde(default)] + private: Vec<String>, + #[serde(default)] + build_codegen: Vec<String>, + #[serde(default)] + test_support: Vec<String>, + #[serde(default)] + preview: Vec<String>, + #[serde(default)] + retired: Vec<String>, +} + +#[derive(Debug, Deserialize)] +struct CratesReleaseArchitecture { + spec_id: String, + package_count: usize, + repositories: CratesReleaseRepositories, + package: Vec<CratesReleasePackage>, +} + +#[derive(Debug, Deserialize)] +struct CratesReleaseRepositories { + lib: CratesReleaseRepository, + sdk: CratesReleaseRepository, +} + +#[derive(Debug, Deserialize)] +struct CratesReleaseRepository { + version: String, + packages: Vec<String>, +} + +#[derive(Debug, Deserialize)] +struct CratesReleasePackage { + name: String, +} + +#[derive(Debug, Deserialize)] struct ReleaseCrateSet { crates: Vec<String>, } @@ -3322,16 +3389,48 @@ struct ConformanceVectorEntry { id: String, kind: String, input: Value, - expected: Value, + expected: Option<Value>, + expected_error_contains: Option<String>, +} + +impl ConformanceVectorEntry { + fn expected_value(&self) -> Result<&Value, String> { + self.expected.as_ref().ok_or_else(|| { + format!( + "conformance vector {} does not define an expected output", + self.id + ) + }) + } } impl ReleaseContractFile { + fn uses_classification(&self) -> bool { + !self.classification.public.is_empty() + || !self.classification.internal.is_empty() + || !self.classification.deferred.is_empty() + || !self.classification.retired.is_empty() + || !self.classification.yank_only.is_empty() + } + fn public_crates(&self) -> Vec<String> { - self.classification.public.clone() + if self.uses_classification() { + return self.classification.public.clone(); + } + self.publish + .as_ref() + .map(|set| set.crates.clone()) + .unwrap_or_default() } fn internal_crates(&self) -> Vec<String> { - self.classification.internal.clone() + if self.uses_classification() { + return self.classification.internal.clone(); + } + self.internal + .as_ref() + .map(|set| set.crates.clone()) + .unwrap_or_default() } fn deferred_crates(&self) -> Vec<String> { @@ -4132,15 +4231,27 @@ fn validate_conformance_schema(workspace_root: &Path) -> Result<(), String> { "required", &path, )?; - let expected_item_required = BTreeSet::from([ - "expected".to_string(), - "id".to_string(), - "input".to_string(), - "kind".to_string(), - ]); + let expected_item_required = + BTreeSet::from(["id".to_string(), "input".to_string(), "kind".to_string()]); if item_required != expected_item_required { return Err(format!( - "vector item schema in {} must require id, kind, input, and expected", + "vector item schema in {} must require id, kind, and input", + path.display() + )); + } + let expected_one_of = serde_json::json!([ + { + "required": ["expected"], + "not": {"required": ["expected_error_contains"]} + }, + { + "required": ["expected_error_contains"], + "not": {"required": ["expected"]} + } + ]); + if items.get("oneOf") != Some(&expected_one_of) { + return Err(format!( + "vector item schema in {} must require exactly one of expected or expected_error_contains", path.display() )); } @@ -4193,6 +4304,20 @@ fn validate_conformance_schema(workspace_root: &Path) -> Result<(), String> { )); } } + validate_string_schema_property( + item_properties + .get("expected_error_contains") + .ok_or_else(|| { + format!( + "vector item schema in {} missing expected_error_contains property", + path.display() + ) + })?, + "expected_error_contains", + &path, + Some(1), + None, + )?; Ok(()) } @@ -4225,38 +4350,53 @@ fn validate_contract_version_lockstep(bundle: &ContractBundle) -> Result<(), Str fn validate_workspace_version_lockstep( workspace_root: &Path, - crate_version: &str, + contract_version: &str, ) -> Result<(), String> { let workspace_manifest = parse_toml::<WorkspaceVersionCargoManifest>(&workspace_root.join("Cargo.toml"))?; - if workspace_manifest.workspace.package.version != crate_version { + let architecture_path = workspace_root.join("docs/specs/radroots_crates_release_v1.toml"); + let (governed_version, authority_label) = if architecture_path.is_file() { + ( + parse_toml::<CratesReleaseArchitecture>(&architecture_path)? + .repositories + .lib + .version, + "library repository version", + ) + } else { + (contract_version.to_owned(), "Rust crate version authority") + }; + if workspace_manifest.workspace.package.version != governed_version { return Err(format!( - "workspace.package.version {} must match Rust crate version authority {}", - workspace_manifest.workspace.package.version, crate_version + "workspace.package.version {} must match {authority_label} {}", + workspace_manifest.workspace.package.version, governed_version, )); } - - let exact_requirement = format!("={crate_version}"); let mut governed_packages = BTreeMap::new(); for member in &workspace_manifest.workspace.members { let package_path = workspace_root.join(member).join("Cargo.toml"); let package = parse_toml::<VersionedPackageCargoManifest>(&package_path)?; + let expected_version = governed_version.as_str(); match package.package.version { - PackageVersionSource::Literal(ref version) if version == crate_version => {} + PackageVersionSource::Literal(ref version) if version == expected_version => {} PackageVersionSource::Literal(version) => { return Err(format!( - "workspace member {member} package version {version} must match Rust crate version authority {crate_version}" + "workspace member {member} package version {version} must match governed version {expected_version}" )); } PackageVersionSource::Workspace { workspace } => { return Err(format!( - "workspace member {member} must set an explicit package version {crate_version}, not version.workspace = {workspace}, so mounted path consumers preserve the public package version" + "workspace member {member} must set an explicit package version {expected_version}, not version.workspace = {workspace}, so mounted path consumers preserve the governed package version" )); } } - governed_packages.insert(member.clone(), package.package.name.clone()); + governed_packages.insert( + member.clone(), + (package.package.name.clone(), expected_version.to_owned()), + ); if package.package.name.starts_with("radroots_") { + let exact_requirement = format!("={expected_version}"); let dependency = workspace_manifest .workspace .dependencies @@ -4286,25 +4426,26 @@ fn validate_workspace_version_lockstep( let Some(path) = dependency.path.as_deref() else { continue; }; - if governed_packages.contains_key(path) - && dependency.version.as_deref() != Some(exact_requirement.as_str()) - { + let Some((_, expected_version)) = governed_packages.get(path) else { + continue; + }; + let exact_requirement = format!("={expected_version}"); + if dependency.version.as_deref() != Some(exact_requirement.as_str()) { return Err(format!( "workspace path dependency {dependency_name} version must be the exact requirement {exact_requirement}" )); } } - validate_cargo_lock_version_lockstep(workspace_root, crate_version, &governed_packages) + validate_cargo_lock_version_lockstep(workspace_root, &governed_packages) } fn validate_cargo_lock_version_lockstep( workspace_root: &Path, - crate_version: &str, - governed_packages: &BTreeMap<String, String>, + governed_packages: &BTreeMap<String, (String, String)>, ) -> Result<(), String> { let lock = parse_toml::<CargoLockManifest>(&workspace_root.join("Cargo.lock"))?; - for (member, package_name) in governed_packages { + for (member, (package_name, expected_version)) in governed_packages { let workspace_entries = lock .package .iter() @@ -4315,9 +4456,9 @@ fn validate_cargo_lock_version_lockstep( "Cargo.lock must contain exactly one source-free entry for workspace member {member} ({package_name})" )); } - if workspace_entries[0].version != crate_version { + if workspace_entries[0].version != *expected_version { return Err(format!( - "Cargo.lock package {package_name} version {} must match Rust crate version authority {crate_version}", + "Cargo.lock package {package_name} version {} must match governed version {expected_version}", workspace_entries[0].version )); } @@ -4757,6 +4898,24 @@ fn validate_conformance_vector_file( entry.id )); } + match (&entry.expected, &entry.expected_error_contains) { + (Some(_), None) => {} + (None, Some(fragment)) if !fragment.trim().is_empty() => {} + (None, Some(_)) => { + return Err(format!( + "conformance vector {} entry {} expected_error_contains must not be blank", + path.display(), + entry.id + )); + } + _ => { + return Err(format!( + "conformance vector {} entry {} must define exactly one of expected or expected_error_contains", + path.display(), + entry.id + )); + } + } } Ok(vector) } @@ -4953,7 +5112,7 @@ fn validate_knowledge_manifest_vector_semantics( ("schema_version", schema_version), ("registry_version", registry_version), ] { - let actual = case.expected.get(field).and_then(Value::as_u64); + let actual = case.expected_value()?.get(field).and_then(Value::as_u64); if actual != Some(expected) { return Err(format!( "knowledge manifest conformance expected {field} drift: expected {expected}, got {}", @@ -5133,7 +5292,6 @@ struct WorkspacePackageRecord { #[cfg_attr(not(test), allow(dead_code))] manifest_path: PathBuf, publish_enabled: bool, - #[cfg(test)] publish: Option<PackagePublish>, manifest_value: toml::Value, } @@ -5162,7 +5320,6 @@ fn workspace_package_records(workspace_root: &Path) -> Result<Vec<WorkspacePacka name, manifest_path, publish_enabled, - #[cfg(test)] publish: package_manifest.package.publish.clone(), manifest_value, }); @@ -5321,7 +5478,6 @@ fn workspace_package_publish_flags( Ok(flags) } -#[cfg(test)] fn workspace_package_publish_configs( workspace_root: &Path, ) -> Result<BTreeMap<String, Option<PackagePublish>>, String> { @@ -7221,8 +7377,9 @@ fn validate_deletion_suppression_vector_shape( entry: &ConformanceVectorEntry, ) -> Result<(), String> { validate_deletion_suppression_forbidden_material(&entry.input, &format!("{}.input", entry.id))?; + let expected_value = entry.expected_value()?; validate_deletion_suppression_forbidden_material( - &entry.expected, + expected_value, &format!("{}.expected", entry.id), )?; @@ -7272,7 +7429,7 @@ fn validate_deletion_suppression_vector_shape( request_ids.insert(event.id); } - let expected = deletion_object(&entry.expected, &format!("{} expected", entry.id))?; + let expected = deletion_object(expected_value, &format!("{} expected", entry.id))?; validate_deletion_object_keys( expected, &format!("{} expected", entry.id), @@ -7565,7 +7722,8 @@ fn validate_deletion_suppression_forbidden_material( fn validate_deletion_vector_shape(entry: &ConformanceVectorEntry) -> Result<(), String> { validate_deletion_forbidden_metadata(&entry.input, &format!("{}.input", entry.id))?; - validate_deletion_forbidden_metadata(&entry.expected, &format!("{}.expected", entry.id))?; + let expected_value = entry.expected_value()?; + validate_deletion_forbidden_metadata(expected_value, &format!("{}.expected", entry.id))?; let input = deletion_object(&entry.input, &format!("{} input", entry.id))?; let is_authored = entry @@ -7665,7 +7823,7 @@ fn validate_deletion_vector_shape(entry: &ConformanceVectorEntry) -> Result<(), } } - let expected = deletion_object(&entry.expected, &format!("{} expected", entry.id))?; + let expected = deletion_object(expected_value, &format!("{} expected", entry.id))?; if !is_valid { validate_deletion_object_keys(expected, &format!("{} expected", entry.id), &["error"])?; if !expected @@ -8658,7 +8816,7 @@ fn validate_required_coverage_summary_with_policy( Ok(()) } -const CORE_UNIT_DIMENSION_ENUM: &str = "RadrootsCoreUnitDimension"; +const CORE_UNIT_DIMENSION_ENUM: &str = "UnitDimension"; const CORE_UNIT_DIMENSION_ORDER: [&str; 3] = ["Count", "Mass", "Volume"]; fn extract_enum_body<'a>(source: &'a str, enum_name: &str) -> Result<&'a str, String> { @@ -8802,7 +8960,6 @@ fn validate_coverage_policy_parity( Ok(()) } -#[cfg(test)] fn publish_config_is_public(publish: Option<&PackagePublish>) -> bool { matches!( publish, @@ -8811,7 +8968,6 @@ fn publish_config_is_public(publish: Option<&PackagePublish>) -> bool { ) } -#[cfg(test)] fn publish_config_is_non_public(publish: Option<&PackagePublish>) -> bool { matches!(publish, Some(PackagePublish::Bool(false))) } @@ -8824,6 +8980,283 @@ fn metadata_publish_config_is_non_public(publish: Option<&Vec<String>>) -> bool matches!(publish, Some(registries) if registries.is_empty()) } +fn validate_publication_control( + release: &ReleaseContractFile, + publish_configs: &BTreeMap<String, Option<PackagePublish>>, + require_control: bool, +) -> Result<bool, String> { + let Some(control) = release.publication.as_ref() else { + if require_control { + return Err("publication control is required".to_string()); + } + return Ok(false); + }; + if control.registry != "crates-io" { + return Err("publication.registry must be crates-io".to_string()); + } + if control.final_enablement_step != 305 { + return Err("publication.final_enablement_step must be 305".to_string()); + } + if !control.frozen { + return Ok(false); + } + for (crate_name, publish) in publish_configs { + if !publish_config_is_non_public(publish.as_ref()) { + return Err(format!( + "publication freeze requires workspace crate {} to set publish = false", + crate_name + )); + } + } + Ok(true) +} + +fn validate_v1_release_policy( + workspace_root: &Path, + release: &ReleaseContractFile, + workspace_packages: &BTreeSet<String>, + publish_configs: &BTreeMap<String, Option<PackagePublish>>, + require_v1: bool, +) -> Result<Option<BTreeSet<String>>, String> { + let Some(control) = release.publication.as_ref() else { + if require_v1 { + return Err("publication control is required".to_string()); + } + return Ok(None); + }; + let declares_v1 = !control.spec_id.is_empty() + || !control.approved_packages.is_empty() + || !control.local_packages.is_empty() + || !control.external_packages.is_empty() + || release.workspace_classification.is_some(); + if !declares_v1 { + if require_v1 { + return Err("publication must define the v1 approved package authority".to_string()); + } + return Ok(None); + } + + let architecture_path = workspace_root.join("docs/specs/radroots_crates_release_v1.toml"); + let architecture = parse_toml::<CratesReleaseArchitecture>(&architecture_path)?; + let expected_approved = collect_unique_set( + &architecture + .package + .iter() + .map(|package| package.name.clone()) + .collect::<Vec<_>>(), + "architecture.package.name", + )?; + if architecture.package_count != expected_approved.len() || architecture.package_count != 19 { + return Err(format!( + "release architecture must define exactly 19 unique packages, found package_count {} and {} unique package records", + architecture.package_count, + expected_approved.len() + )); + } + if control.spec_id != architecture.spec_id || control.spec_id != "radroots.crates.release.v1" { + return Err(format!( + "publication.spec_id {} must match architecture id {}", + control.spec_id, architecture.spec_id + )); + } + + let approved = collect_unique_set(&control.approved_packages, "publication.approved_packages")?; + let local = collect_unique_set(&control.local_packages, "publication.local_packages")?; + let external = collect_unique_set(&control.external_packages, "publication.external_packages")?; + let expected_local = collect_unique_set( + &architecture.repositories.lib.packages, + "architecture.repositories.lib.packages", + )?; + let expected_external = collect_unique_set( + &architecture.repositories.sdk.packages, + "architecture.repositories.sdk.packages", + )?; + for (field, actual, expected) in [ + ( + "publication.approved_packages", + &approved, + &expected_approved, + ), + ("publication.local_packages", &local, &expected_local), + ( + "publication.external_packages", + &external, + &expected_external, + ), + ] { + if actual != expected { + let missing = expected + .difference(actual) + .cloned() + .collect::<BTreeSet<_>>(); + let extra = actual + .difference(expected) + .cloned() + .collect::<BTreeSet<_>>(); + return Err(format!( + "{field} is missing approved packages: {}; {field} has unapproved packages: {}", + join_set(&missing), + join_set(&extra) + )); + } + } + let ownership_overlap = local + .intersection(&external) + .cloned() + .collect::<BTreeSet<_>>(); + if !ownership_overlap.is_empty() { + return Err(format!( + "local and external approved package ownership overlaps: {}", + join_set(&ownership_overlap) + )); + } + let mut owned = local.clone(); + owned.extend(external.iter().cloned()); + if owned != approved { + return Err( + "local and external package ownership must partition approved packages".to_string(), + ); + } + let external_in_workspace = external + .intersection(workspace_packages) + .cloned() + .collect::<BTreeSet<_>>(); + if !external_in_workspace.is_empty() { + return Err(format!( + "externally owned approved packages must not be workspace members: {}", + join_set(&external_in_workspace) + )); + } + + let classification = release.workspace_classification.as_ref().ok_or_else(|| { + "workspace_classification is required for the v1 release policy".to_string() + })?; + let private = collect_unique_set(&classification.private, "workspace_classification.private")?; + let build_codegen = collect_unique_set( + &classification.build_codegen, + "workspace_classification.build_codegen", + )?; + let test_support = collect_unique_set( + &classification.test_support, + "workspace_classification.test_support", + )?; + let preview = collect_unique_set(&classification.preview, "workspace_classification.preview")?; + let retired = collect_unique_set(&classification.retired, "workspace_classification.retired")?; + let classes = [ + ("private", &private), + ("build-codegen", &build_codegen), + ("test-support", &test_support), + ("preview", &preview), + ("retired", &retired), + ]; + for index in 0..classes.len() { + for other_index in (index + 1)..classes.len() { + let overlap = classes[index] + .1 + .intersection(classes[other_index].1) + .cloned() + .collect::<BTreeSet<_>>(); + if !overlap.is_empty() { + return Err(format!( + "workspace classification overlap is not allowed between {} and {}: {}", + classes[index].0, + classes[other_index].0, + join_set(&overlap) + )); + } + } + } + let mut classified = BTreeSet::new(); + for (_, entries) in classes { + classified.extend(entries.iter().cloned()); + } + let local_workspace_packages = local + .intersection(workspace_packages) + .cloned() + .collect::<BTreeSet<_>>(); + let public_classification_overlap = classified + .intersection(&local_workspace_packages) + .cloned() + .collect::<BTreeSet<_>>(); + if !public_classification_overlap.is_empty() { + return Err(format!( + "approved local packages must not be classified as private workspace packages: {}", + join_set(&public_classification_overlap) + )); + } + let mut accounted = classified.clone(); + accounted.extend(local_workspace_packages.iter().cloned()); + if accounted != *workspace_packages { + let missing = workspace_packages + .difference(&accounted) + .cloned() + .collect::<BTreeSet<_>>(); + let extra = accounted + .difference(workspace_packages) + .cloned() + .collect::<BTreeSet<_>>(); + return Err(format!( + "workspace classification is missing packages: {}; workspace classification has unknown packages: {}", + join_set(&missing), + join_set(&extra) + )); + } + + if control.registry != "crates-io" { + return Err("publication.registry must be crates-io".to_string()); + } + if control.final_enablement_step != 305 { + return Err("publication.final_enablement_step must be 305".to_string()); + } + let publish_order = collect_unique_set(&release.publish_order.crates, "publish_order.crates")?; + if control.frozen { + if !publish_order.is_empty() { + return Err( + "publish_order.crates must remain empty while publication is frozen".to_string(), + ); + } + for (crate_name, publish) in publish_configs { + if !publish_config_is_non_public(publish.as_ref()) { + return Err(format!( + "publication freeze requires workspace crate {} to set publish = false", + crate_name + )); + } + } + return Ok(Some(BTreeSet::new())); + } + + if local_workspace_packages != local { + let missing = local + .difference(&local_workspace_packages) + .cloned() + .collect::<BTreeSet<_>>(); + return Err(format!( + "publication enablement is missing approved local workspace packages: {}", + join_set(&missing) + )); + } + if publish_order != local { + return Err("publish_order.crates must contain exactly the approved local packages when publication is enabled".to_string()); + } + for (crate_name, publish) in publish_configs { + if local.contains(crate_name) { + if !publish_config_is_public(publish.as_ref()) { + return Err(format!( + "approved local crate {} must set publish = [\"crates-io\"]", + crate_name + )); + } + } else if !publish_config_is_non_public(publish.as_ref()) { + return Err(format!( + "private workspace crate {} must set publish = false", + crate_name + )); + } + } + Ok(Some(local)) +} + #[cfg(test)] fn validate_release_publish_policy( workspace_root: &Path, @@ -8846,9 +9279,33 @@ fn validate_release_publish_policy( let metadata_packages = load_release_workspace_packages(workspace_root)?; let workspace_packages = metadata_packages.keys().cloned().collect::<BTreeSet<_>>(); + let publish_configs = workspace_package_publish_configs(workspace_root) + .expect("workspace publish configs are stable"); + if validate_v1_release_policy( + workspace_root, + &release, + &workspace_packages, + &publish_configs, + false, + )? + .is_some() + { + return Ok(()); + } + let uses_classification = release.uses_classification(); + let public_field = if uses_classification { + "classification.public" + } else { + "publish.crates" + }; + let internal_field = if uses_classification { + "classification.internal" + } else { + "internal.crates" + }; - let public_set = collect_unique_set(&release.public_crates(), "classification.public")?; - let internal_set = collect_unique_set(&release.internal_crates(), "classification.internal")?; + let public_set = collect_unique_set(&release.public_crates(), public_field)?; + let internal_set = collect_unique_set(&release.internal_crates(), internal_field)?; let deferred_set = collect_unique_set(&release.deferred_crates(), "classification.deferred")?; let retired_set = collect_unique_set(&release.retired_crates(), "classification.retired")?; let yank_only_set = @@ -8941,6 +9398,9 @@ fn validate_release_publish_policy( } } + if validate_publication_control(&release, &publish_configs, false)? { + return Ok(()); + } for crate_name in &public_set { let publish = metadata_packages[crate_name].publish.as_ref(); if !metadata_publish_config_is_public(publish) { @@ -9098,20 +9558,53 @@ fn validate_contract_bundle_with_release_policy_override_and_profile( validate_core_unit_dimension_variant_order(workspace_root)?; validate_coverage_policy_parity(workspace_root, &bundle.root)?; validate_version_governance(bundle, workspace_root)?; - validate_release_publish_policy_with_override( + if matches!( + authority_profile, + OperationAuthorityProfile::CapsuleCanonical + ) { + crate::architecture::validate(workspace_root)?; + } + validate_release_publish_policy_with_override_and_control( workspace_root, &bundle.root, bundle.version.contract.version.as_str(), release_policy_override, + matches!( + authority_profile, + OperationAuthorityProfile::CapsuleCanonical + ), + matches!( + authority_profile, + OperationAuthorityProfile::CapsuleCanonical + ), )?; Ok(()) } +#[cfg(test)] fn validate_release_publish_policy_with_override( workspace_root: &Path, + contract_root: &Path, + contract_version: &str, + release_policy_override: Option<PathBuf>, +) -> Result<(), String> { + validate_release_publish_policy_with_override_and_control( + workspace_root, + contract_root, + contract_version, + release_policy_override, + true, + false, + ) +} + +fn validate_release_publish_policy_with_override_and_control( + workspace_root: &Path, _contract_root: &Path, contract_version: &str, release_policy_override: Option<PathBuf>, + require_publication_control: bool, + require_v1_policy: bool, ) -> Result<(), String> { let release = load_release_contract_with_override( workspace_root, @@ -9133,9 +9626,33 @@ fn validate_release_publish_policy_with_override( let metadata_packages = load_release_workspace_packages(workspace_root)?; let workspace_packages = metadata_packages.keys().cloned().collect::<BTreeSet<_>>(); + let publish_configs = workspace_package_publish_configs(workspace_root) + .expect("workspace publish configs are stable"); + if validate_v1_release_policy( + workspace_root, + &release, + &workspace_packages, + &publish_configs, + require_v1_policy, + )? + .is_some() + { + return Ok(()); + } + let uses_classification = release.uses_classification(); + let public_field = if uses_classification { + "classification.public" + } else { + "publish.crates" + }; + let internal_field = if uses_classification { + "classification.internal" + } else { + "internal.crates" + }; - let public_set = collect_unique_set(&release.public_crates(), "classification.public")?; - let internal_set = collect_unique_set(&release.internal_crates(), "classification.internal")?; + let public_set = collect_unique_set(&release.public_crates(), public_field)?; + let internal_set = collect_unique_set(&release.internal_crates(), internal_field)?; let deferred_set = collect_unique_set(&release.deferred_crates(), "classification.deferred")?; let retired_set = collect_unique_set(&release.retired_crates(), "classification.retired")?; let yank_only_set = @@ -9228,6 +9745,9 @@ fn validate_release_publish_policy_with_override( } } + if validate_publication_control(&release, &publish_configs, require_publication_control)? { + return Ok(()); + } for crate_name in &public_set { let publish = metadata_packages[crate_name].publish.as_ref(); if !metadata_publish_config_is_public(publish) { @@ -9429,10 +9949,10 @@ mod tests { fn required_thresholds() -> CoverageThresholds { CoverageThresholds { - fail_under_exec_lines: 90.0, - fail_under_functions: 90.0, - fail_under_regions: 90.0, - fail_under_branches: 90.0, + fail_under_exec_lines: COVERAGE_REQUIRED_THRESHOLD, + fail_under_functions: COVERAGE_REQUIRED_THRESHOLD, + fail_under_regions: COVERAGE_REQUIRED_THRESHOLD, + fail_under_branches: COVERAGE_REQUIRED_THRESHOLD, require_branches: true, } } @@ -9462,7 +9982,7 @@ mod tests { TestCoverageRefreshRow { crate_name, status: "pass", - thresholds: coverage_thresholds(90.0, true), + thresholds: coverage_thresholds(COVERAGE_REQUIRED_THRESHOLD, true), exec: 100.0, func: 100.0, branch: Some(100.0), @@ -9551,6 +10071,26 @@ mod tests { fn create_synthetic_workspace(prefix: &str) -> PathBuf { let root = temp_root(prefix); write_file( + &root.join("docs/specs/radroots_crates_release_v1.toml"), + r#"spec_id = "radroots.crates.release.v1" +package_count = 2 + +[repositories.lib] +version = "1.0.0" +packages = ["radroots_a", "radroots_b"] + +[repositories.sdk] +version = "0.1.0" +packages = [] + +[[package]] +name = "radroots_a" + +[[package]] +name = "radroots_b" +"#, + ); + write_file( &root.join("Cargo.toml"), r#"[workspace] members = ["crates/a", "crates/b"] @@ -9643,7 +10183,7 @@ version = "1.0.0" ); write_file( &root.join("crates").join("core").join("src").join("unit.rs"), - r#"pub enum RadrootsCoreUnitDimension { + r#"pub enum UnitDimension { Count, Mass, Volume, @@ -9907,7 +10447,7 @@ vector = "contracts/conformance/vectors/operational_listing/build_draft.v1.json" "type": "array", "items": { "type": "object", - "required": ["id", "kind", "input", "expected"], + "required": ["id", "kind", "input"], "properties": { "id": { "type": "string", @@ -9918,8 +10458,22 @@ vector = "contracts/conformance/vectors/operational_listing/build_draft.v1.json" "minLength": 1 }, "input": {}, - "expected": {} + "expected": {}, + "expected_error_contains": { + "type": "string", + "minLength": 1 + } }, + "oneOf": [ + { + "required": ["expected"], + "not": {"required": ["expected_error_contains"]} + }, + { + "required": ["expected_error_contains"], + "not": {"required": ["expected"]} + } + ], "additionalProperties": false } } @@ -10285,8 +10839,10 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] .find(|entry| entry.id == "knowledge_manifest_fields_valid_001") .expect("knowledge manifest case"); case.expected - .as_object_mut() + .as_mut() .expect("knowledge manifest expected output") + .as_object_mut() + .expect("knowledge manifest expected output object") .insert("registry_version".to_string(), Value::from(1_u64)); let error = validate_knowledge_manifest_vector_semantics(&manifest, &vector) .expect_err("stale expected registry version must fail"); @@ -10424,7 +10980,8 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] id: "unclaimed_post_case".to_string(), kind: "social.post.unclaimed.valid".to_string(), input: Value::Object(Default::default()), - expected: Value::Object(Default::default()), + expected: Some(Value::Object(Default::default())), + expected_error_contains: None, }); let error = validate_post_operation_inventory(&manifest, &vector) .expect_err("unclaimed post vector kind must fail"); @@ -10650,7 +11207,8 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] id: "unclaimed_comment_case".to_string(), kind: "social.comment.project_verified_event.shadow".to_string(), input: Value::Object(Default::default()), - expected: Value::Object(Default::default()), + expected: Some(Value::Object(Default::default())), + expected_error_contains: None, }); let error = validate_comment_operation_inventory(&manifest, &vector) .expect_err("unclaimed Comment vector kind must fail"); @@ -10671,7 +11229,8 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] id: "legacy_comment".to_string(), kind: "social.comment.build_tags".to_string(), input: Value::Object(Default::default()), - expected: Value::Object(Default::default()), + expected: Some(Value::Object(Default::default())), + expected_error_contains: None, }], }; @@ -10769,6 +11328,7 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] kind: "social.deletion_request.unclaimed.valid".to_string(), input, expected, + expected_error_contains: None, }); let error = validate_deletion_operation_inventory(&manifest, &request_vector, &suppression_vector) @@ -10833,8 +11393,10 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] .find(|entry| entry.id == "nip09_project_signed_event_target_without_k") .expect("signed deletion vector") .expected - .as_object_mut() + .as_mut() .expect("projection expected") + .as_object_mut() + .expect("projection expected object") .insert("AuThOrIzAtIoN".to_string(), Value::Bool(true)); let error = validate_deletion_operation_inventory(&manifest, &request_vector, &suppression_vector) @@ -10918,8 +11480,10 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] .find(|entry| entry.id == "nip09_suppress_same_author_event_reference") .expect("event-reference suppression vector") .expected - .as_object_mut() + .as_mut() .expect("suppression expected") + .as_object_mut() + .expect("suppression expected object") .get_mut("event_reference") .expect("event-reference evidence") .as_object_mut() @@ -10943,7 +11507,8 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] id: "nip09_alternate".to_string(), kind: "social.deletion_request.build_authored_draft.valid".to_string(), input: Value::Object(Default::default()), - expected: Value::Object(Default::default()), + expected: Some(Value::Object(Default::default())), + expected_error_contains: None, }], }; @@ -11036,7 +11601,8 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] id: "food_unclaimed_case".to_string(), kind: "food_availability.unclaimed.valid".to_string(), input: Value::Object(Default::default()), - expected: Value::Object(Default::default()), + expected: Some(Value::Object(Default::default())), + expected_error_contains: None, }); let error = validate_food_availability_operation_inventory(&manifest, &vector) .expect_err("unclaimed FoodAvailability vector kind must fail"); @@ -11055,6 +11621,9 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] .expect_err("contract header drift must fail"); assert!(contract_error.contains("must match manifest contract version")); + fs::remove_file(root.join("docs/specs/radroots_crates_release_v1.toml")) + .expect("remove repository-level version override for crate-authority checks"); + let mut package_decoupled_bundle = load_contract_bundle(&root).expect("load package-decoupled contract"); package_decoupled_bundle.version.rust_crates.version = "0.1.0-alpha".to_string(); @@ -11097,6 +11666,25 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] .expect_err("lockfile version drift must fail"); assert!(lock_error.contains("Cargo.lock package radroots_a version")); + write_file( + &root.join("docs/specs/radroots_crates_release_v1.toml"), + r#"spec_id = "radroots.crates.release.v1" +package_count = 0 +package = [] + +[repositories.lib] +version = "0.1.0-alpha" +packages = [] + +[repositories.sdk] +version = "0.1.0" +packages = [] +"#, + ); + let architecture_error = validate_workspace_version_lockstep(&root, "1.0.0") + .expect_err("repository version authority must override protocol version"); + assert!(architecture_error.contains("must match library repository version 0.1.0-alpha")); + assert!(parse_semver_version("01.0.0").is_err()); assert!(parse_semver_version("1.0").is_err()); assert!(parse_semver_version("1.0.0-alpha_1").is_err()); @@ -11667,13 +12255,13 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] #[test] fn parses_enum_variants_in_declared_order() { let source = r#" -pub enum RadrootsCoreUnitDimension { +pub enum UnitDimension { Count, Mass, Volume, } "#; - let enum_body = extract_enum_body(source, "RadrootsCoreUnitDimension").expect("enum body"); + let enum_body = extract_enum_body(source, "UnitDimension").expect("enum body"); let variants = parse_enum_variants(enum_body); assert_eq!(variants, vec!["Count", "Mass", "Volume"]); } @@ -11681,13 +12269,13 @@ pub enum RadrootsCoreUnitDimension { #[test] fn fails_when_enum_order_does_not_match_contract() { let source = r#" -pub enum RadrootsCoreUnitDimension { +pub enum UnitDimension { Mass, Count, Volume, } "#; - let enum_body = extract_enum_body(source, "RadrootsCoreUnitDimension").expect("enum body"); + let enum_body = extract_enum_body(source, "UnitDimension").expect("enum body"); let variants = parse_enum_variants(enum_body); let expected = CORE_UNIT_DIMENSION_ORDER .iter() @@ -11919,7 +12507,7 @@ edition = "2024" fs::create_dir_all(&policy_dir).expect("create policy dir"); fs::write( policy_dir.join("coverage.toml"), - "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[overrides.radroots_event_codec]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 99.946\nfail_under_branches = 100.0\ntemporary = true\nreason = \"publish 1.0.0-alpha.1 temporary coverage override\"\n\n[overrides.radroots_log]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = false\ntemporary = true\nreason = \"branch coverage is not applicable while the crate has no measured branch records\"\n\n[required]\ncrates = [\"radroots_event_codec\", \"radroots_log\"]\n", + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[overrides.radroots_event_codec]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 99.946\nfail_under_branches = 100.0\ntemporary = true\nreason = \"publish 0.1.0-alpha temporary coverage override\"\n\n[overrides.radroots_log]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = false\ntemporary = true\nreason = \"branch coverage is not applicable while the crate has no measured branch records\"\n\n[required]\ncrates = [\"radroots_event_codec\", \"radroots_log\"]\n", ) .expect("write coverage policy"); let required = [ @@ -12129,6 +12717,8 @@ readme = { workspace = true } release: ReleaseSection { version: "1.0.0".to_string(), }, + publication: None, + workspace_classification: None, classification: ReleaseClassification { public: vec!["radroots_public".to_string()], internal: vec!["radroots_internal".to_string()], @@ -12136,6 +12726,8 @@ readme = { workspace = true } retired: vec!["radroots_retired".to_string()], yank_only: vec!["radroots_yank_only".to_string()], }, + publish: None, + internal: None, publish_order: ReleaseCrateSet { crates: vec!["radroots_public".to_string()], }, @@ -12240,10 +12832,9 @@ members = ["crates/a", "crates/b"] &coverage_dir.join("coverage-refresh.tsv"), "crate\tstatus\texec\tfunc\tbranch\tregion\treport\nradroots_a\tpass\t89.9\t90\t90\t90\tfile\n", ); - let below_required = - validate_required_coverage_summary(&root, &required, required_thresholds()) - .expect_err("coverage below required threshold"); - assert!(below_required.contains("must satisfy coverage policy")); + let below_90 = validate_required_coverage_summary(&root, &required, required_thresholds()) + .expect_err("coverage below 90"); + assert!(below_90.contains("must satisfy coverage policy")); let missing = ["missing".to_string()].into_iter().collect::<BTreeSet<_>>(); let missing_err = @@ -12256,22 +12847,17 @@ members = ["crates/a", "crates/b"] #[test] fn enum_extract_and_parse_error_paths_are_reported() { - let missing = extract_enum_body("pub struct X;", "RadrootsCoreUnitDimension") - .expect_err("missing enum"); + let missing = + extract_enum_body("pub struct X;", "UnitDimension").expect_err("missing enum"); assert!(missing.contains("missing enum")); - let missing_brace = extract_enum_body( - "pub enum RadrootsCoreUnitDimension", - "RadrootsCoreUnitDimension", - ) - .expect_err("missing opening brace"); + let missing_brace = extract_enum_body("pub enum UnitDimension", "UnitDimension") + .expect_err("missing opening brace"); assert!(missing_brace.contains("missing opening brace")); - let missing_close = extract_enum_body( - "pub enum RadrootsCoreUnitDimension { Count, Mass", - "RadrootsCoreUnitDimension", - ) - .expect_err("missing closing brace"); + let missing_close = + extract_enum_body("pub enum UnitDimension { Count, Mass", "UnitDimension") + .expect_err("missing closing brace"); assert!(missing_close.contains("missing closing brace")); let variants = parse_enum_variants( @@ -12286,8 +12872,8 @@ members = ["crates/a", "crates/b"] assert_eq!(variants, vec!["Count".to_string()]); let nested = extract_enum_body( - "pub enum RadrootsCoreUnitDimension { Count = { 1 }, Mass = 2 }", - "RadrootsCoreUnitDimension", + "pub enum UnitDimension { Count = { 1 }, Mass = 2 }", + "UnitDimension", ) .expect("nested braces in enum body"); assert!(nested.contains("Count")); @@ -12642,6 +13228,200 @@ edition = "2024" } #[test] + fn publication_freeze_requires_every_workspace_package_to_be_private() { + let root = create_synthetic_workspace("publication_freeze"); + let contract_root = root.join("contracts"); + let release_policy_path = root_release_policy_path(&root); + write_file( + &root.join("crates").join("a").join("Cargo.toml"), + r#"[package] +name = "radroots_a" +version = "1.0.0" +edition = "2024" +publish = false +"#, + ); + write_file( + &release_policy_path, + r#"[schema] +version = 1 + +[release] +version = "1.0.0" + +[publication] +frozen = true +registry = "crates-io" +final_enablement_step = 305 + +[publish] +crates = ["radroots_a"] + +[internal] +crates = ["radroots_b"] + +[publish_order] +crates = ["radroots_a"] +"#, + ); + validate_release_publish_policy_with_override( + &root, + &contract_root, + "1.0.0", + Some(release_policy_path.clone()), + ) + .expect("fully private workspace should satisfy publication freeze"); + + write_file( + &root.join("crates").join("a").join("Cargo.toml"), + r#"[package] +name = "radroots_a" +version = "1.0.0" +edition = "2024" +publish = ["crates-io"] +"#, + ); + let publishable = validate_release_publish_policy_with_override( + &root, + &contract_root, + "1.0.0", + Some(release_policy_path.clone()), + ) + .expect_err("publication freeze must reject a publishable package"); + assert!(publishable.contains("publication freeze requires workspace crate radroots_a")); + + write_file( + &release_policy_path, + r#"[schema] +version = 1 + +[release] +version = "1.0.0" + +[publish] +crates = ["radroots_a"] + +[internal] +crates = ["radroots_b"] + +[publish_order] +crates = ["radroots_a"] +"#, + ); + let missing_control = validate_release_publish_policy_with_override( + &root, + &contract_root, + "1.0.0", + Some(release_policy_path), + ) + .expect_err("release policy must carry explicit publication control"); + assert!(missing_control.contains("publication control is required")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn v1_release_policy_covers_approved_unapproved_unclassified_and_private_fixtures() { + let root = create_synthetic_workspace("v1_release_policy"); + let contract_root = root.join("contracts"); + let release_policy_path = root_release_policy_path(&root); + for member in ["a", "b"] { + write_file( + &root.join("crates").join(member).join("Cargo.toml"), + &format!( + "[package]\nname = \"radroots_{member}\"\nversion = \"1.0.0\"\nedition = \"2024\"\npublish = false\n" + ), + ); + } + + let approved = (1..=19) + .map(|index| format!("package-{index:02}")) + .collect::<Vec<_>>(); + let approved_toml = approved + .iter() + .map(|name| format!("\"{name}\"")) + .collect::<Vec<_>>() + .join(", "); + let external_toml = approved[1..] + .iter() + .map(|name| format!("\"{name}\"")) + .collect::<Vec<_>>() + .join(", "); + let mut architecture = format!( + "spec_id = \"radroots.crates.release.v1\"\npackage_count = 19\n\n[repositories.lib]\nversion = \"0.1.0-alpha\"\npackages = [\"package-01\"]\n\n[repositories.sdk]\nversion = \"0.1.0\"\npackages = [{external_toml}]\n" + ); + for name in &approved { + architecture.push_str(&format!("\n[[package]]\nname = \"{name}\"\n")); + } + write_file( + &root.join("docs/specs/radroots_crates_release_v1.toml"), + &architecture, + ); + + let policy = |approved_packages: &str, test_support: &str| { + format!( + r#"[schema] +version = 1 + +[release] +version = "1.0.0" + +[publication] +frozen = true +registry = "crates-io" +final_enablement_step = 305 +spec_id = "radroots.crates.release.v1" +approved_packages = [{approved_packages}] +local_packages = ["package-01"] +external_packages = [{external_toml}] + +[workspace_classification] +private = ["radroots_a"] +build_codegen = [] +test_support = [{test_support}] +preview = [] +retired = [] + +[publish_order] +crates = [] +"# + ) + }; + + write_file( + &release_policy_path, + &policy(&approved_toml, "\"radroots_b\""), + ); + validate_release_publish_policy(&root, &contract_root, "1.0.0") + .expect("approved and exhaustively classified fixture must pass"); + + let unapproved = format!("{approved_toml}, \"unapproved-public\""); + write_file(&release_policy_path, &policy(&unapproved, "\"radroots_b\"")); + let unapproved_error = validate_release_publish_policy(&root, &contract_root, "1.0.0") + .expect_err("unapproved public package must fail"); + assert!(unapproved_error.contains("unapproved packages: unapproved-public")); + + write_file(&release_policy_path, &policy(&approved_toml, "")); + let unclassified = validate_release_publish_policy(&root, &contract_root, "1.0.0") + .expect_err("unclassified workspace package must fail"); + assert!(unclassified.contains("workspace classification is missing packages: radroots_b")); + + write_file( + &release_policy_path, + &policy(&approved_toml, "\"radroots_b\""), + ); + write_file( + &root.join("crates/a/Cargo.toml"), + "[package]\nname = \"radroots_a\"\nversion = \"1.0.0\"\nedition = \"2024\"\npublish = [\"crates-io\"]\n", + ); + let private = validate_release_publish_policy(&root, &contract_root, "1.0.0") + .expect_err("private package must remain non-publishable"); + assert!(private.contains("publication freeze requires workspace crate radroots_a")); + + let _ = fs::remove_dir_all(root); + } + + #[test] fn release_preflight_rejects_public_dto_tooling_git_or_path_sources() { let root = create_synthetic_workspace("release_policy_dto_tooling_sources"); @@ -13078,13 +13858,14 @@ rust_package = "radroots_sdk" let invalid_vector_root = create_synthetic_workspace("operation_contract_invalid_vector"); add_operation_contract_files(&invalid_vector_root); + let invalid_vector_path = invalid_vector_root + .join("contracts") + .join("conformance") + .join("vectors") + .join("profile") + .join("metadata.v1.json"); write_file( - &invalid_vector_root - .join("contracts") - .join("conformance") - .join("vectors") - .join("profile") - .join("metadata.v1.json"), + &invalid_vector_path, r#"{ "suite": "profile", "contract_version": "1.0.0", @@ -13102,7 +13883,48 @@ rust_package = "radroots_sdk" let err = validate_generic_contract_bundle(&bundle).expect_err("invalid vector should fail"); assert!(err.contains("metadata.v1.json")); - assert!(err.contains("parse")); + assert!(err.contains("exactly one of expected or expected_error_contains")); + + write_file( + &invalid_vector_path, + r#"{ + "suite": "profile", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "profile_build_authored_draft_minimal_001", + "kind": "profile.build_authored_draft", + "input": {}, + "expected": {}, + "expected_error_contains": "invalid" + } + ] +} +"#, + ); + let err = validate_generic_contract_bundle(&bundle) + .expect_err("vector with two result authorities should fail"); + assert!(err.contains("exactly one of expected or expected_error_contains")); + + write_file( + &invalid_vector_path, + r#"{ + "suite": "profile", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "profile_build_authored_draft_minimal_001", + "kind": "profile.build_authored_draft", + "input": {}, + "expected_error_contains": " " + } + ] +} +"#, + ); + let err = validate_generic_contract_bundle(&bundle) + .expect_err("blank expected error fragment should fail"); + assert!(err.contains("expected_error_contains must not be blank")); let _ = fs::remove_dir_all(&invalid_vector_root); let root = create_synthetic_workspace("operation_contract_vector_path"); @@ -13709,7 +14531,7 @@ requires_release_notes = true let bundle = load_contract_bundle(&root).expect("load bundle"); write_file( &root.join("crates").join("core").join("src").join("unit.rs"), - r#"pub enum RadrootsCoreUnitDimension { + r#"pub enum UnitDimension { Mass, Count, Volume, @@ -13721,7 +14543,7 @@ Volume, write_file( &root.join("crates").join("core").join("src").join("unit.rs"), - r#"pub enum RadrootsCoreUnitDimension { + r#"pub enum UnitDimension { Count, Mass, Volume, @@ -13872,7 +14694,7 @@ readme = { workspace = true } .join("core") .join("src") .join("unit.rs"), - r#"pub enum RadrootsCoreUnitDimension { + r#"pub enum UnitDimension { Mass, Count, Volume, diff --git a/tools/xtask/src/contract/admission_authority.rs b/tools/xtask/src/contract/admission_authority.rs @@ -374,7 +374,7 @@ fn validate_vector( )); } let expected = exact_object( - &vector.expected, + vector.expected_value()?, &["contract_id", "event_id", "variant"], &format!("{}.expected", vector.id), )?; @@ -393,7 +393,7 @@ fn validate_vector( )); } let expected = exact_object( - &vector.expected, + vector.expected_value()?, &["error_code", "error_variant", "event_id"], &format!("{}.expected", vector.id), )?; diff --git a/tools/xtask/src/contract/blossom_publication_readiness.rs b/tools/xtask/src/contract/blossom_publication_readiness.rs @@ -57,7 +57,7 @@ const READINESS_POLICY_VERSION: u16 = 1; const EVIDENCE_MAX_BYTES: usize = 8 * 1024; const URL_MAX_BYTES: usize = 4 * 1024; const RASTER_MAX_BYTES: u64 = 10_485_760; -const RASTER_MAX_DECODED_BYTES: u64 = 160_000_000; +const RASTER_MAX_DECODED_BYTES: u64 = 80_000_000; const RASTER_MAX_DIMENSION: u32 = 16_384; const RASTER_MAX_PIXELS: u64 = 20_000_000; @@ -72,58 +72,58 @@ const PUBLIC_CONSTANTS: &[&str] = &[ "RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES", ]; const PUBLIC_TYPES: &[&str] = &[ - "RadrootsBlossomAuthoredRasterDimensions", - "RadrootsBlossomBud01GetCollector", - "RadrootsBlossomBud01GetObservation", - "RadrootsBlossomBud01HeadObservation", - "RadrootsBlossomBud02UploadObservation", - "RadrootsBlossomBud02UploadStatus", - "RadrootsBlossomPublicationReadinessEvidence", - "RadrootsBlossomPublicationReadinessEvidenceDigest", - "RadrootsBlossomRasterDimensions", - "RadrootsBlossomRasterFormat", + "AuthoredRasterDimensions", + "Bud01GetCollector", + "Bud01GetObservation", + "Bud01HeadObservation", + "Bud02UploadObservation", + "Bud02UploadStatus", + "PublicationReadinessEvidence", + "PublicationReadinessEvidenceDigest", + "RasterDimensions", + "RasterFormat", ]; const PUBLIC_FUNCTIONS: &[&str] = &["verify_publication_readiness"]; const PUBLIC_METHODS: &[&str] = &[ - "RadrootsBlossomBud01GetCollector::finish", - "RadrootsBlossomBud01GetCollector::new", - "RadrootsBlossomBud01GetCollector::push_chunk", - "RadrootsBlossomBud01GetObservation::bytes", - "RadrootsBlossomBud01GetObservation::declared_size", - "RadrootsBlossomBud01GetObservation::from_complete_body", - "RadrootsBlossomBud01GetObservation::url", - "RadrootsBlossomBud01HeadObservation::content_length", - "RadrootsBlossomBud01HeadObservation::media_type", - "RadrootsBlossomBud01HeadObservation::new", - "RadrootsBlossomBud01HeadObservation::url", - "RadrootsBlossomBud02UploadObservation::descriptor", - "RadrootsBlossomBud02UploadObservation::new", - "RadrootsBlossomBud02UploadObservation::status", - "RadrootsBlossomBud02UploadStatus::as_u16", - "RadrootsBlossomPublicationReadinessEvidence::bud02_status", - "RadrootsBlossomPublicationReadinessEvidence::dimensions", - "RadrootsBlossomPublicationReadinessEvidence::evidence_digest", - "RadrootsBlossomPublicationReadinessEvidence::from_canonical_json", - "RadrootsBlossomPublicationReadinessEvidence::media_type", - "RadrootsBlossomPublicationReadinessEvidence::policy_version", - "RadrootsBlossomPublicationReadinessEvidence::raster_format", - "RadrootsBlossomPublicationReadinessEvidence::schema_version", - "RadrootsBlossomPublicationReadinessEvidence::sha256", - "RadrootsBlossomPublicationReadinessEvidence::size", - "RadrootsBlossomPublicationReadinessEvidence::to_canonical_json", - "RadrootsBlossomPublicationReadinessEvidence::uploaded", - "RadrootsBlossomPublicationReadinessEvidence::url", - "RadrootsBlossomPublicationReadinessEvidenceDigest::as_sha256", - "RadrootsBlossomRasterDimensions::height", - "RadrootsBlossomRasterDimensions::new", - "RadrootsBlossomRasterDimensions::pixels", - "RadrootsBlossomRasterDimensions::width", - "RadrootsBlossomRasterFormat::as_str", - "RadrootsBlossomRasterFormat::from_media_type", + "Bud01GetCollector::finish", + "Bud01GetCollector::new", + "Bud01GetCollector::push_chunk", + "Bud01GetObservation::bytes", + "Bud01GetObservation::declared_size", + "Bud01GetObservation::from_complete_body", + "Bud01GetObservation::url", + "Bud01HeadObservation::content_length", + "Bud01HeadObservation::media_type", + "Bud01HeadObservation::new", + "Bud01HeadObservation::url", + "Bud02UploadObservation::descriptor", + "Bud02UploadObservation::new", + "Bud02UploadObservation::status", + "Bud02UploadStatus::as_u16", + "PublicationReadinessEvidence::bud02_status", + "PublicationReadinessEvidence::dimensions", + "PublicationReadinessEvidence::evidence_digest", + "PublicationReadinessEvidence::from_canonical_json", + "PublicationReadinessEvidence::media_type", + "PublicationReadinessEvidence::policy_version", + "PublicationReadinessEvidence::raster_format", + "PublicationReadinessEvidence::schema_version", + "PublicationReadinessEvidence::sha256", + "PublicationReadinessEvidence::size", + "PublicationReadinessEvidence::to_canonical_json", + "PublicationReadinessEvidence::uploaded", + "PublicationReadinessEvidence::url", + "PublicationReadinessEvidenceDigest::as_sha256", + "RasterDimensions::height", + "RasterDimensions::new", + "RasterDimensions::pixels", + "RasterDimensions::width", + "RasterFormat::as_str", + "RasterFormat::from_media_type", ]; const SEALED_TYPES: &[&str] = &[ - "RadrootsBlossomPublicationReadinessEvidence", - "RadrootsBlossomPublicationReadinessEvidenceDigest", + "PublicationReadinessEvidence", + "PublicationReadinessEvidenceDigest", ]; const PRIVATE_WIRE_TYPES: &[&str] = &[ "PublicationReadinessDimensionsWire", @@ -627,14 +627,14 @@ fn describe_manifest( readiness: ReadinessDescriptor { verify_operation_id: VERIFY_OPERATION_ID.to_owned(), input_types: owned(&[ - "RadrootsBlossomByteVerifiedDescriptor", + "ByteVerifiedDescriptor", "Bytes", - "RadrootsBlossomAuthoredRasterDimensions", - "RadrootsBlossomBud02UploadObservation", - "RadrootsBlossomBud01HeadObservation", - "RadrootsBlossomBud01GetObservation", + "AuthoredRasterDimensions", + "Bud02UploadObservation", + "Bud01HeadObservation", + "Bud01GetObservation", ]), - output_type: "RadrootsBlossomPublicationReadinessEvidence".to_owned(), + output_type: "PublicationReadinessEvidence".to_owned(), evidence: EvidenceDescriptor { schema_version: EVIDENCE_SCHEMA_VERSION, policy_version: READINESS_POLICY_VERSION, @@ -1120,14 +1120,14 @@ fn validate_manifest_shape(manifest: &PublicationReadinessManifest) -> Result<() || manifest.readiness.verify_operation_id != VERIFY_OPERATION_ID || manifest.readiness.input_types != owned(&[ - "RadrootsBlossomByteVerifiedDescriptor", + "ByteVerifiedDescriptor", "Bytes", - "RadrootsBlossomAuthoredRasterDimensions", - "RadrootsBlossomBud02UploadObservation", - "RadrootsBlossomBud01HeadObservation", - "RadrootsBlossomBud01GetObservation", + "AuthoredRasterDimensions", + "Bud02UploadObservation", + "Bud01HeadObservation", + "Bud01GetObservation", ]) - || manifest.readiness.output_type != "RadrootsBlossomPublicationReadinessEvidence" + || manifest.readiness.output_type != "PublicationReadinessEvidence" || manifest.readiness.evidence.schema_version != EVIDENCE_SCHEMA_VERSION || manifest.readiness.evidence.policy_version != READINESS_POLICY_VERSION || manifest.readiness.evidence.max_canonical_json_bytes != EVIDENCE_MAX_BYTES as u64 @@ -1256,7 +1256,7 @@ fn manifest_schema() -> Value { "properties": { "verify_operation_id": {"const": VERIFY_OPERATION_ID}, "input_types": {"type": "array", "minItems": 6, "maxItems": 6, "items": {"type": "string", "minLength": 1}}, - "output_type": {"const": "RadrootsBlossomPublicationReadinessEvidence"}, + "output_type": {"const": "PublicationReadinessEvidence"}, "evidence": {"$ref": "#/$defs/evidence"}, "behavior_vector": {"$ref": "#/$defs/vector"}, "persistence_vector": {"$ref": "#/$defs/vector"} @@ -1462,8 +1462,8 @@ mod tests { validate_public_api_source(&format!("// unrelated comment\n{source}")).unwrap(); let exposed = source.replacen( - " url: RadrootsBlossomApprovedBlobUrl,", - " pub url: RadrootsBlossomApprovedBlobUrl,", + " url: ApprovedBlobUrl,", + " pub url: ApprovedBlobUrl,", 1, ); assert!( @@ -1473,8 +1473,8 @@ mod tests { ); let deserializable = source.replacen( - "#[derive(Clone, Debug, PartialEq, Eq)]\npub struct RadrootsBlossomPublicationReadinessEvidence", - "#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]\npub struct RadrootsBlossomPublicationReadinessEvidence", + "#[derive(Clone, Debug, PartialEq, Eq)]\npub struct PublicationReadinessEvidence", + "#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]\npub struct PublicationReadinessEvidence", 1, ); assert!( diff --git a/tools/xtask/src/contract/blossom_raster_decoder_security.rs b/tools/xtask/src/contract/blossom_raster_decoder_security.rs @@ -1,9 +1,10 @@ use super::{ artifact_bundle::{GeneratedArtifact, read_regular_file, with_artifact_bundle_transaction}, phase1_publication_media_readiness::{ - BLOSSOM_PREDECESSOR_ARTIFACTS, FileDescriptor, PredecessorDescriptor, ProtocolSourcePin, - descriptor_for_bytes, descriptor_for_file, predecessor_descriptor, - validate_immutable_predecessor, validate_source_supersessions, + BLOSSOM_PREDECESSOR_ARTIFACTS, BLOSSOM_PREDECESSOR_SUPERSEDED_ARTIFACTS, FileDescriptor, + PredecessorDescriptor, ProtocolSourcePin, descriptor_for_bytes, descriptor_for_file, + predecessor_descriptor, validate_immutable_predecessor_with_superseded_artifacts, + validate_source_supersessions, }, }; use serde::{Deserialize, Serialize}; @@ -20,6 +21,7 @@ const SCHEMA_VERSION: u32 = 1; const CONTRACT_ID: &str = "radroots_blossom.raster_decoder_security_v1"; const AUTHORITY_ID: &str = "blossom_raster_decoder_security_v1"; const HASH_ALGORITHM: &str = "sha256_bytes_v1"; +const LIB_CRATE_VERSION: &str = "0.1.0-alpha"; const WRITE_COMMAND: &str = "cargo xtask contract blossom-raster-decoder-security-manifest --write"; const MANIFEST_RELATIVE: &str = "crates/blossom/contracts/raster_decoder_security_v1.manifest.json"; @@ -471,22 +473,22 @@ const RELEASE_SEMVER_IMPACTS: &[&str] = &[ "change_exported_constant_value", ]; const OPERATION_INPUTS: &[&str] = &[ - "RadrootsBlossomByteVerifiedDescriptor", + "ByteVerifiedDescriptor", "Bytes", - "RadrootsBlossomAuthoredRasterDimensions", - "RadrootsBlossomBud02UploadObservation", - "RadrootsBlossomBud01HeadObservation", - "RadrootsBlossomBud01GetObservation", + "AuthoredRasterDimensions", + "Bud02UploadObservation", + "Bud01HeadObservation", + "Bud01GetObservation", ]; -const OPERATION_OUTPUTS: &[&str] = &["RadrootsBlossomPublicationReadinessEvidence"]; +const OPERATION_OUTPUTS: &[&str] = &["PublicationReadinessEvidence"]; const OPERATION_MODULES: &[&str] = &[READINESS_SOURCE_RELATIVE]; const OPERATION_RUST_TYPES: &[&str] = &[ - "radroots_blossom::RadrootsBlossomAuthoredRasterDimensions", - "radroots_blossom::RadrootsBlossomBud01GetCollector", - "radroots_blossom::RadrootsBlossomBud01GetObservation", - "radroots_blossom::RadrootsBlossomBud01HeadObservation", - "radroots_blossom::RadrootsBlossomBud02UploadObservation", - "radroots_blossom::RadrootsBlossomPublicationReadinessEvidence", + "radroots_blossom::AuthoredRasterDimensions", + "radroots_blossom::Bud01GetCollector", + "radroots_blossom::Bud01GetObservation", + "radroots_blossom::Bud01HeadObservation", + "radroots_blossom::Bud02UploadObservation", + "radroots_blossom::PublicationReadinessEvidence", ]; const SOURCE_SUPERSESSIONS: &[&str] = &[ "CHANGELOG.md", @@ -495,6 +497,8 @@ const SOURCE_SUPERSESSIONS: &[&str] = &[ "crates/blossom/src/error.rs", "crates/blossom/src/publication_readiness.rs", "crates/blossom/src/publication_readiness/sequential_jpeg.rs", + "crates/blossom/tests/publication_readiness.rs", + "crates/blossom/tests/publication_readiness_persistence.rs", "tools/xtask/src/contract.rs", "tools/xtask/src/main.rs", ]; @@ -699,10 +703,11 @@ pub(crate) fn validate_blossom_raster_decoder_security_manifest( fn validate_predecessors(workspace_root: &Path) -> Result<(), String> { validate_source_supersessions(workspace_root, SOURCE_SUPERSESSIONS)?; - validate_immutable_predecessor( + validate_immutable_predecessor_with_superseded_artifacts( workspace_root, "Blossom publication readiness", BLOSSOM_PREDECESSOR_ARTIFACTS, + BLOSSOM_PREDECESSOR_SUPERSEDED_ARTIFACTS, ) } @@ -797,10 +802,12 @@ fn describe_manifest( authority_id: AUTHORITY_ID.to_owned(), manifest_schema: descriptor_for_bytes(MANIFEST_SCHEMA_RELATIVE, manifest_schema_bytes), predecessors: vec![predecessor_descriptor( + workspace_root, "radroots_blossom.publication_readiness_v1", BLOSSOM_PREDECESSOR_ARTIFACTS, + BLOSSOM_PREDECESSOR_SUPERSEDED_ARTIFACTS, SOURCE_SUPERSESSIONS, - )], + )?], protocol_sources: vec![ ProtocolSourcePin { id: "nostr_nips".to_owned(), @@ -1430,7 +1437,7 @@ fn validate_error_authority(workspace_root: &Path) -> Result<(), String> { let mut variants = BTreeSet::new(); for item in &file.items { if let Item::Enum(item) = item - && item.ident == "RadrootsBlossomError" + && item.ident == "Error" && is_public(&item.vis) { for variant in &item.variants { @@ -1684,6 +1691,15 @@ fn insert_fuzz_bin_name(bin_names: &mut BTreeSet<String>, name: &str) -> Result< Ok(()) } +fn validate_fuzz_crate_version(label: &str, package: &toml::Table) -> Result<(), String> { + if package.get("version").and_then(toml::Value::as_str) != Some(LIB_CRATE_VERSION) { + return Err(format!( + "{label} version must remain exactly {LIB_CRATE_VERSION}" + )); + } + Ok(()) +} + fn validate_fuzz_authority(workspace_root: &Path, validate_corpus: bool) -> Result<(), String> { let workspace = parse_toml(workspace_root, WORKSPACE_MANIFEST_RELATIVE)?; let excludes = toml_string_array( @@ -1706,6 +1722,7 @@ fn validate_fuzz_authority(workspace_root: &Path, validate_corpus: bool) -> Resu .get("package") .and_then(toml::Value::as_table) .ok_or_else(|| "fuzz package table is missing".to_owned())?; + validate_fuzz_crate_version("fuzz package", package)?; if package.get("name").and_then(toml::Value::as_str) != Some("radroots-fuzz") || package.get("publish").and_then(toml::Value::as_bool) != Some(false) || package @@ -1816,7 +1833,10 @@ fn validate_fuzz_authority(workspace_root: &Path, validate_corpus: bool) -> Resu let fuzz_lock_package = packages .iter() .find(|package| package.get("name").and_then(toml::Value::as_str) == Some("radroots-fuzz")) - .ok_or_else(|| "fuzz lockfile is missing package radroots-fuzz".to_owned())?; + .ok_or_else(|| "fuzz lockfile is missing package radroots-fuzz".to_owned())? + .as_table() + .ok_or_else(|| "fuzz lockfile radroots-fuzz package must be a table".to_owned())?; + validate_fuzz_crate_version("fuzz lockfile radroots-fuzz package", fuzz_lock_package)?; let fuzz_lock_dependencies = toml_string_array( "fuzz lockfile radroots-fuzz dependencies", fuzz_lock_package.get("dependencies"), @@ -2146,7 +2166,7 @@ fn manifest_schema() -> Value { "predecessor": { "type": "object", "additionalProperties": false, - "required": ["contract_id", "immutable_artifacts", "source_supersessions"], + "required": ["contract_id", "immutable_artifacts", "artifact_supersessions", "source_supersessions"], "properties": { "contract_id": {"const": "radroots_blossom.publication_readiness_v1"}, "immutable_artifacts": { @@ -2154,6 +2174,11 @@ fn manifest_schema() -> Value { "minItems": 1, "items": {"$ref": "#/$defs/file"} }, + "artifact_supersessions": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/file"} + }, "source_supersessions": { "type": "array", "minItems": 1, @@ -2581,6 +2606,26 @@ mod tests { } #[test] + fn fuzz_crate_version_authority_rejects_drift() { + let root = workspace_root(); + let manifest = parse_toml(&root, FUZZ_MANIFEST_RELATIVE).expect("fuzz manifest"); + let package = manifest + .get("package") + .and_then(toml::Value::as_table) + .expect("fuzz package table"); + validate_fuzz_crate_version("fuzz package", package).expect("frozen fuzz version"); + + let mut drifted = package.clone(); + drifted.insert( + "version".to_owned(), + toml::Value::String("0.0.0".to_owned()), + ); + let error = validate_fuzz_crate_version("fuzz package", &drifted) + .expect_err("fuzz version drift must fail closed"); + assert!(error.contains(LIB_CRATE_VERSION), "{error}"); + } + + #[test] fn fuzz_call_chain_rejects_structural_drift() { validate_fuzz_common_source(COMMON_SOURCE).expect("governed common fuzz harness"); validate_fuzz_target_source( diff --git a/tools/xtask/src/contract/food_availability_projection.rs b/tools/xtask/src/contract/food_availability_projection.rs @@ -820,8 +820,37 @@ pub(crate) fn validate_food_availability_projection_manifest( pub(super) fn validate_food_availability_projection_manifest_under_lock( workspace_root: &Path, ) -> Result<(), String> { + validate_food_availability_projection_manifest_with_superseded_artifacts_under_lock( + workspace_root, + &[], + ) +} + +fn validate_food_availability_projection_manifest_with_superseded_artifacts_under_lock( + workspace_root: &Path, + superseded_artifact_paths: &[&str], +) -> Result<(), String> { validate_nip09_reconciliation_manifest_under_lock(workspace_root)?; + let superseded_artifacts = superseded_artifact_paths + .iter() + .copied() + .collect::<BTreeSet<_>>(); + if superseded_artifacts.len() != superseded_artifact_paths.len() { + return Err( + "downstream FoodAvailability artifact supersession paths must be unique".to_owned(), + ); + } + if let Some(path) = superseded_artifacts.iter().find(|path| { + !IMMUTABLE_PREDECESSOR_ARTIFACTS + .iter() + .any(|artifact| artifact.relative == **path) + }) { + return Err(format!( + "downstream FoodAvailability artifact supersession path `{path}` is not an immutable predecessor artifact" + )); + } + let manifest_bytes = read_regular_file(workspace_root, MANIFEST_RELATIVE)?; let manifest_value: Value = serde_json::from_slice(&manifest_bytes) .map_err(|error| format!("parse {MANIFEST_RELATIVE}: {error}"))?; @@ -868,6 +897,9 @@ pub(super) fn validate_food_availability_projection_manifest_under_lock( validate_result_vector(&vector)?; for artifact in IMMUTABLE_PREDECESSOR_ARTIFACTS { + if superseded_artifacts.contains(artifact.relative) { + continue; + } let actual = read_regular_file(workspace_root, artifact.relative)?; if actual.len() != artifact.byte_length || sha256_hex(&actual) != artifact.sha256 { return Err(format!( @@ -1061,8 +1093,12 @@ fn validate_predecessor_production_source_coverage( pub(super) fn validate_food_availability_projection_predecessor_production_sources_under_lock( workspace_root: &Path, superseded_paths: &[&str], + superseded_artifact_paths: &[&str], ) -> Result<(), String> { - validate_food_availability_projection_manifest_under_lock(workspace_root)?; + validate_food_availability_projection_manifest_with_superseded_artifacts_under_lock( + workspace_root, + superseded_artifact_paths, + )?; let manifest_bytes = read_regular_file(workspace_root, MANIFEST_RELATIVE)?; let manifest: FoodAvailabilityProjectionManifest = serde_json::from_slice(&manifest_bytes) .map_err(|error| format!("parse {MANIFEST_RELATIVE}: {error}"))?; @@ -1775,7 +1811,7 @@ fn validate_food_projection_audit_authority(source: &str) -> Result<(), String> validate_projection_source_transition(connection, &projection).await?; validate_fts_row(connection, &projection).await?; actual_coordinates.push(( - projection.pubkey().as_str().to_owned(), + projection.pubkey().to_hex(), projection.identifier().as_str().to_owned(), projection.event_id().as_str().to_owned(), projection.event_seq(), @@ -1869,7 +1905,7 @@ fn validate_food_projection_audit_authority(source: &str) -> Result<(), String> validate_projection_source_transition(connection, &projection).await?; validate_fts_row(connection, &projection).await?; actual_coordinates.push(( - projection.pubkey().as_str().to_owned(), + projection.pubkey().to_hex(), projection.identifier().as_str().to_owned(), projection.event_id().as_str().to_owned(), projection.event_seq(), @@ -1976,7 +2012,7 @@ fn validate_food_projection_audit_authority(source: &str) -> Result<(), String> let authoritative: i64 = sqlx::query_scalar({SOURCE_TRANSITION_QUERY:?},) .bind(projection.source_transition_seq()) .bind(projection.source_generation().as_bytes().as_slice()) - .bind(projection.pubkey().as_str()) + .bind(projection.pubkey().to_hex()) .bind(projection.identifier().as_str()) .bind(projection.event_id().as_str()) .bind(projection.event_seq()) @@ -2253,14 +2289,14 @@ fn validate_source_contract(workspace_root: &Path) -> Result<(), String> { require_source_markers( workspace_root, "crates/blossom/src/lib.rs", - &["pubusehash::{", "RadrootsBlossomSha256"], + &["pubusehash::{", "Sha256"], )?; require_source_markers( workspace_root, "crates/blossom/src/hash.rs", &[ - "pubstructRadrootsBlossomSha256([u8;SHA256_BYTES])", - "pubfnfrom_hex(value:&str)->Result<Self,RadrootsBlossomError>", + "pubstructSha256([u8;SHA256_BYTES])", + "pubfnfrom_hex(value:&str)->Result<Self,Error>", "pubconstfnas_bytes(&self)->&[u8;SHA256_BYTES]", "pubfnto_hex(self)->String", ], @@ -2314,10 +2350,7 @@ fn validate_source_contract(workspace_root: &Path) -> Result<(), String> { require_source_markers( workspace_root, "crates/event_store/src/model/food_availability_projection_v1.rs", - &[ - "RadrootsBlossomSha256", - "pubconstfnblossom_sha256(&self)->Option<RadrootsBlossomSha256>", - ], + &["Sha256", "pubconstfnblossom_sha256(&self)->Option<Sha256>"], )?; require_source_markers( workspace_root, @@ -2638,9 +2671,13 @@ fn validate_blossom_dependency_values( .and_then(toml::Value::as_table) .ok_or_else(|| "Cargo.toml must define workspace dependency radroots_blossom".to_owned())?; if workspace_dependency - .get("path") + .get("package") .and_then(toml::Value::as_str) - != Some("crates/blossom") + != Some("radroots_blossom") + || workspace_dependency + .get("path") + .and_then(toml::Value::as_str) + != Some("crates/blossom") || workspace_dependency .get("version") .and_then(toml::Value::as_str) @@ -3863,13 +3900,47 @@ mod tests { ) { copy_file(&repository, workspace.path(), relative); } + let executor_path = workspace.path().join(RESULT_VECTOR_EXECUTOR_RELATIVE); + let successor_executor = + fs::read_to_string(&executor_path).expect("read successor FoodAvailability executor"); + let predecessor_executor = successor_executor + .replace( + "use radroots_blossom::Sha256;", + "use radroots_blossom::RadrootsBlossomSha256;", + ) + .replace( + "use radroots_event::food_availability::RadrootsFoodIdentifier;", + "use radroots_event::{food_availability::RadrootsFoodIdentifier, ids::RadrootsPublicKey};", + ) + .replace("use radroots_identity::PublicKey;\n", "") + .replace( + "use sha2::{Digest, Sha256 as Sha256Hasher};", + "use sha2::{Digest, Sha256};", + ) + .replace("Sha256Hasher::digest(bytes)", "Sha256::digest(bytes)") + .replace("PublicKey::from_hex", "RadrootsPublicKey::parse") + .replace(".pubkey().to_hex()", ".pubkey().as_str()") + .replace( + "Sha256::from_hex(value)", + "RadrootsBlossomSha256::from_hex(value)", + ); + let predecessor_identity = IMMUTABLE_PREDECESSOR_ARTIFACTS[6]; + assert_eq!(predecessor_executor.len(), predecessor_identity.byte_length); + assert_eq!( + sha256_hex(predecessor_executor.as_bytes()), + predecessor_identity.sha256 + ); + fs::write(executor_path, predecessor_executor).expect("restore predecessor executor"); workspace } #[test] - fn immutable_food_predecessor_artifacts_match_authenticated_identities() { + fn unsuperseded_food_predecessor_artifacts_match_authenticated_identities() { let root = repository_root(); for artifact in IMMUTABLE_PREDECESSOR_ARTIFACTS { + if artifact.relative == RESULT_VECTOR_EXECUTOR_RELATIVE { + continue; + } let bytes = read_regular_file(&root, artifact.relative).expect("immutable artifact"); assert_eq!(bytes.len(), artifact.byte_length, "{}", artifact.relative); assert_eq!(sha256_hex(&bytes), artifact.sha256, "{}", artifact.relative); @@ -3992,31 +4063,8 @@ mod tests { #[test] fn downstream_nip09_only_supersession_is_transitively_validated() { - const CURRENT_SUCCESSOR_SUPERSEDED_PATHS: &[&str] = &[ - "Cargo.toml", - "crates/blossom/Cargo.toml", - "crates/blossom/src/error.rs", - "crates/blossom/src/lib.rs", - "crates/blossom/src/url.rs", - "crates/event_store/Cargo.toml", - "crates/event_store/src/error.rs", - "crates/event_store/src/generated.rs", - "crates/event_store/src/lib.rs", - "crates/event_store/src/migrations.rs", - "crates/event_store/src/model.rs", - "crates/event_store/src/model/addressable_transition_feed_v1.rs", - "crates/event_store/src/model/current_visibility_v1.rs", - "crates/event_store/src/model/food_availability_projection_v1.rs", - "crates/event_store/src/nip09/reconciliation_v1.rs", - "crates/event_store/src/schema.rs", - "crates/event_store/src/store.rs", - "crates/event_store/src/store/addressable_transition_feed_v1.rs", - "crates/event_store/src/store/current_visibility_v1.rs", - "crates/event_store/src/store/food_availability_projection_v1.rs", - "crates/event_store/src/store/post_core_extensions_v1.rs", - "crates/event_store/src/store/protocol_reconciliation_v1.rs", - "crates/event_store/src/store/protocol_storage_v1.rs", - ]; + let current_successor_superseded_paths = + super::super::raw_source_rebuild::transitive_predecessor_superseded_paths(); let root = repository_root(); let (food_paths, nip09_paths) = partition_downstream_predecessor_supersessions( @@ -4037,24 +4085,29 @@ mod tests { ); validate_food_availability_projection_predecessor_production_sources_under_lock( &root, - CURRENT_SUCCESSOR_SUPERSEDED_PATHS, + &current_successor_superseded_paths, + &[RESULT_VECTOR_EXECUTOR_RELATIVE], ) .expect("Food and transitive NIP-09 successor source coverage"); - let mut duplicate = CURRENT_SUCCESSOR_SUPERSEDED_PATHS.to_vec(); + let mut duplicate = current_successor_superseded_paths.clone(); duplicate.push("crates/event_store/src/store/protocol_reconciliation_v1.rs"); let error = validate_food_availability_projection_predecessor_production_sources_under_lock( - &root, &duplicate, + &root, + &duplicate, + &[RESULT_VECTOR_EXECUTOR_RELATIVE], ) .expect_err("duplicate transitive supersession must fail"); assert!(error.contains("must be unique"), "{error}"); - let mut unknown = CURRENT_SUCCESSOR_SUPERSEDED_PATHS.to_vec(); + let mut unknown = current_successor_superseded_paths; unknown.push("crates/event_store/src/store/not_predecessor_bound.rs"); let error = validate_food_availability_projection_predecessor_production_sources_under_lock( - &root, &unknown, + &root, + &unknown, + &[RESULT_VECTOR_EXECUTOR_RELATIVE], ) .expect_err("unknown transitive supersession must fail"); assert!( diff --git a/tools/xtask/src/contract/nip09_reconciliation.rs b/tools/xtask/src/contract/nip09_reconciliation.rs @@ -91,11 +91,11 @@ const EVENT_STORE_STORE_ROOT_BASELINE_SHA256: &str = const EVENT_STORE_MIGRATION_IMPL_BASELINE_SHA256: &str = "69f3c730f8a4f3a4af0028c74f6903126def01ceb63eed8a173e696ce291dc09"; const EVENT_CRATE_ROOT_BASELINE_SHA256: &str = - "8d39ce65fa4cfe5157a9e57d4bc40ad911dc3cc827e718a920a59cbfd79ef6cd"; + "ec032e602c1afaebc5a998e74cd1e70066afd9a07b82bce2d0d7307caac15cd6"; const EVENT_CODEC_CRATE_ROOT_BASELINE_SHA256: &str = "8c29ceccb06abc94279db3257d52e14ad721e2eb302e54fbf26f8856d0db0b53"; const BLOSSOM_CRATE_ROOT_BASELINE_SHA256: &str = - "679b7a82807461fa142f0fcb38719a2f2b84bf69569b3e74075719c735f1f4ea"; + "5066faee05fad71a94be757767f67bf99a10e5637acc41522cefe7a37eb0b4e4"; const ROUTE_FACADE_BASELINE_SHA256: &str = "8891c7824e4db6de269f2b833f2cb25510967034145423acdbac559b3ad5a52d"; const ROUTE_FACADE_BASELINE_SOURCES: [&str; 17] = [ @@ -184,7 +184,7 @@ const CORE_CARGO_MANIFEST_RELATIVE: &str = "crates/core/Cargo.toml"; const BLOSSOM_CARGO_MANIFEST_RELATIVE: &str = "crates/blossom/Cargo.toml"; const TRANSPORT_CARGO_MANIFEST_RELATIVE: &str = "crates/transport/Cargo.toml"; const CARGO_CONFIG_RELATIVE: &str = ".cargo/config.toml"; -const GOVERNED_WORKSPACE_DEPENDENCY_NAMES: [&str; 24] = [ +const GOVERNED_WORKSPACE_DEPENDENCY_NAMES: [&str; 23] = [ "dto_bindgen", "futures", "getrandom", @@ -198,7 +198,6 @@ const GOVERNED_WORKSPACE_DEPENDENCY_NAMES: [&str; 24] = [ "radroots_event_codec", "radroots_transport", "rust_decimal", - "rust_decimal_macros", "secp256k1", "serde", "serde_json", @@ -213,23 +212,23 @@ const GOVERNED_WORKSPACE_DEPENDENCY_NAMES: [&str; 24] = [ const GOVERNED_DEPENDENCY_TABLE_SHA256: [(&str, &str); 7] = [ ( CORE_CARGO_MANIFEST_RELATIVE, - "36ec909334f7bf1d8353753bcc0651f66f808307e30f4d7441595c7bd51f7d3d", + "79f6cee54c1a4f7ca0d029c6cc6ef1493ee9d441e231634a04885ac85d793759", ), ( EVENT_CARGO_MANIFEST_RELATIVE, - "46f1af7d20b6a040ed3fd9fcad8c151d1edfb6eb1558ff0042b3bf9bea9bfddf", + "d37e85cb3be5a72471b9bdf3ed405433dd558cb546c24a63f9c420529eaede5a", ), ( EVENT_CODEC_CARGO_MANIFEST_RELATIVE, - "b4ae0cfe31e089ad5ee69d398e69be36e7f24d0c101ec3d0a00a34aa109e9f06", + "7f34026a9d53ab8e2bb2dcf79d00850790b19f82b218dc9b9f25e3a4999a32be", ), ( BLOSSOM_CARGO_MANIFEST_RELATIVE, - "03bd2a20057d4be04f2d0ec613ec65536b42eb6be4f8cd396fdf8b05e5b856fa", + "5029df69fa04b89850e4411c3a57ead718bbad42bac43404602eddb7b24e8b16", ), ( EVENT_STORE_CARGO_MANIFEST_RELATIVE, - "3f34cc54eebaad1ae414905a5376bc3e4eb97369bab62479f365c40c26743531", + "8ecd900f34ea701429e326911dff0ed49364b4b5aee654ee9f68b0c1e1bf7cbf", ), ( TRANSPORT_CARGO_MANIFEST_RELATIVE, @@ -237,7 +236,7 @@ const GOVERNED_DEPENDENCY_TABLE_SHA256: [(&str, &str); 7] = [ ), ( "Cargo.toml#governed-workspace-dependencies", - "5d43b9baf1b1f354296cb6bde5024e779189d121f69cc3bb364989dd645a94de", + "4407cc5e0b79b323d98d1ec8988c3e597315d435463758793a4f3df886e62997", ), ]; @@ -303,8 +302,8 @@ const IMMUTABLE_PREDECESSOR_ARTIFACTS: [ImmutableArtifactSpec; 11] = [ }, ImmutableArtifactSpec { relative: RESULT_VECTOR_EXECUTOR_RELATIVE, - byte_length: 18_446, - sha256: "ca2a2bf54062aa6ddf2e553fd624c7217a01ad56309487ce73fa58c47c06c208", + byte_length: 18_451, + sha256: "9e9cc8d2f2382da6c73e78d73d41015559a5a3649b797cce13d7aafa9bbcc8d7", }, ImmutableArtifactSpec { relative: MIGRATION_V1_UP_RELATIVE, @@ -597,7 +596,11 @@ const RUNTIME_DEPENDENCY_ROOTS: [RuntimeDependencyRootSpec; 10] = [ #[derive(Clone, Copy)] struct CargoPackageFeatureSpec { + // The reconciliation manifest predates the Cargo package-name migration and + // records the Rust crate identifier. Keep that immutable identifier separate + // from the package name validated in the current Cargo manifest. package: &'static str, + cargo_package_name: &'static str, manifest_path: &'static str, default_features_enabled: bool, selected_features: &'static [&'static str], @@ -607,6 +610,7 @@ struct CargoPackageFeatureSpec { const CARGO_PACKAGE_FEATURE_SPECS: &[CargoPackageFeatureSpec] = &[ CargoPackageFeatureSpec { package: "radroots_core", + cargo_package_name: "radroots_core", manifest_path: CORE_CARGO_MANIFEST_RELATIVE, default_features_enabled: false, selected_features: &["serde", "std"], @@ -614,6 +618,7 @@ const CARGO_PACKAGE_FEATURE_SPECS: &[CargoPackageFeatureSpec] = &[ }, CargoPackageFeatureSpec { package: "radroots_event_store", + cargo_package_name: "radroots_event_store", manifest_path: EVENT_STORE_CARGO_MANIFEST_RELATIVE, default_features_enabled: true, selected_features: &["runtime-tokio", "sqlite"], @@ -621,6 +626,7 @@ const CARGO_PACKAGE_FEATURE_SPECS: &[CargoPackageFeatureSpec] = &[ }, CargoPackageFeatureSpec { package: "radroots_event_codec", + cargo_package_name: "radroots_event_codec", manifest_path: EVENT_CODEC_CARGO_MANIFEST_RELATIVE, default_features_enabled: false, selected_features: &["nostr", "serde", "serde_json", "std"], @@ -628,6 +634,7 @@ const CARGO_PACKAGE_FEATURE_SPECS: &[CargoPackageFeatureSpec] = &[ }, CargoPackageFeatureSpec { package: "radroots_event", + cargo_package_name: "radroots_event", manifest_path: EVENT_CARGO_MANIFEST_RELATIVE, default_features_enabled: false, selected_features: &["serde", "std"], @@ -635,6 +642,7 @@ const CARGO_PACKAGE_FEATURE_SPECS: &[CargoPackageFeatureSpec] = &[ }, CargoPackageFeatureSpec { package: "radroots_blossom", + cargo_package_name: "radroots_blossom", manifest_path: BLOSSOM_CARGO_MANIFEST_RELATIVE, default_features_enabled: false, selected_features: &["std"], @@ -5330,6 +5338,7 @@ fn validate_raw_crate_attributes(relative: &str, source: &str) -> Result<(), Str "#![forbid(unsafe_code)]", ], "crates/blossom/src/lib.rs" => &[ + "#![doc=include_str!(\"../README.md\")]", "#![cfg_attr(not(feature=\"std\"),no_std)]", "#![cfg_attr(coverage_nightly,feature(coverage_attribute))]", "#![forbid(unsafe_code)]", @@ -12991,7 +13000,7 @@ fn validate_source_maintenance_runtime_token_authority( workspace_root: &Path, ) -> Result<(), String> { const SOURCE_RUNTIME_AST_SHA256: &str = - "181576a5de365cf664b8a87091c30b0389ce0be90e7d1cc16fd7170342f6c2bc"; + "c52b1acee9261d45590d8b443f10ad4fdd539fa8d6227e4ca8bc09c593c4c2b6"; const FUNCTION_SPECS: [(&str, &str, &str); 4] = [ ( EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, @@ -14597,7 +14606,7 @@ fn validate_governed_compiler_inputs_with_event_store_successor( let expected_toolchain: toml::Value = toml::from_str( r#" [toolchain] -channel = "1.97.0" +channel = "1.97.1" components = ["clippy", "rust-analyzer", "rust-src", "rustfmt"] targets = ["wasm32-unknown-unknown"] "#, @@ -14605,7 +14614,7 @@ targets = ["wasm32-unknown-unknown"] .map_err(|error| format!("parse governed Rust toolchain expectation: {error}"))?; if toolchain != expected_toolchain { return Err(format!( - "{RUST_TOOLCHAIN_RELATIVE} must remain the exact governed Rust 1.97.0 toolchain document" + "{RUST_TOOLCHAIN_RELATIVE} must remain the exact governed Rust 1.97.1 toolchain document" )); } for legacy_relative in ["rust-toolchain", ".cargo/config"] { @@ -14652,22 +14661,54 @@ xtask = "run -q -p xtask --" } let governed_packages = [ - (CORE_CARGO_MANIFEST_RELATIVE, "radroots_core"), - (EVENT_CARGO_MANIFEST_RELATIVE, "radroots_event"), - (EVENT_CODEC_CARGO_MANIFEST_RELATIVE, "radroots_event_codec"), - (BLOSSOM_CARGO_MANIFEST_RELATIVE, "radroots_blossom"), - (EVENT_STORE_CARGO_MANIFEST_RELATIVE, "radroots_event_store"), - (TRANSPORT_CARGO_MANIFEST_RELATIVE, "radroots_transport"), + ( + CORE_CARGO_MANIFEST_RELATIVE, + "radroots_core", + "0.1.0-alpha", + Some("radroots_core"), + ), + ( + EVENT_CARGO_MANIFEST_RELATIVE, + "radroots_event", + "0.1.0-alpha", + Some("radroots_event"), + ), + ( + EVENT_CODEC_CARGO_MANIFEST_RELATIVE, + "radroots_event_codec", + "0.1.0-alpha", + Some("radroots_event_codec"), + ), + ( + BLOSSOM_CARGO_MANIFEST_RELATIVE, + "radroots_blossom", + "0.1.0-alpha", + Some("radroots_blossom"), + ), + ( + EVENT_STORE_CARGO_MANIFEST_RELATIVE, + "radroots_event_store", + "0.1.0-alpha", + None, + ), + ( + TRANSPORT_CARGO_MANIFEST_RELATIVE, + "radroots_transport", + "0.1.0-alpha", + Some("radroots_transport"), + ), ]; let mut actual_identities = Vec::new(); - for (relative, expected_package_name) in governed_packages { + for (relative, expected_package_name, expected_version, expected_crate_name) in + governed_packages + { let manifest = parse_cargo_manifest(workspace_root, relative)?; let package = manifest .get("package") .and_then(toml::Value::as_table) .ok_or_else(|| format!("{relative} must declare [package]"))?; if package.get("name").and_then(toml::Value::as_str) != Some(expected_package_name) - || package.get("version").and_then(toml::Value::as_str) != Some("0.1.0-alpha") + || package.get("version").and_then(toml::Value::as_str) != Some(expected_version) || package .get("edition") .and_then(toml::Value::as_table) @@ -14682,9 +14723,22 @@ xtask = "run -q -p xtask --" != Some(true) { return Err(format!( - "{relative} compiler package identity must remain {expected_package_name} 0.1.0-alpha with workspace edition and rust-version" + "{relative} compiler package identity must remain {expected_package_name} {expected_version} with workspace edition and rust-version" )); } + match (expected_crate_name, manifest.get("lib")) { + (None, None) => {} + (Some(expected), Some(lib)) + if lib.as_table().is_some_and(|lib| { + lib.len() == 1 + && lib.get("name").and_then(toml::Value::as_str) == Some(expected) + }) => {} + _ => { + return Err(format!( + "{relative} must preserve its exact Rust crate name without target-path authority" + )); + } + } if [ "build", "autolib", @@ -14703,7 +14757,6 @@ xtask = "run -q -p xtask --" if [ "build-dependencies", "target", - "lib", "bin", "example", "test", @@ -14815,7 +14868,7 @@ xtask = "run -q -p xtask --" "{WORKSPACE_CARGO_MANIFEST_RELATIVE} must not exclude or narrow the default governed workspace member graph beyond the governed fuzz exclusion" )); } - if workspace.get("resolver").and_then(toml::Value::as_str) != Some("2") + if workspace.get("resolver").and_then(toml::Value::as_str) != Some("3") || workspace_package .get("edition") .and_then(toml::Value::as_str) @@ -14823,10 +14876,10 @@ xtask = "run -q -p xtask --" || workspace_package .get("rust-version") .and_then(toml::Value::as_str) - != Some("1.97.0") + != Some("1.97.1") { return Err(format!( - "{WORKSPACE_CARGO_MANIFEST_RELATIVE} must retain resolver 2, edition 2024, and rust-version 1.97.0 compiler authority" + "{WORKSPACE_CARGO_MANIFEST_RELATIVE} must retain resolver 3, edition 2024, and rust-version 1.97.1 compiler authority" )); } if workspace_manifest.get("replace").is_some() @@ -14956,10 +15009,10 @@ fn describe_cargo_package_features( .and_then(|package| package.get("name")) .and_then(toml::Value::as_str) .ok_or_else(|| format!("{} must declare package.name", spec.manifest_path))?; - if package_name != spec.package { + if package_name != spec.cargo_package_name { return Err(format!( "{} package.name must be {}", - spec.manifest_path, spec.package + spec.manifest_path, spec.cargo_package_name )); } let feature_table = manifest @@ -20820,44 +20873,60 @@ pub(crate) fn migration_for_version"#, fn predecessor_impl_resolution_projection_is_fail_closed() { let workspace = synthetic_workspace(); let manifest = immutable_manifest(); + let superseded_paths = + super::super::source_maintenance::predecessor_superseded_source_paths() + .iter() + .copied() + .chain( + RAW_SOURCE_REBUILD_PREDECESSOR_SUPERSEDED_PATHS + .iter() + .copied(), + ) + .collect::<BTreeSet<_>>() + .into_iter() + .collect::<Vec<_>>(); validate_predecessor_impl_resolution_authority( workspace.path(), &manifest, - &RAW_SOURCE_REBUILD_PREDECESSOR_SUPERSEDED_PATHS, + &superseded_paths, ) .expect("current successor must preserve predecessor impl authority"); - let unchanged_relative = "crates/event_codec/src/deletion/reconciliation_v1.rs"; + let unchanged_relative = "crates/event_codec/src/error.rs"; let unchanged_path = workspace.path().join(unchanged_relative); let unchanged = fs::read_to_string(&unchanged_path).expect("unchanged predecessor source"); fs::write( &unchanged_path, format!( - "{unchanged}\ntrait UnexpectedPredecessorResolution {{}}\nimpl UnexpectedPredecessorResolution for RadrootsNip09SuppressionDecision {{}}\n" + "{unchanged}\ntrait UnexpectedPredecessorResolution {{}}\nimpl UnexpectedPredecessorResolution for EventEncodeError {{}}\n" ), ) .expect("add unexpected predecessor impl authority"); let error = validate_predecessor_impl_resolution_authority( workspace.path(), &manifest, - &RAW_SOURCE_REBUILD_PREDECESSOR_SUPERSEDED_PATHS, + &superseded_paths, ) .expect_err("new predecessor-bound impl authority must fail closed"); assert!(error.contains("unexpected ["), "{error}"); assert!(error.contains("UnexpectedPredecessorResolution"), "{error}"); - let changed = unchanged.replacen("self.address_reference.as_ref()", "None", 1); + let changed = unchanged.replacen( + "failed to serialize JSON", + "failed to serialize canonical JSON", + 1, + ); assert_ne!(changed, unchanged, "expected impl fixture must mutate"); fs::write(&unchanged_path, changed).expect("change expected predecessor impl authority"); let error = validate_predecessor_impl_resolution_authority( workspace.path(), &manifest, - &RAW_SOURCE_REBUILD_PREDECESSOR_SUPERSEDED_PATHS, + &superseded_paths, ) .expect_err("changed predecessor-bound impl authority must fail closed"); assert!(error.contains("missing ["), "{error}"); assert!(error.contains("unexpected ["), "{error}"); - assert!(error.matches("address_reference").count() >= 2, "{error}"); + assert!(error.matches("EventEncodeError").count() >= 2, "{error}"); fs::write(&unchanged_path, unchanged).expect("restore unchanged predecessor source"); let successor_relative = @@ -20874,7 +20943,7 @@ pub(crate) fn migration_for_version"#, validate_predecessor_impl_resolution_authority( workspace.path(), &manifest, - &RAW_SOURCE_REBUILD_PREDECESSOR_SUPERSEDED_PATHS, + &superseded_paths, ) .expect("successor-only authority must not rotate predecessor projection"); } @@ -21075,7 +21144,7 @@ version = "0.1.0" ), ( format!( - "{original}\nmacro_rules! inject_impl {{ () => {{ impl Drop for RadrootsBlossomSha256 {{ fn drop(&mut self) {{}} }} }} }}\ninject_impl!();\n" + "{original}\nmacro_rules! inject_impl {{ () => {{ impl Drop for Sha256 {{ fn drop(&mut self) {{}} }} }} }}\ninject_impl!();\n" ), "unapproved production item-macro authority", ), @@ -21609,12 +21678,12 @@ async fn nip09_reconciliation_v1_result_vector() { let toolchain = fs::read_to_string(&toolchain_path).expect("Rust toolchain"); fs::write( &toolchain_path, - toolchain.replacen("channel = \"1.97.0\"", "channel = \"nightly\"", 1), + toolchain.replacen("channel = \"1.97.1\"", "channel = \"nightly\"", 1), ) .expect("write toolchain mutation"); let error = validate_governed_compiler_inputs(workspace.path()) .expect_err("Rust toolchain mutation must fail"); - assert!(error.contains("exact governed Rust 1.97.0"), "{error}"); + assert!(error.contains("exact governed Rust 1.97.1"), "{error}"); fs::write(&toolchain_path, toolchain).expect("restore Rust toolchain"); let legacy_toolchain_path = workspace.path().join("rust-toolchain"); @@ -21741,7 +21810,7 @@ async fn nip09_reconciliation_v1_result_vector() { for (label, mutation) in [ ( "resolver", - workspace_manifest.replacen("resolver = \"2\"", "resolver = \"1\"", 1), + workspace_manifest.replacen("resolver = \"3\"", "resolver = \"1\"", 1), ), ( "edition", @@ -21754,7 +21823,7 @@ async fn nip09_reconciliation_v1_result_vector() { let error = validate_governed_compiler_inputs(workspace.path()) .expect_err("workspace compiler authority mutation must fail"); assert!( - error.contains("resolver 2, edition 2024, and rust-version 1.97.0"), + error.contains("resolver 3, edition 2024, and rust-version 1.97.1"), "{label} produced unexpected error: {error}" ); fs::write(&workspace_manifest_path, &workspace_manifest) diff --git a/tools/xtask/src/contract/phase1_publication_artifact.rs b/tools/xtask/src/contract/phase1_publication_artifact.rs @@ -85,7 +85,7 @@ const EXPLICIT_SOURCE_SPECS: &[(&str, &str)] = &[ "event_codec_manifest_authority", "crates/event_codec/Cargo.toml", ), - ("event_codec_documentation", "crates/event_codec/README"), + ("event_codec_documentation", "crates/event_codec/README.md"), ( "event_store_manifest_authority", "crates/event_store/Cargo.toml", @@ -127,13 +127,26 @@ const BLOSSOM_READINESS_RAW_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = &[ "Cargo.lock", "Cargo.toml", "build/nix/common.nix", + "crates/event_store/src/generated/food_availability_projection_manifest.rs", + "crates/event_store/src/generated/source_maintenance_manifest.rs", + "crates/event_store/src/nip09/reconciliation_v1/result_vector_executor.rs", "tools/xtask/src/contract/food_availability_projection.rs", "tools/xtask/src/contract/source_maintenance.rs", ]; -const RASTER_DECODER_SECURITY_RAW_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = - &["build/nix/apps.nix", "build/nix/toolchains.nix"]; -const RUST_CRATE_VERSION_RAW_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = - &["crates/event_store/Cargo.toml", "tools/xtask/Cargo.toml"]; +const RASTER_DECODER_SECURITY_RAW_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = &[ + "build/nix/apps.nix", + "build/nix/toolchains.nix", + "flake.lock", +]; +const RUST_CRATE_VERSION_RAW_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = &[ + "crates/event_store/Cargo.toml", + "rust-toolchain.toml", + "tools/xtask/Cargo.toml", +]; +const RAW_PREDECESSOR_SUPERSEDED_ARTIFACT_PATHS: &[&str] = &[ + "crates/event_store/contracts/source_maintenance_v1.manifest.json", + "crates/event_store/migrations/0004_source_maintenance.up.sql", +]; const PUBLIC_TYPES: &[&str] = &[ "RadrootsPhase1PublicationEventVariant", @@ -291,6 +304,7 @@ const PUBLICATION_IMMUTABLE_ARTIFACTS: &[ImmutableArtifactSpec] = &[ "7a31169eac4217a38cb3ef25eb9213f2f89e11fb17e76ceaf7449b34225e98af", ), ]; +const PUBLICATION_SUCCESSOR_SUPERSEDED_ARTIFACT_PATHS: &[&str] = &[VECTOR_EXECUTOR_RELATIVE]; pub(super) const PUBLICATION_SUCCESSOR_SUPERSEDED_PATHS: &[&str] = &[ "Cargo.lock", @@ -675,8 +689,29 @@ fn validate_manifest_under_lock(workspace_root: &Path) -> Result<(), String> { pub(super) fn validate_immutable_phase1_publication_artifact_predecessor_under_lock( workspace_root: &Path, ) -> Result<(), String> { + let superseded_artifacts = PUBLICATION_SUCCESSOR_SUPERSEDED_ARTIFACT_PATHS + .iter() + .copied() + .collect::<BTreeSet<_>>(); + if superseded_artifacts.len() != PUBLICATION_SUCCESSOR_SUPERSEDED_ARTIFACT_PATHS.len() { + return Err( + "publication predecessor artifact supersession paths must be unique".to_owned(), + ); + } + if let Some(path) = superseded_artifacts.iter().find(|path| { + !PUBLICATION_IMMUTABLE_ARTIFACTS + .iter() + .any(|artifact| artifact.relative == **path) + }) { + return Err(format!( + "publication predecessor artifact supersession path {path} is not predecessor-bound" + )); + } for spec in PUBLICATION_IMMUTABLE_ARTIFACTS { let bytes = read_regular_file(workspace_root, spec.relative)?; + if superseded_artifacts.contains(spec.relative) { + continue; + } if bytes.len() != spec.byte_length || sha256_hex(&bytes) != spec.sha256 { return Err(format!( "immutable Phase 1 publication artifact predecessor {} drifted", @@ -1462,7 +1497,10 @@ fn validate_immutable_raw_predecessor_under_lock(workspace_root: &Path) -> Resul .and_then(Value::as_array) .ok_or_else(|| format!("{RAW_MANIFEST_RELATIVE} has no migration inventory"))? { - validate_value_descriptor(workspace_root, descriptor, "raw migration")?; + let path = descriptor_path(descriptor, "raw migration")?; + if !RAW_PREDECESSOR_SUPERSEDED_ARTIFACT_PATHS.contains(&path) { + validate_value_descriptor(workspace_root, descriptor, "raw migration")?; + } } validate_value_descriptor( workspace_root, @@ -1471,13 +1509,17 @@ fn validate_immutable_raw_predecessor_under_lock(workspace_root: &Path) -> Resul .ok_or_else(|| format!("{RAW_MANIFEST_RELATIVE} has no manifest_schema"))?, "raw manifest schema", )?; - validate_value_descriptor( - workspace_root, - manifest - .pointer("/predecessor/manifest") - .ok_or_else(|| format!("{RAW_MANIFEST_RELATIVE} has no predecessor manifest"))?, - "raw predecessor manifest", - )?; + let predecessor_manifest = manifest + .pointer("/predecessor/manifest") + .ok_or_else(|| format!("{RAW_MANIFEST_RELATIVE} has no predecessor manifest"))?; + let predecessor_path = descriptor_path(predecessor_manifest, "raw predecessor manifest")?; + if !RAW_PREDECESSOR_SUPERSEDED_ARTIFACT_PATHS.contains(&predecessor_path) { + validate_value_descriptor( + workspace_root, + predecessor_manifest, + "raw predecessor manifest", + )?; + } let executor = json!({ "path": RAW_VECTOR_EXECUTOR_RELATIVE, "byte_length": manifest.pointer("/result_vector/executor_byte_length"), diff --git a/tools/xtask/src/contract/phase1_publication_media_readiness.rs b/tools/xtask/src/contract/phase1_publication_media_readiness.rs @@ -167,6 +167,8 @@ const PUBLICATION_ARTIFACT_TRANSITIVE_PREDECESSOR_ARTIFACTS: &[ImmutableArtifact "7a31169eac4217a38cb3ef25eb9213f2f89e11fb17e76ceaf7449b34225e98af", ), ]; +const PUBLICATION_ARTIFACT_TRANSITIVE_PREDECESSOR_SUPERSEDED_ARTIFACTS: &[&str] = + &["crates/event_codec/tests/publication_artifact.rs"]; const ALLOWLIST_PREDECESSOR_ARTIFACTS: &[ImmutableArtifactSpec] = &[ ImmutableArtifactSpec::new( @@ -268,6 +270,7 @@ const ALLOWLIST_PREDECESSOR_SOURCE_SUPERSESSIONS: &[&str] = &[ "contracts/releases/1.0.0-alpha.1.toml", "crates/event_codec/Cargo.toml", "crates/event_codec/src/wire/publication.rs", + "crates/event_codec/tests/publication_artifact.rs", "tools/xtask/src/contract.rs", "tools/xtask/src/contract/nip09_reconciliation.rs", "tools/xtask/src/contract/phase1_publication_artifact.rs", @@ -278,12 +281,18 @@ const BLOSSOM_PREDECESSOR_SOURCE_SUPERSESSIONS: &[&str] = &[ "CHANGELOG.md", "contracts/operations.toml", "contracts/releases/1.0.0-alpha.1.toml", + "crates/blossom/tests/publication_readiness.rs", + "crates/blossom/tests/publication_readiness_persistence.rs", "tools/xtask/src/contract.rs", "tools/xtask/src/contract/nip09_reconciliation.rs", "tools/xtask/src/contract/phase1_publication_artifact.rs", "tools/xtask/src/contract/raw_source_rebuild.rs", "tools/xtask/src/main.rs", ]; +pub(super) const BLOSSOM_PREDECESSOR_SUPERSEDED_ARTIFACTS: &[&str] = &[ + "crates/blossom/tests/publication_readiness.rs", + "crates/blossom/tests/publication_readiness_persistence.rs", +]; #[derive(Clone, Copy)] pub(super) struct ImmutableArtifactSpec { @@ -316,6 +325,7 @@ pub(super) struct FileDescriptor { pub(super) struct PredecessorDescriptor { contract_id: String, immutable_artifacts: Vec<FileDescriptor>, + artifact_supersessions: Vec<FileDescriptor>, source_supersessions: Vec<String>, } @@ -469,10 +479,11 @@ pub(crate) fn validate_immutable_phase1_publication_artifact_predecessor( workspace_root: &Path, ) -> Result<(), String> { with_artifact_bundle_transaction(workspace_root, |_| { - validate_immutable_predecessor( + validate_immutable_predecessor_with_superseded_artifacts( workspace_root, "Phase 1 publication artifact", PUBLICATION_ARTIFACT_TRANSITIVE_PREDECESSOR_ARTIFACTS, + PUBLICATION_ARTIFACT_TRANSITIVE_PREDECESSOR_SUPERSEDED_ARTIFACTS, ) }) } @@ -481,10 +492,11 @@ pub(crate) fn validate_immutable_blossom_publication_readiness_predecessor( workspace_root: &Path, ) -> Result<(), String> { with_artifact_bundle_transaction(workspace_root, |_| { - validate_immutable_predecessor( + validate_immutable_predecessor_with_superseded_artifacts( workspace_root, "Blossom publication readiness", BLOSSOM_PREDECESSOR_ARTIFACTS, + BLOSSOM_PREDECESSOR_SUPERSEDED_ARTIFACTS, ) }) } @@ -492,20 +504,22 @@ pub(crate) fn validate_immutable_blossom_publication_readiness_predecessor( fn validate_predecessors(workspace_root: &Path) -> Result<(), String> { validate_source_supersessions(workspace_root, ALLOWLIST_PREDECESSOR_SOURCE_SUPERSESSIONS)?; validate_source_supersessions(workspace_root, BLOSSOM_PREDECESSOR_SOURCE_SUPERSESSIONS)?; - validate_immutable_predecessor( + validate_immutable_predecessor_with_superseded_artifacts( workspace_root, "Phase 1 publication artifact", PUBLICATION_ARTIFACT_TRANSITIVE_PREDECESSOR_ARTIFACTS, + PUBLICATION_ARTIFACT_TRANSITIVE_PREDECESSOR_SUPERSEDED_ARTIFACTS, )?; validate_immutable_predecessor( workspace_root, "Phase 1 publication allowlist", ALLOWLIST_PREDECESSOR_ARTIFACTS, )?; - validate_immutable_predecessor( + validate_immutable_predecessor_with_superseded_artifacts( workspace_root, "Blossom publication readiness", BLOSSOM_PREDECESSOR_ARTIFACTS, + BLOSSOM_PREDECESSOR_SUPERSEDED_ARTIFACTS, ) } @@ -531,8 +545,37 @@ pub(super) fn validate_immutable_predecessor( label: &str, artifacts: &[ImmutableArtifactSpec], ) -> Result<(), String> { + validate_immutable_predecessor_with_superseded_artifacts(workspace_root, label, artifacts, &[]) +} + +pub(super) fn validate_immutable_predecessor_with_superseded_artifacts( + workspace_root: &Path, + label: &str, + artifacts: &[ImmutableArtifactSpec], + superseded_artifact_paths: &[&str], +) -> Result<(), String> { + let superseded_artifacts = superseded_artifact_paths + .iter() + .copied() + .collect::<BTreeSet<_>>(); + if superseded_artifacts.len() != superseded_artifact_paths.len() { + return Err(format!( + "{label} predecessor artifact supersession paths must be unique" + )); + } + if let Some(path) = superseded_artifacts + .iter() + .find(|path| !artifacts.iter().any(|artifact| artifact.relative == **path)) + { + return Err(format!( + "{label} predecessor artifact supersession path `{path}` is not an immutable predecessor artifact" + )); + } for artifact in artifacts { let bytes = read_regular_file(workspace_root, artifact.relative)?; + if superseded_artifacts.contains(artifact.relative) { + continue; + } if bytes.len() != artifact.byte_length || sha256_hex(&bytes) != artifact.sha256 { return Err(format!( "immutable {label} predecessor artifact {} drifted", @@ -648,15 +691,19 @@ fn describe_manifest( manifest_schema: descriptor_for_bytes(MANIFEST_SCHEMA_RELATIVE, manifest_schema_bytes), predecessors: vec![ predecessor_descriptor( + workspace_root, "radroots_event_codec.phase1_publication_allowlist_v1", ALLOWLIST_PREDECESSOR_ARTIFACTS, + PUBLICATION_ARTIFACT_TRANSITIVE_PREDECESSOR_SUPERSEDED_ARTIFACTS, ALLOWLIST_PREDECESSOR_SOURCE_SUPERSESSIONS, - ), + )?, predecessor_descriptor( + workspace_root, "radroots_blossom.publication_readiness_v1", BLOSSOM_PREDECESSOR_ARTIFACTS, + BLOSSOM_PREDECESSOR_SUPERSEDED_ARTIFACTS, BLOSSOM_PREDECESSOR_SOURCE_SUPERSESSIONS, - ), + )?, ], protocol_sources: vec![ ProtocolSourcePin { @@ -720,11 +767,13 @@ fn describe_manifest( } pub(super) fn predecessor_descriptor( + workspace_root: &Path, contract_id: &str, artifacts: &[ImmutableArtifactSpec], + artifact_supersessions: &[&str], source_supersessions: &[&str], -) -> PredecessorDescriptor { - PredecessorDescriptor { +) -> Result<PredecessorDescriptor, String> { + Ok(PredecessorDescriptor { contract_id: contract_id.to_owned(), immutable_artifacts: artifacts .iter() @@ -735,8 +784,12 @@ pub(super) fn predecessor_descriptor( hash_algorithm: HASH_ALGORITHM.to_owned(), }) .collect(), + artifact_supersessions: artifact_supersessions + .iter() + .map(|path| descriptor_for_file(workspace_root, path)) + .collect::<Result<Vec<_>, _>>()?, source_supersessions: owned(source_supersessions), - } + }) } fn validate_source_contract(workspace_root: &Path) -> Result<(), String> { @@ -1114,7 +1167,7 @@ fn validate_operations_authority(workspace_root: &Path) -> Result<(), String> { BIND_OPERATION_ID, &[ "RadrootsPhase1AllowlistedPublicationArtifact", - "RadrootsBlossomPublicationReadinessEvidence", + "PublicationReadinessEvidence", ], &["RadrootsPhase1MediaReadyPublicationArtifact"], "validation_error", @@ -1401,10 +1454,11 @@ fn manifest_schema() -> Value { "items": { "type": "object", "additionalProperties": false, - "required": ["contract_id", "immutable_artifacts", "source_supersessions"], + "required": ["contract_id", "immutable_artifacts", "artifact_supersessions", "source_supersessions"], "properties": { "contract_id": {"type": "string", "minLength": 1}, "immutable_artifacts": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/file"}}, + "artifact_supersessions": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/file"}}, "source_supersessions": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}, "uniqueItems": true} } } diff --git a/tools/xtask/src/contract/raw_source_rebuild.rs b/tools/xtask/src/contract/raw_source_rebuild.rs @@ -5,7 +5,9 @@ use super::nip09_reconciliation::{ validate_current_event_store_successor_authority, validate_raw_source_rebuild_successor_compiler_inputs, }; -use super::source_maintenance::validate_source_maintenance_manifest_under_lock; +use super::source_maintenance::{ + predecessor_superseded_source_paths, validate_source_maintenance_manifest_under_lock, +}; use quote::ToTokens; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -44,13 +46,13 @@ const VISIBILITY_ORACLE: &str = "pure_verified_raw_snapshot_direct_indexed_evide const VISIBILITY_ORACLE_EXPECTED_VISIBILITY_AST_SHA256: &str = "88155fb497668bc65d1adb107c8692483c44f8be1be7dea4663772aa6df23897"; const VISIBILITY_ORACLE_DECISION_AST_SHA256: &str = - "be2034bc552829af7cb8f8f77ce7c0b97e2e23b94551994f6463877ef87c9404"; + "4dd437fa4342716e02306f01b6d971b7bd272e05c4e5be397dfc8425a01a630c"; const RECONCILIATION_REQUEST_INDEX_INSERT_AST_SHA256: &str = - "81d1e02d42dda1b34fd6ab30873765072d7030abf0bb42f7dc117b88eb206933"; + "c30fc073c990f16b75b28e31c316742812b72e313bae9e649e15df04cb9e49a6"; const RECONCILIATION_REQUEST_INDEX_DECISION_AST_SHA256: &str = - "2920383a13dc1f7f701039147cb3e5595797a5fe68217a7de6d26cb27715add1"; + "c27ce0bd2830a016b9f08a8a099f3d1edfa1e148396017a25037b1e33617f5c6"; const RECONCILIATION_AFFECTED_COORDINATES_AST_SHA256: &str = - "8b1aca89e5a20be8f5eb44e08e205e693ba6f2ea0cf4e3a5bd25910f5f4e25cf"; + "ba549c79645b92962a97de8aaa75ecf28a87cb93b91fe25afbb8ca357effaa99"; const EVENT_STORE_SUCCESSOR_COMPILER_TABLES_SHA256: &str = "10e6177bb51094e1775994e1cb3c7c72d01d71890ce45df6c3129ff3d7ee301c"; const SCOPED_INTEGRITY_MODE: &str = "event_store_owned_tables_and_indices_v1"; @@ -116,7 +118,6 @@ const XTASK_FORBIDDEN_AUTO_TARGET_PATHS: &[&str] = &[ "tools/xtask/src/lib.rs", "tools/xtask/src/bin.rs", "tools/xtask/src/bin", - "tools/xtask/tests", "tools/xtask/examples", "tools/xtask/benches", ]; @@ -595,7 +596,7 @@ const DELEGATED_COMPILER_SOURCE_PINS: &[(&str, &str)] = &[ ), ( FLAKE_LOCK_RELATIVE, - "41b569739bfa0c488625326f4f0a874561601787951cdf7a3f171e60572fa20e", + "eee23520f8cb4243871707bd57c225b378a79ad75a0358a719fa4a346e523b7d", ), ( CONTRACT_APP_SOURCE_RELATIVE, @@ -611,11 +612,11 @@ const DELEGATED_COMPILER_SOURCE_PINS: &[(&str, &str)] = &[ ), ( RUST_TOOLCHAIN_RELATIVE, - "c33aa38292bab6513bf79ed2f69c1525b736dd738b15ca78af713b70b29265c9", + "52093bb43b3e219e5346bd2a2d56f7ce5845f3a89d0e55c3b75609397f093572", ), ( XTASK_MANIFEST_RELATIVE, - "63b32e8981247bfec6aceb5925f6754d8f6360b73e57e73a57d2d1149701f6c5", + "11b796cd1dec34a36dcd0431e93c0f88d100ed8cba2d3aff74dc858848aaa1ce", ), ]; @@ -906,6 +907,16 @@ const TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = &[ "crates/event_store/src/store/protocol_reconciliation_v1.rs", "crates/event_store/src/store/protocol_storage_v1.rs", ]; + +pub(super) fn transitive_predecessor_superseded_paths() -> Vec<&'static str> { + predecessor_superseded_source_paths() + .iter() + .copied() + .chain(TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS.iter().copied()) + .collect::<BTreeSet<_>>() + .into_iter() + .collect() +} const BLOSSOM_READINESS_SUCCESSOR_TRANSITIVE_PATHS: &[&str] = &[ "Cargo.toml", "crates/blossom/Cargo.toml", @@ -1206,9 +1217,8 @@ pub(super) fn validate_raw_source_rebuild_predecessor_production_sources_under_l } } - let transitive = TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS - .iter() - .copied() + let transitive = transitive_predecessor_superseded_paths() + .into_iter() .chain(transitive_superseded_paths.iter().copied()) .collect::<BTreeSet<_>>() .into_iter() @@ -1216,6 +1226,7 @@ pub(super) fn validate_raw_source_rebuild_predecessor_production_sources_under_l validate_food_availability_projection_predecessor_production_sources_under_lock( workspace_root, &transitive, + &["crates/event_store/tests/food_availability_projection_v1_result_vector.rs"], ) } @@ -1956,6 +1967,34 @@ fn validate_xtask_manifest_authority(workspace_root: &Path) -> Result<(), String } } } + let fixture_root = workspace_root.join("tools/xtask/tests"); + match fs::symlink_metadata(&fixture_root) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err( + "tools/xtask/tests must be an ordinary directory containing only fixtures" + .to_owned(), + ); + } + Ok(_) => { + for entry in fs::read_dir(&fixture_root) + .map_err(|error| format!("read tools/xtask/tests: {error}"))? + { + let entry = entry.map_err(|error| format!("read tools/xtask/tests: {error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("inspect tools/xtask/tests entry: {error}"))?; + if entry.file_name() != "fixtures" || file_type.is_symlink() || !file_type.is_dir() + { + return Err( + "delegated xtask compiler authority permits only the non-target tools/xtask/tests/fixtures directory" + .to_owned(), + ); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("inspect tools/xtask/tests: {error}")), + } Ok(()) } @@ -2165,6 +2204,7 @@ fn validate_predecessor_source_supersession( validate_food_availability_projection_predecessor_production_sources_under_lock( workspace_root, TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS, + &["crates/event_store/tests/food_availability_projection_v1_result_vector.rs"], )?; Ok(()) } @@ -2522,7 +2562,7 @@ fn validate_error_authority(workspace_root: &Path) -> Result<(), String> { } let errors = exact_enum(&file, "RadrootsEventStoreError")?; const EXPECTED_ERROR_ENUM_SHA256: &str = - "dcb9416ca05bda35845f8708fe73132df7137b0c0e002e8ba6d709989bc31939"; + "f644778f99add959dc4e0e10afbf29ad275e1babfb7915a4bd9c03e64f30b870"; let actual_error_enum_sha256 = sha256_hex(compact_tokens(errors).as_bytes()); if actual_error_enum_sha256 != EXPECTED_ERROR_ENUM_SHA256 { return Err(format!( @@ -3134,10 +3174,10 @@ fn validate_reconciliation_direct_request_index_authority( } } for marker in [ - "request_id<current.as_str()", + "request_id.as_str()<current.as_str()", "request_event.created_at_u64()>current.created_at", "request_event.created_at_u64()==current.created_at", - "request_id<current.request_id.as_str()", + "request_id.as_str()<current.request_id.as_str()", "evidence.unauthorized=true", ] { if !insert.contains(marker) { @@ -3157,7 +3197,7 @@ fn validate_reconciliation_direct_request_index_authority( let decision = compact_tokens(decision_function); for marker in [ "self.event_targets.get(event.id_str())", - "by_author.get(event.author_str())", + "by_author.get(&event.author().to_hex())", "self.address_targets.get(coordinate)", "RadrootsNip09SuppressionReason::DeletionRequestImmune", "RadrootsNip09SuppressionReason::NoAuthorizedReference", @@ -3341,7 +3381,7 @@ fn validate_visibility_oracle_index_authority( let decision = compact_tokens(decision_function); for marker in [ "self.event_targets.get(event.id_str())", - "by_author.get(event.author_str())", + "by_author.get(&event.author().to_hex())", "self.address_targets.get(coordinate)", "self.requests[index].event()", "RadrootsNip09SuppressionReason::DeletionRequestImmune", @@ -7569,11 +7609,16 @@ mod tests { #[test] fn source_maintenance_predecessor_identity_is_frozen() { let root = workspace_root(); - let bytes = read_regular_file(&root, PREDECESSOR_MANIFEST_RELATIVE) - .expect("SourceMaintenance predecessor manifest"); - validate_predecessor_identity(&bytes).expect("frozen predecessor identity"); - validate_predecessor_source_supersession(&root, &bytes) - .expect("changed predecessor sources are superseded"); + validate_source_maintenance_manifest_under_lock(&root) + .expect("current refactor SourceMaintenance predecessor"); + let raw_bytes = read_regular_file(&root, MANIFEST_RELATIVE).expect("raw manifest"); + let raw: RawSourceRebuildManifest = + serde_json::from_slice(&raw_bytes).expect("typed raw manifest"); + assert_eq!( + raw.predecessor.manifest.byte_length, + PREDECESSOR_MANIFEST_BYTE_LENGTH as u64 + ); + assert_eq!(raw.predecessor.manifest.sha256, PREDECESSOR_MANIFEST_SHA256); } #[test] diff --git a/tools/xtask/src/contract/source_maintenance.rs b/tools/xtask/src/contract/source_maintenance.rs @@ -358,6 +358,84 @@ pub(super) fn source_contract_fixture_source_paths() -> Vec<&'static str> { SOURCE_SPECS.iter().map(|source| source.path).collect() } +const PREDECESSOR_SUPERSEDED_SOURCE_PATHS: &[&str] = &[ + "Cargo.toml", + "crates/blossom/src/authorization.rs", + "crates/blossom/src/descriptor.rs", + "crates/blossom/src/error.rs", + "crates/blossom/src/hash.rs", + "crates/blossom/src/lib.rs", + "crates/blossom/src/url.rs", + "crates/core/src/currency.rs", + "crates/core/src/decimal.rs", + "crates/core/src/money.rs", + "crates/core/src/percent.rs", + "crates/core/src/quantity.rs", + "crates/core/src/quantity_price.rs", + "crates/core/src/unit.rs", + "crates/event/src/calendar.rs", + "crates/event/src/classified_listing.rs", + "crates/event/src/comment.rs", + "crates/event/src/contract.rs", + "crates/event/src/contract/registry_v7.rs", + "crates/event/src/deletion.rs", + "crates/event/src/draft.rs", + "crates/event/src/envelope.rs", + "crates/event/src/event_head.rs", + "crates/event/src/event_head/v1.rs", + "crates/event/src/food_availability.rs", + "crates/event/src/ids.rs", + "crates/event/src/kinds.rs", + "crates/event/src/media.rs", + "crates/event/src/post.rs", + "crates/event/src/profile.rs", + "crates/event/src/relay_hint.rs", + "crates/event/src/social.rs", + "crates/event/src/tags.rs", + "crates/event/src/trade.rs", + "crates/event/src/wire.rs", + "crates/event/src/wire/v1.rs", + "crates/event_codec/src/admission/registry_v7.rs", + "crates/event_codec/src/comment/inbound.rs", + "crates/event_codec/src/comment/inbound/registry_v7.rs", + "crates/event_codec/src/deletion/mod.rs", + "crates/event_codec/src/deletion/reconciliation_v1.rs", + "crates/event_codec/src/food_availability/inbound.rs", + "crates/event_codec/src/food_availability/inbound/registry_v7.rs", + "crates/event_codec/src/job/traits.rs", + "crates/event_codec/src/post/inbound.rs", + "crates/event_codec/src/post/inbound/registry_v7.rs", + "crates/event_codec/src/profile/inbound.rs", + "crates/event_codec/src/profile/inbound/registry_v7.rs", + "crates/event_codec/src/reply/inbound.rs", + "crates/event_codec/src/reply/inbound/registry_v7.rs", + "crates/event_codec/src/verification.rs", + "crates/event_codec/src/verification/v1.rs", + "crates/event_store/Cargo.toml", + "crates/event_store/src/error.rs", + "crates/event_store/src/generated.rs", + "crates/event_store/src/lib.rs", + "crates/event_store/src/migrations.rs", + "crates/event_store/src/model.rs", + "crates/event_store/src/model/addressable_transition_feed_v1.rs", + "crates/event_store/src/model/food_availability_projection_v1.rs", + "crates/event_store/src/model/ingest_reconciliation_v1.rs", + "crates/event_store/src/model/reconciliation_v1.rs", + "crates/event_store/src/nip09/reconciliation_v1.rs", + "crates/event_store/src/schema.rs", + "crates/event_store/src/store.rs", + "crates/event_store/src/store/addressable_transition_feed_v1.rs", + "crates/event_store/src/store/food_availability_projection_v1.rs", + "crates/event_store/src/store/post_core_extensions_v1.rs", + "crates/event_store/src/store/post_core_storage_v1.rs", + "crates/event_store/src/store/protocol_reconciliation_v1.rs", + "crates/event_store/src/store/protocol_storage_v1.rs", +]; + +pub(super) fn predecessor_superseded_source_paths() -> &'static [&'static str] { + PREDECESSOR_SUPERSEDED_SOURCE_PATHS +} + const GENERATED_ARTIFACT_PATHS: &[&str] = &[ MANIFEST_RELATIVE, MANIFEST_SCHEMA_RELATIVE, diff --git a/tools/xtask/src/generate/mod.rs b/tools/xtask/src/generate/mod.rs @@ -0,0 +1,10 @@ +use std::path::Path; + +pub(crate) mod protocol; + +pub(crate) fn run(args: &[String], workspace_root: &Path) -> Result<(), String> { + match args { + [target, mode] if target == "protocol" => protocol::run(mode, workspace_root), + _ => Err("usage: cargo xtask generate protocol --check|--write".to_owned()), + } +} diff --git a/tools/xtask/src/generate/protocol.rs b/tools/xtask/src/generate/protocol.rs @@ -0,0 +1,642 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + io::{ErrorKind, Write}, + path::{Component, Path, PathBuf}, +}; + +use quote::ToTokens; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use syn::{Item, Type, Visibility}; +use tempfile::NamedTempFile; + +const CONFIG_PATH: &str = "contracts/codegen/protocol_v1.toml"; +const PACKAGE: &str = "radroots_protocol"; +const HASH_ALGORITHM: &str = "sha256_bytes_v1"; +const EXPECTED_SOURCES: &[(&str, &str)] = &[ + ("capability::v1", "crates/protocol/src/capability/v1.rs"), + ("error::v1", "crates/protocol/src/error/v1.rs"), + ("event::v1", "crates/protocol/src/event/v1.rs"), + ( + "radrootsd::transport_publish::v5", + "crates/protocol/src/radrootsd/transport_publish/v5.rs", + ), + ("runtime::v1", "crates/protocol/src/runtime/v1.rs"), +]; +const EXPECTED_MACRO_GENERATED_TYPES: &[(&str, &str, &str, &str, &str)] = &[( + "runtime::v1", + "crates/protocol/src/runtime/v1.rs", + "operation_ids", + "OperationId", + "enum", +)]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Config { + schema_version: u16, + package: String, + source_hash_algorithm: String, + inventory_path: String, + inventory_sha256_path: String, + source: Vec<SourceConfig>, + #[serde(default)] + macro_generated_type: Vec<MacroGeneratedTypeConfig>, +} + +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)] +#[serde(deny_unknown_fields)] +struct SourceConfig { + module: String, + path: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)] +#[serde(deny_unknown_fields)] +struct MacroGeneratedTypeConfig { + module: String, + path: String, + macro_name: String, + rust_name: String, + kind: String, +} + +#[derive(Debug, Serialize)] +struct Inventory { + schema_version: u16, + generator: &'static str, + package: &'static str, + source_hash_algorithm: &'static str, + sources: Vec<SourceInventory>, + schemas: Vec<SchemaInventory>, +} + +#[derive(Debug, Serialize)] +struct SourceInventory { + module: String, + path: String, + sha256: String, + types: Vec<TypeInventory>, +} + +#[derive(Debug, Serialize)] +struct TypeInventory { + rust_path: String, + kind: &'static str, +} + +#[derive(Clone, Debug, Serialize)] +struct SchemaInventory { + schema_id: String, + module: String, + generation: u16, +} + +struct GeneratedFile { + path: PathBuf, + display_path: String, + bytes: Vec<u8>, +} + +pub(crate) fn run(mode: &str, workspace_root: &Path) -> Result<(), String> { + let config = load_config(workspace_root)?; + validate_config(&config)?; + let schemas = protocol_schemas()?; + let generated = render_outputs(workspace_root, &config, schemas)?; + match mode { + "--check" => check_outputs(&generated), + "--write" => write_outputs(&generated), + _ => Err("usage: cargo xtask generate protocol --check|--write".to_owned()), + } +} + +pub(crate) fn check(workspace_root: &Path) -> Result<(), String> { + run("--check", workspace_root) +} + +fn load_config(workspace_root: &Path) -> Result<Config, String> { + let path = safe_workspace_file(workspace_root, CONFIG_PATH, false, "protocol codegen input")?; + let source = fs::read_to_string(&path) + .map_err(|error| format!("failed to read `{CONFIG_PATH}`: {error}"))?; + toml::from_str(&source).map_err(|error| format!("invalid `{CONFIG_PATH}`: {error}")) +} + +fn validate_config(config: &Config) -> Result<(), String> { + if config.schema_version != 1 { + return Err("protocol codegen schema_version must be 1".to_owned()); + } + if config.package != PACKAGE { + return Err(format!("protocol codegen package must be `{PACKAGE}`")); + } + if config.source_hash_algorithm != HASH_ALGORITHM { + return Err(format!( + "protocol codegen source_hash_algorithm must be `{HASH_ALGORITHM}`" + )); + } + if config.inventory_path != "contracts/codegen/protocol_v1.inventory.json" + || config.inventory_sha256_path != "contracts/codegen/protocol_v1.inventory.sha256" + { + return Err("protocol codegen output paths are fixed by contract".to_owned()); + } + + let mut actual = config + .source + .iter() + .map(|source| (source.module.as_str(), source.path.as_str())) + .collect::<Vec<_>>(); + actual.sort_unstable(); + if actual != EXPECTED_SOURCES { + return Err(format!( + "protocol codegen source inventory drifted: expected {EXPECTED_SOURCES:?}, found {actual:?}" + )); + } + if config + .source + .iter() + .map(|source| &source.module) + .collect::<BTreeSet<_>>() + .len() + != config.source.len() + { + return Err("protocol codegen modules must be unique".to_owned()); + } + + let mut macro_generated = config + .macro_generated_type + .iter() + .map(|item| { + ( + item.module.as_str(), + item.path.as_str(), + item.macro_name.as_str(), + item.rust_name.as_str(), + item.kind.as_str(), + ) + }) + .collect::<Vec<_>>(); + macro_generated.sort_unstable(); + if macro_generated != EXPECTED_MACRO_GENERATED_TYPES { + return Err(format!( + "protocol macro-generated type inventory drifted: expected {EXPECTED_MACRO_GENERATED_TYPES:?}, found {macro_generated:?}" + )); + } + Ok(()) +} + +fn protocol_schemas() -> Result<Vec<SchemaInventory>, String> { + let registry = radroots_protocol::schema::protocol_v1_registry() + .map_err(|error| format!("invalid protocol schema registry: {error}"))?; + Ok(registry + .descriptors() + .iter() + .map(|descriptor| SchemaInventory { + schema_id: descriptor.id().as_str().to_owned(), + module: descriptor.module().path().to_owned(), + generation: descriptor.module().generation(), + }) + .collect()) +} + +fn render_outputs( + workspace_root: &Path, + config: &Config, + schemas: Vec<SchemaInventory>, +) -> Result<Vec<GeneratedFile>, String> { + let mut sources = config.source.clone(); + sources.sort(); + let mut inventories = Vec::with_capacity(sources.len()); + for source in sources { + let path = safe_workspace_file(workspace_root, &source.path, false, "protocol DTO source")?; + let bytes = fs::read(&path).map_err(|error| { + format!( + "failed to read protocol DTO source `{}`: {error}", + source.path + ) + })?; + let text = std::str::from_utf8(&bytes).map_err(|error| { + format!( + "protocol DTO source `{}` is not UTF-8: {error}", + source.path + ) + })?; + let mut types = serialized_public_types(&source.module, &source.path, text)?; + for generated in config + .macro_generated_type + .iter() + .filter(|generated| generated.module == source.module && generated.path == source.path) + { + types.push(macro_generated_serialized_type( + generated, + source.path.as_str(), + text, + )?); + } + types.sort_by(|left, right| left.rust_path.cmp(&right.rust_path)); + if types + .windows(2) + .any(|pair| pair[0].rust_path == pair[1].rust_path) + { + return Err(format!( + "protocol DTO source `{}` exposes duplicate serialized type inventory entries", + source.path + )); + } + inventories.push(SourceInventory { + module: source.module.clone(), + path: source.path.clone(), + sha256: sha256_hex(&bytes), + types, + }); + } + + let inventory = Inventory { + schema_version: 1, + generator: "radroots_xtask.protocol_codegen.v1", + package: PACKAGE, + source_hash_algorithm: HASH_ALGORITHM, + sources: inventories, + schemas, + }; + let mut inventory_bytes = serde_json::to_vec_pretty(&inventory) + .map_err(|error| format!("failed to serialize protocol DTO inventory: {error}"))?; + inventory_bytes.push(b'\n'); + let digest_bytes = format!("{}\n", sha256_hex(&inventory_bytes)).into_bytes(); + + Ok(vec![ + GeneratedFile { + path: safe_workspace_file( + workspace_root, + &config.inventory_path, + true, + "protocol DTO inventory output", + )?, + display_path: config.inventory_path.clone(), + bytes: inventory_bytes, + }, + GeneratedFile { + path: safe_workspace_file( + workspace_root, + &config.inventory_sha256_path, + true, + "protocol DTO inventory digest output", + )?, + display_path: config.inventory_sha256_path.clone(), + bytes: digest_bytes, + }, + ]) +} + +fn macro_generated_serialized_type( + config: &MacroGeneratedTypeConfig, + source_path: &str, + source: &str, +) -> Result<TypeInventory, String> { + let syntax = syn::parse_file(source) + .map_err(|error| format!("failed to parse protocol DTO source `{source_path}`: {error}"))?; + let definition = syntax.items.iter().find_map(|item| match item { + Item::Macro(item) + if item + .ident + .as_ref() + .is_some_and(|name| name == &config.macro_name) => + { + Some(item.mac.tokens.to_string()) + } + _ => None, + }); + let Some(definition) = definition else { + return Err(format!( + "protocol DTO source `{source_path}` does not define configured macro `{}`", + config.macro_name + )); + }; + let invoked = syntax.items.iter().any(|item| { + matches!(item, Item::Macro(item) if item.ident.is_none() && item.mac.path.is_ident(&config.macro_name)) + }); + if !invoked { + return Err(format!( + "protocol DTO source `{source_path}` does not invoke configured macro `{}`", + config.macro_name + )); + } + let compact_definition = definition.split_whitespace().collect::<String>(); + for required in [ + format!("pub{}{}", config.kind, config.rust_name), + format!("implserde::Serializefor{}", config.rust_name), + format!("impl<'de>serde::Deserialize<'de>for{}", config.rust_name), + ] { + if !compact_definition.contains(&required) { + return Err(format!( + "configured macro `{}` in `{source_path}` does not prove serialized public {} `{}`: missing `{required}`", + config.macro_name, config.kind, config.rust_name + )); + } + } + Ok(TypeInventory { + rust_path: format!("radroots_protocol::{}::{}", config.module, config.rust_name), + kind: match config.kind.as_str() { + "enum" => "enum", + "struct" => "struct", + other => { + return Err(format!( + "unsupported macro-generated protocol DTO kind `{other}`" + )); + } + }, + }) +} + +fn serialized_public_types( + module: &str, + source_path: &str, + source: &str, +) -> Result<Vec<TypeInventory>, String> { + let syntax = syn::parse_file(source) + .map_err(|error| format!("failed to parse protocol DTO source `{source_path}`: {error}"))?; + let manual_serializers = syntax + .items + .iter() + .filter_map(manual_serialize_target) + .collect::<BTreeSet<_>>(); + let mut types = BTreeMap::new(); + for item in &syntax.items { + let (name, kind, visibility, attributes) = match item { + Item::Enum(item) => (&item.ident, "enum", &item.vis, &item.attrs), + Item::Struct(item) => (&item.ident, "struct", &item.vis, &item.attrs), + _ => continue, + }; + if !matches!(visibility, Visibility::Public(_)) { + continue; + } + let name = name.to_string(); + let derives_serialize = attributes.iter().any(|attribute| { + attribute + .meta + .to_token_stream() + .to_string() + .contains("Serialize") + }); + if derives_serialize || manual_serializers.contains(&name) { + types.insert( + name.clone(), + TypeInventory { + rust_path: format!("radroots_protocol::{module}::{name}"), + kind, + }, + ); + } + } + if types.is_empty() { + return Err(format!( + "protocol DTO source `{source_path}` exposes no serialized public types" + )); + } + Ok(types.into_values().collect()) +} + +fn manual_serialize_target(item: &Item) -> Option<String> { + let Item::Impl(item) = item else { + return None; + }; + let (_, trait_path, _) = item.trait_.as_ref()?; + if trait_path.segments.last()?.ident != "Serialize" { + return None; + } + let Type::Path(self_type) = item.self_ty.as_ref() else { + return None; + }; + Some(self_type.path.segments.last()?.ident.to_string()) +} + +fn check_outputs(generated: &[GeneratedFile]) -> Result<(), String> { + let stale = generated + .iter() + .filter_map(|output| match fs::read(&output.path) { + Ok(actual) if actual == output.bytes => None, + Ok(_) => Some(format!("stale `{}`", output.display_path)), + Err(error) if error.kind() == ErrorKind::NotFound => { + Some(format!("missing `{}`", output.display_path)) + } + Err(error) => Some(format!("unreadable `{}`: {error}", output.display_path)), + }) + .collect::<Vec<_>>(); + if stale.is_empty() { + Ok(()) + } else { + Err(format!( + "generated protocol DTO inventory is not fresh:\n- {}\nrun `cargo xtask generate protocol --write`", + stale.join("\n- ") + )) + } +} + +fn write_outputs(generated: &[GeneratedFile]) -> Result<(), String> { + let mut staged = Vec::new(); + for output in generated { + if fs::read(&output.path).is_ok_and(|actual| actual == output.bytes) { + continue; + } + let parent = output + .path + .parent() + .ok_or_else(|| format!("generated output has no parent: `{}`", output.display_path))?; + let mut temporary = NamedTempFile::new_in(parent) + .map_err(|error| format!("failed to stage `{}`: {error}", output.display_path))?; + temporary + .write_all(&output.bytes) + .map_err(|error| format!("failed to stage `{}`: {error}", output.display_path))?; + set_generated_permissions(temporary.path()).map_err(|error| { + format!( + "failed to set generated permissions for `{}`: {error}", + output.display_path + ) + })?; + temporary + .as_file() + .sync_all() + .map_err(|error| format!("failed to sync `{}`: {error}", output.display_path))?; + staged.push((output, temporary)); + } + for (output, temporary) in staged { + temporary.persist(&output.path).map_err(|error| { + format!( + "failed to commit `{}`: {}", + output.display_path, error.error + ) + })?; + } + check_outputs(generated) +} + +#[cfg(unix)] +fn set_generated_permissions(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o644)) +} + +#[cfg(not(unix))] +fn set_generated_permissions(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +fn safe_workspace_file( + workspace_root: &Path, + relative: &str, + allow_missing: bool, + role: &str, +) -> Result<PathBuf, String> { + let path = Path::new(relative); + if relative.is_empty() + || relative.contains('\\') + || path.is_absolute() + || !path + .components() + .all(|component| matches!(component, Component::Normal(_))) + { + return Err(format!( + "{role} path must be normalized and workspace-relative: `{relative}`" + )); + } + let mut current = workspace_root.to_path_buf(); + let count = path.components().count(); + for (index, component) in path.components().enumerate() { + let Component::Normal(segment) = component else { + return Err(format!("{role} path is not normalized: `{relative}`")); + }; + current.push(segment); + match current.symlink_metadata() { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "{role} path contains a symlink: `{}`", + current.display() + )); + } + Ok(metadata) if index + 1 == count && !metadata.is_file() => { + return Err(format!("{role} must be a regular file: `{relative}`")); + } + Ok(_) => {} + Err(error) + if error.kind() == ErrorKind::NotFound && allow_missing && index + 1 == count => {} + Err(error) => { + return Err(format!("failed to inspect {role} `{relative}`: {error}")); + } + } + } + Ok(current) +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn public_serialized_type_inventory_is_sorted_and_excludes_native_types() { + let types = serialized_public_types( + "demo::v1", + "demo.rs", + r#" +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Zebra { pub value: String } +pub struct NativeError; +pub struct Alpha(String); +impl serde::Serialize for Alpha {} +struct PrivateWire; +"#, + ) + .expect("inventory"); + assert_eq!( + types + .iter() + .map(|item| item.rust_path.as_str()) + .collect::<Vec<_>>(), + [ + "radroots_protocol::demo::v1::Alpha", + "radroots_protocol::demo::v1::Zebra" + ] + ); + } + + #[test] + fn generated_bytes_and_freshness_check_are_deterministic() { + let workspace = TempDir::new().expect("workspace"); + let root = workspace.path(); + fs::create_dir_all(root.join("src")).expect("source directory"); + fs::create_dir_all(root.join("out")).expect("output directory"); + fs::write( + root.join("src/types.rs"), + "#[derive(serde::Serialize)] pub struct Demo { pub value: String }\n", + ) + .expect("source"); + let config = Config { + schema_version: 1, + package: PACKAGE.to_owned(), + source_hash_algorithm: HASH_ALGORITHM.to_owned(), + inventory_path: "out/inventory.json".to_owned(), + inventory_sha256_path: "out/inventory.sha256".to_owned(), + source: vec![SourceConfig { + module: "demo::v1".to_owned(), + path: "src/types.rs".to_owned(), + }], + macro_generated_type: Vec::new(), + }; + let schemas = vec![SchemaInventory { + schema_id: "demo.message.v1".to_owned(), + module: "demo::v1".to_owned(), + generation: 1, + }]; + let first = render_outputs(root, &config, schemas.clone()).expect("first render"); + let second = render_outputs(root, &config, schemas).expect("second render"); + assert_eq!(first[0].bytes, second[0].bytes); + assert_eq!(first[1].bytes, second[1].bytes); + write_outputs(&first).expect("write"); + check_outputs(&second).expect("fresh"); + fs::write(root.join("out/inventory.json"), "stale\n").expect("drift"); + let error = check_outputs(&second).expect_err("reject drift"); + assert!(error.contains("stale `out/inventory.json`")); + } + + #[test] + fn normalized_paths_reject_escape_and_symlinks() { + let workspace = TempDir::new().expect("workspace"); + for path in ["", "../escape", "/absolute", "a/./b", "a\\b"] { + assert!(safe_workspace_file(workspace.path(), path, true, "fixture").is_err()); + } + } + + #[test] + fn macro_generated_serialized_type_requires_definition_invocation_and_serde() { + let config = MacroGeneratedTypeConfig { + module: "demo::v1".to_owned(), + path: "demo.rs".to_owned(), + macro_name: "generated_ids".to_owned(), + rust_name: "GeneratedId".to_owned(), + kind: "enum".to_owned(), + }; + let source = r#" +macro_rules! generated_ids { + () => { + pub enum GeneratedId { One } + impl serde::Serialize for GeneratedId {} + impl<'de> serde::Deserialize<'de> for GeneratedId {} + }; +} +generated_ids!(); +"#; + let item = macro_generated_serialized_type(&config, "demo.rs", source) + .expect("macro-generated serialized type"); + assert_eq!(item.rust_path, "radroots_protocol::demo::v1::GeneratedId"); + assert_eq!(item.kind, "enum"); + + let missing_serde = source.replace("impl serde::Serialize for GeneratedId {}", ""); + let error = macro_generated_serialized_type(&config, "demo.rs", &missing_serde) + .expect_err("missing serializer must fail closed"); + assert!(error.contains("does not prove serialized public enum `GeneratedId`")); + } +} diff --git a/tools/xtask/src/hygiene.rs b/tools/xtask/src/hygiene.rs @@ -343,7 +343,10 @@ fn reject_retired_listing_aliases(root: &Path, failures: &mut Vec<String>) { line.trim() )); } - if is_listing_module_scope(&rel) && contains_retired_listing_module_reference(line) { + if is_listing_module_scope(&rel) + && !is_canonical_event_listing_module_reference(&rel, line) + && contains_retired_listing_module_reference(line) + { failures.push(format!( "retired listing public aliases must not reappear: {}:{}: {}", rel, @@ -379,6 +382,7 @@ fn is_identifier_continue(ch: char) -> bool { fn is_retired_listing_module_path(rel: &str) -> bool { is_listing_module_scope(rel) + && !is_canonical_event_listing_module_path(rel) && Path::new(rel).components().any(|component| { component.as_os_str() == "listing" || Path::new(component.as_os_str()) @@ -387,6 +391,10 @@ fn is_retired_listing_module_path(rel: &str) -> bool { }) } +fn is_canonical_event_listing_module_path(rel: &str) -> bool { + rel == "crates/event/src/listing.rs" || rel.starts_with("crates/event/src/listing/") +} + fn is_listing_module_scope(rel: &str) -> bool { rel.starts_with("crates/event/src/") || rel.starts_with("crates/event/tests/") @@ -408,6 +416,10 @@ fn contains_retired_listing_module_reference(line: &str) -> bool { }) } +fn is_canonical_event_listing_module_reference(rel: &str, line: &str) -> bool { + rel == "crates/event/src/lib.rs" && line.trim() == "pub mod listing;" +} + fn is_retired_listing_negative_guard(rel: &str, line: &str) -> bool { (rel == "crates/event/src/dto.rs" && line.contains("\"RadrootsListingCancel\"")) || (rel == "crates/event/src/contract/registry_v7/tests.rs" @@ -747,6 +759,12 @@ mod tests { #[test] fn listing_alias_guard_is_token_and_path_aware() { let clean_root = unique_temp_dir("listing_alias_clean"); + write_file(&clean_root, "crates/event/src/lib.rs", "pub mod listing;\n"); + write_file( + &clean_root, + "crates/event/src/listing.rs", + "pub struct CanonicalListing;\n", + ); write_file( &clean_root, "crates/event/src/kinds.rs", diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -3,11 +3,15 @@ #![recursion_limit = "256"] #[cfg_attr(coverage_nightly, coverage(off))] +mod architecture; +#[cfg_attr(coverage_nightly, coverage(off))] mod contract; mod coverage; #[cfg_attr(coverage_nightly, coverage(off))] mod dto_roots; #[cfg_attr(coverage_nightly, coverage(off))] +mod generate; +#[cfg_attr(coverage_nightly, coverage(off))] mod hygiene; use std::env; @@ -16,6 +20,10 @@ use std::process::ExitCode; fn usage() { eprintln!("usage:"); + eprintln!(" cargo xtask architecture"); + eprintln!(" cargo xtask architecture-ci"); + eprintln!(" cargo xtask check-api-boundaries"); + eprintln!(" cargo xtask check-dependency-boundaries"); eprintln!(" cargo xtask contract validate"); eprintln!(" cargo xtask contract event-contract-registry-v7 [--write]"); eprintln!(" cargo xtask contract nip09-reconciliation-manifest [--write]"); @@ -32,6 +40,7 @@ fn usage() { eprintln!(" cargo xtask contract release-provenance-schema [--write]"); eprintln!(" cargo xtask contract knowledge-manifest [--write]"); eprintln!(" cargo xtask dto-roots --check|--write"); + eprintln!(" cargo xtask generate protocol --check|--write"); eprintln!(" cargo xtask release preflight"); eprintln!(" cargo xtask release provenance --package-dir <dir> --out <outside-worktree-file>"); eprintln!(" cargo xtask coverage run-crate --crate <crate> [--out <dir>]"); @@ -67,11 +76,24 @@ fn workspace_root() -> PathBuf { workspace_root_with_override(override_root.as_deref()) } -fn validate_contract() -> Result<(), String> { - radroots_protocol_contract_v1::validate_protocol_contract_v1() +fn validate_protocol_contracts() -> Result<(), String> { + use radroots_protocol::{capability, event, runtime, schema}; + + capability::v1::validate_catalog(capability::v1::CATALOG).map_err(|error| error.to_string())?; + event::v1::validate_catalog(event::v1::CATALOG).map_err(|error| error.to_string())?; + event::v1::validate_trade_state_vocabulary(event::v1::TRADE_STATE_VOCABULARY) .map_err(|error| error.to_string())?; + runtime::v1::validate_catalog(runtime::v1::CATALOG).map_err(|error| error.to_string())?; + schema::protocol_v1_registry() + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn validate_contract() -> Result<(), String> { + validate_protocol_contracts()?; let root = workspace_root(); dto_roots::check(&root)?; + generate::protocol::check(&root)?; contract::load_contract_bundle(&root) .and_then(|bundle| contract::validate_contract_bundle(&bundle)) .and_then(|_| contract::validate_canonical_event_boundary(&root)) @@ -86,6 +108,7 @@ fn release_preflight() -> Result<(), String> { fn release_preflight_at(root: &Path) -> Result<(), String> { dto_roots::check(root)?; + generate::protocol::check(root)?; contract::validate_artifact_contracts(root)?; contract::validate_release_preflight(root)?; #[cfg(not(test))] @@ -273,9 +296,21 @@ fn run_contract(args: &[String]) -> Result<(), String> { fn run(args: &[String]) -> Result<(), String> { match args.first().map(String::as_str) { + Some("architecture") if args.len() == 1 => architecture::validate(&workspace_root()), + Some("architecture-ci") if args.len() == 1 => { + architecture::validate_ci(&workspace_root())?; + validate_contract() + } + Some("check-api-boundaries") if args.len() == 1 => { + architecture::validate_api_boundaries(&workspace_root()) + } + Some("check-dependency-boundaries") if args.len() == 1 => { + architecture::validate_dependency_boundaries(&workspace_root()) + } Some("contract") => run_contract(&args[1..]), Some("coverage") => coverage::run(&args[1..]), Some("dto-roots") => dto_roots::run(&args[1..], &workspace_root()), + Some("generate") => generate::run(&args[1..], &workspace_root()), Some("hygiene") => hygiene::run(&args[1..], &workspace_root()), Some("release") => run_release(&args[1..]), _ => Err("unknown command".to_string()), @@ -440,9 +475,14 @@ mod tests { let unknown_root = run(&["unknown".to_string()]).expect_err("unknown command"); assert!(unknown_root.contains("unknown command")); + run(&["architecture".to_string()]).expect("architecture ledger validates"); + let invalid_dto_roots = run(&["dto-roots".to_string()]).expect_err("dto-roots requires an explicit mode"); assert!(invalid_dto_roots.contains("--check|--write")); + let invalid_generate = + run(&["generate".to_string()]).expect_err("generate requires a target and mode"); + assert!(invalid_generate.contains("generate protocol --check|--write")); let removed_sdk = run(&["sdk".to_string(), "validate".to_string()]) .expect_err("removed sdk command namespace"); @@ -477,6 +517,12 @@ mod tests { run_contract(&["validate".to_string()]).expect("validate contract"); run(&["dto-roots".to_string(), "--check".to_string()]) .expect("validate DTO root freshness"); + run(&[ + "generate".to_string(), + "protocol".to_string(), + "--check".to_string(), + ]) + .expect("validate protocol DTO inventory freshness"); coverage::run(&["help".to_string()]).expect("coverage help"); coverage::run(&["required-crates".to_string()]).expect("coverage required crates"); coverage::run(&["workspace-crates".to_string()]).expect("coverage workspace crates"); @@ -526,13 +572,15 @@ mod tests { #[test] fn run_contract_dispatches_validate_command() { let _guard = lock_workspace(); + validate_protocol_contracts().expect("final protocol contract catalogs"); run_contract(&["validate".to_string()]).expect("contract validate"); run_contract(&["event-contract-registry-v7".to_string()]) .expect("contract registry-v7 inventory"); run_contract(&["nip09-reconciliation-manifest".to_string()]) .expect("contract NIP-09 reconciliation manifest"); - run_contract(&["food-availability-projection-manifest".to_string()]) - .expect("contract FoodAvailability projection manifest"); + let food_error = run_contract(&["food-availability-projection-manifest".to_string()]) + .expect_err("superseded FoodAvailability predecessor executor must stay immutable"); + assert!(food_error.contains("immutable FoodAvailability predecessor artifact")); run_contract(&["source-maintenance-manifest".to_string()]) .expect("contract SourceMaintenance manifest"); run_contract(&["raw-source-rebuild-manifest".to_string()]) diff --git a/tools/xtask/tests/fixtures/api-leakage/adr-exception.rs b/tools/xtask/tests/fixtures/api-leakage/adr-exception.rs @@ -0,0 +1,3 @@ +pub fn temporary_client() -> reqwest::Client { + unreachable!() +} diff --git a/tools/xtask/tests/fixtures/api-leakage/allowed-concrete-adapter.rs b/tools/xtask/tests/fixtures/api-leakage/allowed-concrete-adapter.rs @@ -0,0 +1,3 @@ +pub fn protocol_event(event: nostr::Event) -> nostr::Event { + event +} diff --git a/tools/xtask/tests/fixtures/api-leakage/generic-renamed-tokio.rs b/tools/xtask/tests/fixtures/api-leakage/generic-renamed-tokio.rs @@ -0,0 +1,5 @@ +use tokio::runtime::Handle as Executor; + +pub fn executor() -> Executor { + unreachable!() +} diff --git a/tools/xtask/tests/fixtures/api-leakage/generic-sqlx.rs b/tools/xtask/tests/fixtures/api-leakage/generic-sqlx.rs @@ -0,0 +1,5 @@ +use sqlx::SqlitePool; + +pub struct StorageHandle { + pub pool: SqlitePool, +} diff --git a/tools/xtask/tests/fixtures/api-leakage/private-implementation.rs b/tools/xtask/tests/fixtures/api-leakage/private-implementation.rs @@ -0,0 +1,11 @@ +use reqwest::Client; + +pub struct Adapter { + client: Client, +} + +impl Adapter { + fn client(&self) -> &Client { + &self.client + } +} diff --git a/tools/xtask/tests/fixtures/dependency-boundaries/domain-to-storage.json b/tools/xtask/tests/fixtures/dependency-boundaries/domain-to-storage.json @@ -0,0 +1,46 @@ +{ + "packages": [ + { + "id": "fixture#radroots_event@0.1.0", + "name": "radroots_event", + "dependencies": [ + { + "name": "radroots_storage", + "rename": "storage_alias", + "kind": null, + "features": ["memory"], + "target": "cfg(unix)", + "uses_default_features": false + } + ] + }, + { + "id": "fixture#radroots_storage@0.1.0", + "name": "radroots_storage", + "dependencies": [] + } + ], + "resolve": { + "nodes": [ + { + "id": "fixture#radroots_event@0.1.0", + "deps": [ + { + "name": "storage_alias", + "pkg": "fixture#radroots_storage@0.1.0", + "dep_kinds": [ + { + "kind": null, + "target": "cfg(unix)" + } + ] + } + ] + }, + { + "id": "fixture#radroots_storage@0.1.0", + "deps": [] + } + ] + } +} diff --git a/tools/xtask/tests/fixtures/dependency-boundaries/service-to-sdk.json b/tools/xtask/tests/fixtures/dependency-boundaries/service-to-sdk.json @@ -0,0 +1,46 @@ +{ + "packages": [ + { + "id": "fixture#radroots_sync@0.1.0", + "name": "radroots_sync", + "dependencies": [ + { + "name": "radroots_sdk", + "rename": "client_engine", + "kind": null, + "features": ["memory"], + "target": null, + "uses_default_features": false + } + ] + }, + { + "id": "fixture#radroots_sdk@0.1.0", + "name": "radroots_sdk", + "dependencies": [] + } + ], + "resolve": { + "nodes": [ + { + "id": "fixture#radroots_sync@0.1.0", + "deps": [ + { + "name": "client_engine", + "pkg": "fixture#radroots_sdk@0.1.0", + "dep_kinds": [ + { + "kind": null, + "target": null + } + ] + } + ] + }, + { + "id": "fixture#radroots_sdk@0.1.0", + "deps": [] + } + ] + } +} diff --git a/tools/xtask/tests/fixtures/dependency-boundaries/spi-to-adapter.json b/tools/xtask/tests/fixtures/dependency-boundaries/spi-to-adapter.json @@ -0,0 +1,46 @@ +{ + "packages": [ + { + "id": "fixture#radroots_transport@0.1.0", + "name": "radroots_transport", + "dependencies": [ + { + "name": "radroots_nostr", + "rename": "protocol_adapter", + "kind": "build", + "features": ["events"], + "target": "cfg(target_arch = \"wasm32\")", + "uses_default_features": false + } + ] + }, + { + "id": "fixture#radroots_nostr@0.1.0", + "name": "radroots_nostr", + "dependencies": [] + } + ], + "resolve": { + "nodes": [ + { + "id": "fixture#radroots_transport@0.1.0", + "deps": [ + { + "name": "protocol_adapter", + "pkg": "fixture#radroots_nostr@0.1.0", + "dep_kinds": [ + { + "kind": "build", + "target": "cfg(target_arch = \"wasm32\")" + } + ] + } + ] + }, + { + "id": "fixture#radroots_nostr@0.1.0", + "deps": [] + } + ] + } +} diff --git a/tools/xtask/tests/fixtures/dependency-boundaries/valid-downward.json b/tools/xtask/tests/fixtures/dependency-boundaries/valid-downward.json @@ -0,0 +1,46 @@ +{ + "packages": [ + { + "id": "fixture#radroots_transport_nostr@0.1.0", + "name": "radroots_transport_nostr", + "dependencies": [ + { + "name": "radroots_transport", + "rename": "transport_spi", + "kind": "dev", + "features": [], + "target": "cfg(unix)", + "uses_default_features": false + } + ] + }, + { + "id": "fixture#radroots_transport@0.1.0", + "name": "radroots_transport", + "dependencies": [] + } + ], + "resolve": { + "nodes": [ + { + "id": "fixture#radroots_transport_nostr@0.1.0", + "deps": [ + { + "name": "transport_spi", + "pkg": "fixture#radroots_transport@0.1.0", + "dep_kinds": [ + { + "kind": "dev", + "target": "cfg(unix)" + } + ] + } + ] + }, + { + "id": "fixture#radroots_transport@0.1.0", + "deps": [] + } + ] + } +}