sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit d53fc30d91f923d934e714c61762336a68b162f0
parent 90caf3a977c00df1acb00b2f2aa19befe576d655
Author: triesap <tyson@radroots.org>
Date:   Thu,  6 Aug 2026 19:27:47 +0000

sdk: retire duplicated rust implementation

- remove reusable Rust now owned by canonical lib
- delete the superseded SDK-local generator and fixtures
- retire obsolete Rust release and coverage contracts
- preserve product packages generated bindings and rollback history

Diffstat:
Dcontracts/coverage.toml | 76----------------------------------------------------------------------------
Dcontracts/releases/api_boundaries.toml | 156-------------------------------------------------------------------------------
Dcontracts/releases/api_semver.toml | 8--------
Dcontracts/releases/package_tiers.toml | 78------------------------------------------------------------------------------
Dcontracts/releases/portable_matrix.toml | 7-------
Dcontracts/releases/publication.toml | 67-------------------------------------------------------------------
Dcontracts/releases/supply_chain.toml | 49-------------------------------------------------
Dcontracts/releases/target_matrix.toml | 18------------------
Dcrates/core_bindings/Cargo.toml | 15---------------
Dcrates/core_bindings/src/dto.rs | 139-------------------------------------------------------------------------------
Dcrates/core_bindings/src/lib.rs | 20--------------------
Dcrates/event_bindings/Cargo.toml | 14--------------
Dcrates/event_bindings/src/lib.rs | 28----------------------------
Dcrates/event_bindings/src/model.rs | 161-------------------------------------------------------------------------------
Dcrates/event_codec_wasm/Cargo.toml | 41-----------------------------------------
Dcrates/event_codec_wasm/README | 24------------------------
Dcrates/event_codec_wasm/src/lib.rs | 2184-------------------------------------------------------------------------------
Dcrates/ffi/Cargo.toml | 30------------------------------
Dcrates/ffi/README.md | 10----------
Dcrates/ffi/src/bin/bindgen.rs | 3---
Dcrates/ffi/src/lib.rs | 173-------------------------------------------------------------------------------
Dcrates/identity_bindings/Cargo.toml | 14--------------
Dcrates/identity_bindings/src/lib.rs | 37-------------------------------------
Dcrates/radroots/Cargo.toml | 51---------------------------------------------------
Dcrates/radroots/LICENSE-APACHE | 201-------------------------------------------------------------------------------
Dcrates/radroots/LICENSE-MIT | 21---------------------
Dcrates/radroots/README.md | 94-------------------------------------------------------------------------------
Dcrates/radroots/examples/ordinary_memory.rs | 10----------
Dcrates/radroots/src/client.rs | 145-------------------------------------------------------------------------------
Dcrates/radroots/src/event.rs | 5-----
Dcrates/radroots/src/farm.rs | 4----
Dcrates/radroots/src/identity.rs | 5-----
Dcrates/radroots/src/knowledge.rs | 8--------
Dcrates/radroots/src/lib.rs | 18------------------
Dcrates/radroots/src/listing.rs | 12------------
Dcrates/radroots/src/signing.rs | 3---
Dcrates/radroots/src/storage.rs | 6------
Dcrates/radroots/src/sync.rs | 1-
Dcrates/radroots/src/trade.rs | 13-------------
Dcrates/radroots/src/transport.rs | 10----------
Dcrates/radroots/tests/package_boundary.rs | 93-------------------------------------------------------------------------------
Dcrates/radroots/tests/public_surface.rs | 120-------------------------------------------------------------------------------
Dcrates/replica_schema_bindings/Cargo.toml | 14--------------
Dcrates/replica_schema_bindings/src/lib.rs | 31-------------------------------
Dcrates/replica_store_wasm/Cargo.toml | 33---------------------------------
Dcrates/replica_store_wasm/README | 25-------------------------
Dcrates/replica_store_wasm/src/lib.rs | 10----------
Dcrates/replica_store_wasm/src/snapshot.rs | 87-------------------------------------------------------------------------------
Dcrates/replica_store_wasm/src/utils.rs | 13-------------
Dcrates/replica_store_wasm/src/wasm_impl.rs | 923-------------------------------------------------------------------------------
Dcrates/replica_sync_wasm/Cargo.toml | 31-------------------------------
Dcrates/replica_sync_wasm/README | 25-------------------------
Dcrates/replica_sync_wasm/src/lib.rs | 179-------------------------------------------------------------------------------
Dcrates/sdk/Cargo.toml | 123-------------------------------------------------------------------------------
Dcrates/sdk/LICENSE-APACHE | 201-------------------------------------------------------------------------------
Dcrates/sdk/LICENSE-MIT | 21---------------------
Dcrates/sdk/README.md | 171-------------------------------------------------------------------------------
Dcrates/sdk/examples/safe_memory_client.rs | 20--------------------
Dcrates/sdk/examples/transport_profile.rs | 11-----------
Dcrates/sdk/src/adapters/mod.rs | 2--
Dcrates/sdk/src/adapters/radrootsd.rs | 352-------------------------------------------------------------------------------
Dcrates/sdk/src/capability.rs | 423-------------------------------------------------------------------------------
Dcrates/sdk/src/client.rs | 1668-------------------------------------------------------------------------------
Dcrates/sdk/src/diagnostics.rs | 59-----------------------------------------------------------
Dcrates/sdk/src/error.rs | 465-------------------------------------------------------------------------------
Dcrates/sdk/src/farm.rs | 549-------------------------------------------------------------------------------
Dcrates/sdk/src/lib.rs | 44--------------------------------------------
Dcrates/sdk/src/listing.rs | 641-------------------------------------------------------------------------------
Dcrates/sdk/src/listing/operational_listing/draft.rs | 548-------------------------------------------------------------------------------
Dcrates/sdk/src/listing/operational_listing/mod.rs | 253-------------------------------------------------------------------------------
Dcrates/sdk/src/listing/operational_listing/model.rs | 15---------------
Dcrates/sdk/src/listing/operational_listing/mutation.rs | 510-------------------------------------------------------------------------------
Dcrates/sdk/src/listing/operational_listing/price_ext.rs | 176-------------------------------------------------------------------------------
Dcrates/sdk/src/listing/operational_listing/validation.rs | 988-------------------------------------------------------------------------------
Dcrates/sdk/src/signing.rs | 532-------------------------------------------------------------------------------
Dcrates/sdk/src/storage.rs | 259-------------------------------------------------------------------------------
Dcrates/sdk/src/sync.rs | 520-------------------------------------------------------------------------------
Dcrates/sdk/src/trade.rs | 868-------------------------------------------------------------------------------
Dcrates/sdk/src/transport.rs | 742-------------------------------------------------------------------------------
Dcrates/sdk/tests/lifecycle.rs | 156-------------------------------------------------------------------------------
Dcrates/sdk/tests/package_boundary.rs | 554-------------------------------------------------------------------------------
Dcrates/sdk/tests/public_api.rs | 172-------------------------------------------------------------------------------
Dcrates/sdk/tests/unit/adapters_radrootsd_tests.rs | 940-------------------------------------------------------------------------------
Dcrates/sql_wasm_runtime/Cargo.toml | 24------------------------
Dcrates/sql_wasm_runtime/src/lib.rs | 534-------------------------------------------------------------------------------
Dcrates/trade_bindings/Cargo.toml | 10----------
Dcrates/trade_bindings/src/lib.rs | 297-------------------------------------------------------------------------------
Ddeny.toml | 67-------------------------------------------------------------------
Dtools/xtask/Cargo.toml | 33---------------------------------
Dtools/xtask/src/api_qualification.rs | 139-------------------------------------------------------------------------------
Dtools/xtask/src/architecture.rs | 1511-------------------------------------------------------------------------------
Dtools/xtask/src/architecture/api_leakage.rs | 1433-------------------------------------------------------------------------------
Dtools/xtask/src/architecture/dependency_boundary.rs | 718-------------------------------------------------------------------------------
Dtools/xtask/src/bindings.rs | 281-------------------------------------------------------------------------------
Dtools/xtask/src/check.rs | 3710-------------------------------------------------------------------------------
Dtools/xtask/src/contracts.rs | 694-------------------------------------------------------------------------------
Dtools/xtask/src/coverage.rs | 156-------------------------------------------------------------------------------
Dtools/xtask/src/coverage_policy.rs | 803-------------------------------------------------------------------------------
Dtools/xtask/src/coverage_policy_tests.rs | 661-------------------------------------------------------------------------------
Dtools/xtask/src/dto_roots.rs | 969-------------------------------------------------------------------------------
Dtools/xtask/src/fs.rs | 61-------------------------------------------------------------
Dtools/xtask/src/generate.rs | 35-----------------------------------
Dtools/xtask/src/main.rs | 281-------------------------------------------------------------------------------
Dtools/xtask/src/manifest.rs | 53-----------------------------------------------------
Dtools/xtask/src/output.rs | 493-------------------------------------------------------------------------------
Dtools/xtask/src/package_matrix.rs | 199-------------------------------------------------------------------------------
Dtools/xtask/src/package_metadata.rs | 181-------------------------------------------------------------------------------
Dtools/xtask/src/portable_qualification.rs | 58----------------------------------------------------------
Dtools/xtask/src/release_graph.rs | 687-------------------------------------------------------------------------------
Dtools/xtask/src/release_qualification.rs | 135-------------------------------------------------------------------------------
Dtools/xtask/src/smoke.rs | 236-------------------------------------------------------------------------------
Dtools/xtask/src/supply_chain_qualification.rs | 729-------------------------------------------------------------------------------
Dtools/xtask/src/target_qualification.rs | 199-------------------------------------------------------------------------------
Dtools/xtask/src/ts.rs | 37-------------------------------------
Dtools/xtask/src/wasm.rs | 1147-------------------------------------------------------------------------------
Dtools/xtask/src/wasm_declarations.rs | 1147-------------------------------------------------------------------------------
Dtools/xtask/tests/fixtures/api-leakage/adr-exception.rs | 3---
Dtools/xtask/tests/fixtures/api-leakage/allowed-concrete-adapter.rs | 3---
Dtools/xtask/tests/fixtures/api-leakage/generic-renamed-tokio.rs | 5-----
Dtools/xtask/tests/fixtures/api-leakage/generic-sqlx.rs | 5-----
Dtools/xtask/tests/fixtures/api-leakage/private-implementation.rs | 11-----------
Dtools/xtask/tests/fixtures/dependency-boundaries/domain-to-storage.json | 46----------------------------------------------
Dtools/xtask/tests/fixtures/dependency-boundaries/service-to-sdk.json | 46----------------------------------------------
Dtools/xtask/tests/fixtures/dependency-boundaries/spi-to-adapter.json | 46----------------------------------------------
Dtools/xtask/tests/fixtures/dependency-boundaries/valid-downward.json | 46----------------------------------------------
125 files changed, 0 insertions(+), 35455 deletions(-)

diff --git a/contracts/coverage.toml b/contracts/coverage.toml @@ -1,76 +0,0 @@ -[policy] -enforce = true -require_regions = true -require_functions = true -require_executable_lines = true -require_branches = true - -[toolchain] -rust = "1.97.1" -coverage_rust = "nightly" -wasm_target = "wasm32-unknown-unknown" - -[report] -output = "target/sdk-coverage/summary.json" -ignore_filename_regex = "(/target/|/\\.cargo/registry/|/Cellar/rust/|/crates/.+_bindings/|/crates/replica_store_wasm/src/wasm_impl.rs|/tools/xtask/src/(check|contracts|coverage|fs|generate|main|output|package_matrix|wasm)\\.rs)" - -[scopes.radroots_sdk] -paths = ["crates/sdk/src/**"] -threshold = 90.0 - -[scopes.radroots] -paths = ["crates/radroots/src/**"] -threshold = 90.0 -branches_applicable = false -branch_reason = "the facade contains straight-line curated constructors and re-exports with no measured branch records" - -[scopes.xtask_policy] -paths = ["tools/xtask/src/coverage_policy.rs"] -threshold = 90.0 - -[scopes.event_codec_wasm] -paths = ["crates/event_codec_wasm/src/**"] -threshold = 90.0 - -[scopes.sql_wasm_runtime] -paths = ["crates/sql_wasm_runtime/src/**"] -threshold = 90.0 - -[scopes.replica_sync_wasm] -paths = ["crates/replica_sync_wasm/src/**"] -threshold = 90.0 - -[scopes.replica_store_snapshot] -paths = ["crates/replica_store_wasm/src/snapshot.rs"] -threshold = 90.0 - -[generated] -typescript = "generated TypeScript is excluded from line coverage and verified by generator tests, generated-output reproducibility, package export checks, and pnpm typecheck; current handwritten package entrypoints only re-export generated surfaces and must gain focused tests if runtime logic is added" -binding_crates = "generated Rust binding crates are excluded from line coverage and verified by generator unit tests plus generated TypeScript reproducibility" -wasm_glue = "wasm-bindgen glue is excluded only while pure SDK behavior is covered by native extracted-logic tests and generated package checks" - -[exclusions.generated_typescript] -paths = ["packages/*/src/generated/**", "packages/*/dist/**"] -reason = "generated package output is checked through cargo xtask check, cargo xtask generate ts, cargo xtask generate wasm, and pnpm typecheck" - -[exclusions.generated_binding_crates] -paths = ["crates/*_bindings/**"] -reason = "binding crates are generator-owned source facades with behavior covered by xtask generator tests" - -[exclusions.wasm_glue_bootstrap] -paths = ["crates/replica_store_wasm/src/wasm_impl.rs"] -reason = "temporary bootstrap exclusion for the generated-style DB wrapper forwarding file while exported snapshot policy and sync parsing behavior are covered natively" - -[exclusions.xtask_command_glue] -paths = [ - "tools/xtask/src/check.rs", - "tools/xtask/src/contracts.rs", - "tools/xtask/src/coverage.rs", - "tools/xtask/src/fs.rs", - "tools/xtask/src/generate.rs", - "tools/xtask/src/main.rs", - "tools/xtask/src/output.rs", - "tools/xtask/src/package_matrix.rs", - "tools/xtask/src/wasm.rs", -] -reason = "xtask command, filesystem, package-generation, and process-runner glue is validated by cargo xtask generate ts, cargo xtask generate wasm, cargo xtask check, package build/typecheck, and cargo xtask coverage run; pure coverage policy enforcement remains under the xtask_policy strict gate" diff --git a/contracts/releases/api_boundaries.toml b/contracts/releases/api_boundaries.toml @@ -1,156 +0,0 @@ -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-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 = 313 -rationale = "Hidden, publish-frozen client signatures remain only for CLI, Myc, and residual first-party cutovers in Steps 271, 288, and 293." - -[[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 = 313 -rationale = "Hidden, publish-frozen aliases remain only for CLI, Myc, and residual first-party cutovers in Steps 271, 288, and 293." - -[[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 = 313 -rationale = "Hidden, publish-frozen URI aliases remain only for CLI, Myc, and residual first-party cutovers in Steps 271, 288, and 293." diff --git a/contracts/releases/api_semver.toml b/contracts/releases/api_semver.toml @@ -1,8 +0,0 @@ -schema_version = 2 -baseline_revision = "5217d73" -package_version = "0.1.0-alpha" -tool = "cargo-public-api" -minimum_tool_version = "0.52.0" -policy = "reviewed-breaking-snapshot" -feature_policy = "all" -packages = ["radroots", "radroots_sdk"] diff --git a/contracts/releases/package_tiers.toml b/contracts/releases/package_tiers.toml @@ -1,78 +0,0 @@ -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/portable_matrix.toml b/contracts/releases/portable_matrix.toml @@ -1,7 +0,0 @@ -schema_version = 1 -no_std_target = "thumbv7em-none-eabihf" -no_std_cross_compiler = "zig cc" -no_std_cross_cflags = "-target thumb-freestanding-eabihf -mcpu=cortex_m4" -wasm_target = "wasm32-unknown-unknown" -portable_public_packages = [] -std_only_public_packages = ["radroots", "radroots_sdk"] diff --git a/contracts/releases/publication.toml b/contracts/releases/publication.toml @@ -1,67 +0,0 @@ -[publication] -frozen = false -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_sdk", "radroots"] -external_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", -] - -[workspace_classification] -private = ["radroots_sdk_ffi"] -build_codegen = [ - "radroots_core_bindings", - "radroots_event_bindings", - "radroots_event_codec_wasm", - "radroots_identity_bindings", - "radroots_replica_schema_bindings", - "radroots_replica_store_wasm", - "radroots_replica_sync_wasm", - "radroots_sdk_sql_wasm_runtime", - "radroots_sdk_xtask", - "radroots_trade_bindings", -] -test_support = [] -preview = [] -retired = [] - -[publish_order] -crates = ["radroots_sdk", "radroots"] diff --git a/contracts/releases/supply_chain.toml b/contracts/releases/supply_chain.toml @@ -1,49 +0,0 @@ -schema_version = 1 -spec_id = "radroots.crates.release.v1" -package_version = "0.1.0-alpha" - -[tools] -cargo_deny = "0.19.8" -cargo_cyclonedx = "0.5.9" - -[sbom] -format = "json" -spec_version = "1.5" -target = "all" -all_features = true -source_date_epoch = 0 - -[[advisory_exception]] -id = "RUSTSEC-2024-0384" -package = "instant" -affected_version = "0.1.13" -introduced_by = "nostr 0.44.7" -classification = "unmaintained" -mitigation = "No Radroots public API exposes instant; replace the dependency when upstream Nostr removes it." -remove_when = "nostr >=0.45.0 stable" - -[[advisory_exception]] -id = "RUSTSEC-2024-0421" -package = "idna" -affected_version = "0.5.0" -introduced_by = "nostr 0.44.7 via url-fork 3.0.1" -classification = "vulnerability" -mitigation = "radroots_transport_nostr canonicalizes every relay URL with url >=2.5.4 before upstream Nostr receives it." -remove_when = "nostr >=0.45.0 stable" - -[[advisory_exception]] -id = "CARGO-YANKED-SPIN-0.9.8" -package = "spin" -affected_version = "0.9.8" -introduced_by = "sqlx 0.9.0 via flume 0.12.0" -classification = "yanked" -mitigation = "The package has no RustSec vulnerability; the gate rejects every yanked name/version except this exact transitive dependency." -remove_when = "sqlx no longer resolves flume 0.12.0 with spin 0.9.8" - -[[package]] -name = "radroots_sdk" -manifest_path = "crates/sdk/Cargo.toml" - -[[package]] -name = "radroots" -manifest_path = "crates/radroots/Cargo.toml" diff --git a/contracts/releases/target_matrix.toml b/contracts/releases/target_matrix.toml @@ -1,18 +0,0 @@ -schema_version = 1 -msrv_toolchain = "1.97.1" -current_toolchain = "stable" - -[[operating_system]] -name = "linux" -target = "x86_64-unknown-linux-gnu" -cross_compiler = "zig cc" -cross_cflags = "-target x86_64-linux-gnu" - -[[operating_system]] -name = "macos" -target = "aarch64-apple-darwin" - -[[operating_system]] -name = "windows" -target = "x86_64-pc-windows-gnu" -cross_compiler = "x86_64-w64-mingw32-gcc" diff --git a/crates/core_bindings/Cargo.toml b/crates/core_bindings/Cargo.toml @@ -1,15 +0,0 @@ -[package] -name = "radroots_core_bindings" -description = "Generated bindings for Radroots core primitives" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -publish = false - -[dependencies] -dto_bindgen = { workspace = true } -dto_bindgen_core = { workspace = true } -radroots_core = { workspace = true, features = ["serde", "std"] } diff --git a/crates/core_bindings/src/dto.rs b/crates/core_bindings/src/dto.rs @@ -1,139 +0,0 @@ -#![allow(dead_code)] - -use dto_bindgen_core::RootDescriptor; - -pub fn dto_roots() -> Vec<RootDescriptor> { - vec![ - RootDescriptor::new::<DiscountDescriptor>(), - RootDescriptor::new::<DiscountScopeDescriptor>(), - RootDescriptor::new::<DiscountThresholdDescriptor>(), - RootDescriptor::new::<DiscountValueDescriptor>(), - RootDescriptor::new::<MoneyDescriptor>(), - RootDescriptor::new::<PercentDescriptor>(), - RootDescriptor::new::<QuantityDescriptor>(), - RootDescriptor::new::<QuantityPriceDescriptor>(), - RootDescriptor::new::<UnitDescriptor>(), - RootDescriptor::new::<UnitDimensionDescriptor>(), - ] -} - -#[derive(dto_bindgen::Dto)] -#[dto(as = "string")] -#[dto(ts(name = "Currency"))] -struct CurrencyDescriptor(String); - -#[derive(dto_bindgen::Dto)] -#[dto(as = "string")] -#[dto(ts(name = "Decimal"))] -struct DecimalDescriptor(String); - -#[derive(dto_bindgen::Dto)] -#[dto(ts(name = "Money"))] -struct MoneyDescriptor { - amount: DecimalDescriptor, - currency: CurrencyDescriptor, -} - -#[derive(dto_bindgen::Dto)] -#[dto(ts(name = "Percent"))] -struct PercentDescriptor { - value: DecimalDescriptor, -} - -#[derive(dto_bindgen::Dto)] -#[dto(as = "string_enum")] -#[dto(ts(name = "Unit"))] -enum UnitDescriptor { - #[dto(rename = "each")] - Each, - #[dto(rename = "kg")] - MassKg, - #[dto(rename = "g")] - MassG, - #[dto(rename = "oz")] - MassOz, - #[dto(rename = "lb")] - MassLb, - #[dto(rename = "l")] - VolumeL, - #[dto(rename = "ml")] - VolumeMl, -} - -#[derive(dto_bindgen::Dto)] -#[dto(as = "string_enum")] -#[dto(ts(name = "UnitDimension"))] -enum UnitDimensionDescriptor { - #[dto(rename = "count")] - Count, - #[dto(rename = "mass")] - Mass, - #[dto(rename = "volume")] - Volume, -} - -#[derive(dto_bindgen::Dto)] -#[dto(ts(name = "Quantity"))] -struct QuantityDescriptor { - amount: DecimalDescriptor, - unit: UnitDescriptor, - #[serde(skip_serializing_if = "Option::is_none")] - label: Option<String>, -} - -#[derive(dto_bindgen::Dto)] -#[dto(ts(name = "QuantityPrice"))] -struct QuantityPriceDescriptor { - amount: MoneyDescriptor, - quantity: QuantityDescriptor, -} - -#[derive(dto_bindgen::Dto)] -#[dto(as = "string_enum")] -#[dto(ts(name = "DiscountScope"))] -enum DiscountScopeDescriptor { - #[dto(rename = "bin")] - Bin, - #[dto(rename = "order_total")] - OrderTotal, -} - -#[derive(dto_bindgen::Dto)] -#[dto(ts(name = "DiscountThreshold"))] -#[serde(rename_all = "snake_case", tag = "kind", content = "amount")] -enum DiscountThresholdDescriptor { - BinCount { bin_id: String, min: u32 }, - OrderQuantity { min: QuantityDescriptor }, -} - -#[derive(dto_bindgen::Dto)] -#[dto(ts(name = "DiscountValue"))] -#[serde(rename_all = "snake_case", tag = "kind", content = "amount")] -enum DiscountValueDescriptor { - MoneyPerBin(MoneyDescriptor), - Percent(PercentDescriptor), -} - -#[derive(dto_bindgen::Dto)] -#[dto(ts(name = "Discount"))] -struct DiscountDescriptor { - scope: DiscountScopeDescriptor, - threshold: DiscountThresholdDescriptor, - value: DiscountValueDescriptor, -} - -#[cfg(test)] -mod tests { - use dto_bindgen_core::build_registry; - - use super::dto_roots; - - #[test] - fn canonical_core_descriptor_roots_build_without_diagnostics() { - let roots = dto_roots(); - let registry = build_registry(roots.clone()); - - assert!(!registry.has_errors(), "{:?}", registry.diagnostics); - assert_eq!(registry.roots.len(), roots.len()); - } -} diff --git a/crates/core_bindings/src/lib.rs b/crates/core_bindings/src/lib.rs @@ -1,20 +0,0 @@ -mod dto; - -pub use dto::dto_roots; -pub use radroots_core as upstream; - -#[cfg(test)] -mod tests { - const GENERATED_TYPES_TS: &str = - include_str!("../../../packages/core-bindings/src/generated/types.ts"); - - #[test] - fn generated_core_types_are_source_rendered() { - assert!(GENERATED_TYPES_TS.contains("export type Money")); - assert!(GENERATED_TYPES_TS.contains("export type QuantityPrice")); - assert!(GENERATED_TYPES_TS.contains("export type UnitDimension")); - assert!(GENERATED_TYPES_TS.contains("label?: string | null")); - assert!(!GENERATED_TYPES_TS.contains("label: string | null")); - assert!(!GENERATED_TYPES_TS.contains("RadrootsCore")); - } -} diff --git a/crates/event_bindings/Cargo.toml b/crates/event_bindings/Cargo.toml @@ -1,14 +0,0 @@ -[package] -name = "radroots_event_bindings" -description = "Generated bindings for Radroots event contracts" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -publish = false - -[dependencies] -dto_bindgen_backend_ts = { workspace = true } -radroots_event = { workspace = true } diff --git a/crates/event_bindings/src/lib.rs b/crates/event_bindings/src/lib.rs @@ -1,28 +0,0 @@ -pub use radroots_event as upstream; - -mod model; - -pub use model::{constants_module, kinds_module}; - -#[cfg(test)] -mod tests { - use radroots_event::{ - envelope::kind as kinds, - listing::operational::RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS, - }; - - use super::{constants_module, kinds_module}; - - #[test] - fn preserves_event_constant_exports() { - let constants = constants_module().render_source(); - let kinds_ts = kinds_module().render_source(); - assert!(constants.contains("RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS")); - assert!(constants.contains(RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS[0])); - assert!(kinds_ts.contains("KIND_CLASSIFIED_LISTING")); - assert!(kinds_ts.contains(&kinds::KIND_CLASSIFIED_LISTING.to_string())); - assert!(kinds_ts.contains("KIND_TRADE_PROPOSAL")); - assert!(kinds_ts.contains(&kinds::KIND_TRADE_PROPOSAL.to_string())); - assert!(!kinds_ts.contains("KIND_ORDER_REQUEST")); - } -} diff --git a/crates/event_bindings/src/model.rs b/crates/event_bindings/src/model.rs @@ -1,161 +0,0 @@ -use dto_bindgen_backend_ts::{ - TypeScriptDeclaration, TypeScriptImport, TypeScriptModule, TypeScriptType, TypeScriptValue, -}; -use radroots_event::{ - envelope::kind as kinds, listing::operational::RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS, -}; - -pub fn constants_module() -> TypeScriptModule { - TypeScriptModule::new("src/generated/constants.ts") - .with_import(TypeScriptImport::type_only( - ["RadrootsOperationalListingProductTagKeys"], - "./types.js", - )) - .with_declaration(TypeScriptDeclaration::constant( - "RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS", - Some(TypeScriptType::named( - "RadrootsOperationalListingProductTagKeys", - )), - TypeScriptValue::array( - RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS - .iter() - .map(|value| TypeScriptValue::string(*value)) - .collect::<Vec<_>>(), - ), - )) -} - -pub fn kinds_module() -> TypeScriptModule { - EVENT_KIND_EXPORTS.iter().fold( - TypeScriptModule::new("src/generated/kinds.ts"), - |module, (name, value)| { - module.with_declaration(TypeScriptDeclaration::constant( - *name, - None, - TypeScriptValue::number(i64::from(*value)), - )) - }, - ) -} - -const EVENT_KIND_EXPORTS: &[(&str, u32)] = &[ - ("KIND_PROFILE", kinds::KIND_PROFILE), - ("KIND_POST", kinds::KIND_POST), - ("KIND_FOLLOW", kinds::KIND_FOLLOW), - ("KIND_REACTION", kinds::KIND_REACTION), - ("KIND_SEAL", kinds::KIND_SEAL), - ("KIND_MESSAGE", kinds::KIND_MESSAGE), - ("KIND_MESSAGE_FILE", kinds::KIND_MESSAGE_FILE), - ("KIND_GIFT_WRAP", kinds::KIND_GIFT_WRAP), - ("KIND_COMMENT", kinds::KIND_COMMENT), - ("KIND_GEOCHAT", kinds::KIND_GEOCHAT), - ("KIND_WIKI_MERGE_REQUEST", kinds::KIND_WIKI_MERGE_REQUEST), - ("KIND_WIKI_ARTICLE", kinds::KIND_WIKI_ARTICLE), - ("KIND_WIKI_REDIRECT", kinds::KIND_WIKI_REDIRECT), - ("KIND_LIST_MUTE", kinds::KIND_LIST_MUTE), - ("KIND_LIST_PINNED_NOTES", kinds::KIND_LIST_PINNED_NOTES), - ( - "KIND_LIST_READ_WRITE_RELAYS", - kinds::KIND_LIST_READ_WRITE_RELAYS, - ), - ("KIND_LIST_BOOKMARKS", kinds::KIND_LIST_BOOKMARKS), - ("KIND_LIST_COMMUNITIES", kinds::KIND_LIST_COMMUNITIES), - ("KIND_LIST_PUBLIC_CHATS", kinds::KIND_LIST_PUBLIC_CHATS), - ("KIND_LIST_BLOCKED_RELAYS", kinds::KIND_LIST_BLOCKED_RELAYS), - ("KIND_LIST_SEARCH_RELAYS", kinds::KIND_LIST_SEARCH_RELAYS), - ("KIND_LIST_SIMPLE_GROUPS", kinds::KIND_LIST_SIMPLE_GROUPS), - ("KIND_LIST_RELAY_FEEDS", kinds::KIND_LIST_RELAY_FEEDS), - ("KIND_LIST_INTERESTS", kinds::KIND_LIST_INTERESTS), - ("KIND_LIST_MEDIA_FOLLOWS", kinds::KIND_LIST_MEDIA_FOLLOWS), - ("KIND_LIST_EMOJIS", kinds::KIND_LIST_EMOJIS), - ("KIND_LIST_DM_RELAYS", kinds::KIND_LIST_DM_RELAYS), - ( - "KIND_LIST_GOOD_WIKI_AUTHORS", - kinds::KIND_LIST_GOOD_WIKI_AUTHORS, - ), - ( - "KIND_LIST_GOOD_WIKI_RELAYS", - kinds::KIND_LIST_GOOD_WIKI_RELAYS, - ), - ("KIND_LIST_SET_FOLLOW", kinds::KIND_LIST_SET_FOLLOW), - ("KIND_LIST_SET_GENERIC", kinds::KIND_LIST_SET_GENERIC), - ("KIND_LIST_SET_RELAY", kinds::KIND_LIST_SET_RELAY), - ("KIND_LIST_SET_BOOKMARK", kinds::KIND_LIST_SET_BOOKMARK), - ("KIND_LIST_SET_CURATION", kinds::KIND_LIST_SET_CURATION), - ("KIND_LIST_SET_VIDEO", kinds::KIND_LIST_SET_VIDEO), - ("KIND_LIST_SET_PICTURE", kinds::KIND_LIST_SET_PICTURE), - ("KIND_LIST_SET_KIND_MUTE", kinds::KIND_LIST_SET_KIND_MUTE), - ("KIND_LIST_SET_INTEREST", kinds::KIND_LIST_SET_INTEREST), - ("KIND_LIST_SET_EMOJI", kinds::KIND_LIST_SET_EMOJI), - ( - "KIND_LIST_SET_RELEASE_ARTIFACT", - kinds::KIND_LIST_SET_RELEASE_ARTIFACT, - ), - ( - "KIND_LIST_SET_APP_CURATION", - kinds::KIND_LIST_SET_APP_CURATION, - ), - ("KIND_LIST_SET_CALENDAR", kinds::KIND_LIST_SET_CALENDAR), - ( - "KIND_LIST_SET_STARTER_PACK", - kinds::KIND_LIST_SET_STARTER_PACK, - ), - ( - "KIND_LIST_SET_MEDIA_STARTER_PACK", - kinds::KIND_LIST_SET_MEDIA_STARTER_PACK, - ), - ("KIND_FARM", kinds::KIND_FARM), - ("KIND_PLOT", kinds::KIND_PLOT), - ("KIND_COOP", kinds::KIND_COOP), - ("KIND_DOCUMENT", kinds::KIND_DOCUMENT), - ("KIND_RESOURCE_AREA", kinds::KIND_RESOURCE_AREA), - ( - "KIND_RESOURCE_HARVEST_CAP", - kinds::KIND_RESOURCE_HARVEST_CAP, - ), - ("KIND_ACCOUNT_CLAIM", kinds::KIND_ACCOUNT_CLAIM), - ("KIND_APP_DATA", kinds::KIND_APP_DATA), - ("KIND_CLASSIFIED_LISTING", kinds::KIND_CLASSIFIED_LISTING), - ("KIND_APPLICATION_HANDLER", kinds::KIND_APPLICATION_HANDLER), - ("KIND_TRADE_PROPOSAL", kinds::KIND_TRADE_PROPOSAL), - ("KIND_TRADE_DECISION", kinds::KIND_TRADE_DECISION), - ( - "KIND_TRADE_REVISION_PROPOSAL", - kinds::KIND_TRADE_REVISION_PROPOSAL, - ), - ( - "KIND_TRADE_REVISION_DECISION", - kinds::KIND_TRADE_REVISION_DECISION, - ), - ("KIND_TRADE_CANCELLATION", kinds::KIND_TRADE_CANCELLATION), - ( - "KIND_TRADE_SELLER_RESERVATION_ASSERTION", - kinds::KIND_TRADE_SELLER_RESERVATION_ASSERTION, - ), - ( - "KIND_TRADE_VALIDATION_RECEIPT", - kinds::KIND_TRADE_VALIDATION_RECEIPT, - ), - ("KIND_KNOWLEDGE_CLAIM", kinds::KIND_KNOWLEDGE_CLAIM), - ("KIND_KNOWLEDGE_RELATION", kinds::KIND_KNOWLEDGE_RELATION), - ("KIND_KNOWLEDGE_REVIEW", kinds::KIND_KNOWLEDGE_REVIEW), - ( - "KIND_KNOWLEDGE_FIELD_REPORT", - kinds::KIND_KNOWLEDGE_FIELD_REPORT, - ), - ( - "KIND_KNOWLEDGE_CHANGE_PROPOSAL", - kinds::KIND_KNOWLEDGE_CHANGE_PROPOSAL, - ), - ( - "KIND_CONTRIBUTION_ATTESTATION", - kinds::KIND_CONTRIBUTION_ATTESTATION, - ), - ("KIND_KNOWLEDGE_SOURCE", kinds::KIND_KNOWLEDGE_SOURCE), - ("KIND_EVIDENCE_BOUNTY", kinds::KIND_EVIDENCE_BOUNTY), - ("KIND_JOB_REQUEST_MIN", kinds::KIND_JOB_REQUEST_MIN), - ("KIND_JOB_REQUEST_MAX", kinds::KIND_JOB_REQUEST_MAX), - ("KIND_JOB_RESULT_MIN", kinds::KIND_JOB_RESULT_MIN), - ("KIND_JOB_RESULT_MAX", kinds::KIND_JOB_RESULT_MAX), - ("KIND_JOB_FEEDBACK", kinds::KIND_JOB_FEEDBACK), -]; diff --git a/crates/event_codec_wasm/Cargo.toml b/crates/event_codec_wasm/Cargo.toml @@ -1,41 +0,0 @@ -[package] -name = "radroots_event_codec_wasm" -publish = false -version = "0.1.0-alpha" -edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] -rust-version.workspace = true -license.workspace = true -description = "WASM boundary for Radroots event codecs" -repository.workspace = true -homepage.workspace = true -readme = "README" - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -radroots_blossom = { workspace = true, default-features = false, features = [ - "std", -] } -radroots_event = { workspace = true, default-features = false, features = [ - "std", - "serde", - "knowledge", -] } -radroots_event_codec = { workspace = true, default-features = false, features = [ - "std", - "manifests", -] } -serde = { workspace = true } -serde_json = { workspace = true } -wasm-bindgen = { workspace = true } - -[dev-dependencies] -nostr = { workspace = true } -radroots_core = { workspace = true, default-features = false, features = [ - "std", -] } -radroots_identity = { workspace = true, default-features = false, features = [ - "std", -] } diff --git a/crates/event_codec_wasm/README b/crates/event_codec_wasm/README @@ -1,24 +0,0 @@ -# radroots_event_codec_wasm - -This is the README for `radroots_event_codec_wasm`, which provides WebAssembly -bindings for `radroots_event_codec` in the `radroots` core libraries. - -## Overview - - * wasm-bindgen entry points for event content and tag encoding and decoding; - * specialized tag helpers for farm, list, job, message, plot, and reaction - event families; - * JSON and `JsValue` boundaries built around `serde-wasm-bindgen` and base64 - helpers; - * built as `cdylib` and `rlib` artifacts for wasm consumers. - -## Copyright - -Except as otherwise noted, all files in the `radroots_event_codec_wasm` -distribution are - - Copyright (c) 2026 Tyson Lupul - -For information on usage and redistribution, and for a DISCLAIMER OF ALL -WARRANTIES, see LICENSE included in the `radroots_event_codec_wasm` -distribution. diff --git a/crates/event_codec_wasm/src/lib.rs b/crates/event_codec_wasm/src/lib.rs @@ -1,2184 +0,0 @@ -#![forbid(unsafe_code)] - -use radroots_blossom::{BlobDescriptor, BlobUrl, Error, MediaType, Sha256}; -use radroots_event::envelope::{EventEnvelope, EventEnvelopeError, EventEnvelopeParts}; -use radroots_event::farm::Farm; -use radroots_event::farm::coop::Coop; -use radroots_event::farm::crdt::FarmCrdtChange; -use radroots_event::farm::file::FarmFileMetadata; -use radroots_event::farm::plot::Plot; -use radroots_event::farm::workspace::FarmWorkspaceManifest; -use radroots_event::knowledge::{ - KnowledgeClaim, KnowledgeFieldReport, KnowledgeRelation, KnowledgeReview, KnowledgeSource, - WikiArticle, WikiMergeRequest, WikiRedirect, -}; -use radroots_event::listing::operational::OperationalListing; -use radroots_event::media::file_metadata::FileMetadata; -use radroots_event::media::{AuthoredImage, AuthoredImageError}; -use radroots_event::post::article::Article; -use radroots_event::post::comment::{ - AuthoredNip22Comment, Nip22AddressRootReference, Nip22CommentError, - Nip22CommentParentReference, Nip22CommentRoot, Nip22EventRootReference, -}; -use radroots_event::post::document::Document; -use radroots_event::post::reaction::Reaction; -use radroots_event::post::report::Report; -use radroots_event::post::repost::{GenericRepost, Repost}; -use radroots_event::post::{ - AuthoredAsk, AuthoredPhotoUpdate, AuthoredPostError, AuthoredPostImage, AuthoredUpdate, - PostImageDimensions, -}; -use radroots_event::social::follow::Follow; -use radroots_event::social::gift_wrap::GiftWrap; -use radroots_event::social::group::{ - GroupAdmins, GroupCreateGroup, GroupCreateInvite, GroupDeleteEvent, GroupDeleteGroup, - GroupEditMetadata, GroupJoinRequest, GroupLeaveRequest, GroupMembers, GroupMetadata, - GroupPutUser, GroupRemoveUser, GroupRoles, -}; -use radroots_event::social::http_auth::HttpAuth; -use radroots_event::social::job_feedback::JobFeedback; -use radroots_event::social::job_request::JobRequest; -use radroots_event::social::job_result::JobResult; -use radroots_event::social::list::List; -use radroots_event::social::list_set::ListSet; -use radroots_event::social::message::Message; -use radroots_event::social::message_file::MessageFile; -use radroots_event::social::relay_auth::RelayAuth; -use radroots_event::social::seal::Seal; -use radroots_event::wire::Nip01EventWireParts; -use radroots_event_codec::encode::article::article_build_tags; -use radroots_event_codec::encode::comment::authored_nip22_comment_to_wire_parts; -use radroots_event_codec::encode::coop::coop_build_tags; -use radroots_event_codec::encode::document::document_build_tags; -use radroots_event_codec::encode::farm::farm_build_tags; -use radroots_event_codec::encode::farm_crdt::farm_crdt_change_build_tags_with_author; -use radroots_event_codec::encode::farm_file::farm_file_metadata_build_tags; -use radroots_event_codec::encode::farm_workspace::farm_workspace_build_tags; -use radroots_event_codec::encode::file_metadata::file_metadata_build_tags; -use radroots_event_codec::encode::follow::follow_build_tags; -use radroots_event_codec::encode::gift_wrap::gift_wrap_build_tags; -use radroots_event_codec::encode::group::{ - group_admins_build_tags, group_create_group_build_tags, group_create_invite_build_tags, - group_delete_event_build_tags, group_delete_group_build_tags, group_edit_metadata_build_tags, - group_join_request_build_tags, group_leave_request_build_tags, group_members_build_tags, - group_metadata_build_tags, group_put_user_build_tags, group_remove_user_build_tags, - group_roles_build_tags, -}; -use radroots_event_codec::encode::http_auth::http_auth_build_tags; -use radroots_event_codec::encode::job::feedback::job_feedback_build_tags; -use radroots_event_codec::encode::job::request::job_request_build_tags; -use radroots_event_codec::encode::job::result::job_result_build_tags; -use radroots_event_codec::encode::knowledge::{ - knowledge_claim_build_tags, knowledge_field_report_build_tags, knowledge_relation_build_tags, - knowledge_review_build_tags, knowledge_source_build_tags, wiki_article_build_tags, - wiki_merge_request_build_tags, wiki_redirect_build_tags, -}; -use radroots_event_codec::encode::list::list_build_tags; -use radroots_event_codec::encode::list_set::list_set_build_tags; -use radroots_event_codec::encode::message::message_build_tags; -use radroots_event_codec::encode::message_file::message_file_build_tags; -use radroots_event_codec::encode::operational_listing::{ - operational_listing_tags as operational_listing_tags_impl, - operational_listing_tags_full as operational_listing_tags_full_impl, -}; -use radroots_event_codec::encode::plot::plot_build_tags; -use radroots_event_codec::encode::post::{ - authored_ask_to_wire_parts, authored_photo_update_to_wire_parts, authored_update_to_wire_parts, -}; -use radroots_event_codec::encode::reaction::reaction_build_tags; -use radroots_event_codec::encode::relay_auth::relay_auth_build_tags; -use radroots_event_codec::encode::report::report_build_tags; -use radroots_event_codec::encode::repost::{generic_repost_build_tags, repost_build_tags}; -use radroots_event_codec::encode::seal::seal_build_tags; -use radroots_event_codec::verify::{RadrootsDecodeError, RadrootsDecodedEvent}; -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; -#[cfg(target_arch = "wasm32")] -use wasm_bindgen::JsValue; -#[cfg(target_arch = "wasm32")] -use wasm_bindgen::prelude::*; - -#[cfg(target_arch = "wasm32")] -type RadrootsJsValue = JsValue; - -#[cfg(not(target_arch = "wasm32"))] -type RadrootsJsValue = String; - -fn err_js<E: ToString>(err: E) -> RadrootsJsValue { - #[cfg(target_arch = "wasm32")] - { - JsValue::from_str(&err.to_string()) - } - #[cfg(not(target_arch = "wasm32"))] - { - err.to_string() - } -} - -fn error_json(code: &str, inner_code: Option<&str>) -> RadrootsJsValue { - let value = serde_json::json!({ - "code": code, - "inner_code": inner_code, - }); - err_js(value) -} - -fn normalized_payload(input: &str) -> &str { - if input.is_empty() { "{}" } else { input } -} - -fn parse_json<T: DeserializeOwned>(input: &str) -> Result<T, RadrootsJsValue> { - serde_json::from_str(normalized_payload(input)).map_err(err_js) -} - -fn parse_authored_json<T: DeserializeOwned>(input: &str) -> Result<T, RadrootsJsValue> { - serde_json::from_str(input).map_err(|_| error_json("invalid_json", Some("authored_input"))) -} - -fn authored_error(code: &str) -> RadrootsJsValue { - error_json("encode_error", Some(code)) -} - -fn blossom_authored_error(error: Error) -> RadrootsJsValue { - authored_error(error.code()) -} - -fn post_authored_error(error: AuthoredPostError) -> RadrootsJsValue { - authored_error(error.code()) -} - -fn image_authored_error(error: AuthoredImageError) -> RadrootsJsValue { - authored_error(error.code()) -} - -fn comment_authored_error(error: Nip22CommentError) -> RadrootsJsValue { - authored_error(error.code()) -} - -fn wire_parts_to_json(parts: &Nip01EventWireParts) -> Result<String, RadrootsJsValue> { - serde_json::to_string(parts) - .map_err(|_| error_json("internal_error", Some("wire_parts_serialization"))) -} - -fn tags_to_json(tags: Vec<Vec<String>>) -> Result<String, RadrootsJsValue> { - serde_json::to_string(&tags).map_err(err_js) -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct EventEnvelopeInput { - id: String, - author: String, - created_at: u64, - kind: u32, - tags: Vec<Vec<String>>, - content: String, - sig: String, -} - -fn envelope_error_code(error: &EventEnvelopeError) -> &'static str { - match error { - EventEnvelopeError::InvalidId(_) => "id_invalid", - EventEnvelopeError::InvalidAuthor(_) => "author_invalid", - EventEnvelopeError::InvalidSignature(_) => "signature_invalid", - EventEnvelopeError::NonCanonicalId => "id_non_canonical", - EventEnvelopeError::NonCanonicalAuthor => "author_non_canonical", - EventEnvelopeError::NonCanonicalSignature => "signature_non_canonical", - EventEnvelopeError::EmptyTag { .. } => "tag_empty", - EventEnvelopeError::EmptyTagKey { .. } => "tag_key_empty", - EventEnvelopeError::ControlCharacterTagKey { .. } => "tag_key_control_character", - EventEnvelopeError::ContentTooLarge { .. } => "content_too_large", - EventEnvelopeError::TooManyTags { .. } => "too_many_tags", - EventEnvelopeError::TooManyTagElements { .. } => "too_many_tag_elements", - EventEnvelopeError::TagElementTooLarge { .. } => "tag_element_too_large", - EventEnvelopeError::TagsTooLarge { .. } => "tags_too_large", - } -} - -fn parse_event_json(input: &str) -> Result<EventEnvelope, RadrootsJsValue> { - let envelope: EventEnvelopeInput = - serde_json::from_str(input).map_err(|_| error_json("invalid_json", Some("event_json")))?; - EventEnvelope::new(EventEnvelopeParts { - id: envelope.id, - author: envelope.author, - created_at: envelope.created_at, - kind: envelope.kind, - tags: envelope.tags, - content: envelope.content, - sig: envelope.sig, - }) - .map_err(|error| error_json("invalid_event", Some(envelope_error_code(&error)))) -} - -fn build_tags_json<T, E, F>(input: &str, build: F) -> Result<String, RadrootsJsValue> -where - T: DeserializeOwned, - E: ToString, - F: FnOnce(&T) -> Result<Vec<Vec<String>>, E>, -{ - let value = parse_json::<T>(input)?; - let tags = build(&value).map_err(err_js)?; - tags_to_json(tags) -} - -fn build_tags_json_infallible<T, F>(input: &str, build: F) -> Result<String, RadrootsJsValue> -where - T: DeserializeOwned, - F: FnOnce(&T) -> Vec<Vec<String>>, -{ - let value = parse_json::<T>(input)?; - let tags = build(&value); - tags_to_json(tags) -} - -#[derive(Serialize)] -struct DecodedEventJson<'a, T> -where - T: Serialize, -{ - event_type: &'static str, - contract_id: &'static str, - event: &'a radroots_event::envelope::EventEnvelope, - payload: &'a T, -} - -fn decoded_event_to_json(decoded: RadrootsDecodedEvent) -> Result<String, RadrootsJsValue> { - match decoded { - RadrootsDecodedEvent::WikiArticle(parsed) => { - decoded_payload_to_json("wiki_article", "radroots.wiki.article.v1", &parsed) - } - RadrootsDecodedEvent::WikiRedirect(parsed) => { - decoded_payload_to_json("wiki_redirect", "radroots.wiki.redirect.v1", &parsed) - } - RadrootsDecodedEvent::WikiMergeRequest(parsed) => decoded_payload_to_json( - "wiki_merge_request", - "radroots.wiki.merge_request.v1", - &parsed, - ), - RadrootsDecodedEvent::KnowledgeSource(parsed) => { - decoded_payload_to_json("knowledge_source", "radroots.knowledge.source.v1", &parsed) - } - RadrootsDecodedEvent::KnowledgeClaim(parsed) => { - decoded_payload_to_json("knowledge_claim", "radroots.knowledge.claim.v1", &parsed) - } - RadrootsDecodedEvent::KnowledgeRelation(parsed) => decoded_payload_to_json( - "knowledge_relation", - "radroots.knowledge.relation.v1", - &parsed, - ), - RadrootsDecodedEvent::KnowledgeReview(parsed) => { - decoded_payload_to_json("knowledge_review", "radroots.knowledge.review.v1", &parsed) - } - RadrootsDecodedEvent::KnowledgeFieldReport(parsed) => decoded_payload_to_json( - "knowledge_field_report", - "radroots.knowledge.field_report.v1", - &parsed, - ), - RadrootsDecodedEvent::EvidenceBounty(parsed) => decoded_payload_to_json( - "evidence_bounty", - "radroots.knowledge.evidence_bounty.v1", - &parsed, - ), - RadrootsDecodedEvent::KnowledgeChangeProposal(parsed) => decoded_payload_to_json( - "knowledge_change_proposal", - "radroots.knowledge.change_proposal.v1", - &parsed, - ), - RadrootsDecodedEvent::ContributionAttestation(parsed) => decoded_payload_to_json( - "contribution_attestation", - "radroots.knowledge.contribution_attestation.v1", - &parsed, - ), - } -} - -fn decoded_payload_to_json<T>( - event_type: &'static str, - contract_id: &'static str, - parsed: &radroots_event_codec::decode::RadrootsParsedEvent<T>, -) -> Result<String, RadrootsJsValue> -where - T: Serialize, -{ - serde_json::to_string(&DecodedEventJson { - event_type, - contract_id, - event: &parsed.event, - payload: &parsed.data.data, - }) - .map_err(err_js) -} - -fn decode_error_json(error: RadrootsDecodeError) -> RadrootsJsValue { - let inner = match &error { - RadrootsDecodeError::Nip01Verification(error) => Some(error.code()), - RadrootsDecodeError::ContractValidation(error) => Some(error.code()), - RadrootsDecodeError::EventParse(error) => Some(error.code()), - RadrootsDecodeError::UnsupportedContract { .. } => None, - }; - error_json(error.code(), inner) -} - -#[derive(serde::Deserialize)] -struct FarmCrdtTagsInput { - change: FarmCrdtChange, - author_pubkey: String, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct AuthoredUpdateInput { - content: String, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct AuthoredMediaPostInput { - content: String, - #[serde(default)] - images: Vec<AuthoredImageInput>, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct AuthoredImageInput { - bytes: Vec<u8>, - url: String, - media_type: String, - uploaded: u64, - width: u32, - height: u32, - alt: String, - #[serde(default)] - fallbacks: Vec<String>, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct AuthoredCommentInput { - content: String, - root: AuthoredCommentRootInput, - position: AuthoredCommentPositionInput, -} - -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -enum AuthoredCommentRootInput { - Event { - event_id: String, - author: String, - kind: u32, - #[serde(default)] - relay: Option<String>, - }, - Address { - coordinate: String, - author: String, - kind: u32, - #[serde(default)] - relay: Option<String>, - }, -} - -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -enum AuthoredCommentPositionInput { - TopEvent, - TopAddress { current_revision: String }, - Nested { parent: AuthoredCommentParentInput }, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct AuthoredCommentParentInput { - event_id: String, - author: String, - #[serde(default)] - relay: Option<String>, -} - -fn authored_post_images( - inputs: Vec<AuthoredImageInput>, -) -> Result<Vec<AuthoredPostImage>, RadrootsJsValue> { - inputs.into_iter().map(authored_post_image).collect() -} - -fn authored_post_image(input: AuthoredImageInput) -> Result<AuthoredPostImage, RadrootsJsValue> { - let media_type = MediaType::parse(&input.media_type).map_err(blossom_authored_error)?; - let sha256 = Sha256::digest(&input.bytes); - let size = u64::try_from(input.bytes.len()) - .map_err(|_| authored_error("blob_size_platform_overflow"))?; - let descriptor = BlobDescriptor::new( - BlobUrl::parse(&input.url).map_err(blossom_authored_error)?, - sha256, - size, - media_type.clone(), - input.uploaded, - ) - .map_err(blossom_authored_error)? - .approve_reference() - .map_err(blossom_authored_error)? - .verify_bytes(&input.bytes, &media_type) - .map_err(blossom_authored_error)?; - let image = - AuthoredImage::try_from_verified_descriptor(descriptor).map_err(image_authored_error)?; - let dimensions = - PostImageDimensions::new(input.width, input.height).map_err(post_authored_error)?; - let mut authored = - AuthoredPostImage::new(image, dimensions, input.alt).map_err(post_authored_error)?; - for fallback in input.fallbacks { - let fallback = BlobUrl::parse(&fallback) - .map_err(blossom_authored_error)? - .approve() - .map_err(blossom_authored_error)?; - authored = authored - .try_with_fallback(fallback) - .map_err(post_authored_error)?; - } - Ok(authored) -} - -fn authored_comment_from_input( - input: AuthoredCommentInput, -) -> Result<AuthoredNip22Comment, RadrootsJsValue> { - let root = match input.root { - AuthoredCommentRootInput::Event { - event_id, - author, - kind, - relay, - } => Nip22CommentRoot::Event( - Nip22EventRootReference::parse(event_id, author, kind, relay.as_deref()) - .map_err(comment_authored_error)?, - ), - AuthoredCommentRootInput::Address { - coordinate, - author, - kind, - relay, - } => { - let root = Nip22AddressRootReference::parse(coordinate, relay.as_deref()) - .map_err(comment_authored_error)?; - if root.author().to_hex() != author { - return Err(authored_error("comment_root_author_mismatch")); - } - if root.kind().as_u32() != kind { - return Err(authored_error("comment_root_kind_mismatch")); - } - Nip22CommentRoot::Address(root) - } - }; - - match (root, input.position) { - (Nip22CommentRoot::Event(root), AuthoredCommentPositionInput::TopEvent) => { - AuthoredNip22Comment::top_level_event(input.content, root) - .map_err(comment_authored_error) - } - ( - Nip22CommentRoot::Address(root), - AuthoredCommentPositionInput::TopAddress { current_revision }, - ) => AuthoredNip22Comment::parse_top_level_address(input.content, root, current_revision) - .map_err(comment_authored_error), - (root, AuthoredCommentPositionInput::Nested { parent }) => { - let parent = Nip22CommentParentReference::parse( - parent.event_id, - parent.author, - parent.relay.as_deref(), - ) - .map_err(comment_authored_error)?; - AuthoredNip22Comment::nested(input.content, root, parent) - .map_err(comment_authored_error) - } - _ => Err(authored_error("comment_root_position_incompatible")), - } -} - -/// Builds deterministic unsigned kind-1 wire parts for a strict Update. -#[cfg_attr( - target_arch = "wasm32", - wasm_bindgen(js_name = social_update_build_authored_draft) -)] -pub fn social_update_build_authored_draft(input_json: &str) -> Result<String, RadrootsJsValue> { - let input = parse_authored_json::<AuthoredUpdateInput>(input_json)?; - let update = AuthoredUpdate::new(input.content).map_err(post_authored_error)?; - wire_parts_to_json(&authored_update_to_wire_parts(&update)) -} - -/// Builds deterministic unsigned kind-1 wire parts for a strict PhotoUpdate. -#[cfg_attr( - target_arch = "wasm32", - wasm_bindgen(js_name = social_photo_update_build_authored_draft) -)] -pub fn social_photo_update_build_authored_draft( - input_json: &str, -) -> Result<String, RadrootsJsValue> { - let input = parse_authored_json::<AuthoredMediaPostInput>(input_json)?; - let images = authored_post_images(input.images)?; - let photo = AuthoredPhotoUpdate::new(input.content, images).map_err(post_authored_error)?; - wire_parts_to_json(&authored_photo_update_to_wire_parts(&photo)) -} - -/// Builds deterministic unsigned kind-1 wire parts for a strict Ask. -#[cfg_attr( - target_arch = "wasm32", - wasm_bindgen(js_name = social_ask_build_authored_draft) -)] -pub fn social_ask_build_authored_draft(input_json: &str) -> Result<String, RadrootsJsValue> { - let input = parse_authored_json::<AuthoredMediaPostInput>(input_json)?; - let images = authored_post_images(input.images)?; - let ask = AuthoredAsk::new(input.content, images).map_err(post_authored_error)?; - wire_parts_to_json(&authored_ask_to_wire_parts(&ask)) -} - -/// Builds deterministic unsigned kind-1111 wire parts for a strict NIP-22 Comment. -#[cfg_attr( - target_arch = "wasm32", - wasm_bindgen(js_name = social_comment_build_authored_draft) -)] -pub fn social_comment_build_authored_draft(input_json: &str) -> Result<String, RadrootsJsValue> { - let input = parse_authored_json::<AuthoredCommentInput>(input_json)?; - let comment = authored_comment_from_input(input)?; - wire_parts_to_json(&authored_nip22_comment_to_wire_parts(&comment)) -} - -#[cfg_attr( - target_arch = "wasm32", - wasm_bindgen(js_name = operational_listing_tags) -)] -pub fn operational_listing_tags(listing_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<OperationalListing, _, _>(listing_json, operational_listing_tags_impl) -} - -#[cfg_attr( - target_arch = "wasm32", - wasm_bindgen(js_name = operational_listing_tags_full) -)] -pub fn operational_listing_tags_full(listing_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<OperationalListing, _, _>(listing_json, operational_listing_tags_full_impl) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = article_tags))] -pub fn article_tags(article_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<Article, _, _>(article_json, article_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = wiki_article_tags))] -pub fn wiki_article_tags(article_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<WikiArticle, _, _>(article_json, wiki_article_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = wiki_redirect_tags))] -pub fn wiki_redirect_tags(redirect_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<WikiRedirect, _, _>(redirect_json, wiki_redirect_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = wiki_merge_request_tags))] -pub fn wiki_merge_request_tags(request_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<WikiMergeRequest, _, _>(request_json, wiki_merge_request_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = knowledge_source_tags))] -pub fn knowledge_source_tags(source_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<KnowledgeSource, _, _>(source_json, knowledge_source_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = knowledge_claim_tags))] -pub fn knowledge_claim_tags(claim_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<KnowledgeClaim, _, _>(claim_json, knowledge_claim_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = knowledge_relation_tags))] -pub fn knowledge_relation_tags(relation_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<KnowledgeRelation, _, _>(relation_json, knowledge_relation_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = knowledge_review_tags))] -pub fn knowledge_review_tags(review_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<KnowledgeReview, _, _>(review_json, knowledge_review_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = knowledge_field_report_tags))] -pub fn knowledge_field_report_tags(report_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<KnowledgeFieldReport, _, _>(report_json, knowledge_field_report_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = verify_and_decode_event_json))] -pub fn verify_and_decode_event_json(event_json: &str) -> Result<String, RadrootsJsValue> { - let event = parse_event_json(event_json)?; - let decoded = radroots_event_codec::verify::verify_and_decode_radroots_event(event) - .map_err(decode_error_json)?; - decoded_event_to_json(decoded) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = contract_manifest_json))] -pub fn contract_manifest_json() -> Result<String, RadrootsJsValue> { - radroots_event_codec::manifest::contract_manifest_json().map_err(err_js) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = file_metadata_tags))] -pub fn file_metadata_tags(metadata_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<FileMetadata, _, _>(metadata_json, file_metadata_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = repost_tags))] -pub fn repost_tags(repost_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<Repost, _, _>(repost_json, repost_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = generic_repost_tags))] -pub fn generic_repost_tags(repost_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GenericRepost, _, _>(repost_json, generic_repost_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = report_tags))] -pub fn report_tags(report_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<Report, _, _>(report_json, report_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = follow_tags))] -pub fn follow_tags(follow_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<Follow, _, _>(follow_json, follow_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = document_tags))] -pub fn document_tags(document_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<Document, _, _>(document_json, document_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = coop_tags))] -pub fn coop_tags(coop_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<Coop, _, _>(coop_json, coop_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = farm_tags))] -pub fn farm_tags(farm_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<Farm, _, _>(farm_json, farm_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = list_tags))] -pub fn list_tags(list_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<List, _, _>(list_json, list_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = list_set_tags))] -pub fn list_set_tags(list_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<ListSet, _, _>(list_json, list_set_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = plot_tags))] -pub fn plot_tags(plot_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<Plot, _, _>(plot_json, plot_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = job_request_tags))] -pub fn job_request_tags(job_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json_infallible::<JobRequest, _>(job_json, job_request_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = job_result_tags))] -pub fn job_result_tags(job_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json_infallible::<JobResult, _>(job_json, job_result_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = job_feedback_tags))] -pub fn job_feedback_tags(job_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json_infallible::<JobFeedback, _>(job_json, job_feedback_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = reaction_tags))] -pub fn reaction_tags(reaction_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<Reaction, _, _>(reaction_json, reaction_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = message_tags))] -pub fn message_tags(message_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<Message, _, _>(message_json, message_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = message_file_tags))] -pub fn message_file_tags(message_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<MessageFile, _, _>(message_json, message_file_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = seal_tags))] -pub fn seal_tags(seal_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<Seal, _, _>(seal_json, seal_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = gift_wrap_tags))] -pub fn gift_wrap_tags(gift_wrap_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GiftWrap, _, _>(gift_wrap_json, gift_wrap_build_tags) -} - -#[cfg_attr( - target_arch = "wasm32", - wasm_bindgen(js_name = farm_workspace_manifest_tags) -)] -pub fn farm_workspace_manifest_tags(workspace_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<FarmWorkspaceManifest, _, _>(workspace_json, farm_workspace_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = farm_crdt_change_tags))] -pub fn farm_crdt_change_tags(input_json: &str) -> Result<String, RadrootsJsValue> { - let input = parse_json::<FarmCrdtTagsInput>(input_json)?; - let tags = farm_crdt_change_build_tags_with_author(&input.change, Some(&input.author_pubkey)) - .map_err(err_js)?; - tags_to_json(tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = farm_file_metadata_tags))] -pub fn farm_file_metadata_tags(file_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<FarmFileMetadata, _, _>(file_json, farm_file_metadata_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = relay_auth_tags))] -pub fn relay_auth_tags(auth_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<RelayAuth, _, _>(auth_json, relay_auth_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = http_auth_tags))] -pub fn http_auth_tags(auth_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<HttpAuth, _, _>(auth_json, http_auth_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_put_user_tags))] -pub fn group_put_user_tags(group_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GroupPutUser, _, _>(group_json, group_put_user_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_remove_user_tags))] -pub fn group_remove_user_tags(group_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GroupRemoveUser, _, _>(group_json, group_remove_user_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_create_group_tags))] -pub fn group_create_group_tags(group_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GroupCreateGroup, _, _>(group_json, group_create_group_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_edit_metadata_tags))] -pub fn group_edit_metadata_tags(group_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GroupEditMetadata, _, _>(group_json, group_edit_metadata_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_delete_group_tags))] -pub fn group_delete_group_tags(group_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GroupDeleteGroup, _, _>(group_json, group_delete_group_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_delete_event_tags))] -pub fn group_delete_event_tags(group_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GroupDeleteEvent, _, _>(group_json, group_delete_event_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_create_invite_tags))] -pub fn group_create_invite_tags(group_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GroupCreateInvite, _, _>(group_json, group_create_invite_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_join_request_tags))] -pub fn group_join_request_tags(group_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GroupJoinRequest, _, _>(group_json, group_join_request_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_leave_request_tags))] -pub fn group_leave_request_tags(group_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GroupLeaveRequest, _, _>(group_json, group_leave_request_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_metadata_tags))] -pub fn group_metadata_tags(group_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GroupMetadata, _, _>(group_json, group_metadata_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_admins_tags))] -pub fn group_admins_tags(group_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GroupAdmins, _, _>(group_json, group_admins_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_members_tags))] -pub fn group_members_tags(group_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GroupMembers, _, _>(group_json, group_members_build_tags) -} - -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_roles_tags))] -pub fn group_roles_tags(group_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<GroupRoles, _, _>(group_json, group_roles_build_tags) -} - -#[cfg(test)] -mod tests { - use super::*; - use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; - use radroots_event::envelope::kind::{ - KIND_FARM_FILE_METADATA, KIND_FILE_METADATA, KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_SOURCE, - KIND_WIKI_ARTICLE, - }; - use radroots_event::envelope::{EventEnvelope, EventEnvelopeLimits, EventEnvelopeParts}; - use radroots_event::farm::FarmRef; - use radroots_event::farm::crdt::{ - CrdtBackend, FarmCrdtDocumentKind, FarmSemanticKind, RADROOTS_FARM_CRDT_CHANGE_SCHEMA, - }; - use radroots_event::farm::file::{FarmFileDimensions, FarmFileMetadata, FarmFileSource}; - use radroots_event::farm::workspace::{ - FarmWorkspaceManifest, FarmWorkspaceMediaServer, FarmWorkspaceRef, FarmWorkspaceRelay, - FarmWorkspaceRelayMode, RADROOTS_FARM_WORKSPACE_PROTOCOL_VERSION, - RADROOTS_FARM_WORKSPACE_SCHEMA, - }; - use radroots_event::knowledge::{ - AddressableRef, KnowledgeCitationSpan, KnowledgeFieldContext, KnowledgeLocation, - KnowledgeLocationPrecision, KnowledgeNodeRef, KnowledgeObservation, - KnowledgeObservationValue, KnowledgeReviewScope, KnowledgeReviewScore, - KnowledgeReviewTarget, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, - RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, RADROOTS_KNOWLEDGE_RELATION_SCHEMA, - RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SCHEMA_VERSION, - RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, WikiArticleVersionRef, - }; - use radroots_event::listing::operational::{OperationalListingBin, OperationalListingProduct}; - use radroots_event::social::group::{ - GroupAdmins, GroupCreateGroup, GroupCreateInvite, GroupDeleteEvent, GroupDeleteGroup, - GroupEditMetadata, GroupEditableMetadata, GroupJoinRequest, GroupLeaveRequest, - GroupMembers, GroupMetadata, GroupPutUser, GroupRemoveUser, GroupRole, GroupRoles, - GroupUserRef, - }; - use radroots_event::social::http_auth::HttpAuth; - use radroots_event::social::job::JobInputType; - use radroots_event::social::job_request::{JobInput, JobParam}; - use radroots_event::social::relay_auth::RelayAuth; - use radroots_event::social::{ - ReportFileTarget, ReportType, SocialFarmAnchor, SocialLocation, SocialMediaDimensions, - SocialTarget, - }; - - fn sample_listing() -> OperationalListing { - let quantity = - Quantity::try_new(Decimal::from(1u32), Unit::Each).expect("positive fixture quantity"); - let price = QuantityPrice::try_new( - Money::try_new(Decimal::from(10u32), Currency::USD) - .expect("non-negative fixture money"), - quantity.clone(), - ) - .expect("non-zero fixture pricing quantity"); - - OperationalListing { - d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"), - published_at: None, - farm: FarmRef { - pubkey: "farm_pubkey".to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - }, - product: OperationalListingProduct { - key: "sku".to_string(), - title: "widget".to_string(), - category: "tools".to_string(), - summary: None, - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: "bin-1".parse().expect("primary bin id"), - bins: vec![OperationalListingBin { - bin_id: "bin-1".parse().expect("bin id"), - quantity, - price_per_canonical_unit: price, - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: None, - availability: None, - delivery_method: None, - location: None, - images: None, - } - } - - fn synthetic_pubkey(seed: char) -> String { - seed.to_string().repeat(64) - } - - fn synthetic_event_id(seed: char) -> String { - seed.to_string().repeat(64) - } - - fn social_farm_anchor() -> SocialFarmAnchor { - SocialFarmAnchor { - farm: FarmRef { - pubkey: synthetic_pubkey('a'), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - }, - relays: Some(vec!["wss://relay.example.test".to_string()]), - } - } - - fn event_target(kind: u32, seed: char) -> SocialTarget { - SocialTarget::Event { - id: synthetic_event_id(seed), - author: Some(synthetic_pubkey('b')), - event_kind: Some(kind), - relays: Some(vec!["wss://relay.example.test".to_string()]), - } - } - - fn address_target(kind: u32, d_tag: &str) -> SocialTarget { - let author = synthetic_pubkey('c'); - SocialTarget::Address { - address: format!("{kind}:{author}:{d_tag}"), - author: Some(author), - event_kind: Some(kind), - relays: Some(vec!["wss://relay2.example.test".to_string()]), - } - } - - fn knowledge_event_ref(seed: char, kind: u32) -> radroots_event::tag::EventRef { - radroots_event::tag::EventRef { - id: synthetic_event_id(seed), - author: radroots_identity::PublicKey::from_hex(&synthetic_pubkey('a')) - .expect("fixture public key"), - kind, - d_tag: None, - relays: Some(vec!["wss://relay.example.test".to_string()]), - } - } - - fn knowledge_address_ref() -> AddressableRef { - AddressableRef { - kind: KIND_WIKI_ARTICLE, - pubkey: synthetic_pubkey('a'), - d_tag: "soil-health".to_string(), - relays: vec!["wss://relay.example.test".to_string()], - } - } - - fn sample_wiki_article() -> WikiArticle { - WikiArticle { - d_tag: "soil-health".to_string(), - title: Some("Soil health".to_string()), - content_djot: "# Soil health".to_string(), - summary: Some("Living soil basics".to_string()), - topics: vec!["soil".to_string()], - references: vec![knowledge_event_ref('1', KIND_KNOWLEDGE_SOURCE)], - forked_from: vec![WikiArticleVersionRef { - event_id: synthetic_event_id('b'), - address_ref: knowledge_address_ref(), - }], - deferred_to: None, - } - } - - fn sample_wiki_redirect() -> WikiRedirect { - WikiRedirect { - d_tag: "soil".to_string(), - target: knowledge_address_ref(), - } - } - - fn sample_wiki_merge_request() -> WikiMergeRequest { - WikiMergeRequest { - target_article: knowledge_address_ref(), - destination_pubkey: synthetic_pubkey('a'), - base_version_event_id: Some(synthetic_event_id('e')), - source_version_event_id: synthetic_event_id('f'), - explanation: Some("Merge synthetic soil article updates".to_string()), - } - } - - fn sample_knowledge_source() -> KnowledgeSource { - KnowledgeSource { - schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(), - schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, - d_tag: "soil-source".to_string(), - title: "Soil Source".to_string(), - source_type: "book".to_string(), - authors: vec!["A. Example".to_string()], - publisher: Some("Radroots Synthetic Press".to_string()), - publication_year: Some(2026), - edition: None, - canonical_url: Some("https://source.example.test/soil-source".to_string()), - artifact_refs: vec![knowledge_event_ref('3', KIND_FILE_METADATA)], - author_asserted_rights: None, - topics: vec!["soil".to_string()], - summary: Some("Synthetic source for wasm coverage".to_string()), - } - } - - fn sample_knowledge_claim() -> KnowledgeClaim { - KnowledgeClaim { - schema: RADROOTS_KNOWLEDGE_CLAIM_SCHEMA.to_string(), - schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, - claim_type: "practice_effect".to_string(), - text: "Cover crops improve soil structure.".to_string(), - citation_spans: vec![KnowledgeCitationSpan { - source_ref: knowledge_event_ref('4', KIND_KNOWLEDGE_SOURCE), - artifact_ref: None, - page_start: Some(12), - page_end: Some(13), - section_path: vec!["chapter-1".to_string()], - quote_hash: Some(synthetic_event_id('5')), - chunk_id: Some("chunk-1".to_string()), - }], - topics: vec!["cover-crops".to_string()], - applies_to: vec!["local-food".to_string()], - author_asserted_confidence: Some("medium".to_string()), - supersedes: Vec::new(), - } - } - - fn sample_knowledge_node_ref(label: &str) -> KnowledgeNodeRef { - KnowledgeNodeRef { - node_type: "event".to_string(), - event_ref: Some(knowledge_event_ref('6', KIND_KNOWLEDGE_CLAIM)), - address_ref: None, - external_id: None, - label: Some(label.to_string()), - } - } - - fn sample_knowledge_relation() -> KnowledgeRelation { - KnowledgeRelation { - schema: RADROOTS_KNOWLEDGE_RELATION_SCHEMA.to_string(), - schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, - subject: sample_knowledge_node_ref("cover crops"), - predicate: "supports".to_string(), - object: sample_knowledge_node_ref("soil structure"), - support_refs: vec![knowledge_event_ref('7', KIND_KNOWLEDGE_CLAIM)], - author_asserted_confidence: Some("medium".to_string()), - supersedes: Vec::new(), - } - } - - fn sample_knowledge_review() -> KnowledgeReview { - KnowledgeReview { - schema: RADROOTS_KNOWLEDGE_REVIEW_SCHEMA.to_string(), - schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, - target: KnowledgeReviewTarget { - event_id: synthetic_event_id('8'), - author_pubkey: synthetic_pubkey('a'), - kind: KIND_KNOWLEDGE_CLAIM, - address: None, - relays: vec!["wss://relay.example.test".to_string()], - review_scope: KnowledgeReviewScope::SpecificVersion, - }, - reviewer_role: "peer".to_string(), - verdict: "needs_more_evidence".to_string(), - scores: vec![KnowledgeReviewScore { - dimension: "evidence".to_string(), - value: "partial".to_string(), - note: None, - }], - notes: Some("Synthetic review".to_string()), - evidence_refs: vec![knowledge_event_ref('9', KIND_KNOWLEDGE_SOURCE)], - } - } - - fn sample_knowledge_field_report() -> KnowledgeFieldReport { - KnowledgeFieldReport { - schema: RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA.to_string(), - schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, - report_type: "observation".to_string(), - title: "Field observation".to_string(), - summary: Some("Observed cover crop residue.".to_string()), - context: KnowledgeFieldContext { - location_precision: KnowledgeLocationPrecision::CoarseGeohash, - public_location: Some(KnowledgeLocation { - label: Some("watershed".to_string()), - region: Some("synthetic-region".to_string()), - locality: None, - geohash: Some("c23".to_string()), - }), - private_location_ref: None, - topics: vec!["field".to_string()], - context_tags: vec!["observation".to_string()], - }, - observations: vec![KnowledgeObservation { - observation_type: "residue".to_string(), - text: "Residue was visible across beds.".to_string(), - observed_at: Some("2026-07-05".to_string()), - values: vec![KnowledgeObservationValue { - key: "coverage".to_string(), - value: "medium".to_string(), - unit: None, - }], - }], - artifact_refs: vec![knowledge_event_ref('c', KIND_FILE_METADATA)], - related_refs: vec![knowledge_event_ref('d', KIND_KNOWLEDGE_CLAIM)], - limitations: vec!["single observer".to_string()], - } - } - - fn signed_claim_event_json() -> String { - let claim_json = serde_json::to_string(&sample_knowledge_claim()).expect("claim json"); - let tags = serde_json::from_str::<Vec<Vec<String>>>( - &knowledge_claim_tags(&claim_json).expect("claim tags"), - ) - .expect("tags"); - let tags = tags - .into_iter() - .map(nostr::Tag::parse) - .collect::<Result<Vec<_>, _>>() - .expect("parsed tags"); - let keys = nostr::Keys::generate(); - let event = - nostr::EventBuilder::new(nostr::Kind::Custom(KIND_KNOWLEDGE_CLAIM as u16), claim_json) - .tags(tags) - .custom_created_at(nostr::Timestamp::from_secs(1_800_000_000)) - .sign_with_keys(&keys) - .expect("signed event"); - let envelope = EventEnvelope::new(EventEnvelopeParts { - id: event.id.to_hex(), - author: event.pubkey.to_hex(), - created_at: event.created_at.as_secs(), - kind: u32::from(event.kind.as_u16()), - tags: event - .tags - .as_slice() - .iter() - .map(|tag| tag.as_slice().to_vec()) - .collect(), - content: event.content, - sig: event.sig.to_string(), - }) - .expect("event envelope"); - serde_json::to_string(&envelope).expect("event json") - } - - fn social_location() -> SocialLocation { - SocialLocation { - name: Some("field edge".to_string()), - geohash: Some("c23nb62w20st".to_string()), - } - } - - fn sample_article() -> Article { - Article { - d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_string(), - title: "soil notes".to_string(), - content: "# soil notes".to_string(), - summary: Some("cover crop observations".to_string()), - image: Some("https://media.example.test/article.jpg".to_string()), - published_at: Some(1_780_000_000), - farm: Some(social_farm_anchor()), - location: Some(social_location()), - topics: Some(vec!["soil".to_string(), "cover-crops".to_string()]), - } - } - - fn sample_public_file_metadata() -> FileMetadata { - FileMetadata { - url: "https://media.example.test/public.jpg".to_string(), - mime_type: "image/jpeg".to_string(), - sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), - original_sha256: Some( - "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(), - ), - size: Some(4096), - dimensions: Some(SocialMediaDimensions { - width: 1200, - height: 800, - }), - blurhash: None, - thumbnails: None, - summary: Some("public field photo".to_string()), - alt: Some("rows after harvest".to_string()), - fallback: Some("https://media.example.test/fallback.jpg".to_string()), - magnet: Some("magnet:?xt=urn:btih:abc".to_string()), - content_hashes: Some(vec!["sha256:field".to_string()]), - services: Some(vec!["https://media.example.test".to_string()]), - content: Some("caption".to_string()), - } - } - - fn sample_reaction() -> Reaction { - Reaction { - target: address_target(30023, "AAAAAAAAAAAAAAAAAAAAAg"), - content: String::new(), - } - } - - fn sample_repost() -> Repost { - Repost { - target: event_target(1, 'b'), - content: Some("field update".to_string()), - } - } - - fn sample_generic_repost() -> GenericRepost { - GenericRepost { - target: address_target(30023, "AAAAAAAAAAAAAAAAAAAAAg"), - target_kind: 30023, - content: Some("article share".to_string()), - } - } - - fn sample_report() -> Report { - Report { - reported_pubkey: synthetic_pubkey('b'), - report_type: ReportType::Spam, - event: Some(event_target(1, 'c')), - file: Some(ReportFileTarget { - sha256: Some( - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), - ), - url: Some("https://media.example.test/bad.jpg".to_string()), - magnet: None, - }), - content: Some("spam report".to_string()), - } - } - - fn sample_job_request() -> JobRequest { - JobRequest { - kind: 5100, - inputs: vec![JobInput { - data: "alpha".to_string(), - input_type: JobInputType::Text, - relay: None, - marker: None, - }], - output: None, - params: vec![JobParam { - key: "mode".to_string(), - value: "fast".to_string(), - }], - bid_sat: Some(42), - relays: vec!["wss://relay.example.com".to_string()], - providers: vec!["provider-a".to_string()], - topics: vec!["topic-a".to_string()], - encrypted: false, - } - } - - fn sample_workspace_manifest() -> FarmWorkspaceManifest { - FarmWorkspaceManifest { - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - schema: RADROOTS_FARM_WORKSPACE_SCHEMA.to_string(), - farm_group_id: "field-group".to_string(), - name: "Small Regen Farm".to_string(), - owner_pubkey: "workspace_owner_pubkey".to_string(), - farm: Some(FarmRef { - pubkey: "farm_pubkey".to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), - }), - relays: vec![FarmWorkspaceRelay { - url: "wss://relay.example.invalid/farm/field-group".to_string(), - mode: FarmWorkspaceRelayMode::ReadWrite, - }], - media_servers: vec![FarmWorkspaceMediaServer { - url: "https://media.example.invalid/farm/field-group".to_string(), - service: "RadrootsPrivateMedia".to_string(), - }], - supported_kinds: vec![78, 30078, KIND_FARM_FILE_METADATA], - protocol_version: RADROOTS_FARM_WORKSPACE_PROTOCOL_VERSION.to_string(), - created_at_ms: 1_780_000_000_000, - updated_at_ms: None, - } - } - - fn sample_crdt_change() -> FarmCrdtChange { - FarmCrdtChange { - schema: RADROOTS_FARM_CRDT_CHANGE_SCHEMA.to_string(), - workspace: FarmWorkspaceRef { - pubkey: "workspace_pubkey".to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - }, - farm_group_id: "field-group".to_string(), - document_id: "AAAAAAAAAAAAAAAAAAAAAg".to_string(), - document_kind: FarmCrdtDocumentKind::FarmTask, - crdt_backend: CrdtBackend::Automerge, - crdt_backend_version: Some("0.x".to_string()), - actor_id: "actor_abc".to_string(), - change_hash: "crdt_hash_abc".to_string(), - dependencies: Vec::new(), - encoded_change: "abc-DEF_012".to_string(), - semantic_kind: FarmSemanticKind::FarmTaskCreate, - business_time_ms: 1_780_000_000_000, - author_member_id: Some("member_abc".to_string()), - app_version: Some("0.1.0".to_string()), - } - } - - fn sample_file_metadata() -> FarmFileMetadata { - FarmFileMetadata { - d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), - workspace: FarmWorkspaceRef { - pubkey: "workspace_pubkey".to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - }, - farm_group_id: "field-group".to_string(), - owner_document_id: "AAAAAAAAAAAAAAAAAAAAAg".to_string(), - owner_document_kind: FarmCrdtDocumentKind::FarmTask, - caption: Some("Tomatoes harvested from Patch Y.".to_string()), - url: "https://media.example.invalid/blob/sha256".to_string(), - mime_type: "image/jpeg".to_string(), - sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), - original_sha256: None, - size_bytes: Some(123_456), - dimensions: Some(FarmFileDimensions { w: 1600, h: 1200 }), - blurhash: None, - thumb: Some(FarmFileSource { - url: "https://media.example.invalid/thumb/sha256".to_string(), - mime_type: Some("image/jpeg".to_string()), - dimensions: Some(FarmFileDimensions { w: 320, h: 240 }), - }), - image: None, - alt: Some("Harvested tomatoes in a crate".to_string()), - fallbacks: Vec::new(), - } - } - - fn sample_group_metadata() -> GroupEditableMetadata { - GroupEditableMetadata { - name: Some("Small Regen Farm".to_string()), - about: Some("Field app group".to_string()), - picture: Some("https://media.example.invalid/group.png".to_string()), - is_private: false, - is_restricted: true, - is_closed: false, - is_hidden: false, - supported_kinds: Some(vec![78, 30078, KIND_FARM_FILE_METADATA]), - } - } - - fn sample_group_user(role: &str) -> GroupUserRef { - GroupUserRef { - pubkey: format!("{role}_pubkey"), - roles: vec![role.to_string()], - } - } - - fn sample_group_role() -> GroupRole { - GroupRole { - name: "member".to_string(), - description: Some("can read and write group events".to_string()), - permissions: vec!["read".to_string(), "write".to_string()], - } - } - - fn assert_tags_json(value: Result<String, RadrootsJsValue>) { - let tags = tags_json(value); - assert!(!tags.is_empty()); - } - - fn tags_json(value: Result<String, RadrootsJsValue>) -> Vec<Vec<String>> { - let json = value.expect("tags json"); - serde_json::from_str(&json).expect("tags") - } - - fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool { - tags.iter().any(|tag| { - tag.first().map(|entry| entry.as_str()) == Some(key) - && tag.get(1).map(|entry| entry.as_str()) == Some(value) - }) - } - - fn has_exact_tag(tags: &[Vec<String>], expected: &[&str]) -> bool { - tags.iter().any(|tag| { - tag.iter() - .map(|entry| entry.as_str()) - .eq(expected.iter().copied()) - }) - } - - type BindingEncoder = fn(&str) -> Result<String, RadrootsJsValue>; - - const POST_AUTHORED_VECTORS: &str = include_str!( - "../../../../lib/contracts/conformance/vectors/post/verified_profiles.v1.json" - ); - const COMMENT_AUTHORED_VECTORS: &str = include_str!( - "../../../../lib/contracts/conformance/vectors/comment/verified_profile.v1.json" - ); - - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct AuthoredVectorSuite { - suite: String, - contract_version: String, - vectors: Vec<AuthoredVector>, - } - - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct AuthoredVector { - id: String, - kind: String, - input: serde_json::Value, - expected: serde_json::Value, - } - - fn authored_binding(kind: &str) -> Option<BindingEncoder> { - if kind.starts_with("social.update.build_authored_draft.") { - Some(social_update_build_authored_draft) - } else if kind.starts_with("social.photo_update.build_authored_draft.") { - Some(social_photo_update_build_authored_draft) - } else if kind.starts_with("social.ask.build_authored_draft.") { - Some(social_ask_build_authored_draft) - } else if kind.starts_with("social.comment.build_authored_draft.") { - Some(social_comment_build_authored_draft) - } else { - None - } - } - - fn authored_vector_request(vector: &AuthoredVector) -> String { - let mut input = vector.input.clone(); - if let Some(images) = input - .get_mut("images") - .and_then(serde_json::Value::as_array_mut) - { - for image in images { - let image = image - .as_object_mut() - .unwrap_or_else(|| panic!("{} image must be an object", vector.id)); - let bytes_utf8 = image - .remove("bytes_utf8") - .unwrap_or_else(|| panic!("{} image must carry vector bytes", vector.id)); - let bytes_utf8 = bytes_utf8 - .as_str() - .unwrap_or_else(|| panic!("{} vector bytes must be UTF-8 text", vector.id)); - image.insert( - "bytes".to_owned(), - serde_json::Value::Array( - bytes_utf8 - .as_bytes() - .iter() - .copied() - .map(serde_json::Value::from) - .collect(), - ), - ); - } - } - serde_json::to_string(&input) - .unwrap_or_else(|error| panic!("{} request JSON failed: {error}", vector.id)) - } - - fn authored_error_value(error: RadrootsJsValue) -> serde_json::Value { - serde_json::from_str(&error).expect("authored binding error must be coded JSON") - } - - fn authored_error_inner(error: RadrootsJsValue) -> String { - let error = authored_error_value(error); - assert_eq!(error["code"], "encode_error"); - error["inner_code"] - .as_str() - .expect("authored error inner_code") - .to_owned() - } - - fn execute_authored_vectors(raw: &str, expected_suite: &str, expected_vector_count: usize) { - let suite: AuthoredVectorSuite = - serde_json::from_str(raw).expect("owner conformance vectors"); - assert_eq!(suite.suite, expected_suite); - assert_eq!(suite.contract_version, "1.0.0"); - let mut executed = 0usize; - for vector in suite.vectors { - let Some(binding) = authored_binding(&vector.kind) else { - continue; - }; - executed += 1; - let request = authored_vector_request(&vector); - if vector.kind.ends_with(".valid") { - let first = binding(&request) - .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); - let second = binding(&request) - .unwrap_or_else(|error| panic!("{} repeat failed: {error}", vector.id)); - assert_eq!(first, second, "{} repeat encoding drifted", vector.id); - let actual: serde_json::Value = serde_json::from_str(&first) - .unwrap_or_else(|error| panic!("{} output JSON failed: {error}", vector.id)); - assert_eq!(actual, vector.expected, "{}", vector.id); - } else if vector.kind.ends_with(".invalid") { - let first = binding(&request).expect_err("invalid authored vector must fail"); - let second = - binding(&request).expect_err("repeated invalid authored vector must fail"); - let expected = vector.expected["error"] - .as_str() - .unwrap_or_else(|| panic!("{} expected error must be a string", vector.id)); - assert_eq!(authored_error_inner(first), expected, "{}", vector.id); - assert_eq!(authored_error_inner(second), expected, "{}", vector.id); - } else { - panic!("{} has unsupported authored vector kind", vector.id); - } - } - assert_eq!(executed, expected_vector_count); - } - - #[test] - fn bindings_reject_invalid_json() { - let bindings: [BindingEncoder; 44] = [ - social_update_build_authored_draft, - social_photo_update_build_authored_draft, - social_ask_build_authored_draft, - social_comment_build_authored_draft, - operational_listing_tags, - operational_listing_tags_full, - article_tags, - file_metadata_tags, - repost_tags, - generic_repost_tags, - report_tags, - follow_tags, - document_tags, - coop_tags, - farm_tags, - list_tags, - list_set_tags, - plot_tags, - job_request_tags, - job_result_tags, - job_feedback_tags, - reaction_tags, - message_tags, - message_file_tags, - seal_tags, - gift_wrap_tags, - farm_workspace_manifest_tags, - farm_crdt_change_tags, - farm_file_metadata_tags, - relay_auth_tags, - http_auth_tags, - group_put_user_tags, - group_remove_user_tags, - group_create_group_tags, - group_edit_metadata_tags, - group_delete_group_tags, - group_delete_event_tags, - group_create_invite_tags, - group_join_request_tags, - group_leave_request_tags, - group_metadata_tags, - group_admins_tags, - group_members_tags, - group_roles_tags, - ]; - - for binding in bindings { - assert!(binding("{").is_err()); - } - assert!(operational_listing_tags("").is_err()); - } - - #[test] - fn strict_authored_post_bindings_execute_owner_conformance_vectors() { - execute_authored_vectors(POST_AUTHORED_VECTORS, "post_profiles", 6); - } - - #[test] - fn strict_authored_comment_binding_executes_owner_conformance_vectors() { - execute_authored_vectors(COMMENT_AUTHORED_VECTORS, "nip22_comment_profile", 31); - } - - #[test] - fn authored_comment_rejects_address_coordinate_author_and_kind_mismatches() { - let author = synthetic_pubkey('e'); - let coordinate = format!("30402:{author}:victoria-market"); - let request = |author: &str, kind: u32| { - serde_json::json!({ - "content": "Available this week", - "root": { - "type": "address", - "coordinate": coordinate, - "author": author, - "kind": kind, - "relay": null, - }, - "position": { - "type": "top_address", - "current_revision": synthetic_event_id('b'), - }, - }) - .to_string() - }; - - let author_error = - social_comment_build_authored_draft(&request(&synthetic_pubkey('f'), 30402)) - .expect_err("coordinate author mismatch"); - assert_eq!( - authored_error_inner(author_error), - "comment_root_author_mismatch" - ); - - let kind_error = social_comment_build_authored_draft(&request(&author, 31922)) - .expect_err("coordinate kind mismatch"); - assert_eq!( - authored_error_inner(kind_error), - "comment_root_kind_mismatch" - ); - } - - #[test] - fn authored_image_errors_keep_the_stable_binding_code() { - let error = image_authored_error(AuthoredImageError::MediaTypeNotImage); - assert_eq!(authored_error_inner(error), "media_type_not_image"); - } - - #[test] - fn authored_media_binding_verifies_exact_arbitrary_bytes() { - let bytes = vec![0, 159, 146, 150]; - let hash = Sha256::digest(&bytes); - let url = format!("https://media.example/{hash}.webp"); - let input = serde_json::json!({ - "content": format!("Harvest {url}"), - "images": [{ - "bytes": bytes, - "url": url, - "media_type": "image/webp", - "uploaded": 1_784_347_200_u64, - "width": 1200, - "height": 900, - "alt": "Harvest", - "fallbacks": [], - }], - }); - - let encoded = social_photo_update_build_authored_draft( - &serde_json::to_string(&input).expect("media request"), - ) - .expect("byte-verified PhotoUpdate"); - let encoded: Nip01EventWireParts = serde_json::from_str(&encoded).expect("wire parts"); - assert_eq!(encoded.tags.len(), 1); - assert!(encoded.tags[0].contains(&format!("x {hash}"))); - assert!(encoded.tags[0].contains(&"size 4".to_owned())); - - let mut wrong_bytes = input.clone(); - wrong_bytes["images"][0]["bytes"] = serde_json::json!([1, 2, 3, 4]); - let error = social_photo_update_build_authored_draft( - &serde_json::to_string(&wrong_bytes).expect("wrong-byte request"), - ) - .expect_err("URL digest must bind exact bytes"); - assert_eq!(authored_error_inner(error), "descriptor_hash_mismatch"); - } - - #[test] - fn authored_media_binding_rejects_fabricated_descriptor_fields() { - let bytes = b"image".to_vec(); - let hash = Sha256::digest(&bytes); - let url = format!("https://media.example/{hash}.webp"); - let mut input = serde_json::json!({ - "content": format!("Harvest {url}"), - "images": [{ - "bytes": bytes, - "url": url, - "media_type": "image/webp", - "uploaded": 1_784_347_200_u64, - "width": 1200, - "height": 900, - "alt": "Harvest", - }], - }); - input["images"][0]["sha256"] = serde_json::json!(hash.to_string()); - input["images"][0]["size"] = serde_json::json!(5); - - let error = social_photo_update_build_authored_draft( - &serde_json::to_string(&input).expect("fabricated descriptor request"), - ) - .expect_err("caller-supplied descriptor commitments must be rejected"); - let error = authored_error_value(error); - assert_eq!(error["code"], "invalid_json"); - assert_eq!(error["inner_code"], "authored_input"); - } - - #[test] - fn ask_allows_zero_media_while_photo_update_requires_media() { - let ask = social_ask_build_authored_draft(r#"{"content":"When is harvest?","images":[]}"#) - .expect("Ask without media"); - let ask: Nip01EventWireParts = serde_json::from_str(&ask).expect("Ask wire parts"); - assert_eq!( - ask.tags, - vec![vec!["t".to_owned(), "radroots-ask".to_owned()]] - ); - - let error = - social_photo_update_build_authored_draft(r#"{"content":"Harvest","images":[]}"#) - .expect_err("PhotoUpdate without media"); - assert_eq!(authored_error_inner(error), "photo_imeta_missing"); - } - - #[test] - fn bindings_encode_to_json_when_input_is_valid() { - let listing_json = serde_json::to_string(&sample_listing()).expect("listing json"); - let listing_tags_json = - operational_listing_tags(&listing_json).expect("operational listing tags"); - let listing_tags: Vec<Vec<String>> = - serde_json::from_str(&listing_tags_json).expect("listing tags json"); - assert!(!listing_tags.is_empty()); - - let request_json = serde_json::to_string(&sample_job_request()).expect("request json"); - let request_tags_json = job_request_tags(&request_json).expect("request tags"); - let request_tags: Vec<Vec<String>> = - serde_json::from_str(&request_tags_json).expect("request tags json"); - assert!(!request_tags.is_empty()); - } - - #[test] - fn social_bindings_encode_to_json_when_input_is_valid() { - assert_tags_json(article_tags( - &serde_json::to_string(&sample_article()).expect("article json"), - )); - assert_tags_json(file_metadata_tags( - &serde_json::to_string(&sample_public_file_metadata()).expect("file json"), - )); - assert_tags_json(reaction_tags( - &serde_json::to_string(&sample_reaction()).expect("reaction json"), - )); - assert_tags_json(repost_tags( - &serde_json::to_string(&sample_repost()).expect("repost json"), - )); - assert_tags_json(generic_repost_tags( - &serde_json::to_string(&sample_generic_repost()).expect("generic repost json"), - )); - assert_tags_json(report_tags( - &serde_json::to_string(&sample_report()).expect("report json"), - )); - } - - #[test] - fn social_bindings_surface_builder_errors() { - let mut article = sample_article(); - article.d_tag.clear(); - assert!(article_tags(&serde_json::to_string(&article).expect("article json")).is_err()); - - let mut reaction = sample_reaction(); - reaction.target = SocialTarget::External { - id: "https://example.test/object".to_string(), - external_kind: "web".to_string(), - hint: None, - }; - assert!(reaction_tags(&serde_json::to_string(&reaction).expect("reaction json")).is_err()); - - let mut report = sample_report(); - report.reported_pubkey.clear(); - assert!(report_tags(&serde_json::to_string(&report).expect("report json")).is_err()); - } - - #[test] - fn knowledge_bindings_encode_to_json_when_input_is_valid() { - let article_tags = tags_json(wiki_article_tags( - &serde_json::to_string(&sample_wiki_article()).expect("wiki article json"), - )); - assert!(has_tag(&article_tags, "title", "Soil health")); - let fork_address = format!( - "{}:{}:soil-health", - KIND_WIKI_ARTICLE, - synthetic_pubkey('a') - ); - let fork_event_id = synthetic_event_id('b'); - assert!(has_exact_tag( - &article_tags, - &[ - "a", - fork_address.as_str(), - "wss://relay.example.test", - "fork" - ] - )); - assert!(has_exact_tag( - &article_tags, - &[ - "e", - fork_event_id.as_str(), - "wss://relay.example.test", - "fork" - ] - )); - - let redirect_tags = tags_json(wiki_redirect_tags( - &serde_json::to_string(&sample_wiki_redirect()).expect("wiki redirect json"), - )); - let redirect_address = format!( - "{}:{}:soil-health", - KIND_WIKI_ARTICLE, - synthetic_pubkey('a') - ); - assert!(has_exact_tag( - &redirect_tags, - &["a", redirect_address.as_str(), "wss://relay.example.test"] - )); - - let merge_request = sample_wiki_merge_request(); - let merge_parts = - radroots_event_codec::encode::knowledge::wiki_merge_request_to_wire_parts( - &merge_request, - ) - .expect("merge request parts"); - assert_eq!(merge_parts.content, "Merge synthetic soil article updates"); - let merge_tags = tags_json(wiki_merge_request_tags( - &serde_json::to_string(&sample_wiki_merge_request()).expect("merge request json"), - )); - let source_event_id = synthetic_event_id('f'); - assert!(has_exact_tag( - &merge_tags, - &["e", source_event_id.as_str(), "", "source"] - )); - - let source_tags = tags_json(knowledge_source_tags( - &serde_json::to_string(&sample_knowledge_source()).expect("source json"), - )); - assert!(has_tag( - &source_tags, - "contract", - RADROOTS_KNOWLEDGE_SOURCE_SCHEMA - )); - - let claim_tags = tags_json(knowledge_claim_tags( - &serde_json::to_string(&sample_knowledge_claim()).expect("claim json"), - )); - assert!(has_tag( - &claim_tags, - "contract", - RADROOTS_KNOWLEDGE_CLAIM_SCHEMA - )); - - assert_tags_json(knowledge_relation_tags( - &serde_json::to_string(&sample_knowledge_relation()).expect("relation json"), - )); - assert_tags_json(knowledge_review_tags( - &serde_json::to_string(&sample_knowledge_review()).expect("review json"), - )); - assert_tags_json(knowledge_field_report_tags( - &serde_json::to_string(&sample_knowledge_field_report()).expect("field report json"), - )); - } - - #[test] - fn wiki_article_tags_accept_missing_title() { - let mut article = sample_wiki_article(); - article.title = None; - let tags = tags_json(wiki_article_tags( - &serde_json::to_string(&article).expect("wiki article json"), - )); - assert!( - !tags - .iter() - .any(|tag| tag.first().map(String::as_str) == Some("title")) - ); - } - - #[test] - fn knowledge_claim_tags_enforce_citation_rules() { - let mut claim = sample_knowledge_claim(); - claim.citation_spans.clear(); - let error = knowledge_claim_tags( - &serde_json::to_string(&claim).expect("uncited source-backed claim json"), - ) - .expect_err("source-backed claim requires citations"); - assert!(error.contains("citation_spans")); - - assert_tags_json(knowledge_claim_tags( - &serde_json::to_string(&sample_knowledge_claim()).expect("claim json"), - )); - - for claim_type in ["hypothesis", "observation", "question"] { - let mut uncited = sample_knowledge_claim(); - uncited.claim_type = claim_type.to_string(); - uncited.citation_spans.clear(); - assert_tags_json(knowledge_claim_tags( - &serde_json::to_string(&uncited).expect("uncited claim json"), - )); - } - } - - #[test] - fn knowledge_bindings_verify_decode_and_manifest_json() { - let decoded = verify_and_decode_event_json(&signed_claim_event_json()).expect("decoded"); - let decoded: serde_json::Value = serde_json::from_str(&decoded).expect("decoded json"); - assert_eq!(decoded["event_type"], "knowledge_claim"); - assert_eq!(decoded["contract_id"], RADROOTS_KNOWLEDGE_CLAIM_SCHEMA); - assert_eq!( - decoded["payload"]["text"], - "Cover crops improve soil structure." - ); - - let manifest = contract_manifest_json().expect("manifest"); - let manifest: serde_json::Value = serde_json::from_str(&manifest).expect("manifest json"); - assert_eq!(manifest["schema_version"], 2); - assert_eq!(manifest["contract_count"], 11); - let claim_contract = manifest["contracts"] - .as_array() - .expect("contracts") - .iter() - .find(|contract| contract["contract_id"] == RADROOTS_KNOWLEDGE_CLAIM_SCHEMA) - .expect("claim contract"); - assert_eq!(claim_contract["sdk_builder_support"], true); - assert_eq!(claim_contract["sdk_draft_support"], true); - assert_eq!(claim_contract["wasm_tag_builder_support"], true); - assert_eq!(claim_contract["wasm_verified_decode_support"], true); - } - - #[test] - fn knowledge_bindings_verify_decode_errors_are_coded_json() { - let invalid_json = verify_and_decode_event_json("not json").expect_err("invalid json"); - let invalid_json: serde_json::Value = - serde_json::from_str(&invalid_json).expect("error json"); - assert_eq!(invalid_json["code"], "invalid_json"); - assert_eq!(invalid_json["inner_code"], "event_json"); - - let mut event: serde_json::Value = - serde_json::from_str(&signed_claim_event_json()).expect("event json"); - event["sig"] = serde_json::Value::String("0".repeat(128)); - let signature_error = - verify_and_decode_event_json(&event.to_string()).expect_err("signature error"); - let signature_error: serde_json::Value = - serde_json::from_str(&signature_error).expect("signature error json"); - assert_eq!(signature_error["code"], "nip01_verification"); - assert_eq!(signature_error["inner_code"], "signature_invalid"); - } - - #[test] - fn knowledge_bindings_reject_unchecked_event_envelope_input() { - let mut oversized: serde_json::Value = - serde_json::from_str(&signed_claim_event_json()).expect("event json"); - oversized["content"] = serde_json::Value::String( - "x".repeat(EventEnvelopeLimits::default().max_content_bytes + 1), - ); - let oversized_error = - verify_and_decode_event_json(&oversized.to_string()).expect_err("oversized content"); - let oversized_error: serde_json::Value = - serde_json::from_str(&oversized_error).expect("oversized error json"); - assert_eq!(oversized_error["code"], "invalid_event"); - assert_eq!(oversized_error["inner_code"], "content_too_large"); - - let mut non_canonical: serde_json::Value = - serde_json::from_str(&signed_claim_event_json()).expect("event json"); - non_canonical["id"] = serde_json::Value::String("A".repeat(64)); - let canonical_error = - verify_and_decode_event_json(&non_canonical.to_string()).expect_err("non-canonical id"); - let canonical_error: serde_json::Value = - serde_json::from_str(&canonical_error).expect("canonical error json"); - assert_eq!(canonical_error["code"], "invalid_event"); - assert_eq!(canonical_error["inner_code"], "id_non_canonical"); - - let mut unknown_field: serde_json::Value = - serde_json::from_str(&signed_claim_event_json()).expect("event json"); - unknown_field["verified"] = serde_json::Value::Bool(true); - let unknown_error = verify_and_decode_event_json(&unknown_field.to_string()) - .expect_err("unknown event field"); - let unknown_error: serde_json::Value = - serde_json::from_str(&unknown_error).expect("unknown-field error json"); - assert_eq!(unknown_error["code"], "invalid_json"); - assert_eq!(unknown_error["inner_code"], "event_json"); - } - - #[test] - fn event_envelope_error_codes_cover_every_structural_limit_class() { - let cases = [ - ( - EventEnvelopeError::NonCanonicalAuthor, - "author_non_canonical", - ), - ( - EventEnvelopeError::NonCanonicalSignature, - "signature_non_canonical", - ), - (EventEnvelopeError::EmptyTag { index: 0 }, "tag_empty"), - ( - EventEnvelopeError::EmptyTagKey { index: 0 }, - "tag_key_empty", - ), - ( - EventEnvelopeError::ControlCharacterTagKey { index: 0 }, - "tag_key_control_character", - ), - ( - EventEnvelopeError::TooManyTags { max: 1, actual: 2 }, - "too_many_tags", - ), - ( - EventEnvelopeError::TooManyTagElements { max: 1, actual: 2 }, - "too_many_tag_elements", - ), - ( - EventEnvelopeError::TagElementTooLarge { - tag_index: 0, - element_index: 0, - max: 1, - actual: 2, - }, - "tag_element_too_large", - ), - ( - EventEnvelopeError::TagsTooLarge { max: 1, actual: 2 }, - "tags_too_large", - ), - ]; - for (error, code) in cases { - assert_eq!(envelope_error_code(&error), code); - } - } - - #[test] - fn field_bindings_encode_to_json_when_input_is_valid() { - let workspace_json = - serde_json::to_string(&sample_workspace_manifest()).expect("workspace json"); - assert_tags_json(farm_workspace_manifest_tags(&workspace_json)); - - let crdt_json = serde_json::json!({ - "change": sample_crdt_change(), - "author_pubkey": "author_pubkey" - }) - .to_string(); - assert_tags_json(farm_crdt_change_tags(&crdt_json)); - - let file_json = serde_json::to_string(&sample_file_metadata()).expect("file json"); - assert_tags_json(farm_file_metadata_tags(&file_json)); - - let relay_auth_json = serde_json::to_string(&RelayAuth { - relay: "wss://relay.example.invalid/farm/field-group".to_string(), - challenge: "relay-provided-challenge".to_string(), - }) - .expect("relay auth json"); - assert_tags_json(relay_auth_tags(&relay_auth_json)); - - let http_auth_json = serde_json::to_string(&HttpAuth { - url: "https://media.example.invalid/upload".to_string(), - method: "POST".to_string(), - payload_sha256: Some( - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), - ), - }) - .expect("http auth json"); - assert_tags_json(http_auth_tags(&http_auth_json)); - } - - #[test] - fn field_bindings_surface_builder_errors() { - let crdt_json = serde_json::json!({ - "change": sample_crdt_change(), - "author_pubkey": " " - }) - .to_string(); - - assert!(farm_crdt_change_tags(&crdt_json).is_err()); - } - - #[test] - fn group_bindings_encode_to_json_when_input_is_valid() { - let metadata = sample_group_metadata(); - assert_tags_json(group_put_user_tags( - &serde_json::to_string(&GroupPutUser { - group_id: "field-group".to_string(), - message: Some("add member".to_string()), - pubkey: "member_pubkey".to_string(), - roles: vec!["member".to_string()], - }) - .expect("put user json"), - )); - assert_tags_json(group_remove_user_tags( - &serde_json::to_string(&GroupRemoveUser { - group_id: "field-group".to_string(), - message: Some("remove member".to_string()), - pubkey: "member_pubkey".to_string(), - }) - .expect("remove user json"), - )); - assert_tags_json(group_create_group_tags( - &serde_json::to_string(&GroupCreateGroup { - group_id: "field-group".to_string(), - message: Some("create group".to_string()), - metadata: metadata.clone(), - }) - .expect("create group json"), - )); - assert_tags_json(group_edit_metadata_tags( - &serde_json::to_string(&GroupEditMetadata { - group_id: "field-group".to_string(), - message: Some("edit metadata".to_string()), - metadata: metadata.clone(), - }) - .expect("edit metadata json"), - )); - assert_tags_json(group_delete_group_tags( - &serde_json::to_string(&GroupDeleteGroup { - group_id: "field-group".to_string(), - message: Some("delete group".to_string()), - }) - .expect("delete group json"), - )); - assert_tags_json(group_delete_event_tags( - &serde_json::to_string(&GroupDeleteEvent { - group_id: "field-group".to_string(), - message: Some("delete event".to_string()), - event_id: "event_id".to_string(), - }) - .expect("delete event json"), - )); - let invite_tags = tags_json(group_create_invite_tags( - &serde_json::to_string(&GroupCreateInvite { - group_id: "field-group".to_string(), - message: Some("join the field group".to_string()), - code: "invite-code".to_string(), - }) - .expect("invite json"), - )); - assert!(invite_tags.contains(&vec!["code".to_string(), "invite-code".to_string()])); - assert_tags_json(group_join_request_tags( - &serde_json::to_string(&GroupJoinRequest { - group_id: "field-group".to_string(), - message: Some("requesting access".to_string()), - code: Some("invite-code".to_string()), - }) - .expect("join json"), - )); - assert_tags_json(group_leave_request_tags( - &serde_json::to_string(&GroupLeaveRequest { - group_id: "field-group".to_string(), - message: Some("leaving".to_string()), - }) - .expect("leave json"), - )); - let metadata_tags = tags_json(group_metadata_tags( - &serde_json::to_string(&GroupMetadata { - d_tag: "field-group".to_string(), - metadata, - }) - .expect("metadata json"), - )); - assert!(metadata_tags.contains(&vec!["restricted".to_string()])); - assert!(metadata_tags.contains(&vec![ - "supported_kinds".to_string(), - "78".to_string(), - "30078".to_string(), - KIND_FARM_FILE_METADATA.to_string() - ])); - assert_tags_json(group_admins_tags( - &serde_json::to_string(&GroupAdmins { - d_tag: "field-group".to_string(), - description: Some("group admins".to_string()), - admins: vec![sample_group_user("admin")], - }) - .expect("admins json"), - )); - assert_tags_json(group_members_tags( - &serde_json::to_string(&GroupMembers { - d_tag: "field-group".to_string(), - description: Some("group members".to_string()), - members: vec![sample_group_user("member")], - }) - .expect("members json"), - )); - assert_tags_json(group_roles_tags( - &serde_json::to_string(&GroupRoles { - d_tag: "field-group".to_string(), - description: Some("group roles".to_string()), - roles: vec![sample_group_role()], - }) - .expect("roles json"), - )); - } - - #[test] - fn listing_bindings_surface_builder_errors() { - let mut listing_json = serde_json::to_value(sample_listing()).expect("listing value"); - listing_json["bins"] = serde_json::Value::Array(Vec::new()); - let listing_json = serde_json::to_string(&listing_json).expect("listing json"); - - assert!(operational_listing_tags(&listing_json).is_err()); - assert!(operational_listing_tags_full(&listing_json).is_err()); - } -} diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml @@ -1,30 +0,0 @@ -[package] -name = "radroots_sdk_ffi" -publish = false -version.workspace = true -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license.workspace = true -description = "Private UniFFI wrapper over the shared Radroots SDK engine" -repository.workspace = true -homepage.workspace = true -readme = "README.md" - -[lib] -crate-type = ["lib", "staticlib", "cdylib"] - -[[bin]] -name = "radroots-sdk-bindgen" -path = "src/bin/bindgen.rs" - -[dependencies] -radroots_sdk = { workspace = true, features = ["memory"] } -thiserror = { workspace = true } -uniffi = { workspace = true, features = ["cli"] } - -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt"] } - -[lints] -workspace = true diff --git a/crates/ffi/README.md b/crates/ffi/README.md @@ -1,10 +0,0 @@ -# radroots_sdk_ffi - -Private UniFFI bindings over the host-neutral `radroots_sdk` engine. The V1 -surface exposes deterministic memory-client construction, versioned capability -DTOs, secret-safe errors, and explicit asynchronous close. It does not own -keychain prompts, background execution, application scheduling, or presentation -DTOs; those remain mobile-host responsibilities. - -This Rust build crate is never published. Generated Swift and Kotlin artifacts -are qualified independently from the public 19-crate Rust inventory. diff --git a/crates/ffi/src/bin/bindgen.rs b/crates/ffi/src/bin/bindgen.rs @@ -1,3 +0,0 @@ -fn main() { - uniffi::uniffi_bindgen_main(); -} diff --git a/crates/ffi/src/lib.rs b/crates/ffi/src/lib.rs @@ -1,173 +0,0 @@ -//! Private mobile FFI over the shared SDK engine. - -#![forbid(unsafe_code)] -#![doc = include_str!("../README.md")] - -use std::sync::Arc; - -use radroots_sdk::{Client, ClientBuilder}; - -uniffi::setup_scaffolding!("radroots_sdk"); - -/// Generation-1 mobile DTOs. -pub mod v1 { - use super::*; - - /// Stable capability maturity independent of runtime availability. - #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] - pub enum CapabilityMaturity { - Stable, - Preview, - Experimental, - } - - /// Current side-effect-free capability availability. - #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] - pub enum CapabilityAvailability { - Available, - Degraded, - Unavailable, - Unsupported, - } - - /// Versioned, presentation-independent capability observation. - #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] - pub struct CapabilityStatus { - pub id: String, - pub compiled: bool, - pub configured: bool, - pub availability: CapabilityAvailability, - pub maturity: CapabilityMaturity, - } - - /// Secret-safe SDK failure at the language boundary. - #[derive(Debug, thiserror::Error, uniffi::Error)] - pub enum Error { - #[error("SDK {code}: {message}")] - Sdk { - code: String, - message: String, - retryable: bool, - }, - } - - impl From<radroots_sdk::Error> for Error { - fn from(error: radroots_sdk::Error) -> Self { - let descriptor = error.descriptor(); - Self::Sdk { - code: descriptor.code().as_str().to_owned(), - message: descriptor.message().to_owned(), - retryable: descriptor.retryable(), - } - } - } - - /// Thread-safe mobile handle delegating lifecycle to [`radroots_sdk::Client`]. - #[derive(uniffi::Object)] - pub struct MobileClient { - client: Client, - } - - #[uniffi::export] - impl MobileClient { - /// Creates a deterministic, local-only memory client without I/O. - #[uniffi::constructor] - pub fn memory() -> Result<Arc<Self>, Error> { - let client = ClientBuilder::memory_default().build()?; - Ok(Arc::new(Self { client })) - } - - /// Returns stable capability DTOs without probing host resources. - pub fn capabilities(&self) -> Vec<CapabilityStatus> { - self.client - .capabilities() - .iter() - .copied() - .map(CapabilityStatus::from) - .collect() - } - - /// Returns whether explicit SDK close completed across all clones. - pub fn is_closed(&self) -> bool { - self.client.is_closed() - } - - /// Explicitly closes SDK-owned resources without installing a runtime. - pub async fn close(&self) -> Result<(), Error> { - self.client.close().await.map_err(Error::from) - } - } - - impl From<radroots_sdk::capability::CapabilityStatus> for CapabilityStatus { - fn from(status: radroots_sdk::capability::CapabilityStatus) -> Self { - Self { - id: status.id().as_str().to_owned(), - compiled: status.is_compiled(), - configured: status.is_configured(), - availability: match status.availability() { - radroots_sdk::capability::Availability::Available => { - CapabilityAvailability::Available - } - radroots_sdk::capability::Availability::Degraded => { - CapabilityAvailability::Degraded - } - radroots_sdk::capability::Availability::Unavailable => { - CapabilityAvailability::Unavailable - } - radroots_sdk::capability::Availability::Unsupported => { - CapabilityAvailability::Unsupported - } - }, - maturity: match status.maturity() { - radroots_sdk::capability::Maturity::Stable => CapabilityMaturity::Stable, - radroots_sdk::capability::Maturity::Preview => CapabilityMaturity::Preview, - radroots_sdk::capability::Maturity::Experimental => { - CapabilityMaturity::Experimental - } - }, - } - } - } -} - -#[cfg(test)] -mod tests { - use super::v1::{CapabilityAvailability, Error, MobileClient}; - - fn assert_send_sync<T: Send + Sync>() {} - - #[tokio::test(flavor = "current_thread")] - async fn memory_client_delegates_capabilities_and_lifecycle() { - assert_send_sync::<MobileClient>(); - let client = MobileClient::memory().expect("memory client"); - let capabilities = client.capabilities(); - let storage = capabilities - .iter() - .find(|status| status.id == "storage.canonical") - .expect("canonical storage"); - assert!(storage.compiled); - assert!(storage.configured); - assert_eq!(storage.availability, CapabilityAvailability::Available); - assert!(!client.is_closed()); - - client.close().await.expect("close"); - assert!(client.is_closed()); - client.close().await.expect("idempotent close"); - } - - #[test] - fn sdk_error_mapping_is_versioned_and_secret_safe() { - let native = radroots_sdk::ClientBuilder::new() - .build() - .expect_err("missing storage"); - let error = Error::from(native); - let Error::Sdk { - code, - message, - retryable, - } = error; - assert_eq!(code, "missing_storage"); - assert_eq!(message, "SDK storage capability is not configured"); - assert!(!retryable); - } -} diff --git a/crates/identity_bindings/Cargo.toml b/crates/identity_bindings/Cargo.toml @@ -1,14 +0,0 @@ -[package] -name = "radroots_identity_bindings" -description = "Generated bindings for Radroots identity contracts" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -publish = false - -[dependencies] -dto_bindgen_backend_ts = { workspace = true } -radroots_identity = { workspace = true } diff --git a/crates/identity_bindings/src/lib.rs b/crates/identity_bindings/src/lib.rs @@ -1,37 +0,0 @@ -pub use radroots_identity as upstream; - -use dto_bindgen_backend_ts::{TypeScriptDeclaration, TypeScriptModule, TypeScriptValue}; -use radroots_identity::username::{MAX_LENGTH, MIN_LENGTH}; - -pub fn constants_module() -> TypeScriptModule { - TypeScriptModule::new("src/generated/constants.ts") - .with_declaration(TypeScriptDeclaration::constant( - "RADROOTS_USERNAME_MIN_LENGTH", - None, - usize_value(MIN_LENGTH), - )) - .with_declaration(TypeScriptDeclaration::constant( - "RADROOTS_USERNAME_MAX_LENGTH", - None, - usize_value(MAX_LENGTH), - )) -} - -fn usize_value(value: usize) -> TypeScriptValue { - TypeScriptValue::number(i64::try_from(value).expect("TypeScript constant fits in i64")) -} - -#[cfg(test)] -mod tests { - use super::{MAX_LENGTH, MIN_LENGTH, constants_module}; - - #[test] - fn preserves_username_constant_exports() { - let rendered = constants_module().render_source(); - assert!(rendered.contains("RADROOTS_USERNAME_MIN_LENGTH")); - assert!(rendered.contains(&MIN_LENGTH.to_string())); - assert!(rendered.contains("RADROOTS_USERNAME_MAX_LENGTH")); - assert!(rendered.contains(&MAX_LENGTH.to_string())); - assert!(!rendered.contains("REGEX")); - } -} diff --git a/crates/radroots/Cargo.toml b/crates/radroots/Cargo.toml @@ -1,51 +0,0 @@ -[package] -name = "radroots" -description = "Curated ordinary-user Rust facade for Radroots" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -authors.workspace = true -readme = "README.md" -documentation = "https://docs.rs/radroots" -keywords = ["radroots", "local-food", "sdk", "nostr"] -categories = ["api-bindings", "asynchronous", "network-programming"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] -publish = ["crates-io"] -autoexamples = false - -[package.metadata.docs.rs] -features = ["client", "nostr", "nip46", "geonames", "knowledge"] - -[lints] -workspace = true - -[lib] -name = "radroots" -path = "src/lib.rs" - -[dependencies] -radroots_core = { workspace = true } -radroots_event = { workspace = true } -radroots_identity = { workspace = true } -radroots_sdk = { workspace = true } -radroots_trade = { workspace = true } -radroots_transport = { workspace = true } - -[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"] - -[[example]] -name = "ordinary_memory" -path = "examples/ordinary_memory.rs" -required-features = ["client"] diff --git a/crates/radroots/LICENSE-APACHE b/crates/radroots/LICENSE-APACHE @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright 2026 Tyson Lupul - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/crates/radroots/LICENSE-MIT b/crates/radroots/LICENSE-MIT @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2026 Tyson Lupul - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/crates/radroots/README.md b/crates/radroots/README.md @@ -1,94 +0,0 @@ -# radroots - -`radroots` is the canonical ordinary-user Rust package for Radroots. It offers -safe local construction, deliberate domain paths, and the common client -boundary without exposing every lower crate or duplicating the advanced SDK -engine. - -The package is enabled for validation-only release qualification. After the -separately approved publication gate, the onboarding command is: - -```sh -cargo add radroots -``` - -## Start locally - -The default `client` feature enables deterministic in-process memory storage. -Construction creates no file, contacts no network or daemon, reads no keyring, -installs no runtime or subscriber, and starts no worker. The initial transport -profile is explicitly local-only. - -```rust -# async fn run() -> radroots::Result<()> { -let client = radroots::client::memory().build()?; -let profile = radroots::client::local_only(); -assert!(profile.is_local_only()); - -// Clone handles share lifecycle state; shutdown is explicit and asynchronous. -client.close().await?; -# Ok(()) -# } -``` - -The default memory generation is process-local and deterministic. Hosts that -persist cursors or compose their own storage generation should use -`radroots_sdk::ClientBuilder` directly. - -## Product operations - -Farm, listing, and trade writes follow one reliability boundary: - -1. `prepare` validates and freezes a side-effect-free plan. -2. The configured signer authorizes and signs that plan. -3. Enqueue durably accepts it under an operation ID and idempotency key. -4. Delivery runs only for an explicitly selected transport profile. - -Cancellation before durable acceptance makes no commit claim. Cancellation -after acceptance cannot roll back the accepted event; resume with the same -idempotency identity and inspect the canonical receipt. Public event models do -not contain exact farm coordinates, private trade terms, credentials, or key -material. - -Errors expose stable classifications and recovery metadata while retaining -their lower source chain for local diagnostics. Display, debug, diagnostics, -and protocol reports redact bearer credentials and signer material. Canonical -lower crates own serialization and versioned wire contracts; facade aliases -are Rust paths, not a second persistence format. - -## Features - -| Feature | Behavior | -| --- | --- | -| `client` | safe memory-backed client; the default | -| `native` | explicit SQLite, sync, and local-signing SDK capabilities | -| `nostr` | explicit Nostr source/sink composition | -| `nip46` | host-owned NIP-46 signer composition; implies `nostr` | -| `radrootsd` | explicitly invoked daemon delivery | -| `geonames` | concrete GeoNames provider capability | -| `knowledge` | canonical knowledge event contracts | -| `full` | the governed complete SDK capability bundle | - -Enabling a feature compiles capability; it never performs I/O. Native storage -opens only through `client::native`, transport is caller-injected, and daemon -delivery happens only when its `deliver` method is invoked. - -## When to use radroots_sdk - -Use `radroots` for ordinary applications and the primary farm, listing, trade, -identity, event, and transport paths. Use `radroots_sdk` directly when the host -must own source-generation identity, inject arbitrary storage/signing/sync -implementations, coordinate FFI/mobile lifecycle, or inspect advanced -capability and diagnostics contracts. There is intentionally no -`radroots::sdk` namespace or wildcard SDK reexport. - -The namespace separation is a compiled contract: - -```compile_fail -use radroots::sdk; -``` - -The normative package charter is the [`radroots` release-v1 -specification](../../docs/specs/radroots_crates_release_v1.md#19-radroots). -The reviewed pre-release public API is recorded in the -[`radroots` baseline](../../docs/api/radroots-0.1.0-alpha.txt). diff --git a/crates/radroots/examples/ordinary_memory.rs b/crates/radroots/examples/ordinary_memory.rs @@ -1,10 +0,0 @@ -//! Constructs the ordinary, safe, local-only Radroots front door. - -fn main() -> radroots::Result<()> { - let client = radroots::client::memory().build()?; - let profile = radroots::client::local_only(); - - assert!(!client.is_closed()); - assert!(profile.is_local_only()); - Ok(()) -} diff --git a/crates/radroots/src/client.rs b/crates/radroots/src/client.rs @@ -1,145 +0,0 @@ -//! Curated client construction and operation entry points. - -#[cfg(any(feature = "nostr", feature = "full"))] -use std::sync::Arc; - -#[cfg(any(feature = "nostr", feature = "full"))] -use radroots_transport::{EventSink, EventSource}; - -#[cfg(any( - feature = "client", - feature = "native", - feature = "nostr", - feature = "nip46", - feature = "full" -))] -use crate::ClientBuilder; -use crate::transport::Profile; - -/// Creates the safe ordinary client builder with deterministic in-process -/// storage and no transport, signer, runtime, worker, or file authority. -#[cfg(feature = "client")] -#[must_use] -pub fn memory() -> ClientBuilder { - ClientBuilder::memory_default() -} - -/// Returns the explicit no-transport profile used by ordinary local work. -#[must_use] -pub const fn local_only() -> Profile { - Profile::local_only() -} - -/// Adds caller-owned source and sink capabilities without connecting them or -/// selecting a fallback transport. -#[cfg(any(feature = "nostr", feature = "full"))] -#[must_use] -pub fn with_transport( - builder: ClientBuilder, - source: Arc<dyn EventSource>, - sink: Arc<dyn EventSink>, -) -> ClientBuilder { - builder.source(source).sink(sink) -} - -/// Adds an explicitly constructed NIP-46 signer provider. -#[cfg(any(feature = "nip46", feature = "full"))] -#[must_use] -#[cfg_attr(coverage_nightly, coverage(off))] -pub fn with_nip46_signer( - builder: ClientBuilder, - provider: crate::signing::Provider, -) -> ClientBuilder { - builder.signing(provider) -} - -/// Explicitly opens validated native SQLite storage. -#[cfg(any(feature = "native", feature = "full"))] -#[cfg_attr(coverage_nightly, coverage(off))] -pub async fn native(options: crate::storage::SqliteOptions) -> crate::Result<ClientBuilder> { - ClientBuilder::sqlite(options).await -} - -/// Reports whether the concrete GeoNames capability was selected at compile -/// time. Asset inspection, acquisition, and database opening remain explicit. -#[cfg(any(feature = "geonames", feature = "full"))] -#[must_use] -pub const fn geonames_enabled() -> bool { - true -} - -/// Reports whether canonical knowledge contracts were selected at compile -/// time. Merely selecting the feature performs no event or storage operation. -#[cfg(any(feature = "knowledge", feature = "full"))] -#[must_use] -pub const fn knowledge_enabled() -> bool { - true -} - -#[cfg(all(test, feature = "full"))] -mod tests { - use super::{ - Arc, EventSink, EventSource, Profile, geonames_enabled, knowledge_enabled, local_only, - memory, with_transport, - }; - use radroots_transport::{ - DeliveryReceipt, DeliveryRequest, Error as TransportError, FetchPage, FetchRequest, - SinkFailure, SinkStatus, SourceStatus, outcome::Retryability, - source::BoxFuture as TransportFuture, - }; - - struct TestSource; - struct TestSink; - - impl EventSource for TestSource { - fn status(&self) -> TransportFuture<'_, Result<SourceStatus, TransportError>> { - Box::pin(async { Err(TransportError::UnsupportedOperation) }) - } - - fn fetch( - &self, - _request: FetchRequest, - ) -> TransportFuture<'_, Result<FetchPage, TransportError>> { - Box::pin(async { Err(TransportError::UnsupportedOperation) }) - } - } - - impl EventSink for TestSink { - fn status(&self) -> TransportFuture<'_, Result<SinkStatus, TransportError>> { - Box::pin(async { Err(TransportError::UnsupportedOperation) }) - } - - fn deliver( - &self, - request: DeliveryRequest, - ) -> TransportFuture<'_, Result<DeliveryReceipt, SinkFailure>> { - Box::pin(async move { - Err(SinkFailure::for_request( - &request, - "test_sink_unavailable", - Retryability::Terminal, - None, - None, - Vec::new(), - ) - .expect("test sink failure")) - }) - } - } - - #[test] - fn curated_builders_cover_every_directly_testable_entry_point() { - let local = memory().build().expect("memory client"); - assert_eq!(local_only(), Profile::local_only()); - assert!(local.source().expect("source").is_none()); - - let composed = with_transport(memory(), Arc::new(TestSource), Arc::new(TestSink)) - .build() - .expect("transport client"); - assert!(composed.source().expect("source").is_some()); - assert!(composed.sink().expect("sink").is_some()); - - assert!(geonames_enabled()); - assert!(knowledge_enabled()); - } -} diff --git a/crates/radroots/src/event.rs b/crates/radroots/src/event.rs @@ -1,5 +0,0 @@ -//! Curated event authoring and inspection entry points. - -pub use radroots_event::{ - Error, Event, EventId, EventKind, EventTag, GenericEventDraft, SignedEvent, VerifiedEvent, -}; diff --git a/crates/radroots/src/farm.rs b/crates/radroots/src/farm.rs @@ -1,4 +0,0 @@ -//! Curated farm-domain entry points. - -pub use radroots_event::farm::{Farm, FarmPublicLocation, FarmRef}; -pub use radroots_sdk::farm::{Plan, PrepareError, PrepareErrorKind, PrepareRequest, prepare}; diff --git a/crates/radroots/src/identity.rs b/crates/radroots/src/identity.rs @@ -1,5 +0,0 @@ -//! Curated public identity entry points. - -pub use radroots_identity::{ - AccountId, Error, IdentityId, Profile, PublicIdentity, PublicKey, Username, -}; diff --git a/crates/radroots/src/knowledge.rs b/crates/radroots/src/knowledge.rs @@ -1,8 +0,0 @@ -//! Curated knowledge-domain entry points. - -#[cfg(any(feature = "knowledge", feature = "full"))] -pub use radroots_event::knowledge::{ - AddressableRef, KnowledgeClaim, KnowledgeFieldReport, KnowledgeRelation, KnowledgeReview, - KnowledgeSource, KnowledgeValidationError, WikiArticle, WikiDTagError, WikiMergeRequest, - WikiRedirect, normalize_wiki_d_tag, validate_wiki_d_tag, -}; diff --git a/crates/radroots/src/lib.rs b/crates/radroots/src/lib.rs @@ -1,18 +0,0 @@ -#![forbid(unsafe_code)] -#![cfg_attr(coverage_nightly, feature(coverage_attribute))] -#![warn(missing_docs)] -#![doc = include_str!("../README.md")] - -pub mod client; -pub mod event; -pub mod farm; -pub mod identity; -pub mod knowledge; -pub mod listing; -pub mod signing; -pub mod storage; -pub mod sync; -pub mod trade; -pub mod transport; - -pub use radroots_sdk::{Client, ClientBuilder, Error, Result}; diff --git a/crates/radroots/src/listing.rs b/crates/radroots/src/listing.rs @@ -1,12 +0,0 @@ -//! Curated listing-domain entry points. - -pub use radroots_event::listing::operational::{ - OperationalListing, OperationalListingAvailability, OperationalListingBin, - OperationalListingDeliveryMethod, OperationalListingImage, OperationalListingProduct, - OperationalListingPublicLocation, OperationalListingStatus, -}; -pub use radroots_sdk::listing::{ - Action, Plan, PrepareError, PrepareErrorKind, PrepareRequest, - RadrootsOperationalListingEditDocumentV1 as EditV1, - RadrootsOperationalListingLifecycleState as Lifecycle, prepare, -}; diff --git a/crates/radroots/src/signing.rs b/crates/radroots/src/signing.rs @@ -1,3 +0,0 @@ -//! Curated signing entry points. - -pub use radroots_sdk::signing::{Mode, Provider}; diff --git a/crates/radroots/src/storage.rs b/crates/radroots/src/storage.rs @@ -1,6 +0,0 @@ -//! Curated storage entry points. - -pub use radroots_sdk::storage::{IntegrityStatus, Operations, Status}; - -#[cfg(any(feature = "native", feature = "full"))] -pub use radroots_sdk::storage::{SqliteOpenMode, SqliteOptions, SqlitePaths}; diff --git a/crates/radroots/src/sync.rs b/crates/radroots/src/sync.rs @@ -1 +0,0 @@ -//! Curated synchronization entry points. diff --git a/crates/radroots/src/trade.rs b/crates/radroots/src/trade.rs @@ -1,13 +0,0 @@ -//! Curated trade-domain entry points. - -pub use radroots_core::{Currency, Decimal, Money, Percent, Quantity, QuantityPrice, Unit}; -pub use radroots_event::trade::{ - FulfillmentProfileV1, TradeCancellationProfileV1, TradeCandidateLineV1, TradeCandidateTermsV1, - TradeDecisionV1, TradeEconomicAdjustmentV1, TradeEconomicsProfileV1, - TradeMutationBodyV1 as MutationBodyV1, TradeMutationEnvelopeV1 as MutationV1, - TradeMutationKindV1 as MutationKindV1, TradePrivateTermsRefV1, -}; -pub use radroots_sdk::trade::{ - Plan, PrepareError, PrepareErrorKind, PrepareRequest, prepare, project, -}; -pub use radroots_trade::{Projection, ReducerIssue, ReductionInput, ValidationError, WorkflowPlan}; diff --git a/crates/radroots/src/transport.rs b/crates/radroots/src/transport.rs @@ -1,10 +0,0 @@ -//! Curated transport entry points. - -pub use radroots_sdk::transport::Profile; -pub use radroots_transport::{ - Error, EventSink, EventSource, Target, TargetSet, TransportId, - policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, -}; - -#[cfg(any(feature = "radrootsd", feature = "full"))] -pub use radroots_sdk::transport::{DaemonAuth, DaemonConfig, DaemonDelivery, DaemonError}; diff --git a/crates/radroots/tests/package_boundary.rs b/crates/radroots/tests/package_boundary.rs @@ -1,93 +0,0 @@ -use std::collections::BTreeSet; - -const MANIFEST: &str = include_str!("../Cargo.toml"); -const WORKSPACE: &str = include_str!("../../../Cargo.toml"); -const ROOT: &str = include_str!("../src/lib.rs"); -const README: &str = include_str!("../README.md"); - -#[test] -fn package_has_final_identity_and_exact_dependencies() { - assert!(MANIFEST.contains("name = \"radroots\"")); - assert!(MANIFEST.contains("publish = [\"crates-io\"]")); - assert_eq!( - dependency_names(MANIFEST), - BTreeSet::from([ - "radroots_core", - "radroots_event", - "radroots_identity", - "radroots_sdk", - "radroots_trade", - "radroots_transport", - ]) - ); - assert!(WORKSPACE.contains( - "radroots_sdk = { path = \"crates/sdk\", version = \"=0.1.0-alpha\", default-features = false }" - )); -} - -#[test] -fn features_forward_the_exact_user_oriented_graph() { - for forwarding in [ - "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\"]", - ] { - assert!(MANIFEST.contains(forwarding), "missing `{forwarding}`"); - } - for forbidden in ["reticulum", "mesh", "simplex", "nostrdb", "replica", "sp1"] { - assert!( - !MANIFEST.to_ascii_lowercase().contains(forbidden), - "forbidden feature vocabulary `{forbidden}`" - ); - } -} - -#[test] -fn root_has_exact_exports_and_no_sdk_namespace() { - assert!(ROOT.contains("pub use radroots_sdk::{Client, ClientBuilder, Error, Result};")); - assert!(!ROOT.contains("pub mod sdk")); - assert!(!ROOT.contains("pub use radroots_sdk::*")); - assert_eq!(ROOT.matches("pub use ").count(), 1); - assert!(README.contains("```compile_fail\nuse radroots::sdk;\n```")); -} - -#[test] -fn modules_use_deliberate_reexports_without_wildcards() { - let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - for entry in std::fs::read_dir(source_root).expect("read facade source") { - let path = entry.expect("read facade source entry").path(); - if path.extension().and_then(|value| value.to_str()) != Some("rs") { - continue; - } - let source = std::fs::read_to_string(&path).expect("read facade module"); - assert!( - !source.contains("::*"), - "{} contains a wildcard reexport", - path.display() - ); - assert!( - !source.contains("pub mod sdk"), - "{} exposes the forbidden SDK namespace", - path.display() - ); - } -} - -fn dependency_names(manifest: &str) -> BTreeSet<&str> { - manifest - .split_once("[dependencies]") - .expect("dependency section") - .1 - .split_once("\n[") - .expect("section after dependencies") - .0 - .lines() - .filter_map(|line| line.split_once(" = ").map(|(name, _)| name.trim())) - .collect() -} diff --git a/crates/radroots/tests/public_surface.rs b/crates/radroots/tests/public_surface.rs @@ -1,120 +0,0 @@ -#[test] -fn approved_module_skeleton_is_public() { - #[allow(unused_imports)] - use radroots::{ - client, event, farm, identity, knowledge, listing, signing, storage, sync, trade, transport, - }; -} - -#[test] -fn root_exports_only_the_client_boundary() { - fn assert_client<T: Clone + Send + Sync>() {} - fn assert_builder<T: Send + Sync>() {} - fn assert_error<T: std::error::Error + Send + Sync + 'static>() {} - - assert_client::<radroots::Client>(); - assert_builder::<radroots::ClientBuilder>(); - assert_error::<radroots::Error>(); - let result: radroots::Result<()> = Ok(()); - assert!(result.is_ok()); -} - -#[test] -fn canonical_domain_paths_compile() { - #[allow(unused_imports)] - use radroots::{ - event::{Event, EventId, GenericEventDraft}, - farm::{Farm, FarmPublicLocation, Plan as FarmPlan, PrepareRequest as FarmRequest}, - identity::{AccountId, PublicKey, Username}, - listing::{EditV1, OperationalListing, Plan as ListingPlan}, - signing::{Mode, Provider}, - storage::{IntegrityStatus, Operations as StorageOperations, Status}, - trade::{Money, MutationV1, Plan as TradePlan, Projection}, - transport::{Profile, SatisfactionPolicy, Target, TargetSet, TransportId}, - }; -} - -#[cfg(feature = "client")] -#[test] -fn ordinary_memory_and_local_only_construction_is_inert() { - let client = radroots::client::memory().build().expect("memory client"); - assert!(!client.is_closed()); - assert!(radroots::client::local_only().is_local_only()); -} - -#[cfg(any(feature = "nostr", feature = "full"))] -#[test] -fn explicit_transport_composition_is_inert() { - use std::sync::Arc; - - use radroots::transport::{EventSink, EventSource}; - use radroots_transport::{ - DeliveryReceipt, DeliveryRequest, Error, FetchPage, FetchRequest, SinkFailure, SinkStatus, - SourceStatus, outcome::Retryability, source::BoxFuture, - }; - - struct Source; - struct Sink; - - impl EventSource for Source { - fn status(&self) -> BoxFuture<'_, Result<SourceStatus, Error>> { - Box::pin(async { Err(Error::UnsupportedOperation) }) - } - - fn fetch(&self, _request: FetchRequest) -> BoxFuture<'_, Result<FetchPage, Error>> { - Box::pin(async { Err(Error::UnsupportedOperation) }) - } - } - - impl EventSink for Sink { - fn status(&self) -> BoxFuture<'_, Result<SinkStatus, Error>> { - Box::pin(async { Err(Error::UnsupportedOperation) }) - } - - fn deliver( - &self, - request: DeliveryRequest, - ) -> BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> { - Box::pin(async move { - Err(SinkFailure::for_request( - &request, - "test_sink_unavailable", - Retryability::Terminal, - None, - None, - Vec::new(), - ) - .expect("test sink failure")) - }) - } - } - - let client = radroots::client::with_transport( - radroots::client::memory(), - Arc::new(Source), - Arc::new(Sink), - ) - .build() - .expect("explicit transport client"); - assert!(client.source().expect("source").is_some()); - assert!(client.sink().expect("sink").is_some()); -} - -#[cfg(any(feature = "geonames", feature = "full"))] -#[test] -fn geonames_selection_is_explicit() { - assert!(radroots::client::geonames_enabled()); -} - -#[cfg(any(feature = "knowledge", feature = "full"))] -#[test] -fn knowledge_selection_is_explicit() { - assert!(radroots::client::knowledge_enabled()); -} - -#[cfg(any(feature = "knowledge", feature = "full"))] -#[test] -fn canonical_knowledge_paths_compile() { - #[allow(unused_imports)] - use radroots::knowledge::{KnowledgeClaim, WikiArticle, normalize_wiki_d_tag}; -} diff --git a/crates/replica_schema_bindings/Cargo.toml b/crates/replica_schema_bindings/Cargo.toml @@ -1,14 +0,0 @@ -[package] -name = "radroots_replica_schema_bindings" -description = "Generated bindings for Radroots replica schemas" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -publish = false - -[dependencies] -dto_bindgen_core = { workspace = true } -radroots_replica_schema = { workspace = true, features = ["dto-bindgen"] } diff --git a/crates/replica_schema_bindings/src/lib.rs b/crates/replica_schema_bindings/src/lib.rs @@ -1,31 +0,0 @@ -pub use radroots_replica_schema as upstream; - -pub fn dto_registry() -> dto_bindgen_core::Registry { - upstream::dto::dto_registry() -} - -#[cfg(test)] -mod tests { - use super::dto_registry; - - #[test] - fn preserves_replica_schema_registry_exports() { - let registry = dto_registry(); - let actual = registry - .types_by_id - .values() - .map(|type_def| match type_def { - dto_bindgen_core::TypeDef::Struct(def) => def.export_name.as_str(), - dto_bindgen_core::TypeDef::Enum(def) => def.export_name.as_str(), - }) - .collect::<Vec<_>>(); - - assert!(actual.contains(&"Farm")); - assert!(actual.contains(&"GcsLocation")); - assert!(actual.contains(&"IGcsLocationFindMany")); - assert!(actual.contains(&"IGcsLocationFindManyResolve")); - assert!(actual.contains(&"IMediaImageFindMany")); - assert!(actual.contains(&"INostrProfileFindMany")); - assert!(actual.contains(&"INostrRelayFindMany")); - } -} diff --git a/crates/replica_store_wasm/Cargo.toml b/crates/replica_store_wasm/Cargo.toml @@ -1,33 +0,0 @@ -[package] -name = "radroots_replica_store_wasm" -publish = false -version = "0.1.0-alpha" -edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] -rust-version.workspace = true -license.workspace = true -description = "WASM boundary for Radroots replica store access" -repository.workspace = true -homepage.workspace = true -readme = "README" - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -radroots_sql_core = { workspace = true, features = ["web"] } -radroots_sdk_sql_wasm_runtime = { workspace = true } -radroots_replica_store = { workspace = true } -radroots_replica_schema = { workspace = true } -radroots_replica_sync = { workspace = true, features = ["std"] } -js-sys = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -serde-wasm-bindgen = { workspace = true } -wasm-bindgen = { workspace = true } - -[dev-dependencies] -wasm-bindgen-test = { workspace = true } - -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/replica_store_wasm/README b/crates/replica_store_wasm/README @@ -1,25 +0,0 @@ -# radroots_replica_store_wasm - -This is the README for `radroots_replica_store_wasm`, which provides WebAssembly -bindings for `radroots_replica_store` in the `radroots` core libraries. - -## Overview - - * a wasm32-only wrapper around the replica store runtime surface; - * integration with `radroots_sql_wasm_core` for browser and worker SQL - execution; - * a small target-specific entry point layer rather than a separate database - implementation; - * intended for JavaScript-facing callers that need the replica store APIs - in wasm builds. - -## Copyright - -Except as otherwise noted, all files in the `radroots_replica_store_wasm` -distribution are - - Copyright (c) 2026 Tyson Lupul - -For information on usage and redistribution, and for a DISCLAIMER OF ALL -WARRANTIES, see LICENSE included in the `radroots_replica_store_wasm` -distribution. diff --git a/crates/replica_store_wasm/src/lib.rs b/crates/replica_store_wasm/src/lib.rs @@ -1,10 +0,0 @@ -#![forbid(unsafe_code)] - -mod snapshot; -#[cfg(target_arch = "wasm32")] -mod utils; -#[cfg(target_arch = "wasm32")] -mod wasm_impl; -pub use snapshot::*; -#[cfg(target_arch = "wasm32")] -pub use wasm_impl::*; diff --git a/crates/replica_store_wasm/src/snapshot.rs b/crates/replica_store_wasm/src/snapshot.rs @@ -1,87 +0,0 @@ -use radroots_replica_store::ReplicaStoreExportManifestRs; - -pub const EXPORT_MANIFEST_FIELD: &str = "manifest_rs"; -pub const EXPORT_DB_BYTES_FIELD: &str = "db_bytes"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExportManifestSummary { - pub export_version: String, - pub replica_store_version: String, - pub backup_format_version: String, - pub schema_hash: String, - pub schema_table_count: usize, - pub migration_count: usize, - pub table_count_count: usize, -} - -pub fn synced_export_error(pending_count: usize, expected_count: usize) -> Option<String> { - if pending_count == 0 { - None - } else { - Some(format!( - "replica store export requires synced state (pending {pending_count}/{expected_count})" - )) - } -} - -pub fn export_manifest_summary(manifest: &ReplicaStoreExportManifestRs) -> ExportManifestSummary { - ExportManifestSummary { - export_version: manifest.export_version.clone(), - replica_store_version: manifest.replica_store_version.clone(), - backup_format_version: manifest.backup_format_version.clone(), - schema_hash: manifest.schema_hash.clone(), - schema_table_count: manifest.schema.len(), - migration_count: manifest.migrations.len(), - table_count_count: manifest.table_counts.len(), - } -} - -#[cfg(test)] -mod tests { - use super::{ - EXPORT_DB_BYTES_FIELD, EXPORT_MANIFEST_FIELD, export_manifest_summary, synced_export_error, - }; - - fn manifest() -> radroots_replica_store::ReplicaStoreExportManifestRs { - radroots_replica_store::ReplicaStoreExportManifestRs { - export_version: "1".to_owned(), - replica_store_version: "0.1.0".to_owned(), - backup_format_version: "1".to_owned(), - schema_hash: "schema-hash".to_owned(), - schema: Vec::new(), - migrations: Vec::new(), - table_counts: Vec::new(), - } - } - - #[test] - fn export_snapshot_field_names_are_stable() { - assert_eq!(EXPORT_MANIFEST_FIELD, "manifest_rs"); - assert_eq!(EXPORT_DB_BYTES_FIELD, "db_bytes"); - } - - #[test] - fn synced_export_error_allows_empty_pending_queue() { - assert_eq!(synced_export_error(0, 4), None); - } - - #[test] - fn synced_export_error_reports_pending_and_expected_counts() { - assert_eq!( - synced_export_error(2, 4).expect("error"), - "replica store export requires synced state (pending 2/4)" - ); - } - - #[test] - fn export_manifest_summary_preserves_versions_and_counts() { - let summary = export_manifest_summary(&manifest()); - assert_eq!(summary.export_version, "1"); - assert_eq!(summary.replica_store_version, "0.1.0"); - assert_eq!(summary.backup_format_version, "1"); - assert_eq!(summary.schema_hash, "schema-hash"); - assert_eq!(summary.schema_table_count, 0); - assert_eq!(summary.migration_count, 0); - assert_eq!(summary.table_count_count, 0); - } -} diff --git a/crates/replica_store_wasm/src/utils.rs b/crates/replica_store_wasm/src/utils.rs @@ -1,13 +0,0 @@ -use serde::Serialize; -use wasm_bindgen::prelude::*; - -use radroots_sql_core::SqlError; - -pub fn value_to_js<T>(value: T) -> Result<JsValue, JsValue> -where - T: Serialize, -{ - let json = serde_json::to_string(&value) - .map_err(|err| radroots_sdk_sql_wasm_runtime::err_js(SqlError::from(err)))?; - Ok(JsValue::from_str(&json)) -} diff --git a/crates/replica_store_wasm/src/wasm_impl.rs b/crates/replica_store_wasm/src/wasm_impl.rs @@ -1,923 +0,0 @@ -use crate::{ - snapshot::{EXPORT_DB_BYTES_FIELD, EXPORT_MANIFEST_FIELD, synced_export_error}, - utils::value_to_js, -}; -use radroots_replica_store::migrations; -use radroots_replica_store::{ReplicaStoreExportManifestRs, export_manifest}; -use radroots_replica_sync::radroots_replica_sync_status; -use radroots_sdk_sql_wasm_runtime::parse_json; -use radroots_sdk_sql_wasm_runtime::{ - WasmSqlExecutor, err_js, export_bytes, export_lock_begin, export_lock_end, - with_export_lock_bypass, -}; -use wasm_bindgen::JsValue; -use wasm_bindgen::prelude::*; - -use radroots_replica_schema::farm::{ - IFarmCreate, IFarmDelete, IFarmFindMany, IFarmFindOne, IFarmUpdate, -}; - -use radroots_replica_schema::farm_gcs_location::{ - IFarmGcsLocationCreate, IFarmGcsLocationDelete, IFarmGcsLocationFindMany, - IFarmGcsLocationFindOne, IFarmGcsLocationUpdate, -}; - -use radroots_replica_schema::farm_member::{ - IFarmMemberCreate, IFarmMemberDelete, IFarmMemberFindMany, IFarmMemberFindOne, - IFarmMemberUpdate, -}; - -use radroots_replica_schema::farm_member_claim::{ - IFarmMemberClaimCreate, IFarmMemberClaimDelete, IFarmMemberClaimFindMany, - IFarmMemberClaimFindOne, IFarmMemberClaimUpdate, -}; - -use radroots_replica_schema::farm_tag::{ - IFarmTagCreate, IFarmTagDelete, IFarmTagFindMany, IFarmTagFindOne, IFarmTagUpdate, -}; - -use radroots_replica_schema::gcs_location::{ - IGcsLocationCreate, IGcsLocationDelete, IGcsLocationFindMany, IGcsLocationFindOne, - IGcsLocationUpdate, -}; - -use radroots_replica_schema::log_error::{ - ILogErrorCreate, ILogErrorDelete, ILogErrorFindMany, ILogErrorFindOne, ILogErrorUpdate, -}; - -use radroots_replica_schema::media_image::{ - IMediaImageCreate, IMediaImageDelete, IMediaImageFindMany, IMediaImageFindOne, - IMediaImageUpdate, -}; - -use radroots_replica_schema::nostr_profile::{ - INostrProfileCreate, INostrProfileDelete, INostrProfileFindMany, INostrProfileFindOne, - INostrProfileUpdate, -}; - -use radroots_replica_schema::nostr_event_head::{ - INostrEventHeadCreate, INostrEventHeadDelete, INostrEventHeadFindMany, INostrEventHeadFindOne, - INostrEventHeadUpdate, -}; - -use radroots_replica_schema::nostr_relay::{ - INostrRelayCreate, INostrRelayDelete, INostrRelayFindMany, INostrRelayFindOne, - INostrRelayUpdate, -}; - -use radroots_replica_schema::trade_product::{ - ITradeProductCreate, ITradeProductDelete, ITradeProductFindMany, ITradeProductFindOne, - ITradeProductUpdate, -}; - -use radroots_replica_schema::plot::{ - IPlotCreate, IPlotDelete, IPlotFindMany, IPlotFindOne, IPlotUpdate, -}; - -use radroots_replica_schema::plot_gcs_location::{ - IPlotGcsLocationCreate, IPlotGcsLocationDelete, IPlotGcsLocationFindMany, - IPlotGcsLocationFindOne, IPlotGcsLocationUpdate, -}; - -use radroots_replica_schema::plot_tag::{ - IPlotTagCreate, IPlotTagDelete, IPlotTagFindMany, IPlotTagFindOne, IPlotTagUpdate, -}; - -use radroots_replica_schema::nostr_profile_relay::INostrProfileRelayRelation; - -use radroots_replica_schema::trade_product_location::ITradeProductLocationRelation; - -use radroots_replica_schema::trade_product_media::ITradeProductMediaRelation; - -#[wasm_bindgen(js_name = replica_store_run_migrations)] -pub fn replica_store_run_migrations() -> Result<(), JsValue> { - let exec = WasmSqlExecutor::new(); - migrations::run_all_up(&exec).map_err(err_js) -} - -#[wasm_bindgen(js_name = replica_store_reset_database)] -pub fn replica_store_reset_database() -> Result<(), JsValue> { - let exec = WasmSqlExecutor::new(); - migrations::run_all_down(&exec).map_err(err_js) -} - -#[wasm_bindgen(js_name = replica_store_export_json)] -pub fn replica_store_export_json() -> Result<JsValue, JsValue> { - let exec = WasmSqlExecutor::new(); - let dump = radroots_replica_store::backup::export_database_backup(&exec).map_err(err_js)?; - value_to_js(dump) -} - -#[wasm_bindgen(js_name = replica_store_import_json)] -pub fn replica_store_import_json(dump_json: &str) -> Result<(), JsValue> { - let exec = WasmSqlExecutor::new(); - radroots_replica_store::backup::restore_database_backup_json(&exec, dump_json).map_err(err_js) -} - -#[wasm_bindgen(js_name = replica_store_export_begin)] -pub fn replica_store_export_begin() -> Result<JsValue, JsValue> { - export_lock_begin().map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let result = with_export_lock_bypass(|| export_snapshot(&exec)); - match result { - Ok(value) => Ok(value), - Err(err) => { - export_lock_end(); - Err(err) - } - } -} - -#[wasm_bindgen(js_name = replica_store_export_finish)] -pub fn replica_store_export_finish() -> Result<(), JsValue> { - export_lock_end(); - Ok(()) -} - -fn export_snapshot(exec: &WasmSqlExecutor) -> Result<JsValue, JsValue> { - let status = radroots_replica_sync_status(exec).map_err(|err| { - err_js(radroots_sql_core::SqlError::InvalidArgument( - err.to_string(), - )) - })?; - if let Some(message) = synced_export_error(status.pending_count, status.expected_count) { - return Err(err_js(radroots_sql_core::SqlError::InvalidArgument( - message, - ))); - } - let manifest = export_manifest(exec).map_err(err_js)?; - export_snapshot_value(manifest) -} - -fn export_snapshot_value(manifest: ReplicaStoreExportManifestRs) -> Result<JsValue, JsValue> { - let bytes_js = export_bytes(); - export_snapshot_value_with_bytes(manifest, bytes_js) -} - -fn export_snapshot_value_with_bytes( - manifest: ReplicaStoreExportManifestRs, - bytes_js: JsValue, -) -> Result<JsValue, JsValue> { - let manifest_js = serde_wasm_bindgen::to_value(&manifest).map_err(|err| { - err_js(radroots_sql_core::SqlError::SerializationError( - err.to_string(), - )) - })?; - let obj = js_sys::Object::new(); - js_sys::Reflect::set( - &obj, - &JsValue::from_str(EXPORT_MANIFEST_FIELD), - &manifest_js, - ) - .map_err(|_| err_js(radroots_sql_core::SqlError::Internal))?; - js_sys::Reflect::set(&obj, &JsValue::from_str(EXPORT_DB_BYTES_FIELD), &bytes_js) - .map_err(|_| err_js(radroots_sql_core::SqlError::Internal))?; - Ok(JsValue::from(obj)) -} - -#[cfg(all(test, target_arch = "wasm32"))] -mod tests { - use super::export_snapshot_value_with_bytes; - use js_sys::{Reflect, Uint8Array}; - use wasm_bindgen::JsValue; - - #[wasm_bindgen_test::wasm_bindgen_test] - fn export_snapshot_value_includes_fields() { - let manifest = radroots_replica_store::ReplicaStoreExportManifestRs { - export_version: "1".to_string(), - replica_store_version: "0.0.0".to_string(), - backup_format_version: "0.0.0".to_string(), - schema_hash: "hash".to_string(), - schema: Vec::new(), - migrations: Vec::new(), - table_counts: Vec::new(), - }; - let bytes = Uint8Array::new_with_length(2); - let js = - export_snapshot_value_with_bytes(manifest, JsValue::from(bytes)).expect("snapshot"); - let manifest_rs = - Reflect::get(&js, &JsValue::from_str("manifest_rs")).expect("manifest_rs"); - let db_bytes = Reflect::get(&js, &JsValue::from_str("db_bytes")).expect("db_bytes"); - assert!(manifest_rs.is_object()); - assert!(db_bytes.is_object()); - } -} - -#[wasm_bindgen(js_name = replica_store_farm_create)] -pub fn replica_store_farm_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm::create(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_find_one)] -pub fn replica_store_farm_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm::find_one(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_find_many)] -pub fn replica_store_farm_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm::find_many(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_update)] -pub fn replica_store_farm_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm::update(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_delete)] -pub fn replica_store_farm_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm::delete(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_create)] -pub fn replica_store_plot_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::plot::create(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_find_one)] -pub fn replica_store_plot_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::plot::find_one(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_find_many)] -pub fn replica_store_plot_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::plot::find_many(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_update)] -pub fn replica_store_plot_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::plot::update(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_delete)] -pub fn replica_store_plot_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::plot::delete(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_gcs_location_create)] -pub fn replica_store_gcs_location_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IGcsLocationCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::gcs_location::create(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_gcs_location_find_one)] -pub fn replica_store_gcs_location_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IGcsLocationFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::gcs_location::find_one(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_gcs_location_find_many)] -pub fn replica_store_gcs_location_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IGcsLocationFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::gcs_location::find_many(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_gcs_location_update)] -pub fn replica_store_gcs_location_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IGcsLocationUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::gcs_location::update(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_gcs_location_delete)] -pub fn replica_store_gcs_location_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IGcsLocationDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::gcs_location::delete(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_gcs_location_create)] -pub fn replica_store_farm_gcs_location_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmGcsLocationCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm_gcs_location::create(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_gcs_location_find_one)] -pub fn replica_store_farm_gcs_location_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmGcsLocationFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm_gcs_location::find_one(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_gcs_location_find_many)] -pub fn replica_store_farm_gcs_location_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmGcsLocationFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm_gcs_location::find_many(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_gcs_location_update)] -pub fn replica_store_farm_gcs_location_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmGcsLocationUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm_gcs_location::update(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_gcs_location_delete)] -pub fn replica_store_farm_gcs_location_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmGcsLocationDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm_gcs_location::delete(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_gcs_location_create)] -pub fn replica_store_plot_gcs_location_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotGcsLocationCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::plot_gcs_location::create(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_gcs_location_find_one)] -pub fn replica_store_plot_gcs_location_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotGcsLocationFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::plot_gcs_location::find_one(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_gcs_location_find_many)] -pub fn replica_store_plot_gcs_location_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotGcsLocationFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::plot_gcs_location::find_many(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_gcs_location_update)] -pub fn replica_store_plot_gcs_location_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotGcsLocationUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::plot_gcs_location::update(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_gcs_location_delete)] -pub fn replica_store_plot_gcs_location_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotGcsLocationDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::plot_gcs_location::delete(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_tag_create)] -pub fn replica_store_farm_tag_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmTagCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::farm_tag::create(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_tag_find_one)] -pub fn replica_store_farm_tag_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmTagFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::farm_tag::find_one(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_tag_find_many)] -pub fn replica_store_farm_tag_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmTagFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::farm_tag::find_many(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_tag_update)] -pub fn replica_store_farm_tag_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmTagUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::farm_tag::update(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_tag_delete)] -pub fn replica_store_farm_tag_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmTagDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::farm_tag::delete(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_tag_create)] -pub fn replica_store_plot_tag_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotTagCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::plot_tag::create(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_tag_find_one)] -pub fn replica_store_plot_tag_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotTagFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::plot_tag::find_one(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_tag_find_many)] -pub fn replica_store_plot_tag_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotTagFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::plot_tag::find_many(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_tag_update)] -pub fn replica_store_plot_tag_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotTagUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::plot_tag::update(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_plot_tag_delete)] -pub fn replica_store_plot_tag_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IPlotTagDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::plot_tag::delete(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_member_create)] -pub fn replica_store_farm_member_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmMemberCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::farm_member::create(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_member_find_one)] -pub fn replica_store_farm_member_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmMemberFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::farm_member::find_one(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_member_find_many)] -pub fn replica_store_farm_member_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmMemberFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm_member::find_many(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_member_update)] -pub fn replica_store_farm_member_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmMemberUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::farm_member::update(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_member_delete)] -pub fn replica_store_farm_member_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmMemberDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::farm_member::delete(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_member_claim_create)] -pub fn replica_store_farm_member_claim_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmMemberClaimCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm_member_claim::create(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_member_claim_find_one)] -pub fn replica_store_farm_member_claim_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmMemberClaimFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm_member_claim::find_one(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_member_claim_find_many)] -pub fn replica_store_farm_member_claim_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmMemberClaimFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm_member_claim::find_many(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_member_claim_update)] -pub fn replica_store_farm_member_claim_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmMemberClaimUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm_member_claim::update(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_farm_member_claim_delete)] -pub fn replica_store_farm_member_claim_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IFarmMemberClaimDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::farm_member_claim::delete(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_log_error_create)] -pub fn replica_store_log_error_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ILogErrorCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::log_error::create(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_log_error_find_one)] -pub fn replica_store_log_error_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ILogErrorFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::log_error::find_one(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_log_error_find_many)] -pub fn replica_store_log_error_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ILogErrorFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::log_error::find_many(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_log_error_update)] -pub fn replica_store_log_error_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ILogErrorUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::log_error::update(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_log_error_delete)] -pub fn replica_store_log_error_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ILogErrorDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::log_error::delete(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_media_image_create)] -pub fn replica_store_media_image_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IMediaImageCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::media_image::create(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_media_image_find_one)] -pub fn replica_store_media_image_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IMediaImageFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::media_image::find_one(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_media_image_find_many)] -pub fn replica_store_media_image_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IMediaImageFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::media_image::find_many(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_media_image_update)] -pub fn replica_store_media_image_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IMediaImageUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::media_image::update(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_media_image_delete)] -pub fn replica_store_media_image_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: IMediaImageDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::media_image::delete(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_profile_create)] -pub fn replica_store_nostr_profile_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrProfileCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::nostr_profile::create(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_profile_find_one)] -pub fn replica_store_nostr_profile_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrProfileFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::nostr_profile::find_one(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_profile_find_many)] -pub fn replica_store_nostr_profile_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrProfileFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::nostr_profile::find_many(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_profile_update)] -pub fn replica_store_nostr_profile_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrProfileUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::nostr_profile::update(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_profile_delete)] -pub fn replica_store_nostr_profile_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrProfileDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::nostr_profile::delete(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_event_head_create)] -pub fn replica_store_nostr_event_head_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrEventHeadCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::nostr_event_head::create(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_event_head_find_one)] -pub fn replica_store_nostr_event_head_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrEventHeadFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::nostr_event_head::find_one(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_event_head_find_many)] -pub fn replica_store_nostr_event_head_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrEventHeadFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::nostr_event_head::find_many(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_event_head_update)] -pub fn replica_store_nostr_event_head_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrEventHeadUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::nostr_event_head::update(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_event_head_delete)] -pub fn replica_store_nostr_event_head_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrEventHeadDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::nostr_event_head::delete(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_relay_create)] -pub fn replica_store_nostr_relay_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrRelayCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::nostr_relay::create(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_relay_find_one)] -pub fn replica_store_nostr_relay_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrRelayFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::nostr_relay::find_one(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_relay_find_many)] -pub fn replica_store_nostr_relay_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrRelayFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::nostr_relay::find_many(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_relay_update)] -pub fn replica_store_nostr_relay_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrRelayUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::nostr_relay::update(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_relay_delete)] -pub fn replica_store_nostr_relay_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrRelayDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::nostr_relay::delete(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_trade_product_create)] -pub fn replica_store_trade_product_create(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ITradeProductCreate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::trade_product::create(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_trade_product_find_one)] -pub fn replica_store_trade_product_find_one(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ITradeProductFindOne = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::trade_product::find_one(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_trade_product_find_many)] -pub fn replica_store_trade_product_find_many(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ITradeProductFindMany = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::trade_product::find_many(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_trade_product_update)] -pub fn replica_store_trade_product_update(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ITradeProductUpdate = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::trade_product::update(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_trade_product_delete)] -pub fn replica_store_trade_product_delete(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ITradeProductDelete = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = - radroots_replica_store::trade_product::delete(&exec, &opts).map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_profile_relay_set)] -pub fn replica_store_nostr_profile_relay_set(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrProfileRelayRelation = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::nostr_profile_relay::set(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_nostr_profile_relay_unset)] -pub fn replica_store_nostr_profile_relay_unset(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: INostrProfileRelayRelation = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::nostr_profile_relay::unset(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_trade_product_location_set)] -pub fn replica_store_trade_product_location_set(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ITradeProductLocationRelation = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::trade_product_location::set(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_trade_product_location_unset)] -pub fn replica_store_trade_product_location_unset(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ITradeProductLocationRelation = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::trade_product_location::unset(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_trade_product_media_set)] -pub fn replica_store_trade_product_media_set(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ITradeProductMediaRelation = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::trade_product_media::set(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} - -#[wasm_bindgen(js_name = replica_store_trade_product_media_unset)] -pub fn replica_store_trade_product_media_unset(opts_json: &str) -> Result<JsValue, JsValue> { - let opts: ITradeProductMediaRelation = parse_json(opts_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let out = radroots_replica_store::trade_product_media::unset(&exec, &opts) - .map_err(|e| err_js(e.error))?; - value_to_js(out) -} diff --git a/crates/replica_sync_wasm/Cargo.toml b/crates/replica_sync_wasm/Cargo.toml @@ -1,31 +0,0 @@ -[package] -name = "radroots_replica_sync_wasm" -publish = false -version = "0.1.0-alpha" -edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] -rust-version.workspace = true -license.workspace = true -description = "WASM boundary for Radroots replica synchronization" -repository.workspace = true -homepage.workspace = true -readme = "README" - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -base64 = { workspace = true } -radroots_event = { workspace = true, default-features = false, features = [ - "serde", -] } -radroots_sdk_sql_wasm_runtime = { workspace = true } -radroots_replica_sync = { workspace = true, features = ["std", "legacy-ingest"] } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -serde-wasm-bindgen = { workspace = true } -uuid = { workspace = true, features = ["js"] } -wasm-bindgen = { workspace = true } - -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/replica_sync_wasm/README b/crates/replica_sync_wasm/README @@ -1,25 +0,0 @@ -# radroots_replica_sync_wasm - -This is the README for `radroots_replica_sync_wasm`, which provides WebAssembly -bindings for `radroots_replica_sync` in the `radroots` core libraries. - -## Overview - - * wasm32 entry points for full sync and single-event ingest operations; - * integration with `WasmSqlExecutor` from `radroots_sql_core` for database - access; - * UUIDv7 id generation and JSON and base64 boundary handling for JavaScript - callers; - * a small target-specific wrapper around the Rust sync crate rather than a - separate sync engine. - -## Copyright - -Except as otherwise noted, all files in the `radroots_replica_sync_wasm` -distribution are - - Copyright (c) 2026 Tyson Lupul - -For information on usage and redistribution, and for a DISCLAIMER OF ALL -WARRANTIES, see LICENSE included in the `radroots_replica_sync_wasm` -distribution. diff --git a/crates/replica_sync_wasm/src/lib.rs b/crates/replica_sync_wasm/src/lib.rs @@ -1,179 +0,0 @@ -#![forbid(unsafe_code)] - -#[cfg(target_arch = "wasm32")] -use base64::Engine; -#[cfg(target_arch = "wasm32")] -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use radroots_event::envelope::{EventEnvelope, EventEnvelopeParts}; -#[cfg(any(target_arch = "wasm32", test))] -use radroots_replica_sync::RadrootsReplicaIngestOutcome; -use radroots_replica_sync::RadrootsReplicaSyncRequest; -#[cfg(target_arch = "wasm32")] -use radroots_replica_sync::{ - RadrootsReplicaIdFactory, radroots_replica_ingest_event_with_factory, radroots_replica_sync_all, -}; -#[cfg(target_arch = "wasm32")] -use radroots_sdk_sql_wasm_runtime::WasmSqlExecutor; -use serde::Deserialize; -#[cfg(target_arch = "wasm32")] -use uuid::Uuid; -#[cfg(target_arch = "wasm32")] -use wasm_bindgen::prelude::*; - -#[cfg(target_arch = "wasm32")] -fn err_js<E: ToString>(err: E) -> JsValue { - JsValue::from_str(&err.to_string()) -} - -#[cfg(target_arch = "wasm32")] -struct WasmIdFactory; - -#[cfg(target_arch = "wasm32")] -impl RadrootsReplicaIdFactory for WasmIdFactory { - fn new_d_tag(&self) -> String { - let uuid = Uuid::now_v7(); - URL_SAFE_NO_PAD.encode(uuid.as_bytes()) - } -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct EventEnvelopeInput { - id: String, - author: String, - created_at: u64, - kind: u32, - tags: Vec<Vec<String>>, - content: String, - sig: String, -} - -pub fn parse_request_model(request_json: &str) -> Result<RadrootsReplicaSyncRequest, String> { - serde_json::from_str(request_json).map_err(|error| error.to_string()) -} - -pub fn parse_event_model(event_json: &str) -> Result<EventEnvelope, String> { - let envelope: EventEnvelopeInput = - serde_json::from_str(event_json).map_err(|error| error.to_string())?; - EventEnvelope::new(EventEnvelopeParts { - id: envelope.id, - author: envelope.author, - created_at: envelope.created_at, - kind: envelope.kind, - tags: envelope.tags, - content: envelope.content, - sig: envelope.sig, - }) - .map_err(|error| error.to_string()) -} - -#[cfg(any(target_arch = "wasm32", test))] -fn ingest_outcome_label(outcome: RadrootsReplicaIngestOutcome) -> &'static str { - match outcome { - RadrootsReplicaIngestOutcome::Applied => "applied", - RadrootsReplicaIngestOutcome::Excluded => "excluded", - RadrootsReplicaIngestOutcome::Rejected => "rejected", - RadrootsReplicaIngestOutcome::Skipped => "skipped", - } -} - -#[cfg(target_arch = "wasm32")] -#[wasm_bindgen(js_name = replica_sync_sync_all)] -pub fn replica_sync_sync_all(request_json: &str) -> Result<JsValue, JsValue> { - let request = parse_request_model(request_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let bundle = radroots_replica_sync_all(&exec, &request).map_err(err_js)?; - serde_wasm_bindgen::to_value(&bundle).map_err(err_js) -} - -#[cfg(target_arch = "wasm32")] -#[wasm_bindgen(js_name = replica_sync_ingest_event)] -pub fn replica_sync_ingest_event(event_json: &str) -> Result<JsValue, JsValue> { - let event = parse_event_model(event_json).map_err(err_js)?; - let exec = WasmSqlExecutor::new(); - let factory = WasmIdFactory; - let outcome = - radroots_replica_ingest_event_with_factory(&exec, &event, &factory).map_err(err_js)?; - Ok(JsValue::from_str(ingest_outcome_label(outcome))) -} - -#[cfg(test)] -mod tests { - use super::{ingest_outcome_label, parse_event_model, parse_request_model}; - use radroots_replica_sync::RadrootsReplicaIngestOutcome; - - fn event_json(author: Option<&str>, pubkey: Option<&str>) -> String { - let mut fields = vec![ - format!(r#""id":"{}""#, "0".repeat(64)), - r#""created_at":123"#.to_owned(), - r#""kind":30023"#.to_owned(), - r#""tags":[["d","one"]]"#.to_owned(), - r#""content":"content""#.to_owned(), - format!(r#""sig":"{}""#, "f".repeat(128)), - ]; - if let Some(author) = author { - fields.push(format!(r#""author":"{author}""#)); - } - if let Some(pubkey) = pubkey { - fields.push(format!(r#""pubkey":"{pubkey}""#)); - } - format!("{{{}}}", fields.join(",")) - } - - #[test] - fn parse_event_accepts_author_domain_envelope() { - let author = "a".repeat(64); - let event = parse_event_model(&event_json(Some(author.as_str()), None)).expect("event"); - assert_eq!(event.author().to_hex(), author); - assert_eq!( - event.tags_as_vec(), - vec![vec!["d".to_owned(), "one".to_owned()]] - ); - } - - #[test] - fn parse_event_rejects_pubkey_wire_alias() { - let author = "a".repeat(64); - let error = parse_event_model(&event_json(Some(author.as_str()), Some(author.as_str()))) - .expect_err("error"); - assert!(error.contains("unknown field `pubkey`")); - } - - #[test] - fn parse_event_rejects_missing_author() { - let error = parse_event_model(&event_json(None, None)).expect_err("error"); - assert!(error.contains("missing field `author`")); - } - - #[test] - fn parse_event_rejects_malformed_json() { - let error = parse_event_model("{").expect_err("error"); - assert!(error.contains("EOF")); - } - - #[test] - fn parse_request_rejects_malformed_json() { - let error = parse_request_model("{").expect_err("error"); - assert!(error.contains("EOF")); - } - - #[test] - fn ingest_outcome_labels_cover_the_replica_contract() { - assert_eq!( - ingest_outcome_label(RadrootsReplicaIngestOutcome::Applied), - "applied" - ); - assert_eq!( - ingest_outcome_label(RadrootsReplicaIngestOutcome::Excluded), - "excluded" - ); - assert_eq!( - ingest_outcome_label(RadrootsReplicaIngestOutcome::Rejected), - "rejected" - ); - assert_eq!( - ingest_outcome_label(RadrootsReplicaIngestOutcome::Skipped), - "skipped" - ); - } -} diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -1,123 +0,0 @@ -[package] -name = "radroots_sdk" -publish = ["crates-io"] -version.workspace = true -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license.workspace = true -description = "Host-neutral asynchronous client engine for Radroots" -repository.workspace = true -homepage.workspace = true -readme = "README.md" -documentation = "https://docs.rs/radroots_sdk" -keywords = ["radroots", "sdk", "local-food", "nostr"] -categories = ["api-bindings", "asynchronous", "network-programming"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] -autotests = false -autoexamples = false - -[package.metadata.docs.rs] -features = ["memory", "sync", "nostr", "nip46", "local-signing", "geonames", "knowledge"] - -[lib] -name = "radroots_sdk" - -[features] -default = ["memory"] -memory = ["radroots_storage/memory"] -sqlite = ["dep:radroots_storage_sqlite"] -sync = ["dep:radroots_sync", "dep:uuid"] -nostr = [ - "sync", - "dep:radroots_nostr", - "dep:radroots_transport_nostr", -] -nip46 = [ - "nostr", - "dep:radroots_nostr_connect", -] -local-signing = [ - "dep:radroots_nostr", - "dep:radroots_secrets", - "radroots_nostr/signing", -] -radrootsd = [ - "sync", - "dep:reqwest", - "dep:serde", - "dep:serde_json", -] -geonames = ["dep:radroots_geonames"] -knowledge = [ - "radroots_event/knowledge", - "radroots_event_codec/knowledge", -] -native = ["sqlite", "sync", "local-signing"] -full = [ - "native", - "nostr", - "nip46", - "radrootsd", - "geonames", - "knowledge", -] - -[dependencies] -radroots_core = { workspace = true, default-features = false } -radroots_event = { workspace = true, default-features = false } -radroots_event_codec = { workspace = true, default-features = false, features = [ - "json", - "std", -] } -radroots_identity = { workspace = true, default-features = false } -radroots_protocol = { workspace = true, default-features = false } -radroots_signing = { workspace = true, default-features = false } -radroots_storage = { workspace = true, default-features = false } -radroots_trade = { workspace = true, default-features = false } -radroots_transport = { workspace = true, default-features = false } - -radroots_geonames = { workspace = true, optional = true, default-features = false } -radroots_nostr = { workspace = true, optional = true, default-features = false } -radroots_nostr_connect = { workspace = true, optional = true, default-features = false } -radroots_secrets = { workspace = true, optional = true, default-features = false } -radroots_storage_sqlite = { workspace = true, optional = true, default-features = false } -radroots_sync = { workspace = true, optional = true, default-features = false } -radroots_transport_nostr = { workspace = true, optional = true, default-features = false } - -reqwest = { workspace = true, optional = true, default-features = false, features = [ - "json", - "rustls-tls", -] } -serde = { workspace = true, optional = true, features = ["derive"] } -serde_json = { workspace = true, optional = true, features = ["std"] } -uuid = { workspace = true, optional = true, features = ["v4"] } - -[dev-dependencies] -nostr = { workspace = true } -tempfile = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } - -[[test]] -name = "package_boundary" -path = "tests/package_boundary.rs" - -[[test]] -name = "lifecycle" -path = "tests/lifecycle.rs" - -[[test]] -name = "public_api" -path = "tests/public_api.rs" - -[[example]] -name = "safe_memory_client" -path = "examples/safe_memory_client.rs" -required-features = ["memory"] - -[[example]] -name = "transport_profile" -path = "examples/transport_profile.rs" - -[lints] -workspace = true diff --git a/crates/sdk/LICENSE-APACHE b/crates/sdk/LICENSE-APACHE @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright 2026 Tyson Lupul - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/crates/sdk/LICENSE-MIT b/crates/sdk/LICENSE-MIT @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2026 Tyson Lupul - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/crates/sdk/README.md b/crates/sdk/README.md @@ -1,171 +0,0 @@ -# radroots_sdk - -`radroots_sdk` is the host-neutral asynchronous client engine for Radroots. -It composes the canonical event, trade, signing, transport, storage, and sync -crates without installing a runtime, starting workers, opening files, probing -the network, selecting an account, or choosing fallback transports. - -The crate root intentionally exports only `Client`, `ClientBuilder`, `Error`, -and `Result`. Advanced operations live in the `farm`, `listing`, `trade`, -`signing`, `transport`, `storage`, `sync`, `diagnostics`, and `capability` -modules. - -The package charter is the normative [`radroots_sdk` crate -specification](../../docs/specs/radroots_crates_release_v1.md#18-radroots_sdk). -This advanced front door is intended for CLI, Studio, FFI/mobile, and native -applications that need to compose storage, signing, transport, or sync -capabilities directly. Ordinary Rust applications should use the curated -`radroots` facade. - -## Getting started - -The default `memory` feature provides an inert, in-process backend. The caller -supplies its source generation; building the client creates no files, opens no -network connection, installs no runtime, and starts no worker. - -```rust -use radroots_sdk::{ClientBuilder, capability::CapabilityId}; -use radroots_storage::event::SourceGeneration; - -let generation = SourceGeneration::new([1; 32])?; -let client = ClientBuilder::memory(generation).build()?; -let storage = client - .capabilities() - .get(CapabilityId::CANONICAL_STORAGE); -assert!(storage.is_some()); - -# Ok::<(), Box<dyn std::error::Error>>(()) -``` - -The complete executable form, including explicit asynchronous shutdown, lives -in [`examples/safe_memory_client.rs`](examples/safe_memory_client.rs). The -side-effect-free transport-selection example lives in -[`examples/transport_profile.rs`](examples/transport_profile.rs). - -## Feature contract - -The complete public feature vocabulary is: - -| Feature | Capability | -| --- | --- | -| `memory` | deterministic in-process reference storage; the default feature | -| `sqlite` | explicit canonical SQLite storage construction | -| `sync` | composition with a caller-supplied canonical sync engine | -| `nostr` | Nostr conversion and concrete source/sink adapters; implies `sync` | -| `nip46` | NIP-46 signer provider; implies `nostr` | -| `local-signing` | explicit local signing and secret-provider adapters | -| `radrootsd` | explicitly invoked private daemon execution adapter; implies `sync` | -| `geonames` | concrete GeoNames provider integration | -| `knowledge` | deterministic knowledge event contracts/codecs | -| `native` | `sqlite`, `sync`, and `local-signing` | -| `full` | every supported production capability | - -There are no `runtime`, `local-runtime`, `signer-adapters`, -`transport-nostr-runtime`, `transport-nostr-client`, or fixture features. -Features compile capabilities; they do not perform I/O. Optional dependencies -are activated only by their owning feature. - -The supported qualification matrix is: - -```sh -cargo check -p radroots_sdk --all-targets --no-default-features -cargo check -p radroots_sdk --all-targets -cargo check -p radroots_sdk --all-targets --no-default-features --features memory -cargo check -p radroots_sdk --all-targets --no-default-features --features sqlite -cargo check -p radroots_sdk --all-targets --no-default-features --features sync -cargo check -p radroots_sdk --all-targets --no-default-features --features nostr -cargo check -p radroots_sdk --all-targets --no-default-features --features nip46 -cargo check -p radroots_sdk --all-targets --no-default-features --features local-signing -cargo check -p radroots_sdk --all-targets --no-default-features --features radrootsd -cargo check -p radroots_sdk --all-targets --no-default-features --features geonames -cargo check -p radroots_sdk --all-targets --no-default-features --features knowledge -cargo check -p radroots_sdk --all-targets --no-default-features --features native -cargo check -p radroots_sdk --all-targets --no-default-features --features full -cargo check -p radroots_sdk --all-targets --all-features -``` - -## Explicit composition - -`ClientBuilder` requires a storage capability. `ClientBuilder::memory(...)` -and `ClientBuilder::sqlite(...)` are explicit constructors; merely enabling a -feature or constructing an empty builder creates no resource. Signers, event -sources, event sinks, and the sync engine are injected separately. - -Native mobile hosts can retain one client while changing host-owned identity -and relay selection. `signing::Slot` accepts a key restored from secure host -storage or generates a one-time `nsec` handoff; it never persists that secret. -`transport::NostrSlot` validates a complete relay set before atomically -installing it. `ClientBuilder::host_sync(sync::HostPolicy)` explicitly opts -SDK-created memory or SQLite storage into system-clock and random operation-ID -policy without creating a runtime, timer, retry loop, or worker. - -With `sync`, `nostr`, and `local-signing`, `Client::social()` exposes bounded -profile/post fetch and publish operations. Fetch verifies and durably ingests -each accepted event. Publish durably commits a signed event, then performs one -explicit delivery pass and reports whether delivery remains pending. Host UI -lifecycle code owns polling, background policy, keychain access, and any later -retry. Profile authoring is deliberately media-free until the host can supply -the canonical byte-verified media descriptor required by the event contract. - -Transport profiles are explicit. `Profile::local_only()` contains no target. -`Profile::delivery(...)` retains the exact canonical target set and -satisfaction policy. Preview transports report unavailable and never -substitute Nostr, daemon, local persistence, or another route. - -Farm, listing, and trade preparation is deterministic and side-effect-free. -Commit operations accept native operation and idempotency identities, -cancellation policy, and an explicit transport profile, then return the -canonical sync receipt. Repeating the same idempotent request is the supported -resume/replay path. - -Preparation has no commit point. During commit, durable local acceptance is -the first commit point; a cancellation observed before that point returns -without claiming acceptance. Cancellation after durable acceptance cannot -roll the accepted event back: the returned error/receipt identifies the safe -resume path, and retrying with the same idempotency identity must not create a -second logical operation. Dropping `Client::close` before completion leaves -the shared client in a retry-required closing state; call `close` again. - -The SDK does not define a second wire model. Canonical lower-crate domain and -protocol types own serialization, validation, versioning, and size limits. -SDK request, plan, and receipt structs are constructor-led orchestration types; -their private representation is not a persistence or interchange format. - -## Reliability and privacy - -Backup, restore, integrity, and status operations delegate to -`radroots_storage::StorageReliability` and return its native versioned plans, -manifests, revisions, stages, and status values. Restore is staged and must be -explicitly finalized. Client shutdown is explicit and asynchronous. - -Public farm/listing events contain only the coarse locality represented by the -canonical event model. Exact coordinates, private trade terms, protected -content, and key references remain behind the private-artifact and secrets -SPIs. Diagnostics contain only capability and canonical storage status. Public -errors and daemon failures use stable, redacted classifications while retaining -private source chains for local diagnostics. - -Signer material and bearer credentials are caller-owned capabilities. The SDK -does not read a keyring, persist secrets, or include credentials -in `Debug`, `Display`, diagnostics, receipts, or public error text. Hosts remain -responsible for protecting source chains and any lower-level logs they choose -to expose. When explicitly requested, `signing::Slot::generate` creates one -ephemeral key and immediately hands its only persistence representation to the -host for secure custody. - -## Daemon execution - -The `radrootsd` feature compiles a private HTTP/RPC adapter using the versioned -`radroots_protocol::radrootsd::transport_publish::v5` contract. Constructing -`transport::DaemonDelivery` is inert. Network contact occurs only when the host -invokes `deliver`; bearer credentials are redacted, HTTP error bodies are not -surfaced, and the response must match the signed event and requested policies. - -## Release posture - -This package is enabled for package-realistic validation only. Actual crates.io -publication remains blocked pending the approval packet and a separately -authorized operator step. The crate is licensed under `MIT OR Apache-2.0`. - -The reviewed all-features public API baseline is recorded at -[`docs/api/radroots_sdk-0.1.0-alpha.txt`](../../docs/api/radroots_sdk-0.1.0-alpha.txt). diff --git a/crates/sdk/examples/safe_memory_client.rs b/crates/sdk/examples/safe_memory_client.rs @@ -1,20 +0,0 @@ -//! Builds and explicitly closes the safe in-process client. - -use radroots_sdk::{ClientBuilder, capability::CapabilityId}; -use radroots_storage::event::SourceGeneration; - -#[tokio::main] -async fn main() -> Result<(), Box<dyn std::error::Error>> { - let generation = SourceGeneration::new([1; 32])?; - let client = ClientBuilder::memory(generation).build()?; - - assert!( - client - .capabilities() - .get(CapabilityId::CANONICAL_STORAGE) - .is_some() - ); - - client.close().await?; - Ok(()) -} diff --git a/crates/sdk/examples/transport_profile.rs b/crates/sdk/examples/transport_profile.rs @@ -1,11 +0,0 @@ -//! Selects local-only behavior without constructing or probing a transport. - -use radroots_sdk::transport::Profile; - -fn main() { - let profile = Profile::local_only(); - - assert!(profile.is_local_only()); - assert!(profile.targets().is_none()); - assert!(profile.satisfaction().is_none()); -} diff --git a/crates/sdk/src/adapters/mod.rs b/crates/sdk/src/adapters/mod.rs @@ -1,2 +0,0 @@ -#[cfg(feature = "radrootsd")] -pub(crate) mod radrootsd; diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs @@ -1,352 +0,0 @@ -use core::fmt; -use core::time::Duration; - -use radroots_event::draft::SignedEvent; -use radroots_protocol::radrootsd::transport_publish::v5::{ - DeliveryPolicy as TransportPublishDeliveryPolicy, Error as TransportPublishProtocolError, - EventRequest as TransportPublishEventRequest, EventResponse as TransportPublishEventResponse, - METHOD_EVENT, TargetPolicy as TransportPublishTargetPolicy, -}; -use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use serde_json::{Value, json}; - -pub const SDK_RADROOTSD_PUBLISH_REQUEST_ID: &str = "radroots-sdk-transport-publish-event"; -pub const SDK_RADROOTSD_PUBLISH_MAX_TARGETS: usize = 20; - -#[derive(Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum RadrootsdAuth { - #[default] - None, - BearerToken(String), -} - -impl fmt::Debug for RadrootsdAuth { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::None => f.write_str("None"), - Self::BearerToken(_) => f.write_str("BearerToken(<redacted>)"), - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsdPublishConfig { - pub endpoint: String, - pub auth: RadrootsdAuth, - pub timeout: Duration, -} - -impl RadrootsdPublishConfig { - pub fn new(endpoint: impl Into<String>) -> Self { - Self { - endpoint: endpoint.into(), - auth: RadrootsdAuth::None, - timeout: Duration::from_secs(10), - } - } - - pub fn with_auth(mut self, auth: RadrootsdAuth) -> Self { - self.auth = auth; - self - } - - pub fn with_timeout(mut self, timeout: Duration) -> Self { - self.timeout = timeout; - self - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsdPublishAdapter { - config: RadrootsdPublishConfig, -} - -impl RadrootsdPublishAdapter { - pub fn new(config: RadrootsdPublishConfig) -> Self { - Self { config } - } - - #[cfg(test)] - pub fn config(&self) -> &RadrootsdPublishConfig { - &self.config - } - - pub async fn publish_signed_event( - &self, - request: RadrootsdPublishRequest, - ) -> Result<TransportPublishEventResponse, RadrootsdError> { - let event_identity = - RadrootsdPublishEventIdentity::from_signed_event(&request.signed_event); - let request = request.into_protocol_request(); - request - .validate(SDK_RADROOTSD_PUBLISH_MAX_TARGETS) - .map_err(RadrootsdError::from_protocol)?; - let response = publish_event( - self.config.endpoint.as_str(), - &self.config.auth, - &request, - self.config.timeout, - ) - .await?; - validate_transport_publish_response_for_request(&request, &event_identity, &response)?; - Ok(response) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsdPublishRequest { - pub signed_event: SignedEvent, - pub target_policy: TransportPublishTargetPolicy, - pub delivery_policy: TransportPublishDeliveryPolicy, - pub idempotency_key: Option<String>, - pub timeout_ms: Option<u64>, -} - -impl RadrootsdPublishRequest { - fn into_protocol_request(self) -> TransportPublishEventRequest { - TransportPublishEventRequest { - raw_event_json: self.signed_event.raw_json().to_owned(), - target_policy: self.target_policy, - delivery_policy: self.delivery_policy, - idempotency_key: self.idempotency_key, - timeout_ms: self.timeout_ms, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct RadrootsdPublishEventIdentity { - event_id: String, - pubkey: String, - kind: u32, -} - -impl RadrootsdPublishEventIdentity { - fn from_signed_event(event: &SignedEvent) -> Self { - Self { - event_id: event.id_str().to_owned(), - pubkey: event.pubkey().to_hex().to_owned(), - kind: event.kind(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadrootsdError { - InvalidAuthHeader(String), - InvalidRequest(String), - Http(String), - JsonRpc { code: i64, message: String }, - MalformedResponse(String), -} - -impl RadrootsdError { - fn from_protocol(error: TransportPublishProtocolError) -> Self { - Self::InvalidRequest(error.to_string()) - } -} - -impl fmt::Display for RadrootsdError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidAuthHeader(value) => { - write!(f, "invalid radrootsd bearer token header: {value}") - } - Self::InvalidRequest(value) => f.write_str(value), - Self::Http(value) => f.write_str(value), - Self::MalformedResponse(value) => f.write_str(value), - Self::JsonRpc { code, message } => { - write!(f, "radrootsd jsonrpc failed {code}: {message}") - } - } - } -} - -impl std::error::Error for RadrootsdError {} - -#[derive(Debug, Deserialize)] -struct JsonRpcEnvelope<T> { - jsonrpc: Option<String>, - id: Option<Value>, - result: Option<T>, - error: Option<JsonRpcError>, -} - -#[derive(Debug, Deserialize)] -struct JsonRpcError { - code: i64, - message: String, -} - -pub async fn publish_event( - endpoint: &str, - auth: &RadrootsdAuth, - request: &TransportPublishEventRequest, - timeout: Duration, -) -> Result<TransportPublishEventResponse, RadrootsdError> { - jsonrpc_call( - endpoint, - auth, - SDK_RADROOTSD_PUBLISH_REQUEST_ID, - METHOD_EVENT, - request, - timeout, - ) - .await -} - -fn auth_headers(auth: &RadrootsdAuth) -> Result<HeaderMap, RadrootsdError> { - let mut headers = HeaderMap::new(); - match auth { - RadrootsdAuth::None => Ok(headers), - RadrootsdAuth::BearerToken(token) => { - let header = format!("Bearer {token}"); - let value = HeaderValue::from_str(header.as_str()) - .map_err(|err| RadrootsdError::InvalidAuthHeader(err.to_string()))?; - headers.insert(AUTHORIZATION, value); - Ok(headers) - } - } -} - -#[cfg(test)] -pub fn publish_event_request_json( - request: &TransportPublishEventRequest, -) -> Result<Value, RadrootsdError> { - Ok(serde_json::to_value(request).expect("radrootsd transport publish request serializes")) -} - -fn http_status_error(status: reqwest::StatusCode, body: &str) -> RadrootsdError { - let body_summary = if body.is_empty() { - "response body empty".to_owned() - } else { - format!("response body omitted ({} bytes)", body.len()) - }; - RadrootsdError::Http(format!( - "radrootsd returned http {}: {}", - status.as_u16(), - body_summary - )) -} - -fn decode_jsonrpc_response<R>( - method: &str, - expected_id: &str, - body: &str, -) -> Result<R, RadrootsdError> -where - R: DeserializeOwned, -{ - let envelope: JsonRpcEnvelope<R> = serde_json::from_str(body).map_err(|err| { - RadrootsdError::MalformedResponse(format!("decode radrootsd {method} response: {err}")) - })?; - if envelope.jsonrpc.as_deref() != Some("2.0") { - return Err(RadrootsdError::MalformedResponse(format!( - "radrootsd {method} returned invalid jsonrpc version" - ))); - } - let expected_id_value = Value::String(expected_id.to_owned()); - if envelope.id.as_ref() != Some(&expected_id_value) { - return Err(RadrootsdError::MalformedResponse(format!( - "radrootsd {method} returned mismatched jsonrpc id" - ))); - } - match (envelope.result, envelope.error) { - (Some(result), None) => Ok(result), - (None, Some(error)) => Err(RadrootsdError::JsonRpc { - code: error.code, - message: error.message, - }), - (Some(_), Some(error)) => Err(RadrootsdError::MalformedResponse(format!( - "radrootsd {method} returned result and error: {} {}", - error.code, error.message - ))), - (None, None) => Err(RadrootsdError::MalformedResponse(format!( - "radrootsd {method} returned neither result nor error" - ))), - } -} - -async fn jsonrpc_call<P, R>( - endpoint: &str, - auth: &RadrootsdAuth, - request_id: &str, - method: &str, - params: &P, - timeout: Duration, -) -> Result<R, RadrootsdError> -where - P: Serialize + ?Sized, - R: DeserializeOwned, -{ - let client = reqwest::Client::builder() - .timeout(timeout) - .build() - .map_err(|err| RadrootsdError::Http(format!("build radrootsd client: {err}")))?; - let mut request_builder = client - .post(endpoint) - .headers(auth_headers(auth)?) - .json(&json!({ - "jsonrpc": "2.0", - "id": request_id, - "method": method, - "params": params, - })); - - request_builder = request_builder.header(CONTENT_TYPE, "application/json"); - - let response = request_builder - .send() - .await - .map_err(|err| RadrootsdError::Http(format!("send radrootsd {method} request: {err}")))?; - let status = response.status(); - let body = response - .text() - .await - .map_err(|err| RadrootsdError::Http(format!("read radrootsd response body: {err}")))?; - - if !status.is_success() { - return Err(http_status_error(status, body.as_str())); - } - - decode_jsonrpc_response(method, request_id, body.as_str()) -} - -fn validate_transport_publish_response_for_request( - request: &TransportPublishEventRequest, - event_identity: &RadrootsdPublishEventIdentity, - response: &TransportPublishEventResponse, -) -> Result<(), RadrootsdError> { - response.job.validate().map_err(|error| { - RadrootsdError::MalformedResponse(format!( - "radrootsd transport publish response invalid: {error}" - )) - })?; - if response.job.event_id != event_identity.event_id { - return Err(response_mismatch("event_id")); - } - if response.job.pubkey != event_identity.pubkey { - return Err(response_mismatch("pubkey")); - } - if response.job.event_kind != event_identity.kind { - return Err(response_mismatch("event_kind")); - } - if response.job.delivery_policy != request.delivery_policy { - return Err(response_mismatch("delivery_policy")); - } - if response.job.target_policy != request.target_policy { - return Err(response_mismatch("target_policy")); - } - Ok(()) -} - -fn response_mismatch(field: &str) -> RadrootsdError { - RadrootsdError::MalformedResponse(format!( - "radrootsd transport publish response {field} does not match request" - )) -} - -#[cfg(test)] -#[path = "../../tests/unit/adapters_radrootsd_tests.rs"] -mod tests; diff --git a/crates/sdk/src/capability.rs b/crates/sdk/src/capability.rs @@ -1,423 +0,0 @@ -//! Side-effect-free client capability reporting. - -use std::collections::{BTreeMap, BTreeSet}; - -/// Stable runtime identity for an SDK capability. -/// -/// These values intentionally describe behavior rather than Cargo features. -/// Construction is private so every reported ID comes from the governed -/// catalog below. -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct CapabilityId(&'static str); - -impl CapabilityId { - /// Canonical storage operations. - pub const CANONICAL_STORAGE: Self = Self("storage.canonical"); - /// Persistent storage operations. - pub const PERSISTENT_STORAGE: Self = Self("storage.persistent"); - /// Storage backup and restore operations. - pub const BACKUP_RESTORE: Self = Self("storage.backup-restore"); - /// Local signing. - pub const LOCAL_SIGNING: Self = Self("signing.local"); - /// NIP-46 remote signing. - pub const NIP46_SIGNING: Self = Self("signing.nip46"); - /// Nostr event fetch. - pub const NOSTR_FETCH: Self = Self("transport.nostr.fetch"); - /// Nostr event delivery. - pub const NOSTR_DELIVERY: Self = Self("transport.nostr.delivery"); - /// Reticulum event fetch preview. - pub const RETICULUM_FETCH: Self = Self("transport.reticulum.fetch"); - /// Reticulum event delivery preview. - pub const RETICULUM_DELIVERY: Self = Self("transport.reticulum.delivery"); - /// Mesh transport preview. - pub const MESH_TRANSPORT: Self = Self("transport.mesh"); - /// SimpleX transport experiment. - pub const SIMPLEX_TRANSPORT: Self = Self("transport.simplex"); - /// Daemon-mediated event delivery. - pub const DAEMON_DELIVERY: Self = Self("transport.daemon.delivery"); - /// Inbound synchronization. - pub const SYNC_PULL: Self = Self("sync.pull"); - /// Outbound synchronization. - pub const SYNC_PUSH: Self = Self("sync.push"); - /// Farm event publication. - pub const FARM_PUBLICATION: Self = Self("product.farm.publish"); - /// Listing event publication. - pub const LISTING_PUBLICATION: Self = Self("product.listing.publish"); - /// Trade command execution. - pub const TRADE_COMMANDS: Self = Self("product.trade.command"); - /// Trade queries. - pub const TRADE_QUERIES: Self = Self("product.trade.query"); - /// Knowledge event support. - pub const KNOWLEDGE_EVENTS: Self = Self("event.knowledge"); - - /// Returns the stable presentation-independent identity. - #[must_use] - pub const fn as_str(self) -> &'static str { - self.0 - } -} - -impl std::fmt::Display for CapabilityId { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(self.0) - } -} - -/// Product maturity independent of runtime availability. -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub enum Maturity { - /// Supported Release V1 behavior. - Stable, - /// Preserved pre-stable behavior with an explicit compatibility warning. - Preview, - /// Exploratory behavior without a compatibility commitment. - Experimental, -} - -/// Current runtime availability independent of compilation and configuration. -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub enum Availability { - /// The configured capability is ready. - Available, - /// The configured capability is usable with reduced functionality. - Degraded, - /// The capability is compiled but not currently usable. - Unavailable, - /// The capability is not supported by this build. - Unsupported, -} - -/// One immutable capability observation. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct CapabilityStatus { - id: CapabilityId, - compiled: bool, - configured: bool, - availability: Availability, - maturity: Maturity, -} - -impl CapabilityStatus { - /// Returns the stable runtime identity. - #[must_use] - pub const fn id(self) -> CapabilityId { - self.id - } - - /// Returns whether support was compiled into this package. - #[must_use] - pub const fn is_compiled(self) -> bool { - self.compiled - } - - /// Returns whether the host configured the capability for this client. - #[must_use] - pub const fn is_configured(self) -> bool { - self.configured - } - - /// Returns the current side-effect-free availability observation. - #[must_use] - pub const fn availability(self) -> Availability { - self.availability - } - - /// Returns the independent product maturity classification. - #[must_use] - pub const fn maturity(self) -> Maturity { - self.maturity - } -} - -/// Complete deterministic capability report for one client observation. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CapabilityReport { - statuses: Vec<CapabilityStatus>, -} - -impl CapabilityReport { - /// Returns all known capabilities in stable catalog order. - pub fn iter(&self) -> impl ExactSizeIterator<Item = &CapabilityStatus> { - self.statuses.iter() - } - - /// Finds one capability by stable runtime identity. - #[must_use] - pub fn get(&self, id: CapabilityId) -> Option<CapabilityStatus> { - self.statuses.iter().copied().find(|status| status.id == id) - } -} - -#[derive(Clone, Copy)] -struct Definition { - id: CapabilityId, - maturity: Maturity, - compiled: bool, -} - -const CATALOG: &[Definition] = &[ - Definition::stable(CapabilityId::CANONICAL_STORAGE, true), - Definition::stable(CapabilityId::PERSISTENT_STORAGE, cfg!(feature = "sqlite")), - Definition::stable(CapabilityId::BACKUP_RESTORE, true), - Definition::stable(CapabilityId::LOCAL_SIGNING, cfg!(feature = "local-signing")), - Definition::stable(CapabilityId::NIP46_SIGNING, cfg!(feature = "nip46")), - Definition::stable(CapabilityId::NOSTR_FETCH, cfg!(feature = "nostr")), - Definition::stable(CapabilityId::NOSTR_DELIVERY, cfg!(feature = "nostr")), - Definition::preview(CapabilityId::RETICULUM_FETCH), - Definition::preview(CapabilityId::RETICULUM_DELIVERY), - Definition::experimental(CapabilityId::MESH_TRANSPORT), - Definition::experimental(CapabilityId::SIMPLEX_TRANSPORT), - Definition::stable(CapabilityId::DAEMON_DELIVERY, cfg!(feature = "radrootsd")), - Definition::stable(CapabilityId::SYNC_PULL, cfg!(feature = "sync")), - Definition::stable(CapabilityId::SYNC_PUSH, cfg!(feature = "sync")), - Definition::stable(CapabilityId::FARM_PUBLICATION, true), - Definition::stable(CapabilityId::LISTING_PUBLICATION, true), - Definition::stable(CapabilityId::TRADE_COMMANDS, true), - Definition::stable(CapabilityId::TRADE_QUERIES, true), - Definition::stable(CapabilityId::KNOWLEDGE_EVENTS, cfg!(feature = "knowledge")), -]; - -impl Definition { - const fn stable(id: CapabilityId, compiled: bool) -> Self { - Self { - id, - maturity: Maturity::Stable, - compiled, - } - } - - const fn preview(id: CapabilityId) -> Self { - Self { - id, - maturity: Maturity::Preview, - compiled: false, - } - } - - const fn experimental(id: CapabilityId) -> Self { - Self { - id, - maturity: Maturity::Experimental, - compiled: false, - } - } -} - -pub(crate) struct Context<'a> { - pub(crate) storage: bool, - pub(crate) signer: bool, - pub(crate) source: bool, - pub(crate) sink: bool, - pub(crate) sync: bool, - pub(crate) lifecycle_availability: Availability, - pub(crate) explicitly_configured: &'a BTreeSet<CapabilityId>, - pub(crate) overrides: &'a BTreeMap<CapabilityId, Availability>, -} - -pub(crate) fn report(context: Context<'_>) -> CapabilityReport { - let statuses = CATALOG - .iter() - .map(|definition| { - let configured = configured(definition.id, &context); - let availability = if !definition.compiled { - Availability::Unsupported - } else if !configured { - Availability::Unavailable - } else if context.lifecycle_availability != Availability::Available { - context.lifecycle_availability - } else { - context - .overrides - .get(&definition.id) - .copied() - .unwrap_or(context.lifecycle_availability) - }; - CapabilityStatus { - id: definition.id, - compiled: definition.compiled, - configured, - availability, - maturity: definition.maturity, - } - }) - .collect(); - CapabilityReport { statuses } -} - -fn configured(id: CapabilityId, context: &Context<'_>) -> bool { - match id { - CapabilityId::CANONICAL_STORAGE - | CapabilityId::BACKUP_RESTORE - | CapabilityId::TRADE_QUERIES => context.storage, - CapabilityId::SYNC_PULL => (context.sync, context.source) == (true, true), - CapabilityId::SYNC_PUSH => (context.sync, context.sink) == (true, true), - CapabilityId::FARM_PUBLICATION - | CapabilityId::LISTING_PUBLICATION - | CapabilityId::TRADE_COMMANDS => (context.signer, context.sink) == (true, true), - CapabilityId::KNOWLEDGE_EVENTS => cfg!(feature = "knowledge"), - CapabilityId::RETICULUM_FETCH - | CapabilityId::RETICULUM_DELIVERY - | CapabilityId::MESH_TRANSPORT - | CapabilityId::SIMPLEX_TRANSPORT => false, - _ => context.explicitly_configured.contains(&id), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn catalog_ids_are_unique_and_not_feature_names() { - for (index, definition) in CATALOG.iter().enumerate() { - assert!( - !CATALOG[..index] - .iter() - .any(|candidate| candidate.id == definition.id), - "duplicate capability {}", - definition.id - ); - assert!(definition.id.as_str().contains('.')); - } - } - - #[test] - fn maturity_and_unsupported_preview_states_are_independent() { - let overrides = BTreeMap::new(); - let explicitly_configured = BTreeSet::new(); - let report = report(Context { - storage: true, - signer: false, - source: false, - sink: false, - sync: false, - lifecycle_availability: Availability::Available, - explicitly_configured: &explicitly_configured, - overrides: &overrides, - }); - let storage = report - .get(CapabilityId::CANONICAL_STORAGE) - .expect("storage"); - assert!(storage.is_compiled()); - assert!(storage.is_configured()); - assert_eq!(storage.availability(), Availability::Available); - assert_eq!(storage.maturity(), Maturity::Stable); - - let knowledge = report - .get(CapabilityId::KNOWLEDGE_EVENTS) - .expect("knowledge"); - assert_eq!(knowledge.is_compiled(), cfg!(feature = "knowledge")); - assert_eq!(knowledge.is_configured(), cfg!(feature = "knowledge")); - - let reticulum = report - .get(CapabilityId::RETICULUM_FETCH) - .expect("reticulum"); - assert!(!reticulum.is_compiled()); - assert!(!reticulum.is_configured()); - assert_eq!(reticulum.availability(), Availability::Unsupported); - assert_eq!(reticulum.maturity(), Maturity::Preview); - - let mesh = report.get(CapabilityId::MESH_TRANSPORT).expect("mesh"); - assert_eq!(mesh.maturity(), Maturity::Experimental); - } - - #[test] - fn configuration_and_availability_matrix_covers_every_decision_path() { - let explicitly_configured = BTreeSet::from([ - CapabilityId::PERSISTENT_STORAGE, - CapabilityId::NIP46_SIGNING, - CapabilityId::NOSTR_FETCH, - ]); - let overrides = BTreeMap::from([ - (CapabilityId::PERSISTENT_STORAGE, Availability::Degraded), - (CapabilityId::NOSTR_FETCH, Availability::Unavailable), - ]); - - let complete = report(Context { - storage: false, - signer: true, - source: true, - sink: true, - sync: true, - lifecycle_availability: Availability::Available, - explicitly_configured: &explicitly_configured, - overrides: &overrides, - }); - assert!( - complete - .get(CapabilityId::SYNC_PULL) - .expect("pull") - .is_configured() - ); - assert!( - complete - .get(CapabilityId::SYNC_PUSH) - .expect("push") - .is_configured() - ); - assert!( - complete - .get(CapabilityId::FARM_PUBLICATION) - .expect("farm") - .is_configured() - ); - assert_eq!( - complete - .get(CapabilityId::NOSTR_FETCH) - .expect("fetch") - .availability(), - if cfg!(feature = "nostr") { - Availability::Unavailable - } else { - Availability::Unsupported - } - ); - - let partial = report(Context { - storage: false, - signer: true, - source: false, - sink: false, - sync: true, - lifecycle_availability: Availability::Degraded, - explicitly_configured: &explicitly_configured, - overrides: &overrides, - }); - assert!( - !partial - .get(CapabilityId::SYNC_PULL) - .expect("pull") - .is_configured() - ); - assert!( - !partial - .get(CapabilityId::SYNC_PUSH) - .expect("push") - .is_configured() - ); - assert!( - !partial - .get(CapabilityId::TRADE_COMMANDS) - .expect("trade") - .is_configured() - ); - if partial - .get(CapabilityId::NIP46_SIGNING) - .expect("nip46") - .is_compiled() - { - assert_eq!( - partial - .get(CapabilityId::NIP46_SIGNING) - .expect("nip46") - .availability(), - Availability::Degraded - ); - } - assert_eq!(complete.iter().len(), CATALOG.len()); - assert_eq!( - CapabilityId::NOSTR_FETCH.to_string(), - "transport.nostr.fetch" - ); - } -} diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs @@ -1,1668 +0,0 @@ -//! Client construction and lifecycle. - -use std::{ - collections::{BTreeMap, BTreeSet}, - sync::{ - Arc, - atomic::{AtomicU8, Ordering}, - }, -}; - -use radroots_signing::Signer; -use radroots_storage::Storage; -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -use radroots_storage::authored_delivery::AuthoredDeliveryState; -#[cfg(feature = "memory")] -use radroots_storage::{event::SourceGeneration, memory::MemoryStorage}; -use radroots_transport::{EventSink, EventSource}; - -use crate::{ - Error, Result, - capability::{Availability, CapabilityId, CapabilityReport}, -}; - -/// Cloneable handle to a composed Radroots client. -#[derive(Clone)] -pub struct Client { - inner: Arc<ClientInner>, -} - -/// Explicit composition boundary for a [`Client`]. -#[derive(Default)] -pub struct ClientBuilder { - storage: Option<Arc<dyn Storage>>, - #[cfg(feature = "sync")] - sync_storage: Option<Arc<dyn radroots_sync::policy::SyncStorage>>, - signer: Option<Arc<dyn Signer>>, - source: Option<Arc<dyn EventSource>>, - sink: Option<Arc<dyn EventSink>>, - #[cfg(feature = "sync")] - sync: Option<radroots_sync::Engine>, - #[cfg(feature = "sync")] - host_sync: Option<crate::sync::HostPolicy>, - #[cfg(feature = "local-signing")] - signing_slot: Option<crate::signing::Slot>, - #[cfg(feature = "nostr")] - nostr_slot: Option<crate::transport::NostrSlot>, - capability_availability: BTreeMap<CapabilityId, Availability>, - explicitly_configured_capabilities: BTreeSet<CapabilityId>, -} - -struct ClientInner { - storage: Arc<dyn Storage>, - signer: Option<Arc<dyn Signer>>, - source: Option<Arc<dyn EventSource>>, - sink: Option<Arc<dyn EventSink>>, - #[cfg(feature = "sync")] - sync: Option<radroots_sync::Engine>, - #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] - signing_slot: Option<crate::signing::Slot>, - #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] - nostr_slot: Option<crate::transport::NostrSlot>, - capability_availability: BTreeMap<CapabilityId, Availability>, - explicitly_configured_capabilities: BTreeSet<CapabilityId>, - lifecycle: AtomicU8, -} - -const OPEN: u8 = 0; -const CLOSING: u8 = 1; -const CLOSE_RETRY_REQUIRED: u8 = 2; -const CLOSED: u8 = 3; - -/// Host-authored, media-free profile replacement. -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProfileDraft { - name: String, - display_name: Option<String>, - about: Option<String>, - nip05: Option<String>, - bot: Option<bool>, -} - -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -impl ProfileDraft { - /// Creates a complete replacement with the required canonical name. - #[must_use] - pub fn new(name: impl Into<String>) -> Self { - Self { - name: name.into(), - display_name: None, - about: None, - nip05: None, - bot: None, - } - } - - /// Sets the optional display name. - #[must_use] - pub fn with_display_name(mut self, value: impl Into<String>) -> Self { - self.display_name = Some(value.into()); - self - } - - /// Sets the optional profile description. - #[must_use] - pub fn with_about(mut self, value: impl Into<String>) -> Self { - self.about = Some(value.into()); - self - } - - /// Sets a syntax-checked NIP-05 identifier at publish time. - #[must_use] - pub fn with_nip05(mut self, value: impl Into<String>) -> Self { - self.nip05 = Some(value.into()); - self - } - - /// Sets the optional NIP-05 bot marker. - #[must_use] - pub const fn with_bot(mut self, value: bool) -> Self { - self.bot = Some(value); - self - } -} - -/// One verified, durably ingested profile observation. -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProfileEvent { - event_id: String, - author: String, - created_at: u64, - name: Option<String>, - display_name: Option<String>, - about: Option<String>, - picture: Option<String>, - banner: Option<String>, - nip05: Option<String>, - bot: Option<bool>, -} - -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -impl ProfileEvent { - /// Returns the canonical event identifier. - pub fn event_id(&self) -> &str { - self.event_id.as_str() - } - /// Returns the canonical author public key. - pub fn author(&self) -> &str { - self.author.as_str() - } - /// Returns the event timestamp in Unix seconds. - pub const fn created_at(&self) -> u64 { - self.created_at - } - /// Returns the projected profile name. - pub fn name(&self) -> Option<&str> { - self.name.as_deref() - } - /// Returns the projected display name. - pub fn display_name(&self) -> Option<&str> { - self.display_name.as_deref() - } - /// Returns the projected description. - pub fn about(&self) -> Option<&str> { - self.about.as_deref() - } - /// Returns the unverified inbound picture reference. - pub fn picture(&self) -> Option<&str> { - self.picture.as_deref() - } - /// Returns the unverified inbound banner reference. - pub fn banner(&self) -> Option<&str> { - self.banner.as_deref() - } - /// Returns the syntax-checked, unresolved NIP-05 identifier. - pub fn nip05(&self) -> Option<&str> { - self.nip05.as_deref() - } - /// Returns the optional bot marker. - pub const fn bot(&self) -> Option<bool> { - self.bot - } -} - -/// One verified, durably ingested kind-1 social event. -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PostEvent { - event_id: String, - author: String, - created_at: u64, - content: String, -} - -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -impl PostEvent { - /// Returns the canonical event identifier. - pub fn event_id(&self) -> &str { - self.event_id.as_str() - } - /// Returns the canonical author public key. - pub fn author(&self) -> &str { - self.author.as_str() - } - /// Returns the event timestamp in Unix seconds. - pub const fn created_at(&self) -> u64 { - self.created_at - } - /// Returns the canonical event content. - pub fn content(&self) -> &str { - self.content.as_str() - } -} - -/// Result of one explicit local commit followed by one delivery pass. -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PublishReceipt { - event_id: String, - replay: bool, - delivery_state: AuthoredDeliveryState, -} - -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -impl PublishReceipt { - /// Returns the canonical signed event identifier committed locally. - pub fn event_id(&self) -> &str { - self.event_id.as_str() - } - /// Returns whether preparation replayed an identical durable operation. - pub const fn is_replay(&self) -> bool { - self.replay - } - /// Returns the complete durable delivery state after this explicit pass. - pub const fn delivery_state(&self) -> AuthoredDeliveryState { - self.delivery_state - } - /// Returns whether this explicit pass satisfied the delivery policy. - pub const fn is_delivered(&self) -> bool { - matches!(self.delivery_state, AuthoredDeliveryState::Satisfied) - } - /// Returns whether durable local intent remains eligible for delivery. - pub const fn is_delivery_pending(&self) -> bool { - matches!( - self.delivery_state, - AuthoredDeliveryState::Pending | AuthoredDeliveryState::Retryable - ) - } -} - -/// Passive status of the configured shared Nostr source and sink. -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TransportHealth { - configured: bool, - source_available: bool, - sink_available: bool, -} - -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -impl TransportHealth { - /// Returns whether a validated relay set is installed. - pub const fn is_configured(&self) -> bool { - self.configured - } - /// Returns whether passive source status is fully available. - pub const fn is_source_available(&self) -> bool { - self.source_available - } - /// Returns whether passive sink status is fully available. - pub const fn is_sink_available(&self) -> bool { - self.sink_available - } -} - -/// Borrowed high-level social operations over one shared SDK engine. -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -#[derive(Clone, Copy)] -pub struct SocialOperations<'a> { - client: &'a Client, -} - -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -enum SocialDraft { - Profile(Box<radroots_event::profile::AuthoredProfile>), - Update(radroots_event::post::AuthoredUpdate), - Reply(radroots_event::post::reply::AuthoredNip10Reply), -} - -impl ClientBuilder { - /// Creates an empty builder with no hidden storage, network, signing, or - /// runtime side effects. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Creates a builder backed by deterministic in-process memory storage. - #[cfg(feature = "memory")] - #[must_use] - pub fn memory(generation: SourceGeneration) -> Self { - let storage = Arc::new(MemoryStorage::new(generation)); - let builder = Self::new().storage(storage.clone()); - #[cfg(feature = "sync")] - { - let mut builder = builder; - builder.sync_storage = Some(storage); - builder - } - #[cfg(not(feature = "sync"))] - builder - } - - /// Creates the ordinary deterministic in-process memory configuration. - /// - /// This performs no I/O and is intended for ephemeral local clients whose - /// source-generation identity does not need to survive the process. Hosts - /// that persist cursors should call [`Self::memory`] with their own - /// generation instead. - #[cfg(feature = "memory")] - #[must_use] - pub fn memory_default() -> Self { - let storage = Arc::new(MemoryStorage::default()); - let builder = Self::new().storage(storage.clone()); - #[cfg(feature = "sync")] - { - let mut builder = builder; - builder.sync_storage = Some(storage); - builder - } - #[cfg(not(feature = "sync"))] - builder - } - - /// Explicitly opens canonical SQLite storage from validated host-owned - /// configuration and returns a builder containing only the storage SPI. - #[cfg(feature = "sqlite")] - pub async fn sqlite(options: crate::storage::SqliteOptions) -> Result<Self> { - let storage = radroots_storage_sqlite::SqliteStorage::open(options) - .await - .map_err(Error::storage_open_failed)?; - let storage = Arc::new(storage); - let builder = Self::new() - .storage(storage.clone()) - .capability_availability(CapabilityId::PERSISTENT_STORAGE, Availability::Available); - #[cfg(feature = "sync")] - { - let mut builder = builder; - builder.sync_storage = Some(storage); - Ok(builder) - } - #[cfg(not(feature = "sync"))] - Ok(builder) - } - - /// Injects the canonical storage capability. - #[must_use] - pub fn storage(mut self, storage: Arc<dyn Storage>) -> Self { - self.storage = Some(storage); - self - } - - /// Injects an optional canonical signer capability. - #[must_use] - pub fn signer(mut self, signer: Arc<dyn Signer>) -> Self { - self.signer = Some(signer); - self - } - - /// Installs a module-scoped signer provider over the canonical SPI and - /// records its presentation-independent runtime capability. - #[must_use] - pub fn signing(mut self, provider: crate::signing::Provider) -> Self { - let capability = match provider.mode() { - crate::signing::Mode::Local => Some(CapabilityId::LOCAL_SIGNING), - crate::signing::Mode::Nip46 => Some(CapabilityId::NIP46_SIGNING), - crate::signing::Mode::Host => None, - }; - #[cfg(feature = "local-signing")] - { - let (signer, slot) = provider.into_parts(); - self.signer = Some(signer); - self.signing_slot = slot; - } - #[cfg(not(feature = "local-signing"))] - { - self.signer = Some(provider.into_signer()); - } - if let Some(capability) = capability { - self.explicitly_configured_capabilities.insert(capability); - self.capability_availability - .insert(capability, Availability::Available); - } - self - } - - /// Injects an optional inbound event source. - #[must_use] - pub fn source(mut self, source: Arc<dyn EventSource>) -> Self { - self.source = Some(source); - self - } - - /// Injects an optional outbound event sink. - #[must_use] - pub fn sink(mut self, sink: Arc<dyn EventSink>) -> Self { - self.sink = Some(sink); - self - } - - /// Installs one host-reconfigurable Nostr source and sink. - #[cfg(feature = "nostr")] - #[must_use] - pub fn nostr(mut self, slot: crate::transport::NostrSlot) -> Self { - self.source = Some(Arc::new(slot.clone())); - self.sink = Some(Arc::new(slot.clone())); - self.nostr_slot = Some(slot); - self - } - - /// Injects an explicitly composed synchronization engine. - #[cfg(feature = "sync")] - #[must_use] - pub fn sync_engine(mut self, sync: radroots_sync::Engine) -> Self { - self.sync = Some(sync); - self - } - - /// Requests one SDK-composed engine using explicit native host policy. - /// - /// This is available only for SDK-created memory or SQLite storage, whose - /// complete synchronization capability is known without downcasting. - #[cfg(feature = "sync")] - #[must_use] - pub fn host_sync(mut self, policy: crate::sync::HostPolicy) -> Self { - self.host_sync = Some(policy); - self - } - - /// Marks a specialized capability as configured and records its - /// host-observed initial availability without probing resources. - /// - /// Reports ignore this observation for capabilities that are not compiled - /// or configured. Runtime IDs are independent from Cargo feature names. - #[must_use] - pub fn capability_availability(mut self, id: CapabilityId, availability: Availability) -> Self { - self.explicitly_configured_capabilities.insert(id); - self.capability_availability.insert(id, availability); - self - } - - /// Validates the selected capabilities and creates a client handle. - #[allow(unused_mut)] - pub fn build(mut self) -> Result<Client> { - let storage = self.storage.ok_or_else(Error::missing_storage)?; - if (self.signer.is_some(), self.sink.is_some()) == (true, false) { - return Err(Error::signer_without_sink()); - } - #[cfg(feature = "sync")] - if let Some(policy) = self.host_sync { - let sync_storage = self - .sync_storage - .take() - .ok_or_else(Error::shared_operation_unavailable)?; - let (clock, ids, deadlines) = policy.composition(); - let mut builder = radroots_sync::Engine::builder(sync_storage, clock, ids, deadlines); - if let Some(source) = self.source.as_ref() { - builder = builder.source(Arc::clone(source)); - } - if let Some(sink) = self.sink.as_ref() { - builder = builder.sink(Arc::clone(sink)); - } - if let Some(signer) = self.signer.as_ref() { - builder = builder.signer(Arc::clone(signer)); - } - self.sync = Some(builder.build().map_err(Error::invalid_host_configuration)?); - } - Ok(Client { - inner: Arc::new(ClientInner { - storage, - signer: self.signer, - source: self.source, - sink: self.sink, - #[cfg(feature = "sync")] - sync: self.sync, - #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] - signing_slot: self.signing_slot, - #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] - nostr_slot: self.nostr_slot, - capability_availability: self.capability_availability, - explicitly_configured_capabilities: self.explicitly_configured_capabilities, - lifecycle: AtomicU8::new(OPEN), - }), - }) - } -} - -impl Client { - /// Returns a deterministic capability report without probing resources or - /// performing filesystem, network, signing, or storage operations. - #[must_use] - pub fn capabilities(&self) -> CapabilityReport { - let lifecycle_availability = match self.inner.lifecycle.load(Ordering::Acquire) { - OPEN => Availability::Available, - CLOSING | CLOSE_RETRY_REQUIRED => Availability::Degraded, - _ => Availability::Unavailable, - }; - crate::capability::report(crate::capability::Context { - storage: true, - signer: self.inner.signer.is_some(), - source: self.inner.source.is_some(), - sink: self.inner.sink.is_some(), - sync: self.sync_is_configured(), - lifecycle_availability, - explicitly_configured: &self.inner.explicitly_configured_capabilities, - overrides: &self.inner.capability_availability, - }) - } - - /// Returns canonical backend status without exposing a backend handle. - pub async fn storage_status(&self) -> Result<crate::storage::Status> { - let storage = self.storage()?; - radroots_storage::BackupSource::status(storage) - .await - .map_err(Error::storage_inspection_failed) - } - - /// Runs canonical integrity inspection without exposing backend internals. - pub async fn storage_integrity(&self) -> Result<crate::storage::IntegrityStatus> { - let storage = self.storage()?; - radroots_storage::BackupSource::integrity(storage) - .await - .map_err(Error::storage_inspection_failed) - } - - /// Returns the injected canonical storage capability. - pub fn storage(&self) -> Result<&dyn Storage> { - self.require_open()?; - Ok(self.inner.storage.as_ref()) - } - - /// Returns backend-neutral backup, restore, status, and integrity operations. - pub fn storage_operations(&self) -> Result<crate::storage::Operations<'_>> { - Ok(crate::storage::Operations::new(self.storage()?)) - } - - /// Returns the injected signer, when outbound authoring is enabled. - pub fn signer(&self) -> Result<Option<&dyn Signer>> { - self.require_open()?; - Ok(self.inner.signer.as_deref()) - } - - /// Returns the injected inbound source, when pull is enabled. - pub fn source(&self) -> Result<Option<&dyn EventSource>> { - self.require_open()?; - Ok(self.inner.source.as_deref()) - } - - /// Returns the injected outbound sink, when delivery is enabled. - pub fn sink(&self) -> Result<Option<&dyn EventSink>> { - self.require_open()?; - Ok(self.inner.sink.as_deref()) - } - - /// Returns client-scoped canonical synchronization operations, when configured. - #[cfg(feature = "sync")] - pub fn sync(&self) -> Result<Option<crate::sync::Operations<'_>>> { - self.require_open()?; - Ok(self.inner.sync.as_ref().map(crate::sync::Operations::new)) - } - - /// Returns farm commit operations when canonical synchronization is configured. - #[cfg(feature = "sync")] - pub fn farm(&self) -> Result<Option<crate::farm::Operations<'_>>> { - Ok(self.sync()?.map(crate::farm::Operations::new)) - } - - /// Returns listing commit operations when canonical synchronization is configured. - #[cfg(feature = "sync")] - pub fn listing(&self) -> Result<Option<crate::listing::Operations<'_>>> { - Ok(self.sync()?.map(crate::listing::Operations::new)) - } - - /// Returns trade operations when canonical synchronization is configured. - #[cfg(feature = "sync")] - pub fn trade(&self) -> Result<Option<crate::trade::Operations<'_>>> { - let storage = self.storage()?; - Ok(self - .sync()? - .map(|sync| crate::trade::Operations::new(storage, sync))) - } - - /// Returns high-level shared social operations when the required explicit - /// signer, transport, and synchronization composition is present. - #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] - pub fn social(&self) -> Result<SocialOperations<'_>> { - self.require_open()?; - let social_composition = ( - self.inner.sync.is_some(), - self.inner.signing_slot.is_some(), - self.inner.nostr_slot.is_some(), - ); - if social_composition != (true, true, true) { - return Err(Error::shared_operation_unavailable()); - } - Ok(SocialOperations { client: self }) - } - - /// Returns whether explicit close completed successfully or reached the - /// lower storage commit point. - #[must_use] - pub fn is_closed(&self) -> bool { - self.inner.lifecycle.load(Ordering::Acquire) == CLOSED - } - - /// Explicitly closes active storage resources across every client clone. - /// - /// Dropping this future before its first poll has no effect. Cancellation - /// after close begins leaves the client unavailable and permits an - /// explicit retry; it never reports rollback. Repeated completed close is - /// idempotent. No worker, executor, or blocking `Drop` path is installed. - pub async fn close(&self) -> Result<()> { - loop { - match self.inner.lifecycle.load(Ordering::Acquire) { - CLOSED => return Ok(()), - CLOSING => return Err(Error::close_in_progress()), - state @ (OPEN | CLOSE_RETRY_REQUIRED) => { - if self - .inner - .lifecycle - .compare_exchange(state, CLOSING, Ordering::AcqRel, Ordering::Acquire) - .is_ok() - { - break; - } - } - _ => return Err(Error::client_closed()), - } - } - - let attempt = CloseAttempt::new(Arc::clone(&self.inner)); - let close_result = radroots_storage::BackupSource::close(self.inner.storage.as_ref()).await; - attempt.complete(); - close_result - .map(|_| ()) - .map_err(Error::storage_close_failed) - } - - fn require_open(&self) -> Result<()> { - match self.inner.lifecycle.load(Ordering::Acquire) { - OPEN => Ok(()), - CLOSING | CLOSE_RETRY_REQUIRED => Err(Error::client_closing()), - _ => Err(Error::client_closed()), - } - } - - #[cfg(feature = "sync")] - fn sync_is_configured(&self) -> bool { - self.inner.sync.is_some() - } - - #[cfg(not(feature = "sync"))] - fn sync_is_configured(&self) -> bool { - false - } -} - -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -impl SocialOperations<'_> { - /// Observes both transport directions without initiating relay work. - pub async fn transport_health(&self) -> Result<TransportHealth> { - use radroots_transport::capability::Availability as TransportAvailability; - let slot = self.nostr()?; - let configured = slot.targets().is_some(); - let source = radroots_transport::EventSource::status(slot) - .await - .map_err(|_| Error::shared_operation_failed_without_source())?; - let sink = radroots_transport::EventSink::status(slot) - .await - .map_err(|_| Error::shared_operation_failed_without_source())?; - Ok(TransportHealth { - configured, - source_available: source.availability() == TransportAvailability::Available, - sink_available: sink.availability() == TransportAvailability::Available, - }) - } - - /// Fetches, verifies, and durably ingests the latest profile for the active signer. - pub async fn fetch_profile_for_signer(&self) -> Result<Option<ProfileEvent>> { - let identity = self.identity()?; - let selector = radroots_transport::source::FetchSelector::all() - .with_kinds(vec![radroots_event::envelope::kind::KIND_PROFILE]) - .and_then(|selector| selector.with_authors(vec![identity.public_key()])) - .map_err(|_| Error::invalid_host_configuration_without_source())?; - let events = self.fetch(32, selector).await?; - let mut profiles = events - .into_iter() - .filter_map(|event| profile_event(&event).ok()) - .collect::<Vec<_>>(); - profiles.sort_by_key(|event| std::cmp::Reverse(event.created_at)); - Ok(profiles.into_iter().next()) - } - - /// Fetches, verifies, and durably ingests a bounded kind-1 page. - pub async fn fetch_posts( - &self, - limit: u16, - since_unix_seconds: Option<u64>, - ) -> Result<Vec<PostEvent>> { - let mut selector = radroots_transport::source::FetchSelector::all() - .with_kinds(vec![radroots_event::envelope::kind::KIND_POST]) - .map_err(|_| Error::invalid_host_configuration_without_source())?; - if let Some(since) = since_unix_seconds { - selector = selector - .with_since_unix_seconds(since) - .map_err(|_| Error::invalid_host_configuration_without_source())?; - } - let mut posts = self - .fetch(limit, selector) - .await? - .into_iter() - .map(|event| PostEvent { - event_id: event.id_hex(), - author: event.pubkey().to_hex(), - created_at: event.created_at(), - content: event.content().to_owned(), - }) - .collect::<Vec<_>>(); - posts.sort_by_key(|event| std::cmp::Reverse(event.created_at)); - Ok(posts) - } - - /// Publishes a complete media-free profile replacement. - pub async fn publish_profile(&self, draft: ProfileDraft) -> Result<PublishReceipt> { - let mut profile = radroots_event::profile::AuthoredProfile::new(draft.name) - .map_err(Error::invalid_host_configuration)?; - if let Some(value) = draft.display_name { - profile = profile.with_display_name(value); - } - if let Some(value) = draft.about { - profile = profile.with_about(value); - } - if let Some(value) = draft.nip05 { - profile = profile.with_nip05( - radroots_event::profile::Nip05Identifier::parse(value.as_str()) - .map_err(Error::invalid_host_configuration)?, - ); - } - if let Some(value) = draft.bot { - profile = profile.with_bot(value); - } - self.publish(SocialDraft::Profile(Box::new(profile))).await - } - - /// Publishes one strict root kind-1 update. - pub async fn publish_text(&self, content: impl Into<String>) -> Result<PublishReceipt> { - let update = radroots_event::post::AuthoredUpdate::new(content) - .map_err(Error::invalid_host_configuration)?; - self.publish(SocialDraft::Update(update)).await - } - - /// Publishes one strict direct NIP-10 reply. - pub async fn publish_reply( - &self, - content: impl Into<String>, - root_event_id: &str, - root_author: &str, - relay_hint: Option<&str>, - ) -> Result<PublishReceipt> { - let reference = radroots_event::post::reply::Nip10ReplyReference::parse( - root_event_id, - root_author, - relay_hint, - ) - .map_err(Error::invalid_host_configuration)?; - let reply = radroots_event::post::reply::AuthoredNip10Reply::direct(content, reference) - .map_err(Error::invalid_host_configuration)?; - self.publish(SocialDraft::Reply(reply)).await - } - - async fn fetch( - &self, - limit: u16, - selector: radroots_transport::source::FetchSelector, - ) -> Result<Vec<radroots_event::SignedEvent>> { - let slot = self.nostr()?; - let targets = slot - .targets() - .ok_or_else(Error::shared_operation_unavailable)?; - let request_id = format!("sdk-fetch-{}", uuid::Uuid::new_v4()); - let deadline = now_unix_ms()?.saturating_add(30_000); - let request = radroots_transport::FetchRequest::new( - request_id, - targets, - radroots_transport::source::FetchBounds::new(limit, deadline) - .map_err(|_| Error::invalid_host_configuration_without_source())?, - ) - .map_err(|_| Error::invalid_host_configuration_without_source())? - .with_selector(selector); - let page = radroots_transport::EventSource::fetch(slot, request) - .await - .map_err(|_| Error::shared_operation_failed_without_source())?; - let observed = page.events().to_vec(); - let receipt = self - .client - .sync()? - .ok_or_else(Error::shared_operation_unavailable)? - .ingest_batch( - observed.clone(), - &radroots_sync::ingest::RegistryPolicy::verified(), - ) - .await; - Ok(observed - .into_iter() - .zip(receipt.outcomes()) - .filter_map(|(observed, outcome)| { - outcome.as_ref().ok().map(|_| observed.event().clone()) - }) - .collect()) - } - - async fn publish(&self, authored: SocialDraft) -> Result<PublishReceipt> { - use radroots_event::contract::AuthorRole; - use radroots_event_codec::authoring::AuthoredEventPlan; - use radroots_signing::{Actor, actor::ActorSource, request::CancellationPolicy}; - use radroots_storage::journal::IdempotencyKey; - use radroots_sync::{PushRequest, policy::SyncId}; - use radroots_transport::policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}; - - let identity = self.identity()?; - let targets = self - .nostr()? - .targets() - .ok_or_else(Error::shared_operation_unavailable)?; - let operation_uuid = uuid::Uuid::new_v4(); - let operation_id = - SyncId::new(*operation_uuid.as_bytes()).map_err(Error::invalid_host_configuration)?; - let now = now_unix_ms()?; - let created_at = now / 1_000; - let plan = match authored { - SocialDraft::Profile(profile) => { - AuthoredEventPlan::from_profile(&profile, created_at, identity.public_key_hex()) - } - SocialDraft::Update(update) => { - AuthoredEventPlan::from_update(&update, created_at, identity.public_key_hex()) - } - SocialDraft::Reply(reply) => { - AuthoredEventPlan::from_nip10_reply(&reply, created_at, identity.public_key_hex()) - } - } - .map_err(Error::invalid_host_configuration)?; - let actor = Actor::new( - identity.public_key(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Any], - ) - .map_err(Error::invalid_host_configuration)?; - let request = PushRequest::new( - operation_id, - IdempotencyKey::parse(format!("sdk-{operation_uuid}")) - .map_err(Error::invalid_host_configuration)?, - actor, - plan, - targets, - SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::any()), - now.checked_add(30_000) - .ok_or_else(Error::invalid_host_configuration_without_source)?, - CancellationPolicy::PreservePublishedRequest, - ) - .map_err(Error::invalid_host_configuration)?; - let sync = self - .client - .sync()? - .ok_or_else(Error::shared_operation_unavailable)?; - let preparation = sync - .prepare_push(request.clone()) - .await - .map_err(Error::shared_operation_failed)?; - sync.sign_prepared(request) - .await - .map_err(Error::shared_operation_failed)?; - sync.admit_signed(operation_id) - .await - .map_err(Error::shared_operation_failed)?; - let status = sync - .push_status(operation_id) - .await - .map_err(Error::shared_operation_failed)? - .ok_or_else(Error::shared_operation_failed_without_source)?; - let event_id = status - .artifact() - .signed() - .ok_or_else(Error::shared_operation_failed_without_source)? - .event() - .id_hex(); - let delivery = sync - .deliver_push(operation_id) - .await - .map_err(Error::shared_operation_failed)?; - Ok(PublishReceipt { - event_id, - replay: preparation.is_replay(), - delivery_state: delivery.plan().state(), - }) - } - - fn identity(&self) -> Result<crate::signing::LocalIdentity> { - self.client - .inner - .signing_slot - .as_ref() - .and_then(crate::signing::Slot::identity) - .ok_or_else(Error::shared_operation_unavailable) - } - - fn nostr(&self) -> Result<&crate::transport::NostrSlot> { - self.client - .inner - .nostr_slot - .as_ref() - .ok_or_else(Error::shared_operation_unavailable) - } -} - -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -fn now_unix_ms() -> Result<u64> { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok() - .and_then(|duration| u64::try_from(duration.as_millis()).ok()) - .filter(|value| *value != 0) - .ok_or_else(Error::shared_operation_unavailable) -} - -#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] -fn profile_event( - event: &radroots_event::SignedEvent, -) -> std::result::Result< - ProfileEvent, - radroots_event_codec::decode::profile::RadrootsProfileMetadataParseError, -> { - let profile = - radroots_event_codec::decode::profile::parse_inbound_profile_metadata(event.content())?; - Ok(ProfileEvent { - event_id: event.id_hex(), - author: event.pubkey().to_hex(), - created_at: event.created_at(), - name: profile.name().map(str::to_owned), - display_name: profile.display_name().map(str::to_owned), - about: profile.about().map(str::to_owned), - picture: profile.picture().map(|value| value.as_str().to_owned()), - banner: profile.banner().map(|value| value.as_str().to_owned()), - nip05: profile.nip05().map(|value| value.as_str().to_owned()), - bot: profile.bot(), - }) -} - -struct CloseAttempt { - inner: Arc<ClientInner>, - completed: bool, -} - -impl CloseAttempt { - fn new(inner: Arc<ClientInner>) -> Self { - Self { - inner, - completed: false, - } - } - - fn complete(mut self) { - self.inner.lifecycle.store(CLOSED, Ordering::Release); - self.completed = true; - } -} - -impl Drop for CloseAttempt { - fn drop(&mut self) { - if !self.completed { - self.inner - .lifecycle - .store(CLOSE_RETRY_REQUIRED, Ordering::Release); - } - } -} - -impl std::fmt::Debug for Client { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("Client") - .field("signer", &self.inner.signer.is_some()) - .field("source", &self.inner.source.is_some()) - .field("sink", &self.inner.sink.is_some()) - .field("closed", &self.is_closed()) - .finish_non_exhaustive() - } -} - -impl std::fmt::Debug for ClientBuilder { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("ClientBuilder") - .field("storage", &self.storage.is_some()) - .field("signer", &self.signer.is_some()) - .field("source", &self.source.is_some()) - .field("sink", &self.sink.is_some()) - .finish_non_exhaustive() - } -} - -#[cfg(all(test, feature = "memory"))] -mod tests { - use super::*; - use radroots_signing::{ - Error as SigningError, SignReceipt, SignRequest, SignerStatus, error::Kind, - signer::BoxFuture as SigningFuture, - }; - use radroots_transport::{ - DeliveryReceipt, DeliveryRequest, Error as TransportError, FetchPage, FetchRequest, - SinkFailure, SinkStatus, SourceStatus, outcome::Retryability, - source::BoxFuture as TransportFuture, - }; - use std::{ - future::Future, - sync::Arc, - task::{Context, Poll, Wake, Waker}, - }; - - struct TestSource; - struct TestSink; - struct TestSigner; - - impl EventSource for TestSource { - fn status(&self) -> TransportFuture<'_, std::result::Result<SourceStatus, TransportError>> { - Box::pin(async { Err(TransportError::UnsupportedOperation) }) - } - - fn fetch( - &self, - _request: FetchRequest, - ) -> TransportFuture<'_, std::result::Result<FetchPage, TransportError>> { - Box::pin(async { Err(TransportError::UnsupportedOperation) }) - } - } - - impl EventSink for TestSink { - fn status(&self) -> TransportFuture<'_, std::result::Result<SinkStatus, TransportError>> { - Box::pin(async { Err(TransportError::UnsupportedOperation) }) - } - - fn deliver( - &self, - request: DeliveryRequest, - ) -> TransportFuture<'_, std::result::Result<DeliveryReceipt, SinkFailure>> { - Box::pin(async move { - Err(SinkFailure::for_request( - &request, - "test_sink_unavailable", - Retryability::Terminal, - None, - None, - Vec::new(), - ) - .expect("test sink failure")) - }) - } - } - - impl Signer for TestSigner { - fn status(&self) -> SigningFuture<'_, std::result::Result<SignerStatus, SigningError>> { - Box::pin(async { Err(SigningError::new(Kind::InternalError)) }) - } - - fn sign( - &self, - _request: SignRequest, - ) -> SigningFuture<'_, std::result::Result<SignReceipt, SigningError>> { - Box::pin(async { Err(SigningError::new(Kind::InternalError)) }) - } - } - - fn generation() -> SourceGeneration { - SourceGeneration::new([1; 32]).expect("non-zero generation") - } - - #[test] - fn missing_storage_and_signer_without_sink_fail_closed() { - assert!(matches!( - ClientBuilder::new().build(), - Err(error) if error.kind() == crate::error::ErrorKind::MissingStorage - )); - assert!(matches!( - ClientBuilder::memory(generation()) - .signer(Arc::new(TestSigner)) - .build(), - Err(error) if error.kind() == crate::error::ErrorKind::SignerWithoutSink - )); - } - - #[test] - fn memory_local_source_only_and_sink_only_compositions_are_explicit() { - let local = ClientBuilder::memory(generation()).build().expect("local"); - assert!(local.source().expect("source capability").is_none()); - assert!(local.sink().expect("sink capability").is_none()); - assert!(local.signer().expect("signer capability").is_none()); - - let source = ClientBuilder::memory(generation()) - .source(Arc::new(TestSource)) - .build() - .expect("source-only"); - assert!(source.source().expect("source capability").is_some()); - assert!(source.sink().expect("sink capability").is_none()); - - let sink = ClientBuilder::memory(generation()) - .sink(Arc::new(TestSink)) - .build() - .expect("sink-only"); - assert!(sink.source().expect("source capability").is_none()); - assert!(sink.sink().expect("sink capability").is_some()); - } - - #[test] - fn signer_with_sink_is_valid_and_diagnostics_are_capability_only() { - let client = ClientBuilder::memory(generation()) - .sink(Arc::new(TestSink)) - .signer(Arc::new(TestSigner)) - .build() - .expect("outbound client"); - assert!(client.signer().expect("signer capability").is_some()); - assert_eq!( - format!("{client:?}"), - "Client { signer: true, source: false, sink: true, closed: false, .. }" - ); - } - - #[cfg(all( - feature = "sync", - feature = "nostr", - feature = "local-signing", - feature = "nip46" - ))] - #[test] - fn curated_models_accessors_and_builder_modes_are_complete() { - let profile_draft = ProfileDraft::new("farm") - .with_display_name("Farm") - .with_about("Local food") - .with_nip05("farm@example.test") - .with_bot(false); - assert_eq!(profile_draft.name, "farm"); - assert_eq!(profile_draft.display_name.as_deref(), Some("Farm")); - assert_eq!(profile_draft.about.as_deref(), Some("Local food")); - assert_eq!(profile_draft.nip05.as_deref(), Some("farm@example.test")); - assert_eq!(profile_draft.bot, Some(false)); - - let profile = ProfileEvent { - event_id: "event".to_owned(), - author: "author".to_owned(), - created_at: 7, - name: Some("farm".to_owned()), - display_name: Some("Farm".to_owned()), - about: Some("Local food".to_owned()), - picture: Some("https://example.test/picture".to_owned()), - banner: Some("https://example.test/banner".to_owned()), - nip05: Some("farm@example.test".to_owned()), - bot: Some(false), - }; - assert_eq!(profile.event_id(), "event"); - assert_eq!(profile.author(), "author"); - assert_eq!(profile.created_at(), 7); - assert_eq!(profile.name(), Some("farm")); - assert_eq!(profile.display_name(), Some("Farm")); - assert_eq!(profile.about(), Some("Local food")); - assert_eq!(profile.picture(), Some("https://example.test/picture")); - assert_eq!(profile.banner(), Some("https://example.test/banner")); - assert_eq!(profile.nip05(), Some("farm@example.test")); - assert_eq!(profile.bot(), Some(false)); - - let post = PostEvent { - event_id: "post".to_owned(), - author: "author".to_owned(), - created_at: 8, - content: "content".to_owned(), - }; - assert_eq!(post.event_id(), "post"); - assert_eq!(post.author(), "author"); - assert_eq!(post.created_at(), 8); - assert_eq!(post.content(), "content"); - - for (delivery_state, delivered, pending) in [ - (AuthoredDeliveryState::Pending, false, true), - (AuthoredDeliveryState::Retryable, false, true), - (AuthoredDeliveryState::Satisfied, true, false), - (AuthoredDeliveryState::Exhausted, false, false), - (AuthoredDeliveryState::FailedTerminal, false, false), - (AuthoredDeliveryState::Cancelled, false, false), - ] { - let receipt = PublishReceipt { - event_id: "published".to_owned(), - replay: true, - delivery_state, - }; - assert_eq!(receipt.event_id(), "published"); - assert!(receipt.is_replay()); - assert_eq!(receipt.delivery_state(), delivery_state); - assert_eq!(receipt.is_delivered(), delivered); - assert_eq!(receipt.is_delivery_pending(), pending); - } - - let health = TransportHealth { - configured: true, - source_available: true, - sink_available: false, - }; - assert!(health.is_configured()); - assert!(health.is_source_available()); - assert!(!health.is_sink_available()); - - let builder = ClientBuilder::memory_default() - .source(Arc::new(TestSource)) - .host_sync(crate::sync::HostPolicy::default()); - assert!(format!("{builder:?}").contains("storage: true")); - let client = builder.build().expect("sync client"); - assert!(client.sync().expect("sync").is_some()); - assert!(client.farm().expect("farm").is_some()); - assert!(client.listing().expect("listing").is_some()); - assert!(client.trade().expect("trade").is_some()); - assert!(client.social().is_err()); - assert!( - format!( - "{:?}", - client.storage_operations().expect("storage operations") - ) - .contains("borrowed canonical storage") - ); - assert!( - format!("{:?}", client.sync().expect("sync").expect("operations")) - .contains("borrowed canonical engine") - ); - - let host = ClientBuilder::memory_default() - .sink(Arc::new(TestSink)) - .signing(crate::signing::Provider::host(Arc::new(TestSigner))) - .build() - .expect("host signer"); - assert!( - !host - .capabilities() - .get(CapabilityId::NIP46_SIGNING) - .expect("nip46") - .is_configured() - ); - - let nip46 = ClientBuilder::memory_default() - .sink(Arc::new(TestSink)) - .signing(crate::signing::Provider::nip46(Arc::new(TestSigner))) - .build() - .expect("nip46 signer"); - assert!( - nip46 - .capabilities() - .get(CapabilityId::NIP46_SIGNING) - .expect("nip46") - .is_configured() - ); - } - - #[cfg(feature = "local-signing")] - #[test] - fn module_scoped_signing_provider_configures_the_matching_capability() { - let signer = radroots_nostr::signing::LocalSigner::generate().expect("local signer"); - let client = ClientBuilder::memory(generation()) - .sink(Arc::new(TestSink)) - .signing(crate::signing::Provider::local(signer)) - .build() - .expect("client"); - let status = client - .capabilities() - .get(CapabilityId::LOCAL_SIGNING) - .expect("local signing"); - assert!(status.is_compiled()); - assert!(status.is_configured()); - assert_eq!(status.availability(), Availability::Available); - } - - #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] - #[tokio::test] - async fn shared_mobile_composition_is_single_client_explicit_and_fail_closed() { - let signing = crate::signing::Slot::new(); - let nostr = crate::transport::NostrSlot::new(crate::transport::RelayUrlPolicy::Local); - let client = ClientBuilder::memory(generation()) - .signing(crate::signing::Provider::slot(signing.clone())) - .nostr(nostr.clone()) - .host_sync(crate::sync::HostPolicy::standard()) - .build() - .expect("shared client"); - - let health = client - .social() - .expect("social composition") - .transport_health() - .await - .expect("passive health"); - assert!(!health.is_configured()); - assert!(!health.is_source_available()); - assert!(!health.is_sink_available()); - assert!(matches!( - client - .social() - .expect("social composition") - .fetch_posts(1, None) - .await, - Err(error) if error.kind() == crate::error::ErrorKind::SharedOperationUnavailable - )); - assert!( - client - .social() - .expect("social composition") - .fetch_profile_for_signer() - .await - .is_err() - ); - - let (_secret, identity) = signing.generate().expect("host key handoff"); - assert_eq!(signing.identity(), Some(identity)); - let social = client.social().expect("social composition"); - assert!(social.fetch_profile_for_signer().await.is_err()); - assert!(social.fetch_posts(2, Some(1)).await.is_err()); - assert!( - social - .publish_profile( - ProfileDraft::new("farm") - .with_display_name("Farm") - .with_about("Local food") - .with_nip05("farm@example.test") - .with_bot(false), - ) - .await - .is_err() - ); - assert!( - social - .publish_profile(ProfileDraft::new("farm")) - .await - .is_err() - ); - assert!(social.publish_text("local update").await.is_err()); - assert!( - social - .publish_reply( - "local reply", - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - Some("ws://127.0.0.1:7447"), - ) - .await - .is_err() - ); - assert!( - social - .publish_reply("local reply", "invalid", "invalid", None) - .await - .is_err() - ); - nostr - .configure(["ws://127.0.0.1:7447"]) - .expect("relay selection"); - assert!(nostr.targets().is_some()); - let social = client.social().expect("social composition"); - assert!( - social - .transport_health() - .await - .expect("configured passive health") - .is_configured() - ); - let fetch = tokio::time::timeout( - core::time::Duration::from_secs(3), - social.fetch_posts(2, Some(1)), - ) - .await - .expect("localhost fetch remains bounded"); - assert!(fetch.is_err() || fetch.is_ok_and(|events| events.is_empty())); - let profile_fetch = tokio::time::timeout( - core::time::Duration::from_secs(3), - social.fetch_profile_for_signer(), - ) - .await - .expect("localhost profile fetch remains bounded"); - assert!(profile_fetch.is_err() || profile_fetch.is_ok_and(|event| event.is_none())); - let publish = tokio::time::timeout( - core::time::Duration::from_secs(3), - social.publish_text("configured local update"), - ) - .await - .expect("localhost publish remains bounded"); - match publish { - Ok(receipt) => { - assert_eq!(receipt.event_id().len(), 64); - assert!(!receipt.is_replay()); - } - Err(error) => assert_eq!(error.kind(), crate::error::ErrorKind::SharedOperationFailed), - } - let profile_publish = tokio::time::timeout( - core::time::Duration::from_secs(3), - social.publish_profile( - ProfileDraft::new("configured-farm") - .with_display_name("Configured Farm") - .with_about("Local food"), - ), - ) - .await - .expect("localhost profile publish remains bounded"); - assert!( - profile_publish.is_ok() - || matches!( - profile_publish, - Err(error) - if error.kind() == crate::error::ErrorKind::SharedOperationFailed - ) - ); - let reply_publish = tokio::time::timeout( - core::time::Duration::from_secs(3), - social.publish_reply( - "configured reply", - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - None, - ), - ) - .await - .expect("localhost reply publish remains bounded"); - assert!( - reply_publish.is_ok() - || matches!( - reply_publish, - Err(error) - if error.kind() == crate::error::ErrorKind::SharedOperationFailed - ) - ); - nostr.clear(); - assert!(nostr.targets().is_none()); - signing.clear(); - assert!(signing.identity().is_none()); - assert_eq!( - radroots_signing::Signer::status(&signing) - .await - .expect("empty slot status") - .availability(), - radroots_signing::status::SignerAvailability::Unavailable - ); - } - - #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] - #[test] - fn verified_profile_projection_preserves_the_curated_public_fields() { - use radroots_event::wire::v1::Nip01EventWire; - - let author = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - let content = r#"{"name":"farm","display_name":"Farm","about":"Local food","nip05":"farm@example.test","bot":false}"#; - let id = radroots_event::draft::compute_nip01_event_id(author, 7, 0, &[], content) - .expect("canonical profile id") - .to_hex(); - let raw = serde_json::json!({ - "id": id, - "pubkey": author, - "created_at": 7, - "kind": 0, - "tags": [], - "content": content, - "sig": "d".repeat(128), - }) - .to_string(); - let wire = Nip01EventWire::parse_json(&raw).expect("profile wire"); - let event = radroots_event::SignedEvent::from_wire_verified_id(wire, raw) - .expect("verified profile event"); - let profile = profile_event(&event).expect("profile projection"); - assert_eq!(profile.event_id(), id); - assert_eq!(profile.author(), author); - assert_eq!(profile.created_at(), 7); - assert_eq!(profile.name(), Some("farm")); - assert_eq!(profile.display_name(), Some("Farm")); - assert_eq!(profile.about(), Some("Local food")); - assert_eq!(profile.nip05(), Some("farm@example.test")); - assert_eq!(profile.bot(), Some(false)); - assert_eq!(profile.picture(), None); - assert_eq!(profile.banner(), None); - } - - #[test] - fn close_is_clone_shared_idempotent_and_rejects_later_capability_access() { - let client = ClientBuilder::memory(generation()).build().expect("client"); - let clone = client.clone(); - assert!(!client.is_closed()); - block_on(client.close()).expect("first close"); - assert!(clone.is_closed()); - block_on(clone.close()).expect("repeated close"); - assert!(matches!( - clone.storage(), - Err(error) if error.kind() == crate::error::ErrorKind::ClientClosed - )); - assert!(matches!( - client.source(), - Err(error) if error.kind() == crate::error::ErrorKind::ClientClosed - )); - } - - #[test] - fn close_cancellation_boundaries_are_explicit_and_retryable() { - let client = ClientBuilder::memory(generation()).build().expect("client"); - let unpolled = client.close(); - drop(unpolled); - assert!(client.storage().is_ok()); - - client.inner.lifecycle.store(CLOSING, Ordering::Release); - let attempt = CloseAttempt::new(Arc::clone(&client.inner)); - drop(attempt); - assert!(matches!( - client.storage(), - Err(error) if error.kind() == crate::error::ErrorKind::ClientClosing - )); - block_on(client.close()).expect("retry close"); - assert!(client.is_closed()); - } - - #[test] - fn concurrent_clones_converge_on_one_closed_state() { - let client = ClientBuilder::memory(generation()).build().expect("client"); - let first = client.clone(); - let second = client.clone(); - let outcomes = std::thread::scope(|scope| { - let first_close = scope.spawn(move || block_on(first.close())); - let second_close = scope.spawn(move || block_on(second.close())); - [ - first_close.join().expect("first thread"), - second_close.join().expect("second thread"), - ] - }); - assert!(outcomes.iter().all(|outcome| { - outcome.is_ok() - || matches!( - outcome, - Err(error) - if error.kind() == crate::error::ErrorKind::CloseInProgress - ) - })); - if !client.is_closed() { - block_on(client.close()).expect("finish close"); - } - assert!(client.is_closed()); - } - - #[test] - fn capability_reports_separate_configuration_degradation_and_lifecycle() { - let local = ClientBuilder::memory(generation()) - .capability_availability(CapabilityId::CANONICAL_STORAGE, Availability::Degraded) - .build() - .expect("client"); - let report = local.capabilities(); - let storage = report - .get(CapabilityId::CANONICAL_STORAGE) - .expect("storage"); - assert!(storage.is_compiled()); - assert!(storage.is_configured()); - assert_eq!(storage.availability(), Availability::Degraded); - - let signing = report.get(CapabilityId::LOCAL_SIGNING).expect("signing"); - assert!(!signing.is_configured()); - assert!(matches!( - signing.availability(), - Availability::Unavailable | Availability::Unsupported - )); - - block_on(local.close()).expect("close"); - assert_eq!( - local - .capabilities() - .get(CapabilityId::BACKUP_RESTORE) - .expect("backup") - .availability(), - Availability::Unavailable - ); - } - - #[test] - fn memory_storage_status_integrity_and_lifecycle_use_native_contracts() { - use radroots_storage::status::{ - IntegrityHealth, ShutdownState, StorageBackend, StorageOpenMode, WriterPolicy, - }; - - let client = ClientBuilder::memory(generation()).build().expect("client"); - let status = block_on(client.storage_status()).expect("status"); - assert_eq!(status.backend(), StorageBackend::Memory); - assert_eq!(status.open_mode(), StorageOpenMode::Create); - assert_eq!(status.writer_policy(), WriterPolicy::NoWriter); - assert_eq!(status.shutdown(), ShutdownState::Open); - assert_eq!( - block_on(client.storage_integrity()) - .expect("integrity") - .health(), - IntegrityHealth::Healthy - ); - block_on(client.close()).expect("close"); - assert_eq!( - block_on(client.storage_status()) - .expect_err("closed") - .kind(), - crate::error::ErrorKind::ClientClosed - ); - } - - #[cfg(feature = "sqlite")] - #[tokio::test] - async fn sqlite_builder_exposes_native_status_integrity_and_lifecycle() { - use radroots_storage::status::{ - IntegrityHealth, ShutdownState, StorageBackend, StorageOpenMode, WriterPolicy, - }; - - let directory = tempfile::tempdir().expect("temporary directory"); - let paths = crate::storage::SqlitePaths::from_directory(directory.path()).expect("paths"); - let options = - crate::storage::SqliteOptions::new(paths, crate::storage::SqliteOpenMode::Create) - .with_source_generation(generation(), 1) - .expect("source generation"); - let client = ClientBuilder::sqlite(options) - .await - .expect("open builder") - .build() - .expect("client"); - let status = client.storage_status().await.expect("status"); - assert_eq!(status.backend(), StorageBackend::Sqlite); - assert_eq!(status.open_mode(), StorageOpenMode::Create); - assert_eq!(status.writer_policy(), WriterPolicy::AdvisoryProcessLock); - assert_eq!(status.shutdown(), ShutdownState::Open); - assert!(status.wal_enabled()); - assert_ne!(status.busy_timeout_ms(), 0); - assert_eq!( - client - .storage_integrity() - .await - .expect("integrity") - .health(), - IntegrityHealth::Unknown - ); - client.close().await.expect("close"); - assert!(client.is_closed()); - } - - struct ThreadWaker; - - impl Wake for ThreadWaker { - fn wake(self: Arc<Self>) { - std::thread::current().unpark(); - } - } - - fn block_on<F: Future>(future: F) -> F::Output { - let waker = Waker::from(Arc::new(ThreadWaker)); - let mut context = Context::from_waker(&waker); - let mut future = Box::pin(future); - loop { - match future.as_mut().poll(&mut context) { - Poll::Ready(output) => return output, - Poll::Pending => std::thread::park(), - } - } - } -} diff --git a/crates/sdk/src/diagnostics.rs b/crates/sdk/src/diagnostics.rs @@ -1,59 +0,0 @@ -//! Redacted client diagnostics composed from canonical capability and storage status. - -/// One passive diagnostics snapshot containing only lower-owned status types. -#[derive(Clone, Debug)] -pub struct Report { - capabilities: crate::capability::CapabilityReport, - storage: radroots_storage::StorageStatus, -} - -impl Report { - /// Returns the side-effect-free SDK capability report. - #[must_use] - pub const fn capabilities(&self) -> &crate::capability::CapabilityReport { - &self.capabilities - } - - /// Returns the canonical backend status without implementation details. - #[must_use] - pub const fn storage(&self) -> radroots_storage::StorageStatus { - self.storage - } -} - -/// Captures passive, secret-safe diagnostics without filesystem paths, SQL, -/// connection handles, private artifacts, or recovery side effects. -pub async fn inspect(client: &crate::Client) -> crate::Result<Report> { - Ok(Report { - capabilities: client.capabilities(), - storage: client.storage_status().await?, - }) -} - -#[cfg(all(test, feature = "memory"))] -mod tests { - use std::sync::Arc; - - use radroots_storage::{ - event::SourceGeneration, memory::MemoryStorage, status::StorageBackend, - }; - - use crate::ClientBuilder; - - #[tokio::test] - async fn report_is_passive_native_and_redacted() { - let client = ClientBuilder::new() - .storage(Arc::new(MemoryStorage::new( - SourceGeneration::new([9; 32]).expect("generation"), - ))) - .build() - .expect("client"); - let report = super::inspect(&client).await.expect("report"); - - assert_eq!(report.storage().backend(), StorageBackend::Memory); - assert!(report.capabilities().iter().all(|status| { - !status.id().as_str().contains('/') && !status.id().as_str().contains('\\') - })); - assert!(!format!("{report:?}").contains("sqlite")); - } -} diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -1,465 +0,0 @@ -//! SDK-owned native errors and secret-safe protocol conversion. - -use std::{error, fmt}; - -use radroots_protocol::{ - error::v1::{ - CapabilityId as ProtocolCapabilityId, Class, ErrorReport, KnownCode, RecoveryAction, - SafeDetails, SafeMessage, - }, - runtime::v1::OperationId, -}; - -use crate::capability::CapabilityId; - -macro_rules! error_catalog { - ($( - $kind:ident => { - code: $code:ident, - operation: $operation:expr, - capability: $capability:expr, - message: $message:literal, - safe_detail_keys: [$($detail_key:literal),* $(,)?] - } - ),+ $(,)?) => { - /// Stable native SDK error category. - #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] - #[non_exhaustive] - pub enum ErrorKind { - $($kind,)+ - } - - impl ErrorKind { - /// Every SDK error category in catalog order. - pub const ALL: &'static [Self] = &[$(Self::$kind),+]; - - /// Returns metadata generated from the single SDK authority. - #[must_use] - pub const fn descriptor(self) -> ErrorDescriptor { - match self { - $(Self::$kind => ErrorDescriptor { - kind: Self::$kind, - code: KnownCode::$code, - operation: $operation, - capability: $capability, - message: $message, - safe_detail_keys: &[$($detail_key),*], - },)+ - } - } - } - - /// Complete SDK-native error metadata catalog. - pub const CATALOG: &[ErrorDescriptor] = &[ - $(ErrorDescriptor { - kind: ErrorKind::$kind, - code: KnownCode::$code, - operation: $operation, - capability: $capability, - message: $message, - safe_detail_keys: &[$($detail_key),*], - },)+ - ]; - }; -} - -error_catalog! { - MissingStorage => { - code: MissingStorage, - operation: None, - capability: Some(CapabilityId::CANONICAL_STORAGE), - message: "SDK storage capability is not configured", - safe_detail_keys: [] - }, - SignerWithoutSink => { - code: SignerWithoutSink, - operation: None, - capability: None, - message: "SDK signer requires an outbound event sink", - safe_detail_keys: [] - }, - CloseInProgress => { - code: ClientCloseInProgress, - operation: None, - capability: Some(CapabilityId::CANONICAL_STORAGE), - message: "SDK client close is in progress", - safe_detail_keys: [] - }, - ClientClosing => { - code: ClientClosing, - operation: None, - capability: Some(CapabilityId::CANONICAL_STORAGE), - message: "SDK client close requires completion or retry", - safe_detail_keys: [] - }, - ClientClosed => { - code: ClientClosed, - operation: None, - capability: Some(CapabilityId::CANONICAL_STORAGE), - message: "SDK client is closed", - safe_detail_keys: [] - }, - StorageCloseFailed => { - code: StorageCloseFailed, - operation: None, - capability: Some(CapabilityId::CANONICAL_STORAGE), - message: "SDK storage close failed", - safe_detail_keys: [] - }, - StorageOpenFailed => { - code: InternalError, - operation: None, - capability: Some(CapabilityId::PERSISTENT_STORAGE), - message: "SDK persistent storage open failed", - safe_detail_keys: [] - }, - StorageInspectionFailed => { - code: StorageIntegrityFailed, - operation: None, - capability: Some(CapabilityId::CANONICAL_STORAGE), - message: "SDK storage inspection failed", - safe_detail_keys: [] - }, - InvalidHostConfiguration => { - code: InvalidArgument, - operation: None, - capability: None, - message: "SDK host configuration is invalid", - safe_detail_keys: [] - }, - SharedOperationUnavailable => { - code: TransportOperationUnavailable, - operation: None, - capability: None, - message: "SDK shared network operation is unavailable", - safe_detail_keys: [] - }, - SharedOperationFailed => { - code: SyncPartial, - operation: None, - capability: None, - message: "SDK shared network operation failed", - safe_detail_keys: [] - }, -} - -/// Stable metadata for one native SDK failure. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct ErrorDescriptor { - kind: ErrorKind, - code: KnownCode, - operation: Option<OperationId>, - capability: Option<CapabilityId>, - message: &'static str, - safe_detail_keys: &'static [&'static str], -} - -impl ErrorDescriptor { - /// Returns the native category. - #[must_use] - pub const fn kind(self) -> ErrorKind { - self.kind - } - - /// Returns the stable protocol code. - #[must_use] - pub const fn code(self) -> KnownCode { - self.code - } - - /// Returns the class from the generated protocol authority. - #[must_use] - pub const fn class(self) -> Class { - self.code.descriptor().class - } - - /// Returns retryability from the generated protocol authority. - #[must_use] - pub const fn retryable(self) -> bool { - self.code.descriptor().retryable - } - - /// Returns recovery actions from the generated protocol authority. - #[must_use] - pub const fn recovery_actions(self) -> &'static [RecoveryAction] { - self.code.descriptor().recovery_actions - } - - /// Returns the related operation when the protocol catalog defines one. - #[must_use] - pub const fn operation(self) -> Option<OperationId> { - self.operation - } - - /// Returns the related runtime capability. - #[must_use] - pub const fn capability(self) -> Option<CapabilityId> { - self.capability - } - - /// Returns the secret-safe native display message. - #[must_use] - pub const fn message(self) -> &'static str { - self.message - } - - /// Returns the only structured detail keys permitted for this category. - #[must_use] - pub const fn safe_detail_keys(self) -> &'static [&'static str] { - self.safe_detail_keys - } -} - -/// Native SDK failure retaining an optional private source chain. -pub struct Error { - kind: ErrorKind, - source: Option<Box<dyn error::Error + Send + Sync>>, -} - -impl Error { - pub(crate) fn missing_storage() -> Self { - Self::without_source(ErrorKind::MissingStorage) - } - - pub(crate) fn signer_without_sink() -> Self { - Self::without_source(ErrorKind::SignerWithoutSink) - } - - pub(crate) fn close_in_progress() -> Self { - Self::without_source(ErrorKind::CloseInProgress) - } - - pub(crate) fn client_closing() -> Self { - Self::without_source(ErrorKind::ClientClosing) - } - - pub(crate) fn client_closed() -> Self { - Self::without_source(ErrorKind::ClientClosed) - } - - pub(crate) fn storage_close_failed(source: radroots_storage::Error) -> Self { - Self { - kind: ErrorKind::StorageCloseFailed, - source: Some(Box::new(source)), - } - } - - #[cfg(feature = "sqlite")] - pub(crate) fn storage_open_failed(source: radroots_storage_sqlite::Error) -> Self { - Self { - kind: ErrorKind::StorageOpenFailed, - source: Some(Box::new(source)), - } - } - - pub(crate) fn storage_inspection_failed(source: radroots_storage::Error) -> Self { - Self { - kind: ErrorKind::StorageInspectionFailed, - source: Some(Box::new(source)), - } - } - - #[cfg(any(feature = "sync", feature = "nostr"))] - pub(crate) fn invalid_host_configuration( - source: impl error::Error + Send + Sync + 'static, - ) -> Self { - Self { - kind: ErrorKind::InvalidHostConfiguration, - source: Some(Box::new(source)), - } - } - - #[cfg(feature = "nostr")] - pub(crate) fn invalid_host_configuration_without_source() -> Self { - Self::without_source(ErrorKind::InvalidHostConfiguration) - } - - #[cfg(any(feature = "sync", feature = "nostr"))] - pub(crate) fn shared_operation_unavailable() -> Self { - Self::without_source(ErrorKind::SharedOperationUnavailable) - } - - #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] - pub(crate) fn shared_operation_failed( - source: impl error::Error + Send + Sync + 'static, - ) -> Self { - Self { - kind: ErrorKind::SharedOperationFailed, - source: Some(Box::new(source)), - } - } - - #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] - pub(crate) fn shared_operation_failed_without_source() -> Self { - Self::without_source(ErrorKind::SharedOperationFailed) - } - - fn without_source(kind: ErrorKind) -> Self { - Self { kind, source: None } - } - - /// Returns the stable native category. - #[must_use] - pub const fn kind(&self) -> ErrorKind { - self.kind - } - - /// Returns metadata from the single SDK catalog. - #[must_use] - pub const fn descriptor(&self) -> ErrorDescriptor { - self.kind.descriptor() - } - - /// Converts to the V1 secret-safe protocol boundary. - /// - /// Native source messages are deliberately excluded. Catalog validation - /// tests guarantee that static messages and capability IDs are valid; this - /// conversion still fails closed to redacted text or no capability if a - /// future catalog edit violates those invariants. - #[must_use] - pub fn to_report(&self) -> ErrorReport { - let descriptor = self.descriptor(); - let capability = descriptor - .capability() - .and_then(|id| ProtocolCapabilityId::parse(id.as_str().to_owned()).ok()); - let message = SafeMessage::parse(descriptor.message().to_owned()) - .unwrap_or_else(|_| SafeMessage::redacted()); - ErrorReport::known( - descriptor.code(), - descriptor.operation(), - capability, - message, - SafeDetails::default(), - ) - } -} - -impl fmt::Display for Error { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.descriptor().message()) - } -} - -impl fmt::Debug for Error { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("Error") - .field("kind", &self.kind) - .field("code", &self.descriptor().code()) - .finish_non_exhaustive() - } -} - -impl error::Error for Error { - fn source(&self) -> Option<&(dyn error::Error + 'static)> { - self.source - .as_deref() - .map(|source| source as &(dyn error::Error + 'static)) - } -} - -/// SDK result alias. -pub type Result<T> = std::result::Result<T, Error>; - -#[cfg(test)] -mod tests { - use std::{collections::BTreeSet, error::Error as _}; - - use super::*; - - #[test] - fn catalog_is_exhaustive_unique_and_protocol_valid() { - assert_eq!(CATALOG.len(), ErrorKind::ALL.len()); - let mut kinds = BTreeSet::new(); - let mut codes = BTreeSet::new(); - for (index, descriptor) in CATALOG.iter().copied().enumerate() { - assert!(kinds.insert(descriptor.kind())); - assert!(codes.insert(descriptor.code().as_str())); - assert_eq!(descriptor.kind(), ErrorKind::ALL[index]); - assert_eq!(descriptor, descriptor.kind().descriptor()); - assert!(!descriptor.recovery_actions().is_empty()); - assert!(SafeMessage::parse(descriptor.message()).is_ok()); - if let Some(capability) = descriptor.capability() { - assert!(ProtocolCapabilityId::parse(capability.as_str()).is_ok()); - } - assert!(descriptor.safe_detail_keys().is_empty()); - Error::without_source(descriptor.kind()) - .to_report() - .validate() - .expect("protocol report"); - } - } - - #[test] - fn native_source_is_preserved_but_protocol_report_is_redacted_from_it() { - let error = Error::storage_close_failed(radroots_storage::Error::BackendUnavailable); - assert!(error.source().is_some()); - assert_eq!(error.kind(), ErrorKind::StorageCloseFailed); - assert_eq!(error.to_string(), "SDK storage close failed"); - let report = error.to_report(); - assert_eq!(report.code().as_str(), "storage_close_failed"); - assert_eq!(report.message().as_str(), "SDK storage close failed"); - assert!(!report.message().as_str().contains("backend")); - assert!(format!("{error:?}").contains("StorageCloseFailed")); - assert!(!format!("{error:?}").contains("BackendUnavailable")); - } - - #[test] - fn native_constructor_and_descriptor_surface_is_complete() { - let source_free = [ - Error::missing_storage(), - Error::signer_without_sink(), - Error::close_in_progress(), - Error::client_closing(), - Error::client_closed(), - ]; - for error in source_free { - assert!(error.source().is_none()); - let descriptor = error.descriptor(); - assert_eq!(descriptor.kind(), error.kind()); - assert_eq!(descriptor.class(), descriptor.code().descriptor().class); - assert_eq!( - descriptor.retryable(), - descriptor.code().descriptor().retryable - ); - assert_eq!(descriptor.operation(), None); - assert_eq!(descriptor.message(), error.to_string()); - } - - let inspection = - Error::storage_inspection_failed(radroots_storage::Error::BackendUnavailable); - assert_eq!(inspection.kind(), ErrorKind::StorageInspectionFailed); - assert!(inspection.source().is_some()); - - #[cfg(any(feature = "sync", feature = "nostr"))] - { - let host = Error::invalid_host_configuration(std::io::Error::other("private")); - assert_eq!(host.kind(), ErrorKind::InvalidHostConfiguration); - assert!(host.source().is_some()); - assert!(!host.to_string().contains("private")); - assert_eq!( - Error::shared_operation_unavailable().kind(), - ErrorKind::SharedOperationUnavailable - ); - } - - #[cfg(feature = "nostr")] - assert!( - Error::invalid_host_configuration_without_source() - .source() - .is_none() - ); - - #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] - { - let failed = Error::shared_operation_failed(std::io::Error::other("private")); - assert_eq!(failed.kind(), ErrorKind::SharedOperationFailed); - assert!(failed.source().is_some()); - assert!( - Error::shared_operation_failed_without_source() - .source() - .is_none() - ); - } - } -} diff --git a/crates/sdk/src/farm.rs b/crates/sdk/src/farm.rs @@ -1,549 +0,0 @@ -//! Side-effect-free farm planning and canonical durable enqueue operations. - -use std::{error, fmt}; - -use radroots_event::{ - GenericEventDraft, - contract::AuthorRole, - envelope::kind::KIND_FARM, - farm::Farm, - id::{AddressableCoordinate, ParseError}, -}; -use radroots_event_codec::{ - authoring::AuthoredEventPlan, encode::EventEncodeError, encode::farm::to_wire_parts, -}; -use radroots_signing::Actor; - -const FARM_PROFILE_CONTRACT_ID: &str = "radroots.farm.profile.v1"; - -/// Pure inputs for one frozen farm profile plan. -#[derive(Clone, Debug)] -pub struct PrepareRequest { - actor: Actor, - farm: Farm, - created_at_unix: u64, -} - -impl PrepareRequest { - /// Creates explicit canonical planning inputs. - #[must_use] - pub const fn new(actor: Actor, farm: Farm, created_at_unix: u64) -> Self { - Self { - actor, - farm, - created_at_unix, - } - } -} - -/// Frozen, replay-stable farm publication plan. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Plan { - actor: Actor, - coordinate: AddressableCoordinate, - authored_event: AuthoredEventPlan, -} - -impl Plan { - /// Returns the exact authorized actor carried into signing. - #[must_use] - pub const fn actor(&self) -> &Actor { - &self.actor - } - - /// Returns the canonical addressable farm coordinate. - #[must_use] - pub const fn coordinate(&self) -> &AddressableCoordinate { - &self.coordinate - } - - /// Returns the immutable canonical authored event plan. - #[must_use] - pub const fn authored_event(&self) -> &AuthoredEventPlan { - &self.authored_event - } -} - -/// Farm plan validation stage. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum PrepareErrorKind { - /// The actor does not claim the required farm author role. - UnauthorizedActor, - /// The canonical farm codec rejected the native farm model. - Encode, - /// The canonical addressable coordinate rejected the farm identity. - Coordinate, - /// The canonical event draft rejected the encoded parts. - Draft, -} - -/// One secret-safe farm planning failure retaining its lower source. -pub struct PrepareError { - kind: PrepareErrorKind, - source: Option<Box<dyn error::Error + Send + Sync>>, -} - -impl PrepareError { - /// Returns the stable client-level planning stage. - #[must_use] - pub const fn kind(&self) -> PrepareErrorKind { - self.kind - } - - fn unauthorized_actor() -> Self { - Self { - kind: PrepareErrorKind::UnauthorizedActor, - source: None, - } - } - - fn encode(source: EventEncodeError) -> Self { - Self::with_source(PrepareErrorKind::Encode, source) - } - - fn coordinate(source: ParseError) -> Self { - Self::with_source(PrepareErrorKind::Coordinate, source) - } - - fn draft(source: radroots_event::draft::DraftError) -> Self { - Self::with_source(PrepareErrorKind::Draft, source) - } - - fn with_source( - kind: PrepareErrorKind, - source: impl error::Error + Send + Sync + 'static, - ) -> Self { - Self { - kind, - source: Some(Box::new(source)), - } - } -} - -impl fmt::Display for PrepareError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self.kind { - PrepareErrorKind::UnauthorizedActor => "farm actor is not authorized", - PrepareErrorKind::Encode => "farm model is invalid", - PrepareErrorKind::Coordinate => "farm coordinate is invalid", - PrepareErrorKind::Draft => "farm event draft is invalid", - }) - } -} - -impl fmt::Debug for PrepareError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("PrepareError") - .field("kind", &self.kind) - .finish_non_exhaustive() - } -} - -impl error::Error for PrepareError { - fn source(&self) -> Option<&(dyn error::Error + 'static)> { - self.source - .as_deref() - .map(|source| source as &(dyn error::Error + 'static)) - } -} - -/// Validates and freezes one farm profile without storage, signing, or network work. -/// -/// `Farm` contains only public profile locality. Exact coordinates and other -/// private farm artifacts are deliberately not accepted here; hosts persist -/// their typed references and metadata through -/// `radroots_storage::private_artifact::PrivateArtifactStore`. -pub fn prepare(request: PrepareRequest) -> Result<Plan, PrepareError> { - if !request.actor.satisfies(AuthorRole::Farmer) { - return Err(PrepareError::unauthorized_actor()); - } - let parts = to_wire_parts(&request.farm).map_err(PrepareError::encode)?; - let coordinate = AddressableCoordinate::parse(format!( - "{KIND_FARM}:{}:{}", - request.actor.public_key(), - request.farm.d_tag - )) - .map_err(PrepareError::coordinate)?; - let authored_event = AuthoredEventPlan::from_generic( - GenericEventDraft::new( - FARM_PROFILE_CONTRACT_ID, - parts.kind, - request.created_at_unix, - parts.tags, - parts.content, - request.actor.public_key().to_hex(), - ) - .map_err(PrepareError::draft)?, - ) - .map_err(PrepareError::draft)?; - Ok(Plan { - actor: request.actor, - coordinate, - authored_event, - }) -} - -#[cfg(feature = "sync")] -use radroots_signing::request::CancellationPolicy; -#[cfg(feature = "sync")] -use radroots_storage::journal::IdempotencyKey; -#[cfg(feature = "sync")] -use radroots_sync::{ - policy::{Error as SyncError, SyncId}, - push::PushStatus, -}; - -/// Explicit commit inputs for one prepared farm publication. -#[cfg(feature = "sync")] -#[derive(Clone, Debug)] -pub struct EnqueueRequest { - operation_id: SyncId, - idempotency_key: IdempotencyKey, - plan: Plan, - profile: crate::transport::Profile, - delivery_deadline_unix_ms: u64, - cancellation: CancellationPolicy, -} - -#[cfg(feature = "sync")] -impl EnqueueRequest { - /// Creates an enqueue request whose transport selection has no fallback. - #[must_use] - pub const fn new( - operation_id: SyncId, - idempotency_key: IdempotencyKey, - plan: Plan, - profile: crate::transport::Profile, - delivery_deadline_unix_ms: u64, - cancellation: CancellationPolicy, - ) -> Self { - Self { - operation_id, - idempotency_key, - plan, - profile, - delivery_deadline_unix_ms, - cancellation, - } - } -} - -/// Borrowed farm commit operations over the canonical sync engine. -#[cfg(feature = "sync")] -#[derive(Clone, Copy, Debug)] -pub struct Operations<'a> { - sync: crate::sync::Operations<'a>, -} - -#[cfg(feature = "sync")] -impl<'a> Operations<'a> { - pub(crate) const fn new(sync: crate::sync::Operations<'a>) -> Self { - Self { sync } - } - - /// Durably prepares, signs, and locally admits a farm publication. - /// - /// Once preparation commits, cancellation cannot erase the authored - /// intent. Replay with identical operation and idempotency inputs resumes - /// from its durable signing or admission phase. - pub async fn enqueue(&self, request: EnqueueRequest) -> Result<PushStatus, SyncError> { - let targets = request - .profile - .targets() - .cloned() - .ok_or(SyncError::InvalidPushRequest)?; - let satisfaction = request - .profile - .satisfaction() - .cloned() - .ok_or(SyncError::InvalidPushRequest)?; - self.sync - .submit_push(radroots_sync::PushRequest::new( - request.operation_id, - request.idempotency_key, - request.plan.actor, - request.plan.authored_event, - targets, - satisfaction, - request.delivery_deadline_unix_ms, - request.cancellation, - )?) - .await - } -} - -#[cfg(test)] -mod tests { - use radroots_event::farm::FarmPublicLocation; - use radroots_signing::actor::ActorSource; - - use super::*; - - const PUBLIC_KEY: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - - fn actor(role: AuthorRole) -> Actor { - Actor::from_public_key_hex(PUBLIC_KEY, ActorSource::ExplicitPublicKey, [role]) - .expect("actor") - } - - fn farm() -> Farm { - Farm { - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), - name: "Moss Street Farm".to_owned(), - about: Some("seasonal vegetables".to_owned()), - website: None, - picture: None, - banner: None, - location: Some(FarmPublicLocation { - primary: "Santa Cruz, California".to_owned(), - city: Some("Santa Cruz".to_owned()), - region: Some("California".to_owned()), - country: Some("US".to_owned()), - geohash: "9q8yy".to_owned(), - }), - tags: Some(vec!["vegetables".to_owned()]), - } - } - - #[test] - fn prepare_is_pure_deterministic_and_uses_canonical_public_types() { - let request = PrepareRequest::new(actor(AuthorRole::Farmer), farm(), 1_800_000_000); - let first = prepare(request.clone()).expect("first plan"); - let second = prepare(request).expect("second plan"); - - assert_eq!(first, second); - assert_eq!( - first - .authored_event() - .body() - .contract() - .contract_id() - .as_str(), - FARM_PROFILE_CONTRACT_ID - ); - assert_eq!(first.authored_event().body().kind(), KIND_FARM); - assert_eq!(first.authored_event().created_at(), 1_800_000_000); - assert_eq!( - first.authored_event().author(), - &actor(AuthorRole::Farmer).public_key() - ); - assert_eq!( - first.coordinate().as_str(), - format!("{KIND_FARM}:{PUBLIC_KEY}:AAAAAAAAAAAAAAAAAAAAAA") - ); - assert!( - first - .authored_event() - .body() - .content() - .contains("Moss Street Farm") - ); - assert!(!first.authored_event().body().content().contains("latitude")); - assert!( - !first - .authored_event() - .body() - .content() - .contains("longitude") - ); - } - - #[test] - fn prepare_maps_authorization_and_lower_validation_once() { - let unauthorized = prepare(PrepareRequest::new( - actor(AuthorRole::Buyer), - farm(), - 1_800_000_000, - )) - .expect_err("unauthorized"); - assert_eq!(unauthorized.kind(), PrepareErrorKind::UnauthorizedActor); - assert!(std::error::Error::source(&unauthorized).is_none()); - - let mut invalid = farm(); - invalid.name.clear(); - let encoded = prepare(PrepareRequest::new( - actor(AuthorRole::Farmer), - invalid, - 1_800_000_000, - )) - .expect_err("invalid model"); - assert_eq!(encoded.kind(), PrepareErrorKind::Encode); - assert!(std::error::Error::source(&encoded).is_some()); - assert_eq!(encoded.to_string(), "farm model is invalid"); - assert!(!format!("{encoded:?}").contains("name")); - } - - #[cfg(all(feature = "sync", feature = "memory", feature = "local-signing"))] - mod enqueue { - use std::sync::{ - Arc, - atomic::{AtomicU8, Ordering}, - }; - - use radroots_storage::{ - Outbox, event::SourceGeneration, journal::IdempotencyKey, memory::MemoryStorage, - }; - use radroots_sync::{ - Engine, - policy::{Clock, DeadlinePolicy, Error, IdSource, OperationKind, SyncId, SyncStorage}, - }; - use radroots_transport::{ - DeliveryReceipt, DeliveryRequest, Error as TransportError, EventSink, SinkFailure, - SinkStatus, Target, TargetSet, TransportId, - capability::{Availability, Maturity, SinkCapabilities}, - outcome::Retryability, - policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, - }; - - use crate::{ClientBuilder, transport::Profile}; - - use super::*; - - struct HostClock; - struct SequenceIds(AtomicU8); - struct NoopSink; - - impl Clock for HostClock { - fn now_unix_ms(&self) -> Result<u64, Error> { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok() - .and_then(|duration| u64::try_from(duration.as_millis()).ok()) - .filter(|value| *value != 0) - .ok_or(Error::ClockUnavailable) - } - } - - impl IdSource for SequenceIds { - fn next_id(&self, _operation: OperationKind) -> Result<SyncId, Error> { - SyncId::new([self.0.fetch_add(1, Ordering::Relaxed); 16]) - } - } - - impl EventSink for NoopSink { - fn status( - &self, - ) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, TransportError>> { - Box::pin(async { - Ok(SinkStatus::new( - TransportId::NOSTR, - true, - Maturity::Stable, - Availability::Available, - SinkCapabilities::DELIVER, - "ready", - )) - }) - } - - fn deliver( - &self, - request: DeliveryRequest, - ) -> radroots_transport::BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> - { - Box::pin(async move { - Err(SinkFailure::for_request( - &request, - "test_sink_unavailable", - Retryability::Terminal, - None, - None, - Vec::new(), - ) - .expect("test sink failure")) - }) - } - } - - #[tokio::test] - async fn enqueue_preserves_commit_cancellation_idempotency_and_delivery_policy() { - let storage = Arc::new(MemoryStorage::new( - SourceGeneration::new([4; 32]).expect("generation"), - )); - let signer = - Arc::new(radroots_nostr::signing::LocalSigner::generate().expect("local signer")); - let farm_actor = Actor::new( - signer.public_key(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Farmer], - ) - .expect("actor"); - let plan = - prepare(PrepareRequest::new(farm_actor, farm(), 1_800_000_000)).expect("plan"); - let targets = TargetSet::new(vec![ - Target::nostr_relay("wss://farm.example").expect("target"), - ]) - .expect("targets"); - let satisfaction = - SatisfactionPolicy::new(SatisfactionClass::Delivered, TargetPolicy::all()); - let profile = Profile::delivery(targets.clone(), satisfaction.clone()) - .expect("transport profile"); - let capability: Arc<dyn SyncStorage> = storage.clone(); - let engine = Engine::builder( - capability, - Arc::new(HostClock), - Arc::new(SequenceIds(AtomicU8::new(1))), - DeadlinePolicy::new(30_000, 30_000, 30_000).expect("deadlines"), - ) - .sink(Arc::new(NoopSink)) - .signer(signer) - .build() - .expect("engine"); - let client = ClientBuilder::new() - .storage(storage.clone()) - .sync_engine(engine) - .build() - .expect("client"); - let operations = client.farm().expect("open").expect("farm operations"); - let request = EnqueueRequest::new( - SyncId::new([7; 16]).expect("operation id"), - IdempotencyKey::parse("farm-publish-a").expect("idempotency key"), - plan, - profile, - 2_000_000_001_000, - CancellationPolicy::PreservePublishedRequest, - ); - - drop(operations.enqueue(request.clone())); - assert_eq!( - Outbox::status(storage.as_ref()) - .await - .expect("outbox status") - .pending, - 0 - ); - - let committed = operations - .enqueue(request.clone()) - .await - .expect("committed enqueue"); - assert_eq!(committed.delivery_plan().intent().target_set(), &targets); - assert_eq!( - committed.delivery_plan().intent().satisfaction(), - &satisfaction - ); - let replay = operations.enqueue(request).await.expect("replay"); - assert_eq!(replay, committed); - - let unavailable = EnqueueRequest::new( - SyncId::new([8; 16]).expect("operation id"), - IdempotencyKey::parse("farm-publish-preview").expect("idempotency key"), - prepare(PrepareRequest::new( - actor(AuthorRole::Farmer), - farm(), - 1_800_000_000, - )) - .expect("plan"), - Profile::unavailable_preview(TransportId::RETICULUM), - 2_000_000_001_000, - CancellationPolicy::PreservePublishedRequest, - ); - assert_eq!( - operations.enqueue(unavailable).await, - Err(Error::InvalidPushRequest) - ); - } - } -} diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -1,44 +0,0 @@ -//! Host-neutral asynchronous client engine for Radroots. -//! -//! Advanced hosts compose capabilities through [`ClientBuilder`] and operate -//! through a cloneable [`Client`]. All fallible root operations use [`Result`] -//! and the SDK-owned [`Error`] boundary. -//! -//! Constructing an empty builder performs no I/O and makes missing composition -//! explicit: -//! -//! ``` -//! use radroots_sdk::ClientBuilder; -//! -//! let result = ClientBuilder::new().build(); -//! assert!(result.is_err()); -//! ``` -//! -//! Native structs are constructor-led and representation-private. Hosts must -//! not depend on field layout: -//! -//! ```compile_fail -//! use radroots_sdk::ClientBuilder; -//! -//! let _ = ClientBuilder { storage: None }; -//! ``` - -#![forbid(unsafe_code)] -#![doc = include_str!("../README.md")] - -mod adapters; - -pub mod capability; -pub mod client; -pub mod diagnostics; -pub mod error; -pub mod farm; -pub mod listing; -pub mod signing; -pub mod storage; -pub mod sync; -pub mod trade; -pub mod transport; - -pub use crate::client::{Client, ClientBuilder}; -pub use crate::error::{Error, Result}; diff --git a/crates/sdk/src/listing.rs b/crates/sdk/src/listing.rs @@ -1,641 +0,0 @@ -//! Side-effect-free listing planning and canonical durable enqueue operations. - -use std::{error, fmt}; - -use radroots_event::{contract::AuthorRole, id::ClassifiedListingAddress}; -use radroots_event_codec::authoring::AuthoredEventPlan; -use radroots_signing::Actor; - -mod operational_listing; - -pub use operational_listing::{ - BinPricingTryExt, RadrootsClassifiedListingAddressParts, - RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1, - RadrootsOperationalListingEditError, RadrootsOperationalListingLifecycleState, - RadrootsOperationalListingMutation, RadrootsOperationalListingMutationError, - RadrootsOperationalListingSubtotal, RadrootsOperationalListingTotal, - RadrootsOperationalListingTradeProjection, RadrootsPublicClassifiedListingAddress, - parse_classified_listing_address, parse_operational_listing_event, - parse_public_classified_listing_address, validate_operational_listing_event, - validate_operational_listing_model, -}; -use operational_listing::{ - build_operational_listing_mutation_plan, canonicalize_operational_listing_edit, -}; - -/// Supported public listing mutation intent. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Action { - /// Publish the first public version of a listing. - Publish, - /// Replace an existing addressable listing with a new public version. - Update, -} - -/// Pure inputs for one frozen public listing plan. -#[derive(Clone, Debug)] -pub struct PrepareRequest { - actor: Actor, - document: RadrootsOperationalListingEditDocumentV1, - action: Action, - created_at_unix: u64, -} - -impl PrepareRequest { - /// Creates a public listing publication request. - #[must_use] - pub const fn publish( - actor: Actor, - document: RadrootsOperationalListingEditDocumentV1, - created_at_unix: u64, - ) -> Self { - Self { - actor, - document, - action: Action::Publish, - created_at_unix, - } - } - - /// Creates a public listing replacement request. - #[must_use] - pub const fn update( - actor: Actor, - document: RadrootsOperationalListingEditDocumentV1, - created_at_unix: u64, - ) -> Self { - Self { - actor, - document, - action: Action::Update, - created_at_unix, - } - } -} - -/// Frozen, replay-stable public listing mutation plan. -#[derive(Clone, Debug)] -pub struct Plan { - actor: Actor, - action: Action, - address: ClassifiedListingAddress, - lifecycle: RadrootsOperationalListingLifecycleState, - authored_event: AuthoredEventPlan, -} - -impl Plan { - /// Returns the exact authorized actor carried into signing. - #[must_use] - pub const fn actor(&self) -> &Actor { - &self.actor - } - - /// Returns the requested listing mutation intent. - #[must_use] - pub const fn action(&self) -> Action { - self.action - } - - /// Returns the canonical addressable listing identity. - #[must_use] - pub const fn address(&self) -> &ClassifiedListingAddress { - &self.address - } - - /// Returns the lower-owned lifecycle state resulting from the mutation. - #[must_use] - pub const fn lifecycle(&self) -> RadrootsOperationalListingLifecycleState { - self.lifecycle - } - - /// Returns the immutable canonical authored event plan. - #[must_use] - pub const fn authored_event(&self) -> &AuthoredEventPlan { - &self.authored_event - } -} - -/// Listing plan validation stage. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum PrepareErrorKind { - /// The actor does not claim the seller author role. - UnauthorizedActor, - /// The lower trade boundary rejected the untrusted edit document. - Edit, - /// The lower trade/event boundary rejected mutation preparation. - Mutation, -} - -/// One secret-safe listing planning failure retaining its lower source. -pub struct PrepareError { - kind: PrepareErrorKind, - source: Option<Box<dyn error::Error + Send + Sync>>, -} - -impl PrepareError { - /// Returns the stable client-level planning stage. - #[must_use] - pub const fn kind(&self) -> PrepareErrorKind { - self.kind - } - - fn unauthorized_actor() -> Self { - Self { - kind: PrepareErrorKind::UnauthorizedActor, - source: None, - } - } - - fn edit(source: RadrootsOperationalListingEditError) -> Self { - Self::with_source(PrepareErrorKind::Edit, source) - } - - fn mutation(source: RadrootsOperationalListingMutationError) -> Self { - Self::with_source(PrepareErrorKind::Mutation, source) - } - - fn with_source( - kind: PrepareErrorKind, - source: impl error::Error + Send + Sync + 'static, - ) -> Self { - Self { - kind, - source: Some(Box::new(source)), - } - } -} - -impl fmt::Display for PrepareError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self.kind { - PrepareErrorKind::UnauthorizedActor => "listing actor is not authorized", - PrepareErrorKind::Edit => "listing edit is invalid", - PrepareErrorKind::Mutation => "listing mutation is invalid", - }) - } -} - -impl fmt::Debug for PrepareError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("PrepareError") - .field("kind", &self.kind) - .finish_non_exhaustive() - } -} - -impl error::Error for PrepareError { - fn source(&self) -> Option<&(dyn error::Error + 'static)> { - self.source - .as_deref() - .map(|source| source as &(dyn error::Error + 'static)) - } -} - -/// Validates and freezes one listing mutation without storage, signing, or network work. -/// -/// The canonical public model permits only coarse public locality. Exact -/// coordinates and other private artifacts are deliberately absent; hosts -/// persist those through `radroots_storage::private_artifact::PrivateArtifactStore`. -pub fn prepare(request: PrepareRequest) -> Result<Plan, PrepareError> { - if !request.actor.satisfies(AuthorRole::Seller) { - return Err(PrepareError::unauthorized_actor()); - } - let canonical = - canonicalize_operational_listing_edit(request.actor.public_key(), request.document) - .map_err(PrepareError::edit)?; - let address = canonical.public_listing_addr().clone(); - let mutation = match request.action { - Action::Publish => RadrootsOperationalListingMutation::publish(canonical), - Action::Update => RadrootsOperationalListingMutation::update(canonical), - }; - let lifecycle = mutation.lifecycle_state().map_err(PrepareError::mutation)?; - let authored_event = - build_operational_listing_mutation_plan(&mutation, request.created_at_unix) - .map_err(PrepareError::mutation)?; - Ok(Plan { - actor: request.actor, - action: request.action, - address, - lifecycle, - authored_event, - }) -} - -#[cfg(feature = "sync")] -use radroots_signing::request::CancellationPolicy; -#[cfg(feature = "sync")] -use radroots_storage::journal::IdempotencyKey; -#[cfg(feature = "sync")] -use radroots_sync::{ - policy::{Error as SyncError, SyncId}, - push::PushStatus, -}; - -/// Explicit commit inputs for one prepared public listing mutation. -#[cfg(feature = "sync")] -#[derive(Clone, Debug)] -pub struct EnqueueRequest { - operation_id: SyncId, - idempotency_key: IdempotencyKey, - plan: Plan, - profile: crate::transport::Profile, - delivery_deadline_unix_ms: u64, - cancellation: CancellationPolicy, -} - -#[cfg(feature = "sync")] -impl EnqueueRequest { - /// Creates an enqueue request whose transport selection has no fallback. - #[must_use] - pub const fn new( - operation_id: SyncId, - idempotency_key: IdempotencyKey, - plan: Plan, - profile: crate::transport::Profile, - delivery_deadline_unix_ms: u64, - cancellation: CancellationPolicy, - ) -> Self { - Self { - operation_id, - idempotency_key, - plan, - profile, - delivery_deadline_unix_ms, - cancellation, - } - } -} - -/// Borrowed listing commit operations over the canonical sync engine. -#[cfg(feature = "sync")] -#[derive(Clone, Copy, Debug)] -pub struct Operations<'a> { - sync: crate::sync::Operations<'a>, -} - -#[cfg(feature = "sync")] -impl<'a> Operations<'a> { - pub(crate) const fn new(sync: crate::sync::Operations<'a>) -> Self { - Self { sync } - } - - /// Durably prepares, signs, and locally admits a public listing mutation. - pub async fn enqueue(&self, request: EnqueueRequest) -> Result<PushStatus, SyncError> { - let targets = request - .profile - .targets() - .cloned() - .ok_or(SyncError::InvalidPushRequest)?; - let satisfaction = request - .profile - .satisfaction() - .cloned() - .ok_or(SyncError::InvalidPushRequest)?; - self.sync - .submit_push(radroots_sync::PushRequest::new( - request.operation_id, - request.idempotency_key, - request.plan.actor, - request.plan.authored_event, - targets, - satisfaction, - request.delivery_deadline_unix_ms, - request.cancellation, - )?) - .await - } -} - -#[cfg(test)] -mod tests { - use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; - use radroots_event::{ - envelope::kind::KIND_CLASSIFIED_LISTING, - farm::FarmRef, - id::{DTag, InventoryBinId}, - listing::operational::{ - OperationalListing, OperationalListingAvailability, OperationalListingBin, - OperationalListingDeliveryMethod, OperationalListingProduct, - OperationalListingPublicLocation, OperationalListingStatus, - }, - }; - use radroots_signing::actor::ActorSource; - - use super::*; - - const PUBLIC_KEY: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - - fn actor(role: AuthorRole) -> Actor { - Actor::from_public_key_hex(PUBLIC_KEY, ActorSource::ExplicitPublicKey, [role]) - .expect("actor") - } - - fn listing(seller: &str) -> OperationalListing { - OperationalListing { - d_tag: DTag::parse("AAAAAAAAAAAAAAAAAAAAAg").expect("d tag"), - published_at: None, - farm: FarmRef { - pubkey: seller.to_owned(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), - }, - product: OperationalListingProduct { - key: "coffee".to_owned(), - title: "Coffee".to_owned(), - category: "coffee".to_owned(), - summary: Some("Single origin coffee".to_owned()), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: InventoryBinId::parse("bin-1").expect("bin id"), - bins: vec![OperationalListingBin { - bin_id: InventoryBinId::parse("bin-1").expect("bin id"), - quantity: Quantity::try_new(Decimal::from(1000_u32), Unit::MassG) - .expect("quantity"), - price_per_canonical_unit: QuantityPrice::try_new( - Money::try_new(Decimal::from(20_u32), Currency::USD).expect("money"), - Quantity::try_new(Decimal::from(1_u32), Unit::MassG).expect("unit"), - ) - .expect("price"), - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: Some(Decimal::from(5_u32)), - availability: Some(OperationalListingAvailability::Status { - status: OperationalListingStatus::Active, - }), - delivery_method: Some(OperationalListingDeliveryMethod::Pickup), - location: Some(OperationalListingPublicLocation { - primary: "Santa Cruz, California".to_owned(), - city: Some("Santa Cruz".to_owned()), - region: Some("California".to_owned()), - country: Some("US".to_owned()), - geohash: "9q8yy".to_owned(), - }), - images: None, - } - } - - fn document(seller: &str) -> RadrootsOperationalListingEditDocumentV1 { - RadrootsOperationalListingEditDocumentV1::new(listing(seller)) - } - - #[test] - fn prepare_publish_and_update_are_pure_canonical_and_privacy_bounded() { - let publish_request = PrepareRequest::publish( - actor(AuthorRole::Seller), - document(PUBLIC_KEY), - 1_800_000_000, - ); - let first = prepare(publish_request.clone()).expect("publish plan"); - let replay = prepare(publish_request).expect("replayed plan"); - let update = prepare(PrepareRequest::update( - actor(AuthorRole::Seller), - document(PUBLIC_KEY), - 1_800_000_001, - )) - .expect("update plan"); - - assert_eq!(first.action(), Action::Publish); - assert_eq!(update.action(), Action::Update); - assert_eq!( - first.lifecycle(), - RadrootsOperationalListingLifecycleState::Published - ); - assert_eq!( - update.lifecycle(), - RadrootsOperationalListingLifecycleState::Published - ); - assert_eq!(first.address(), replay.address()); - assert_eq!(first.authored_event(), replay.authored_event()); - assert_eq!(first.address(), update.address()); - assert_eq!( - first.authored_event().body().kind(), - KIND_CLASSIFIED_LISTING - ); - assert_eq!(first.authored_event().created_at(), 1_800_000_000); - assert!(!first.authored_event().body().content().contains("latitude")); - assert!( - !first - .authored_event() - .body() - .content() - .contains("longitude") - ); - } - - #[test] - fn prepare_maps_authorization_and_lower_validation_classes_once() { - let unauthorized = prepare(PrepareRequest::publish( - actor(AuthorRole::Buyer), - document(PUBLIC_KEY), - 1_800_000_000, - )) - .expect_err("unauthorized"); - assert_eq!(unauthorized.kind(), PrepareErrorKind::UnauthorizedActor); - assert!(std::error::Error::source(&unauthorized).is_none()); - - let mut invalid = listing(PUBLIC_KEY); - invalid.product.title.clear(); - let edit = prepare(PrepareRequest::publish( - actor(AuthorRole::Seller), - RadrootsOperationalListingEditDocumentV1::new(invalid), - 1_800_000_000, - )) - .expect_err("invalid listing"); - assert_eq!(edit.kind(), PrepareErrorKind::Edit); - assert!(std::error::Error::source(&edit).is_some()); - assert_eq!(edit.to_string(), "listing edit is invalid"); - assert!(!format!("{edit:?}").contains("title")); - - let mismatch = prepare(PrepareRequest::update( - actor(AuthorRole::Seller), - document("8f"), - 1_800_000_000, - )) - .expect_err("invalid seller identity"); - assert_eq!(mismatch.kind(), PrepareErrorKind::Edit); - } - - #[cfg(all(feature = "sync", feature = "memory", feature = "local-signing"))] - mod enqueue { - use std::sync::{ - Arc, - atomic::{AtomicU8, Ordering}, - }; - - use radroots_storage::{ - Outbox, event::SourceGeneration, journal::IdempotencyKey, memory::MemoryStorage, - }; - use radroots_sync::{ - Engine, - policy::{Clock, DeadlinePolicy, Error, IdSource, OperationKind, SyncId, SyncStorage}, - }; - use radroots_transport::{ - DeliveryReceipt, DeliveryRequest, Error as TransportError, EventSink, SinkFailure, - SinkStatus, Target, TargetSet, TransportId, - capability::{Availability, Maturity, SinkCapabilities}, - outcome::Retryability, - policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, - }; - - use super::*; - use crate::{ClientBuilder, transport::Profile}; - - struct HostClock; - struct SequenceIds(AtomicU8); - struct NoopSink; - - impl Clock for HostClock { - fn now_unix_ms(&self) -> Result<u64, Error> { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok() - .and_then(|duration| u64::try_from(duration.as_millis()).ok()) - .filter(|value| *value != 0) - .ok_or(Error::ClockUnavailable) - } - } - impl IdSource for SequenceIds { - fn next_id(&self, _operation: OperationKind) -> Result<SyncId, Error> { - SyncId::new([self.0.fetch_add(1, Ordering::Relaxed); 16]) - } - } - impl EventSink for NoopSink { - fn status( - &self, - ) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, TransportError>> { - Box::pin(async { - Ok(SinkStatus::new( - TransportId::NOSTR, - true, - Maturity::Stable, - Availability::Available, - SinkCapabilities::DELIVER, - "ready", - )) - }) - } - fn deliver( - &self, - request: DeliveryRequest, - ) -> radroots_transport::BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> - { - Box::pin(async move { - Err(SinkFailure::for_request( - &request, - "test_sink_unavailable", - Retryability::Terminal, - None, - None, - Vec::new(), - ) - .expect("test sink failure")) - }) - } - } - - #[tokio::test] - async fn enqueue_preserves_commit_cancellation_idempotency_and_transport_outcomes() { - let storage = Arc::new(MemoryStorage::new( - SourceGeneration::new([5; 32]).expect("generation"), - )); - let signer = - Arc::new(radroots_nostr::signing::LocalSigner::generate().expect("local signer")); - let seller = Actor::new( - signer.public_key(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Seller], - ) - .expect("actor"); - let plan = prepare(PrepareRequest::publish( - seller, - document(&signer.public_key().to_hex()), - 1_800_000_000, - )) - .expect("plan"); - let targets = TargetSet::new(vec![ - Target::nostr_relay("wss://listing.example").expect("target"), - ]) - .expect("targets"); - let satisfaction = - SatisfactionPolicy::new(SatisfactionClass::Delivered, TargetPolicy::all()); - let profile = Profile::delivery(targets.clone(), satisfaction.clone()) - .expect("transport profile"); - let capability: Arc<dyn SyncStorage> = storage.clone(); - let engine = Engine::builder( - capability, - Arc::new(HostClock), - Arc::new(SequenceIds(AtomicU8::new(1))), - DeadlinePolicy::new(30_000, 30_000, 30_000).expect("deadlines"), - ) - .sink(Arc::new(NoopSink)) - .signer(signer) - .build() - .expect("engine"); - let client = ClientBuilder::new() - .storage(storage.clone()) - .sync_engine(engine) - .build() - .expect("client"); - let operations = client.listing().expect("open").expect("listing operations"); - let request = EnqueueRequest::new( - SyncId::new([9; 16]).expect("operation id"), - IdempotencyKey::parse("listing-publish-a").expect("idempotency key"), - plan, - profile, - 2_000_000_001_000, - CancellationPolicy::PreservePublishedRequest, - ); - - drop(operations.enqueue(request.clone())); - assert_eq!( - Outbox::status(storage.as_ref()) - .await - .expect("outbox status") - .pending, - 0 - ); - let committed = operations - .enqueue(request.clone()) - .await - .expect("committed enqueue"); - assert_eq!(committed.delivery_plan().intent().target_set(), &targets); - assert_eq!( - committed.delivery_plan().intent().satisfaction(), - &satisfaction - ); - let replay = operations.enqueue(request).await.expect("replay"); - assert_eq!(replay, committed); - - let unavailable = EnqueueRequest::new( - SyncId::new([10; 16]).expect("operation id"), - IdempotencyKey::parse("listing-update-preview").expect("idempotency key"), - prepare(PrepareRequest::update( - actor(AuthorRole::Seller), - document(PUBLIC_KEY), - 1_800_000_001, - )) - .expect("plan"), - Profile::unavailable_preview(TransportId::RETICULUM), - 2_000_000_001_000, - CancellationPolicy::PreservePublishedRequest, - ); - assert_eq!( - operations.enqueue(unavailable).await, - Err(Error::InvalidPushRequest) - ); - } - } -} diff --git a/crates/sdk/src/listing/operational_listing/draft.rs b/crates/sdk/src/listing/operational_listing/draft.rs @@ -1,548 +0,0 @@ -//! Canonicalization for Radroots Listing v1 drafts. - -#![forbid(unsafe_code)] - -use core::fmt; - -use std::{format, vec::Vec}; - -use radroots_event::{ - envelope::kind::KIND_CLASSIFIED_LISTING, - id::{ClassifiedListingAddress, InventoryBinId, ParseError}, - listing::operational::OperationalListing, - trade::validation::OperationalListingValidationError, -}; -use radroots_identity::{Error as PublicKeyError, PublicKey}; - -use super::validation::validate_operational_listing_model; - -#[derive(Clone, Debug)] -pub struct RadrootsOperationalListingEditDocumentV1 { - pub listing: OperationalListing, -} - -impl RadrootsOperationalListingEditDocumentV1 { - pub fn new(listing: OperationalListing) -> Self { - Self { listing } - } -} - -#[derive(Clone, Debug)] -pub struct RadrootsOperationalListingCanonicalEdit { - listing: OperationalListing, - seller_pubkey: PublicKey, - public_listing_addr: ClassifiedListingAddress, -} - -impl RadrootsOperationalListingCanonicalEdit { - pub fn new( - mut listing: OperationalListing, - seller_pubkey: PublicKey, - ) -> Result<Self, RadrootsOperationalListingEditError> { - let farm_pubkey = PublicKey::from_hex(listing.farm.pubkey.as_str()) - .map_err(RadrootsOperationalListingEditError::InvalidFarmPubkey)?; - if farm_pubkey != seller_pubkey { - return Err(RadrootsOperationalListingEditError::FarmPubkeyMismatch { - expected_pubkey: seller_pubkey, - actual_pubkey: farm_pubkey, - }); - } - listing.farm.pubkey = farm_pubkey.to_hex(); - validate_listing_bins(&listing)?; - let listing = validate_operational_listing_model(listing, &seller_pubkey) - .map_err(RadrootsOperationalListingEditError::InvalidModel)? - .listing; - - let public_listing_addr = listing_addr( - KIND_CLASSIFIED_LISTING, - &seller_pubkey, - listing.d_tag.as_str(), - ); - - Ok(Self { - listing, - seller_pubkey, - public_listing_addr, - }) - } - - pub fn listing(&self) -> &OperationalListing { - &self.listing - } - - pub fn seller_pubkey(&self) -> &PublicKey { - &self.seller_pubkey - } - - pub fn public_listing_addr(&self) -> &ClassifiedListingAddress { - &self.public_listing_addr - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum RadrootsOperationalListingEditError { - InvalidFarmPubkey(PublicKeyError), - InvalidClassifiedListingAddress(ParseError), - InvalidModel(OperationalListingValidationError), - FarmPubkeyMismatch { - expected_pubkey: PublicKey, - actual_pubkey: PublicKey, - }, - MissingPrimaryBin { - primary_bin_id: InventoryBinId, - }, - DuplicateBinId { - bin_id: InventoryBinId, - }, -} - -impl fmt::Display for RadrootsOperationalListingEditError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidFarmPubkey(error) => { - write!(f, "invalid listing edit farm pubkey: {error}") - } - Self::InvalidClassifiedListingAddress(error) => { - write!(f, "invalid listing edit address: {error}") - } - Self::InvalidModel(error) => { - write!(f, "invalid listing edit model: {error}") - } - Self::FarmPubkeyMismatch { .. } => { - f.write_str("listing edit farm pubkey does not match seller") - } - Self::MissingPrimaryBin { .. } => f.write_str("listing edit primary bin is missing"), - Self::DuplicateBinId { .. } => f.write_str("listing edit contains duplicate bin ID"), - } - } -} - -impl core::error::Error for RadrootsOperationalListingEditError {} - -fn validate_listing_bins( - listing: &OperationalListing, -) -> Result<(), RadrootsOperationalListingEditError> { - let primary_bin_id = listing.primary_bin_id.clone(); - let mut seen_bin_ids = Vec::new(); - let mut primary_bin_found = false; - for bin in &listing.bins { - if seen_bin_ids - .iter() - .any(|seen_bin_id| seen_bin_id == &bin.bin_id) - { - return Err(RadrootsOperationalListingEditError::DuplicateBinId { - bin_id: bin.bin_id.clone(), - }); - } - if bin.bin_id == primary_bin_id { - primary_bin_found = true; - } - seen_bin_ids.push(bin.bin_id.clone()); - } - - if !primary_bin_found { - return Err(RadrootsOperationalListingEditError::MissingPrimaryBin { primary_bin_id }); - } - Ok(()) -} - -fn listing_addr(kind: u32, seller_pubkey: &PublicKey, d_tag: &str) -> ClassifiedListingAddress { - ClassifiedListingAddress::parse(format!("{kind}:{seller_pubkey}:{d_tag}")) - .expect("typed listing identity must form a listing address") -} - -/// Canonicalizes an untrusted listing edit for an already-authorized seller. -/// -/// Actor provenance and role authorization belong to the signing/workflow -/// boundary. This deterministic function validates only the supplied public -/// identity and listing data and performs no signing or authorization. -pub fn canonicalize_operational_listing_edit( - seller_pubkey: PublicKey, - mut document: RadrootsOperationalListingEditDocumentV1, -) -> Result<RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditError> { - let farm_pubkey = document.listing.farm.pubkey.as_str(); - if farm_pubkey.is_empty() { - document.listing.farm.pubkey = seller_pubkey.to_hex(); - } - - RadrootsOperationalListingCanonicalEdit::new(document.listing, seller_pubkey) -} - -#[cfg(test)] -mod tests { - const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - const FIXTURE_BOB_PUBLIC_KEY_HEX: &str = - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; - use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; - use radroots_event::{ - envelope::kind::KIND_CLASSIFIED_LISTING, - farm::FarmRef, - id::{ClassifiedListingAddress, DTag, InventoryBinId}, - listing::operational::{ - OperationalListing, OperationalListingAvailability, OperationalListingBin, - OperationalListingDeliveryMethod, OperationalListingProduct, - OperationalListingPublicLocation, OperationalListingStatus, - }, - trade::validation::OperationalListingValidationError, - }; - use radroots_identity::PublicKey; - - use super::{ - RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1, - RadrootsOperationalListingEditError, canonicalize_operational_listing_edit, - }; - - const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX; - const OTHER: &str = FIXTURE_BOB_PUBLIC_KEY_HEX; - - fn d_tag(raw: &str) -> DTag { - DTag::parse(raw).expect("d tag") - } - - fn bin_id(raw: &str) -> InventoryBinId { - InventoryBinId::parse(raw).expect("bin id") - } - - fn listing() -> OperationalListing { - OperationalListing { - d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), - published_at: None, - farm: FarmRef { - pubkey: SELLER.to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - }, - product: OperationalListingProduct { - key: "coffee".to_string(), - title: "Coffee".to_string(), - category: "coffee".to_string(), - summary: Some("Single origin coffee".to_string()), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: bin_id("bin-1"), - bins: vec![OperationalListingBin { - bin_id: bin_id("bin-1"), - 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, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: Some(Decimal::from(5u32)), - availability: Some(OperationalListingAvailability::Status { - status: OperationalListingStatus::Active, - }), - delivery_method: Some(OperationalListingDeliveryMethod::Pickup), - location: Some(OperationalListingPublicLocation { - primary: "Victoria".to_string(), - city: Some("Victoria".to_string()), - region: Some("British Columbia".to_string()), - country: Some("CA".to_string()), - geohash: "c287g".to_string(), - }), - images: None, - } - } - - fn seller_pubkey() -> PublicKey { - PublicKey::from_hex(SELLER).expect("seller public key") - } - - #[test] - fn draft_document_wraps_listing() { - let document = RadrootsOperationalListingEditDocumentV1::new(listing()); - - assert_eq!(document.listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); - assert_eq!(document.listing.product.title, "Coffee"); - } - - #[cfg(any())] - #[test] - fn draft_document_deserializes_as_untrusted_input() { - let json = serde_json::to_string(&RadrootsOperationalListingEditDocumentV1::new(listing())) - .expect("serialize document"); - - let document: RadrootsOperationalListingEditDocumentV1 = - serde_json::from_str(&json).expect("deserialize document"); - let canonical = canonicalize_operational_listing_edit(seller_pubkey(), document) - .expect("canonical draft"); - - 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 = PublicKey::from_hex(SELLER).expect("seller"); - let listing = listing(); - - let canonical = RadrootsOperationalListingCanonicalEdit::new(listing, seller_pubkey) - .expect("canonical"); - - assert_eq!(canonical.seller_pubkey(), &seller_pubkey); - assert_eq!( - canonical.public_listing_addr().as_str(), - format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") - ); - assert_eq!(canonical.listing().d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); - } - - #[test] - fn listing_edit_error_variants_are_precise() { - assert!(matches!( - RadrootsOperationalListingEditError::InvalidFarmPubkey( - PublicKey::from_hex("bad").unwrap_err() - ), - RadrootsOperationalListingEditError::InvalidFarmPubkey(_) - )); - assert!(matches!( - RadrootsOperationalListingEditError::InvalidClassifiedListingAddress( - ClassifiedListingAddress::parse("bad").unwrap_err() - ), - RadrootsOperationalListingEditError::InvalidClassifiedListingAddress(_) - )); - } - - #[test] - fn canonicalize_operational_listing_edit_fills_missing_farm_pubkey_and_derives_address() { - let mut listing = listing(); - listing.farm.pubkey.clear(); - let document = RadrootsOperationalListingEditDocumentV1::new(listing); - - let canonical = canonicalize_operational_listing_edit(seller_pubkey(), document) - .expect("canonical draft"); - - assert_eq!(canonical.seller_pubkey().to_hex(), SELLER); - assert_eq!( - canonical.public_listing_addr().as_str(), - format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") - ); - assert_eq!(canonical.listing().farm.pubkey, SELLER); - } - - #[test] - fn canonicalize_operational_listing_edit_rejects_mismatched_farm_pubkey() { - let mut listing = listing(); - listing.farm.pubkey = OTHER.to_string(); - let document = RadrootsOperationalListingEditDocumentV1::new(listing); - - let error = canonicalize_operational_listing_edit(seller_pubkey(), document).unwrap_err(); - - assert!(matches!( - error, - RadrootsOperationalListingEditError::FarmPubkeyMismatch { .. } - )); - } - - #[test] - fn canonicalize_operational_listing_edit_rejects_invalid_farm_pubkey() { - let mut listing = listing(); - listing.farm.pubkey = "bad".to_string(); - let document = RadrootsOperationalListingEditDocumentV1::new(listing); - - let error = canonicalize_operational_listing_edit(seller_pubkey(), document).unwrap_err(); - - assert!(matches!( - error, - RadrootsOperationalListingEditError::InvalidFarmPubkey(_) - )); - } - - #[test] - fn canonical_draft_new_rejects_mismatched_farm_pubkey() { - let mut listing = listing(); - listing.farm.pubkey = OTHER.to_string(); - - let error = RadrootsOperationalListingCanonicalEdit::new( - listing, - PublicKey::from_hex(SELLER).expect("seller"), - ) - .unwrap_err(); - - assert!(matches!( - error, - RadrootsOperationalListingEditError::FarmPubkeyMismatch { .. } - )); - } - - #[test] - fn canonical_draft_new_rejects_invalid_farm_pubkey() { - let mut listing = listing(); - listing.farm.pubkey = "bad".to_string(); - - let error = RadrootsOperationalListingCanonicalEdit::new( - listing, - PublicKey::from_hex(SELLER).expect("seller"), - ) - .unwrap_err(); - - assert!(matches!( - error, - RadrootsOperationalListingEditError::InvalidFarmPubkey(_) - )); - } - - #[test] - fn canonical_draft_new_rejects_empty_farm_pubkey() { - let mut listing = listing(); - listing.farm.pubkey.clear(); - - let error = RadrootsOperationalListingCanonicalEdit::new( - listing, - PublicKey::from_hex(SELLER).expect("seller"), - ) - .unwrap_err(); - - assert!(matches!( - error, - RadrootsOperationalListingEditError::InvalidFarmPubkey(_) - )); - } - - #[test] - fn canonicalize_operational_listing_edit_rejects_missing_primary_bin() { - let mut listing = listing(); - listing.primary_bin_id = bin_id("bin-2"); - let document = RadrootsOperationalListingEditDocumentV1::new(listing); - - let error = canonicalize_operational_listing_edit(seller_pubkey(), document).unwrap_err(); - - assert_eq!( - error, - RadrootsOperationalListingEditError::MissingPrimaryBin { - primary_bin_id: bin_id("bin-2") - } - ); - } - - #[test] - fn canonical_draft_new_rejects_missing_primary_bin() { - let mut listing = listing(); - listing.primary_bin_id = bin_id("bin-2"); - - let error = RadrootsOperationalListingCanonicalEdit::new( - listing, - PublicKey::from_hex(SELLER).expect("seller"), - ) - .unwrap_err(); - - assert_eq!( - error, - RadrootsOperationalListingEditError::MissingPrimaryBin { - primary_bin_id: bin_id("bin-2") - } - ); - } - - #[test] - fn canonicalize_operational_listing_edit_rejects_duplicate_bin_ids() { - let mut listing = listing(); - listing.bins.push(listing.bins[0].clone()); - let document = RadrootsOperationalListingEditDocumentV1::new(listing); - - let error = canonicalize_operational_listing_edit(seller_pubkey(), document).unwrap_err(); - - assert_eq!( - error, - RadrootsOperationalListingEditError::DuplicateBinId { - bin_id: bin_id("bin-1") - } - ); - } - - #[test] - fn canonical_draft_new_rejects_invalid_model() { - let mut listing = listing(); - listing.inventory_available = None; - - let error = RadrootsOperationalListingCanonicalEdit::new( - listing, - PublicKey::from_hex(SELLER).expect("seller"), - ) - .unwrap_err(); - - assert_eq!( - error, - RadrootsOperationalListingEditError::InvalidModel( - OperationalListingValidationError::MissingInventory - ) - ); - } - - #[test] - fn canonical_draft_new_rejects_invalid_secondary_bin() { - 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 = Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(); - invalid_quantity_listing.bins.push(secondary_bin); - - let error = RadrootsOperationalListingCanonicalEdit::new( - invalid_quantity_listing, - PublicKey::from_hex(SELLER).expect("seller"), - ) - .unwrap_err(); - - assert_eq!( - error, - RadrootsOperationalListingEditError::InvalidModel( - OperationalListingValidationError::InvalidBin - ) - ); - - 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 = 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, - PublicKey::from_hex(SELLER).expect("seller"), - ) - .unwrap_err(); - - assert_eq!( - error, - RadrootsOperationalListingEditError::InvalidModel( - OperationalListingValidationError::InvalidPrice - ) - ); - } - - #[test] - fn canonical_draft_new_rejects_duplicate_bin_ids() { - let mut listing = listing(); - listing.bins.push(listing.bins[0].clone()); - - let error = RadrootsOperationalListingCanonicalEdit::new( - listing, - PublicKey::from_hex(SELLER).expect("seller"), - ) - .unwrap_err(); - - assert_eq!( - error, - RadrootsOperationalListingEditError::DuplicateBinId { - bin_id: bin_id("bin-1") - } - ); - } -} diff --git a/crates/sdk/src/listing/operational_listing/mod.rs b/crates/sdk/src/listing/operational_listing/mod.rs @@ -1,253 +0,0 @@ -pub mod draft; -pub mod model; -pub mod mutation; -pub mod price_ext; -pub mod validation; - -use radroots_event::{ - envelope::EventEnvelope, - id::{AddressableCoordinateParts, ClassifiedListingAddress, DTag, ParseError}, - listing::operational::{OperationalListing, OperationalListingParseError}, -}; -use radroots_event_codec::decode::operational_listing::operational_listing_from_nostr_event; -use radroots_identity::PublicKey; - -pub use self::draft::{ - RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1, - RadrootsOperationalListingEditError, canonicalize_operational_listing_edit, -}; -pub use self::model::{RadrootsOperationalListingSubtotal, RadrootsOperationalListingTotal}; -pub use self::mutation::build_operational_listing_mutation_plan; -pub use self::mutation::{ - RadrootsOperationalListingLifecycleState, RadrootsOperationalListingMutation, - RadrootsOperationalListingMutationError, -}; -pub use self::price_ext::BinPricingTryExt; -pub use self::validation::{ - RadrootsOperationalListingTradeProjection, validate_operational_listing_event, - validate_operational_listing_model, -}; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsClassifiedListingAddressParts { - pub address: ClassifiedListingAddress, - pub kind: u32, - pub seller_pubkey: PublicKey, - pub listing_id: DTag, -} - -impl RadrootsClassifiedListingAddressParts { - pub fn parse(value: impl AsRef<str>) -> Result<Self, ParseError> { - parse_classified_listing_address(value) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsPublicClassifiedListingAddress { - pub address: ClassifiedListingAddress, - pub kind: u32, - pub seller_pubkey: PublicKey, - pub listing_id: DTag, -} - -impl RadrootsPublicClassifiedListingAddress { - pub fn parse(value: impl AsRef<str>) -> Result<Self, ParseError> { - parse_public_classified_listing_address(value) - } -} - -pub fn parse_classified_listing_address( - value: impl AsRef<str>, -) -> Result<RadrootsClassifiedListingAddressParts, ParseError> { - let value = value.as_ref(); - let address = ClassifiedListingAddress::parse(value)?; - let parts = AddressableCoordinateParts::parse(address.as_str()) - .expect("typed listing address must contain valid coordinate parts"); - Ok(RadrootsClassifiedListingAddressParts { - address, - kind: parts.kind, - seller_pubkey: parts.pubkey, - listing_id: parts.d_tag, - }) -} - -pub fn parse_public_classified_listing_address( - value: impl AsRef<str>, -) -> Result<RadrootsPublicClassifiedListingAddress, ParseError> { - let parts = parse_classified_listing_address(value)?; - Ok(RadrootsPublicClassifiedListingAddress { - address: parts.address, - kind: parts.kind, - seller_pubkey: parts.seller_pubkey, - listing_id: parts.listing_id, - }) -} - -pub fn parse_operational_listing_event( - event: &EventEnvelope, -) -> Result<OperationalListing, OperationalListingParseError> { - operational_listing_from_nostr_event(event) -} - -#[cfg(test)] -mod tests { - use super::{ - RadrootsClassifiedListingAddressParts, RadrootsPublicClassifiedListingAddress, - parse_classified_listing_address, parse_operational_listing_event, - parse_public_classified_listing_address, - }; - use radroots_event::{ - envelope::EventEnvelope, - envelope::EventEnvelopeParts, - envelope::kind::{KIND_CLASSIFIED_LISTING, KIND_PROFILE}, - id::ClassifiedListingAddress, - listing::operational::OperationalListingParseError, - }; - - const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - fn listing_event() -> EventEnvelope { - EventEnvelope::new(EventEnvelopeParts { - id: "9".repeat(64), - author: SELLER.to_string(), - created_at: 1, - kind: KIND_CLASSIFIED_LISTING, - tags: vec![ - vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()], - vec!["p".into(), SELLER.into()], - vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")], - vec!["key".into(), "coffee".into()], - vec!["title".into(), "Coffee".into()], - vec!["category".into(), "coffee".into()], - vec!["summary".into(), "Single origin".into()], - vec!["radroots:primary_bin".into(), "bin-1".into()], - vec![ - "radroots:bin".into(), - "bin-1".into(), - "1000".into(), - "g".into(), - ], - vec![ - "radroots:price".into(), - "bin-1".into(), - "20".into(), - "USD".into(), - "1".into(), - "g".into(), - ], - ], - content: String::new(), - sig: "f".repeat(128), - }) - .expect("listing event") - } - - #[test] - fn parse_operational_listing_event_rejects_non_listing_kind() { - let event = EventEnvelope::new(EventEnvelopeParts { - id: "8".repeat(64), - author: SELLER.to_string(), - created_at: 1, - kind: KIND_PROFILE, - tags: vec![], - content: String::new(), - sig: "f".repeat(128), - }) - .expect("profile event"); - - assert!(matches!( - parse_operational_listing_event(&event), - Err(OperationalListingParseError::InvalidKind(KIND_PROFILE)) - )); - } - - #[test] - fn parse_operational_listing_event_accepts_listing_kind() { - let listing = parse_operational_listing_event(&listing_event()).expect("listing"); - - assert_eq!(listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); - assert_eq!(listing.farm.pubkey, SELLER); - assert_eq!(listing.primary_bin_id.as_str(), "bin-1"); - } - - #[test] - fn listing_address_associated_parsers_delegate_to_public_parsers() { - let raw = format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:listing-1"); - - let listing = - RadrootsClassifiedListingAddressParts::parse(raw.clone()).expect("listing address"); - let public = RadrootsPublicClassifiedListingAddress::parse(&raw).expect("public address"); - let typed = parse_public_classified_listing_address( - ClassifiedListingAddress::parse(&raw).expect("typed addr"), - ) - .expect("typed public address"); - - 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.to_hex(), SELLER); - assert_eq!(public.seller_pubkey.to_hex(), SELLER); - assert_eq!(typed.seller_pubkey.to_hex(), SELLER); - } - - #[test] - fn parse_public_classified_listing_address_accepts_public_listing_kind() { - let raw = format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:listing-1"); - let parsed = parse_public_classified_listing_address(&raw).expect("public listing address"); - - assert_eq!(parsed.address.as_str(), raw); - assert_eq!(parsed.kind, KIND_CLASSIFIED_LISTING); - assert_eq!(parsed.seller_pubkey.to_hex(), SELLER); - assert_eq!(parsed.listing_id.as_str(), "listing-1"); - } - - #[test] - fn parse_classified_listing_address_rejects_retired_listing_kind() { - let raw = format!("30403:{SELLER}:listing-1"); - - assert!(matches!( - parse_classified_listing_address(&raw), - Err(radroots_event::id::ParseError::UnexpectedKind { - expected: KIND_CLASSIFIED_LISTING, - actual: 30403, - }) - )); - assert!(matches!( - parse_public_classified_listing_address(&raw), - Err(radroots_event::id::ParseError::UnexpectedKind { - expected: KIND_CLASSIFIED_LISTING, - actual: 30403, - }) - )); - } - - #[test] - fn parse_public_classified_listing_address_maps_invalid_listing_addresses() { - assert!(matches!( - parse_public_classified_listing_address("not-an-address"), - Err(radroots_event::id::ParseError::InvalidFormat) - )); - - let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1"); - assert!(matches!( - parse_public_classified_listing_address(&raw), - Err(radroots_event::id::ParseError::UnexpectedKind { - expected: KIND_CLASSIFIED_LISTING, - actual: KIND_PROFILE, - }) - )); - assert!(ClassifiedListingAddress::parse(&raw).is_err()); - } - - #[test] - fn parse_classified_listing_address_rejects_non_listing_kind() { - let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1"); - - assert!(matches!( - parse_classified_listing_address(&raw), - Err(radroots_event::id::ParseError::UnexpectedKind { - expected: KIND_CLASSIFIED_LISTING, - actual: KIND_PROFILE, - }) - )); - } -} diff --git a/crates/sdk/src/listing/operational_listing/model.rs b/crates/sdk/src/listing/operational_listing/model.rs @@ -1,15 +0,0 @@ -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsOperationalListingSubtotal { - pub price_amount: radroots_core::Money, - pub price_currency: radroots_core::Currency, - pub quantity_amount: radroots_core::Decimal, - pub quantity_unit: radroots_core::Unit, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsOperationalListingTotal { - 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/sdk/src/listing/operational_listing/mutation.rs b/crates/sdk/src/listing/operational_listing/mutation.rs @@ -1,510 +0,0 @@ -//! Mutation plan preparation for Radroots Listing v1. - -#![forbid(unsafe_code)] - -use core::fmt; - -use std::string::{String, ToString}; - -use radroots_event::id::ClassifiedListingAddress; -use radroots_event::{ - GenericEventDraft, draft::DraftError, envelope::kind::KIND_CLASSIFIED_LISTING, -}; -use radroots_event_codec::{ - authoring::AuthoredEventPlan, encode::operational_listing::to_wire_parts_with_kind, -}; - -use crate::listing::operational_listing::draft::RadrootsOperationalListingCanonicalEdit; - -/// Listing v1 mutation intent for draft preparation only. -/// -/// Publish and update target the public listing event, while local-only draft -/// persistence and archive are intentionally unsupported as wire events. -#[derive(Clone, Debug)] -pub enum RadrootsOperationalListingMutation { - Publish { - draft: RadrootsOperationalListingCanonicalEdit, - }, - Update { - draft: RadrootsOperationalListingCanonicalEdit, - }, - SaveDraft { - draft: RadrootsOperationalListingCanonicalEdit, - }, - Archive { - listing_addr: ClassifiedListingAddress, - }, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RadrootsOperationalListingLifecycleState { - Draft, - Published, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum RadrootsOperationalListingMutationError { - UnsupportedMutation, - EncodeListing(String), - AuthoredPlan(DraftError), -} - -impl fmt::Display for RadrootsOperationalListingMutationError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::UnsupportedMutation => f.write_str("listing mutation is not supported"), - Self::EncodeListing(error) => { - write!(f, "failed to encode listing mutation: {error}") - } - Self::AuthoredPlan(error) => { - write!(f, "failed to build listing authored plan: {error}") - } - } - } -} - -impl core::error::Error for RadrootsOperationalListingMutationError {} - -const OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID: &str = "radroots.operational_listing.published.v1"; - -impl RadrootsOperationalListingMutation { - pub fn publish(draft: RadrootsOperationalListingCanonicalEdit) -> Self { - Self::Publish { draft } - } - - pub fn update(draft: RadrootsOperationalListingCanonicalEdit) -> Self { - Self::Update { draft } - } - - pub fn save_draft(draft: RadrootsOperationalListingCanonicalEdit) -> Self { - Self::SaveDraft { draft } - } - - pub fn archive(listing_addr: ClassifiedListingAddress) -> Self { - Self::Archive { listing_addr } - } - - pub fn lifecycle_state( - &self, - ) -> Result<RadrootsOperationalListingLifecycleState, RadrootsOperationalListingMutationError> - { - match self { - Self::Publish { .. } | Self::Update { .. } => { - Ok(RadrootsOperationalListingLifecycleState::Published) - } - Self::SaveDraft { .. } => Ok(RadrootsOperationalListingLifecycleState::Draft), - Self::Archive { .. } => { - Err(RadrootsOperationalListingMutationError::UnsupportedMutation) - } - } - } - - pub fn canonical_draft( - &self, - ) -> Result<&RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingMutationError> - { - match self { - Self::Publish { draft } | Self::Update { draft } | Self::SaveDraft { draft } => { - Ok(draft) - } - Self::Archive { .. } => { - Err(RadrootsOperationalListingMutationError::UnsupportedMutation) - } - } - } - - pub fn listing_addr( - &self, - ) -> Result<&ClassifiedListingAddress, RadrootsOperationalListingMutationError> { - match self { - Self::Publish { draft } | Self::Update { draft } => Ok(draft.public_listing_addr()), - Self::SaveDraft { draft } => Ok(draft.public_listing_addr()), - Self::Archive { .. } => { - Err(RadrootsOperationalListingMutationError::UnsupportedMutation) - } - } - } -} - -pub fn build_operational_listing_mutation_plan( - mutation: &RadrootsOperationalListingMutation, - created_at: u64, -) -> Result<AuthoredEventPlan, RadrootsOperationalListingMutationError> { - let (draft, kind, contract_id) = match mutation { - RadrootsOperationalListingMutation::Publish { draft } - | RadrootsOperationalListingMutation::Update { draft } => ( - draft, - KIND_CLASSIFIED_LISTING, - OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID, - ), - RadrootsOperationalListingMutation::SaveDraft { .. } - | RadrootsOperationalListingMutation::Archive { .. } => { - return Err(RadrootsOperationalListingMutationError::UnsupportedMutation); - } - }; - let parts = to_wire_parts_with_kind(draft.listing(), kind).map_err(|error| { - RadrootsOperationalListingMutationError::EncodeListing(error.to_string()) - })?; - AuthoredEventPlan::from_generic( - GenericEventDraft::new( - contract_id, - parts.kind, - created_at, - parts.tags, - parts.content, - draft.seller_pubkey().to_hex(), - ) - .map_err(RadrootsOperationalListingMutationError::AuthoredPlan)?, - ) - .map_err(RadrootsOperationalListingMutationError::AuthoredPlan) -} - -#[cfg(all(test, feature = "local-signing"))] -mod tests { - const FIXTURE_ALICE_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; - const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; - use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; - use radroots_event::{ - contract::validate_event_contract_shape, - envelope::kind::KIND_CLASSIFIED_LISTING, - farm::FarmRef, - farm::resource_area::ResourceAreaRef, - id::{ClassifiedListingAddress, DTag, InventoryBinId}, - listing::operational::{ - OperationalListing, OperationalListingAvailability, OperationalListingBin, - OperationalListingDeliveryMethod, OperationalListingProduct, - OperationalListingPublicLocation, OperationalListingStatus, - }, - wire::Nip01EventWire, - }; - use radroots_event_codec::{authoring::AuthoredEventPlan, verify::verify_nip01_event}; - use radroots_identity::PublicKey; - - use crate::listing::operational_listing::draft::RadrootsOperationalListingCanonicalEdit; - use crate::listing::operational_listing::validation::validate_operational_listing_event; - - use super::{ - OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID, RadrootsOperationalListingLifecycleState, - RadrootsOperationalListingMutation, RadrootsOperationalListingMutationError, - build_operational_listing_mutation_plan, - }; - - const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX; - - fn d_tag(raw: &str) -> DTag { - DTag::parse(raw).expect("d tag") - } - - fn bin_id(raw: &str) -> InventoryBinId { - InventoryBinId::parse(raw).expect("bin id") - } - - fn sign_plan(plan: &AuthoredEventPlan) -> radroots_event::envelope::EventEnvelope { - let keys = Keys::parse(FIXTURE_ALICE_SECRET_KEY_HEX).expect("fixture signing key"); - assert_eq!(keys.public_key().to_hex(), plan.author().to_hex()); - let tags = plan - .body() - .tags() - .iter() - .cloned() - .map(|tag| Tag::parse(tag).expect("plan tag")) - .collect::<Vec<_>>(); - let event = EventBuilder::new( - Kind::Custom(u16::try_from(plan.body().kind()).expect("NIP-01 kind")), - plan.body().content(), - ) - .tags(tags) - .allow_self_tagging() - .custom_created_at(Timestamp::from_secs(plan.created_at())) - .sign_with_keys(&keys) - .expect("signed listing event"); - assert_eq!(event.id.to_hex(), plan.expected_event_id().to_hex()); - let raw_json = event.as_json(); - Nip01EventWire::parse_json(raw_json.as_str()) - .expect("canonical event wire") - .into_envelope() - .expect("event envelope") - } - - fn listing() -> OperationalListing { - OperationalListing { - d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), - published_at: None, - farm: FarmRef { - pubkey: SELLER.to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - }, - product: OperationalListingProduct { - key: "coffee".to_string(), - title: "Coffee".to_string(), - category: "coffee".to_string(), - summary: Some("Single origin coffee".to_string()), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: bin_id("bin-1"), - bins: vec![OperationalListingBin { - bin_id: bin_id("bin-1"), - 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, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: Some(Decimal::from(5u32)), - availability: Some(OperationalListingAvailability::Status { - status: OperationalListingStatus::Active, - }), - delivery_method: Some(OperationalListingDeliveryMethod::Pickup), - location: Some(OperationalListingPublicLocation { - primary: "Farm".to_string(), - city: Some("Town".to_string()), - region: Some("Region".to_string()), - country: Some("US".to_string()), - geohash: "9q8yy".to_string(), - }), - images: None, - } - } - - fn canonical_draft() -> RadrootsOperationalListingCanonicalEdit { - RadrootsOperationalListingCanonicalEdit::new( - listing(), - PublicKey::from_hex(SELLER).expect("seller"), - ) - .expect("canonical listing edit") - } - - #[test] - fn supported_mutations_report_lifecycle_states() { - assert_eq!( - RadrootsOperationalListingMutation::publish(canonical_draft()) - .lifecycle_state() - .expect("state"), - RadrootsOperationalListingLifecycleState::Published - ); - assert_eq!( - RadrootsOperationalListingMutation::update(canonical_draft()) - .lifecycle_state() - .expect("state"), - RadrootsOperationalListingLifecycleState::Published - ); - assert_eq!( - RadrootsOperationalListingMutation::save_draft(canonical_draft()) - .lifecycle_state() - .expect("state"), - RadrootsOperationalListingLifecycleState::Draft - ); - } - - #[test] - fn supported_mutations_expose_canonical_drafts() { - let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); - let update = RadrootsOperationalListingMutation::update(canonical_draft()); - let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft()); - - assert_eq!( - publish - .canonical_draft() - .expect("draft") - .seller_pubkey() - .to_hex(), - SELLER - ); - assert_eq!( - update - .canonical_draft() - .expect("draft") - .seller_pubkey() - .to_hex(), - SELLER - ); - assert_eq!( - save_draft - .canonical_draft() - .expect("draft") - .seller_pubkey() - .to_hex(), - SELLER - ); - assert_eq!( - publish - .canonical_draft() - .expect("draft") - .listing() - .d_tag - .as_str(), - "AAAAAAAAAAAAAAAAAAAAAg" - ); - } - - #[test] - fn supported_mutations_report_listing_addresses() { - let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); - let update = RadrootsOperationalListingMutation::update(canonical_draft()); - let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft()); - - assert_eq!( - publish.listing_addr().expect("address").as_str(), - format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") - ); - assert_eq!( - update.listing_addr().expect("address").as_str(), - format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") - ); - assert_eq!( - save_draft.listing_addr().expect("address").as_str(), - format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") - ); - } - - #[test] - fn archive_is_explicitly_unsupported() { - let archive = RadrootsOperationalListingMutation::archive( - ClassifiedListingAddress::parse(format!( - "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg" - )) - .expect("listing address"), - ); - - assert_eq!( - archive.lifecycle_state().unwrap_err(), - RadrootsOperationalListingMutationError::UnsupportedMutation - ); - assert_eq!( - archive.canonical_draft().unwrap_err(), - RadrootsOperationalListingMutationError::UnsupportedMutation - ); - assert_eq!( - archive.listing_addr().unwrap_err(), - RadrootsOperationalListingMutationError::UnsupportedMutation - ); - } - - #[test] - fn build_operational_listing_mutation_plan_maps_publish_and_update_to_published_listing() { - let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); - let update = RadrootsOperationalListingMutation::update(canonical_draft()); - - let publish_draft = - build_operational_listing_mutation_plan(&publish, 1_700_000_000).expect("draft"); - let update_draft = - build_operational_listing_mutation_plan(&update, 1_700_000_000).expect("draft"); - - assert_eq!(publish_draft.body().kind(), KIND_CLASSIFIED_LISTING); - assert_eq!( - publish_draft.body().contract().contract_id().as_str(), - OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID - ); - assert_eq!(publish_draft.author().to_hex(), SELLER); - assert_eq!(publish_draft.created_at(), 1_700_000_000); - assert_eq!( - publish_draft.body().content(), - "# Coffee\n\nSingle origin coffee" - ); - assert_eq!(update_draft.body().kind(), KIND_CLASSIFIED_LISTING); - assert_eq!( - update_draft.body().contract().contract_id().as_str(), - OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID - ); - assert_eq!(update_draft.author().to_hex(), SELLER); - } - - #[test] - fn build_operational_listing_mutation_plan_rejects_save_draft() { - let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft()); - - assert_eq!( - build_operational_listing_mutation_plan(&save_draft, 1_700_000_000).unwrap_err(), - RadrootsOperationalListingMutationError::UnsupportedMutation - ); - } - - #[test] - fn build_operational_listing_mutation_plan_rejects_archive() { - let archive = RadrootsOperationalListingMutation::archive( - ClassifiedListingAddress::parse(format!( - "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg" - )) - .expect("listing address"), - ); - - assert_eq!( - build_operational_listing_mutation_plan(&archive, 1_700_000_000).unwrap_err(), - RadrootsOperationalListingMutationError::UnsupportedMutation - ); - } - - #[test] - fn build_operational_listing_mutation_plan_reports_encode_errors() { - let mut listing = listing(); - listing.resource_area = Some(ResourceAreaRef { - pubkey: SELLER.to_string(), - d_tag: "bad d tag".to_string(), - }); - let draft = RadrootsOperationalListingCanonicalEdit::new( - listing, - PublicKey::from_hex(SELLER).expect("seller"), - ) - .expect("canonical listing edit"); - let publish = RadrootsOperationalListingMutation::publish(draft); - - let err = build_operational_listing_mutation_plan(&publish, 1_700_000_000).unwrap_err(); - - assert!(matches!( - err, - RadrootsOperationalListingMutationError::EncodeListing(_) - )); - } - - #[test] - fn build_operational_listing_mutation_plan_event_id_is_stable_for_fixed_input() { - let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); - - let first = - build_operational_listing_mutation_plan(&publish, 1_700_000_000).expect("draft"); - let second = - build_operational_listing_mutation_plan(&publish, 1_700_000_000).expect("draft"); - - assert_eq!(first.expected_event_id(), second.expected_event_id()); - assert_eq!(first.expected_event_id().to_hex().len(), 64); - assert_eq!(first.body().tags(), second.body().tags()); - assert_eq!(first.body().content(), second.body().content()); - } - - #[test] - fn build_operational_listing_mutation_plan_output_validates_as_operational_listing() { - let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); - let draft = - build_operational_listing_mutation_plan(&publish, 1_700_000_000).expect("draft"); - - let signed = sign_plan(&draft); - validate_event_contract_shape(&signed, OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID) - .expect("operational listing contract"); - let verified = verify_nip01_event(signed).expect("verified listing"); - let validated = validate_operational_listing_event(&verified).expect("validated listing"); - - assert_eq!(validated.seller_pubkey, SELLER); - assert!( - validated - .listing_addr - .as_str() - .contains(&format!(":{SELLER}:")) - ); - } -} diff --git a/crates/sdk/src/listing/operational_listing/price_ext.rs b/crates/sdk/src/listing/operational_listing/price_ext.rs @@ -1,176 +0,0 @@ -use crate::listing::operational_listing::model::{ - RadrootsOperationalListingSubtotal, RadrootsOperationalListingTotal, -}; -use radroots_core::pricing::{Error as PricingError, QuantityPriceOps}; -use radroots_core::{Decimal, Quantity}; -use radroots_event::listing::operational::OperationalListingBin; - -pub trait BinPricingTryExt { - fn try_subtotal_for_count( - &self, - bin_count: u32, - ) -> Result<RadrootsOperationalListingSubtotal, PricingError>; - fn try_total_for_count( - &self, - bin_count: u32, - ) -> Result<RadrootsOperationalListingTotal, PricingError>; -} - -#[inline] -fn effective_quantity( - bin: &OperationalListingBin, - 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 OperationalListingBin { - fn try_subtotal_for_count( - &self, - bin_count: u32, - ) -> 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(); - - Ok(RadrootsOperationalListingSubtotal { - price_amount: money, - price_currency: currency, - quantity_amount: effective_qty.amount(), - quantity_unit: effective_qty.unit(), - }) - } - - fn try_total_for_count( - &self, - bin_count: u32, - ) -> Result<RadrootsOperationalListingTotal, PricingError> { - let sub = self.try_subtotal_for_count(bin_count)?; - Ok(RadrootsOperationalListingTotal { - price_amount: sub.price_amount, - price_currency: sub.price_currency, - quantity_amount: sub.quantity_amount, - quantity_unit: sub.quantity_unit, - }) - } -} - -#[cfg(test)] -mod tests { - use super::BinPricingTryExt; - use radroots_core::pricing::Error as PricingError; - use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; - use radroots_event::id::InventoryBinId; - use radroots_event::listing::operational::OperationalListingBin; - - fn bin_id(raw: &str) -> InventoryBinId { - InventoryBinId::parse(raw).expect("bin id") - } - - fn valid_bin() -> OperationalListingBin { - OperationalListingBin { - bin_id: bin_id("bin-1"), - 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, - display_price: None, - display_price_unit: None, - } - } - - #[test] - fn try_subtotal_for_rejects_unit_mismatch() { - let bin = OperationalListingBin { - bin_id: bin_id("bin-1"), - 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, - display_price: None, - display_price_unit: None, - }; - - let err = bin.try_subtotal_for_count(1).unwrap_err(); - assert_eq!( - err, - PricingError::UnitMismatch { - have: Unit::MassG, - want: Unit::Each, - } - ); - } - - #[test] - fn subtotal_and_total_for_count_follow_effective_quantity() { - let bin = valid_bin(); - let subtotal = bin.try_subtotal_for_count(3).unwrap(); - let total = bin.try_total_for_count(3).unwrap(); - - 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); - assert_eq!(total.price_amount, subtotal.price_amount); - assert_eq!(total.price_currency, subtotal.price_currency); - } - - #[test] - 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, 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); - } - - #[test] - fn try_total_for_count_propagates_subtotal_errors() { - let bin = OperationalListingBin { - bin_id: bin_id("bin-1"), - 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, - display_price: None, - display_price_unit: None, - }; - - let err = bin.try_total_for_count(1).unwrap_err(); - assert_eq!( - err, - PricingError::UnitMismatch { - have: Unit::MassG, - want: Unit::Each, - } - ); - } -} diff --git a/crates/sdk/src/listing/operational_listing/validation.rs b/crates/sdk/src/listing/operational_listing/validation.rs @@ -1,988 +0,0 @@ -#![forbid(unsafe_code)] - -use std::{ - format, - string::{String, ToString}, -}; - -use radroots_core::{Decimal, Money, Quantity, Unit}; -use radroots_event::{ - envelope::kind::{KIND_CLASSIFIED_LISTING, is_classified_listing_kind}, - farm::location::{has_textual_locality, is_public_geohash5}, - id::ClassifiedListingAddress, - listing::classified::{ClassifiedListingPartition, classify_classified_listing_tags}, - listing::operational::{ - OperationalListing, OperationalListingAvailability, OperationalListingBin, - OperationalListingDeliveryMethod, OperationalListingPublicLocation, - }, - trade::validation::OperationalListingValidationError, -}; -use radroots_identity::PublicKey; - -use radroots_event_codec::{ - decode::operational_listing::operational_listing_from_nostr_event, - verify::RadrootsSignatureVerifiedEvent, -}; - -#[derive(Clone, Debug)] -pub struct RadrootsOperationalListingTradeProjection { - pub listing_id: String, - pub listing_addr: ClassifiedListingAddress, - pub seller_pubkey: String, - pub title: String, - pub description: String, - pub product_type: String, - pub primary_bin_id: String, - pub bin_quantity: Quantity, - pub unit: Unit, - pub unit_price: Money, - pub inventory_available: Decimal, - pub availability: OperationalListingAvailability, - pub location: OperationalListingPublicLocation, - pub delivery_method: OperationalListingDeliveryMethod, - pub listing: OperationalListing, -} - -/// Validates a signature-verified Operational Listing event. -/// -/// A plain envelope cannot cross this boundary: -/// -/// ```compile_fail -/// use radroots_event::envelope::EventEnvelope; -/// use radroots_sdk::listing::validate_operational_listing_event; -/// -/// fn validate_unverified(event: &EventEnvelope) { -/// let _ = validate_operational_listing_event(event); -/// } -/// ``` -pub fn validate_operational_listing_event( - verified_event: &RadrootsSignatureVerifiedEvent, -) -> Result<RadrootsOperationalListingTradeProjection, OperationalListingValidationError> { - let event = verified_event.event(); - if !is_classified_listing_kind(event.kind_u32()) { - return Err(OperationalListingValidationError::InvalidKind { - kind: event.kind_u32(), - }); - } - if classify_classified_listing_tags(event.tags()) - != ClassifiedListingPartition::OperationalListing - { - return Err(OperationalListingValidationError::InvalidProfile); - } - - let listing = operational_listing_from_nostr_event(event) - .map_err(|error| OperationalListingValidationError::ParseError { error })?; - validate_operational_listing_model(listing, event.author()) -} - -/// Validates the trade semantics of an unsigned Operational Listing model. -/// -/// The seller is typed independently from the model because it is the -/// authority against which the listing's farm identity is checked. This -/// function does not perform event-kind, profile, decoding, or signature -/// checks; callers handling Nostr events must use -/// [`validate_operational_listing_event`] instead. -pub fn validate_operational_listing_model( - listing: OperationalListing, - seller_pubkey: &PublicKey, -) -> Result<RadrootsOperationalListingTradeProjection, OperationalListingValidationError> { - let listing_id = listing.d_tag.as_str().trim().to_string(); - - if listing.farm.pubkey != seller_pubkey.to_hex() { - return Err(OperationalListingValidationError::InvalidSeller); - } - let listing_addr_raw = format!("{KIND_CLASSIFIED_LISTING}:{}:{listing_id}", seller_pubkey); - let listing_addr = ClassifiedListingAddress::parse(&listing_addr_raw) - .expect("validated listing identity must form a listing address"); - - let title = listing.product.title.trim().to_string(); - if title.is_empty() { - return Err(OperationalListingValidationError::MissingTitle); - } - - let description = listing - .product - .summary - .as_ref() - .map(|s| s.trim().to_string()) - .unwrap_or_default(); - if description.is_empty() { - return Err(OperationalListingValidationError::MissingDescription); - } - - let product_type = if !listing.product.category.trim().is_empty() { - listing.product.category.trim().to_string() - } else { - listing.product.key.trim().to_string() - }; - if product_type.is_empty() { - return Err(OperationalListingValidationError::MissingProductType); - } - - if listing.bins.is_empty() { - return Err(OperationalListingValidationError::MissingBins); - } - let primary_bin_id = listing.primary_bin_id.as_str().trim().to_string(); - let primary_bin_index = listing - .bins - .iter() - .position(|bin| bin.bin_id.as_str() == primary_bin_id) - .ok_or(OperationalListingValidationError::MissingPrimaryBin)?; - for (index, bin) in listing.bins.iter().enumerate() { - if listing.bins[..index] - .iter() - .any(|seen| seen.bin_id == bin.bin_id) - { - return Err(OperationalListingValidationError::InvalidBin); - } - } - let primary_bin = &listing.bins[primary_bin_index]; - - validate_listing_bin(primary_bin)?; - for (index, bin) in listing.bins.iter().enumerate() { - if index != primary_bin_index { - validate_listing_bin(bin)?; - } - } - - let inventory_available = listing - .inventory_available - .ok_or(OperationalListingValidationError::MissingInventory)?; - if inventory_available.is_sign_negative() { - return Err(OperationalListingValidationError::InvalidInventory); - } - - let availability = listing - .availability - .clone() - .ok_or(OperationalListingValidationError::MissingAvailability)?; - let location = listing - .location - .clone() - .ok_or(OperationalListingValidationError::MissingLocation)?; - if !has_textual_locality( - &location.primary, - location.city.as_deref(), - location.region.as_deref(), - location.country.as_deref(), - ) { - return Err(OperationalListingValidationError::MissingLocationLocality); - } - validate_listing_location_geohash(&location.geohash)?; - let delivery_method = listing - .delivery_method - .clone() - .ok_or(OperationalListingValidationError::MissingDeliveryMethod)?; - - Ok(RadrootsOperationalListingTradeProjection { - listing_id, - listing_addr, - 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(), - inventory_available, - availability, - location, - delivery_method, - listing, - }) -} - -fn validate_listing_bin( - bin: &OperationalListingBin, -) -> Result<(), OperationalListingValidationError> { - 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() - .is_sign_negative() - || bin.price_per_canonical_unit.quantity().unit() != bin.quantity.unit() - { - return Err(OperationalListingValidationError::InvalidPrice); - } - Ok(()) -} - -fn validate_listing_location_geohash( - geohash: &str, -) -> Result<(), OperationalListingValidationError> { - if geohash.trim().is_empty() { - return Err(OperationalListingValidationError::MissingLocationGeohash); - } - if !is_public_geohash5(geohash) { - return Err(OperationalListingValidationError::InvalidLocationGeohash); - } - Ok(()) -} - -#[cfg(all(test, feature = "local-signing"))] -mod tests { - const FIXTURE_ALICE_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; - const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - const FIXTURE_BOB_SECRET_KEY_HEX: &str = - "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; - const FIXTURE_BOB_PUBLIC_KEY_HEX: &str = - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; - use super::{ - OperationalListingValidationError, validate_operational_listing_event, - validate_operational_listing_model, - }; - use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; - use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; - use radroots_event::{ - envelope::EventEnvelope, - envelope::EventEnvelopeParts, - envelope::kind::KIND_CLASSIFIED_LISTING, - farm::FarmRef, - id::{DTag, InventoryBinId}, - listing::operational::{ - OperationalListing, OperationalListingAvailability, OperationalListingBin, - OperationalListingDeliveryMethod, OperationalListingProduct, - OperationalListingPublicLocation, - }, - wire::Nip01EventWire, - }; - use radroots_event_codec::verify::{RadrootsSignatureVerifiedEvent, verify_nip01_event}; - use radroots_identity::PublicKey; - - const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX; - const OTHER_SELLER: &str = FIXTURE_BOB_PUBLIC_KEY_HEX; - - fn d_tag(raw: &str) -> DTag { - DTag::parse(raw).expect("d tag") - } - - fn bin_id(raw: &str) -> InventoryBinId { - InventoryBinId::parse(raw).expect("bin id") - } - - fn seller_pubkey() -> PublicKey { - PublicKey::from_hex(SELLER).expect("seller pubkey") - } - - fn other_seller_pubkey() -> PublicKey { - PublicKey::from_hex(OTHER_SELLER).expect("other seller pubkey") - } - - fn base_listing() -> OperationalListing { - OperationalListing { - d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), - published_at: None, - farm: FarmRef { - pubkey: SELLER.into(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), - }, - product: OperationalListingProduct { - key: "coffee".into(), - title: "Coffee".into(), - category: "coffee".into(), - summary: Some("Single origin coffee".into()), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: bin_id("bin-1"), - bins: vec![OperationalListingBin { - bin_id: bin_id("bin-1"), - 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, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: Some(Decimal::from(5u32)), - availability: Some(OperationalListingAvailability::Status { - status: radroots_event::listing::operational::OperationalListingStatus::Active, - }), - delivery_method: Some(OperationalListingDeliveryMethod::Pickup), - location: Some(OperationalListingPublicLocation { - primary: "Farm".into(), - city: Some("Town".into()), - region: Some("Region".into()), - country: Some("US".into()), - geohash: "9q8yy".into(), - }), - images: None, - } - } - - fn base_event(listing: &OperationalListing) -> RadrootsSignatureVerifiedEvent { - let mut tags = vec![ - vec!["d".into(), listing.d_tag.to_string()], - vec!["p".into(), listing.farm.pubkey.clone()], - vec![ - "a".into(), - format!("30340:{}:{}", listing.farm.pubkey, listing.farm.d_tag), - ], - vec!["key".into(), listing.product.key.clone()], - vec!["title".into(), listing.product.title.clone()], - vec!["category".into(), listing.product.category.clone()], - vec![ - "summary".into(), - listing.product.summary.clone().unwrap_or_default(), - ], - vec![ - "radroots:primary_bin".into(), - listing.primary_bin_id.to_string(), - ], - ]; - for bin in &listing.bins { - tags.push(vec![ - "radroots:bin".into(), - bin.bin_id.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() - .currency() - .as_str() - .to_string(), - bin.price_per_canonical_unit.quantity().amount().to_string(), - bin.price_per_canonical_unit - .quantity() - .unit() - .code() - .to_string(), - ]); - } - if let Some(inventory) = listing.inventory_available { - tags.push(vec!["inventory".into(), inventory.to_string()]); - } - if let Some(availability) = &listing.availability { - match availability { - OperationalListingAvailability::Status { status } => tags.push(vec![ - "status".into(), - match status { - radroots_event::listing::operational::OperationalListingStatus::Active => { - "active".into() - } - radroots_event::listing::operational::OperationalListingStatus::Sold => { - "sold".into() - } - radroots_event::listing::operational::OperationalListingStatus::Other { - value, - } => value.clone(), - }, - ]), - OperationalListingAvailability::Window { start, end } => { - if let Some(start) = start { - tags.push(vec![ - "radroots:availability_start".into(), - start.to_string(), - ]); - } - if let Some(end) = end { - tags.push(vec!["expires_at".into(), end.to_string()]); - } - } - } - } - if let Some(delivery) = &listing.delivery_method { - let mut tag = vec!["delivery".into()]; - match delivery { - OperationalListingDeliveryMethod::Pickup => tag.push("pickup".into()), - OperationalListingDeliveryMethod::LocalDelivery => { - tag.push("local_delivery".into()) - } - OperationalListingDeliveryMethod::Shipping => tag.push("shipping".into()), - OperationalListingDeliveryMethod::Other { method } => { - tag.push("other".into()); - tag.push(method.clone()); - } - } - tags.push(tag); - } - if let Some(location) = &listing.location { - tags.push(vec![ - "location".into(), - location.primary.clone(), - location.city.clone().unwrap_or_default(), - location.region.clone().unwrap_or_default(), - location.country.clone().unwrap_or_default(), - ]); - tags.push(vec!["g".into(), location.geohash.clone()]); - } - - event_with_parts(SELLER, KIND_CLASSIFIED_LISTING, tags, String::new()) - } - - fn event_with_parts( - author: &str, - kind: u32, - tags: Vec<Vec<String>>, - content: String, - ) -> RadrootsSignatureVerifiedEvent { - let secret = match author { - SELLER => FIXTURE_ALICE_SECRET_KEY_HEX, - OTHER_SELLER => FIXTURE_BOB_SECRET_KEY_HEX, - _ => panic!("test author must be an approved fixture identity"), - }; - let keys = Keys::parse(secret).expect("fixture signing key"); - let tags = tags - .into_iter() - .map(|tag| Tag::parse(tag).expect("test tag")) - .collect::<Vec<_>>(); - let event = EventBuilder::new( - Kind::Custom(u16::try_from(kind).expect("test kind")), - content, - ) - .tags(tags) - .allow_self_tagging() - .custom_created_at(Timestamp::from_secs(1)) - .sign_with_keys(&keys) - .expect("signed test event"); - let raw_json = event.as_json(); - let envelope = Nip01EventWire::parse_json(raw_json.as_str()) - .expect("canonical event wire") - .into_envelope() - .expect("event envelope"); - verify_nip01_event(envelope).expect("verified test event") - } - - fn assert_validation_err( - listing: OperationalListing, - expected: OperationalListingValidationError, - ) { - let event = base_event(&listing); - let err = validate_operational_listing_event(&event).unwrap_err(); - assert_eq!(format!("{err}"), format!("{expected}")); - } - - fn assert_secondary_bin_model_error( - update: impl FnOnce(&mut OperationalListingBin), - expected: OperationalListingValidationError, - ) { - let mut listing = listing_with_secondary_bin(); - update(&mut listing.bins[1]); - - assert_eq!( - validate_operational_listing_model(listing, &seller_pubkey()) - .expect_err("invalid secondary bin"), - expected - ); - } - - fn listing_with_secondary_bin() -> OperationalListing { - let mut listing = base_listing(); - let mut secondary_bin = listing.bins[0].clone(); - secondary_bin.bin_id = bin_id("bin-2"); - listing.bins.push(secondary_bin); - listing - } - - #[test] - fn validate_listing_ok() { - let listing = base_listing(); - let event = base_event(&listing); - assert!(validate_operational_listing_event(&event).is_ok()); - } - - #[test] - #[cfg(any())] - fn model_and_verified_event_validation_return_the_same_projection() { - let listing = listing_with_secondary_bin(); - let event_projection = - validate_operational_listing_event(&base_event(&listing)).expect("event projection"); - let model_projection = validate_operational_listing_model(listing, &seller_pubkey()) - .expect("model projection"); - - assert_eq!( - serde_json::to_value(model_projection).expect("model projection JSON"), - serde_json::to_value(event_projection).expect("event projection JSON") - ); - } - - #[test] - fn model_and_verified_event_validation_return_the_same_semantic_errors() { - let mut listing = base_listing(); - listing.inventory_available = None; - let event_error = validate_operational_listing_event(&base_event(&listing)) - .expect_err("event inventory error"); - let model_error = validate_operational_listing_model(listing, &seller_pubkey()) - .expect_err("model inventory error"); - assert_eq!(model_error, event_error); - - let listing = base_listing(); - let event = event_with_parts( - OTHER_SELLER, - KIND_CLASSIFIED_LISTING, - base_event(&listing).event().tags_as_vec(), - String::new(), - ); - let event_error = - validate_operational_listing_event(&event).expect_err("event seller error"); - let model_error = validate_operational_listing_model(listing, &other_seller_pubkey()) - .expect_err("model seller error"); - assert_eq!(model_error, event_error); - } - - #[test] - fn model_validation_reports_errors_before_event_encoding() { - let mut listing = base_listing(); - listing.bins.clear(); - assert_eq!( - validate_operational_listing_model(listing, &seller_pubkey()) - .expect_err("missing bins"), - OperationalListingValidationError::MissingBins - ); - - let mut listing = base_listing(); - listing.primary_bin_id = bin_id("missing"); - assert_eq!( - validate_operational_listing_model(listing, &seller_pubkey()) - .expect_err("missing primary bin"), - OperationalListingValidationError::MissingPrimaryBin - ); - - let mut listing = base_listing(); - listing.bins.push(listing.bins[0].clone()); - assert_eq!( - validate_operational_listing_model(listing, &seller_pubkey()) - .expect_err("duplicate bin ID"), - OperationalListingValidationError::InvalidBin - ); - - let mut listing = base_listing(); - listing.location.as_mut().expect("location").geohash = " ".into(); - assert_eq!( - validate_operational_listing_model(listing, &seller_pubkey()) - .expect_err("missing geohash"), - OperationalListingValidationError::MissingLocationGeohash - ); - - let mut listing = base_listing(); - listing.location.as_mut().expect("location").geohash = "9q8yyz".into(); - assert_eq!( - validate_operational_listing_model(listing, &seller_pubkey()) - .expect_err("invalid geohash"), - OperationalListingValidationError::InvalidLocationGeohash - ); - } - - #[test] - fn model_validation_rejects_invalid_secondary_bin_quantities() { - assert_secondary_bin_model_error( - |bin| bin.quantity = Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(), - OperationalListingValidationError::InvalidBin, - ); - } - - #[test] - fn model_validation_rejects_invalid_secondary_bin_prices() { - assert_secondary_bin_model_error( - |bin| { - 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 = 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 = QuantityPrice::try_new( - bin.price_per_canonical_unit.amount().clone(), - Quantity::try_new(Decimal::ONE, Unit::Each).unwrap(), - ) - .unwrap(); - }, - OperationalListingValidationError::InvalidPrice, - ); - } - - #[test] - fn validate_listing_rejects_retired_kind() { - let listing = base_listing(); - let event = event_with_parts( - SELLER, - 30403, - base_event(&listing).event().tags_as_vec(), - String::new(), - ); - let err = validate_operational_listing_event(&event).unwrap_err(); - assert_eq!( - err, - OperationalListingValidationError::InvalidKind { kind: 30403 } - ); - } - - #[test] - fn validate_listing_rejects_missing_d_tag() { - let event = event_with_parts(SELLER, KIND_CLASSIFIED_LISTING, Vec::new(), String::new()); - let err = validate_operational_listing_event(&event).unwrap_err(); - assert_eq!(err, OperationalListingValidationError::InvalidProfile); - } - - #[test] - fn validate_listing_rejects_invalid_currency() { - let event = event_with_parts( - SELLER, - KIND_CLASSIFIED_LISTING, - vec![ - vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()], - vec!["p".into(), SELLER.into()], - vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")], - vec!["key".into(), "coffee".into()], - vec!["title".into(), "Coffee".into()], - vec!["category".into(), "coffee".into()], - vec!["summary".into(), "Single origin".into()], - vec!["radroots:primary_bin".into(), "bin-1".into()], - vec![ - "quantity".into(), - "1".into(), - "lb".into(), - "bag".into(), - "5".into(), - ], - vec![ - "price".into(), - "20".into(), - "US".into(), - "1".into(), - "lb".into(), - ], - vec![ - "location".into(), - "Farm".into(), - "Town".into(), - "Region".into(), - ], - vec!["status".into(), "active".into()], - vec!["delivery".into(), "pickup".into()], - ], - String::new(), - ); - let err = validate_operational_listing_event(&event).unwrap_err(); - assert!(format!("{err:?}").starts_with("ParseError")); - } - - #[test] - fn validate_listing_rejects_mismatched_seller() { - let listing = base_listing(); - let event = event_with_parts( - OTHER_SELLER, - KIND_CLASSIFIED_LISTING, - base_event(&listing).event().tags_as_vec(), - String::new(), - ); - let err = validate_operational_listing_event(&event).unwrap_err(); - assert_eq!(err, OperationalListingValidationError::InvalidSeller); - } - - #[test] - fn validate_listing_rejects_missing_inventory() { - let mut listing = base_listing(); - listing.inventory_available = None; - let event = base_event(&listing); - let err = validate_operational_listing_event(&event).unwrap_err(); - assert_eq!(err, OperationalListingValidationError::MissingInventory); - } - - #[test] - fn validate_listing_rejects_invalid_kind() { - let listing = base_listing(); - let event = event_with_parts( - SELLER, - 0, - base_event(&listing).event().tags_as_vec(), - String::new(), - ); - let err = validate_operational_listing_event(&event).unwrap_err(); - assert_eq!( - err, - OperationalListingValidationError::InvalidKind { kind: 0 } - ); - } - - #[test] - fn validate_listing_rejects_missing_title() { - let mut listing = base_listing(); - listing.product.title = " ".into(); - assert_validation_err(listing, OperationalListingValidationError::MissingTitle); - } - - #[test] - fn tampered_envelope_cannot_reach_operational_validation() { - let verified = base_event(&base_listing()); - let event = verified.into_event(); - let tampered = EventEnvelope::new(EventEnvelopeParts { - id: event.id_hex(), - author: event.author().to_hex().to_owned(), - created_at: event.created_at_u64(), - kind: event.kind_u32(), - tags: event.tags_as_vec(), - content: "tampered".to_owned(), - sig: event.signature_hex(), - }) - .expect("well-shaped tampered envelope"); - - assert!(verify_nip01_event(tampered).is_err()); - } - - #[test] - fn validate_listing_rejects_missing_description() { - let mut listing = base_listing(); - listing.product.summary = Some(" ".into()); - assert_validation_err( - listing, - OperationalListingValidationError::MissingDescription, - ); - } - - #[test] - fn validate_listing_rejects_missing_product_type() { - let mut listing = base_listing(); - listing.product.category = " ".into(); - listing.product.key = " ".into(); - assert_validation_err( - listing, - OperationalListingValidationError::MissingProductType, - ); - } - - #[test] - fn validate_listing_rejects_missing_bins() { - let mut listing = base_listing(); - listing.bins.clear(); - assert_validation_err( - listing, - OperationalListingValidationError::ParseError { - error: - radroots_event::listing::operational::OperationalListingParseError::InvalidTag( - "radroots:primary_bin".into(), - ), - }, - ); - } - - #[test] - fn validate_listing_rejects_missing_primary_bin_id() { - assert!(InventoryBinId::parse(" ").is_err()); - } - - #[test] - fn validate_listing_rejects_primary_bin_not_found() { - let mut listing = base_listing(); - listing.primary_bin_id = bin_id("missing"); - assert_validation_err( - listing, - OperationalListingValidationError::ParseError { - error: - radroots_event::listing::operational::OperationalListingParseError::InvalidTag( - "radroots:primary_bin".into(), - ), - }, - ); - } - - #[test] - fn validate_listing_rejects_non_canonical_quantity() { - let mut listing = base_listing(); - listing.bins[0].quantity = Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(); - assert_validation_err( - listing, - OperationalListingValidationError::ParseError { - error: - radroots_event::listing::operational::OperationalListingParseError::InvalidTag( - "radroots:bin".into(), - ), - }, - ); - } - - #[test] - fn validate_listing_rejects_non_canonical_price_quantity() { - let mut listing = base_listing(); - 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 { - error: - radroots_event::listing::operational::OperationalListingParseError::InvalidTag( - "radroots:price".into(), - ), - }, - ); - } - - #[test] - fn validate_listing_rejects_price_unit_mismatch() { - let mut listing = base_listing(); - 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 { - error: - radroots_event::listing::operational::OperationalListingParseError::InvalidTag( - "radroots:price".into(), - ), - }, - ); - } - - #[test] - fn validate_listing_rejects_negative_inventory() { - let mut listing = base_listing(); - listing.inventory_available = Some("-1".parse().unwrap()); - assert_validation_err(listing, OperationalListingValidationError::InvalidInventory); - } - - #[test] - fn validate_listing_rejects_missing_availability() { - let mut listing = base_listing(); - listing.availability = None; - assert_validation_err( - listing, - OperationalListingValidationError::MissingAvailability, - ); - } - - #[test] - fn validate_listing_rejects_missing_location() { - let mut listing = base_listing(); - listing.location = None; - assert_validation_err(listing, OperationalListingValidationError::MissingLocation); - } - - #[test] - fn validate_listing_rejects_missing_location_locality() { - let mut listing = base_listing(); - let location = listing.location.as_mut().expect("location"); - location.city = None; - location.region = None; - location.country = None; - assert_validation_err( - listing, - OperationalListingValidationError::MissingLocationLocality, - ); - } - - #[test] - fn validate_listing_rejects_missing_location_geohash() { - let mut listing = base_listing(); - listing.location.as_mut().expect("location").geohash = " ".into(); - assert_validation_err( - listing, - OperationalListingValidationError::ParseError { - error: - radroots_event::listing::operational::OperationalListingParseError::InvalidTag( - "g".to_string(), - ), - }, - ); - } - - #[test] - fn validate_listing_rejects_invalid_location_geohash() { - let mut listing = base_listing(); - listing.location.as_mut().expect("location").geohash = "9q8yyz".into(); - assert_validation_err( - listing, - OperationalListingValidationError::ParseError { - error: - radroots_event::listing::operational::OperationalListingParseError::InvalidTag( - "g".to_string(), - ), - }, - ); - } - - #[test] - fn validate_listing_rejects_missing_delivery_method() { - let mut listing = base_listing(); - listing.delivery_method = None; - assert_validation_err( - listing, - OperationalListingValidationError::MissingDeliveryMethod, - ); - } - - #[test] - fn validation_error_display_covers_all_variants() { - let errors = vec![ - OperationalListingValidationError::InvalidKind { kind: 9 }, - OperationalListingValidationError::InvalidProfile, - OperationalListingValidationError::MissingListingId, - OperationalListingValidationError::ListingEventNotFound { - listing_addr: "addr".into(), - }, - OperationalListingValidationError::ListingEventFetchFailed { - listing_addr: "addr".into(), - }, - OperationalListingValidationError::ParseError { - error: - radroots_event::listing::operational::OperationalListingParseError::InvalidTag( - "d".into(), - ), - }, - OperationalListingValidationError::InvalidSeller, - OperationalListingValidationError::MissingFarmProfile, - OperationalListingValidationError::MissingFarmRecord, - OperationalListingValidationError::MissingTitle, - OperationalListingValidationError::MissingDescription, - OperationalListingValidationError::MissingProductType, - OperationalListingValidationError::MissingBins, - OperationalListingValidationError::MissingPrimaryBin, - OperationalListingValidationError::InvalidBin, - OperationalListingValidationError::MissingPrice, - OperationalListingValidationError::InvalidPrice, - OperationalListingValidationError::MissingInventory, - OperationalListingValidationError::InvalidInventory, - OperationalListingValidationError::MissingAvailability, - OperationalListingValidationError::MissingLocation, - OperationalListingValidationError::MissingLocationLocality, - OperationalListingValidationError::MissingLocationGeohash, - OperationalListingValidationError::InvalidLocationGeohash, - OperationalListingValidationError::MissingDeliveryMethod, - ]; - for error in errors { - assert!(!error.to_string().trim().is_empty()); - } - } -} diff --git a/crates/sdk/src/signing.rs b/crates/sdk/src/signing.rs @@ -1,532 +0,0 @@ -//! Generic signer composition without protocol or relay ownership. - -use std::sync::Arc; -#[cfg(feature = "local-signing")] -use std::sync::RwLock; - -use radroots_signing::{SignReceipt, SignRequest, Signer, SignerStatus}; - -/// Host-visible signer composition mode. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Mode { - /// A concrete local Nostr adapter owns opaque key material. - Local, - /// A host-provided signer drives NIP-46 protocol and relay execution. - Nip46, - /// Another host-provided implementation of the generic signer SPI. - Host, -} - -/// Cloneable SDK composition wrapper around the canonical signer SPI. -#[derive(Clone)] -pub struct Provider { - mode: Mode, - signer: Arc<dyn Signer>, - #[cfg(feature = "local-signing")] - slot: Option<Slot>, -} - -impl Provider { - /// Wraps any host-provided canonical signer implementation. - #[must_use] - pub fn host(signer: Arc<dyn Signer>) -> Self { - Self { - mode: Mode::Host, - signer, - #[cfg(feature = "local-signing")] - slot: None, - } - } - - /// Wraps the concrete local Nostr adapter without exposing its key. - #[cfg(feature = "local-signing")] - #[must_use] - pub fn local(signer: radroots_nostr::signing::LocalSigner) -> Self { - Self { - mode: Mode::Local, - signer: Arc::new(signer), - slot: None, - } - } - - /// Wraps a host-controlled local signer slot. - /// - /// The slot starts inert and can be populated or cleared without rebuilding - /// the client. Secret persistence remains entirely host-owned. - #[cfg(feature = "local-signing")] - #[must_use] - pub fn slot(slot: Slot) -> Self { - Self { - mode: Mode::Local, - signer: Arc::new(slot.clone()), - slot: Some(slot), - } - } - - /// Marks a host-provided canonical signer as a NIP-46 composition. - /// - /// `radroots_nostr_connect` owns protocol state and its transport SPI. The - /// injected implementation owns that client plus explicit relay execution; - /// this wrapper does not contact a relay, persist a session, or start work. - #[cfg(feature = "nip46")] - #[must_use] - pub fn nip46(signer: Arc<dyn Signer>) -> Self { - Self { - mode: Mode::Nip46, - signer, - #[cfg(feature = "local-signing")] - slot: None, - } - } - - /// Returns the explicit composition mode. - #[must_use] - pub const fn mode(&self) -> Mode { - self.mode - } - - /// Borrows the canonical signer SPI. - #[must_use] - pub fn as_signer(&self) -> &dyn Signer { - self.signer.as_ref() - } - - /// Reports canonical status without initiating a signing request. - pub async fn status(&self) -> Result<SignerStatus, radroots_signing::Error> { - self.signer.status().await - } - - /// Delegates one already-authorized request to the canonical SPI. - pub async fn sign(&self, request: SignRequest) -> Result<SignReceipt, radroots_signing::Error> { - self.signer.sign(request).await - } - - #[cfg(feature = "local-signing")] - pub(crate) fn into_parts(self) -> (Arc<dyn Signer>, Option<Slot>) { - (self.signer, self.slot) - } - - #[cfg(not(feature = "local-signing"))] - pub(crate) fn into_signer(self) -> Arc<dyn Signer> { - self.signer - } -} - -/// Public identity controlled by an installed local signer. -#[cfg(feature = "local-signing")] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct LocalIdentity { - public_key: radroots_identity::PublicKey, - npub: String, -} - -#[cfg(feature = "local-signing")] -impl LocalIdentity { - fn from_public_key( - public_key: radroots_identity::PublicKey, - ) -> Result<Self, radroots_nostr::Error> { - Ok(Self { - public_key, - npub: radroots_nostr::key::public_key_to_npub(public_key)?, - }) - } - - /// Returns the canonical lowercase public-key hexadecimal form. - #[must_use] - pub fn public_key_hex(&self) -> String { - self.public_key.to_hex() - } - - /// Returns the canonical NIP-19 public identity. - #[must_use] - pub fn npub(&self) -> &str { - self.npub.as_str() - } - - #[cfg(any(test, all(feature = "sync", feature = "nostr")))] - pub(crate) const fn public_key(&self) -> radroots_identity::PublicKey { - self.public_key - } -} - -/// Mutable, client-shareable local signer selected by the host. -/// -/// The slot never persists key material and its debug output never observes -/// the installed signer. Installing and clearing are explicit host actions. -#[cfg(feature = "local-signing")] -#[derive(Clone, Default)] -pub struct Slot { - state: Arc<RwLock<Option<SlotState>>>, -} - -#[cfg(feature = "local-signing")] -struct SlotState { - signer: Arc<dyn Signer>, - identity: LocalIdentity, -} - -#[cfg(feature = "local-signing")] -impl Slot { - /// Creates an empty signer slot. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Validates and installs one host-supplied hexadecimal or `nsec` secret. - pub fn install(&self, encoded: &str) -> Result<LocalIdentity, radroots_nostr::Error> { - let secret = radroots_nostr::key::SecretKey::parse(encoded)?; - self.install_secret(secret) - } - - /// Generates, installs, and returns one secret for immediate host custody. - /// - /// The SDK retains only the opaque signer. The returned `nsec` is the sole - /// persistence handoff and must be moved into host secure storage. - pub fn generate(&self) -> Result<(String, LocalIdentity), radroots_nostr::Error> { - let secret = radroots_nostr::key::SecretKey::generate(); - let encoded = radroots_nostr::key::secret_key_to_nsec(&secret); - let identity = self.install_secret(secret)?; - Ok((encoded, identity)) - } - - /// Removes the active signer from this process. - pub fn clear(&self) { - if let Ok(mut state) = self.state.write() { - *state = None; - } - } - - /// Returns the currently installed public identity. - #[must_use] - pub fn identity(&self) -> Option<LocalIdentity> { - self.state - .read() - .ok() - .and_then(|state| state.as_ref().map(|state| state.identity.clone())) - } - - fn install_secret( - &self, - secret: radroots_nostr::key::SecretKey, - ) -> Result<LocalIdentity, radroots_nostr::Error> { - let public_key = secret.public_key()?; - let identity = LocalIdentity::from_public_key(public_key)?; - let signer: Arc<dyn Signer> = Arc::new(radroots_nostr::signing::LocalSigner::new(secret)?); - if let Ok(mut state) = self.state.write() { - *state = Some(SlotState { - signer, - identity: identity.clone(), - }); - } - Ok(identity) - } - - fn signer(&self) -> Result<Arc<dyn Signer>, radroots_signing::Error> { - self.state - .read() - .map_err(|source| { - radroots_signing::Error::with_source( - radroots_signing::error::Kind::InternalError, - LockFailure(source.to_string()), - ) - })? - .as_ref() - .map(|state| Arc::clone(&state.signer)) - .ok_or_else(|| { - radroots_signing::Error::new(radroots_signing::error::Kind::SignerUnavailable) - }) - } -} - -#[cfg(feature = "local-signing")] -impl Signer for Slot { - fn status( - &self, - ) -> radroots_signing::signer::BoxFuture<'_, Result<SignerStatus, radroots_signing::Error>> - { - Box::pin(async move { - match self.signer() { - Ok(signer) => signer.status().await, - Err(error) if error.kind() == radroots_signing::error::Kind::SignerUnavailable => { - Ok(SignerStatus::unavailable()) - } - Err(error) => Err(error), - } - }) - } - - fn sign( - &self, - request: SignRequest, - ) -> radroots_signing::signer::BoxFuture<'_, Result<SignReceipt, radroots_signing::Error>> { - Box::pin(async move { self.signer()?.sign(request).await }) - } -} - -#[cfg(feature = "local-signing")] -impl std::fmt::Debug for Slot { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("Slot") - .field("installed", &self.identity().is_some()) - .finish() - } -} - -#[cfg(feature = "local-signing")] -#[derive(Debug)] -struct LockFailure(String); - -#[cfg(feature = "local-signing")] -impl std::fmt::Display for LockFailure { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(self.0.as_str()) - } -} - -#[cfg(feature = "local-signing")] -impl std::error::Error for LockFailure {} - -impl std::fmt::Debug for Provider { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("Provider") - .field("mode", &self.mode) - .field("signer", &"<opaque>") - .finish() - } -} - -#[cfg(test)] -mod tests { - use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }; - - use radroots_event::{GenericEventDraft, contract::AuthorRole}; - use radroots_event_codec::authoring::AuthoredEventPlan; - use radroots_identity::PublicKey; - use radroots_protocol::runtime::v1::OperationId; - use radroots_signing::{ - Actor, AuthoredArtifactId, Error, SignReceipt, SignRequest, SignerStatus, SigningIntentId, - SigningOperationId, - actor::ActorSource, - error::Kind, - request::{CancellationPolicy, SignPolicy}, - signer::BoxFuture, - }; - #[cfg(any(feature = "local-signing", feature = "nip46"))] - use radroots_signing::{capability::SignerKind, status::SignerAvailability}; - #[cfg(feature = "nip46")] - use radroots_signing::{ - capability::{CancellationSupport, SignerCapability}, - recovery::ReplayCapability, - status::{AuthChallenge, SignProgress}, - }; - - use super::*; - - const PUBLIC_KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - struct ScriptedSigner { - status: SignerStatus, - result: Kind, - polls: Arc<AtomicUsize>, - } - - impl Signer for ScriptedSigner { - fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> { - let status = self.status.clone(); - Box::pin(async move { Ok(status) }) - } - - fn sign(&self, _request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { - let result = self.result; - let polls = Arc::clone(&self.polls); - Box::pin(async move { - polls.fetch_add(1, Ordering::Relaxed); - Err(Error::new(result)) - }) - } - } - - fn request() -> SignRequest { - let actor = Actor::new( - PublicKey::from_hex(PUBLIC_KEY).expect("public key"), - ActorSource::ExplicitPublicKey, - [AuthorRole::Any], - ) - .expect("actor"); - let plan = AuthoredEventPlan::from_generic( - GenericEventDraft::new( - "radroots.social.geochat.v1", - 20_000, - 1_700_000_000, - Vec::new(), - "frozen-content", - PUBLIC_KEY, - ) - .expect("draft"), - ) - .expect("authored plan"); - SignRequest::new( - OperationId::SyncPush, - SigningIntentId::new( - SigningOperationId::new([1; 16]).expect("operation id"), - AuthoredArtifactId::new([2; 16]).expect("artifact id"), - ), - actor, - plan, - SignPolicy::new(1_700_000_100, CancellationPolicy::PreservePublishedRequest) - .expect("policy"), - ) - .expect("request") - } - - #[cfg(feature = "nip46")] - fn remote_status(progress: Option<SignProgress>) -> SignerStatus { - SignerStatus::new( - SignerAvailability::AwaitingAuthentication, - vec![SignerCapability::new( - SignerKind::Remote, - ReplayCapability::ExactReplayByRequestId, - CancellationSupport::BeforeAndAfterPublication, - true, - true, - )], - progress, - ) - } - - #[cfg(feature = "local-signing")] - #[tokio::test] - async fn local_provider_uses_the_concrete_lower_adapter() { - let local = radroots_nostr::signing::LocalSigner::generate().expect("local signer"); - let provider = Provider::local(local); - let status = provider.status().await.expect("status"); - assert_eq!(provider.mode(), Mode::Local); - assert_eq!(status.availability(), SignerAvailability::Ready); - assert_eq!(status.capabilities()[0].kind(), SignerKind::Local); - } - - #[cfg(feature = "local-signing")] - #[tokio::test] - async fn local_slot_hands_secret_to_host_and_supports_lock_restore() { - let slot = Slot::new(); - assert!(slot.identity().is_none()); - assert_eq!( - slot.status().await.expect("empty status").availability(), - SignerAvailability::Unavailable - ); - - let (secret, generated) = slot.generate().expect("generated identity"); - assert!(secret.starts_with("nsec1")); - assert_eq!(slot.identity().expect("installed"), generated); - assert!(!format!("{slot:?}").contains(secret.as_str())); - - slot.clear(); - assert!(slot.identity().is_none()); - let restored = slot.install(secret.as_str()).expect("restored identity"); - assert_eq!(restored, generated); - assert_eq!(restored.public_key_hex(), restored.public_key().to_hex()); - assert!(restored.npub().starts_with("npub1")); - - let provider = Provider::slot(slot.clone()); - assert_eq!(provider.mode(), Mode::Local); - assert!(format!("{provider:?}").contains("<opaque>")); - let (_signer, provider_slot) = provider.into_parts(); - assert!(provider_slot.is_some()); - - slot.clear(); - assert_eq!( - slot.sign(request()).await.expect_err("empty slot").kind(), - Kind::SignerUnavailable - ); - } - - #[cfg(feature = "local-signing")] - #[tokio::test] - async fn poisoned_local_slot_fails_closed_without_exposing_key_state() { - let slot = Slot::new(); - let state = Arc::clone(&slot.state); - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _guard = state.write().expect("write lock"); - panic!("poison signer slot"); - })); - - slot.clear(); - assert!(slot.identity().is_none()); - let (_secret, identity) = slot.generate().expect("secret remains host-owned"); - assert!(!identity.public_key_hex().is_empty()); - assert_eq!( - slot.sign(request()) - .await - .expect_err("poisoned slot") - .kind(), - Kind::InternalError - ); - } - - #[cfg(feature = "nip46")] - #[tokio::test] - async fn nip46_provider_preserves_auth_challenge_and_capabilities() { - let challenge = AuthChallenge::new( - "https://signer.example/approve", - 1_700_000_000, - Some(1_700_000_100), - ) - .expect("challenge"); - let signer = ScriptedSigner { - status: remote_status(Some(SignProgress::authentication(challenge))), - result: Kind::SignerRejected, - polls: Arc::new(AtomicUsize::new(0)), - }; - let provider = Provider::nip46(Arc::new(signer)); - let status = provider.status().await.expect("status"); - assert_eq!(provider.mode(), Mode::Nip46); - assert_eq!( - status.availability(), - SignerAvailability::AwaitingAuthentication - ); - assert!(status.progress().expect("progress").challenge().is_some()); - assert!(status.capabilities()[0].may_require_authentication()); - } - - #[tokio::test] - async fn canonical_errors_preserve_timeout_drift_and_cancellation() { - for expected in [ - Kind::SignerTimeout, - Kind::SignerOutputInvalid, - Kind::SignerCancelled, - ] { - let provider = Provider::host(Arc::new(ScriptedSigner { - status: SignerStatus::unavailable(), - result: expected, - polls: Arc::new(AtomicUsize::new(0)), - })); - assert_eq!( - provider.sign(request()).await.expect_err("failure").kind(), - expected - ); - assert_eq!( - provider.as_signer().status().await.expect("status"), - SignerStatus::unavailable() - ); - } - } - - #[test] - fn dropping_unpolled_signing_future_has_no_effect() { - let polls = Arc::new(AtomicUsize::new(0)); - let provider = Provider::host(Arc::new(ScriptedSigner { - status: SignerStatus::unavailable(), - result: Kind::SignerCancelled, - polls: Arc::clone(&polls), - })); - drop(provider.sign(request())); - assert_eq!(polls.load(Ordering::Relaxed), 0); - } -} diff --git a/crates/sdk/src/storage.rs b/crates/sdk/src/storage.rs @@ -1,259 +0,0 @@ -//! Canonical storage capability composition. - -/// Backend-neutral storage status owned by `radroots_storage`. -pub type Status = radroots_storage::StorageStatus; - -/// Backend-neutral integrity status owned by `radroots_storage`. -pub type IntegrityStatus = radroots_storage::status::IntegrityStatus; - -/// Validated SQLite open configuration; this contains no connection or pool. -#[cfg(feature = "sqlite")] -pub type SqliteOptions = radroots_storage_sqlite::OpenOptions; - -/// Explicit SQLite lifecycle mode. -#[cfg(feature = "sqlite")] -pub type SqliteOpenMode = radroots_storage_sqlite::OpenMode; - -/// Validated SQLite-owned paths; this contains no backend handle. -#[cfg(feature = "sqlite")] -pub type SqlitePaths = radroots_storage_sqlite::Paths; - -use radroots_storage::backup::{ - BackupId, BackupOperation, BackupPlan, BackupTransition, ReliabilityRevision, RestoreOperation, - RestorePlan, RestoreTransition, StorageReliability, -}; - -/// Borrowed reliability operations over the canonical backend-neutral SPI. -#[derive(Clone, Copy)] -pub struct Operations<'a> { - storage: &'a dyn radroots_storage::Storage, -} - -impl<'a> Operations<'a> { - pub(crate) const fn new(storage: &'a dyn radroots_storage::Storage) -> Self { - Self { storage } - } - - /// Begins or resumes one idempotent backup plan. - pub async fn begin_backup( - &self, - plan: BackupPlan, - ) -> Result<BackupOperation, radroots_storage::Error> { - StorageReliability::begin_backup(self.storage, plan).await - } - - /// Applies one optimistic backup transition. - pub async fn transition_backup( - &self, - backup_id: BackupId, - expected_revision: ReliabilityRevision, - transition: BackupTransition, - at_unix_ms: u64, - ) -> Result<BackupOperation, radroots_storage::Error> { - StorageReliability::transition_backup( - self.storage, - backup_id, - expected_revision, - transition, - at_unix_ms, - ) - .await - } - - /// Begins or resumes one staged restore plan. - pub async fn begin_restore( - &self, - plan: RestorePlan, - ) -> Result<RestoreOperation, radroots_storage::Error> { - StorageReliability::begin_restore(self.storage, plan).await - } - - /// Applies one optimistic staged restore transition. - pub async fn transition_restore( - &self, - backup_id: BackupId, - expected_revision: ReliabilityRevision, - transition: RestoreTransition, - at_unix_ms: u64, - ) -> Result<RestoreOperation, radroots_storage::Error> { - StorageReliability::transition_restore( - self.storage, - backup_id, - expected_revision, - transition, - at_unix_ms, - ) - .await - } - - /// Returns passive backend status without initiating recovery work. - pub async fn status(&self) -> Result<Status, radroots_storage::Error> { - StorageReliability::status(self.storage).await - } - - /// Runs backend-owned integrity inspection. - pub async fn integrity(&self) -> Result<IntegrityStatus, radroots_storage::Error> { - StorageReliability::integrity(self.storage).await - } -} - -impl std::fmt::Debug for Operations<'_> { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("Operations") - .field("storage", &"<borrowed canonical storage>") - .finish() - } -} - -#[cfg(all(test, feature = "memory"))] -mod tests { - use std::sync::Arc; - - use radroots_storage::{ - backup::{ - BackupFormatVersion, BackupManifest, BackupMember, BackupMemberKind, - BackupSecretPolicy, BackupStage, MemberDigest, MemberVerification, RestoreMemberStatus, - RestoreStage, - }, - event::SourceGeneration, - memory::MemoryStorage, - status::IntegrityHealth, - }; - - use crate::ClientBuilder; - - use super::*; - - fn manifest(backup_id: BackupId) -> BackupManifest { - BackupManifest::new( - BackupFormatVersion::V1, - backup_id, - 1_800_000_000_100, - BackupSecretPolicy::ExcludeProtectedStorage, - vec![ - BackupMember::new( - "runtime/events.bin", - BackupMemberKind::Runtime, - 16, - MemberDigest::new([3; 32]), - ) - .expect("member"), - ], - ) - .expect("manifest") - } - - #[tokio::test] - async fn memory_reliability_preserves_staging_interruption_integrity_and_native_states() { - let storage = Arc::new(MemoryStorage::new( - SourceGeneration::new([8; 32]).expect("generation"), - )); - let client = ClientBuilder::new() - .storage(storage) - .build() - .expect("client"); - let operations = client.storage_operations().expect("operations"); - let backup_id = BackupId::new([9; 16]).expect("backup id"); - let plan = BackupPlan::new( - backup_id, - BackupFormatVersion::V1, - BackupSecretPolicy::ExcludeProtectedStorage, - 1_800_000_000_000, - ) - .expect("plan"); - - drop(operations.begin_backup(plan.clone())); - let planned = operations.begin_backup(plan).await.expect("planned"); - assert_eq!(planned.stage(), BackupStage::Planned); - let captured = operations - .transition_backup( - backup_id, - planned.revision(), - BackupTransition::Captured(manifest(backup_id)), - 1_800_000_000_200, - ) - .await - .expect("captured"); - let verified = operations - .transition_backup( - backup_id, - captured.revision(), - BackupTransition::Verified, - 1_800_000_000_300, - ) - .await - .expect("verified"); - let finalized = operations - .transition_backup( - backup_id, - verified.revision(), - BackupTransition::Finalize, - 1_800_000_000_400, - ) - .await - .expect("finalized"); - assert_eq!(finalized.stage(), BackupStage::Finalized); - - let restore_plan = RestorePlan::new( - manifest(backup_id), - BackupSecretPolicy::ExcludeProtectedStorage, - 1_800_000_001_000, - ) - .expect("restore plan"); - let staging = operations - .begin_restore(restore_plan.clone()) - .await - .expect("staging"); - assert_eq!(staging.stage(), RestoreStage::Staging); - let replayed = operations - .begin_restore(restore_plan) - .await - .expect("resume staging"); - assert_eq!(replayed, staging); - let verifying = operations - .transition_restore( - backup_id, - staging.revision(), - RestoreTransition::Staged, - 1_800_000_001_100, - ) - .await - .expect("verifying"); - let finalizing = operations - .transition_restore( - backup_id, - verifying.revision(), - RestoreTransition::Verified(vec![ - RestoreMemberStatus::new("runtime/events.bin", MemberVerification::Verified) - .expect("member status"), - ]), - 1_800_000_001_200, - ) - .await - .expect("finalizing"); - let restored = operations - .transition_restore( - backup_id, - finalizing.revision(), - RestoreTransition::Finalize, - 1_800_000_001_300, - ) - .await - .expect("restored"); - assert_eq!(restored.stage(), RestoreStage::Finalized); - assert_eq!( - operations.integrity().await.expect("integrity").health(), - IntegrityHealth::Healthy - ); - assert_eq!( - operations - .status() - .await - .expect("status") - .integrity() - .health(), - IntegrityHealth::Healthy - ); - } -} diff --git a/crates/sdk/src/sync.rs b/crates/sdk/src/sync.rs @@ -1,520 +0,0 @@ -//! Client-scoped access to the canonical synchronization engine. - -#[cfg(feature = "sync")] -use std::sync::Arc; - -#[cfg(feature = "sync")] -use radroots_storage::{authored_delivery::AuthoredDeliveryPlan, projection::ProjectionId}; -#[cfg(feature = "sync")] -use radroots_sync::{ - Engine, PullReceipt, PullRequest, PushRequest, SyncStatus, - ingest::{AdmissionPolicy, IngestBatchReceipt, IngestReceipt}, - policy::Error, - projection::{Reducer, RefreshReceipt, RefreshRequest}, - push::{ - AdmissionRunReceipt, DeliveryExecutionReceipt, PushPreparation, PushStatus, - SigningRunReceipt, - }, -}; -#[cfg(feature = "sync")] -use radroots_transport::source::ObservedEvent; - -/// Explicit host policy for SDK-composed synchronization. -/// -/// Selecting this policy opts into the system clock and operating-system -/// randomness for operation IDs. It creates no executor, timer, or worker. -#[cfg(feature = "sync")] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct HostPolicy { - deadlines: radroots_sync::policy::DeadlinePolicy, -} - -#[cfg(feature = "sync")] -impl HostPolicy { - /// Creates a bounded policy for pull, signing, and delivery calls. - pub fn new( - pull_timeout_ms: u64, - sign_timeout_ms: u64, - delivery_timeout_ms: u64, - ) -> Result<Self, radroots_sync::policy::Error> { - Ok(Self { - deadlines: radroots_sync::policy::DeadlinePolicy::new( - pull_timeout_ms, - sign_timeout_ms, - delivery_timeout_ms, - )?, - }) - } - - /// Returns the ordinary bounded native-host policy. - #[must_use] - pub fn standard() -> Self { - Self::new(30_000, 30_000, 30_000).expect("static host deadlines are valid") - } - - pub(crate) fn composition( - self, - ) -> ( - Arc<dyn radroots_sync::policy::Clock>, - Arc<dyn radroots_sync::policy::IdSource>, - radroots_sync::policy::DeadlinePolicy, - ) { - (Arc::new(SystemClock), Arc::new(RandomIds), self.deadlines) - } -} - -#[cfg(feature = "sync")] -impl Default for HostPolicy { - fn default() -> Self { - Self::standard() - } -} - -#[cfg(feature = "sync")] -struct SystemClock; - -#[cfg(feature = "sync")] -impl radroots_sync::policy::Clock for SystemClock { - fn now_unix_ms(&self) -> Result<u64, radroots_sync::policy::Error> { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok() - .and_then(|duration| u64::try_from(duration.as_millis()).ok()) - .filter(|value| *value != 0) - .ok_or(radroots_sync::policy::Error::ClockUnavailable) - } -} - -#[cfg(feature = "sync")] -struct RandomIds; - -#[cfg(feature = "sync")] -impl radroots_sync::policy::IdSource for RandomIds { - fn next_id( - &self, - _operation: radroots_sync::policy::OperationKind, - ) -> Result<radroots_sync::policy::SyncId, radroots_sync::policy::Error> { - radroots_sync::policy::SyncId::new(*uuid::Uuid::new_v4().as_bytes()) - } -} - -/// Borrowed client operations over one explicitly composed sync engine. -/// -/// This type owns no scheduling, retries, status strings, outbox state, or -/// projection state. Every method delegates once to the canonical engine and -/// returns its native receipt or error. -#[cfg(feature = "sync")] -#[derive(Clone, Copy)] -pub struct Operations<'a> { - engine: &'a Engine, -} - -#[cfg(feature = "sync")] -impl<'a> Operations<'a> { - pub(crate) const fn new(engine: &'a Engine) -> Self { - Self { engine } - } - - /// Runs one caller-bounded pull and canonical ingest sequence. - pub async fn pull( - &self, - request: PullRequest, - admission: &dyn AdmissionPolicy, - ) -> Result<PullReceipt, Error> { - self.engine.pull(request, admission).await - } - - /// Verifies and atomically ingests one observed event. - pub async fn ingest( - &self, - observed: ObservedEvent, - admission: &dyn AdmissionPolicy, - ) -> Result<IngestReceipt, Error> { - self.engine.ingest(observed, admission).await - } - - /// Ingests a bounded caller-owned batch while preserving partial outcomes. - pub async fn ingest_batch( - &self, - observed: Vec<ObservedEvent>, - admission: &dyn AdmissionPolicy, - ) -> IngestBatchReceipt { - self.engine.ingest_batch(observed, admission).await - } - - /// Runs one bounded projection refresh through its owning reducer. - pub async fn refresh_projection( - &self, - request: RefreshRequest, - reducer: &dyn Reducer, - ) -> Result<RefreshReceipt, Error> { - self.engine.refresh_projection(request, reducer).await - } - - /// Atomically persists one complete authored operation before any side effect. - pub async fn prepare_push(&self, request: PushRequest) -> Result<PushPreparation, Error> { - self.engine.prepare_push(request).await - } - - /// Returns the complete durable state of one prepared push operation. - pub async fn push_status( - &self, - operation_id: radroots_sync::policy::SyncId, - ) -> Result<Option<PushStatus>, Error> { - self.engine.push_status(operation_id).await - } - - /// Runs one bounded signing phase for an exactly prepared operation. - pub async fn sign_prepared(&self, request: PushRequest) -> Result<SigningRunReceipt, Error> { - self.engine.sign_prepared(request).await - } - - /// Runs one bounded local-admission phase for a durably signed artifact. - pub async fn admit_signed( - &self, - operation_id: radroots_sync::policy::SyncId, - ) -> Result<AdmissionRunReceipt, Error> { - self.engine.admit_signed(operation_id).await - } - - /// Prepares, signs, and locally admits one authored operation. - /// - /// Preparation commits the complete recoverable intent before the signer - /// can be invoked. Delivery remains an explicit caller-driven phase. - pub async fn submit_push(&self, request: PushRequest) -> Result<PushStatus, Error> { - let operation_id = request.operation_id(); - self.sign_prepared(request).await?; - self.admit_signed(operation_id).await?; - self.push_status(operation_id) - .await? - .ok_or(Error::StorageFailed) - } - - /// Runs one bounded delivery attempt for one durable authored plan. - pub async fn deliver_push( - &self, - operation_id: radroots_sync::policy::SyncId, - ) -> Result<DeliveryExecutionReceipt, Error> { - self.engine.deliver_push(operation_id).await - } - - /// Returns the native passive sync status without starting recovery work. - pub async fn status(&self, projections: &[ProjectionId]) -> Result<SyncStatus, Error> { - self.engine.status(projections).await - } - - /// Returns the native host scheduling decision for one durable plan. - pub fn retry_decision( - &self, - plan: &AuthoredDeliveryPlan, - now_unix_ms: u64, - ) -> Result<radroots_protocol::runtime::v1::SyncRetryDecision, Error> { - self.engine.retry_decision(plan, now_unix_ms) - } -} - -#[cfg(feature = "sync")] -impl std::fmt::Debug for Operations<'_> { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("Operations") - .field("engine", &"<borrowed canonical engine>") - .finish() - } -} - -#[cfg(all(test, feature = "sync", feature = "memory"))] -mod tests { - use std::sync::{ - Arc, - atomic::{AtomicU8, Ordering}, - }; - - use radroots_event::{ - GenericEventDraft, SignedEvent, contract::AuthorRole, wire::Nip01EventWire, - }; - use radroots_event_codec::authoring::AuthoredEventPlan; - use radroots_identity::PublicKey; - use radroots_protocol::runtime::v1::SyncCapabilityState; - use radroots_signing::{Actor, actor::ActorSource, request::CancellationPolicy}; - use radroots_storage::{ - event::{SourceGeneration, StoredVisibleEvent}, - journal::IdempotencyKey, - memory::MemoryStorage, - projection::{ - ProjectionGeneration, ProjectionId, RawSourceDigest, RebuildFailure, RebuildTicketId, - }, - }; - use radroots_sync::{ - Engine, PullRequest, PushRequest, - ingest::RegistryPolicy, - policy::{Clock, DeadlinePolicy, Error, IdSource, OperationKind, SyncId, SyncStorage}, - projection::{Reducer, ReducerError, RefreshRequest, RefreshState}, - pull::PullTermination, - }; - use radroots_transport::{ - Error as TransportError, EventSource, FetchPage, FetchRequest, SourceStatus, Target, - TargetSet, TransportId, - capability::{Availability, Maturity, SourceCapabilities}, - policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, - source::{EventProvenance, NextPage, ObservedEvent}, - }; - - use crate::{ClientBuilder, error::ErrorKind}; - - const PUBLIC_KEY: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - - struct FixedClock; - struct SequenceIds(AtomicU8); - struct CancelledSource; - - impl Clock for FixedClock { - fn now_unix_ms(&self) -> Result<u64, Error> { - Ok(1_700_000_000_000) - } - } - - impl IdSource for SequenceIds { - fn next_id(&self, _operation: OperationKind) -> Result<SyncId, Error> { - let next = self.0.fetch_add(1, Ordering::Relaxed); - SyncId::new([next; 16]) - } - } - - impl EventSource for CancelledSource { - fn status( - &self, - ) -> radroots_transport::BoxFuture<'_, Result<SourceStatus, TransportError>> { - Box::pin(async { - Ok(SourceStatus::new( - TransportId::NOSTR, - true, - Maturity::Stable, - Availability::Available, - SourceCapabilities::FETCH, - "ready", - )) - }) - } - - fn fetch( - &self, - request: FetchRequest, - ) -> radroots_transport::BoxFuture<'_, Result<FetchPage, TransportError>> { - Box::pin(async move { - FetchPage::for_request( - &request, - Vec::new(), - Vec::new(), - NextPage::Cancelled { resume_from: None }, - ) - }) - } - } - - struct EmptyReducer { - id: ProjectionId, - generation: ProjectionGeneration, - } - - impl Reducer for EmptyReducer { - fn projection_id(&self) -> &ProjectionId { - &self.id - } - - fn generation(&self) -> ProjectionGeneration { - self.generation - } - - fn begin_rebuild( - &self, - _ticket_id: RebuildTicketId, - _source_generation: SourceGeneration, - _source_digest: RawSourceDigest, - ) -> Result<(), ReducerError> { - Ok(()) - } - - fn reduce( - &self, - events: &[StoredVisibleEvent], - prior_projected_rows: u64, - _rebuild_ticket: Option<RebuildTicketId>, - ) -> Result<u64, ReducerError> { - assert!(events.is_empty()); - Ok(prior_projected_rows) - } - - fn abort_rebuild( - &self, - _ticket_id: RebuildTicketId, - _failure: RebuildFailure, - ) -> Result<(), ReducerError> { - Ok(()) - } - } - - fn target() -> Target { - Target::nostr_relay("wss://sync.example").expect("target") - } - - fn engine(storage: Arc<MemoryStorage>) -> Engine { - let capability: Arc<dyn SyncStorage> = storage; - Engine::builder( - capability, - Arc::new(FixedClock), - Arc::new(SequenceIds(AtomicU8::new(1))), - DeadlinePolicy::new(1_000, 1_000, 1_000).expect("deadlines"), - ) - .source(Arc::new(CancelledSource)) - .build() - .expect("engine") - } - - fn invalid_observation() -> ObservedEvent { - let mut wire = Nip01EventWire { - id: "0".repeat(64), - pubkey: PUBLIC_KEY.to_owned(), - created_at: 1_800_000_100, - kind: 0, - tags: vec![], - content: "invalid signature fixture".to_owned(), - sig: "42".repeat(64), - extra: Default::default(), - }; - wire.id = wire.computed_event_id().expect("event id").to_hex(); - let raw = format!( - "{{\"id\":\"{}\",\"pubkey\":\"{}\",\"created_at\":{},\"kind\":0,\"tags\":[],\"content\":\"invalid signature fixture\",\"sig\":\"{}\"}}", - wire.id, wire.pubkey, wire.created_at, wire.sig - ); - let event = SignedEvent::from_wire_verified_id(wire, raw).expect("signed event"); - let target = target(); - let provenance = EventProvenance::new( - TransportId::NOSTR, - target.fingerprint().clone(), - 1_700_000_000_000, - ) - .expect("provenance"); - ObservedEvent::new(event, provenance) - } - - fn push_request() -> PushRequest { - let actor = Actor::new( - PublicKey::from_hex(PUBLIC_KEY).expect("public key"), - ActorSource::ExplicitPublicKey, - [AuthorRole::Any], - ) - .expect("actor"); - let plan = AuthoredEventPlan::from_generic( - GenericEventDraft::new( - "radroots.social.geochat.v1", - 20_000, - 1_700_000_000, - Vec::new(), - "content", - PUBLIC_KEY, - ) - .expect("draft"), - ) - .expect("authored plan"); - PushRequest::new( - SyncId::new([8; 16]).expect("operation id"), - IdempotencyKey::parse("sdk-sync-wrapper").expect("idempotency key"), - actor, - plan, - TargetSet::new(vec![target()]).expect("targets"), - SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()), - 1_700_000_001_000, - CancellationPolicy::PreservePublishedRequest, - ) - .expect("push request") - } - - #[tokio::test] - async fn operations_delegate_native_pull_ingest_projection_push_delivery_and_status() { - let storage = Arc::new(MemoryStorage::new( - SourceGeneration::new([3; 32]).expect("generation"), - )); - let client = ClientBuilder::new() - .storage(storage.clone()) - .sync_engine(engine(storage)) - .build() - .expect("client"); - let operations = client.sync().expect("open client").expect("sync"); - let policy = RegistryPolicy::verified(); - - let pull = operations - .pull( - PullRequest::new(TargetSet::new(vec![target()]).expect("targets"), 10, 1) - .expect("pull request"), - &policy, - ) - .await - .expect("pull"); - assert_eq!(pull.termination(), PullTermination::Cancelled); - - let ingest = operations - .ingest_batch(vec![invalid_observation(), invalid_observation()], &policy) - .await; - assert_eq!(ingest.accepted(), 0); - assert_eq!(ingest.rejected(), 2); - assert!( - ingest - .outcomes() - .iter() - .all(|outcome| matches!(outcome, Err(Error::VerificationFailed))) - ); - - let projection_id = ProjectionId::parse("sdk.sync.test").expect("projection id"); - let generation = ProjectionGeneration::new([5; 32]).expect("generation"); - let projection = operations - .refresh_projection( - RefreshRequest::new(projection_id.clone(), generation, 10, 1) - .expect("refresh request"), - &EmptyReducer { - id: projection_id.clone(), - generation, - }, - ) - .await - .expect("projection"); - assert_eq!(projection.state(), RefreshState::Complete); - - let push = push_request(); - let operation_id = push.operation_id(); - let preparation = operations - .prepare_push(push.clone()) - .await - .expect("durable preparation"); - assert!(!preparation.is_replay()); - assert_eq!( - operations.sign_prepared(push).await, - Err(Error::MissingSigner) - ); - assert!( - operations - .push_status(operation_id) - .await - .expect("push status") - .is_some() - ); - assert_eq!( - operations.deliver_push(operation_id).await, - Err(Error::MissingSink) - ); - - let status = operations - .status(std::slice::from_ref(&projection_id)) - .await - .expect("status"); - assert_eq!(status.source().state(), SyncCapabilityState::Available); - assert_eq!(status.sink().state(), SyncCapabilityState::Unsupported); - assert_eq!(status.projections().len(), 1); - - client.close().await.expect("close"); - assert_eq!( - client.sync().expect_err("closed").kind(), - ErrorKind::ClientClosed - ); - } -} diff --git a/crates/sdk/src/trade.rs b/crates/sdk/src/trade.rs @@ -1,868 +0,0 @@ -//! Canonical trade planning, commit, query, and private-evidence operations. - -use std::{error, fmt}; - -use radroots_event::{ - GenericEventDraft, - contract::AuthorRole, - trade::{TradeMutationEnvelopeV1, TradeProtocolError, canonical_trade_mutation_content}, -}; -use radroots_event_codec::{ - authoring::AuthoredEventPlan, encode::EventEncodeError, - encode::trade::trade_mutation_event_build, -}; -use radroots_signing::Actor; -use radroots_trade::{Projection, ReductionInput, WorkflowPlan, reducer::reduce_trade_records}; - -/// Pure inputs for one frozen trade command. -#[derive(Clone, Debug)] -pub struct PrepareRequest { - actor: Actor, - mutation: TradeMutationEnvelopeV1, -} - -impl PrepareRequest { - /// Creates explicit inputs for any canonical proposal, revision, decision, - /// cancellation, or resumable mutation. - #[must_use] - pub const fn new(actor: Actor, mutation: TradeMutationEnvelopeV1) -> Self { - Self { actor, mutation } - } -} - -/// Frozen, replay-stable trade workflow plan. -#[derive(Clone, Debug)] -pub struct Plan { - actor: Actor, - workflow: WorkflowPlan, - authored_event: AuthoredEventPlan, -} - -impl Plan { - /// Returns the exact authorized actor carried into signing. - #[must_use] - pub const fn actor(&self) -> &Actor { - &self.actor - } - - /// Returns the lower-owned validated workflow and required host actions. - pub const fn workflow(&self) -> &WorkflowPlan { - &self.workflow - } - - /// Returns the immutable canonical authored event plan. - #[must_use] - pub const fn authored_event(&self) -> &AuthoredEventPlan { - &self.authored_event - } -} - -/// Trade planning failure stage. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum PrepareErrorKind { - /// The actor identity or role cannot author the supplied mutation. - UnauthorizedActor, - /// Event-domain canonicalization rejected the mutation. - CanonicalMutation, - /// The lower trade workflow rejected the canonical mutation. - Workflow, - /// The canonical event codec rejected the mutation. - Encode, - /// The canonical event draft rejected the encoded mutation. - Draft, -} - -/// One secret-safe trade planning failure retaining its lower source. -pub struct PrepareError { - kind: PrepareErrorKind, - source: Option<Box<dyn error::Error + Send + Sync>>, -} - -impl PrepareError { - /// Returns the stable client-level planning stage. - #[must_use] - pub const fn kind(&self) -> PrepareErrorKind { - self.kind - } - - fn unauthorized_actor() -> Self { - Self { - kind: PrepareErrorKind::UnauthorizedActor, - source: None, - } - } - - fn canonical(source: TradeProtocolError) -> Self { - Self::with_source(PrepareErrorKind::CanonicalMutation, source) - } - - fn workflow(source: radroots_trade::Error) -> Self { - Self::with_source(PrepareErrorKind::Workflow, source) - } - - fn encode(source: EventEncodeError) -> Self { - Self::with_source(PrepareErrorKind::Encode, source) - } - - fn draft(source: radroots_event::draft::DraftError) -> Self { - Self::with_source(PrepareErrorKind::Draft, source) - } - - fn with_source( - kind: PrepareErrorKind, - source: impl error::Error + Send + Sync + 'static, - ) -> Self { - Self { - kind, - source: Some(Box::new(source)), - } - } -} - -impl fmt::Display for PrepareError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self.kind { - PrepareErrorKind::UnauthorizedActor => "trade actor is not authorized", - PrepareErrorKind::CanonicalMutation => "trade mutation is not canonical", - PrepareErrorKind::Workflow => "trade workflow is invalid", - PrepareErrorKind::Encode => "trade event encoding failed", - PrepareErrorKind::Draft => "trade event draft is invalid", - }) - } -} - -impl fmt::Debug for PrepareError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("PrepareError") - .field("kind", &self.kind) - .finish_non_exhaustive() - } -} - -impl error::Error for PrepareError { - fn source(&self) -> Option<&(dyn error::Error + 'static)> { - self.source - .as_deref() - .map(|source| source as &(dyn error::Error + 'static)) - } -} - -/// Canonicalizes, authorizes, validates, and freezes one trade command. -/// -/// This operation performs no signing, persistence, private-artifact access, -/// scheduling, or delivery. Every proposal, revision, decision, cancellation, -/// and resumed command uses the same lower-owned `TradeId` and workflow law. -pub fn prepare(request: PrepareRequest) -> Result<Plan, PrepareError> { - let canonical = canonical_trade_mutation_content(request.mutation) - .map_err(PrepareError::canonical)? - .envelope; - let required_role = match ( - canonical.author_pubkey == canonical.buyer_pubkey, - canonical.author_pubkey == canonical.seller_pubkey, - ) { - (true, _) => AuthorRole::Buyer, - (false, true) => AuthorRole::Seller, - (false, false) => return Err(PrepareError::unauthorized_actor()), - }; - if ( - request.actor.public_key() == canonical.author_pubkey, - request.actor.satisfies(required_role), - ) != (true, true) - { - return Err(PrepareError::unauthorized_actor()); - } - let workflow = WorkflowPlan::prepare(canonical.clone()).map_err(PrepareError::workflow)?; - let parts = trade_mutation_event_build(canonical.clone()).map_err(PrepareError::encode)?; - let authored_event = AuthoredEventPlan::from_generic( - GenericEventDraft::new( - canonical.contract_id.clone(), - parts.kind, - canonical.authored_at_unix_s, - parts.tags, - parts.content, - canonical.author_pubkey.to_hex(), - ) - .map_err(PrepareError::draft)?, - ) - .map_err(PrepareError::draft)?; - Ok(Plan { - actor: request.actor, - workflow, - authored_event, - }) -} - -/// Deterministically reduces caller-supplied canonical evidence. -#[must_use] -pub fn project(input: ReductionInput) -> Projection { - reduce_trade_records(input) -} - -#[cfg(feature = "sync")] -use radroots_signing::request::CancellationPolicy; -#[cfg(feature = "sync")] -use radroots_storage::{ - event::{EventPage, EventQuery, StoredVisibleEvent}, - journal::IdempotencyKey, - private_artifact::{PrivateArtifactId, PrivateArtifactMetadata, PrivateArtifactStage}, -}; -#[cfg(feature = "sync")] -use radroots_sync::{ - policy::{Error as SyncError, SyncId}, - push::PushStatus, -}; - -/// Explicit commit inputs for one prepared trade command. -#[cfg(feature = "sync")] -#[derive(Clone, Debug)] -pub struct EnqueueRequest { - operation_id: SyncId, - idempotency_key: IdempotencyKey, - plan: Plan, - profile: crate::transport::Profile, - delivery_deadline_unix_ms: u64, - cancellation: CancellationPolicy, -} - -#[cfg(feature = "sync")] -impl EnqueueRequest { - /// Creates a command request whose transport selection has no fallback. - #[must_use] - pub const fn new( - operation_id: SyncId, - idempotency_key: IdempotencyKey, - plan: Plan, - profile: crate::transport::Profile, - delivery_deadline_unix_ms: u64, - cancellation: CancellationPolicy, - ) -> Self { - Self { - operation_id, - idempotency_key, - plan, - profile, - delivery_deadline_unix_ms, - cancellation, - } - } -} - -/// Private-term metadata verification failure. -#[cfg(feature = "sync")] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum PrivateTermsError { - /// Canonical storage failed to inspect the requested metadata. - Storage, - /// The workflow does not require private terms. - NotRequired, - /// Metadata is absent, inactive, or does not match the public commitment. - EvidenceMismatch, -} - -/// Borrowed trade operations over canonical storage and sync capabilities. -#[cfg(feature = "sync")] -#[derive(Clone, Copy)] -pub struct Operations<'a> { - storage: &'a dyn radroots_storage::Storage, - sync: crate::sync::Operations<'a>, -} - -#[cfg(feature = "sync")] -impl fmt::Debug for Operations<'_> { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("Operations") - .field("storage", &"<borrowed canonical storage>") - .field("sync", &self.sync) - .finish() - } -} - -#[cfg(feature = "sync")] -impl<'a> Operations<'a> { - pub(crate) const fn new( - storage: &'a dyn radroots_storage::Storage, - sync: crate::sync::Operations<'a>, - ) -> Self { - Self { storage, sync } - } - - /// Durably prepares, signs, and locally admits a command. Reusing the same - /// idempotency input is the canonical resume/replay operation. - pub async fn enqueue(&self, request: EnqueueRequest) -> Result<PushStatus, SyncError> { - let targets = request - .profile - .targets() - .cloned() - .ok_or(SyncError::InvalidPushRequest)?; - let satisfaction = request - .profile - .satisfaction() - .cloned() - .ok_or(SyncError::InvalidPushRequest)?; - self.sync - .submit_push(radroots_sync::PushRequest::new( - request.operation_id, - request.idempotency_key, - request.plan.actor, - request.plan.authored_event, - targets, - satisfaction, - request.delivery_deadline_unix_ms, - request.cancellation, - )?) - .await - } - - /// Returns one native, bounded, generation-bound page of visible evidence. - pub async fn query_visible( - &self, - query: EventQuery, - ) -> Result<EventPage<StoredVisibleEvent>, radroots_storage::Error> { - radroots_storage::event::EventStore::query_visible(self.storage, query).await - } - - /// Returns native private-artifact metadata without reading secret material. - pub async fn private_artifact( - &self, - artifact_id: PrivateArtifactId, - ) -> Result<Option<PrivateArtifactMetadata>, radroots_storage::Error> { - radroots_storage::private_artifact::PrivateArtifactStore::metadata( - self.storage, - artifact_id, - ) - .await - } - - /// Verifies that canonical active metadata matches a plan's public schema - /// and ciphertext commitment. Plaintext, ciphertext, and keys never cross - /// the SDK boundary. - pub async fn verify_private_terms( - &self, - plan: &Plan, - artifact_id: PrivateArtifactId, - ) -> Result<PrivateArtifactMetadata, PrivateTermsError> { - let expected = plan - .workflow - .private_terms() - .ok_or(PrivateTermsError::NotRequired)?; - let metadata = self - .private_artifact(artifact_id) - .await - .map_err(|_| PrivateTermsError::Storage)? - .ok_or(PrivateTermsError::EvidenceMismatch)?; - let commitment = hex_lower(metadata.commitment().as_bytes()); - let evidence_matches = [ - metadata.stage() == PrivateArtifactStage::Active, - metadata.schema_id().as_str() == expected.schema_id(), - commitment == expected.ciphertext_commitment(), - ]; - if evidence_matches != [true; 3] { - return Err(PrivateTermsError::EvidenceMismatch); - } - Ok(metadata) - } -} - -#[cfg(feature = "sync")] -fn hex_lower(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut encoded = String::with_capacity(bytes.len() * 2); - for byte in bytes { - encoded.push(char::from(HEX[usize::from(byte >> 4)])); - encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); - } - encoded -} - -#[cfg(test)] -mod tests { - use radroots_event::{ - id::{ClassifiedListingAddress, DTag, EventId, InventoryBinId, MutationId, TradeId}, - trade::{ - FulfillmentProfileV1, RADROOTS_TRADE_CANCELLATION_CONTRACT_ID, - RADROOTS_TRADE_DECISION_CONTRACT_ID, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, - RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID, - RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, - TradeCancellationProfileV1, TradeCandidateLineV1, TradeCandidateTermsV1, - TradeDecisionV1, TradeEconomicAdjustmentV1, TradeEconomicsProfileV1, - TradeLineTombstoneV1, TradeMutationBodyV1, TradeMutationKindV1, TradePrivateTermsRefV1, - }, - }; - use radroots_identity::PublicKey; - use radroots_signing::actor::ActorSource; - use radroots_trade::workflow::WorkflowAction; - - use super::*; - - const BUYER: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - const SELLER: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; - - fn pubkey(value: &str) -> PublicKey { - PublicKey::from_hex(value).expect("public key") - } - - fn actor(public_key: &str, role: AuthorRole) -> Actor { - Actor::from_public_key_hex(public_key, ActorSource::ExplicitPublicKey, [role]) - .expect("actor") - } - - fn mutation_id(marker: char) -> MutationId { - MutationId::parse(std::iter::repeat_n(marker, 64).collect::<String>()).expect("mutation id") - } - - fn candidate(suffix: &str) -> TradeCandidateTermsV1 { - TradeCandidateTermsV1 { - candidate_id: None, - schema_version: RADROOTS_TRADE_SCHEMA_VERSION, - base_candidate_id: None, - supersession_intent: None, - buyer_pubkey: pubkey(BUYER), - seller_pubkey: pubkey(SELLER), - farm_id: DTag::parse("farm-1").expect("farm id"), - lines: vec![TradeCandidateLineV1 { - line_id: DTag::parse(format!("line-{suffix}")).expect("line id"), - listing_addr: ClassifiedListingAddress::parse(format!( - "30402:{SELLER}:listing-{suffix}" - )) - .expect("listing address"), - listing_event_id: EventId::parse("cc".repeat(32)).expect("event id"), - listing_snapshot_sha256: "dd".repeat(32), - product_id: format!("carrots-{suffix}"), - option_id: None, - bin_id: InventoryBinId::parse(format!("bin-{suffix}")).expect("bin id"), - quantity_mantissa: "2".into(), - quantity_scale: 0, - unit_code: "count".into(), - unit_profile: "mvp-count".into(), - unit_price_mantissa: "500".into(), - currency_code: "USD".into(), - line_subtotal_mantissa: "1000".into(), - replaces_line_id: None, - }], - line_tombstones: Vec::<TradeLineTombstoneV1>::new(), - economics: TradeEconomicsProfileV1 { - profile_id: "mvp-fixed".into(), - currency_code: "USD".into(), - currency_exponent: 2, - rounding_profile: "half-even".into(), - subtotal_mantissa: "1000".into(), - discount_total_mantissa: "0".into(), - adjustment_total_mantissa: "0".into(), - total_mantissa: "1000".into(), - adjustments: Vec::<TradeEconomicAdjustmentV1>::new(), - }, - fulfillment: FulfillmentProfileV1 { - profile_id: "market-pickup".into(), - method: "pickup".into(), - starts_at_unix_s: 1_800_000_000, - ends_at_unix_s: 1_800_003_600, - timezone: "America/New_York".into(), - utc_offset_seconds: -18_000, - fold: 0, - location_class: "farmstand".into(), - requires_private_terms: true, - }, - cancellation: TradeCancellationProfileV1 { - profile_id: "buyer-pre-agreement".into(), - buyer_pre_agreement: true, - post_agreement_cutoff_unix_s: None, - }, - private_terms: Some(TradePrivateTermsRefV1 { - artifact_id: "artifact-1".into(), - schema_id: "radroots.private.fulfillment.v1".into(), - ciphertext_commitment: "ee".repeat(32), - required_acknowledgement: true, - }), - proposal_expires_at_unix_s: 1_800_010_000, - } - } - - fn envelope(contract_id: &str, body: TradeMutationBodyV1) -> TradeMutationEnvelopeV1 { - let initial = body.mutation_kind() == TradeMutationKindV1::Proposal; - TradeMutationEnvelopeV1 { - mutation_id: None, - contract_id: contract_id.into(), - schema_version: RADROOTS_TRADE_SCHEMA_VERSION, - trade_id: TradeId::parse("11".repeat(16)).expect("trade id"), - root_mutation_id: (!initial).then(|| mutation_id('1')), - buyer_pubkey: pubkey(BUYER), - seller_pubkey: pubkey(SELLER), - farm_id: DTag::parse("farm-1").expect("farm id"), - parent_mutation_ids: if initial { - vec![] - } else { - vec![mutation_id('1')] - }, - author_pubkey: pubkey(BUYER), - counterparty_pubkey: pubkey(SELLER), - authored_at_unix_s: 1_800_000_000, - body, - } - } - - fn all_commands() -> Vec<TradeMutationEnvelopeV1> { - let proposal = canonical_trade_mutation_content(envelope( - RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, - TradeMutationBodyV1::Proposal { - candidate: candidate("1"), - }, - )) - .expect("proposal") - .envelope; - let proposal_id = proposal.mutation_id.expect("proposal id"); - let candidate_id = match &proposal.body { - TradeMutationBodyV1::Proposal { candidate } => { - candidate.candidate_id.expect("candidate id") - } - _ => unreachable!(), - }; - vec![ - proposal, - envelope( - RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, - TradeMutationBodyV1::RevisionProposal { - candidate: candidate("2"), - }, - ), - envelope( - RADROOTS_TRADE_DECISION_CONTRACT_ID, - TradeMutationBodyV1::Decision { - proposal_mutation_id: proposal_id, - candidate_id, - decision: TradeDecisionV1::Declined { - reason: "unavailable".into(), - }, - }, - ), - envelope( - RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID, - TradeMutationBodyV1::RevisionDecision { - proposal_mutation_id: mutation_id('2'), - candidate_id, - decision: TradeDecisionV1::Declined { - reason: "unavailable".into(), - }, - }, - ), - envelope( - RADROOTS_TRADE_CANCELLATION_CONTRACT_ID, - TradeMutationBodyV1::Cancellation { - target_candidate_id: Some(candidate_id), - target_claim_mutation_id: None, - reason: "cancelled".into(), - }, - ), - ] - } - - #[test] - fn prepare_covers_every_command_with_one_trade_identity_and_private_plan() { - let plans = all_commands() - .into_iter() - .map(|mutation| { - prepare(PrepareRequest::new( - actor(BUYER, AuthorRole::Buyer), - mutation, - )) - }) - .collect::<Result<Vec<_>, _>>() - .expect("plans"); - - assert_eq!( - plans - .iter() - .map(|plan| plan.workflow().kind()) - .collect::<Vec<_>>(), - [ - TradeMutationKindV1::Proposal, - TradeMutationKindV1::RevisionProposal, - TradeMutationKindV1::Decision, - TradeMutationKindV1::RevisionDecision, - TradeMutationKindV1::Cancellation, - ] - ); - assert!( - plans - .iter() - .all(|plan| plan.workflow().trade_id() == plans[0].workflow().trade_id()) - ); - assert_eq!( - plans[0].workflow().required_actions()[0], - WorkflowAction::VerifyPrivateTerms - ); - assert_eq!( - plans[0] - .workflow() - .private_terms() - .expect("private terms") - .artifact_id(), - "artifact-1" - ); - for plan in plans { - assert_eq!( - plan.authored_event() - .body() - .contract() - .contract_id() - .as_str(), - plan.workflow().kind().contract_id() - ); - assert_eq!( - plan.authored_event().body().kind(), - plan.workflow().kind().nostr_kind() - ); - } - } - - #[test] - fn prepare_rejects_wrong_identity_role_and_invalid_protocol_once() { - let mutation = all_commands().remove(0); - let wrong_role = prepare(PrepareRequest::new( - actor(BUYER, AuthorRole::Seller), - mutation.clone(), - )) - .expect_err("wrong role"); - assert_eq!(wrong_role.kind(), PrepareErrorKind::UnauthorizedActor); - assert!(std::error::Error::source(&wrong_role).is_none()); - - let wrong_identity = prepare(PrepareRequest::new( - actor(SELLER, AuthorRole::Buyer), - mutation, - )) - .expect_err("wrong identity"); - assert_eq!(wrong_identity.kind(), PrepareErrorKind::UnauthorizedActor); - - let mut invalid = all_commands().remove(0); - invalid.contract_id = "radroots.trade.cancellation.v1".into(); - let canonical = prepare(PrepareRequest::new( - actor(BUYER, AuthorRole::Buyer), - invalid, - )) - .expect_err("invalid contract"); - assert_eq!(canonical.kind(), PrepareErrorKind::CanonicalMutation); - assert!(std::error::Error::source(&canonical).is_some()); - assert!(!format!("{canonical:?}").contains("artifact-1")); - - let mut seller_authored = all_commands().remove(0); - seller_authored.mutation_id = None; - seller_authored.author_pubkey = pubkey(SELLER); - seller_authored.counterparty_pubkey = pubkey(BUYER); - let seller_plan = prepare(PrepareRequest::new( - actor(SELLER, AuthorRole::Seller), - seller_authored, - )) - .expect("seller-authored command"); - assert_eq!( - seller_plan.workflow().trade_id(), - &TradeId::parse("11".repeat(16)).unwrap() - ); - - let outsider = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let mut unauthorized = all_commands().remove(0); - unauthorized.mutation_id = None; - unauthorized.author_pubkey = pubkey(outsider); - assert_eq!( - prepare(PrepareRequest::new( - actor(outsider, AuthorRole::Any), - unauthorized, - )) - .expect_err("author must be a governed party") - .kind(), - PrepareErrorKind::UnauthorizedActor - ); - } - - #[test] - fn query_projection_returns_the_lower_projection_and_conflict_evidence_types() { - let trade_id = TradeId::parse("22".repeat(16)).expect("trade id"); - let projection = project(ReductionInput::new(trade_id)); - assert_eq!(projection.trade_id(), &trade_id); - assert!(projection.candidate_heads().is_empty()); - assert!(!projection.projection_digest().is_empty()); - } - - #[cfg(all(feature = "sync", feature = "memory", feature = "local-signing"))] - mod operations { - use std::sync::{ - Arc, - atomic::{AtomicU8, Ordering}, - }; - - use radroots_nostr::key::SecretKey; - use radroots_signing::request::CancellationPolicy; - use radroots_storage::{ - Outbox, - event::{EventQuery, EventQueryBounds, SourceGeneration}, - journal::IdempotencyKey, - memory::MemoryStorage, - private_artifact::{ - ArtifactCommitment, ArtifactKind, ArtifactSchemaId, DurableSecretReference, - PrivateArtifactId, PrivateArtifactMetadata, PrivateArtifactStore, RetentionPolicy, - }, - }; - use radroots_sync::{ - Engine, - policy::{Clock, DeadlinePolicy, Error, IdSource, OperationKind, SyncId, SyncStorage}, - }; - use radroots_transport::{ - DeliveryReceipt, DeliveryRequest, Error as TransportError, EventSink, SinkFailure, - SinkStatus, Target, TargetSet, TransportId, - capability::{Availability, Maturity, SinkCapabilities}, - outcome::Retryability, - policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, - }; - - use super::*; - use crate::{ClientBuilder, transport::Profile}; - - const BUYER_SECRET: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; - - struct HostClock; - struct SequenceIds(AtomicU8); - struct NoopSink; - - impl Clock for HostClock { - fn now_unix_ms(&self) -> Result<u64, Error> { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok() - .and_then(|duration| u64::try_from(duration.as_millis()).ok()) - .filter(|value| *value != 0) - .ok_or(Error::ClockUnavailable) - } - } - impl IdSource for SequenceIds { - fn next_id(&self, _operation: OperationKind) -> Result<SyncId, Error> { - SyncId::new([self.0.fetch_add(1, Ordering::Relaxed); 16]) - } - } - impl EventSink for NoopSink { - fn status( - &self, - ) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, TransportError>> { - Box::pin(async { - Ok(SinkStatus::new( - TransportId::NOSTR, - true, - Maturity::Stable, - Availability::Available, - SinkCapabilities::DELIVER, - "ready", - )) - }) - } - fn deliver( - &self, - request: DeliveryRequest, - ) -> radroots_transport::BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> - { - Box::pin(async move { - Err(SinkFailure::for_request( - &request, - "test_sink_unavailable", - Retryability::Terminal, - None, - None, - Vec::new(), - ) - .expect("test sink failure")) - }) - } - } - - #[tokio::test] - async fn operations_cover_private_evidence_pagination_commit_replay_and_cancellation() { - let storage = Arc::new(MemoryStorage::new( - SourceGeneration::new([6; 32]).expect("generation"), - )); - let artifact_id = PrivateArtifactId::new([7; 16]).expect("artifact id"); - let metadata = PrivateArtifactMetadata::new( - artifact_id, - ArtifactKind::parse("trade.private_terms").expect("kind"), - ArtifactSchemaId::parse("radroots.private.fulfillment.v1").expect("schema"), - ArtifactCommitment::new([0xee; 32]), - 64, - DurableSecretReference::new("memory", "trade-artifact-1", 1).expect("reference"), - RetentionPolicy::indefinite(), - 1_800_000_000_000, - ) - .expect("metadata"); - PrivateArtifactStore::put_metadata(storage.as_ref(), metadata) - .await - .expect("store metadata"); - - let signer = Arc::new( - radroots_nostr::signing::LocalSigner::new( - SecretKey::parse(BUYER_SECRET).expect("secret"), - ) - .expect("signer"), - ); - let capability: Arc<dyn SyncStorage> = storage.clone(); - let engine = Engine::builder( - capability, - Arc::new(HostClock), - Arc::new(SequenceIds(AtomicU8::new(1))), - DeadlinePolicy::new(30_000, 30_000, 30_000).expect("deadlines"), - ) - .sink(Arc::new(NoopSink)) - .signer(signer) - .build() - .expect("engine"); - let client = ClientBuilder::new() - .storage(storage.clone()) - .sync_engine(engine) - .build() - .expect("client"); - let operations = client.trade().expect("open").expect("trade operations"); - let plan = prepare(PrepareRequest::new( - actor(BUYER, AuthorRole::Buyer), - all_commands().remove(0), - )) - .expect("plan"); - - let verified = operations - .verify_private_terms(&plan, artifact_id) - .await - .expect("private evidence"); - assert_eq!(verified.artifact_id(), artifact_id); - let page = operations - .query_visible(EventQuery::all(EventQueryBounds::first(1).expect("bounds"))) - .await - .expect("page"); - assert!(page.items().is_empty()); - assert!(page.next_cursor().is_none()); - - let targets = TargetSet::new(vec![ - Target::nostr_relay("wss://trade.example").expect("target"), - ]) - .expect("targets"); - let satisfaction = - SatisfactionPolicy::new(SatisfactionClass::Delivered, TargetPolicy::all()); - let request = EnqueueRequest::new( - SyncId::new([11; 16]).expect("operation id"), - IdempotencyKey::parse("trade-proposal-a").expect("idempotency"), - plan, - Profile::delivery(targets.clone(), satisfaction.clone()).expect("profile"), - 2_000_000_001_000, - CancellationPolicy::PreservePublishedRequest, - ); - drop(operations.enqueue(request.clone())); - assert_eq!( - Outbox::status(storage.as_ref()) - .await - .expect("status") - .pending, - 0 - ); - let committed = operations.enqueue(request.clone()).await.expect("commit"); - assert_eq!(committed.delivery_plan().intent().target_set(), &targets); - let replay = operations.enqueue(request).await.expect("resume replay"); - assert_eq!(replay, committed); - } - } -} diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs @@ -1,742 +0,0 @@ -//! Explicit user-facing transport profile composition. -//! -//! Profiles retain canonical `radroots_transport` identities, targets, -//! policies, and statuses. They select no adapter implicitly and never replace -//! an unavailable selection with another transport. - -use radroots_transport::{ - Error, SinkStatus, SourceStatus, TargetSet, TransportId, - capability::{Availability, Maturity, SinkCapabilities, SourceCapabilities}, - policy::SatisfactionPolicy, -}; -#[cfg(feature = "nostr")] -use std::sync::{Arc, RwLock}; - -#[cfg(feature = "nostr")] -pub use radroots_transport_nostr::RelayUrlPolicy; - -const PREVIEW_UNAVAILABLE_MESSAGE: &str = "preview transport is unavailable in this SDK release"; - -/// A side-effect-free transport selection for a client operation. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Profile { - selection: Selection, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum Selection { - LocalOnly, - Delivery { - targets: TargetSet, - satisfaction: SatisfactionPolicy, - }, - UnavailablePreview { - source: SourceStatus, - sink: SinkStatus, - }, -} - -impl Profile { - /// Selects local persistence only, with no transport target or fallback. - #[must_use] - pub const fn local_only() -> Self { - Self { - selection: Selection::LocalOnly, - } - } - - /// Selects an exact bounded target set and canonical satisfaction policy. - /// - /// Impossible quorum and required-target policies are rejected here by the - /// owning transport contract. Construction performs no network operation. - pub fn delivery(targets: TargetSet, satisfaction: SatisfactionPolicy) -> Result<Self, Error> { - satisfaction.validate_for(&targets)?; - Ok(Self { - selection: Selection::Delivery { - targets, - satisfaction, - }, - }) - } - - /// Describes a preview transport that is intentionally not selectable. - /// - /// Both canonical capability directions remain explicitly unconfigured - /// and unavailable. The profile has no targets and therefore cannot fall - /// back to local, Nostr, daemon, or another transport. - #[must_use] - pub fn unavailable_preview(transport_id: TransportId) -> Self { - Self { - selection: Selection::UnavailablePreview { - source: SourceStatus::new( - transport_id, - false, - Maturity::Preview, - Availability::Unavailable, - SourceCapabilities::NONE, - PREVIEW_UNAVAILABLE_MESSAGE, - ), - sink: SinkStatus::new( - transport_id, - false, - Maturity::Preview, - Availability::Unavailable, - SinkCapabilities::NONE, - PREVIEW_UNAVAILABLE_MESSAGE, - ), - }, - } - } - - /// Returns whether this profile authorizes no transport operation. - #[must_use] - pub const fn is_local_only(&self) -> bool { - matches!(self.selection, Selection::LocalOnly) - } - - /// Returns the exact selected targets, if delivery is authorized. - #[must_use] - pub const fn targets(&self) -> Option<&TargetSet> { - match &self.selection { - Selection::Delivery { targets, .. } => Some(targets), - Selection::LocalOnly | Selection::UnavailablePreview { .. } => None, - } - } - - /// Returns the exact selected satisfaction policy, if delivery is authorized. - #[must_use] - pub const fn satisfaction(&self) -> Option<&SatisfactionPolicy> { - match &self.selection { - Selection::Delivery { satisfaction, .. } => Some(satisfaction), - Selection::LocalOnly | Selection::UnavailablePreview { .. } => None, - } - } - - /// Returns canonical source status for an unavailable preview. - #[must_use] - pub const fn source_status(&self) -> Option<&SourceStatus> { - match &self.selection { - Selection::UnavailablePreview { source, .. } => Some(source), - Selection::LocalOnly | Selection::Delivery { .. } => None, - } - } - - /// Returns canonical sink status for an unavailable preview. - #[must_use] - pub const fn sink_status(&self) -> Option<&SinkStatus> { - match &self.selection { - Selection::UnavailablePreview { sink, .. } => Some(sink), - Selection::LocalOnly | Selection::Delivery { .. } => None, - } - } -} - -impl Default for Profile { - fn default() -> Self { - Self::local_only() - } -} - -/// Host-configured, client-shareable Nostr transport slot. -/// -/// Reconfiguration validates the complete relay set before atomically -/// replacing the active adapter. Construction, clearing, and target -/// inspection perform no network I/O. -#[cfg(feature = "nostr")] -#[derive(Clone)] -pub struct NostrSlot { - policy: RelayUrlPolicy, - state: Arc<RwLock<Option<NostrState>>>, -} - -#[cfg(feature = "nostr")] -#[derive(Clone)] -struct NostrState { - transport: Arc<radroots_transport_nostr::NostrTransport>, - targets: TargetSet, -} - -#[cfg(feature = "nostr")] -impl NostrSlot { - /// Creates an inert slot with an explicit destination policy. - #[must_use] - pub fn new(policy: RelayUrlPolicy) -> Self { - Self { - policy, - state: Arc::new(RwLock::new(None)), - } - } - - /// Validates and atomically installs the complete relay selection. - pub fn configure<I, S>(&self, relays: I) -> crate::Result<()> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - let config = radroots_transport_nostr::Config::new(self.policy, relays) - .map_err(crate::Error::invalid_host_configuration)?; - let targets = TargetSet::new( - config - .relays() - .iter() - .map(radroots_transport_nostr::RelayUrl::to_target) - .collect::<Result<Vec<_>, _>>() - .map_err(crate::Error::invalid_host_configuration)?, - ) - .map_err(|_| crate::Error::invalid_host_configuration_without_source())?; - let state = NostrState { - transport: Arc::new(radroots_transport_nostr::NostrTransport::new(config)), - targets, - }; - let mut current = self - .state - .write() - .map_err(|_| crate::Error::shared_operation_unavailable())?; - *current = Some(state); - Ok(()) - } - - /// Removes the active adapter without starting or stopping background work. - pub fn clear(&self) { - if let Ok(mut state) = self.state.write() { - *state = None; - } - } - - /// Returns the currently selected canonical targets. - #[must_use] - pub fn targets(&self) -> Option<TargetSet> { - self.snapshot().map(|state| state.targets) - } - - fn snapshot(&self) -> Option<NostrState> { - self.state.read().ok().and_then(|state| state.clone()) - } -} - -#[cfg(feature = "nostr")] -impl radroots_transport::EventSource for NostrSlot { - fn status( - &self, - ) -> radroots_transport::BoxFuture<'_, Result<SourceStatus, radroots_transport::Error>> { - Box::pin(async move { - match self.snapshot() { - Some(state) => { - radroots_transport::EventSource::status(state.transport.as_ref()).await - } - None => Ok(SourceStatus::new( - TransportId::NOSTR, - false, - Maturity::Stable, - Availability::Unavailable, - SourceCapabilities::FETCH, - "Nostr transport is not configured", - )), - } - }) - } - - fn fetch( - &self, - request: radroots_transport::FetchRequest, - ) -> radroots_transport::BoxFuture< - '_, - Result<radroots_transport::FetchPage, radroots_transport::Error>, - > { - Box::pin(async move { - let state = self - .snapshot() - .ok_or(radroots_transport::Error::UnsupportedOperation)?; - radroots_transport::EventSource::fetch(state.transport.as_ref(), request).await - }) - } -} - -#[cfg(feature = "nostr")] -impl radroots_transport::EventSink for NostrSlot { - fn status( - &self, - ) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, radroots_transport::Error>> { - Box::pin(async move { - match self.snapshot() { - Some(state) => { - radroots_transport::EventSink::status(state.transport.as_ref()).await - } - None => Ok(SinkStatus::new( - TransportId::NOSTR, - false, - Maturity::Stable, - Availability::Unavailable, - SinkCapabilities::DELIVER, - "Nostr transport is not configured", - )), - } - }) - } - - fn deliver( - &self, - request: radroots_transport::DeliveryRequest, - ) -> radroots_transport::BoxFuture< - '_, - Result<radroots_transport::DeliveryReceipt, radroots_transport::SinkFailure>, - > { - Box::pin(async move { - let Some(state) = self.snapshot() else { - return Err(radroots_transport::SinkFailure::for_request( - &request, - "nostr_transport_not_configured", - radroots_transport::outcome::Retryability::Terminal, - None, - None, - Vec::new(), - ) - .expect("static unconfigured sink failure is valid")); - }; - radroots_transport::EventSink::deliver(state.transport.as_ref(), request).await - }) - } -} - -#[cfg(feature = "nostr")] -impl std::fmt::Debug for NostrSlot { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("NostrSlot") - .field("policy", &self.policy) - .field("configured", &self.targets().is_some()) - .finish() - } -} - -/// Explicit daemon adapter authentication configuration. -#[cfg(feature = "radrootsd")] -#[derive(Clone, Eq, PartialEq)] -#[non_exhaustive] -pub enum DaemonAuth { - /// Sends no authorization header. - None, - /// Sends the supplied bearer credential only when delivery is invoked. - BearerToken(String), -} - -#[cfg(feature = "radrootsd")] -impl std::fmt::Debug for DaemonAuth { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::None => formatter.write_str("None"), - Self::BearerToken(_) => formatter.write_str("BearerToken(<redacted>)"), - } - } -} - -/// Explicit daemon endpoint and request deadline configuration. -#[cfg(feature = "radrootsd")] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DaemonConfig { - endpoint: String, - auth: DaemonAuth, - timeout: core::time::Duration, -} - -#[cfg(feature = "radrootsd")] -impl DaemonConfig { - /// Creates inert configuration; no client is built and no request is sent. - #[must_use] - pub fn new(endpoint: impl Into<String>) -> Self { - Self { - endpoint: endpoint.into(), - auth: DaemonAuth::None, - timeout: core::time::Duration::from_secs(10), - } - } - - /// Selects explicit authentication for later invocation. - #[must_use] - pub fn with_auth(mut self, auth: DaemonAuth) -> Self { - self.auth = auth; - self - } - - /// Selects the complete HTTP/RPC request deadline. - #[must_use] - pub const fn with_timeout(mut self, timeout: core::time::Duration) -> Self { - self.timeout = timeout; - self - } -} - -/// Stable secret-safe daemon execution failure class. -#[cfg(feature = "radrootsd")] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum DaemonErrorKind { - /// The explicit authentication value cannot be represented safely. - Authentication, - /// The versioned protocol rejected the request. - InvalidRequest, - /// HTTP transport or timeout failed. - Transport, - /// The daemon returned a JSON-RPC error. - Rpc, - /// The response was malformed or did not match the request. - InvalidResponse, -} - -/// One redacted daemon failure retaining a private source chain. -#[cfg(feature = "radrootsd")] -pub struct DaemonError { - kind: DaemonErrorKind, - source: crate::adapters::radrootsd::RadrootsdError, -} - -#[cfg(feature = "radrootsd")] -impl DaemonError { - /// Returns the stable failure class. - #[must_use] - pub const fn kind(&self) -> DaemonErrorKind { - self.kind - } - - fn from_private(source: crate::adapters::radrootsd::RadrootsdError) -> Self { - use crate::adapters::radrootsd::RadrootsdError; - let kind = match &source { - RadrootsdError::InvalidAuthHeader(_) => DaemonErrorKind::Authentication, - RadrootsdError::InvalidRequest(_) => DaemonErrorKind::InvalidRequest, - RadrootsdError::Http(_) => DaemonErrorKind::Transport, - RadrootsdError::JsonRpc { .. } => DaemonErrorKind::Rpc, - RadrootsdError::MalformedResponse(_) => DaemonErrorKind::InvalidResponse, - }; - Self { kind, source } - } -} - -#[cfg(feature = "radrootsd")] -impl std::fmt::Display for DaemonError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(match self.kind { - DaemonErrorKind::Authentication => "daemon authentication configuration is invalid", - DaemonErrorKind::InvalidRequest => "daemon delivery request is invalid", - DaemonErrorKind::Transport => "daemon transport failed", - DaemonErrorKind::Rpc => "daemon RPC failed", - DaemonErrorKind::InvalidResponse => "daemon response is invalid", - }) - } -} - -#[cfg(feature = "radrootsd")] -impl std::fmt::Debug for DaemonError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("DaemonError") - .field("kind", &self.kind) - .finish_non_exhaustive() - } -} - -#[cfg(feature = "radrootsd")] -impl std::error::Error for DaemonError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - Some(&self.source) - } -} - -/// Explicitly configured daemon execution adapter. -/// -/// Construction is inert. Network contact occurs only in [`Self::deliver`]. -#[cfg(feature = "radrootsd")] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DaemonDelivery { - adapter: crate::adapters::radrootsd::RadrootsdPublishAdapter, -} - -#[cfg(feature = "radrootsd")] -impl DaemonDelivery { - /// Creates an inert adapter from explicit host configuration. - #[must_use] - pub fn new(config: DaemonConfig) -> Self { - let auth = match config.auth { - DaemonAuth::None => crate::adapters::radrootsd::RadrootsdAuth::None, - DaemonAuth::BearerToken(token) => { - crate::adapters::radrootsd::RadrootsdAuth::BearerToken(token) - } - }; - Self { - adapter: crate::adapters::radrootsd::RadrootsdPublishAdapter::new( - crate::adapters::radrootsd::RadrootsdPublishConfig::new(config.endpoint) - .with_auth(auth) - .with_timeout(config.timeout), - ), - } - } - - /// Invokes the generation-5 daemon transport-publish contract. - pub async fn deliver( - &self, - signed_event: radroots_event::SignedEvent, - target_policy: radroots_protocol::radrootsd::transport_publish::v5::TargetPolicy, - delivery_policy: radroots_protocol::radrootsd::transport_publish::v5::DeliveryPolicy, - idempotency_key: Option<String>, - timeout_ms: Option<u64>, - ) -> Result<radroots_protocol::radrootsd::transport_publish::v5::EventResponse, DaemonError> - { - self.adapter - .publish_signed_event(crate::adapters::radrootsd::RadrootsdPublishRequest { - signed_event, - target_policy, - delivery_policy, - idempotency_key, - timeout_ms, - }) - .await - .map_err(DaemonError::from_private) - } -} - -#[cfg(test)] -mod tests { - use radroots_transport::{ - Error, TARGET_SET_MAX_ITEMS, Target, - capability::{Availability, Maturity}, - policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, - target::TargetFingerprint, - }; - - use super::*; - - fn target(index: usize) -> Target { - Target::nostr_relay(format!("wss://relay-{index}.example")).expect("target") - } - - #[cfg(feature = "nostr")] - fn signed_event() -> radroots_event::SignedEvent { - let raw = r#"{"id":"56bfc78223bb2221bad82b539efdec1ade0f56d0eb0e1f592fd387df4b2ceee0","pubkey":"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df","created_at":1700000001,"kind":0,"tags":[],"content":"{}","sig":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}"#; - let wire = radroots_event::wire::v1::Nip01EventWire::parse_json(raw).expect("wire event"); - radroots_event::SignedEvent::from_wire_verified_id(wire, raw).expect("signed event") - } - - #[test] - fn delivery_profile_preserves_canonical_targets_and_policy() { - let targets = TargetSet::new(vec![target(1), target(2)]).expect("target set"); - let policy = SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()); - let profile = Profile::delivery(targets.clone(), policy.clone()).expect("profile"); - - assert_eq!(profile.targets(), Some(&targets)); - assert_eq!(profile.satisfaction(), Some(&policy)); - assert!(!profile.is_local_only()); - assert!(profile.source_status().is_none()); - assert!(profile.sink_status().is_none()); - } - - #[test] - fn canonical_target_and_policy_bounds_fail_during_profile_construction() { - assert_eq!(TargetSet::new(Vec::new()), Err(Error::EmptyTargetSet)); - assert_eq!( - TargetSet::new((0..=TARGET_SET_MAX_ITEMS).map(target).collect()), - Err(Error::TargetSetTooLarge) - ); - - let targets = TargetSet::new(vec![target(1)]).expect("target set"); - let quorum = SatisfactionPolicy::new( - SatisfactionClass::Delivered, - TargetPolicy::quorum(2).expect("non-zero quorum"), - ); - assert_eq!( - Profile::delivery(targets.clone(), quorum), - Err(Error::InvalidSatisfactionPolicy) - ); - - let missing = - TargetFingerprint::from_target(target(2).kind(), target(2).uri(), target(2).scope()); - let required = SatisfactionPolicy::new( - SatisfactionClass::Accepted, - TargetPolicy::required(vec![missing]).expect("required policy"), - ); - assert_eq!( - Profile::delivery(targets, required), - Err(Error::RequiredTargetNotRequested) - ); - } - - #[test] - fn preview_transport_is_explicitly_unavailable_and_unselectable() { - let profile = Profile::unavailable_preview(TransportId::RETICULUM); - let source = profile.source_status().expect("source status"); - let sink = profile.sink_status().expect("sink status"); - - assert_eq!(source.transport_id(), TransportId::RETICULUM); - assert_eq!(sink.transport_id(), TransportId::RETICULUM); - assert!(!source.is_configured()); - assert!(!sink.is_configured()); - assert_eq!(source.maturity(), Maturity::Preview); - assert_eq!(sink.maturity(), Maturity::Preview); - assert_eq!(source.availability(), Availability::Unavailable); - assert_eq!(sink.availability(), Availability::Unavailable); - assert!(!source.capabilities().can_fetch()); - assert!(!sink.capabilities().can_deliver()); - assert!(profile.targets().is_none()); - assert!(profile.satisfaction().is_none()); - } - - #[test] - fn local_and_preview_profiles_never_substitute_fallback_targets() { - let local = Profile::local_only(); - let preview = Profile::unavailable_preview(TransportId::RETICULUM); - assert!(local.is_local_only()); - assert!(local.targets().is_none()); - assert!(preview.targets().is_none()); - - let selected = TargetSet::new(vec![target(7)]).expect("selected targets"); - let profile = Profile::delivery( - selected.clone(), - SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::any()), - ) - .expect("profile"); - assert_eq!(profile.targets(), Some(&selected)); - assert!( - profile - .targets() - .expect("targets") - .targets() - .iter() - .all(|target| *target.kind() == TransportId::NOSTR) - ); - } - - #[test] - fn default_profile_is_local_only() { - assert_eq!(Profile::default(), Profile::local_only()); - } - - #[cfg(feature = "radrootsd")] - #[test] - fn daemon_configuration_is_inert_explicit_and_redacted() { - let config = DaemonConfig::new("http://127.0.0.1:1/rpc") - .with_auth(DaemonAuth::BearerToken("secret-token".to_owned())) - .with_timeout(core::time::Duration::from_millis(5)); - let adapter = DaemonDelivery::new(config); - - let debug = format!("{adapter:?}"); - assert!(!debug.contains("secret-token")); - assert!(!debug.contains("reqwest")); - assert_eq!(format!("{:?}", DaemonAuth::None), "None"); - assert_eq!( - format!("{:?}", DaemonAuth::BearerToken("private".to_owned())), - "BearerToken(<redacted>)" - ); - } - - #[cfg(feature = "radrootsd")] - #[test] - fn daemon_errors_are_stably_classified_and_redacted() { - use std::error::Error as _; - - use crate::adapters::radrootsd::RadrootsdError; - - let cases = [ - ( - RadrootsdError::InvalidAuthHeader("private".to_owned()), - DaemonErrorKind::Authentication, - "daemon authentication configuration is invalid", - ), - ( - RadrootsdError::InvalidRequest("private".to_owned()), - DaemonErrorKind::InvalidRequest, - "daemon delivery request is invalid", - ), - ( - RadrootsdError::Http("private".to_owned()), - DaemonErrorKind::Transport, - "daemon transport failed", - ), - ( - RadrootsdError::JsonRpc { - code: -1, - message: "private".to_owned(), - }, - DaemonErrorKind::Rpc, - "daemon RPC failed", - ), - ( - RadrootsdError::MalformedResponse("private".to_owned()), - DaemonErrorKind::InvalidResponse, - "daemon response is invalid", - ), - ]; - for (private, kind, display) in cases { - let error = DaemonError::from_private(private); - assert_eq!(error.kind(), kind); - assert_eq!(error.to_string(), display); - assert!(error.source().is_some()); - assert!(!format!("{error:?}").contains("private")); - } - } - - #[cfg(feature = "nostr")] - #[test] - fn nostr_slot_reconfiguration_is_validated_atomic_and_inert() { - let slot = NostrSlot::new(RelayUrlPolicy::Local); - assert!(slot.targets().is_none()); - assert!(slot.configure(["ws://127.0.0.1:7447"]).is_ok()); - let original = slot.targets().expect("configured targets"); - assert!(slot.configure(Vec::<String>::new()).is_err()); - assert_eq!(slot.targets(), Some(original)); - slot.clear(); - assert!(slot.targets().is_none()); - assert!(format!("{slot:?}").contains("configured: false")); - } - - #[cfg(feature = "nostr")] - #[test] - fn poisoned_nostr_slot_fails_closed_for_every_host_operation() { - let slot = NostrSlot::new(RelayUrlPolicy::Local); - let state = Arc::clone(&slot.state); - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _guard = state.write().expect("write lock"); - panic!("poison transport slot"); - })); - - slot.clear(); - assert!(slot.targets().is_none()); - assert!(slot.configure(["ws://127.0.0.1:7447"]).is_err()); - } - - #[cfg(feature = "nostr")] - #[tokio::test] - async fn empty_nostr_slot_reports_unavailable_and_rejects_operations() { - use radroots_transport::{ - DeliveryRequest, EventSink as _, EventSource as _, FetchRequest, sink::DeliveryPayload, - source::FetchBounds, - }; - - let slot = NostrSlot::new(RelayUrlPolicy::Local); - let source = radroots_transport::EventSource::status(&slot) - .await - .expect("source status"); - assert_eq!(source.availability(), Availability::Unavailable); - - let targets = TargetSet::new(vec![target(1)]).expect("targets"); - let fetch = FetchRequest::new( - "fetch", - targets.clone(), - FetchBounds::new(1, 1).expect("bounds"), - ) - .expect("fetch"); - assert_eq!(slot.fetch(fetch).await, Err(Error::UnsupportedOperation)); - - let sink = radroots_transport::EventSink::status(&slot) - .await - .expect("sink status"); - assert_eq!(sink.availability(), Availability::Unavailable); - let deliver = DeliveryRequest::new( - "deliver", - DeliveryPayload::new(signed_event()), - targets, - SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::any()), - 1, - ) - .expect("delivery"); - let failure = slot.deliver(deliver).await.expect_err("unconfigured sink"); - assert_eq!(failure.code(), "nostr_transport_not_configured"); - } -} diff --git a/crates/sdk/tests/lifecycle.rs b/crates/sdk/tests/lifecycle.rs @@ -1,156 +0,0 @@ -#[cfg(feature = "memory")] -use radroots_sdk::{ClientBuilder, capability::CapabilityId, error::ErrorKind}; - -const CLIENT_SOURCE: &str = include_str!("../src/client.rs"); -const MANIFEST: &str = include_str!("../Cargo.toml"); - -#[test] -fn clean_default_path_contains_no_implicit_resource_or_worker_authority() { - let production = CLIENT_SOURCE - .split_once("#[cfg(all(test, feature = \"memory\"))]") - .expect("production client boundary") - .0; - for forbidden in [ - "keyring", - "reqwest", - "radrootsd", - "std::fs", - "tokio::fs", - "tokio::spawn", - "std::thread::spawn", - "Runtime::new", - "File::create", - "impl Drop for Client", - ] { - assert!( - !production.contains(forbidden), - "clean client path contains implicit authority `{forbidden}`" - ); - } - assert!(MANIFEST.contains("default = [\"memory\"]")); - assert!(!MANIFEST.contains("default = [\"native\"]")); - assert!(!MANIFEST.contains("default = [\"full\"]")); -} - -#[cfg(feature = "memory")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn memory_client_is_passive_clone_shared_and_explicitly_closed() { - let generation = - radroots_storage::event::SourceGeneration::new([41; 32]).expect("non-zero generation"); - let client = ClientBuilder::memory(generation).build().expect("client"); - - let first_report = client.capabilities(); - let second_report = client.capabilities(); - assert_eq!(first_report, second_report); - let storage = first_report - .get(CapabilityId::CANONICAL_STORAGE) - .expect("canonical storage status"); - assert!(storage.is_compiled()); - assert!(storage.is_configured()); - - let unpolled_close = client.close(); - drop(unpolled_close); - assert!(client.storage().is_ok()); - - let first = client.clone(); - let second = client.clone(); - let first_close = tokio::spawn(async move { first.close().await }); - let second_close = tokio::spawn(async move { second.close().await }); - let outcomes = [ - first_close.await.expect("first close task"), - second_close.await.expect("second close task"), - ]; - assert!(outcomes.iter().all(|outcome| { - outcome.is_ok() - || outcome - .as_ref() - .is_err_and(|error| error.kind() == ErrorKind::CloseInProgress) - })); - if !client.is_closed() { - client.close().await.expect("complete close"); - } - - assert!(client.is_closed()); - assert_eq!( - client - .storage() - .err() - .expect("closed client rejects storage") - .kind(), - ErrorKind::ClientClosed - ); - assert_eq!( - client - .capabilities() - .get(CapabilityId::CANONICAL_STORAGE) - .expect("closed storage status") - .availability(), - radroots_sdk::capability::Availability::Unavailable - ); -} - -#[cfg(all(feature = "memory", feature = "sqlite"))] -#[tokio::test] -async fn sqlite_resources_exist_only_after_explicit_open_and_close_across_clones() { - use radroots_sdk::storage::{SqliteOpenMode, SqliteOptions, SqlitePaths}; - use radroots_storage::status::{ShutdownState, StorageBackend}; - - let directory = tempfile::tempdir().expect("temporary directory"); - let empty_builder = ClientBuilder::new(); - assert!( - directory - .path() - .read_dir() - .expect("read tempdir") - .next() - .is_none() - ); - drop(empty_builder); - - let paths = SqlitePaths::from_directory(directory.path()).expect("SQLite paths"); - let generation = - radroots_storage::event::SourceGeneration::new([42; 32]).expect("non-zero generation"); - let options = SqliteOptions::new(paths, SqliteOpenMode::Create) - .with_source_generation(generation, 1) - .expect("source generation"); - assert!( - directory - .path() - .read_dir() - .expect("read tempdir") - .next() - .is_none() - ); - - let client = ClientBuilder::sqlite(options) - .await - .expect("explicit SQLite open") - .build() - .expect("client"); - assert!( - directory - .path() - .read_dir() - .expect("read tempdir") - .next() - .is_some() - ); - let status = client.storage_status().await.expect("storage status"); - assert_eq!(status.backend(), StorageBackend::Sqlite); - assert_eq!(status.shutdown(), ShutdownState::Open); - - let clone = client.clone(); - let unpolled_close = clone.close(); - drop(unpolled_close); - assert!(client.storage().is_ok()); - client.close().await.expect("explicit close"); - assert!(clone.is_closed()); - assert_eq!( - clone - .storage_status() - .await - .expect_err("closed clone rejects inspection") - .kind(), - ErrorKind::ClientClosed - ); -} diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs @@ -1,554 +0,0 @@ -use std::collections::BTreeSet; - -const MANIFEST: &str = include_str!("../Cargo.toml"); -const ROOT: &str = include_str!("../src/lib.rs"); -const CLIENT: &str = include_str!("../src/client.rs"); -const FARM: &str = include_str!("../src/farm.rs"); -const LISTING: &str = include_str!("../src/listing.rs"); -const TRADE: &str = include_str!("../src/trade.rs"); -const STORAGE: &str = include_str!("../src/storage.rs"); -const DIAGNOSTICS: &str = include_str!("../src/diagnostics.rs"); -const ADAPTERS: &str = include_str!("../src/adapters/mod.rs"); -const RADROOTSD: &str = include_str!("../src/adapters/radrootsd.rs"); -const SYNC: &str = include_str!("../src/sync.rs"); -const TRANSPORT: &str = include_str!("../src/transport.rs"); -const PUBLICATION: &str = include_str!("../../../contracts/releases/publication.toml"); - -#[test] -fn manifest_has_final_identity_and_dependency_boundary() { - assert!(MANIFEST.contains("name = \"radroots_sdk\"")); - assert!(MANIFEST.contains("publish = [\"crates-io\"]")); - assert!(MANIFEST.contains("version.workspace = true")); - assert!(MANIFEST.contains("name = \"package_boundary\"")); - - let dependencies = dependency_names(MANIFEST); - let expected = BTreeSet::from([ - "radroots_core", - "radroots_event", - "radroots_event_codec", - "radroots_geonames", - "radroots_identity", - "radroots_nostr", - "radroots_nostr_connect", - "radroots_protocol", - "radroots_secrets", - "radroots_signing", - "radroots_storage", - "radroots_storage_sqlite", - "radroots_sync", - "radroots_trade", - "radroots_transport", - "radroots_transport_nostr", - ]); - assert_eq!(dependencies, expected); - for forbidden in [ - "radroots_event_store", - "radroots_nostr_signer", - "radroots_outbox", - "radroots_protected_store", - "radroots_runtime_paths", - "radroots_secret_vault", - "radroots_transport_reticulum", - ] { - assert!( - !dependencies.contains(forbidden), - "SDK depends on private or superseded package `{forbidden}`" - ); - } -} - -#[test] -fn manifest_has_exact_feature_vocabulary_and_explicit_optional_activation() { - let features = MANIFEST - .split_once("[features]") - .expect("feature section") - .1 - .split_once("[dependencies]") - .expect("dependency section") - .0 - .lines() - .filter_map(|line| line.split_once(" = ").map(|(name, _)| name.trim())) - .collect::<BTreeSet<_>>(); - assert_eq!( - features, - BTreeSet::from([ - "default", - "full", - "geonames", - "knowledge", - "local-signing", - "memory", - "native", - "nip46", - "nostr", - "radrootsd", - "sqlite", - "sync", - ]) - ); - for activation in [ - "dep:radroots_storage_sqlite", - "dep:radroots_sync", - "dep:radroots_nostr", - "dep:radroots_transport_nostr", - "dep:radroots_nostr_connect", - "dep:radroots_secrets", - "dep:reqwest", - "dep:serde", - "dep:serde_json", - "dep:radroots_geonames", - ] { - assert!( - MANIFEST.contains(activation), - "missing activation `{activation}`" - ); - } - for retired in [ - "runtime =", - "local-runtime", - "signer-adapters", - "transport-nostr-runtime", - "transport-nostr-client", - "fixtures =", - ] { - assert!(!MANIFEST.contains(retired), "retired feature `{retired}`"); - } -} - -#[test] -fn root_declares_exact_final_module_skeleton() { - let actual = ROOT - .lines() - .filter_map(|line| line.trim().strip_prefix("pub mod ")) - .map(|module| module.trim_end_matches(';')) - .collect::<BTreeSet<_>>(); - let expected = BTreeSet::from([ - "capability", - "client", - "diagnostics", - "error", - "farm", - "listing", - "signing", - "storage", - "sync", - "trade", - "transport", - ]); - assert_eq!(actual, expected); - assert!(!ROOT.contains("no_std")); - assert_eq!(ROOT.matches("pub use crate::").count(), 2); - assert!(ROOT.contains("pub use crate::client::{Client, ClientBuilder};")); - assert!(ROOT.contains("pub use crate::error::{Error, Result};")); - assert!(!ROOT.contains("pub use radroots_")); -} - -#[test] -fn package_contains_only_reachable_sources_and_registered_targets() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - assert_eq!( - rust_files(&root.join("src")), - BTreeSet::from([ - "adapters/mod.rs".to_owned(), - "adapters/radrootsd.rs".to_owned(), - "capability.rs".to_owned(), - "client.rs".to_owned(), - "diagnostics.rs".to_owned(), - "error.rs".to_owned(), - "farm.rs".to_owned(), - "lib.rs".to_owned(), - "listing.rs".to_owned(), - "listing/operational_listing/draft.rs".to_owned(), - "listing/operational_listing/mod.rs".to_owned(), - "listing/operational_listing/model.rs".to_owned(), - "listing/operational_listing/mutation.rs".to_owned(), - "listing/operational_listing/price_ext.rs".to_owned(), - "listing/operational_listing/validation.rs".to_owned(), - "signing.rs".to_owned(), - "storage.rs".to_owned(), - "sync.rs".to_owned(), - "trade.rs".to_owned(), - "transport.rs".to_owned(), - ]) - ); - assert_eq!( - rust_files(&root.join("tests")), - BTreeSet::from([ - "lifecycle.rs".to_owned(), - "package_boundary.rs".to_owned(), - "public_api.rs".to_owned(), - "unit/adapters_radrootsd_tests.rs".to_owned(), - ]) - ); - assert_eq!( - rust_files(&root.join("examples")), - BTreeSet::from([ - "safe_memory_client.rs".to_owned(), - "transport_profile.rs".to_owned(), - ]) - ); -} - -#[test] -fn compatibility_packages_are_removed() { - assert!(PUBLICATION.contains("retired = []")); - let approved = PUBLICATION - .split_once("approved_packages = [") - .expect("approved package list") - .1 - .split_once("\n]") - .expect("approved package list terminator") - .0; - assert!(!approved.contains("radroots_runtime_contract_v1")); - let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(std::path::Path::parent) - .expect("workspace root"); - assert!(!workspace.join("crates/runtime_contract_v1").exists()); - assert!(!workspace.join("crates/event_index_bindings").exists()); -} - -#[test] -fn root_types_have_the_required_std_contracts() { - fn assert_client<T: Clone + Send + Sync>() {} - fn assert_builder<T: Send + Sync>() {} - fn assert_error<T: std::error::Error + Send + Sync + 'static>() {} - - assert_client::<radroots_sdk::Client>(); - assert_builder::<radroots_sdk::ClientBuilder>(); - assert_error::<radroots_sdk::Error>(); - let result: radroots_sdk::Result<()> = Ok(()); - assert!(result.is_ok()); -} - -#[cfg(feature = "sqlite")] -#[test] -fn sqlite_backend_implements_the_canonical_storage_capability() { - fn assert_storage<T: radroots_storage::Storage>() {} - assert_storage::<radroots_storage_sqlite::SqliteStorage>(); -} - -#[cfg(feature = "nostr")] -#[test] -fn nostr_adapter_implements_independent_source_and_sink_capabilities() { - fn assert_source<T: radroots_transport::EventSource>() {} - fn assert_sink<T: radroots_transport::EventSink>() {} - assert_source::<radroots_transport_nostr::NostrTransport>(); - assert_sink::<radroots_transport_nostr::NostrTransport>(); -} - -#[test] -fn lifecycle_source_owns_no_hidden_worker_runtime_or_blocking_drop() { - for forbidden in [ - "tokio::spawn", - "std::thread::spawn", - "Runtime::new", - "block_on(self.close", - "impl Drop for Client", - ] { - assert!( - !CLIENT.contains(forbidden), - "forbidden lifecycle source `{forbidden}`" - ); - } - assert!(CLIENT.contains("pub async fn close(&self)")); - assert!(CLIENT.contains("impl Drop for CloseAttempt")); -} - -#[test] -fn sdk_source_contains_no_studio_storage_surface() { - let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut pending = vec![source_root]; - while let Some(path) = pending.pop() { - for entry in std::fs::read_dir(path).expect("read SDK source") { - let entry = entry.expect("source entry"); - let path = entry.path(); - if path.is_dir() { - pending.push(path); - } else if path.extension().and_then(|value| value.to_str()) == Some("rs") { - let source = std::fs::read_to_string(&path).expect("read source file"); - for forbidden in [ - "studio.sqlite", - "SdkStudioStore", - "sdk_studio_state", - "studio_store", - ] { - assert!( - !source.contains(forbidden), - "{} contains retired Studio storage marker {forbidden}", - path.display() - ); - } - } - } - } -} - -#[test] -fn transport_profiles_reuse_canonical_types_and_forbid_fallback() { - assert!(TRANSPORT.contains("use radroots_transport::{")); - assert!(TRANSPORT.contains("satisfaction.validate_for(&targets)?")); - assert!(TRANSPORT.contains("Selection::UnavailablePreview")); - for duplicate in [ - "pub struct TargetSet", - "pub enum TargetPolicy", - "pub enum SatisfactionPolicy", - "pub struct SourceStatus", - "pub struct SinkStatus", - "DefaultProfile", - ] { - assert!( - !TRANSPORT.contains(duplicate), - "SDK transport source contains forbidden duplicate or fallback `{duplicate}`" - ); - } -} - -#[test] -fn sync_operations_only_delegate_to_the_canonical_engine() { - for delegation in [ - "self.engine.pull(request, admission).await", - "self.engine.ingest(observed, admission).await", - "self.engine.ingest_batch(observed, admission).await", - "self.engine.refresh_projection(request, reducer).await", - "self.engine.prepare_push(request).await", - "self.engine.push_status(operation_id).await", - "self.engine.sign_prepared(request).await", - "self.engine.admit_signed(operation_id).await", - "self.engine.deliver_push(operation_id).await", - "self.engine.status(projections).await", - "self.engine.retry_decision(plan, now_unix_ms)", - ] { - assert!( - SYNC.contains(delegation), - "missing sync delegation `{delegation}`" - ); - } - for duplicate in [ - "pub struct SyncTransport", - "pub struct SyncOutbox", - "pub struct SyncProjection", - "pub struct SyncStatus", - "pub struct PushOutbox", - ] { - assert!( - !SYNC.contains(duplicate), - "SDK sync source contains duplicate lower model `{duplicate}`" - ); - } - assert!( - !std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/sync_runtime.rs") - .exists() - ); -} - -#[test] -fn authored_submission_is_one_protocol_neutral_boundary_for_all_typed_contracts() { - use radroots_event_codec::authoring::REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS; - - assert_eq!(REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS.len(), 8); - assert!(SYNC.contains("pub async fn submit_push")); - assert!(SYNC.contains("request: PushRequest")); - assert!(SYNC.contains("Result<PushStatus, Error>")); - for contract_id in REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS { - assert!( - !SYNC.contains(contract_id), - "SDK submission must not branch on typed contract `{contract_id}`" - ); - } -} - -#[test] -fn farm_operations_preserve_pure_planning_commit_and_privacy_boundaries() { - for required in [ - "encode::farm::to_wire_parts", - "AddressableCoordinate", - "GenericEventDraft", - "AuthoredEventPlan", - "radroots_sync::PushRequest::new", - "self.sync", - ".submit_push(", - "PrivateArtifactStore", - ] { - assert!( - FARM.contains(required), - "missing farm boundary `{required}`" - ); - } - for forbidden in [ - "SdkExactLocation", - "SdkPublicLocality", - "latitude:", - "longitude:", - "enqueue_signed_workflow", - "local_event_seq", - "outbox_event_id", - ] { - assert!( - !FARM.contains(forbidden), - "farm source contains retired SDK or private representation `{forbidden}`" - ); - } - let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - assert!(!source_root.join("farms_runtime.rs").exists()); -} - -#[test] -fn listing_operations_reuse_trade_event_sync_and_privacy_boundaries() { - for required in [ - "canonicalize_operational_listing_edit", - "RadrootsOperationalListingMutation", - "RadrootsOperationalListingLifecycleState", - "build_operational_listing_mutation_plan", - "radroots_sync::PushRequest::new", - "PrivateArtifactStore", - ] { - assert!( - LISTING.contains(required), - "missing listing boundary `{required}`" - ); - } - for forbidden in [ - "SdkMutationState", - "ListingEnqueueReceipt", - "enqueue_signed_workflow", - "local_event_seq", - "outbox_event_id", - "latitude:", - "longitude:", - ] { - assert!( - !LISTING.contains(forbidden), - "listing source contains retired duplicate or private representation `{forbidden}`" - ); - } - assert!( - !std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/listings_runtime.rs") - .exists() - ); -} - -#[test] -fn trade_operations_use_canonical_workflow_storage_sync_and_projection_types() { - for required in [ - "WorkflowPlan", - "TradeMutationEnvelopeV1", - "ReductionInput", - "Projection", - "reduce_trade_records", - "EventQuery", - "EventPage<StoredVisibleEvent>", - "PrivateArtifactMetadata", - "radroots_sync::PushRequest::new", - ] { - assert!( - TRADE.contains(required), - "missing trade boundary `{required}`" - ); - } - for forbidden in [ - "sqlx::", - "QueryBuilder", - "TradeStatusView", - "SdkPrivateTradeArtifact", - "SdkMutationState", - "TradeCommandReceipt", - "struct Page", - "struct TradeId", - ] { - assert!( - !TRADE.contains(forbidden), - "trade source contains retired duplicate `{forbidden}`" - ); - } - assert!( - !std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/trade_runtime.rs") - .exists() - ); -} - -#[test] -fn reliability_and_diagnostics_return_canonical_storage_contracts() { - for required in [ - "StorageReliability::begin_backup", - "StorageReliability::transition_backup", - "StorageReliability::begin_restore", - "StorageReliability::transition_restore", - "StorageReliability::integrity", - "StorageReliability::status", - ] { - assert!( - STORAGE.contains(required), - "missing storage delegation `{required}`" - ); - } - for forbidden in [ - "struct BackupManifest", - "struct IntegrityStatus", - "Studio", - "sqlx::", - ] { - assert!( - !STORAGE.contains(forbidden), - "storage source duplicates `{forbidden}`" - ); - } - assert!(DIAGNOSTICS.contains("radroots_storage::StorageStatus")); - for forbidden in ["path:", "SqlitePool", "private_artifact"] { - assert!( - !DIAGNOSTICS.contains(forbidden), - "diagnostics leaks `{forbidden}`" - ); - } -} - -#[test] -fn radrootsd_adapter_is_private_explicit_versioned_and_redacted() { - assert!(ROOT.contains("mod adapters;")); - assert!(!ROOT.contains("pub mod adapters")); - assert!(ADAPTERS.contains("cfg(feature = \"radrootsd\")")); - assert!(ADAPTERS.contains("pub(crate) mod radrootsd")); - assert!(RADROOTSD.contains("transport_publish::v5")); - assert!(RADROOTSD.contains("reqwest::Client::builder")); - assert!(RADROOTSD.contains("BearerToken(<redacted>)")); - assert!(!RADROOTSD.contains("tokio::spawn")); -} - -fn dependency_names(manifest: &str) -> BTreeSet<&str> { - let dependencies = manifest - .split_once("[dependencies]") - .expect("dependency section") - .1 - .split_once("[[test]]") - .expect("test section") - .0; - dependencies - .lines() - .filter_map(|line| line.split_once(" = ").map(|(name, _)| name.trim())) - .filter(|name| name.starts_with("radroots_")) - .collect() -} - -fn rust_files(root: &std::path::Path) -> BTreeSet<String> { - let mut files = BTreeSet::new(); - let mut pending = vec![root.to_path_buf()]; - while let Some(directory) = pending.pop() { - for entry in std::fs::read_dir(&directory).expect("read package source directory") { - let path = entry.expect("read package source entry").path(); - if path.is_dir() { - pending.push(path); - } else if path.extension().and_then(|value| value.to_str()) == Some("rs") { - files.insert( - path.strip_prefix(root) - .expect("source remains below root") - .to_string_lossy() - .replace(std::path::MAIN_SEPARATOR, "/"), - ); - } - } - } - files -} diff --git a/crates/sdk/tests/public_api.rs b/crates/sdk/tests/public_api.rs @@ -1,172 +0,0 @@ -use std::{any::type_name, collections::BTreeSet}; - -const ACTIVE_MODULES: &[(&str, &str)] = &[ - ("capability", include_str!("../src/capability.rs")), - ("client", include_str!("../src/client.rs")), - ("diagnostics", include_str!("../src/diagnostics.rs")), - ("error", include_str!("../src/error.rs")), - ("farm", include_str!("../src/farm.rs")), - ("listing", include_str!("../src/listing.rs")), - ("signing", include_str!("../src/signing.rs")), - ("storage", include_str!("../src/storage.rs")), - ("sync", include_str!("../src/sync.rs")), - ("trade", include_str!("../src/trade.rs")), - ("transport", include_str!("../src/transport.rs")), -]; - -#[test] -fn public_native_type_snapshot_uses_contextual_names() { - let actual = BTreeSet::from([ - type_name::<radroots_sdk::Client>(), - type_name::<radroots_sdk::ClientBuilder>(), - type_name::<radroots_sdk::capability::Availability>(), - type_name::<radroots_sdk::capability::CapabilityId>(), - type_name::<radroots_sdk::capability::CapabilityReport>(), - type_name::<radroots_sdk::capability::CapabilityStatus>(), - type_name::<radroots_sdk::capability::Maturity>(), - type_name::<radroots_sdk::diagnostics::Report>(), - type_name::<radroots_sdk::error::Error>(), - type_name::<radroots_sdk::error::ErrorDescriptor>(), - type_name::<radroots_sdk::error::ErrorKind>(), - type_name::<radroots_sdk::farm::Plan>(), - type_name::<radroots_sdk::farm::PrepareError>(), - type_name::<radroots_sdk::farm::PrepareErrorKind>(), - type_name::<radroots_sdk::farm::PrepareRequest>(), - type_name::<radroots_sdk::listing::Action>(), - type_name::<radroots_sdk::listing::Plan>(), - type_name::<radroots_sdk::listing::PrepareError>(), - type_name::<radroots_sdk::listing::PrepareErrorKind>(), - type_name::<radroots_sdk::listing::PrepareRequest>(), - type_name::<radroots_sdk::signing::Mode>(), - type_name::<radroots_sdk::signing::Provider>(), - type_name::<radroots_sdk::storage::Operations<'static>>(), - type_name::<radroots_sdk::trade::Plan>(), - type_name::<radroots_sdk::trade::PrepareError>(), - type_name::<radroots_sdk::trade::PrepareErrorKind>(), - type_name::<radroots_sdk::trade::PrepareRequest>(), - type_name::<radroots_sdk::transport::Profile>(), - ]); - #[cfg(feature = "sync")] - let actual = actual - .into_iter() - .chain([ - type_name::<radroots_sdk::farm::EnqueueRequest>(), - type_name::<radroots_sdk::farm::Operations<'static>>(), - type_name::<radroots_sdk::listing::EnqueueRequest>(), - type_name::<radroots_sdk::listing::Operations<'static>>(), - type_name::<radroots_sdk::sync::Operations<'static>>(), - type_name::<radroots_sdk::sync::HostPolicy>(), - type_name::<radroots_sdk::trade::EnqueueRequest>(), - type_name::<radroots_sdk::trade::Operations<'static>>(), - type_name::<radroots_sdk::trade::PrivateTermsError>(), - ]) - .collect::<BTreeSet<_>>(); - #[cfg(feature = "local-signing")] - let actual = actual - .into_iter() - .chain([ - type_name::<radroots_sdk::signing::LocalIdentity>(), - type_name::<radroots_sdk::signing::Slot>(), - ]) - .collect::<BTreeSet<_>>(); - #[cfg(feature = "nostr")] - let actual = actual - .into_iter() - .chain([type_name::<radroots_sdk::transport::NostrSlot>()]) - .collect::<BTreeSet<_>>(); - #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] - let actual = actual - .into_iter() - .chain([ - type_name::<radroots_sdk::client::PostEvent>(), - type_name::<radroots_sdk::client::ProfileDraft>(), - type_name::<radroots_sdk::client::ProfileEvent>(), - type_name::<radroots_sdk::client::PublishReceipt>(), - type_name::<radroots_sdk::client::SocialOperations<'static>>(), - type_name::<radroots_sdk::client::TransportHealth>(), - ]) - .collect::<BTreeSet<_>>(); - #[cfg(feature = "radrootsd")] - let actual = actual - .into_iter() - .chain([ - type_name::<radroots_sdk::transport::DaemonAuth>(), - type_name::<radroots_sdk::transport::DaemonConfig>(), - type_name::<radroots_sdk::transport::DaemonDelivery>(), - type_name::<radroots_sdk::transport::DaemonError>(), - type_name::<radroots_sdk::transport::DaemonErrorKind>(), - ]) - .collect::<BTreeSet<_>>(); - - assert!(actual.iter().all(|name| !name.contains("RadrootsSdk"))); - assert!(actual.iter().all(|name| { - name.rsplit("::") - .next() - .is_some_and(|item| !item.starts_with("Sdk")) - })); - assert_eq!(actual.len(), expected_public_type_count()); -} - -#[test] -fn active_native_api_has_no_sdk_owned_traits_or_public_field_layout() { - for (module, source) in ACTIVE_MODULES { - for line in source.lines() { - let line = line.trim_start(); - assert!( - !line.starts_with("pub trait "), - "{module} must reuse lower host SPIs instead of exposing an SDK-owned trait" - ); - for declaration in ["pub struct ", "pub enum ", "pub trait ", "pub type "] { - if let Some(item) = line.strip_prefix(declaration) { - let item = item - .split(|character: char| { - character == '<' - || character == '(' - || character == '{' - || character == ';' - || character.is_whitespace() - }) - .next() - .expect("public item name"); - assert!( - !item.starts_with("RadrootsSdk") && !item.starts_with("Sdk"), - "{module} exposes representation-prefixed item {item}" - ); - } - } - - let public_field = line.starts_with("pub ") - && line.contains(':') - && ![ - "pub async fn ", - "pub const ", - "pub enum ", - "pub fn ", - "pub mod ", - "pub static ", - "pub struct ", - "pub trait ", - "pub type ", - "pub use ", - ] - .iter() - .any(|prefix| line.starts_with(prefix)); - assert!(!public_field, "{module} exposes native field `{line}`"); - } - } -} - -const fn expected_public_type_count() -> usize { - let count = 28; - #[cfg(feature = "sync")] - let count = count + 9; - #[cfg(feature = "local-signing")] - let count = count + 2; - #[cfg(feature = "nostr")] - let count = count + 1; - #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] - let count = count + 6; - #[cfg(feature = "radrootsd")] - let count = count + 5; - count -} diff --git a/crates/sdk/tests/unit/adapters_radrootsd_tests.rs b/crates/sdk/tests/unit/adapters_radrootsd_tests.rs @@ -1,940 +0,0 @@ -use super::*; -use radroots_event::wire::Nip01EventWire; -use radroots_protocol::radrootsd::transport_publish::v5::{ - DeliveryPolicy as TransportPublishDeliveryPolicy, EventRequest as TransportPublishEventRequest, - EventResponse as TransportPublishEventResponse, Job as TransportPublishJobView, - JobStatus as TransportPublishJobStatus, - NostrTargetSourcePolicy as NostrPublishTargetSourcePolicy, - OutcomeKind as TransportPublishOutcomeKind, - ReticulumBehavior as TransportPublishReticulumBehavior, Target as TransportPublishTarget, - TargetOutcome as TransportPublishTargetOutcome, TargetPolicy as TransportPublishTargetPolicy, - TargetSource as TransportPublishTargetSource, -}; -const RADROOTS_RETICULUM_ENDPOINT_URI: &str = "reticulum:local"; -use std::io::{Read, Write}; -use std::net::TcpListener; -use std::thread::JoinHandle; - -const SIGNED_EVENT_PUBLIC_KEY: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - -struct RecordedHttpRequest { - request_line: String, - headers: Vec<(String, String)>, - body: String, -} - -fn spawn_http_server( - status: &str, - response_body: &str, -) -> (String, JoinHandle<RecordedHttpRequest>) { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); - let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr")); - let status = status.to_owned(); - let response_body = response_body.to_owned(); - let content_length = response_body.len(); - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - let mut request = Vec::new(); - let mut buffer = [0u8; 1024]; - loop { - let read = stream.read(&mut buffer).expect("read request"); - if read == 0 { - break; - } - request.extend_from_slice(&buffer[..read]); - if request.windows(4).any(|window| window == b"\r\n\r\n") { - let headers_end = request - .windows(4) - .position(|window| window == b"\r\n\r\n") - .expect("headers end") - + 4; - let header_text = String::from_utf8_lossy(&request[..headers_end]); - let request_content_length = header_text - .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::<usize>().expect("content length")) - }) - .unwrap_or(0); - while request.len() < headers_end + request_content_length { - let read = stream.read(&mut buffer).expect("read body"); - if read == 0 { - break; - } - request.extend_from_slice(&buffer[..read]); - } - break; - } - } - let request_text = String::from_utf8_lossy(&request); - let (headers_text, body) = request_text.split_once("\r\n\r\n").expect("request body"); - let mut header_lines = headers_text.lines(); - let request_line = header_lines.next().expect("request line").to_owned(); - let headers = header_lines - .filter_map(|line| { - let (name, value) = line.split_once(':')?; - Some((name.to_ascii_lowercase(), value.trim().to_owned())) - }) - .collect::<Vec<_>>(); - let response = format!( - "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {content_length}\r\nconnection: close\r\n\r\n{response_body}", - ); - stream - .write_all(response.as_bytes()) - .expect("write response"); - RecordedHttpRequest { - request_line, - headers, - body: body.to_owned(), - } - }); - (endpoint, handle) -} - -fn signed_event() -> SignedEvent { - let mut wire = Nip01EventWire { - id: String::new(), - pubkey: SIGNED_EVENT_PUBLIC_KEY.to_owned(), - created_at: 1_700_000_000, - kind: 30_402, - tags: vec![vec!["d".to_owned(), "listing-1".to_owned()]], - content: "{\"name\":\"carrots\"}".to_owned(), - sig: "c".repeat(128), - extra: Default::default(), - }; - wire.id = wire.computed_event_id().expect("event id").into_string(); - let raw_json = serde_json::to_string(&wire).expect("raw event json"); - SignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event") -} - -fn publish_request() -> TransportPublishEventRequest { - TransportPublishEventRequest { - raw_event_json: signed_event().raw_json().to_owned(), - target_policy: TransportPublishTargetPolicy::nostr( - NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, - vec!["wss://relay.example.com".to_owned()], - ), - delivery_policy: TransportPublishDeliveryPolicy::Any, - idempotency_key: Some("idem-1".to_owned()), - timeout_ms: Some(10_000), - } -} - -fn signed_event_id() -> String { - signed_event().id_str().to_owned() -} - -fn signed_event_pubkey() -> String { - signed_event().pubkey().to_hex().to_owned() -} - -fn job_status_for_outcome(outcome_kind: TransportPublishOutcomeKind) -> TransportPublishJobStatus { - if outcome_kind.counts_toward_accepted_delivery() { - TransportPublishJobStatus::DeliverySatisfied - } else if outcome_kind.is_retryable() { - TransportPublishJobStatus::DeliveryUnsatisfiedRetryable - } else if outcome_kind.is_terminal_failure() { - TransportPublishJobStatus::DeliveryUnsatisfiedTerminal - } else if outcome_kind == TransportPublishOutcomeKind::DeferredUntilImplemented { - TransportPublishJobStatus::DeliveryDeferred - } else { - TransportPublishJobStatus::DeliveryUnsatisfiedRetryable - } -} - -fn job(outcome_kind: TransportPublishOutcomeKind) -> TransportPublishJobView { - let status = job_status_for_outcome(outcome_kind); - TransportPublishJobView { - job_id: "job-1".to_owned(), - status, - terminal: matches!( - status, - TransportPublishJobStatus::DeliverySatisfied - | TransportPublishJobStatus::DeliveryUnsatisfiedTerminal - | TransportPublishJobStatus::DeliveryDeferred - | TransportPublishJobStatus::DeliveryDeferredUntilImplemented - | TransportPublishJobStatus::Rejected - ), - delivery_satisfied: status == TransportPublishJobStatus::DeliverySatisfied, - event_id: signed_event_id(), - pubkey: signed_event_pubkey(), - event_kind: 30_402, - target_policy: TransportPublishTargetPolicy::nostr( - NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, - vec!["wss://relay.example.com".to_owned()], - ), - delivery_policy: TransportPublishDeliveryPolicy::Any, - target_count: 1, - acknowledged_count: usize::from(outcome_kind.counts_toward_accepted_delivery()), - retryable_count: usize::from(outcome_kind.is_retryable()), - terminal_count: usize::from(outcome_kind.is_terminal_failure()), - requested_at_ms: 1_700_000_000_000, - completed_at_ms: Some(1_700_000_000_100), - last_error: None, - targets: vec![TransportPublishTargetOutcome { - transport_kind: "nostr".to_owned(), - endpoint_uri: "wss://relay.example.com".to_owned(), - target_scope: None, - target_label: None, - source: TransportPublishTargetSource::Request, - attempted: true, - outcome_kind, - message: Some("relay outcome".to_owned()), - latency_ms: Some(7), - }], - } -} - -fn explicit_nostr_job( - endpoints: Vec<String>, - delivery_policy: TransportPublishDeliveryPolicy, -) -> TransportPublishJobView { - let targets = endpoints - .iter() - .map(|endpoint| TransportPublishTargetOutcome { - transport_kind: "nostr".to_owned(), - endpoint_uri: endpoint.clone(), - target_scope: None, - target_label: None, - source: TransportPublishTargetSource::Request, - attempted: true, - outcome_kind: TransportPublishOutcomeKind::Accepted, - message: Some("relay outcome".to_owned()), - latency_ms: Some(7), - }) - .collect::<Vec<_>>(); - TransportPublishJobView { - job_id: "job-explicit-nostr".to_owned(), - status: TransportPublishJobStatus::DeliverySatisfied, - terminal: true, - delivery_satisfied: true, - event_id: signed_event_id(), - pubkey: signed_event_pubkey(), - event_kind: 30_402, - target_policy: TransportPublishTargetPolicy::explicit_targets( - endpoints - .iter() - .map(|endpoint| TransportPublishTarget::nostr(endpoint.as_str())) - .collect::<Vec<_>>(), - ), - delivery_policy, - target_count: targets.len(), - acknowledged_count: targets.len(), - retryable_count: 0, - terminal_count: 0, - requested_at_ms: 1_700_000_000_000, - completed_at_ms: Some(1_700_000_000_100), - last_error: None, - targets, - } -} - -fn reticulum_deferred_job() -> TransportPublishJobView { - TransportPublishJobView { - job_id: "job-reticulum".to_owned(), - status: TransportPublishJobStatus::DeliveryDeferred, - terminal: true, - delivery_satisfied: false, - event_id: signed_event_id(), - pubkey: signed_event_pubkey(), - event_kind: 30_402, - target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ - TransportPublishTarget::reticulum( - TransportPublishReticulumBehavior::DeferDeliveryPlans, - ), - ]), - delivery_policy: TransportPublishDeliveryPolicy::Any, - target_count: 1, - acknowledged_count: 0, - retryable_count: 0, - terminal_count: 0, - requested_at_ms: 1_700_000_000_000, - completed_at_ms: Some(1_700_000_000_100), - last_error: Some("delivery_deferred_until_implemented".to_owned()), - targets: vec![TransportPublishTargetOutcome { - transport_kind: "reticulum".to_owned(), - endpoint_uri: RADROOTS_RETICULUM_ENDPOINT_URI.to_owned(), - target_scope: None, - target_label: None, - source: TransportPublishTargetSource::Reticulum, - attempted: false, - outcome_kind: TransportPublishOutcomeKind::DeferredUntilImplemented, - message: Some("reticulum deferred until implemented".to_owned()), - latency_ms: None, - }], - } -} - -fn publish_response_json_for_job(job: TransportPublishJobView) -> String { - serde_json::json!({ - "jsonrpc": "2.0", - "id": SDK_RADROOTSD_PUBLISH_REQUEST_ID, - "result": { - "deduplicated": false, - "job": job - } - }) - .to_string() -} - -fn publish_response_json() -> String { - publish_response_json_for_job(job(TransportPublishOutcomeKind::Accepted)) -} - -fn reticulum_deferred_response_json() -> String { - serde_json::json!({ - "jsonrpc": "2.0", - "id": SDK_RADROOTSD_PUBLISH_REQUEST_ID, - "result": { - "deduplicated": false, - "job": reticulum_deferred_job() - } - }) - .to_string() -} - -fn assert_message(error: RadrootsdError, fragment: &str) { - let message = error.to_string(); - assert!( - message.contains(fragment), - "expected {message:?} to contain {fragment:?}" - ); -} - -#[test] -fn auth_headers_omit_or_redact_bearer_authorization() { - let none = auth_headers(&RadrootsdAuth::None).expect("none auth"); - assert!(!none.contains_key(AUTHORIZATION)); - assert_eq!(format!("{:?}", RadrootsdAuth::None), "None"); - - let bearer = auth_headers(&RadrootsdAuth::BearerToken("sdk-token".into())).expect("bearer"); - assert_eq!( - bearer - .get(AUTHORIZATION) - .expect("authorization") - .to_str() - .expect("authorization str"), - "Bearer sdk-token" - ); - - let error = auth_headers(&RadrootsdAuth::BearerToken("bad\ntoken".into())).expect_err("error"); - assert!(matches!(error, RadrootsdError::InvalidAuthHeader(_))); - assert_eq!( - format!("{:?}", RadrootsdAuth::BearerToken("token-secret".into())), - "BearerToken(<redacted>)" - ); - assert!( - RadrootsdError::InvalidAuthHeader("bad header".to_owned()) - .to_string() - .contains("invalid radrootsd bearer token header") - ); - assert_eq!( - RadrootsdError::InvalidRequest("invalid request".to_owned()).to_string(), - "invalid request" - ); - assert_eq!( - RadrootsdError::Http("http failed".to_owned()).to_string(), - "http failed" - ); - assert_eq!( - RadrootsdError::MalformedResponse("bad envelope".to_owned()).to_string(), - "bad envelope" - ); -} - -#[test] -fn radrootsd_publish_config_builders_preserve_typed_runtime_options() { - let config = RadrootsdPublishConfig::new("http://127.0.0.1:8080/rpc") - .with_auth(RadrootsdAuth::BearerToken("sdk-token".to_owned())) - .with_timeout(Duration::from_millis(250)); - let adapter = RadrootsdPublishAdapter::new(config.clone()); - - assert_eq!(adapter.config(), &config); - assert_eq!(adapter.config().endpoint, "http://127.0.0.1:8080/rpc"); - assert_eq!( - adapter.config().auth, - RadrootsdAuth::BearerToken("sdk-token".to_owned()) - ); - assert_eq!(adapter.config().timeout, Duration::from_millis(250)); -} - -#[test] -fn publish_event_request_json_uses_signed_event_contract() { - let value = publish_event_request_json(&publish_request()).expect("request json"); - - let raw_event_json = value["raw_event_json"].as_str().expect("raw event json"); - let raw_event: serde_json::Value = serde_json::from_str(raw_event_json).expect("raw event"); - assert_eq!(raw_event["id"], signed_event_id()); - assert_eq!(raw_event["pubkey"], SIGNED_EVENT_PUBLIC_KEY); - assert_eq!(raw_event["kind"], 30_402); - assert_eq!(value["target_policy"]["kind"], "nostr"); - assert_eq!( - value["target_policy"]["source_policy"], - "request_then_author_write_then_daemon_default" - ); - assert_eq!( - value["target_policy"]["relay_urls"][0], - "wss://relay.example.com" - ); - assert_eq!(value["delivery_policy"]["mode"], "any"); - assert_eq!(value["idempotency_key"], "idem-1"); - let rendered = value.to_string(); - assert!(!rendered.contains("signer_session_id")); - assert!(!rendered.contains("bridge.")); -} - -#[test] -fn decode_jsonrpc_response_validates_envelope_and_errors() { - let response: TransportPublishEventResponse = decode_jsonrpc_response( - METHOD_EVENT, - SDK_RADROOTSD_PUBLISH_REQUEST_ID, - publish_response_json().as_str(), - ) - .expect("response"); - assert_eq!(response.job.event_id, signed_event_id()); - - let error = decode_jsonrpc_response::<TransportPublishEventResponse>( - METHOD_EVENT, - SDK_RADROOTSD_PUBLISH_REQUEST_ID, - r#"{"jsonrpc":"2.0","id":"radroots-sdk-transport-publish-event","error":{"code":-32001,"message":"principal unauthorized"}}"#, - ) - .expect_err("jsonrpc error"); - assert!(matches!( - error, - RadrootsdError::JsonRpc { code: -32001, .. } - )); - assert_message(error, "principal unauthorized"); - - assert!(matches!( - decode_jsonrpc_response::<serde_json::Value>(METHOD_EVENT, "expected", "not json"), - Err(RadrootsdError::MalformedResponse(_)) - )); - assert!(matches!( - decode_jsonrpc_response::<serde_json::Value>( - METHOD_EVENT, - "expected", - r#"{"jsonrpc":"2.0","id":"other","result":{}}"# - ), - Err(RadrootsdError::MalformedResponse(_)) - )); - assert!(matches!( - decode_jsonrpc_response::<serde_json::Value>( - METHOD_EVENT, - "expected", - r#"{"jsonrpc":"2.0","id":"expected"}"# - ), - Err(RadrootsdError::MalformedResponse(_)) - )); - assert!(matches!( - decode_jsonrpc_response::<serde_json::Value>( - METHOD_EVENT, - "expected", - r#"{"jsonrpc":"1.0","id":"expected","result":{}}"# - ), - Err(RadrootsdError::MalformedResponse(_)) - )); - assert!(matches!( - decode_jsonrpc_response::<serde_json::Value>( - METHOD_EVENT, - "expected", - r#"{"jsonrpc":"2.0","id":"expected","result":{},"error":{"code":-32002,"message":"both"}}"# - ), - Err(RadrootsdError::MalformedResponse(_)) - )); -} - -#[tokio::test] -async fn publish_event_posts_transport_publish_jsonrpc() { - let (endpoint, handle) = spawn_http_server("200 OK", publish_response_json().as_str()); - - let receipt = publish_event( - endpoint.as_str(), - &RadrootsdAuth::BearerToken("sdk-token".into()), - &publish_request(), - Duration::from_secs(2), - ) - .await - .expect("publish"); - - assert_eq!(receipt.job.event_id, signed_event_id()); - let recorded = handle.join().expect("server thread"); - assert_eq!(recorded.request_line, "POST /rpc HTTP/1.1"); - assert!( - recorded - .headers - .iter() - .any(|(name, value)| name == "authorization" && value == "Bearer sdk-token") - ); - let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body"); - assert_eq!(body["method"], METHOD_EVENT); - assert_eq!(body["id"], SDK_RADROOTSD_PUBLISH_REQUEST_ID); - let raw_event_json = body["params"]["raw_event_json"] - .as_str() - .expect("raw event json"); - let raw_event: serde_json::Value = serde_json::from_str(raw_event_json).expect("raw event"); - assert_eq!(raw_event["content"], "{\"name\":\"carrots\"}"); - assert_eq!(body["params"]["target_policy"]["kind"], "nostr"); - assert_eq!( - body["params"]["target_policy"]["relay_urls"][0], - "wss://relay.example.com" - ); -} - -#[tokio::test] -async fn publish_signed_event_posts_typed_radrootsd_request() { - let mut response_job = explicit_nostr_job( - vec!["wss://relay.example.com".to_owned()], - TransportPublishDeliveryPolicy::All, - ); - response_job.target_policy = TransportPublishTargetPolicy::explicit_targets(vec![ - TransportPublishTarget::nostr("wss://relay.example.com") - .with_scope("farm.local") - .with_label("Farm relay"), - ]); - response_job.targets[0].target_scope = Some("farm.local".to_owned()); - response_job.targets[0].target_label = Some("Farm relay".to_owned()); - let response_json = publish_response_json_for_job(response_job); - let (endpoint, handle) = spawn_http_server("200 OK", response_json.as_str()); - let adapter = RadrootsdPublishAdapter::new( - RadrootsdPublishConfig::new(endpoint) - .with_auth(RadrootsdAuth::BearerToken("sdk-token".into())), - ); - - let receipt = adapter - .publish_signed_event(RadrootsdPublishRequest { - signed_event: signed_event(), - target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ - TransportPublishTarget::nostr("wss://relay.example.com") - .with_scope("farm.local") - .with_label("Farm relay"), - ]), - delivery_policy: TransportPublishDeliveryPolicy::All, - idempotency_key: Some("idem-typed".to_owned()), - timeout_ms: Some(7_000), - }) - .await - .expect("typed publish"); - - assert!(receipt.job.delivery_satisfied); - assert_eq!( - receipt.job.targets[0].target_scope.as_deref(), - Some("farm.local") - ); - assert_eq!( - receipt.job.targets[0].target_label.as_deref(), - Some("Farm relay") - ); - let recorded = handle.join().expect("server thread"); - assert!( - recorded - .headers - .iter() - .any(|(name, value)| name == "authorization" && value == "Bearer sdk-token") - ); - let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body"); - assert_eq!(body["params"]["delivery_policy"]["mode"], "all"); - assert_eq!(body["params"]["target_policy"]["kind"], "explicit_targets"); - assert_eq!( - body["params"]["target_policy"]["targets"][0]["endpoint_uri"], - "wss://relay.example.com" - ); - assert_eq!( - body["params"]["target_policy"]["targets"][0]["target_scope"], - "farm.local" - ); - assert_eq!( - body["params"]["target_policy"]["targets"][0]["target_label"], - "Farm relay" - ); - assert_eq!(body["params"]["idempotency_key"], "idem-typed"); - assert_eq!(body["params"]["timeout_ms"], 7_000); -} - -#[tokio::test] -async fn publish_signed_event_preserves_typed_reticulum_behavior() { - let response_json = reticulum_deferred_response_json(); - let (endpoint, handle) = spawn_http_server("200 OK", response_json.as_str()); - let adapter = RadrootsdPublishAdapter::new(RadrootsdPublishConfig::new(endpoint)); - - let response = adapter - .publish_signed_event(RadrootsdPublishRequest { - signed_event: signed_event(), - target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ - TransportPublishTarget::reticulum( - TransportPublishReticulumBehavior::DeferDeliveryPlans, - ), - ]), - delivery_policy: TransportPublishDeliveryPolicy::Any, - idempotency_key: Some("idem-reticulum".to_owned()), - timeout_ms: None, - }) - .await - .expect("typed Reticulum publish request"); - assert_eq!( - response.job.status, - TransportPublishJobStatus::DeliveryDeferred - ); - assert!(!response.job.delivery_satisfied); - - let recorded = handle.join().expect("server thread"); - let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body"); - assert_eq!(body["params"]["target_policy"]["kind"], "explicit_targets"); - assert_eq!( - body["params"]["target_policy"]["targets"][0]["transport_kind"], - "reticulum" - ); - assert_eq!( - body["params"]["target_policy"]["targets"][0]["endpoint_uri"], - RADROOTS_RETICULUM_ENDPOINT_URI - ); - assert_eq!( - body["params"]["target_policy"]["targets"][0]["reticulum_behavior"], - "defer_delivery_plans" - ); -} - -#[tokio::test] -async fn publish_signed_event_rejects_mismatched_daemon_event_identity() { - let mut response_job = job(TransportPublishOutcomeKind::Accepted); - response_job.event_id = "0".repeat(64); - let response_json = publish_response_json_for_job(response_job); - let (endpoint, _handle) = spawn_http_server("200 OK", response_json.as_str()); - let adapter = RadrootsdPublishAdapter::new(RadrootsdPublishConfig::new(endpoint)); - - let error = adapter - .publish_signed_event(RadrootsdPublishRequest { - signed_event: signed_event(), - target_policy: TransportPublishTargetPolicy::nostr( - NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, - vec!["wss://relay.example.com".to_owned()], - ), - delivery_policy: TransportPublishDeliveryPolicy::Any, - idempotency_key: Some("idem-mismatch".to_owned()), - timeout_ms: None, - }) - .await - .expect_err("mismatched response"); - - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message(error, "event_id"); -} - -#[tokio::test] -async fn publish_signed_event_rejects_mismatched_daemon_pubkey_and_kind() { - for (field, response_job) in [ - { - let mut response_job = job(TransportPublishOutcomeKind::Accepted); - response_job.pubkey = "0".repeat(64); - ("pubkey", response_job) - }, - { - let mut response_job = job(TransportPublishOutcomeKind::Accepted); - response_job.event_kind = 30_403; - ("event_kind", response_job) - }, - ] { - let response_json = publish_response_json_for_job(response_job); - let (endpoint, _handle) = spawn_http_server("200 OK", response_json.as_str()); - let adapter = RadrootsdPublishAdapter::new(RadrootsdPublishConfig::new(endpoint)); - - let error = adapter - .publish_signed_event(RadrootsdPublishRequest { - signed_event: signed_event(), - target_policy: TransportPublishTargetPolicy::nostr( - NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, - vec!["wss://relay.example.com".to_owned()], - ), - delivery_policy: TransportPublishDeliveryPolicy::Any, - idempotency_key: Some(format!("idem-mismatch-{field}")), - timeout_ms: None, - }) - .await - .expect_err("mismatched response"); - - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message(error, field); - } -} - -#[tokio::test] -async fn publish_signed_event_rejects_mismatched_daemon_delivery_policy() { - let mut response_job = job(TransportPublishOutcomeKind::Accepted); - response_job.delivery_policy = TransportPublishDeliveryPolicy::All; - let response_json = publish_response_json_for_job(response_job); - let (endpoint, _handle) = spawn_http_server("200 OK", response_json.as_str()); - let adapter = RadrootsdPublishAdapter::new(RadrootsdPublishConfig::new(endpoint)); - - let error = adapter - .publish_signed_event(RadrootsdPublishRequest { - signed_event: signed_event(), - target_policy: TransportPublishTargetPolicy::nostr( - NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, - vec!["wss://relay.example.com".to_owned()], - ), - delivery_policy: TransportPublishDeliveryPolicy::Any, - idempotency_key: Some("idem-delivery-policy-mismatch".to_owned()), - timeout_ms: None, - }) - .await - .expect_err("delivery policy mismatch"); - - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message(error, "delivery_policy"); -} - -#[tokio::test] -async fn publish_signed_event_rejects_mismatched_explicit_target_response() { - let (endpoint, _handle) = spawn_http_server("200 OK", publish_response_json().as_str()); - let adapter = RadrootsdPublishAdapter::new(RadrootsdPublishConfig::new(endpoint)); - - let error = adapter - .publish_signed_event(RadrootsdPublishRequest { - signed_event: signed_event(), - target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ - TransportPublishTarget::reticulum( - TransportPublishReticulumBehavior::DeferDeliveryPlans, - ), - ]), - delivery_policy: TransportPublishDeliveryPolicy::Any, - idempotency_key: Some("idem-target-mismatch".to_owned()), - timeout_ms: None, - }) - .await - .expect_err("target mismatch"); - - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message(error, "target_policy"); -} - -#[tokio::test] -async fn publish_signed_event_accepts_reordered_explicit_target_outcomes() { - let mut response_job = explicit_nostr_job( - vec![ - "wss://relay-a.example.com".to_owned(), - "wss://relay-b.example.com".to_owned(), - ], - TransportPublishDeliveryPolicy::Any, - ); - response_job.targets.reverse(); - let response_json = publish_response_json_for_job(response_job); - let (endpoint, _handle) = spawn_http_server("200 OK", response_json.as_str()); - let adapter = RadrootsdPublishAdapter::new(RadrootsdPublishConfig::new(endpoint)); - - let response = adapter - .publish_signed_event(RadrootsdPublishRequest { - signed_event: signed_event(), - target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ - TransportPublishTarget::nostr("wss://relay-a.example.com"), - TransportPublishTarget::nostr("wss://relay-b.example.com"), - ]), - delivery_policy: TransportPublishDeliveryPolicy::Any, - idempotency_key: Some("idem-explicit-reordered-outcomes".to_owned()), - timeout_ms: None, - }) - .await - .expect("reordered explicit target outcomes"); - - assert!(response.job.delivery_satisfied); - assert_eq!( - response.job.targets[0].endpoint_uri, - "wss://relay-b.example.com" - ); - assert_eq!( - response.job.targets[1].endpoint_uri, - "wss://relay-a.example.com" - ); -} - -#[tokio::test] -async fn publish_signed_event_rejects_mismatched_explicit_target_outcomes() { - let mut response_job = explicit_nostr_job( - vec!["wss://relay.example.com".to_owned()], - TransportPublishDeliveryPolicy::Any, - ); - response_job.targets[0].endpoint_uri = "wss://relay-other.example.com".to_owned(); - let response_json = publish_response_json_for_job(response_job); - let (endpoint, _handle) = spawn_http_server("200 OK", response_json.as_str()); - let adapter = RadrootsdPublishAdapter::new(RadrootsdPublishConfig::new(endpoint)); - - let error = adapter - .publish_signed_event(RadrootsdPublishRequest { - signed_event: signed_event(), - target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ - TransportPublishTarget::nostr("wss://relay.example.com"), - ]), - delivery_policy: TransportPublishDeliveryPolicy::Any, - idempotency_key: Some("idem-explicit-outcome-mismatch".to_owned()), - timeout_ms: None, - }) - .await - .expect_err("explicit target outcome mismatch"); - - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message(error, "explicit target policy"); -} - -#[tokio::test] -async fn publish_signed_event_rejects_mismatched_scoped_explicit_target_outcomes() { - let mut response_job = explicit_nostr_job( - vec!["wss://relay.example.com".to_owned()], - TransportPublishDeliveryPolicy::Any, - ); - response_job.target_policy = TransportPublishTargetPolicy::explicit_targets(vec![ - TransportPublishTarget::nostr("wss://relay.example.com").with_scope("farm.local"), - ]); - response_job.targets[0].target_scope = Some("farm.remote".to_owned()); - let response_json = publish_response_json_for_job(response_job); - let (endpoint, _handle) = spawn_http_server("200 OK", response_json.as_str()); - let adapter = RadrootsdPublishAdapter::new(RadrootsdPublishConfig::new(endpoint)); - - let error = adapter - .publish_signed_event(RadrootsdPublishRequest { - signed_event: signed_event(), - target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ - TransportPublishTarget::nostr("wss://relay.example.com").with_scope("farm.local"), - ]), - delivery_policy: TransportPublishDeliveryPolicy::Any, - idempotency_key: Some("idem-scoped-explicit-outcome-mismatch".to_owned()), - timeout_ms: None, - }) - .await - .expect_err("scoped explicit target outcome mismatch"); - - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message(error, "explicit target policy"); -} - -#[tokio::test] -async fn publish_event_http_errors_omit_body_and_token_material() { - let body = "{\"error\":\"token-secret content carrots\"}"; - let (endpoint, _handle) = spawn_http_server("503 Service Unavailable", body); - - let error = publish_event( - endpoint.as_str(), - &RadrootsdAuth::BearerToken("token-secret".into()), - &publish_request(), - Duration::from_secs(2), - ) - .await - .expect_err("http error"); - let message = error.to_string(); - - assert!(message.contains("503")); - assert!(message.contains("response body omitted")); - assert!(!message.contains("token-secret")); - assert!(!message.contains("carrots")); -} - -#[tokio::test] -async fn publish_event_empty_http_error_reports_empty_body() { - let (endpoint, _handle) = spawn_http_server("500 Internal Server Error", ""); - - let error = publish_event( - endpoint.as_str(), - &RadrootsdAuth::None, - &publish_request(), - Duration::from_secs(2), - ) - .await - .expect_err("http error"); - - assert!(error.to_string().contains("response body empty")); -} - -#[tokio::test] -async fn publish_signed_event_rejects_invalid_target_requests_before_http() { - let adapter = - RadrootsdPublishAdapter::new(RadrootsdPublishConfig::new("http://127.0.0.1:9/rpc")); - let base = RadrootsdPublishRequest { - signed_event: signed_event(), - target_policy: TransportPublishTargetPolicy::nostr( - NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, - vec!["wss://relay.example.com".to_owned()], - ), - delivery_policy: TransportPublishDeliveryPolicy::Any, - idempotency_key: Some("idem-1".to_owned()), - timeout_ms: Some(1_000), - }; - - let mut invalid_quorum = base.clone(); - invalid_quorum.delivery_policy = TransportPublishDeliveryPolicy::Quorum { quorum: 0 }; - let mut too_many_targets = base.clone(); - too_many_targets.target_policy = TransportPublishTargetPolicy::nostr( - NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, - (0..=SDK_RADROOTSD_PUBLISH_MAX_TARGETS) - .map(|index| format!("wss://relay-{index}.example.com")) - .collect(), - ); - let mut empty_endpoint_uri = base.clone(); - empty_endpoint_uri.target_policy = TransportPublishTargetPolicy::nostr( - NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, - vec![" ".to_owned()], - ); - let mut nostr_reticulum_behavior = base.clone(); - nostr_reticulum_behavior.target_policy = - TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget { - transport_kind: "nostr".to_owned(), - endpoint_uri: "wss://relay.example.com".to_owned(), - target_scope: None, - target_label: None, - reticulum_behavior: Some(TransportPublishReticulumBehavior::RejectDeliveryAttempts), - }]); - let mut explicit_radrootsd_target = base.clone(); - explicit_radrootsd_target.target_policy = - TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget { - transport_kind: "radrootsd".to_owned(), - endpoint_uri: "radrootsd-execution:publish".to_owned(), - target_scope: None, - target_label: None, - reticulum_behavior: None, - }]); - let radrootsd_error = adapter - .publish_signed_event(explicit_radrootsd_target) - .await - .expect_err("explicit radrootsd target"); - assert!(matches!( - radrootsd_error, - RadrootsdError::InvalidRequest(message) - if message.contains("transport target 0 kind must be canonical lowercase") - )); - let mut empty_idempotency = base; - empty_idempotency.idempotency_key = Some(" ".to_owned()); - - for request in [ - invalid_quorum, - too_many_targets, - empty_endpoint_uri, - nostr_reticulum_behavior, - empty_idempotency, - ] { - assert!(matches!( - adapter.publish_signed_event(request).await, - Err(RadrootsdError::InvalidRequest(_)) - )); - } -} - -#[tokio::test] -async fn adapter_rejects_invalid_request_before_transport() { - let adapter = - RadrootsdPublishAdapter::new(RadrootsdPublishConfig::new("http://127.0.0.1:9/rpc")); - let request = RadrootsdPublishRequest { - signed_event: signed_event(), - target_policy: TransportPublishTargetPolicy::nostr( - NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, - Vec::new(), - ), - delivery_policy: TransportPublishDeliveryPolicy::Quorum { quorum: 0 }, - idempotency_key: None, - timeout_ms: None, - }; - - let error = adapter - .publish_signed_event(request) - .await - .expect_err("invalid request"); - - assert!(matches!(error, RadrootsdError::InvalidRequest(_))); -} diff --git a/crates/sql_wasm_runtime/Cargo.toml b/crates/sql_wasm_runtime/Cargo.toml @@ -1,24 +0,0 @@ -[package] -name = "radroots_sdk_sql_wasm_runtime" -publish = false -version = "0.1.0-alpha" -edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] -rust-version.workspace = true -license.workspace = true -description = "SQL WASM runtime for Radroots SDK data surfaces" -repository.workspace = true -homepage.workspace = true - -[lib] -crate-type = ["rlib"] - -[dependencies] -radroots_sql_core = { workspace = true, features = ["web"] } -serde = { workspace = true } -serde_json = { workspace = true } -serde-wasm-bindgen = { workspace = true } -wasm-bindgen = { workspace = true } - -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/sql_wasm_runtime/src/lib.rs b/crates/sql_wasm_runtime/src/lib.rs @@ -1,534 +0,0 @@ -#![forbid(unsafe_code)] - -use radroots_sql_core::{ExecOutcome, SqlError, SqlExecutor, utils}; -use serde::de::DeserializeOwned; -use serde_json::Value; -use std::cell::Cell; -use std::sync::atomic::{AtomicBool, Ordering}; -use wasm_bindgen::JsValue; - -#[cfg(target_arch = "wasm32")] -use wasm_bindgen::prelude::*; - -#[cfg(target_arch = "wasm32")] -#[wasm_bindgen] -extern "C" { - #[wasm_bindgen(js_name = __radroots_sql_wasm_exec)] - fn js_exec(sql: &str, params_json: &str) -> JsValue; - - #[wasm_bindgen(js_name = __radroots_sql_wasm_query)] - fn js_query(sql: &str, params_json: &str) -> JsValue; - - #[wasm_bindgen(js_name = __radroots_sql_wasm_export_bytes)] - fn js_export_bytes() -> JsValue; -} - -#[cfg(not(target_arch = "wasm32"))] -use std::sync::{Mutex, OnceLock}; - -#[cfg(all(not(target_arch = "wasm32"), test))] -use std::collections::VecDeque; - -#[cfg(not(target_arch = "wasm32"))] -type RecordedCall = (String, String); - -#[cfg(all(not(target_arch = "wasm32"), test))] -type NativeHostResult = Result<Value, SqlError>; - -#[cfg(not(target_arch = "wasm32"))] -fn exec_calls() -> &'static Mutex<Vec<RecordedCall>> { - static EXEC_CALLS: OnceLock<Mutex<Vec<RecordedCall>>> = OnceLock::new(); - EXEC_CALLS.get_or_init(|| Mutex::new(Vec::new())) -} - -#[cfg(not(target_arch = "wasm32"))] -fn query_calls() -> &'static Mutex<Vec<RecordedCall>> { - static QUERY_CALLS: OnceLock<Mutex<Vec<RecordedCall>>> = OnceLock::new(); - QUERY_CALLS.get_or_init(|| Mutex::new(Vec::new())) -} - -#[cfg(not(target_arch = "wasm32"))] -fn export_calls() -> &'static Mutex<u64> { - static EXPORT_CALLS: OnceLock<Mutex<u64>> = OnceLock::new(); - EXPORT_CALLS.get_or_init(|| Mutex::new(0)) -} - -#[cfg(all(not(target_arch = "wasm32"), test))] -fn exec_results() -> &'static Mutex<VecDeque<NativeHostResult>> { - static EXEC_RESULTS: OnceLock<Mutex<VecDeque<NativeHostResult>>> = OnceLock::new(); - EXEC_RESULTS.get_or_init(|| Mutex::new(VecDeque::new())) -} - -#[cfg(all(not(target_arch = "wasm32"), test))] -fn query_results() -> &'static Mutex<VecDeque<NativeHostResult>> { - static QUERY_RESULTS: OnceLock<Mutex<VecDeque<NativeHostResult>>> = OnceLock::new(); - QUERY_RESULTS.get_or_init(|| Mutex::new(VecDeque::new())) -} - -#[cfg(all(not(target_arch = "wasm32"), test))] -fn push_exec_result(result: NativeHostResult) { - let mut results = exec_results().lock().expect("exec results lock"); - results.push_back(result); -} - -#[cfg(all(not(target_arch = "wasm32"), test))] -fn push_query_result(result: NativeHostResult) { - let mut results = query_results().lock().expect("query results lock"); - results.push_back(result); -} - -#[cfg(all(not(target_arch = "wasm32"), test))] -fn take_exec_result() -> Option<NativeHostResult> { - exec_results() - .lock() - .expect("exec results lock") - .pop_front() -} - -#[cfg(all(not(target_arch = "wasm32"), test))] -fn take_query_result() -> Option<NativeHostResult> { - query_results() - .lock() - .expect("query results lock") - .pop_front() -} - -#[cfg(not(target_arch = "wasm32"))] -fn js_exec(sql: &str, params_json: &str) -> JsValue { - let mut calls = exec_calls().lock().expect("exec calls lock"); - calls.push((sql.to_string(), params_json.to_string())); - JsValue::NULL -} - -#[cfg(not(target_arch = "wasm32"))] -fn js_query(sql: &str, params_json: &str) -> JsValue { - let mut calls = query_calls().lock().expect("query calls lock"); - calls.push((sql.to_string(), params_json.to_string())); - JsValue::NULL -} - -#[cfg(not(target_arch = "wasm32"))] -fn js_export_bytes() -> JsValue { - let mut calls = export_calls().lock().expect("export calls lock"); - *calls += 1; - JsValue::NULL -} - -const SAVEPOINT: &str = "radroots_schema_tx"; -const EXPORT_LOCK_ERR: &str = "replica db export in progress"; - -static EXPORT_LOCK_ACTIVE: AtomicBool = AtomicBool::new(false); - -thread_local! { - static EXPORT_LOCK_BYPASS: Cell<bool> = const { Cell::new(false) }; -} - -pub struct WasmSqlExecutor; - -impl WasmSqlExecutor { - pub fn new() -> Self { - Self - } -} - -impl Default for WasmSqlExecutor { - fn default() -> Self { - Self::new() - } -} - -impl SqlExecutor for WasmSqlExecutor { - fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError> { - if export_lock_blocked() { - return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); - } - let v = host_exec_json(sql, params_json)?; - Ok(exec_outcome_from_json(&v)) - } - - fn query_raw(&self, sql: &str, params_json: &str) -> Result<String, SqlError> { - let v = host_query_json(sql, params_json)?; - Ok(query_raw_from_json(&v)) - } - - fn begin(&self) -> Result<(), SqlError> { - if export_lock_blocked() { - return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); - } - begin_tx(); - Ok(()) - } - - fn commit(&self) -> Result<(), SqlError> { - if export_lock_blocked() { - return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); - } - commit_tx(); - Ok(()) - } - - fn rollback(&self) -> Result<(), SqlError> { - if export_lock_blocked() { - return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); - } - rollback_tx(); - Ok(()) - } -} - -pub fn parse_json<T: DeserializeOwned>(s: &str) -> Result<T, SqlError> { - utils::parse_json(s) -} - -fn host_exec_json(sql: &str, params_json: &str) -> Result<Value, SqlError> { - let js = exec(sql, params_json); - #[cfg(all(not(target_arch = "wasm32"), test))] - if let Some(result) = take_exec_result() { - return result; - } - sql_value_from_js(js) -} - -fn host_query_json(sql: &str, params_json: &str) -> Result<Value, SqlError> { - let js = query(sql, params_json); - #[cfg(all(not(target_arch = "wasm32"), test))] - if let Some(result) = take_query_result() { - return result; - } - sql_value_from_js(js) -} - -#[cfg(target_arch = "wasm32")] -fn sql_value_from_js(js: JsValue) -> Result<Value, SqlError> { - serde_wasm_bindgen::from_value(js).map_err(|e| SqlError::SerializationError(e.to_string())) -} - -#[cfg(not(target_arch = "wasm32"))] -fn sql_value_from_js(_js: JsValue) -> Result<Value, SqlError> { - Err(SqlError::UnsupportedPlatform) -} - -fn exec_outcome_from_json(v: &Value) -> ExecOutcome { - let changes = v.get("changes").and_then(|x| x.as_i64()).unwrap_or(0); - let last_insert_id = v - .get("last_insert_id") - .or_else(|| v.get("lastInsertRowid")) - .and_then(|x| x.as_i64()) - .unwrap_or(0); - ExecOutcome { - changes, - last_insert_id, - } -} - -fn query_raw_from_json(v: &Value) -> String { - v.to_string() -} - -pub fn err_js(err: SqlError) -> JsValue { - err_js_value(err) -} - -#[cfg(target_arch = "wasm32")] -fn err_js_value(err: SqlError) -> JsValue { - match err_js_with_encoder(err, |err| { - let value = err.to_json(); - serde_wasm_bindgen::to_value(&value).map_err(|_| ()) - }) { - Ok(value) => value, - Err(err) => JsValue::from_str(&err.to_string()), - } -} - -#[cfg(not(target_arch = "wasm32"))] -fn err_js_value(err: SqlError) -> JsValue { - let _ = err.to_json(); - JsValue::NULL -} - -#[cfg(target_arch = "wasm32")] -fn err_js_with_encoder( - err: SqlError, - encode: impl FnOnce(&SqlError) -> Result<JsValue, ()>, -) -> Result<JsValue, SqlError> { - encode(&err).map_err(|()| err) -} - -pub fn exec(sql: &str, params_json: &str) -> JsValue { - js_exec(sql, params_json) -} - -pub fn query(sql: &str, params_json: &str) -> JsValue { - js_query(sql, params_json) -} - -pub fn export_bytes() -> JsValue { - js_export_bytes() -} - -pub fn begin_tx() { - let _ = js_exec(&format!("savepoint {}", SAVEPOINT), "[]"); -} - -pub fn commit_tx() { - let _ = js_exec(&format!("release savepoint {}", SAVEPOINT), "[]"); -} - -pub fn rollback_tx() { - let _ = js_exec(&format!("rollback to savepoint {}", SAVEPOINT), "[]"); - let _ = js_exec(&format!("release savepoint {}", SAVEPOINT), "[]"); -} - -pub fn export_lock_begin() -> Result<(), SqlError> { - let was_active = EXPORT_LOCK_ACTIVE.swap(true, Ordering::SeqCst); - if was_active { - return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); - } - Ok(()) -} - -pub fn export_lock_end() { - EXPORT_LOCK_ACTIVE.store(false, Ordering::SeqCst); -} - -pub fn export_lock_active() -> bool { - EXPORT_LOCK_ACTIVE.load(Ordering::SeqCst) -} - -pub fn with_export_lock_bypass<T>(f: impl FnOnce() -> T) -> T { - EXPORT_LOCK_BYPASS.with(|flag| { - let prev = flag.replace(true); - let out = f(); - flag.set(prev); - out - }) -} - -fn export_lock_blocked() -> bool { - if !EXPORT_LOCK_ACTIVE.load(Ordering::SeqCst) { - return false; - } - EXPORT_LOCK_BYPASS.with(|flag| !flag.get()) -} - -#[cfg(test)] -mod tests { - use std::{ - collections::BTreeMap, - sync::{Mutex, OnceLock}, - }; - - use radroots_sql_core::{SqlError, SqlExecutor}; - use serde_json::json; - - use super::{ - WasmSqlExecutor, begin_tx, commit_tx, err_js, exec, exec_calls, exec_outcome_from_json, - exec_results, export_bytes, export_calls, export_lock_active, export_lock_begin, - export_lock_end, parse_json, push_exec_result, push_query_result, query, query_calls, - query_raw_from_json, query_results, rollback_tx, with_export_lock_bypass, - }; - - fn native_test_lock() -> &'static Mutex<()> { - static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - TEST_LOCK.get_or_init(|| Mutex::new(())) - } - - fn reset_native_state() { - exec_calls().lock().expect("exec calls lock").clear(); - query_calls().lock().expect("query calls lock").clear(); - *export_calls().lock().expect("export calls lock") = 0; - exec_results().lock().expect("exec results lock").clear(); - query_results().lock().expect("query results lock").clear(); - export_lock_end(); - } - - #[test] - fn parse_json_reports_valid_and_invalid_payloads() { - let parsed: BTreeMap<String, u64> = parse_json(r#"{"count":2}"#).expect("parse json"); - assert_eq!(parsed.get("count"), Some(&2)); - assert_eq!( - parse_json::<BTreeMap<String, u64>>("{").unwrap_err().code(), - "ERR_SERIALIZATION" - ); - } - - #[test] - fn err_js_accepts_sql_errors() { - let _ = err_js(SqlError::Internal); - let _ = err_js(SqlError::UnsupportedPlatform); - } - - #[test] - fn exec_query_export_delegate_to_js_hooks() { - let _guard = native_test_lock().lock().expect("native test lock"); - reset_native_state(); - - let _ = exec("select 1", "[]"); - let _ = query("select 2", "[1]"); - let _ = export_bytes(); - - let exec_len = exec_calls().lock().map(|calls| calls.len()).unwrap_or(0); - let query_len = query_calls().lock().map(|calls| calls.len()).unwrap_or(0); - let export_len = export_calls().lock().map(|calls| *calls).unwrap_or(0); - assert!(exec_len >= 1); - assert!(query_len >= 1); - assert!(export_len >= 1); - } - - #[test] - fn tx_helpers_emit_expected_savepoint_statements() { - let _guard = native_test_lock().lock().expect("native test lock"); - reset_native_state(); - - begin_tx(); - commit_tx(); - rollback_tx(); - - let calls = exec_calls() - .lock() - .map(|calls| calls.clone()) - .unwrap_or_default(); - assert!( - calls - .iter() - .any(|(sql, _)| sql == "savepoint radroots_schema_tx") - ); - assert!( - calls - .iter() - .any(|(sql, _)| sql == "release savepoint radroots_schema_tx") - ); - assert!( - calls - .iter() - .any(|(sql, _)| sql == "rollback to savepoint radroots_schema_tx") - ); - } - - #[test] - fn export_lock_tracks_state() { - let _guard = native_test_lock().lock().expect("native test lock"); - reset_native_state(); - - assert!(!export_lock_active()); - export_lock_begin().expect("begin export lock"); - assert!(export_lock_active()); - assert!(with_export_lock_bypass(|| true)); - export_lock_end(); - assert!(!export_lock_active()); - } - - #[test] - fn executor_decodes_exec_outcomes() { - let _guard = native_test_lock().lock().expect("native test lock"); - reset_native_state(); - - let executor = WasmSqlExecutor; - push_exec_result(Ok(json!({"changes": 2, "lastInsertRowid": 99}))); - let outcome = executor - .exec("insert into listing values (?)", r#"["bin-1"]"#) - .expect("exec outcome"); - assert_eq!(outcome.changes, 2); - assert_eq!(outcome.last_insert_id, 99); - - push_exec_result(Ok(json!({"changes": 3, "last_insert_id": 101}))); - let outcome = executor - .exec("update listing set qty = ?", "[1]") - .expect("exec outcome"); - assert_eq!(outcome.changes, 3); - assert_eq!(outcome.last_insert_id, 101); - - let default_outcome = exec_outcome_from_json(&json!({})); - assert_eq!(default_outcome.changes, 0); - assert_eq!(default_outcome.last_insert_id, 0); - - let calls = exec_calls().lock().expect("exec calls lock").clone(); - assert!(calls.iter().any(|(sql, params)| { - sql == "insert into listing values (?)" && params == r#"["bin-1"]"# - })); - } - - #[test] - fn executor_decodes_query_results_and_host_errors() { - let _guard = native_test_lock().lock().expect("native test lock"); - reset_native_state(); - - let executor = WasmSqlExecutor::new(); - let rows = json!([{"id": "listing-1"}]); - push_query_result(Ok(rows.clone())); - assert_eq!( - executor - .query_raw("select id from listing", "[]") - .expect("query rows"), - query_raw_from_json(&rows) - ); - - assert_eq!( - executor - .query_raw("select id from listing", "[]") - .unwrap_err() - .code(), - "ERR_UNSUPPORTED_PLATFORM" - ); - - push_exec_result(Err(SqlError::SerializationError( - "host response was not an object".to_string(), - ))); - assert_eq!( - executor - .exec("insert into listing values (?)", "[]") - .unwrap_err() - .code(), - "ERR_SERIALIZATION" - ); - assert_eq!( - executor - .exec("insert into listing values (?)", "[]") - .unwrap_err() - .code(), - "ERR_UNSUPPORTED_PLATFORM" - ); - } - - #[test] - fn export_lock_rejects_nested_lock_and_write_trait_calls() { - let _guard = native_test_lock().lock().expect("native test lock"); - reset_native_state(); - - let executor = WasmSqlExecutor::new(); - export_lock_begin().expect("begin export lock"); - assert_eq!( - export_lock_begin().unwrap_err().to_string(), - "invalid argument: replica db export in progress" - ); - assert_eq!( - executor - .exec("insert into listing values (?)", "[]") - .unwrap_err() - .code(), - "ERR_INVALID_ARGUMENT" - ); - assert_eq!(executor.begin().unwrap_err().code(), "ERR_INVALID_ARGUMENT"); - assert_eq!( - executor.commit().unwrap_err().code(), - "ERR_INVALID_ARGUMENT" - ); - assert_eq!( - executor.rollback().unwrap_err().code(), - "ERR_INVALID_ARGUMENT" - ); - - with_export_lock_bypass(|| { - push_exec_result(Ok(json!({"changes": 1, "last_insert_id": 7}))); - let outcome = executor - .exec("insert into listing values (?)", "[]") - .expect("bypassed exec"); - assert_eq!(outcome.changes, 1); - executor.begin().expect("bypassed begin"); - executor.commit().expect("bypassed commit"); - executor.rollback().expect("bypassed rollback"); - }); - assert!(export_lock_active()); - export_lock_end(); - } -} diff --git a/crates/trade_bindings/Cargo.toml b/crates/trade_bindings/Cargo.toml @@ -1,10 +0,0 @@ -[package] -name = "radroots_trade_bindings" -description = "Generated bindings for Radroots trade contracts" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -publish = false diff --git a/crates/trade_bindings/src/lib.rs b/crates/trade_bindings/src/lib.rs @@ -1,297 +0,0 @@ -//! Private inventory for canonical native trade bindings. -//! -//! The SDK generator authenticates the reviewed mapping against final -//! `radroots_trade`, `radroots_event`, and `radroots_core` owners. This crate -//! intentionally does not activate code generation in a public runtime crate. - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum TradeTypeDisposition { - SourceTradeRoot, - SourceTradeSupport, - EventsBindingImport, - SdkLocalPackageShape, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct TradeTypeInventoryEntry { - pub export_name: &'static str, - pub disposition: TradeTypeDisposition, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum TradeLargeIntegerPolicy { - JsonNumberSafeCount, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct TradeLargeIntegerPolicyEntry { - pub type_name: &'static str, - pub field_name: &'static str, - pub policy: TradeLargeIntegerPolicy, -} - -pub const TRADE_TYPE_INVENTORY: &[TradeTypeInventoryEntry] = &[ - event_import("RadrootsFarmRef"), - event_import("RadrootsOperationalListing"), - event_import("RadrootsOperationalListingAvailability"), - event_import("RadrootsOperationalListingBin"), - event_import("RadrootsOperationalListingDeliveryMethod"), - event_import("RadrootsOperationalListingProduct"), - event_import("RadrootsOperationalListingPublicLocation"), - event_import("RadrootsOperationalListingStatus"), - local_shape("RadrootsTradeFacetCount"), - source_root("RadrootsTradeAgreementStateV1"), - source_root("RadrootsTradeAttestationStateV1"), - source_root("RadrootsTradeConflictStateV1"), - source_root("RadrootsTradeEvidenceStateV1"), - source_root("RadrootsTradeFulfillmentStateV1"), - source_root("RadrootsOperationalListingTradeProjection"), - local_shape("RadrootsTradeListingBackofficeOverlay"), - local_shape("RadrootsTradeListingBackofficeQuery"), - local_shape("RadrootsTradeListingBackofficeView"), - local_shape("RadrootsTradeListingBinProjection"), - local_shape("RadrootsTradeListingFacets"), - local_shape("RadrootsTradeListingMarketStatus"), - local_shape("RadrootsTradeListingProjection"), - local_shape("RadrootsTradeListingQuery"), - local_shape("RadrootsTradeListingSort"), - local_shape("RadrootsTradeListingSortField"), - source_root("RadrootsOperationalListingSubtotal"), - source_root("RadrootsOperationalListingTotal"), - local_shape("RadrootsTradeMarketplaceListingSummary"), - local_shape("RadrootsTradeModerationFlag"), - local_shape("RadrootsTradeModerationSeverity"), - local_shape("RadrootsTradeModerationStatus"), - source_root("RadrootsTradeNegotiationStateV1"), - source_root("RadrootsTradePaymentStateV1"), - source_root("RadrootsTradePrivateTermsStateV1"), - source_root("RadrootsTradeProjectionV1"), - local_shape("RadrootsTradeReviewPriority"), - local_shape("RadrootsTradeReviewQueueEntry"), - local_shape("RadrootsTradeReviewStatus"), - local_shape("RadrootsTradeSortDirection"), -]; - -pub const TRADE_LARGE_INTEGER_POLICIES: &[TradeLargeIntegerPolicyEntry] = &[ - json_number_safe_count("RadrootsTradeFacetCount", "count"), - json_number_safe_count( - "RadrootsTradeListingBackofficeView", - "open_moderation_flag_count", - ), - json_number_safe_count("RadrootsTradeListingProjection", "trade_count"), - json_number_safe_count("RadrootsTradeListingProjection", "open_trade_count"), - json_number_safe_count("RadrootsTradeListingProjection", "terminal_trade_count"), - json_number_safe_count("RadrootsTradeMarketplaceListingSummary", "trade_count"), - json_number_safe_count("RadrootsTradeMarketplaceListingSummary", "open_trade_count"), - json_number_safe_count( - "RadrootsTradeMarketplaceListingSummary", - "terminal_trade_count", - ), -]; - -const fn source_root(export_name: &'static str) -> TradeTypeInventoryEntry { - TradeTypeInventoryEntry { - export_name, - disposition: TradeTypeDisposition::SourceTradeRoot, - } -} - -const fn event_import(export_name: &'static str) -> TradeTypeInventoryEntry { - TradeTypeInventoryEntry { - export_name, - disposition: TradeTypeDisposition::EventsBindingImport, - } -} - -const fn local_shape(export_name: &'static str) -> TradeTypeInventoryEntry { - TradeTypeInventoryEntry { - export_name, - disposition: TradeTypeDisposition::SdkLocalPackageShape, - } -} - -const fn json_number_safe_count( - type_name: &'static str, - field_name: &'static str, -) -> TradeLargeIntegerPolicyEntry { - TradeLargeIntegerPolicyEntry { - type_name, - field_name, - policy: TradeLargeIntegerPolicy::JsonNumberSafeCount, - } -} - -#[cfg(test)] -mod tests { - use super::{TRADE_LARGE_INTEGER_POLICIES, TRADE_TYPE_INVENTORY, TradeTypeDisposition}; - - #[test] - fn trade_type_inventory_is_deterministic() { - let expected = TRADE_TYPE_INVENTORY - .iter() - .map(|entry| entry.export_name) - .collect::<Vec<_>>(); - - assert_eq!( - expected, - [ - "RadrootsFarmRef", - "RadrootsOperationalListing", - "RadrootsOperationalListingAvailability", - "RadrootsOperationalListingBin", - "RadrootsOperationalListingDeliveryMethod", - "RadrootsOperationalListingProduct", - "RadrootsOperationalListingPublicLocation", - "RadrootsOperationalListingStatus", - "RadrootsTradeFacetCount", - "RadrootsTradeAgreementStateV1", - "RadrootsTradeAttestationStateV1", - "RadrootsTradeConflictStateV1", - "RadrootsTradeEvidenceStateV1", - "RadrootsTradeFulfillmentStateV1", - "RadrootsOperationalListingTradeProjection", - "RadrootsTradeListingBackofficeOverlay", - "RadrootsTradeListingBackofficeQuery", - "RadrootsTradeListingBackofficeView", - "RadrootsTradeListingBinProjection", - "RadrootsTradeListingFacets", - "RadrootsTradeListingMarketStatus", - "RadrootsTradeListingProjection", - "RadrootsTradeListingQuery", - "RadrootsTradeListingSort", - "RadrootsTradeListingSortField", - "RadrootsOperationalListingSubtotal", - "RadrootsOperationalListingTotal", - "RadrootsTradeMarketplaceListingSummary", - "RadrootsTradeModerationFlag", - "RadrootsTradeModerationSeverity", - "RadrootsTradeModerationStatus", - "RadrootsTradeNegotiationStateV1", - "RadrootsTradePaymentStateV1", - "RadrootsTradePrivateTermsStateV1", - "RadrootsTradeProjectionV1", - "RadrootsTradeReviewPriority", - "RadrootsTradeReviewQueueEntry", - "RadrootsTradeReviewStatus", - "RadrootsTradeSortDirection" - ] - ); - } - - #[test] - fn source_owned_trade_support_types_are_marked_for_event_import() { - for export_name in [ - "RadrootsFarmRef", - "RadrootsOperationalListing", - "RadrootsOperationalListingAvailability", - "RadrootsOperationalListingBin", - "RadrootsOperationalListingDeliveryMethod", - "RadrootsOperationalListingProduct", - "RadrootsOperationalListingPublicLocation", - "RadrootsOperationalListingStatus", - ] { - assert_eq!( - disposition(export_name), - TradeTypeDisposition::EventsBindingImport - ); - } - } - - #[test] - fn trade_source_roots_are_marked_for_source_registry() { - let source_roots = TRADE_TYPE_INVENTORY - .iter() - .filter(|entry| entry.disposition == TradeTypeDisposition::SourceTradeRoot) - .map(|entry| entry.export_name) - .collect::<Vec<_>>(); - - assert_eq!( - source_roots, - [ - "RadrootsTradeAgreementStateV1", - "RadrootsTradeAttestationStateV1", - "RadrootsTradeConflictStateV1", - "RadrootsTradeEvidenceStateV1", - "RadrootsTradeFulfillmentStateV1", - "RadrootsOperationalListingTradeProjection", - "RadrootsOperationalListingSubtotal", - "RadrootsOperationalListingTotal", - "RadrootsTradeNegotiationStateV1", - "RadrootsTradePaymentStateV1", - "RadrootsTradePrivateTermsStateV1", - "RadrootsTradeProjectionV1" - ] - ); - } - - #[test] - fn trade_source_support_types_are_marked_for_source_registry() { - assert!( - TRADE_TYPE_INVENTORY - .iter() - .all(|entry| entry.disposition != TradeTypeDisposition::SourceTradeSupport) - ); - } - - #[test] - fn trade_large_integer_policy_covers_current_count_fields() { - let actual = TRADE_LARGE_INTEGER_POLICIES - .iter() - .map(|entry| (entry.type_name, entry.field_name, entry.policy)) - .collect::<Vec<_>>(); - - assert_eq!( - actual, - [ - ( - "RadrootsTradeFacetCount", - "count", - super::TradeLargeIntegerPolicy::JsonNumberSafeCount - ), - ( - "RadrootsTradeListingBackofficeView", - "open_moderation_flag_count", - super::TradeLargeIntegerPolicy::JsonNumberSafeCount - ), - ( - "RadrootsTradeListingProjection", - "trade_count", - super::TradeLargeIntegerPolicy::JsonNumberSafeCount - ), - ( - "RadrootsTradeListingProjection", - "open_trade_count", - super::TradeLargeIntegerPolicy::JsonNumberSafeCount - ), - ( - "RadrootsTradeListingProjection", - "terminal_trade_count", - super::TradeLargeIntegerPolicy::JsonNumberSafeCount - ), - ( - "RadrootsTradeMarketplaceListingSummary", - "trade_count", - super::TradeLargeIntegerPolicy::JsonNumberSafeCount - ), - ( - "RadrootsTradeMarketplaceListingSummary", - "open_trade_count", - super::TradeLargeIntegerPolicy::JsonNumberSafeCount - ), - ( - "RadrootsTradeMarketplaceListingSummary", - "terminal_trade_count", - super::TradeLargeIntegerPolicy::JsonNumberSafeCount - ), - ] - ); - } - - fn disposition(export_name: &str) -> TradeTypeDisposition { - TRADE_TYPE_INVENTORY - .iter() - .find(|entry| entry.export_name == export_name) - .map(|entry| entry.disposition) - .expect("inventory entry") - } -} diff --git a/deny.toml b/deny.toml @@ -1,67 +0,0 @@ -[graph] -targets = [ - "aarch64-apple-darwin", - "wasm32-unknown-unknown", - "x86_64-pc-windows-gnu", - "x86_64-unknown-linux-gnu", -] -all-features = true - -[advisories] -# `nostr` 0.44.7 depends on `url-fork` 3.0.1 and therefore `idna` 0.5.0. -# The only network-capable public adapter canonicalizes every relay URL with -# patched `url` >=2.5.4 before the value reaches upstream Nostr code. This -# exact exception is contract-bound and must be removed with the Nostr 0.45 -# stable upgrade. -ignore = [ - "RUSTSEC-2024-0384", # `instant` is an unmaintained, non-vulnerability Nostr 0.44 dependency. - "RUSTSEC-2024-0421", -] - -[licenses] -allow = [ - "0BSD", - "Apache-2.0", - "Apache-2.0 WITH LLVM-exception", - "BSD-1-Clause", - "BSD-2-Clause", - "BSD-3-Clause", - "BSL-1.0", - "CC0-1.0", - "CDLA-Permissive-2.0", - "ISC", - "MIT", - "MIT-0", - "MPL-2.0", - "Unicode-3.0", - "Unlicense", - "Zlib", -] -confidence-threshold = 0.93 - -[licenses.private] -ignore = false -registries = [] - -[bans] -multiple-versions = "allow" -wildcards = "deny" -highlight = "all" -workspace-default-features = "allow" -external-default-features = "allow" -allow = [] -allow-workspace = true -deny = [] -skip = [] -skip-tree = [] - -[sources] -unknown-registry = "deny" -unknown-git = "deny" -allow-registry = ["https://github.com/rust-lang/crates.io-index"] -allow-git = ["https://github.com/radrootslabs/lib.git"] - -[sources.allow-org] -github = [] -gitlab = [] -bitbucket = [] diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml @@ -1,33 +0,0 @@ -[package] -name = "radroots_sdk_xtask" -description = "Workspace automation for Radroots SDK crates" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -publish = false - -[[bin]] -name = "radroots_sdk_xtask" -path = "src/main.rs" - -[dependencies] -dto_bindgen_backend_ts = { workspace = true } -dto_bindgen_core = { workspace = true } -radroots_core_bindings = { path = "../../crates/core_bindings", version = "=0.1.0-alpha" } -radroots_event_bindings = { path = "../../crates/event_bindings", version = "=0.1.0-alpha" } -radroots_identity = { workspace = true } -radroots_identity_bindings = { path = "../../crates/identity_bindings", version = "=0.1.0-alpha" } -radroots_protocol = { workspace = true } -radroots_replica_schema_bindings = { path = "../../crates/replica_schema_bindings", version = "=0.1.0-alpha" } -flate2 = "1" -serde_json = "1" -serde = { workspace = true, features = ["derive"] } -semver = "1" -sha2 = { workspace = true } -syn = { version = "2", features = ["full", "visit"] } -tar = "0.4" -tempfile = { workspace = true } -toml = "0.8" diff --git a/tools/xtask/src/api_qualification.rs b/tools/xtask/src/api_qualification.rs @@ -1,139 +0,0 @@ -use std::{collections::BTreeSet, fs, path::Path, process::Command}; - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -struct Contract { - schema_version: u16, - baseline_revision: String, - package_version: String, - tool: String, - minimum_tool_version: String, - policy: String, - feature_policy: String, - packages: Vec<String>, -} - -pub fn run(root: &Path) -> Result<(), String> { - let contract = load(root)?; - validate(&contract)?; - verify_tool(&contract)?; - verify_revision(root, &contract.baseline_revision)?; - for package in &contract.packages { - let args = invocation(package); - eprintln!("cargo {}", args.join(" ")); - let output = Command::new("cargo") - .args(&args) - .current_dir(root) - .output() - .map_err(|error| format!("failed to start cargo-public-api: {error}"))?; - if !output.status.success() { - return Err(format!( - "public API extraction failed for {package}: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let actual = String::from_utf8(output.stdout) - .map_err(|error| format!("public API output for {package} was not UTF-8: {error}"))?; - let snapshot_path = root.join(format!( - "docs/api/{package}-{}.txt", - contract.package_version - )); - let expected = fs::read_to_string(&snapshot_path) - .map_err(|error| format!("read {}: {error}", snapshot_path.display()))?; - if actual != expected { - return Err(format!( - "reviewed public API snapshot drifted for {package}: {}", - snapshot_path.display() - )); - } - } - Ok(()) -} - -fn load(root: &Path) -> Result<Contract, String> { - let path = root.join("contracts/releases/api_semver.toml"); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) -} - -fn validate(contract: &Contract) -> Result<(), String> { - let unique = contract.packages.iter().collect::<BTreeSet<_>>(); - if contract.schema_version != 2 - || contract.package_version != "0.1.0-alpha" - || contract.tool != "cargo-public-api" - || contract.policy != "reviewed-breaking-snapshot" - || contract.feature_policy != "all" - || contract.baseline_revision.len() < 7 - || unique.len() != 2 - || unique.len() != contract.packages.len() - { - return Err("invalid SDK public API qualification contract".to_owned()); - } - Ok(()) -} - -fn verify_tool(contract: &Contract) -> Result<(), String> { - let output = Command::new("cargo") - .args(["public-api", "--version"]) - .output() - .map_err(|error| format!("failed to start cargo-public-api: {error}"))?; - if !output.status.success() { - return Err("cargo-public-api is required".to_owned()); - } - let stdout = String::from_utf8_lossy(&output.stdout); - let installed = stdout - .split_whitespace() - .find_map(|value| semver::Version::parse(value).ok()) - .ok_or_else(|| format!("could not parse cargo-public-api version: {stdout}"))?; - let minimum = semver::Version::parse(&contract.minimum_tool_version) - .map_err(|error| format!("invalid minimum tool version: {error}"))?; - if installed < minimum { - return Err(format!( - "cargo-public-api {} or newer is required, found {installed}", - contract.minimum_tool_version - )); - } - Ok(()) -} - -fn verify_revision(root: &Path, revision: &str) -> Result<(), String> { - let status = Command::new("git") - .args(["cat-file", "-e", &format!("{revision}^{{commit}}")]) - .current_dir(root) - .status() - .map_err(|error| format!("failed to inspect API baseline revision: {error}"))?; - if status.success() { - Ok(()) - } else { - Err(format!("API baseline revision {revision} is unavailable")) - } -} - -fn invocation(package: &str) -> Vec<String> { - vec![ - "public-api".to_owned(), - "-p".to_owned(), - package.to_owned(), - "--all-features".to_owned(), - "-sss".to_owned(), - "--color".to_owned(), - "never".to_owned(), - ] -} - -#[cfg(test)] -mod tests { - use super::{invocation, load, validate}; - - #[test] - fn current_contract_covers_both_front_doors() { - let root = crate::fs::workspace_root().expect("workspace root"); - let contract = load(&root).expect("contract"); - validate(&contract).expect("valid contract"); - let invocation = invocation("radroots"); - assert!(invocation.contains(&"--all-features".to_owned())); - assert!(invocation.contains(&"-sss".to_owned())); - } -} diff --git a/tools/xtask/src/architecture.rs b/tools/xtask/src/architecture.rs @@ -1,1511 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - path::{Component, Path}, -}; - -use serde::Deserialize; - -mod api_leakage; -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<_>>(); - 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 workspace_package.version != repository.version - || public_metadata.version != repository.version - { - return Err(format!( - "workspace package version {} and public package version source {} must match frozen repository version {}", - workspace_package.version, public_metadata.version, repository.version - )); - } - for member in &manifest.workspace.members { - let member_path = workspace_root.join(member).join("Cargo.toml"); - let member_raw = fs::read_to_string(&member_path) - .map_err(|error| format!("read {}: {error}", member_path.display()))?; - let member_manifest = member_raw - .parse::<toml::Value>() - .map_err(|error| format!("parse {}: {error}", member_path.display()))?; - let package = member_manifest - .get("package") - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{} is missing [package]", member_path.display()))?; - let package_name = package - .get("name") - .and_then(toml::Value::as_str) - .ok_or_else(|| format!("{} is missing package.name", member_path.display()))?; - let package_version = match package.get("version") { - Some(toml::Value::String(version)) => version.as_str(), - Some(toml::Value::Table(source)) - if source.get("workspace").and_then(toml::Value::as_bool) == Some(true) => - { - workspace_package.version.as_str() - } - _ => { - return Err(format!( - "workspace package {package_name} must declare version 0.1.0-alpha or inherit it from workspace.package" - )); - } - }; - if package_version != repository.version { - return Err(format!( - "workspace package {package_name} version {package_version} must match frozen repository 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}" - )); - } - let publish_registries = package - .get("publish") - .and_then(toml::Value::as_array) - .ok_or_else(|| format!("public package {name} must set publish = [\"crates-io\"]"))?; - if publish_registries.len() != 1 || publish_registries[0].as_str() != Some("crates-io") { - return Err(format!( - "public package {name} must set publish = [\"crates-io\"]" - )); - } - 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-alpha".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-alpha\"\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-alpha\"\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-alpha\"\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")); - - fs::write( - root.join("rust-toolchain.toml"), - "[toolchain]\nchannel = \"1.97.1\"\n", - ) - .expect("restore toolchain"); - let manifest = complete_workspace_manifest("").replacen( - "[workspace.package]\nversion = \"0.1.0-alpha\"", - "[workspace.package]\nversion = \"0.1.0\"", - 1, - ); - fs::write(root.join("Cargo.toml"), manifest).expect("write mismatched version cohort"); - let version_error = validate_workspace_toolchain(&root, &architecture) - .expect_err("mismatched version cohort must fail"); - assert!(version_error.contains("must match frozen repository version")); - 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 = [\"crates-io\"]\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-alpha\" }}\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-alpha\"\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-alpha\"\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-alpha\"", ""), - ) - .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-alpha")); - 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-alpha\"\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-alpha\"\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 @@ -1,1433 +0,0 @@ -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/dependency_boundary.rs b/tools/xtask/src/architecture/dependency_boundary.rs @@ -1,718 +0,0 @@ -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/bindings.rs b/tools/xtask/src/bindings.rs @@ -1,281 +0,0 @@ -use std::{ - collections::BTreeMap, - env, fs, - path::{Path, PathBuf}, - process::Command, -}; - -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -use crate::fs::{workspace_root, write_if_changed}; - -const FFI_PACKAGE: &str = "radroots_sdk_ffi"; -const FFI_VERSION: &str = "0.1.0-alpha"; -const BINDGEN_VERSION: &str = "0.29.5"; - -#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] -struct SourceLock { - schema_version: u16, - source_package: String, - source_version: String, - source_sha256: String, - generator: String, - generator_version: String, - language: String, - outputs: BTreeMap<String, String>, -} - -pub fn generate(args: &[String]) -> Result<(), String> { - match args { - [language] if language == "swift" => generate_language("swift"), - [language] if language == "kotlin" => generate_language("kotlin"), - _ => Err("usage: cargo xtask generate bindings <swift|kotlin>".to_owned()), - } -} - -fn generate_language(language: &'static str) -> Result<(), String> { - let root = workspace_root()?; - build_bindgen(&root)?; - let target_dir = env::var_os("CARGO_TARGET_DIR") - .map(PathBuf::from) - .ok_or_else(|| "CARGO_TARGET_DIR must be set by extbuild for FFI generation".to_owned())?; - let library = target_dir.join("debug").join(format!( - "{}radroots_sdk_ffi{}", - env::consts::DLL_PREFIX, - env::consts::DLL_SUFFIX - )); - if !library.is_file() { - return Err(format!("missing built FFI library: {}", library.display())); - } - let bindgen = target_dir - .join("debug") - .join(format!("radroots-sdk-bindgen{}", env::consts::EXE_SUFFIX)); - if !bindgen.is_file() { - return Err(format!( - "missing FFI bindgen executable: {}", - bindgen.display() - )); - } - let temporary = tempfile::tempdir() - .map_err(|error| format!("failed to create binding output directory: {error}"))?; - let status = Command::new(&bindgen) - .args(["generate", "--library"]) - .arg(&library) - .args(["--crate", FFI_PACKAGE, "--language", language, "--out-dir"]) - .arg(temporary.path()) - .current_dir(&root) - .status() - .map_err(|error| format!("failed to start {}: {error}", bindgen.display()))?; - if !status.success() { - return Err(format!( - "{language} binding generation failed with {status}" - )); - } - install_outputs(&root, language, temporary.path())?; - println!("generated {language} bindings for {FFI_PACKAGE}"); - Ok(()) -} - -fn build_bindgen(root: &Path) -> Result<(), String> { - let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); - let status = Command::new(cargo) - .args([ - "build", - "--locked", - "-p", - FFI_PACKAGE, - "--lib", - "--bin", - "radroots-sdk-bindgen", - ]) - .current_dir(root) - .status() - .map_err(|error| format!("failed to start Cargo for FFI generation: {error}"))?; - if !status.success() { - return Err(format!( - "FFI library and bindgen build failed with {status}" - )); - } - Ok(()) -} - -fn install_outputs(root: &Path, language: &'static str, temporary: &Path) -> Result<(), String> { - let output_dir = root.join("generated").join(language); - fs::create_dir_all(&output_dir) - .map_err(|error| format!("failed to create {}: {error}", output_dir.display()))?; - let mut generated = Vec::new(); - collect_files(temporary, &mut generated)?; - generated.sort(); - let mut outputs = BTreeMap::new(); - for path in generated { - let name = path - .strip_prefix(temporary) - .map_err(|error| format!("failed to relativize {}: {error}", path.display()))? - .to_str() - .ok_or_else(|| format!("non-UTF-8 generated binding path: {}", path.display()))? - .replace('\\', "/"); - let contents = fs::read(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let destination = output_dir.join(&name); - let text = String::from_utf8(contents) - .map_err(|error| format!("generated text binding is not UTF-8: {error}"))?; - let text = normalize_generated_text(&text); - outputs.insert( - name.clone(), - format!("{:x}", Sha256::digest(text.as_bytes())), - ); - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; - } - write_if_changed(&destination, &text)?; - } - if outputs.is_empty() { - return Err(format!("{language} binding generator emitted no files")); - } - let lock = SourceLock { - schema_version: 1, - source_package: FFI_PACKAGE.to_owned(), - source_version: FFI_VERSION.to_owned(), - source_sha256: ffi_source_sha256(root)?, - generator: "uniffi".to_owned(), - generator_version: BINDGEN_VERSION.to_owned(), - language: language.to_owned(), - outputs, - }; - let mut rendered = serde_json::to_string_pretty(&lock) - .map_err(|error| format!("failed to render {language} source lock: {error}"))?; - rendered.push('\n'); - write_if_changed(&output_dir.join("source.lock"), &rendered).map(|_| ()) -} - -fn normalize_generated_text(contents: &str) -> String { - let mut normalized = contents - .lines() - .map(str::trim_end) - .collect::<Vec<_>>() - .join("\n"); - while normalized.ends_with('\n') { - normalized.pop(); - } - normalized.push('\n'); - normalized -} - -pub fn check(root: &Path) -> Result<(), String> { - check_language( - root, - "swift", - &[ - "radroots_sdk.swift", - "radroots_sdkFFI.h", - "radroots_sdkFFI.modulemap", - ], - )?; - check_language(root, "kotlin", &["uniffi/radroots_sdk/radroots_sdk.kt"])?; - check_kotlin_schema_inventory(root) -} - -fn check_kotlin_schema_inventory(root: &Path) -> Result<(), String> { - let path = root.join("generated/kotlin/uniffi/radroots_sdk/radroots_sdk.kt"); - let source = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - for required in [ - "class MobileClient", - "data class CapabilityStatus", - "enum class CapabilityAvailability", - "enum class CapabilityMaturity", - "sealed class Exception", - ] { - if !source.contains(required) { - return Err(format!( - "generated Kotlin schema inventory is missing {required}: {}", - path.display() - )); - } - } - for forbidden in ["Keychain", "BackgroundTask", "Presentation"] { - if source.contains(forbidden) { - return Err(format!( - "generated Kotlin bindings contain host-owned {forbidden} API: {}", - path.display() - )); - } - } - Ok(()) -} - -fn check_language(root: &Path, language: &'static str, expected: &[&str]) -> Result<(), String> { - let output_dir = root.join("generated").join(language); - let lock_path = output_dir.join("source.lock"); - let raw = fs::read_to_string(&lock_path) - .map_err(|error| format!("failed to read {}: {error}", lock_path.display()))?; - let actual: SourceLock = serde_json::from_str(&raw) - .map_err(|error| format!("failed to parse {}: {error}", lock_path.display()))?; - let mut outputs = BTreeMap::new(); - for name in expected { - let path = output_dir.join(name); - let contents = fs::read(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - outputs.insert( - (*name).to_owned(), - format!("{:x}", Sha256::digest(contents)), - ); - } - let expected_lock = SourceLock { - schema_version: 1, - source_package: FFI_PACKAGE.to_owned(), - source_version: FFI_VERSION.to_owned(), - source_sha256: ffi_source_sha256(root)?, - generator: "uniffi".to_owned(), - generator_version: BINDGEN_VERSION.to_owned(), - language: language.to_owned(), - outputs, - }; - if actual != expected_lock { - return Err(format!( - "stale generated {language} bindings: {}", - lock_path.display() - )); - } - Ok(()) -} - -fn ffi_source_sha256(root: &Path) -> Result<String, String> { - let crate_dir = root.join("crates/ffi"); - let mut paths = vec![crate_dir.join("Cargo.toml"), crate_dir.join("README.md")]; - collect_files(&crate_dir.join("src"), &mut paths)?; - paths.sort(); - let mut hasher = Sha256::new(); - for path in paths { - let relative = path - .strip_prefix(root) - .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; - hasher.update(relative.to_string_lossy().as_bytes()); - hasher.update([0]); - hasher.update( - fs::read(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?, - ); - hasher.update([0]); - } - Ok(format!("{:x}", hasher.finalize())) -} - -fn collect_files(dir: &Path, paths: &mut Vec<PathBuf>) -> Result<(), String> { - let mut entries = fs::read_dir(dir) - .map_err(|error| format!("failed to read {}: {error}", dir.display()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|error| format!("failed to enumerate {}: {error}", dir.display()))?; - entries.sort_by_key(std::fs::DirEntry::file_name); - for entry in entries { - let path = entry.path(); - if path.is_dir() { - collect_files(&path, paths)?; - } else if path.is_file() { - paths.push(path); - } - } - Ok(()) -} diff --git a/tools/xtask/src/check.rs b/tools/xtask/src/check.rs @@ -1,3710 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - io::Read, - path::{Path, PathBuf}, - process::Command, -}; - -use flate2::read::GzDecoder; -use serde::Deserialize; -use tar::Archive; - -use crate::{ - contracts::validate_sdk_contracts, - fs::workspace_root, - output::{PackageOutput, package_outputs}, - package_matrix::{ - FORBIDDEN_PACKAGE_NAMES, WasmPackageSpec, package_specs, validate_package_matrix, - wasm_package_specs, - }, - package_metadata::{PACKAGE_FILES, check_package_distribution_metadata, package_description}, - ts::generated_header, - wasm::validate_provenance_manifest, - wasm_declarations::declaration_files, -}; - -const PACKAGE_VERSION: &str = "0.1.0"; -const PACKAGE_LICENSE: &str = "MIT OR Apache-2.0"; -const PACKAGE_HOMEPAGE: &str = "https://radroots.org"; -const PACKAGE_REPOSITORY_URL: &str = "git+https://github.com/radrootslabs/sdk.git"; -const PUBLISH_ACCESS: &str = "public"; -const ALLOWED_RETICULUM_PREVIEW_PACKAGE: &str = "radroots_transport_reticulum"; -const RADROOTS_FACADE_MODULES: [&str; 11] = [ - "client", - "event", - "farm", - "identity", - "knowledge", - "listing", - "signing", - "storage", - "sync", - "trade", - "transport", -]; -const RADROOTS_FACADE_FEATURES: [&str; 9] = [ - "client", - "default", - "full", - "geonames", - "knowledge", - "native", - "nip46", - "nostr", - "radrootsd", -]; - -#[derive(Debug, Deserialize)] -struct PnpmPackEntry { - filename: String, - files: Vec<PnpmPackFile>, -} - -#[derive(Debug, Deserialize)] -struct PnpmPackFile { - path: String, -} - -#[derive(Debug)] -struct PackedPackage { - package_name: String, - tarball_path: PathBuf, - files: Vec<NpmPackFile>, -} - -#[derive(Debug)] -struct NpmPackFile { - path: String, -} - -#[derive(Debug, Deserialize)] -struct CargoMetadata { - packages: Vec<CargoMetadataPackage>, -} - -#[derive(Debug, Deserialize)] -struct CargoMetadataPackage { - name: String, -} - -#[derive(Debug, Deserialize)] -struct PublicationPolicyFile { - publication: PublicationPolicy, - workspace_classification: WorkspacePublicationClassification, - #[serde(default)] - publish_order: PublishOrder, -} - -#[derive(Debug, Default, Deserialize)] -struct PublishOrder { - crates: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct PublicationPolicy { - frozen: bool, - registry: String, - final_enablement_step: u16, - spec_id: String, - approved_packages: Vec<String>, - local_packages: Vec<String>, - external_packages: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct WorkspacePublicationClassification { - private: Vec<String>, - build_codegen: Vec<String>, - test_support: Vec<String>, - preview: Vec<String>, - 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 { - packages: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct CratesReleasePackage { - name: String, -} - -pub fn check() -> Result<(), String> { - validate_package_matrix()?; - let root = workspace_root()?; - crate::architecture::validate(&root)?; - check_publication_policy(&root)?; - check_public_rust_package_metadata(&root)?; - check_radroots_facade_conformance(&root)?; - validate_sdk_contracts(&root)?; - check_sdk_feature_matrix(&root)?; - check_forbidden_packages(&root)?; - check_binding_crate_sources(&root)?; - check_package_source_metadata(&root)?; - check_generated_outputs(&root)?; - check_package_build_artifacts(&root)?; - check_npm_pack_payloads(&root)?; - crate::bindings::check(&root)?; - Ok(()) -} - -pub fn architecture_ci(root: &Path) -> Result<(), String> { - validate_package_matrix()?; - check_publication_policy(root)?; - check_public_rust_package_metadata(root)?; - check_radroots_facade_conformance(root)?; - validate_sdk_contracts(root)?; - check_sdk_feature_matrix(root)?; - check_forbidden_packages(root)?; - check_binding_crate_sources(root)?; - check_package_source_metadata(root)?; - check_generated_outputs(root)?; - crate::bindings::check(root) -} - -fn check_radroots_facade_conformance(root: &Path) -> Result<(), String> { - let manifest_path = root.join("crates/radroots/Cargo.toml"); - let manifest_raw = fs::read_to_string(&manifest_path) - .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?; - let manifest = manifest_raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse {}: {error}", manifest_path.display()))?; - let package = manifest - .get("package") - .and_then(toml::Value::as_table) - .ok_or_else(|| "crates/radroots/Cargo.toml must define [package]".to_owned())?; - for (field, expected) in [ - ("name", "radroots"), - ( - "description", - "Curated ordinary-user Rust facade for Radroots", - ), - ("readme", "README.md"), - ] { - if package.get(field).and_then(toml::Value::as_str) != Some(expected) { - return Err(format!( - "crates/radroots/Cargo.toml package.{field} must be {expected}" - )); - } - } - let publish = package - .get("publish") - .and_then(toml::Value::as_array) - .ok_or_else(|| "radroots facade must set publish = [\"crates-io\"]".to_owned())?; - if publish.len() != 1 || publish[0].as_str() != Some("crates-io") { - return Err("radroots facade must set publish = [\"crates-io\"]".to_owned()); - } - for field in [ - "version", - "edition", - "rust-version", - "license", - "repository", - "homepage", - ] { - let inherited = package - .get(field) - .and_then(toml::Value::as_table) - .and_then(|value| value.get("workspace")) - .and_then(toml::Value::as_bool); - if inherited != Some(true) { - return Err(format!( - "crates/radroots/Cargo.toml package.{field} must inherit workspace metadata" - )); - } - } - let library = manifest - .get("lib") - .and_then(toml::Value::as_table) - .ok_or_else(|| "crates/radroots/Cargo.toml must define [lib]".to_owned())?; - if library.get("name").and_then(toml::Value::as_str) != Some("radroots") - || library.get("path").and_then(toml::Value::as_str) != Some("src/lib.rs") - { - return Err("radroots facade [lib] must use name radroots and path src/lib.rs".to_owned()); - } - let dependencies = manifest - .get("dependencies") - .and_then(toml::Value::as_table) - .ok_or_else(|| "radroots facade must define [dependencies]".to_owned())?; - let actual_dependencies = dependencies - .keys() - .map(String::as_str) - .collect::<BTreeSet<_>>(); - let expected_dependencies = [ - "radroots_core", - "radroots_event", - "radroots_identity", - "radroots_sdk", - "radroots_trade", - "radroots_transport", - ] - .into_iter() - .collect::<BTreeSet<_>>(); - if actual_dependencies != expected_dependencies { - return Err(format!( - "radroots facade dependencies must be exactly {}; found {}", - expected_dependencies - .into_iter() - .collect::<Vec<_>>() - .join(", "), - actual_dependencies - .into_iter() - .collect::<Vec<_>>() - .join(", ") - )); - } - for (name, dependency) in dependencies { - if dependency - .as_table() - .and_then(|value| value.get("workspace")) - .and_then(toml::Value::as_bool) - != Some(true) - { - return Err(format!( - "radroots facade dependency {name} must inherit the governed workspace entry" - )); - } - } - for dependency_table in ["dev-dependencies", "build-dependencies"] { - if manifest - .get(dependency_table) - .and_then(toml::Value::as_table) - .is_some_and(|dependencies| !dependencies.is_empty()) - { - return Err(format!( - "radroots facade must not define [{dependency_table}]" - )); - } - } - - let features = manifest - .get("features") - .and_then(toml::Value::as_table) - .ok_or_else(|| "crates/radroots/Cargo.toml must define [features]".to_owned())?; - let actual_features = features.keys().map(String::as_str).collect::<BTreeSet<_>>(); - let expected_features = RADROOTS_FACADE_FEATURES - .into_iter() - .collect::<BTreeSet<_>>(); - if actual_features != expected_features { - return Err(format!( - "radroots facade features must be exactly {}; found {}", - expected_features.into_iter().collect::<Vec<_>>().join(", "), - actual_features.into_iter().collect::<Vec<_>>().join(", ") - )); - } - let defaults = features - .get("default") - .and_then(toml::Value::as_array) - .ok_or_else(|| "radroots facade default feature must be an array".to_owned())?; - if defaults.len() != 1 || defaults[0].as_str() != Some("client") { - return Err("radroots facade default feature must be exactly client".to_owned()); - } - for (name, expected) in [ - ("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"]), - ] { - let actual = features - .get(name) - .and_then(toml::Value::as_array) - .ok_or_else(|| format!("radroots facade feature {name} must be an array"))? - .iter() - .filter_map(toml::Value::as_str) - .collect::<Vec<_>>(); - if actual != expected { - return Err(format!( - "radroots facade feature {name} must forward exactly {}", - expected.join(", ") - )); - } - } - - if package.get("autoexamples").and_then(toml::Value::as_bool) != Some(false) { - return Err("radroots facade must disable implicit example discovery".to_owned()); - } - let examples = manifest - .get("example") - .and_then(toml::Value::as_array) - .ok_or_else(|| "radroots facade must declare its ordinary example".to_owned())?; - if examples.len() != 1 - || examples[0].get("name").and_then(toml::Value::as_str) != Some("ordinary_memory") - || examples[0].get("path").and_then(toml::Value::as_str) - != Some("examples/ordinary_memory.rs") - { - return Err("radroots facade must declare only the ordinary_memory example".to_owned()); - } - - let source_path = root.join("crates/radroots/src/lib.rs"); - let source = fs::read_to_string(&source_path) - .map_err(|error| format!("failed to read {}: {error}", source_path.display()))?; - let actual_modules = source - .lines() - .filter_map(|line| { - line.trim() - .strip_prefix("pub mod ") - .and_then(|module| module.strip_suffix(';')) - }) - .collect::<BTreeSet<_>>(); - let expected_modules = RADROOTS_FACADE_MODULES.into_iter().collect::<BTreeSet<_>>(); - if actual_modules != expected_modules { - return Err(format!( - "radroots facade modules must be exactly {}; found {}", - expected_modules.into_iter().collect::<Vec<_>>().join(", "), - actual_modules.into_iter().collect::<Vec<_>>().join(", ") - )); - } - if source.contains("pub mod sdk") || source.contains("pub use radroots_sdk::*") { - return Err( - "radroots facade must not expose an sdk namespace or broad SDK re-export".to_owned(), - ); - } - if !source.contains("pub use radroots_sdk::{Client, ClientBuilder, Error, Result};") { - return Err( - "radroots facade must export exactly the four approved SDK root entry points" - .to_owned(), - ); - } - for module in RADROOTS_FACADE_MODULES { - let module_path = root - .join("crates/radroots/src") - .join(format!("{module}.rs")); - if !module_path.is_file() { - return Err(format!( - "radroots facade module file {module}.rs is missing" - )); - } - let module_source = fs::read_to_string(&module_path) - .map_err(|error| format!("failed to read {}: {error}", module_path.display()))?; - if module_source.contains("pub trait ") || module_source.contains("::*") { - return Err(format!( - "radroots facade module {module} must not define traits or wildcard reexports" - )); - } - } - let readme_path = root.join("crates/radroots/README.md"); - let readme = fs::read_to_string(&readme_path) - .map_err(|error| format!("failed to read {}: {error}", readme_path.display()))?; - if !readme.contains("```compile_fail\nuse radroots::sdk;\n```") { - return Err("radroots facade README must compile-fail the SDK namespace".to_owned()); - } - Ok(()) -} - -fn check_publication_policy(root: &Path) -> Result<(), String> { - let policy_path = root.join("contracts/releases/publication.toml"); - let raw = fs::read_to_string(&policy_path) - .map_err(|error| format!("failed to read {}: {error}", policy_path.display()))?; - let policy_file = toml::from_str::<PublicationPolicyFile>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", policy_path.display()))?; - let publish_order = policy_file.publish_order.crates.clone(); - let policy = policy_file.publication; - if policy.registry != "crates-io" { - return Err("publication.registry must be crates-io".to_owned()); - } - if policy.final_enablement_step != 305 { - return Err("publication.final_enablement_step must be 305".to_owned()); - } - let architecture_path = root.join("docs/specs/radroots_crates_release_v1.toml"); - let architecture_raw = fs::read_to_string(&architecture_path) - .map_err(|error| format!("failed to read {}: {error}", architecture_path.display()))?; - let architecture = toml::from_str::<CratesReleaseArchitecture>(&architecture_raw) - .map_err(|error| format!("failed to parse {}: {error}", architecture_path.display()))?; - let expected_approved = policy_set( - architecture - .package - .iter() - .map(|package| package.name.clone()), - "architecture.package.name", - )?; - if architecture.package_count != 19 || expected_approved.len() != architecture.package_count { - 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 policy.spec_id != architecture.spec_id || policy.spec_id != "radroots.crates.release.v1" { - return Err(format!( - "publication.spec_id {} must match architecture id {}", - policy.spec_id, architecture.spec_id - )); - } - let approved = policy_set(policy.approved_packages, "publication.approved_packages")?; - let local = policy_set(policy.local_packages, "publication.local_packages")?; - let external = policy_set(policy.external_packages, "publication.external_packages")?; - let expected_local = policy_set( - architecture.repositories.sdk.packages, - "architecture.repositories.sdk.packages", - )?; - let expected_external = policy_set( - architecture.repositories.lib.packages, - "architecture.repositories.lib.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::<Vec<_>>() - .join(", "); - let extra = actual - .difference(expected) - .cloned() - .collect::<Vec<_>>() - .join(", "); - return Err(format!( - "{field} is missing approved packages: {missing}; {field} has unapproved packages: {extra}" - )); - } - } - if !local.is_disjoint(&external) { - return Err("local and external approved package ownership must not overlap".to_owned()); - } - 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_owned(), - ); - } - if policy.frozen { - if !publish_order.is_empty() { - return Err( - "publish_order.crates must remain empty while publication is frozen".to_owned(), - ); - } - } else { - let publish_order_set = policy_set(publish_order.iter().cloned(), "publish_order.crates")?; - if publish_order_set != local { - return Err( - "publish_order.crates must contain exactly the approved local packages when publication is enabled" - .to_owned(), - ); - } - if publish_order != ["radroots_sdk", "radroots"] { - return Err("publish_order.crates must place radroots_sdk before radroots".to_owned()); - } - } - - let mut workspace_packages = BTreeSet::new(); - for path in sdk_manifest_paths(root)?.into_iter().skip(1) { - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let manifest = raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let package = manifest - .get("package") - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{} must define [package]", path.display()))?; - let name = package - .get("name") - .and_then(toml::Value::as_str) - .ok_or_else(|| format!("{} package.name must be a string", path.display()))?; - if !workspace_packages.insert(name.to_owned()) { - return Err(format!("duplicate workspace package name {name}")); - } - let publish = package.get("publish"); - if policy.frozen { - if publish.and_then(toml::Value::as_bool) != Some(false) { - return Err(format!( - "publication freeze requires workspace package {name} to set publish = false" - )); - } - } else if approved.contains(name) { - let registries = publish.and_then(toml::Value::as_array).ok_or_else(|| { - format!("approved package {name} must set publish = [\"crates-io\"]") - })?; - if registries.len() != 1 || registries[0].as_str() != Some("crates-io") { - return Err(format!( - "approved package {name} must set publish = [\"crates-io\"]" - )); - } - } else if publish.and_then(toml::Value::as_bool) != Some(false) { - return Err(format!( - "non-approved workspace package {name} must set publish = false" - )); - } - } - let external_in_workspace = external - .intersection(&workspace_packages) - .cloned() - .collect::<Vec<_>>(); - if !external_in_workspace.is_empty() { - return Err(format!( - "externally owned approved packages must not be workspace members: {}", - external_in_workspace.join(", ") - )); - } - let classification = policy_file.workspace_classification; - let private = policy_set(classification.private, "workspace_classification.private")?; - let build_codegen = policy_set( - classification.build_codegen, - "workspace_classification.build_codegen", - )?; - let test_support = policy_set( - classification.test_support, - "workspace_classification.test_support", - )?; - let preview = policy_set(classification.preview, "workspace_classification.preview")?; - let retired = policy_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::<Vec<_>>(); - if !overlap.is_empty() { - return Err(format!( - "workspace classification overlap is not allowed between {} and {}: {}", - classes[index].0, - classes[other_index].0, - overlap.join(", ") - )); - } - } - } - let mut classified = BTreeSet::new(); - for (_, entries) in classes { - classified.extend(entries.iter().cloned()); - } - let local_workspace = local - .intersection(&workspace_packages) - .cloned() - .collect::<BTreeSet<_>>(); - let public_classification_overlap = classified - .intersection(&local_workspace) - .cloned() - .collect::<Vec<_>>(); - if !public_classification_overlap.is_empty() { - return Err(format!( - "approved local packages must not be classified as private workspace packages: {}", - public_classification_overlap.join(", ") - )); - } - let mut accounted = classified; - accounted.extend(local_workspace.iter().cloned()); - if accounted != workspace_packages { - let missing = workspace_packages - .difference(&accounted) - .cloned() - .collect::<Vec<_>>() - .join(", "); - let extra = accounted - .difference(&workspace_packages) - .cloned() - .collect::<Vec<_>>() - .join(", "); - return Err(format!( - "workspace classification is missing packages: {missing}; workspace classification has unknown packages: {extra}" - )); - } - if !policy.frozen && !local.is_subset(&workspace_packages) { - let missing = local - .difference(&workspace_packages) - .cloned() - .collect::<Vec<_>>() - .join(", "); - return Err(format!( - "publication enablement is missing approved workspace packages: {missing}" - )); - } - Ok(()) -} - -fn policy_set( - values: impl IntoIterator<Item = String>, - field: &str, -) -> Result<BTreeSet<String>, String> { - let mut result = BTreeSet::new(); - for value in values { - if value.trim().is_empty() { - return Err(format!("{field} must not contain empty package names")); - } - if !result.insert(value.clone()) { - return Err(format!( - "{field} must not contain duplicate package {value}" - )); - } - } - Ok(result) -} - -fn package_field_configured(package: &toml::value::Table, field: &str) -> bool { - match package.get(field) { - Some(toml::Value::String(value)) => !value.trim().is_empty(), - Some(toml::Value::Array(values)) => !values.is_empty(), - Some(toml::Value::Table(value)) => value - .get("workspace") - .and_then(toml::Value::as_bool) - .is_some_and(|configured| configured), - _ => false, - } -} - -fn package_string_array<'a>( - package: &'a toml::value::Table, - package_name: &str, - field: &str, -) -> Result<Vec<&'a str>, String> { - let values = package - .get(field) - .and_then(toml::Value::as_array) - .ok_or_else(|| format!("public package {package_name} must define package.{field}"))?; - if values.is_empty() { - return Err(format!( - "public package {package_name} package.{field} must not be empty" - )); - } - let mut resolved = Vec::with_capacity(values.len()); - let mut unique = BTreeSet::new(); - for value in values { - let value = value.as_str().ok_or_else(|| { - format!("public package {package_name} package.{field} entries must be strings") - })?; - if value.trim().is_empty() || !unique.insert(value) { - return Err(format!( - "public package {package_name} package.{field} entries must be non-empty and unique" - )); - } - resolved.push(value); - } - Ok(resolved) -} - -fn check_public_rust_package_metadata(root: &Path) -> Result<(), String> { - let policy_path = root.join("contracts/releases/publication.toml"); - let policy_raw = fs::read_to_string(&policy_path) - .map_err(|error| format!("failed to read {}: {error}", policy_path.display()))?; - let policy_file = toml::from_str::<PublicationPolicyFile>(&policy_raw) - .map_err(|error| format!("failed to parse {}: {error}", policy_path.display()))?; - let local = policy_set( - policy_file.publication.local_packages, - "publication.local_packages", - )?; - - let mut manifests = BTreeMap::new(); - for manifest_path in sdk_manifest_paths(root)?.into_iter().skip(1) { - let raw = fs::read_to_string(&manifest_path) - .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?; - let manifest = raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to 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!("{} must define package.name", manifest_path.display()))? - .to_owned(); - if manifests - .insert(package_name.clone(), (manifest_path, manifest)) - .is_some() - { - return Err(format!("duplicate workspace package name {package_name}")); - } - } - - for package_name in local { - let (manifest_path, manifest) = manifests - .get(&package_name) - .ok_or_else(|| format!("public package {package_name} has no workspace manifest"))?; - let package = manifest - .get("package") - .and_then(toml::Value::as_table) - .expect("workspace package manifest has a package table"); - for field in [ - "authors", - "version", - "edition", - "rust-version", - "license", - "repository", - "homepage", - "documentation", - "readme", - "description", - ] { - if !package_field_configured(package, field) { - return Err(format!( - "public package {package_name} must configure package.{field}" - )); - } - } - let expected_documentation = format!("https://docs.rs/{package_name}"); - if package.get("documentation").and_then(toml::Value::as_str) - != Some(expected_documentation.as_str()) - { - return Err(format!( - "public package {package_name} package.documentation must be {expected_documentation}" - )); - } - if package.get("license-file").is_some() { - return Err(format!( - "public package {package_name} must use the workspace SPDX license expression" - )); - } - - let keywords = package_string_array(package, &package_name, "keywords")?; - let categories = package_string_array(package, &package_name, "categories")?; - if keywords.len() > 5 || categories.len() > 5 { - return Err(format!( - "public package {package_name} keywords and categories must respect crates.io limits" - )); - } - let include = package_string_array(package, &package_name, "include")?; - for required in [ - "src/**", - "tests/**", - "README.md", - "LICENSE-APACHE", - "LICENSE-MIT", - ] { - if !include.contains(&required) { - return Err(format!( - "public package {package_name} package.include must contain {required}" - )); - } - } - - let package_root = manifest_path - .parent() - .expect("workspace member manifest has a parent"); - for relative in ["LICENSE-APACHE", "LICENSE-MIT"] { - let package_bytes = fs::read(package_root.join(relative)).map_err(|error| { - format!("public package {package_name} must include {relative}: {error}") - })?; - let root_bytes = fs::read(root.join(relative)) - .map_err(|error| format!("failed to read {relative}: {error}"))?; - if package_bytes != root_bytes { - return Err(format!( - "public package {package_name} {relative} must match the workspace license" - )); - } - } - if !package_root.join("README.md").is_file() { - return Err(format!( - "public package {package_name} must include a package-local README.md" - )); - } - - let docs_rs = package - .get("metadata") - .and_then(toml::Value::as_table) - .and_then(|metadata| metadata.get("docs")) - .and_then(toml::Value::as_table) - .and_then(|docs| docs.get("rs")) - .and_then(toml::Value::as_table) - .ok_or_else(|| { - format!("public package {package_name} must define [package.metadata.docs.rs]") - })?; - if docs_rs - .get("all-features") - .and_then(toml::Value::as_bool) - .unwrap_or(false) - { - return Err(format!( - "public package {package_name} docs.rs must use an intentional feature set" - )); - } - let docs_features = docs_rs - .get("features") - .and_then(toml::Value::as_array) - .ok_or_else(|| format!("public package {package_name} docs.rs must define features"))?; - let declared_features = manifest.get("features").and_then(toml::Value::as_table); - for feature in docs_features { - let feature = feature.as_str().ok_or_else(|| { - format!("public package {package_name} docs.rs features must be strings") - })?; - if !declared_features.is_some_and(|features| features.contains_key(feature)) { - return Err(format!( - "public package {package_name} docs.rs selects unknown feature {feature}" - )); - } - } - } - Ok(()) -} - -fn check_sdk_feature_matrix(root: &Path) -> Result<(), String> { - let path = root.join("crates/sdk/Cargo.toml"); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let manifest = raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let features = manifest - .get("features") - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{} must define [features]", path.display()))?; - let expected = BTreeSet::from([ - "default", - "full", - "geonames", - "knowledge", - "local-signing", - "memory", - "native", - "nip46", - "nostr", - "radrootsd", - "sqlite", - "sync", - ]); - let actual = features.keys().map(String::as_str).collect::<BTreeSet<_>>(); - if actual != expected { - return Err(format!( - "crates/sdk/Cargo.toml feature vocabulary mismatch: expected {expected:?}, found {actual:?}" - )); - } - for (feature, expected_entries) in [ - ("default", &["memory"][..]), - ("memory", &["radroots_storage/memory"]), - ("sqlite", &["dep:radroots_storage_sqlite"]), - ("sync", &["dep:radroots_sync", "dep:uuid"]), - ( - "nostr", - &["sync", "dep:radroots_nostr", "dep:radroots_transport_nostr"], - ), - ("nip46", &["nostr", "dep:radroots_nostr_connect"]), - ( - "local-signing", - &[ - "dep:radroots_nostr", - "dep:radroots_secrets", - "radroots_nostr/signing", - ], - ), - ( - "radrootsd", - &["sync", "dep:reqwest", "dep:serde", "dep:serde_json"], - ), - ("geonames", &["dep:radroots_geonames"]), - ( - "knowledge", - &["radroots_event/knowledge", "radroots_event_codec/knowledge"], - ), - ("native", &["sqlite", "sync", "local-signing"]), - ( - "full", - &[ - "native", - "nostr", - "nip46", - "radrootsd", - "geonames", - "knowledge", - ], - ), - ] { - let actual_entries = feature_entries(features, feature)? - .into_iter() - .collect::<BTreeSet<_>>(); - let expected_entries = expected_entries.iter().copied().collect::<BTreeSet<_>>(); - if actual_entries != expected_entries { - return Err(format!( - "crates/sdk/Cargo.toml feature `{feature}` mismatch: expected {expected_entries:?}, found {actual_entries:?}" - )); - } - } - check_sdk_workspace_reticulum_dependency_boundaries(root)?; - Ok(()) -} - -fn check_sdk_workspace_reticulum_dependency_boundaries(root: &Path) -> Result<(), String> { - for path in sdk_manifest_paths(root)? { - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let manifest = raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let manifest_label = relative_path_string(root, &path)?; - check_manifest_reticulum_dependency_boundaries(&manifest, &manifest_label)?; - } - check_cargo_lock_reticulum_package_names(root)?; - check_cargo_metadata_reticulum_package_names(root)?; - Ok(()) -} - -fn sdk_manifest_paths(root: &Path) -> Result<Vec<PathBuf>, String> { - let root_manifest_path = root.join("Cargo.toml"); - let raw = fs::read_to_string(&root_manifest_path) - .map_err(|error| format!("failed to read {}: {error}", root_manifest_path.display()))?; - let manifest = raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse {}: {error}", root_manifest_path.display()))?; - let members = manifest - .get("workspace") - .and_then(|workspace| workspace.get("members")) - .and_then(toml::Value::as_array) - .ok_or_else(|| "Cargo.toml must define workspace.members".to_owned())?; - let mut paths = Vec::with_capacity(members.len() + 1); - paths.push(root_manifest_path); - for member in members { - let member = member - .as_str() - .ok_or_else(|| "Cargo.toml workspace.members entries must be strings".to_owned())?; - if member.contains('*') { - return Err(format!( - "Cargo.toml workspace member globs are not supported by the SDK Reticulum gate: {member}" - )); - } - paths.push(root.join(member).join("Cargo.toml")); - } - Ok(paths) -} - -fn check_manifest_reticulum_dependency_boundaries( - manifest: &toml::Value, - manifest_label: &str, -) -> Result<(), String> { - let table = manifest - .as_table() - .ok_or_else(|| format!("{manifest_label} must parse as a TOML table"))?; - inspect_manifest_table_for_reticulum_dependencies(table, manifest_label, &mut Vec::new()) -} - -fn inspect_manifest_table_for_reticulum_dependencies( - table: &toml::map::Map<String, toml::Value>, - manifest_label: &str, - path: &mut Vec<String>, -) -> Result<(), String> { - for (key, value) in table { - path.push(key.clone()); - if is_dependency_section_key(key) { - let dependencies = value.as_table().ok_or_else(|| { - format!( - "{manifest_label} section `{}` must be a table", - path.join(".") - ) - })?; - check_reticulum_dependency_table(dependencies, manifest_label, path)?; - path.pop(); - continue; - } - if key == "features" && path.len() == 1 { - let features = value.as_table().ok_or_else(|| { - format!( - "{manifest_label} section `{}` must be a table", - path.join(".") - ) - })?; - check_reticulum_feature_table(features, manifest_label, path)?; - } - if let Some(nested) = value.as_table() { - inspect_manifest_table_for_reticulum_dependencies(nested, manifest_label, path)?; - } - path.pop(); - } - Ok(()) -} - -fn is_dependency_section_key(key: &str) -> bool { - matches!( - key, - "dependencies" | "dev-dependencies" | "build-dependencies" - ) -} - -fn check_reticulum_dependency_table( - dependencies: &toml::map::Map<String, toml::Value>, - manifest_label: &str, - path: &[String], -) -> Result<(), String> { - let section = path.join("."); - for (dependency_key, dependency_spec) in dependencies { - reject_forbidden_reticulum_runtime_name(dependency_key, manifest_label, &section)?; - if let Some(package_name) = dependency_spec - .as_table() - .and_then(|table| table.get("package")) - .and_then(toml::Value::as_str) - { - reject_forbidden_reticulum_runtime_name(package_name, manifest_label, &section)?; - } - } - Ok(()) -} - -fn check_reticulum_feature_table( - features: &toml::map::Map<String, toml::Value>, - manifest_label: &str, - path: &[String], -) -> Result<(), String> { - let section = path.join("."); - for (feature_name, entries) in features { - if feature_name == "transport-reticulum-preview" || feature_name == "reticulum-runtime" { - return Err(format!( - "{manifest_label} feature `{feature_name}` must not introduce a Reticulum preview alias" - )); - } - reject_forbidden_reticulum_runtime_name(feature_name, manifest_label, &section)?; - let entries = entries - .as_array() - .ok_or_else(|| format!("{manifest_label} feature `{feature_name}` must be an array"))?; - for entry in entries { - let entry = entry.as_str().ok_or_else(|| { - format!("{manifest_label} feature `{feature_name}` entries must be strings") - })?; - check_reticulum_feature_entry(entry, manifest_label, feature_name)?; - } - } - Ok(()) -} - -fn check_reticulum_feature_entry( - entry: &str, - manifest_label: &str, - feature_name: &str, -) -> Result<(), String> { - let feature_dependency = entry.strip_prefix("dep:").unwrap_or(entry); - let (dependency_name, dependency_feature) = feature_dependency - .split_once('/') - .map_or((feature_dependency, None), |(name, feature)| { - (name, Some(feature)) - }); - if dependency_name == ALLOWED_RETICULUM_PREVIEW_PACKAGE && dependency_feature.is_some() { - return Err(format!( - "{manifest_label} feature `{feature_name}` must not enable Reticulum preview crate features through `{entry}`" - )); - } - reject_forbidden_reticulum_runtime_name(dependency_name, manifest_label, "features")?; - Ok(()) -} - -fn check_cargo_lock_reticulum_package_names(root: &Path) -> Result<(), String> { - let path = root.join("Cargo.lock"); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let lock = raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let packages = lock - .get("package") - .and_then(toml::Value::as_array) - .ok_or_else(|| "Cargo.lock must define package entries".to_owned())?; - for package in packages { - let name = package - .get("name") - .and_then(toml::Value::as_str) - .ok_or_else(|| "Cargo.lock package entry must define name".to_owned())?; - reject_forbidden_reticulum_runtime_name(name, "Cargo.lock", "package")?; - } - Ok(()) -} - -fn check_cargo_metadata_reticulum_package_names(root: &Path) -> Result<(), String> { - let output = Command::new("cargo") - .args(["metadata", "--format-version", "1", "--all-features"]) - .current_dir(root) - .output() - .map_err(|error| format!("failed to run cargo metadata: {error}"))?; - if !output.status.success() { - return Err(format!( - "cargo metadata failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let metadata = serde_json::from_slice::<CargoMetadata>(&output.stdout) - .map_err(|error| format!("failed to parse cargo metadata: {error}"))?; - for package in metadata.packages { - reject_forbidden_reticulum_runtime_name(&package.name, "cargo metadata", "packages")?; - } - Ok(()) -} - -fn reject_forbidden_reticulum_runtime_name( - name: &str, - manifest_label: &str, - section: &str, -) -> Result<(), String> { - if is_forbidden_reticulum_runtime_name(name) { - Err(format!( - "{manifest_label} section `{section}` must not reference real Reticulum or Python runtime dependency `{name}`" - )) - } else { - Ok(()) - } -} - -fn is_forbidden_reticulum_runtime_name(name: &str) -> bool { - if name == ALLOWED_RETICULUM_PREVIEW_PACKAGE { - return false; - } - let normalized = normalize_package_name(name); - matches!( - normalized.as_str(), - "rns" | "rnsd" | "reticulum" | "reticulum-rs" | "python" | "pyo3" - ) || normalized.contains("reticulum") - || normalized.starts_with("rns-") - || normalized.contains("-rns-") - || normalized.ends_with("-rns") - || normalized.starts_with("python-") - || normalized.starts_with("pyo3-") -} - -fn normalize_package_name(name: &str) -> String { - name.to_ascii_lowercase().replace('_', "-") -} - -fn feature_entries<'features>( - features: &'features toml::map::Map<String, toml::Value>, - feature: &str, -) -> Result<Vec<&'features str>, String> { - features - .get(feature) - .and_then(toml::Value::as_array) - .ok_or_else(|| format!("crates/sdk/Cargo.toml must define feature `{feature}`"))? - .iter() - .map(|entry| { - entry - .as_str() - .ok_or_else(|| format!("feature `{feature}` must contain only string entries")) - }) - .collect() -} - -fn check_package_source_metadata(root: &Path) -> Result<(), String> { - for spec in package_specs() { - let package_dir = root.join(spec.package_dir); - let package_json_path = package_dir.join("package.json"); - let index_path = package_dir.join("src/index.ts"); - let package_json = - check_package_json(&package_json_path, spec.package_name, spec.package_dir)?; - check_package_distribution_metadata(root, &package_dir, &package_json_path, &package_json)?; - if !index_path.is_file() { - return Err(format!("missing package index: {}", index_path.display())); - } - check_package_index(&index_path)?; - } - for spec in wasm_package_specs() { - let package_dir = root.join(spec.package_dir); - let package_json_path = package_dir.join("package.json"); - let package_json = - check_package_json(&package_json_path, spec.package_name, spec.package_dir)?; - check_package_distribution_metadata(root, &package_dir, &package_json_path, &package_json)?; - } - Ok(()) -} - -fn check_generated_outputs(root: &Path) -> Result<(), String> { - let outputs = package_outputs()?; - for output in &outputs { - check_generated_package_artifact_inventory(root, output)?; - } - for output in outputs { - for expected in output.files() { - let path = root - .join(output.spec.package_dir) - .join(expected.relative_path); - let actual = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - if actual != expected.contents { - return Err(format!("stale generated output: {}", path.display())); - } - } - let expected = output.provenance_file(); - let path = root.join(&expected.relative_path); - let actual = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - if actual != expected.contents { - return Err(format!("stale generated provenance: {}", path.display())); - } - } - Ok(()) -} - -fn check_package_build_artifacts(root: &Path) -> Result<(), String> { - for spec in package_specs() { - let package_dir = root.join(spec.package_dir); - let package_json_path = package_dir.join("package.json"); - let package_json = read_package_json_value(&package_json_path)?; - let surface_paths = package_surface_paths(&package_json, &package_json_path)?; - check_package_surface_artifacts(&package_dir, spec.package_name, &surface_paths)?; - } - for spec in wasm_package_specs() { - check_wasm_package_surface(root, *spec)?; - } - Ok(()) -} - -fn check_generated_package_artifact_inventory( - root: &Path, - output: &PackageOutput, -) -> Result<(), String> { - let package_dir = root.join(output.spec.package_dir); - let generated_dir = package_dir.join("src/generated"); - let expected = output - .files() - .into_iter() - .map(|file| file.relative_path) - .collect::<BTreeSet<_>>(); - let actual = generated_package_files(&package_dir, &generated_dir)?; - if actual != expected { - let missing = expected.difference(&actual).cloned().collect::<Vec<_>>(); - let extra = actual.difference(&expected).cloned().collect::<Vec<_>>(); - return Err(format!( - "generated artifact inventory mismatch for {}: missing {:?}, extra {:?}", - output.spec.package_name, missing, extra - )); - } - Ok(()) -} - -fn generated_package_files( - package_dir: &Path, - generated_dir: &Path, -) -> Result<BTreeSet<String>, String> { - let mut files = BTreeSet::new(); - collect_package_files(package_dir, generated_dir, &mut files)?; - Ok(files) -} - -fn collect_package_files( - package_dir: &Path, - dir: &Path, - files: &mut BTreeSet<String>, -) -> Result<(), String> { - for entry in - fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))? - { - let entry = - entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?; - let path = entry.path(); - let file_type = entry - .file_type() - .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?; - if file_type.is_dir() { - collect_package_files(package_dir, &path, files)?; - } else if file_type.is_file() { - files.insert(relative_path_string(package_dir, &path)?); - } - } - Ok(()) -} - -fn relative_path_string(base: &Path, path: &Path) -> Result<String, String> { - let relative = path - .strip_prefix(base) - .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; - Ok(relative - .components() - .map(|component| component.as_os_str().to_string_lossy()) - .collect::<Vec<_>>() - .join("/")) -} - -fn check_package_index(path: &Path) -> Result<(), String> { - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - if raw.starts_with(generated_header()) { - return Err(format!( - "package index must be handwritten source: {}", - path.display() - )); - } - Ok(()) -} - -fn check_binding_crate_sources(root: &Path) -> Result<(), String> { - for spec in package_specs() { - let crate_src_dir = root.join(spec.crate_dir).join("src"); - let typescript_dir = crate_src_dir.join("typescript"); - if typescript_dir.exists() { - return Err(format!( - "forbidden crate TypeScript source directory exists: {}", - typescript_dir.display() - )); - } - check_no_typescript_files(&crate_src_dir)?; - } - for spec in wasm_package_specs() { - check_no_typescript_files(&root.join(spec.crate_dir).join("src"))?; - } - Ok(()) -} - -fn check_no_typescript_files(dir: &Path) -> Result<(), String> { - for entry in - fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))? - { - let entry = - entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?; - let path = entry.path(); - let file_type = entry - .file_type() - .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?; - if file_type.is_dir() { - check_no_typescript_files(&path)?; - } else if file_type.is_file() - && path.extension().and_then(|extension| extension.to_str()) == Some("ts") - { - return Err(format!( - "forbidden crate TypeScript source file exists: {}", - path.display() - )); - } - } - Ok(()) -} - -fn check_forbidden_packages(root: &Path) -> Result<(), String> { - for forbidden in FORBIDDEN_PACKAGE_NAMES { - let package_leaf = forbidden.trim_start_matches("@radroots/").to_owned(); - let forbidden_dir = root.join("packages").join(package_leaf); - if forbidden_dir.exists() { - return Err(format!( - "forbidden package directory exists: {}", - forbidden_dir.display() - )); - } - } - Ok(()) -} - -fn check_package_json( - path: &Path, - expected_name: &str, - expected_directory: &str, -) -> Result<serde_json::Value, String> { - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let json = serde_json::from_str::<serde_json::Value>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let actual_name = json - .get("name") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| format!("package.json missing name: {}", path.display()))?; - if actual_name != expected_name { - return Err(format!( - "package name mismatch in {}: expected {expected_name}, found {actual_name}", - path.display() - )); - } - if json.get("private").is_some() { - return Err(format!( - "public package must not set private: {}", - path.display() - )); - } - let _ = package_description(&json, path)?; - require_string_field(&json, path, "version", PACKAGE_VERSION)?; - require_string_field(&json, path, "license", PACKAGE_LICENSE)?; - require_string_field(&json, path, "homepage", PACKAGE_HOMEPAGE)?; - require_string_field(&json, path, "type", "module")?; - require_bool_field(&json, path, "sideEffects", false)?; - check_publish_config(&json, path)?; - check_repository(&json, path, expected_directory)?; - check_package_files(&json, path)?; - check_no_pack_lifecycle_scripts(&json, path)?; - check_workspace_dependencies(&json, path)?; - Ok(json) -} - -pub(crate) fn check_wasm_package_surface(root: &Path, spec: WasmPackageSpec) -> Result<(), String> { - let package_dir = root.join(spec.package_dir); - let package_json_path = package_dir.join("package.json"); - let json = check_package_json(&package_json_path, spec.package_name, spec.package_dir)?; - check_package_distribution_metadata(root, &package_dir, &package_json_path, &json)?; - let dist_manifest = package_dir.join("dist").join("package.json"); - if dist_manifest.exists() { - return Err(format!( - "generated package manifest is forbidden: {}", - dist_manifest.display() - )); - } - check_no_wasm_dist_ignore_files(&package_dir, spec)?; - let surface_paths = package_surface_paths(&json, &package_json_path)?; - check_public_wasm_declaration_inventory(&surface_paths, spec)?; - check_package_surface_artifacts(&package_dir, spec.package_name, &surface_paths)?; - check_wasm_runtime_files(&package_dir, spec)?; - check_wasm_declaration_files(&package_dir, spec)?; - validate_provenance_manifest(root, spec)?; - Ok(()) -} - -fn require_string_field( - json: &serde_json::Value, - package_json_path: &Path, - field: &'static str, - expected: &str, -) -> Result<(), String> { - let actual = json - .get(field) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing {field}: {}", - package_json_path.display() - ) - })?; - if actual != expected { - return Err(format!( - "package.json {field} mismatch in {}: expected {expected}, found {actual}", - package_json_path.display() - )); - } - Ok(()) -} - -fn require_bool_field( - json: &serde_json::Value, - package_json_path: &Path, - field: &'static str, - expected: bool, -) -> Result<(), String> { - let actual = json - .get(field) - .and_then(serde_json::Value::as_bool) - .ok_or_else(|| { - format!( - "package.json missing {field}: {}", - package_json_path.display() - ) - })?; - if actual != expected { - return Err(format!( - "package.json {field} mismatch in {}: expected {expected}, found {actual}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_publish_config(json: &serde_json::Value, package_json_path: &Path) -> Result<(), String> { - let access = json - .get("publishConfig") - .and_then(|value| value.get("access")) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing publishConfig.access: {}", - package_json_path.display() - ) - })?; - if access != PUBLISH_ACCESS { - return Err(format!( - "package.json publishConfig.access mismatch in {}: expected {PUBLISH_ACCESS}, found {access}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_repository( - json: &serde_json::Value, - package_json_path: &Path, - expected_directory: &str, -) -> Result<(), String> { - let repository = json.get("repository").ok_or_else(|| { - format!( - "package.json missing repository: {}", - package_json_path.display() - ) - })?; - let repository_type = repository - .get("type") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing repository.type: {}", - package_json_path.display() - ) - })?; - if repository_type != "git" { - return Err(format!( - "package.json repository.type mismatch in {}: expected git, found {repository_type}", - package_json_path.display() - )); - } - let repository_url = repository - .get("url") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing repository.url: {}", - package_json_path.display() - ) - })?; - if repository_url != PACKAGE_REPOSITORY_URL { - return Err(format!( - "package.json repository.url mismatch in {}: expected {PACKAGE_REPOSITORY_URL}, found {repository_url}", - package_json_path.display() - )); - } - let repository_directory = repository - .get("directory") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing repository.directory: {}", - package_json_path.display() - ) - })?; - if repository_directory != expected_directory { - return Err(format!( - "package.json repository.directory mismatch in {}: expected {expected_directory}, found {repository_directory}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_package_files(json: &serde_json::Value, package_json_path: &Path) -> Result<(), String> { - let files = json - .get("files") - .and_then(serde_json::Value::as_array) - .ok_or_else(|| { - format!( - "package.json missing files: {}", - package_json_path.display() - ) - })?; - let actual = files - .iter() - .map(|value| { - value.as_str().ok_or_else(|| { - format!( - "package.json files entries must be strings: {}", - package_json_path.display() - ) - }) - }) - .collect::<Result<Vec<_>, _>>()?; - if actual != PACKAGE_FILES { - return Err(format!( - "package.json files must publish dist plus approved metadata only: {}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_workspace_dependencies( - json: &serde_json::Value, - package_json_path: &Path, -) -> Result<(), String> { - let Some(dependencies) = json.get("dependencies") else { - return Ok(()); - }; - let dependencies = dependencies.as_object().ok_or_else(|| { - format!( - "package.json dependencies must be an object: {}", - package_json_path.display() - ) - })?; - for (name, value) in dependencies { - if name.starts_with("@radroots/") { - let version = value.as_str().ok_or_else(|| { - format!( - "package.json dependency versions must be strings: {}", - package_json_path.display() - ) - })?; - if version != "workspace:^" { - return Err(format!( - "package.json workspace dependency {name} must use workspace:^ in {}", - package_json_path.display() - )); - } - } - } - Ok(()) -} - -fn check_npm_pack_payloads(root: &Path) -> Result<(), String> { - let pack_dir = - tempfile::tempdir().map_err(|error| format!("failed to create pack temp dir: {error}"))?; - let mut packed_packages = Vec::new(); - for spec in package_specs() { - let package_dir = root.join(spec.package_dir); - let package_json_path = package_dir.join("package.json"); - let json = read_package_json_value(&package_json_path)?; - let expected_dist_files = - expected_packed_dist_files(&package_dir, &json, &package_json_path, None)?; - let required_files = required_npm_payload_files(&expected_dist_files); - let packed = pnpm_pack_package(&package_dir, spec.package_name, pack_dir.path())?; - let packed_json = read_packed_package_json(&packed, spec.package_name)?; - check_packed_package_json( - &json, - &packed_json, - &package_json_path, - spec.package_name, - spec.package_dir, - )?; - let payload_files = packed_payload_files(&packed)?; - validate_npm_pack_payload(spec.package_name, &payload_files, &required_files, None)?; - validate_packed_dist_inventory(spec.package_name, &payload_files, &expected_dist_files)?; - packed_packages.push(packed); - } - for spec in wasm_package_specs() { - let package_dir = root.join(spec.package_dir); - let package_json_path = package_dir.join("package.json"); - let json = read_package_json_value(&package_json_path)?; - let expected_dist_files = - expected_packed_dist_files(&package_dir, &json, &package_json_path, Some(*spec))?; - let required_files = required_npm_payload_files(&expected_dist_files); - let packed = pnpm_pack_package(&package_dir, spec.package_name, pack_dir.path())?; - let packed_json = read_packed_package_json(&packed, spec.package_name)?; - check_packed_package_json( - &json, - &packed_json, - &package_json_path, - spec.package_name, - spec.package_dir, - )?; - let payload_files = packed_payload_files(&packed)?; - validate_npm_pack_payload( - spec.package_name, - &payload_files, - &required_files, - Some(&format!("dist/{}_bg.wasm", spec.out_name)), - )?; - validate_packed_dist_inventory(spec.package_name, &payload_files, &expected_dist_files)?; - packed_packages.push(packed); - } - check_clean_consumer_smoke(&packed_packages)?; - Ok(()) -} - -fn check_no_pack_lifecycle_scripts( - json: &serde_json::Value, - package_json_path: &Path, -) -> Result<(), String> { - let Some(scripts) = json.get("scripts") else { - return Ok(()); - }; - let scripts = scripts.as_object().ok_or_else(|| { - format!( - "package.json scripts must be an object: {}", - package_json_path.display() - ) - })?; - for forbidden in [ - "prepack", - "postpack", - "prepare", - "prepublish", - "prepublishOnly", - ] { - if scripts.contains_key(forbidden) { - return Err(format!( - "package.json script {forbidden} is forbidden because pnpm pack runs lifecycle scripts: {}", - package_json_path.display() - )); - } - } - Ok(()) -} - -fn read_package_json_value(path: &Path) -> Result<serde_json::Value, String> { - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - serde_json::from_str::<serde_json::Value>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display())) -} - -fn required_npm_payload_files(expected_dist_files: &BTreeSet<String>) -> BTreeSet<String> { - let mut required = package_metadata_payload_files(); - required.extend(expected_dist_files.iter().cloned()); - required -} - -fn package_metadata_payload_files() -> BTreeSet<String> { - BTreeSet::from([ - "package.json".to_owned(), - "README.md".to_owned(), - "LICENSE-MIT".to_owned(), - "LICENSE-APACHE".to_owned(), - ]) -} - -fn expected_packed_dist_files( - package_dir: &Path, - json: &serde_json::Value, - package_json_path: &Path, - wasm_spec: Option<WasmPackageSpec>, -) -> Result<BTreeSet<String>, String> { - let mut expected = BTreeSet::new(); - for path in package_surface_paths(json, package_json_path)? { - expected.insert(normalized_package_path(&path)?); - } - if let Some(spec) = wasm_spec { - for path in wasm_runtime_files(spec) { - expected.insert(path); - } - expected.insert(format!("dist/{}.provenance.json", spec.out_name)); - } else { - expected.extend(expected_binding_generated_dist_files(package_dir)?); - } - Ok(expected) -} - -fn expected_binding_generated_dist_files(package_dir: &Path) -> Result<BTreeSet<String>, String> { - let generated_dir = package_dir.join("src/generated"); - let mut expected = BTreeSet::new(); - for source in generated_package_files(package_dir, &generated_dir)? { - let stem = source - .strip_prefix("src/generated/") - .and_then(|path| path.strip_suffix(".ts")) - .ok_or_else(|| { - format!( - "generated package source must be a TypeScript file under src/generated: {}", - package_dir.join(&source).display() - ) - })?; - expected.insert(format!("dist/generated/{stem}.js")); - expected.insert(format!("dist/generated/{stem}.d.ts")); - } - Ok(expected) -} - -fn pnpm_pack_package( - package_dir: &Path, - package_name: &str, - pack_destination: &Path, -) -> Result<PackedPackage, String> { - let output = Command::new("pnpm") - .args(["pack", "--json", "--pack-destination"]) - .arg(pack_destination) - .current_dir(package_dir) - .output() - .map_err(|error| { - format!( - "failed to run pnpm pack for {package_name} in {}: {error}", - package_dir.display() - ) - })?; - if !output.status.success() { - return Err(format!( - "pnpm pack failed for {package_name} in {}: {}", - package_dir.display(), - String::from_utf8_lossy(&output.stderr) - )); - } - let entry = parse_pnpm_pack_entry(package_name, &output.stdout, &output.stderr)?; - packed_package_from_pnpm_entry(package_name, pack_destination, entry) -} - -fn parse_pnpm_pack_entry( - package_name: &str, - stdout: &[u8], - stderr: &[u8], -) -> Result<PnpmPackEntry, String> { - serde_json::from_slice::<PnpmPackEntry>(stdout).map_err(|error| { - format!( - "failed to parse pnpm pack output for {package_name}: {error}; stdout: {}; stderr: {}", - String::from_utf8_lossy(stdout), - String::from_utf8_lossy(stderr) - ) - }) -} - -fn packed_package_from_pnpm_entry( - package_name: &str, - pack_destination: &Path, - entry: PnpmPackEntry, -) -> Result<PackedPackage, String> { - if entry.filename.trim().is_empty() { - return Err(format!( - "pnpm pack output for {package_name} is missing tarball filename" - )); - } - let tarball_path = PathBuf::from(&entry.filename); - if !tarball_path.starts_with(pack_destination) { - return Err(format!( - "pnpm pack tarball for {package_name} must be written under {}: {}", - pack_destination.display(), - tarball_path.display() - )); - } - if !tarball_path.is_file() { - return Err(format!( - "pnpm pack tarball for {package_name} does not exist: {}", - tarball_path.display() - )); - } - Ok(PackedPackage { - package_name: package_name.to_owned(), - tarball_path, - files: entry - .files - .into_iter() - .map(|file| NpmPackFile { path: file.path }) - .collect(), - }) -} - -fn check_clean_consumer_smoke(packed_packages: &[PackedPackage]) -> Result<(), String> { - let consumer_dir = tempfile::tempdir() - .map_err(|error| format!("failed to create clean npm consumer temp dir: {error}"))?; - fs::write( - consumer_dir.path().join("package.json"), - "{\n \"name\": \"radroots-sdk-package-smoke\",\n \"private\": true,\n \"type\": \"module\"\n}\n", - ) - .map_err(|error| { - format!( - "failed to write clean npm consumer package.json in {}: {error}", - consumer_dir.path().display() - ) - })?; - let npm_cache_dir = consumer_dir.path().join(".npm-cache"); - let mut install = Command::new("npm"); - install - .args([ - "install", - "--ignore-scripts", - "--no-audit", - "--no-fund", - "--registry", - "http://127.0.0.1:9", - "--cache", - ]) - .arg(&npm_cache_dir); - for packed in packed_packages { - install.arg(&packed.tarball_path); - } - let output = install - .current_dir(consumer_dir.path()) - .output() - .map_err(|error| { - format!( - "failed to run clean npm consumer install in {}: {error}", - consumer_dir.path().display() - ) - })?; - if !output.status.success() { - return Err(format!( - "clean npm consumer install failed in {}: stdout: {}; stderr: {}", - consumer_dir.path().display(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - )); - } - let package_names = packed_packages - .iter() - .map(|package| package.package_name.clone()) - .collect::<Vec<_>>(); - let smoke_script = consumer_smoke_script(&package_names)?; - fs::write(consumer_dir.path().join("smoke.mjs"), smoke_script).map_err(|error| { - format!( - "failed to write clean npm consumer smoke script in {}: {error}", - consumer_dir.path().display() - ) - })?; - let output = Command::new("node") - .arg("smoke.mjs") - .current_dir(consumer_dir.path()) - .output() - .map_err(|error| { - format!( - "failed to run clean npm consumer import smoke in {}: {error}", - consumer_dir.path().display() - ) - })?; - if !output.status.success() { - return Err(format!( - "clean npm consumer import smoke failed in {}: stdout: {}; stderr: {}", - consumer_dir.path().display(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - )); - } - Ok(()) -} - -fn consumer_smoke_script(package_names: &[String]) -> Result<String, String> { - let mut script = String::new(); - for package_name in package_names { - let quoted = serde_json::to_string(package_name) - .map_err(|error| format!("failed to quote package name {package_name}: {error}"))?; - script.push_str("await import("); - script.push_str(&quoted); - script.push_str(");\n"); - } - Ok(script) -} - -fn packed_payload_files(packed: &PackedPackage) -> Result<BTreeSet<String>, String> { - debug_assert!(packed.tarball_path.is_file()); - packed - .files - .iter() - .map(|file| normalized_package_path(&file.path)) - .collect() -} - -fn read_packed_package_json( - packed: &PackedPackage, - package_name: &str, -) -> Result<serde_json::Value, String> { - let file = fs::File::open(&packed.tarball_path).map_err(|error| { - format!( - "failed to open pnpm pack tarball for {package_name}: {}: {error}", - packed.tarball_path.display() - ) - })?; - let decoder = GzDecoder::new(file); - let mut archive = Archive::new(decoder); - let entries = archive.entries().map_err(|error| { - format!( - "failed to read pnpm pack tarball entries for {package_name}: {}: {error}", - packed.tarball_path.display() - ) - })?; - for entry in entries { - let mut entry = entry.map_err(|error| { - format!( - "failed to read pnpm pack tarball entry for {package_name}: {}: {error}", - packed.tarball_path.display() - ) - })?; - let path = entry.path().map_err(|error| { - format!( - "failed to read pnpm pack tarball entry path for {package_name}: {}: {error}", - packed.tarball_path.display() - ) - })?; - if path.as_ref() != Path::new("package/package.json") { - continue; - } - let mut raw = String::new(); - entry.read_to_string(&mut raw).map_err(|error| { - format!( - "failed to read packed package.json for {package_name}: {}: {error}", - packed.tarball_path.display() - ) - })?; - return serde_json::from_str::<serde_json::Value>(&raw).map_err(|error| { - format!( - "failed to parse packed package.json for {package_name}: {}: {error}", - packed.tarball_path.display() - ) - }); - } - Err(format!( - "pnpm pack tarball for {package_name} is missing package/package.json: {}", - packed.tarball_path.display() - )) -} - -fn check_packed_package_json( - source_json: &serde_json::Value, - packed_json: &serde_json::Value, - package_json_path: &Path, - expected_name: &str, - expected_directory: &str, -) -> Result<(), String> { - let source_description = package_description(source_json, package_json_path)?; - let packed_description = package_description(packed_json, package_json_path)?; - if packed_description != source_description { - return Err(format!( - "packed package.json description mismatch in {}: expected {source_description}, found {packed_description}", - package_json_path.display() - )); - } - require_string_field(packed_json, package_json_path, "name", expected_name)?; - require_string_field(packed_json, package_json_path, "version", PACKAGE_VERSION)?; - require_string_field(packed_json, package_json_path, "license", PACKAGE_LICENSE)?; - require_string_field(packed_json, package_json_path, "homepage", PACKAGE_HOMEPAGE)?; - require_string_field(packed_json, package_json_path, "type", "module")?; - require_bool_field(packed_json, package_json_path, "sideEffects", false)?; - check_publish_config(packed_json, package_json_path)?; - check_repository(packed_json, package_json_path, expected_directory)?; - check_package_files(packed_json, package_json_path)?; - check_no_pack_lifecycle_scripts(packed_json, package_json_path)?; - check_same_packed_field(source_json, packed_json, package_json_path, "main")?; - check_same_packed_field(source_json, packed_json, package_json_path, "types")?; - check_same_packed_field(source_json, packed_json, package_json_path, "exports")?; - check_same_packed_field(source_json, packed_json, package_json_path, "scripts")?; - check_packed_dependency_maps(source_json, packed_json, package_json_path)?; - let source_surface = package_surface_paths(source_json, package_json_path)?; - let packed_surface = package_surface_paths(packed_json, package_json_path)?; - if packed_surface != source_surface { - return Err(format!( - "packed package.json export surface mismatch in {}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_same_packed_field( - source_json: &serde_json::Value, - packed_json: &serde_json::Value, - package_json_path: &Path, - field: &'static str, -) -> Result<(), String> { - if source_json.get(field) != packed_json.get(field) { - return Err(format!( - "packed package.json {field} mismatch in {}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_packed_dependency_maps( - source_json: &serde_json::Value, - packed_json: &serde_json::Value, - package_json_path: &Path, -) -> Result<(), String> { - for field in ["dependencies", "peerDependencies", "optionalDependencies"] { - check_packed_dependency_map(source_json, packed_json, package_json_path, field)?; - } - Ok(()) -} - -fn check_packed_dependency_map( - source_json: &serde_json::Value, - packed_json: &serde_json::Value, - package_json_path: &Path, - field: &'static str, -) -> Result<(), String> { - let source_dependencies = optional_dependency_map(source_json, package_json_path, field)?; - let packed_dependencies = optional_dependency_map(packed_json, package_json_path, field)?; - let source_keys = source_dependencies - .iter() - .flat_map(|dependencies| dependencies.keys().cloned()) - .collect::<BTreeSet<_>>(); - let packed_keys = packed_dependencies - .iter() - .flat_map(|dependencies| dependencies.keys().cloned()) - .collect::<BTreeSet<_>>(); - if packed_keys != source_keys { - return Err(format!( - "packed package.json {field} keys mismatch in {}: expected {:?}, found {:?}", - package_json_path.display(), - source_keys, - packed_keys - )); - } - let Some(packed_dependencies) = packed_dependencies else { - return Ok(()); - }; - let source_dependencies = source_dependencies.expect("matching dependency keys require source"); - for (name, packed_version) in packed_dependencies { - let packed_version = packed_version.as_str().ok_or_else(|| { - format!( - "packed package.json {field} dependency versions must be strings in {}", - package_json_path.display() - ) - })?; - if packed_version.starts_with("workspace:") { - return Err(format!( - "packed package.json {field} dependency {name} must not use workspace protocol in {}", - package_json_path.display() - )); - } - let source_version = source_dependencies - .get(name) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "source package.json {field} dependency {name} must be a string in {}", - package_json_path.display() - ) - })?; - let expected = expected_packed_dependency_version(name, source_version); - if packed_version != expected { - return Err(format!( - "packed package.json {field} dependency {name} mismatch in {}: expected {expected}, found {packed_version}", - package_json_path.display() - )); - } - } - Ok(()) -} - -fn optional_dependency_map<'a>( - json: &'a serde_json::Value, - package_json_path: &Path, - field: &'static str, -) -> Result<Option<&'a serde_json::Map<String, serde_json::Value>>, String> { - json.get(field) - .map(|value| { - value.as_object().ok_or_else(|| { - format!( - "package.json {field} must be an object: {}", - package_json_path.display() - ) - }) - }) - .transpose() -} - -fn expected_packed_dependency_version(name: &str, source_version: &str) -> String { - if name.starts_with("@radroots/") && source_version == "workspace:^" { - format!("^{PACKAGE_VERSION}") - } else { - source_version.to_owned() - } -} - -fn validate_npm_pack_payload( - package_name: &str, - payload_files: &BTreeSet<String>, - required_files: &BTreeSet<String>, - expected_wasm_file: Option<&str>, -) -> Result<(), String> { - let missing = required_files - .difference(payload_files) - .cloned() - .collect::<Vec<_>>(); - if !missing.is_empty() { - return Err(format!( - "npm pack payload for {package_name} is missing required files: {missing:?}" - )); - } - for path in payload_files { - if let Some(reason) = forbidden_npm_payload_path(path) { - return Err(format!( - "npm pack payload for {package_name} includes forbidden {reason}: {path}" - )); - } - } - if let Some(expected_wasm_file) = expected_wasm_file { - let wasm_files = payload_files - .iter() - .filter(|path| path.ends_with(".wasm")) - .cloned() - .collect::<Vec<_>>(); - if wasm_files != [expected_wasm_file.to_owned()] { - return Err(format!( - "npm pack payload for {package_name} must include exactly {expected_wasm_file}; found {wasm_files:?}" - )); - } - } - Ok(()) -} - -fn validate_packed_dist_inventory( - package_name: &str, - payload_files: &BTreeSet<String>, - expected_dist_files: &BTreeSet<String>, -) -> Result<(), String> { - let actual_dist_files = payload_files - .iter() - .filter(|path| path.starts_with("dist/")) - .cloned() - .collect::<BTreeSet<_>>(); - if &actual_dist_files != expected_dist_files { - let missing = expected_dist_files - .difference(&actual_dist_files) - .cloned() - .collect::<Vec<_>>(); - let extra = actual_dist_files - .difference(expected_dist_files) - .cloned() - .collect::<Vec<_>>(); - return Err(format!( - "npm pack payload for {package_name} has dist inventory mismatch: missing {:?}, extra {:?}", - missing, extra - )); - } - Ok(()) -} - -fn normalized_package_path(path: &str) -> Result<String, String> { - let normalized = path.trim_start_matches("./"); - if normalized.is_empty() - || normalized.starts_with('/') - || normalized.contains('\\') - || normalized - .split('/') - .any(|segment| segment.is_empty() || segment == "." || segment == "..") - { - return Err(format!("invalid npm pack payload path: {path}")); - } - Ok(normalized.to_owned()) -} - -fn forbidden_npm_payload_path(path: &str) -> Option<&'static str> { - if path.starts_with("src/") { - return Some("source path"); - } - if path.starts_with("contracts/") { - return Some("contract path"); - } - if path.contains("sdk-manifest") { - return Some("manifest provenance path"); - } - if path.ends_with(".tsbuildinfo") { - return Some("TypeScript build info path"); - } - if path - .split('/') - .any(|segment| matches!(segment, ".gitignore" | ".npmignore")) - { - return Some("ignore file"); - } - None -} - -fn check_public_wasm_declaration_inventory( - surface_paths: &BTreeSet<String>, - spec: WasmPackageSpec, -) -> Result<(), String> { - let actual = surface_paths - .iter() - .filter(|path| path.ends_with(".d.ts")) - .cloned() - .collect::<BTreeSet<_>>(); - let expected = BTreeSet::from([format!("./dist/{}.d.ts", spec.out_name)]); - if actual != expected { - return Err(format!( - "public wasm declaration inventory mismatch for {}: expected {:?}, found {:?}", - spec.package_name, expected, actual - )); - } - Ok(()) -} - -fn check_no_wasm_dist_ignore_files( - package_dir: &Path, - spec: WasmPackageSpec, -) -> Result<(), String> { - for file_name in [".gitignore", ".npmignore"] { - let path = package_dir.join("dist").join(file_name); - if path.exists() { - return Err(format!( - "wasm dist ignore file would hide package payload from npm for {}: {}", - spec.package_name, - path.display() - )); - } - } - Ok(()) -} - -fn check_wasm_runtime_files(package_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> { - for relative in wasm_runtime_files(spec) { - let path = package_dir.join(&relative); - if !path.is_file() { - return Err(format!( - "missing wasm package runtime artifact for {}: {}", - spec.package_name, - path.display() - )); - } - } - Ok(()) -} - -fn wasm_runtime_files(spec: WasmPackageSpec) -> [String; 3] { - [ - format!("dist/{}.js", spec.out_name), - format!("dist/{}_bg.wasm", spec.out_name), - format!("dist/{}_bg.wasm.d.ts", spec.out_name), - ] -} - -fn check_wasm_declaration_files(package_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> { - for expected in declaration_files(spec)? { - let path = package_dir.join(&expected.relative_path); - let actual = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - if actual != expected.contents { - return Err(format!( - "stale dto_bindgen wasm declaration: {}", - path.display() - )); - } - } - Ok(()) -} - -fn package_surface_paths( - json: &serde_json::Value, - package_json_path: &Path, -) -> Result<BTreeSet<String>, String> { - let mut paths = BTreeSet::new(); - collect_required_package_path(json, package_json_path, "main", &mut paths)?; - collect_required_package_path(json, package_json_path, "types", &mut paths)?; - let exports = json.get("exports").ok_or_else(|| { - format!( - "package.json missing exports: {}", - package_json_path.display() - ) - })?; - match exports { - serde_json::Value::String(path) => { - validate_package_surface_path(path, package_json_path, "exports")?; - paths.insert(path.clone()); - } - serde_json::Value::Object(map) => { - if map.keys().any(|key| key != ".") { - return Err(format!( - "package.json only supports root exports: {}", - package_json_path.display() - )); - } - let root_export = map.get(".").ok_or_else(|| { - format!( - "package.json missing root export: {}", - package_json_path.display() - ) - })?; - collect_export_paths(root_export, package_json_path, "exports[\".\"]", &mut paths)?; - } - _ => { - return Err(format!( - "package.json exports must be a string or object: {}", - package_json_path.display() - )); - } - } - Ok(paths) -} - -fn collect_required_package_path( - json: &serde_json::Value, - package_json_path: &Path, - field: &'static str, - paths: &mut BTreeSet<String>, -) -> Result<(), String> { - let value = json - .get(field) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing {field}: {}", - package_json_path.display() - ) - })?; - validate_package_surface_path(value, package_json_path, field)?; - paths.insert(value.to_owned()); - Ok(()) -} - -fn collect_export_paths( - value: &serde_json::Value, - package_json_path: &Path, - field: &str, - paths: &mut BTreeSet<String>, -) -> Result<(), String> { - match value { - serde_json::Value::String(path) => { - validate_package_surface_path(path, package_json_path, field)?; - paths.insert(path.clone()); - Ok(()) - } - serde_json::Value::Object(map) => { - for (key, value) in map { - collect_export_paths(value, package_json_path, &format!("{field}.{key}"), paths)?; - } - Ok(()) - } - _ => Err(format!( - "package.json {field} must name file paths: {}", - package_json_path.display() - )), - } -} - -fn validate_package_surface_path( - value: &str, - package_json_path: &Path, - field: &str, -) -> Result<(), String> { - if value.trim().is_empty() - || value.trim() != value - || !value.starts_with("./dist/") - || value.contains('\\') - || value.split('/').any(|segment| segment == "..") - { - return Err(format!( - "package.json {field} must be a relative dist path: {}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_package_surface_artifacts( - package_dir: &Path, - package_name: &str, - surface_paths: &BTreeSet<String>, -) -> Result<(), String> { - for relative in surface_paths { - let normalized = relative.trim_start_matches("./"); - let path = package_dir.join(normalized); - if !path.is_file() { - return Err(format!( - "missing package export artifact for {package_name}: {}", - path.display() - )); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::{ - collections::BTreeSet, - fs, - path::{Path, PathBuf}, - time::{SystemTime, UNIX_EPOCH}, - }; - - use flate2::{Compression, write::GzEncoder}; - - use crate::{ - output::package_outputs, - package_matrix::{WasmPackageSpec, validate_package_matrix}, - package_metadata::package_readme, - }; - - use super::{ - PackedPackage, check_binding_crate_sources, check_generated_package_artifact_inventory, - check_manifest_reticulum_dependency_boundaries, check_no_typescript_files, - check_package_distribution_metadata, check_package_index, check_package_json, - check_package_surface_artifacts, check_packed_package_json, check_publication_policy, - check_reticulum_feature_entry, check_wasm_package_surface, consumer_smoke_script, - expected_packed_dist_files, is_forbidden_reticulum_runtime_name, normalized_package_path, - parse_pnpm_pack_entry, read_packed_package_json, validate_npm_pack_payload, - validate_packed_dist_inventory, - }; - - const APPROVED_CRATES: [&str; 19] = [ - "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", - ]; - - fn toml_strings(values: &[&str]) -> String { - values - .iter() - .map(|value| format!("\"{value}\"")) - .collect::<Vec<_>>() - .join(", ") - } - - fn write_publication_architecture(root: &Path) { - fs::create_dir_all(root.join("docs/specs")).expect("create spec directory"); - let mut architecture = format!( - "spec_id = \"radroots.crates.release.v1\"\npackage_count = 19\n\n[repositories.lib]\nversion = \"0.1.0-alpha\"\npackages = [{}]\n\n[repositories.sdk]\nversion = \"0.1.0\"\npackages = [{}]\n", - toml_strings(&APPROVED_CRATES[..17]), - toml_strings(&APPROVED_CRATES[17..]), - ); - for name in APPROVED_CRATES { - architecture.push_str(&format!("\n[[package]]\nname = \"{name}\"\n")); - } - fs::write( - root.join("docs/specs/radroots_crates_release_v1.toml"), - architecture, - ) - .expect("write release architecture"); - } - - fn publication_policy( - frozen: bool, - approved: &[&str], - private: &[&str], - build_codegen: &[&str], - ) -> String { - let publish_order = if frozen { - String::new() - } else { - "\n[publish_order]\ncrates = [\"radroots_sdk\", \"radroots\"]\n".to_owned() - }; - format!( - r#"[publication] -frozen = {frozen} -registry = "crates-io" -final_enablement_step = 305 -spec_id = "radroots.crates.release.v1" -approved_packages = [{}] -local_packages = ["radroots_sdk", "radroots"] -external_packages = [{}] - -[workspace_classification] -private = [{}] -build_codegen = [{}] -test_support = [] -preview = [] -retired = [] -{publish_order} -"#, - toml_strings(approved), - toml_strings(&APPROVED_CRATES[..17]), - toml_strings(private), - toml_strings(build_codegen), - ) - } - - #[test] - fn package_skeleton_is_valid() { - validate_package_matrix().expect("package matrix validates"); - } - - #[test] - fn publication_freeze_rejects_publishable_or_implicit_packages() { - let root = test_root("publication_freeze"); - fs::create_dir_all(root.join("contracts/releases")).expect("create contracts"); - write_publication_architecture(&root); - fs::create_dir_all(root.join("crates/a")).expect("create crate a"); - fs::create_dir_all(root.join("crates/b")).expect("create crate b"); - fs::write( - root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/a\", \"crates/b\"]\n", - ) - .expect("write workspace"); - fs::write( - root.join("contracts/releases/publication.toml"), - publication_policy(true, &APPROVED_CRATES, &["crate-a", "crate-b"], &[]), - ) - .expect("write policy"); - fs::write( - root.join("crates/a/Cargo.toml"), - "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\npublish = false\n", - ) - .expect("write crate a"); - fs::write( - root.join("crates/b/Cargo.toml"), - "[package]\nname = \"crate-b\"\nversion = \"0.1.0\"\npublish = false\n", - ) - .expect("write crate b"); - check_publication_policy(&root).expect("private workspace satisfies freeze"); - - fs::write( - root.join("crates/a/Cargo.toml"), - "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\npublish = [\"crates-io\"]\n", - ) - .expect("make crate a publishable"); - let publishable = - check_publication_policy(&root).expect_err("publishable crate must fail freeze"); - assert!(publishable.contains("publication freeze requires workspace package crate-a")); - - fs::write( - root.join("crates/a/Cargo.toml"), - "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\n", - ) - .expect("remove publish control"); - let implicit = - check_publication_policy(&root).expect_err("implicit publishability must fail freeze"); - assert!(implicit.contains("publication freeze requires workspace package crate-a")); - - fs::write( - root.join("crates/a/Cargo.toml"), - "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\npublish = false\n", - ) - .expect("restore private crate a"); - let mut unapproved = APPROVED_CRATES.to_vec(); - unapproved.push("unapproved-public"); - fs::write( - root.join("contracts/releases/publication.toml"), - publication_policy(true, &unapproved, &["crate-a", "crate-b"], &[]), - ) - .expect("write unapproved policy"); - let unapproved_error = check_publication_policy(&root) - .expect_err("unapproved public package must fail exact catalog validation"); - assert!(unapproved_error.contains("unapproved packages: unapproved-public")); - - fs::write( - root.join("contracts/releases/publication.toml"), - publication_policy(true, &APPROVED_CRATES, &["crate-a"], &[]), - ) - .expect("write unclassified policy"); - let unclassified = - check_publication_policy(&root).expect_err("unclassified workspace package must fail"); - assert!(unclassified.contains("workspace classification is missing packages: crate-b")); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn publication_enablement_allows_only_the_two_approved_packages() { - let root = test_root("publication_enablement"); - fs::create_dir_all(root.join("contracts/releases")).expect("create contracts"); - write_publication_architecture(&root); - for member in ["sdk", "facade", "tool"] { - fs::create_dir_all(root.join("crates").join(member)).expect("create member"); - } - fs::write( - root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/sdk\", \"crates/facade\", \"crates/tool\"]\n", - ) - .expect("write workspace"); - fs::write( - root.join("contracts/releases/publication.toml"), - publication_policy(false, &APPROVED_CRATES, &[], &["build-tool"]), - ) - .expect("write policy"); - fs::write( - root.join("crates/sdk/Cargo.toml"), - "[package]\nname = \"radroots_sdk\"\nversion = \"0.1.0\"\npublish = [\"crates-io\"]\n", - ) - .expect("write SDK manifest"); - fs::write( - root.join("crates/facade/Cargo.toml"), - "[package]\nname = \"radroots\"\nversion = \"0.1.0\"\npublish = [\"crates-io\"]\n", - ) - .expect("write facade manifest"); - fs::write( - root.join("crates/tool/Cargo.toml"), - "[package]\nname = \"build-tool\"\nversion = \"0.1.0\"\npublish = false\n", - ) - .expect("write tool manifest"); - check_publication_policy(&root).expect("approved package staging should pass"); - - fs::write( - root.join("crates/tool/Cargo.toml"), - "[package]\nname = \"build-tool\"\nversion = \"0.1.0\"\npublish = [\"crates-io\"]\n", - ) - .expect("make tool publishable"); - let extra = check_publication_policy(&root) - .expect_err("non-approved publishable package must fail enablement"); - assert!(extra.contains("non-approved workspace package build-tool")); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn reticulum_gate_allows_first_party_preview_dependency_only() { - let manifest = manifest_value( - r#" -[package] -name = "radroots_sdk" - -[features] -runtime = ["dep:radroots_transport_reticulum"] - -[dependencies] -radroots_transport_reticulum = { workspace = true, optional = true, default-features = false } -"#, - ); - - check_manifest_reticulum_dependency_boundaries(&manifest, "crates/sdk/Cargo.toml") - .expect("first-party preview dependency is allowed"); - } - - #[test] - fn reticulum_gate_ignores_docs_rs_feature_selection() { - let manifest = manifest_value( - r#" -[package] -name = "radroots_sdk" - -[package.metadata.docs.rs] -features = ["nostr", "nip46"] - -[features] -nostr = [] -nip46 = ["nostr"] -"#, - ); - - check_manifest_reticulum_dependency_boundaries(&manifest, "crates/sdk/Cargo.toml") - .expect("docs.rs feature arrays are metadata, not Cargo feature tables"); - } - - #[test] - fn reticulum_gate_rejects_workspace_package_alias_to_real_runtime() { - let manifest = manifest_value( - r#" -[workspace.dependencies] -rr_mesh = { package = "reticulum", version = "1" } -"#, - ); - - let error = check_manifest_reticulum_dependency_boundaries(&manifest, "Cargo.toml") - .expect_err("Reticulum package alias is rejected"); - - assert!(error.contains("reticulum")); - assert!(error.contains("workspace.dependencies")); - } - - #[test] - fn reticulum_gate_rejects_build_dependency_alias_to_python_binding() { - let manifest = manifest_value( - r#" -[package] -name = "radroots_sdk" - -[build-dependencies] -ffi_bridge = { package = "pyo3", version = "0.24" } -"#, - ); - - let error = - check_manifest_reticulum_dependency_boundaries(&manifest, "crates/sdk/Cargo.toml") - .expect_err("Python runtime binding package alias is rejected"); - - assert!(error.contains("pyo3")); - assert!(error.contains("build-dependencies")); - } - - #[test] - fn reticulum_gate_rejects_reticulum_feature_aliases_and_runtime_features() { - let alias_manifest = manifest_value( - r#" -[package] -name = "radroots_sdk" - -[features] -transport-reticulum-preview = [] -"#, - ); - let alias_error = check_manifest_reticulum_dependency_boundaries( - &alias_manifest, - "crates/sdk/Cargo.toml", - ) - .expect_err("Reticulum feature alias is rejected"); - - assert!(alias_error.contains("transport-reticulum-preview")); - - let entry_error = check_reticulum_feature_entry( - "radroots_transport_reticulum/client", - "crates/sdk/Cargo.toml", - "runtime", - ) - .expect_err("Reticulum preview crate runtime feature is rejected"); - - assert!(entry_error.contains("radroots_transport_reticulum/client")); - } - - #[test] - fn reticulum_gate_package_name_classifier_is_runtime_scoped() { - for name in [ - "reticulum", - "reticulum-rs", - "rns", - "rns-client", - "python", - "python-runtime", - "pyo3", - "pyo3-ffi", - "radroots-transport-reticulum", - ] { - assert!( - is_forbidden_reticulum_runtime_name(name), - "{name} must be forbidden" - ); - } - for name in ["radroots_transport_reticulum", "dto_bindgen_backend_python"] { - assert!( - !is_forbidden_reticulum_runtime_name(name), - "{name} must be allowed" - ); - } - } - - #[test] - fn rejects_crate_typescript_directories() { - let root = test_root("typescript_dir"); - let typescript_dir = root - .join("crates") - .join("core_bindings") - .join("src") - .join("typescript"); - fs::create_dir_all(&typescript_dir).expect("create forbidden directory"); - - let error = check_binding_crate_sources(&root).expect_err("forbidden directory rejected"); - - assert!(error.contains("forbidden crate TypeScript source directory")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn rejects_crate_typescript_files() { - let root = test_root("typescript_file"); - let src_dir = root.join("crates/core_bindings/src"); - fs::create_dir_all(&src_dir).expect("create crate source directory"); - fs::write(src_dir.join("types.ts"), "export type A = string;\n") - .expect("write forbidden file"); - - let error = check_no_typescript_files(&src_dir).expect_err("forbidden file rejected"); - - assert!(error.contains("forbidden crate TypeScript source file")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn rejects_generated_package_index_source() { - let root = test_root("generated_index"); - let path = root.join("src/index.ts"); - fs::create_dir_all(path.parent().expect("parent")).expect("create source directory"); - fs::write( - &path, - "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nexport {};\n", - ) - .expect("write generated index"); - - let error = check_package_index(&path).expect_err("generated index rejected"); - - assert!(error.contains("package index must be handwritten source")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn generated_package_artifact_inventory_rejects_extra_files() { - let root = test_root("generated_inventory_extra"); - let output = package_outputs() - .expect("package outputs") - .into_iter() - .find(|output| output.spec.key == "core") - .expect("core output"); - let package_dir = root.join(output.spec.package_dir); - fs::create_dir_all(package_dir.join("src/generated")).expect("create generated dir"); - for file in output.files() { - let path = package_dir.join(file.relative_path); - fs::write(path, file.contents).expect("write expected file"); - } - fs::write( - package_dir.join("src/generated").join("extra.ts"), - "export type Extra = string;\n", - ) - .expect("write extra file"); - - let error = check_generated_package_artifact_inventory(&root, &output) - .expect_err("extra generated file rejected"); - - assert!(error.contains("generated artifact inventory mismatch")); - assert!(error.contains("extra.ts")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn public_package_metadata_rejects_private_packages() { - let root = test_root("private_package_json"); - let package_dir = root.join("packages").join("example"); - fs::create_dir_all(&package_dir).expect("create package"); - let package_json = package_json("example").replace( - r#""sideEffects": false,"#, - r#""private": true, "sideEffects": false,"#, - ); - fs::write(package_dir.join("package.json"), package_json).expect("write package json"); - - let error = check_package_json( - &package_dir.join("package.json"), - "@radroots/example", - "packages/example", - ) - .expect_err("private package rejected"); - - assert!(error.contains("must not set private")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn public_package_metadata_rejects_src_generated_payloads() { - let root = test_root("src_generated_package_payload"); - let package_dir = root.join("packages").join("example"); - fs::create_dir_all(&package_dir).expect("create package"); - let package_json = package_json("example").replace( - r#""files": ["dist", "README.md", "LICENSE-MIT", "LICENSE-APACHE"]"#, - r#""files": ["dist", "README.md", "LICENSE-MIT", "LICENSE-APACHE", "src/generated"]"#, - ); - fs::write(package_dir.join("package.json"), package_json).expect("write package json"); - - let error = check_package_json( - &package_dir.join("package.json"), - "@radroots/example", - "packages/example", - ) - .expect_err("src generated package payload rejected"); - - assert!(error.contains("files must publish dist plus approved metadata only")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn public_package_metadata_rejects_pack_lifecycle_scripts() { - let root = test_root("pack_lifecycle_scripts"); - let package_dir = root.join("packages").join("example"); - fs::create_dir_all(&package_dir).expect("create package"); - let package_json = package_json("example").replace( - r#""type": "module","#, - r#""scripts": {"prepack": "echo forbidden"}, "type": "module","#, - ); - fs::write(package_dir.join("package.json"), package_json).expect("write package json"); - - let error = check_package_json( - &package_dir.join("package.json"), - "@radroots/example", - "packages/example", - ) - .expect_err("pack lifecycle script rejected"); - - assert!(error.contains("script prepack is forbidden")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn package_distribution_metadata_matches_root_license_files() { - let root = test_root("package_distribution_metadata"); - let package_dir = root.join("packages").join("example"); - fs::create_dir_all(&package_dir).expect("create package"); - fs::write(root.join("LICENSE-MIT"), "MIT license\n").expect("write MIT license"); - fs::write(root.join("LICENSE-APACHE"), "Apache license\n").expect("write Apache license"); - fs::write(package_dir.join("package.json"), package_json("example")) - .expect("write package json"); - fs::write( - package_dir.join("README.md"), - package_readme("@radroots/example", "Example package"), - ) - .expect("write readme"); - fs::write(package_dir.join("LICENSE-MIT"), "MIT license\n") - .expect("write package MIT license"); - fs::write(package_dir.join("LICENSE-APACHE"), "Apache license\n") - .expect("write package Apache license"); - let json = check_package_json( - &package_dir.join("package.json"), - "@radroots/example", - "packages/example", - ) - .expect("valid package json"); - - check_package_distribution_metadata( - &root, - &package_dir, - &package_dir.join("package.json"), - &json, - ) - .expect("metadata matches"); - - fs::write(package_dir.join("README.md"), "stale\n").expect("stale readme"); - let error = check_package_distribution_metadata( - &root, - &package_dir, - &package_dir.join("package.json"), - &json, - ) - .expect_err("stale readme rejected"); - assert!(error.contains("stale package README")); - - fs::write( - package_dir.join("README.md"), - package_readme("@radroots/example", "Example package"), - ) - .expect("restore readme"); - fs::write(package_dir.join("LICENSE-MIT"), "stale\n").expect("stale license"); - let error = check_package_distribution_metadata( - &root, - &package_dir, - &package_dir.join("package.json"), - &json, - ) - .expect_err("stale license rejected"); - assert!(error.contains("stale package license metadata")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn package_surface_artifacts_require_dist_files() { - let root = test_root("package_surface_artifacts"); - let package_dir = root.join("packages").join("example"); - fs::create_dir_all(package_dir.join("dist")).expect("create dist"); - fs::write(package_dir.join("dist").join("index.js"), "export {};\n").expect("write js"); - let surface_paths = - BTreeSet::from(["./dist/index.js".to_owned(), "./dist/index.d.ts".to_owned()]); - - let error = - check_package_surface_artifacts(&package_dir, "@radroots/example", &surface_paths) - .expect_err("missing declaration should fail"); - assert!(error.contains("missing package export artifact")); - assert!(error.contains("index.d.ts")); - - fs::write(package_dir.join("dist").join("index.d.ts"), "export {};\n").expect("write d.ts"); - check_package_surface_artifacts(&package_dir, "@radroots/example", &surface_paths) - .expect("surface artifacts present"); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn binding_dist_inventory_includes_generated_js_and_declarations() { - let root = test_root("binding_dist_inventory"); - let package_dir = root.join("packages").join("example"); - fs::create_dir_all(package_dir.join("src/generated")).expect("create generated dir"); - fs::write( - package_dir.join("src/generated").join("types.ts"), - "export type Example = string;\n", - ) - .expect("write generated types"); - fs::write( - package_dir.join("src/generated").join("constants.ts"), - "export const EXAMPLE = \"example\";\n", - ) - .expect("write generated constants"); - let json = package_json_value(&package_json("example")); - - let expected = expected_packed_dist_files( - &package_dir, - &json, - Path::new("packages/example/package.json"), - None, - ) - .expect("expected dist files"); - - assert_eq!( - expected, - BTreeSet::from([ - "dist/generated/constants.d.ts".to_owned(), - "dist/generated/constants.js".to_owned(), - "dist/generated/types.d.ts".to_owned(), - "dist/generated/types.js".to_owned(), - "dist/index.d.ts".to_owned(), - "dist/index.js".to_owned(), - ]) - ); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn npm_pack_payload_requires_metadata_and_export_files() { - let payload_files = BTreeSet::from([ - "package.json".to_owned(), - "README.md".to_owned(), - "LICENSE-MIT".to_owned(), - "dist/index.js".to_owned(), - ]); - let required_files = BTreeSet::from([ - "package.json".to_owned(), - "README.md".to_owned(), - "LICENSE-MIT".to_owned(), - "LICENSE-APACHE".to_owned(), - "dist/index.js".to_owned(), - "dist/index.d.ts".to_owned(), - ]); - - let error = - validate_npm_pack_payload("@radroots/example", &payload_files, &required_files, None) - .expect_err("missing files should fail"); - assert!(error.contains("missing required files")); - assert!(error.contains("LICENSE-APACHE")); - assert!(error.contains("dist/index.d.ts")); - } - - #[test] - fn packed_dist_inventory_rejects_missing_generated_files() { - let expected_dist_files = BTreeSet::from([ - "dist/generated/types.d.ts".to_owned(), - "dist/generated/types.js".to_owned(), - "dist/index.d.ts".to_owned(), - "dist/index.js".to_owned(), - ]); - let payload_files = BTreeSet::from([ - "package.json".to_owned(), - "README.md".to_owned(), - "LICENSE-MIT".to_owned(), - "LICENSE-APACHE".to_owned(), - "dist/generated/types.js".to_owned(), - "dist/index.d.ts".to_owned(), - "dist/index.js".to_owned(), - ]); - - let error = validate_packed_dist_inventory( - "@radroots/example", - &payload_files, - &expected_dist_files, - ) - .expect_err("missing generated declaration rejected"); - - assert!(error.contains("dist inventory mismatch")); - assert!(error.contains("dist/generated/types.d.ts")); - } - - #[test] - fn packed_payload_path_normalization_rejects_invalid_paths() { - assert_eq!( - normalized_package_path("./dist/index.js").expect("valid path"), - "dist/index.js" - ); - for invalid in [ - "", - "./", - "/dist/index.js", - "dist\\index.js", - "dist/../index.js", - "dist//index.js", - "dist/./index.js", - ] { - let error = normalized_package_path(invalid).expect_err("invalid path rejected"); - assert!(error.contains("invalid npm pack payload path")); - } - } - - #[test] - fn consumer_smoke_script_imports_package_roots() { - let script = consumer_smoke_script(&[ - "@radroots/core-bindings".to_owned(), - "@radroots/event-codec-wasm".to_owned(), - ]) - .expect("smoke script renders"); - - assert_eq!( - script, - "await import(\"@radroots/core-bindings\");\nawait import(\"@radroots/event-codec-wasm\");\n" - ); - } - - #[test] - fn pnpm_pack_json_parser_accepts_single_package_object() { - let entry = parse_pnpm_pack_entry( - "@radroots/example", - br#"{ - "name": "@radroots/example", - "version": "0.1.0", - "filename": "/tmp/example.tgz", - "files": [ - {"path": "dist/index.js"}, - {"path": "package.json"} - ] -}"#, - b"", - ) - .expect("pnpm pack output parses"); - - assert_eq!(entry.filename, "/tmp/example.tgz"); - assert_eq!( - entry - .files - .into_iter() - .map(|file| file.path) - .collect::<Vec<_>>(), - ["dist/index.js", "package.json"] - ); - } - - #[test] - fn reads_packed_manifest_from_tgz() { - let root = test_root("packed_manifest_tgz"); - fs::create_dir_all(&root).expect("create root"); - let tarball_path = root.join("example.tgz"); - let file = fs::File::create(&tarball_path).expect("create tarball"); - let encoder = GzEncoder::new(file, Compression::default()); - let mut builder = tar::Builder::new(encoder); - let contents = br#"{"name":"@radroots/example","version":"0.1.0"}"#; - let mut header = tar::Header::new_gnu(); - header - .set_path("package/package.json") - .expect("set manifest path"); - header.set_size(contents.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder - .append(&header, &contents[..]) - .expect("append manifest"); - let encoder = builder.into_inner().expect("finish tar"); - encoder.finish().expect("finish gzip"); - let packed = PackedPackage { - package_name: "@radroots/example".to_owned(), - tarball_path, - files: Vec::new(), - }; - - let json = - read_packed_package_json(&packed, "@radroots/example").expect("read packed manifest"); - - assert_eq!( - json.get("name").and_then(serde_json::Value::as_str), - Some("@radroots/example") - ); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn packed_manifest_accepts_pnpm_workspace_dependency_rewrite() { - let source = package_json_value(&package_json_with_dependencies( - "trade-bindings", - r#""@radroots/core-bindings": "workspace:^", - "@radroots/event-bindings": "workspace:^""#, - )); - let packed = package_json_value(&package_json_with_dependencies( - "trade-bindings", - r#""@radroots/core-bindings": "^0.1.0", - "@radroots/event-bindings": "^0.1.0""#, - )); - - check_packed_package_json( - &source, - &packed, - Path::new("packages/trade-bindings/package.json"), - "@radroots/trade-bindings", - "packages/trade-bindings", - ) - .expect("packed manifest accepted"); - } - - #[test] - fn packed_manifest_rejects_workspace_dependency_ranges() { - let source = package_json_value(&package_json_with_dependencies( - "trade-bindings", - r#""@radroots/core-bindings": "workspace:^""#, - )); - let packed = package_json_value(&package_json_with_dependencies( - "trade-bindings", - r#""@radroots/core-bindings": "workspace:^""#, - )); - - let error = check_packed_package_json( - &source, - &packed, - Path::new("packages/trade-bindings/package.json"), - "@radroots/trade-bindings", - "packages/trade-bindings", - ) - .expect_err("workspace dependency rejected"); - - assert!(error.contains("must not use workspace protocol")); - } - - #[test] - fn packed_manifest_rejects_internal_dependency_range_mismatch() { - let source = package_json_value(&package_json_with_dependencies( - "trade-bindings", - r#""@radroots/core-bindings": "workspace:^""#, - )); - let packed = package_json_value(&package_json_with_dependencies( - "trade-bindings", - r#""@radroots/core-bindings": "^0.2.0""#, - )); - - let error = check_packed_package_json( - &source, - &packed, - Path::new("packages/trade-bindings/package.json"), - "@radroots/trade-bindings", - "packages/trade-bindings", - ) - .expect_err("internal dependency mismatch rejected"); - - assert!(error.contains("expected ^0.1.0")); - } - - #[test] - fn npm_pack_payload_rejects_source_and_provenance_internals() { - let required_files = BTreeSet::from(["package.json".to_owned()]); - for forbidden in [ - "src/generated/types.ts", - "contracts/provenance/typescript/core.json", - "dist/sdk-manifest.json", - "dist/index.tsbuildinfo", - "dist/.gitignore", - ".npmignore", - ] { - let payload_files = BTreeSet::from(["package.json".to_owned(), forbidden.to_owned()]); - - let error = validate_npm_pack_payload( - "@radroots/example", - &payload_files, - &required_files, - None, - ) - .expect_err("forbidden payload path should fail"); - assert!(error.contains("includes forbidden")); - assert!(error.contains(forbidden)); - } - } - - #[test] - fn npm_pack_payload_requires_exact_wasm_file() { - let required_files = BTreeSet::from([ - "package.json".to_owned(), - "dist/example.js".to_owned(), - "dist/example_bg.wasm".to_owned(), - ]); - let missing_wasm = - BTreeSet::from(["package.json".to_owned(), "dist/example.js".to_owned()]); - let error = validate_npm_pack_payload( - "@radroots/example-wasm", - &missing_wasm, - &required_files, - Some("dist/example_bg.wasm"), - ) - .expect_err("missing wasm should fail"); - assert!(error.contains("missing required files")); - - let extra_wasm = BTreeSet::from([ - "package.json".to_owned(), - "dist/example.js".to_owned(), - "dist/example_bg.wasm".to_owned(), - "dist/extra_bg.wasm".to_owned(), - ]); - let error = validate_npm_pack_payload( - "@radroots/example-wasm", - &extra_wasm, - &required_files, - Some("dist/example_bg.wasm"), - ) - .expect_err("extra wasm should fail"); - assert!(error.contains("must include exactly dist/example_bg.wasm")); - } - - #[test] - fn wasm_package_surface_requires_exported_dist_files() { - let root = test_root("wasm_surface"); - let package_dir = root.join("packages").join("example-wasm"); - fs::create_dir_all(package_dir.join("dist")).expect("create dist"); - fs::write( - package_dir.join("package.json"), - package_json("example-wasm").replace("./dist/index", "./dist/example"), - ) - .expect("write package json"); - write_distribution_metadata( - &root, - &package_dir, - "@radroots/example-wasm", - "Example package", - ); - fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); - let spec = WasmPackageSpec { - key: "example", - crate_name: "radroots_example_wasm", - crate_dir: "crates/example_wasm", - package_name: "@radroots/example-wasm", - package_dir: "packages/example-wasm", - out_name: "example", - out_dir: "../../packages/example-wasm/dist", - }; - - let missing = - check_wasm_package_surface(&root, spec).expect_err("missing d.ts should fail"); - assert!(missing.contains("example.d.ts")); - fs::write( - package_dir.join("dist").join("example.d.ts"), - dto_declaration("example.d.ts"), - ) - .expect("write d.ts"); - fs::write( - package_dir.join("dist").join("example_bg.wasm.d.ts"), - dto_declaration("example_bg.wasm.d.ts"), - ) - .expect("write d.ts"); - fs::write(package_dir.join("dist").join("example_bg.wasm"), b"\0asm").expect("write wasm"); - let error = check_wasm_package_surface(&root, spec) - .expect_err("unknown declaration inventory rejected"); - assert!(error.contains("missing wasm declaration inventory")); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn wasm_package_surface_rejects_generated_package_manifest() { - let root = test_root("wasm_dist_package_manifest"); - let package_dir = root.join("packages").join("example-wasm"); - fs::create_dir_all(package_dir.join("dist")).expect("create dist"); - fs::write( - package_dir.join("package.json"), - package_json("example-wasm").replace("./dist/index", "./dist/example"), - ) - .expect("write package json"); - write_distribution_metadata( - &root, - &package_dir, - "@radroots/example-wasm", - "Example package", - ); - fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); - fs::write( - package_dir.join("dist").join("example.d.ts"), - dto_declaration("example.d.ts"), - ) - .expect("write d.ts"); - fs::write( - package_dir.join("dist").join("example_bg.wasm.d.ts"), - dto_declaration("example_bg.wasm.d.ts"), - ) - .expect("write wasm d.ts"); - fs::write(package_dir.join("dist").join("package.json"), "{}\n") - .expect("write forbidden manifest"); - let spec = WasmPackageSpec { - key: "example", - crate_name: "radroots_example_wasm", - crate_dir: "crates/example_wasm", - package_name: "@radroots/example-wasm", - package_dir: "packages/example-wasm", - out_name: "example", - out_dir: "../../packages/example-wasm/dist", - }; - - let error = - check_wasm_package_surface(&root, spec).expect_err("dist package manifest rejected"); - assert!(error.contains("generated package manifest is forbidden")); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn wasm_package_surface_rejects_dist_ignore_files() { - let root = test_root("wasm_dist_ignore"); - let package_dir = root.join("packages").join("example-wasm"); - fs::create_dir_all(package_dir.join("dist")).expect("create dist"); - fs::write( - package_dir.join("package.json"), - package_json("example-wasm").replace("./dist/index", "./dist/example"), - ) - .expect("write package json"); - write_distribution_metadata( - &root, - &package_dir, - "@radroots/example-wasm", - "Example package", - ); - fs::write(package_dir.join("dist").join(".gitignore"), "*\n").expect("write ignore"); - let spec = WasmPackageSpec { - key: "example", - crate_name: "radroots_example_wasm", - crate_dir: "crates/example_wasm", - package_name: "@radroots/example-wasm", - package_dir: "packages/example-wasm", - out_name: "example", - out_dir: "../../packages/example-wasm/dist", - }; - - let error = check_wasm_package_surface(&root, spec).expect_err("dist ignore file rejected"); - assert!(error.contains("would hide package payload")); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn wasm_package_surface_requires_runtime_wasm_artifact() { - let root = test_root("wasm_runtime_artifact"); - let package_dir = root.join("packages").join("example-wasm"); - fs::create_dir_all(package_dir.join("dist")).expect("create dist"); - fs::write( - package_dir.join("package.json"), - package_json("example-wasm").replace("./dist/index", "./dist/example"), - ) - .expect("write package json"); - write_distribution_metadata( - &root, - &package_dir, - "@radroots/example-wasm", - "Example package", - ); - fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); - fs::write( - package_dir.join("dist").join("example.d.ts"), - dto_declaration("example.d.ts"), - ) - .expect("write d.ts"); - fs::write( - package_dir.join("dist").join("example_bg.wasm.d.ts"), - dto_declaration("example_bg.wasm.d.ts"), - ) - .expect("write wasm d.ts"); - let spec = WasmPackageSpec { - key: "example", - crate_name: "radroots_example_wasm", - crate_dir: "crates/example_wasm", - package_name: "@radroots/example-wasm", - package_dir: "packages/example-wasm", - out_name: "example", - out_dir: "../../packages/example-wasm/dist", - }; - - let error = check_wasm_package_surface(&root, spec).expect_err("missing wasm rejected"); - assert!(error.contains("missing wasm package runtime artifact")); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn wasm_package_surface_rejects_subpath_exports() { - let root = test_root("wasm_subpath_exports"); - let package_dir = root.join("packages").join("example-wasm"); - fs::create_dir_all(package_dir.join("dist")).expect("create dist"); - fs::write( - package_dir.join("package.json"), - package_json("example-wasm") - .replace("./dist/index", "./dist/example") - .replace( - r#""exports": { - ".": { - "types": "./dist/example.d.ts", - "import": "./dist/example.js", - "default": "./dist/example.js" - } - }"#, - r#""exports": { - ".": "./dist/example.js", - "./extra": "./dist/extra.js" - }"#, - ), - ) - .expect("write package json"); - write_distribution_metadata( - &root, - &package_dir, - "@radroots/example-wasm", - "Example package", - ); - fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); - fs::write( - package_dir.join("dist").join("example.d.ts"), - dto_declaration("example.d.ts"), - ) - .expect("write d.ts"); - fs::write( - package_dir.join("dist").join("example_bg.wasm.d.ts"), - dto_declaration("example_bg.wasm.d.ts"), - ) - .expect("write wasm d.ts"); - let spec = WasmPackageSpec { - key: "example", - crate_name: "radroots_example_wasm", - crate_dir: "crates/example_wasm", - package_name: "@radroots/example-wasm", - package_dir: "packages/example-wasm", - out_name: "example", - out_dir: "../../packages/example-wasm/dist", - }; - - let error = check_wasm_package_surface(&root, spec).expect_err("subpath export rejected"); - assert!(error.contains("only supports root exports")); - - let _ = fs::remove_dir_all(root); - } - - fn test_root(name: &str) -> PathBuf { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time after epoch") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "radroots_sdk_xtask_check_{name}_{}_{}", - std::process::id(), - stamp - )); - let _ = fs::remove_dir_all(&root); - root - } - - fn dto_declaration(name: &str) -> String { - format!( - "// @generated by cargo xtask generate wasm via dto_bindgen\n// Do not edit by hand.\nexport type Generated = \"{name}\";\n" - ) - } - - fn write_distribution_metadata( - root: &Path, - package_dir: &Path, - package_name: &str, - description: &str, - ) { - fs::write(root.join("LICENSE-MIT"), "MIT license\n").expect("write MIT license"); - fs::write(root.join("LICENSE-APACHE"), "Apache license\n").expect("write Apache license"); - fs::write(package_dir.join("LICENSE-MIT"), "MIT license\n") - .expect("write package MIT license"); - fs::write(package_dir.join("LICENSE-APACHE"), "Apache license\n") - .expect("write package Apache license"); - fs::write( - package_dir.join("README.md"), - package_readme(package_name, description), - ) - .expect("write package README"); - } - - fn package_json(name: &str) -> String { - format!( - r#"{{ - "name": "@radroots/{name}", - "version": "0.1.0", - "description": "Example package", - "license": "MIT OR Apache-2.0", - "homepage": "https://radroots.org", - "repository": {{ - "type": "git", - "url": "git+https://github.com/radrootslabs/sdk.git", - "directory": "packages/{name}" - }}, - "publishConfig": {{ - "access": "public" - }}, - "type": "module", - "sideEffects": false, - "files": ["dist", "README.md", "LICENSE-MIT", "LICENSE-APACHE"], - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": {{ - ".": {{ - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - }} - }} -}}"# - ) - } - - fn package_json_with_dependencies(name: &str, dependencies: &str) -> String { - let raw = package_json(name); - let body = raw.strip_suffix('}').expect("root package JSON object"); - format!( - r#"{body}, - "dependencies": {{ - {dependencies} - }} -}}"# - ) - } - - fn package_json_value(raw: &str) -> serde_json::Value { - serde_json::from_str(raw).expect("package json parses") - } - - fn manifest_value(raw: &str) -> toml::Value { - raw.parse::<toml::Value>().expect("manifest parses") - } -} diff --git a/tools/xtask/src/contracts.rs b/tools/xtask/src/contracts.rs @@ -1,694 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - path::{Path, PathBuf}, -}; - -use serde::Deserialize; - -use crate::package_matrix::{PackageSpec, WasmPackageSpec, package_specs, wasm_package_specs}; - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ExportContract { - language: LanguageContract, - packages: BTreeMap<String, String>, - artifacts: Option<ExportArtifacts>, - runtime: RuntimeContract, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PackageContract { - language: LanguageContract, - sdk: SdkPackageContract, - rollout: RolloutContract, - operations: BTreeMap<String, String>, - shared_types: BTreeMap<String, String>, - artifacts: Option<SdkArtifacts>, - npm_packages: Option<BTreeMap<String, NpmPackageContract>>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct LanguageContract { - id: String, - repository: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct RuntimeContract { - networking: String, - signing: String, - deterministic_codec: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ExportArtifacts { - models_dir: String, - constants_dir: String, - wasm_dist_dir: Option<String>, - manifest_file: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct SdkPackageContract { - package: Option<String>, - package_family: Option<String>, - module_format: Option<String>, - deterministic_codec: String, - signing: String, - networking: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct NpmPackageContract { - kind: String, - crate_name: String, - crate_dir: String, - package: String, - package_dir: String, - out_name: Option<String>, - out_dir: Option<String>, -} - -#[derive(Debug)] -struct ExpectedNpmPackage { - kind: &'static str, - crate_name: &'static str, - crate_dir: &'static str, - package: &'static str, - package_dir: &'static str, - out_name: Option<&'static str>, - out_dir: Option<&'static str>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct RolloutContract { - stage: String, - order: u32, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct SdkArtifacts { - models_dir: String, - runtime_dir: String, - wasm_dist_dir: String, - manifest_file: String, -} - -pub fn validate_sdk_contracts(root: &Path) -> Result<(), String> { - radroots_protocol::runtime::v1::validate_catalog(radroots_protocol::runtime::v1::CATALOG) - .map_err(|error| error.to_string())?; - let exports = load_contract_dir::<ExportContract>(&root.join("contracts").join("exports"))?; - let packages = load_contract_dir::<PackageContract>(&root.join("contracts").join("packages"))?; - if exports.is_empty() { - return Err("contracts/exports must define at least one language".to_owned()); - } - if packages.is_empty() { - return Err("contracts/packages must define at least one language".to_owned()); - } - - let mut export_packages = BTreeMap::new(); - let mut export_languages = BTreeSet::new(); - for export in &exports { - validate_language(&export.language, "exports")?; - validate_non_empty_map(&export.packages, "exports packages")?; - validate_runtime( - &export.runtime.networking, - &export.runtime.signing, - &export.runtime.deterministic_codec, - &format!("exports {}", export.language.id), - )?; - let artifacts = export - .artifacts - .as_ref() - .ok_or_else(|| format!("exports {} artifacts are required", export.language.id))?; - validate_non_empty(&artifacts.models_dir, "exports artifacts.models_dir")?; - validate_non_empty(&artifacts.constants_dir, "exports artifacts.constants_dir")?; - validate_non_empty(&artifacts.manifest_file, "exports artifacts.manifest_file")?; - if export.language.id == "ts" { - validate_non_empty( - artifacts.wasm_dist_dir.as_deref().unwrap_or(""), - "exports ts artifacts.wasm_dist_dir", - )?; - } - if !export_languages.insert(export.language.id.clone()) { - return Err(format!("duplicate exports language {}", export.language.id)); - } - let packages = export - .packages - .values() - .cloned() - .collect::<BTreeSet<String>>(); - if export.language.id != "ts" && packages.len() != 1 { - return Err(format!( - "exports {} must resolve to one curated package", - export.language.id - )); - } - export_packages.insert(export.language.id.clone(), packages); - } - - let mut package_languages = BTreeSet::new(); - let mut operation_keys: Option<BTreeSet<String>> = None; - let mut shared_type_keys: Option<BTreeSet<String>> = None; - let mut rollout_orders = BTreeMap::new(); - for package in &packages { - validate_language(&package.language, "packages")?; - if let Some(package_name) = package.sdk.package.as_deref() { - validate_non_empty(package_name, "packages sdk.package")?; - } - if let Some(package_family) = package.sdk.package_family.as_deref() { - validate_non_empty(package_family, "packages sdk.package_family")?; - } - validate_runtime( - &package.sdk.networking, - &package.sdk.signing, - &package.sdk.deterministic_codec, - &format!("packages {}", package.language.id), - )?; - if let Some(module_format) = package.sdk.module_format.as_deref() { - validate_non_empty(module_format, "packages sdk.module_format")?; - } - if package.sdk.package.is_none() && package.sdk.package_family.is_none() { - return Err(format!( - "packages {} sdk.package or sdk.package_family is required", - package.language.id - )); - } - validate_rollout(&package.language.id, &package.rollout)?; - validate_non_empty_map(&package.operations, "packages operations")?; - validate_non_empty_map(&package.shared_types, "packages shared_types")?; - if package.language.id == "ts" { - let artifacts = package - .artifacts - .as_ref() - .ok_or_else(|| "packages ts artifacts are required".to_owned())?; - validate_non_empty(&artifacts.models_dir, "packages ts artifacts.models_dir")?; - validate_non_empty(&artifacts.runtime_dir, "packages ts artifacts.runtime_dir")?; - validate_non_empty( - &artifacts.wasm_dist_dir, - "packages ts artifacts.wasm_dist_dir", - )?; - validate_non_empty( - &artifacts.manifest_file, - "packages ts artifacts.manifest_file", - )?; - } - if !package_languages.insert(package.language.id.clone()) { - return Err(format!( - "duplicate packages language {}", - package.language.id - )); - } - let Some(packages_for_language) = export_packages.get(&package.language.id) else { - return Err(format!( - "packages {} is missing a matching export contract", - package.language.id - )); - }; - if package.language.id == "ts" { - let npm_packages = package - .npm_packages - .as_ref() - .ok_or_else(|| "packages ts npm_packages are required".to_owned())?; - validate_ts_npm_packages(package, packages_for_language, npm_packages)?; - } else { - if package.npm_packages.is_some() { - return Err(format!( - "packages {} npm_packages is only supported for ts", - package.language.id - )); - } - let sdk_package = package.sdk.package.as_ref().ok_or_else(|| { - format!("packages {} sdk.package is required", package.language.id) - })?; - let expected = [sdk_package.clone()].into_iter().collect::<BTreeSet<_>>(); - if packages_for_language != &expected { - return Err(format!( - "exports {} must resolve to package {}", - package.language.id, sdk_package - )); - } - } - let current_operations = package.operations.keys().cloned().collect::<BTreeSet<_>>(); - match &operation_keys { - Some(expected) if expected != &current_operations => { - return Err(format!( - "packages {} operations must match the shared operation set", - package.language.id - )); - } - None => operation_keys = Some(current_operations), - _ => {} - } - let current_shared_types = package - .shared_types - .keys() - .cloned() - .collect::<BTreeSet<_>>(); - match &shared_type_keys { - Some(expected) if expected != &current_shared_types => { - return Err(format!( - "packages {} shared_types must match the shared type set", - package.language.id - )); - } - None => shared_type_keys = Some(current_shared_types), - _ => {} - } - rollout_orders.insert(package.language.id.clone(), package.rollout.order); - } - - if export_languages != package_languages { - return Err("contracts/exports and contracts/packages languages must match".to_owned()); - } - if rollout_orders.get("ts") != Some(&1) { - return Err("packages ts rollout.order must be 1".to_owned()); - } - Ok(()) -} - -fn validate_ts_npm_packages( - package: &PackageContract, - export_packages: &BTreeSet<String>, - npm_packages: &BTreeMap<String, NpmPackageContract>, -) -> Result<(), String> { - if package.sdk.package.is_some() { - return Err( - "packages ts sdk.package must not be set for multi-package npm output".to_owned(), - ); - } - let package_family = package - .sdk - .package_family - .as_deref() - .ok_or_else(|| "packages ts sdk.package_family is required".to_owned())?; - if package_family != "@radroots" { - return Err("packages ts sdk.package_family must be @radroots".to_owned()); - } - let expected = expected_ts_npm_packages(); - let expected_keys = expected.keys().cloned().collect::<BTreeSet<_>>(); - let actual_keys = npm_packages.keys().cloned().collect::<BTreeSet<_>>(); - if actual_keys != expected_keys { - let missing = expected_keys - .difference(&actual_keys) - .cloned() - .collect::<Vec<_>>(); - let extra = actual_keys - .difference(&expected_keys) - .cloned() - .collect::<Vec<_>>(); - return Err(format!( - "packages ts npm_packages must match package matrix: missing {:?}, extra {:?}", - missing, extra - )); - } - for (key, expected_package) in expected { - let actual = npm_packages - .get(&key) - .ok_or_else(|| format!("packages ts npm package {key} is missing"))?; - validate_npm_package(&key, actual, &expected_package)?; - } - let expected_names = npm_packages - .values() - .map(|package| package.package.clone()) - .collect::<BTreeSet<_>>(); - if export_packages != &expected_names { - return Err("exports ts package set must match TypeScript npm package matrix".to_owned()); - } - Ok(()) -} - -fn expected_ts_npm_packages() -> BTreeMap<String, ExpectedNpmPackage> { - let mut expected = BTreeMap::new(); - for spec in package_specs() { - expected.insert(spec.key.to_owned(), expected_from_package_spec(*spec)); - } - for spec in wasm_package_specs() { - expected.insert(spec.key.to_owned(), expected_from_wasm_package_spec(*spec)); - } - expected -} - -fn expected_from_package_spec(spec: PackageSpec) -> ExpectedNpmPackage { - ExpectedNpmPackage { - kind: "bindings", - crate_name: spec.crate_name, - crate_dir: spec.crate_dir, - package: spec.package_name, - package_dir: spec.package_dir, - out_name: None, - out_dir: None, - } -} - -fn expected_from_wasm_package_spec(spec: WasmPackageSpec) -> ExpectedNpmPackage { - ExpectedNpmPackage { - kind: "wasm", - crate_name: spec.crate_name, - crate_dir: spec.crate_dir, - package: spec.package_name, - package_dir: spec.package_dir, - out_name: Some(spec.out_name), - out_dir: Some(spec.out_dir), - } -} - -fn validate_npm_package( - key: &str, - actual: &NpmPackageContract, - expected: &ExpectedNpmPackage, -) -> Result<(), String> { - validate_npm_field(key, "kind", &actual.kind, expected.kind)?; - validate_npm_field(key, "crate_name", &actual.crate_name, expected.crate_name)?; - validate_npm_field(key, "crate_dir", &actual.crate_dir, expected.crate_dir)?; - validate_npm_field(key, "package", &actual.package, expected.package)?; - validate_npm_field( - key, - "package_dir", - &actual.package_dir, - expected.package_dir, - )?; - validate_optional_npm_field( - key, - "out_name", - actual.out_name.as_deref(), - expected.out_name, - )?; - validate_optional_npm_field(key, "out_dir", actual.out_dir.as_deref(), expected.out_dir)?; - Ok(()) -} - -fn validate_npm_field(key: &str, field: &str, actual: &str, expected: &str) -> Result<(), String> { - validate_non_empty(actual, &format!("packages ts npm package {key} {field}"))?; - if actual != expected { - return Err(format!( - "packages ts npm package {key} {field} must be {expected}" - )); - } - Ok(()) -} - -fn validate_optional_npm_field( - key: &str, - field: &str, - actual: Option<&str>, - expected: Option<&str>, -) -> Result<(), String> { - match (actual, expected) { - (Some(actual), Some(expected)) => validate_npm_field(key, field, actual, expected), - (None, None) => Ok(()), - (Some(_), None) => Err(format!( - "packages ts npm package {key} {field} must not be set" - )), - (None, Some(expected)) => Err(format!( - "packages ts npm package {key} {field} must be {expected}" - )), - } -} - -fn load_contract_dir<T>(dir: &Path) -> Result<Vec<T>, String> -where - T: for<'de> Deserialize<'de>, -{ - let read_dir = - fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?; - let mut entries = read_dir - .collect::<Result<Vec<_>, _>>() - .map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?; - entries.sort_by_key(|entry| entry.file_name()); - let mut contracts = Vec::new(); - for entry in entries { - let path = entry.path(); - if path.extension().and_then(|extension| extension.to_str()) != Some("toml") { - continue; - } - contracts.push(parse_toml(&path)?); - } - Ok(contracts) -} - -fn parse_toml<T>(path: &PathBuf) -> Result<T, String> -where - T: for<'de> Deserialize<'de>, -{ - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) -} - -fn validate_language(language: &LanguageContract, family: &str) -> Result<(), String> { - validate_non_empty(&language.id, &format!("{family} language.id"))?; - validate_non_empty( - &language.repository, - &format!("{family} language.repository"), - ) -} - -fn validate_runtime( - networking: &str, - signing: &str, - deterministic_codec: &str, - family: &str, -) -> Result<(), String> { - validate_non_empty(networking, &format!("{family} networking"))?; - validate_non_empty(signing, &format!("{family} signing"))?; - validate_non_empty( - deterministic_codec, - &format!("{family} deterministic_codec"), - ) -} - -fn validate_rollout(language: &str, rollout: &RolloutContract) -> Result<(), String> { - validate_non_empty(&rollout.stage, "packages rollout.stage")?; - if !matches!(rollout.stage.as_str(), "active" | "next" | "deferred") { - return Err(format!("packages {language} rollout.stage is invalid")); - } - if rollout.order == 0 { - return Err(format!( - "packages {language} rollout.order must be greater than zero" - )); - } - Ok(()) -} - -fn validate_non_empty(value: &str, field: &str) -> Result<(), String> { - if value.trim().is_empty() || value.trim() != value { - return Err(format!("{field} must be non-empty")); - } - Ok(()) -} - -fn validate_non_empty_map(map: &BTreeMap<String, String>, field: &str) -> Result<(), String> { - if map.is_empty() { - return Err(format!("{field} must not be empty")); - } - for (key, value) in map { - validate_non_empty(key, field)?; - validate_non_empty(value, field)?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::{ - fs, - time::{SystemTime, UNIX_EPOCH}, - }; - - use super::validate_sdk_contracts; - - #[test] - fn current_sdk_contracts_validate() { - let root = crate::fs::workspace_root().expect("workspace root"); - validate_sdk_contracts(&root).expect("sdk contracts validate"); - } - - #[test] - fn rejects_mismatched_language_sets() { - let root = test_root("language_mismatch"); - write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS); - let error = validate_sdk_contracts(&root).expect_err("missing packages should fail"); - assert!(error.contains("contracts/packages")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn rejects_package_export_mismatch() { - let root = test_root("package_mismatch"); - write_contract( - &root, - "contracts/exports/ts.toml", - EXPORT_TS - .replace("@radroots/core-bindings", "@radroots/other") - .as_str(), - ); - write_contract(&root, "contracts/packages/ts.toml", PACKAGE_TS); - let error = validate_sdk_contracts(&root).expect_err("mismatch should fail"); - assert!(error.contains("exports ts package set")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn rejects_ts_package_matrix_drift() { - let root = test_root("ts_matrix_drift"); - write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS); - write_contract( - &root, - "contracts/packages/ts.toml", - PACKAGE_TS - .replace( - "package = \"@radroots/core-bindings\"", - "package = \"@radroots/other\"", - ) - .as_str(), - ); - let error = validate_sdk_contracts(&root).expect_err("mismatch should fail"); - assert!(error.contains("packages ts npm package core package")); - let _ = fs::remove_dir_all(root); - } - - fn test_root(name: &str) -> std::path::PathBuf { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time") - .as_nanos(); - std::env::temp_dir().join(format!("radroots_sdk_contracts_{name}_{stamp}")) - } - - fn write_contract(root: &std::path::Path, relative: &str, contents: &str) { - let path = root.join(relative); - fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); - fs::write(path, contents).expect("write contract"); - } - - const EXPORT_TS: &str = r#"[language] -id = "ts" -repository = "sdk-typescript" - -[packages] -"radroots_core" = "@radroots/core-bindings" -"radroots_event" = "@radroots/event-bindings" -"radroots_identity" = "@radroots/identity-bindings" -"radroots_replica_schema" = "@radroots/replica-schema-bindings" -"radroots_trade" = "@radroots/trade-bindings" -"radroots_event_codec_wasm" = "@radroots/event-codec-wasm" -"radroots_replica_store_wasm" = "@radroots/replica-store-wasm" -"radroots_replica_sync_wasm" = "@radroots/replica-sync-wasm" - -[artifacts] -models_dir = "src/generated" -constants_dir = "src/generated" -wasm_dist_dir = "dist" -manifest_file = "export-manifest.json" - -[runtime] -networking = "native" -signing = "native" -deterministic_codec = "wasm" -"#; - - const PACKAGE_TS: &str = r#"[language] -id = "ts" -repository = "sdk-typescript" - -[sdk] -package_family = "@radroots" -module_format = "esm" -deterministic_codec = "wasm" -signing = "native" -networking = "native" - -[npm_packages.core] -kind = "bindings" -crate_name = "radroots_core_bindings" -crate_dir = "crates/core_bindings" -package = "@radroots/core-bindings" -package_dir = "packages/core-bindings" - -[npm_packages.event] -kind = "bindings" -crate_name = "radroots_event_bindings" -crate_dir = "crates/event_bindings" -package = "@radroots/event-bindings" -package_dir = "packages/event-bindings" - -[npm_packages.identity] -kind = "bindings" -crate_name = "radroots_identity_bindings" -crate_dir = "crates/identity_bindings" -package = "@radroots/identity-bindings" -package_dir = "packages/identity-bindings" - -[npm_packages.replica_schema] -kind = "bindings" -crate_name = "radroots_replica_schema_bindings" -crate_dir = "crates/replica_schema_bindings" -package = "@radroots/replica-schema-bindings" -package_dir = "packages/replica-schema-bindings" - -[npm_packages.trade] -kind = "bindings" -crate_name = "radroots_trade_bindings" -crate_dir = "crates/trade_bindings" -package = "@radroots/trade-bindings" -package_dir = "packages/trade-bindings" - -[npm_packages.event_codec] -kind = "wasm" -crate_name = "radroots_event_codec_wasm" -crate_dir = "crates/event_codec_wasm" -package = "@radroots/event-codec-wasm" -package_dir = "packages/event-codec-wasm" -out_name = "radroots_event_codec_wasm" -out_dir = "../../packages/event-codec-wasm/dist" - -[npm_packages.replica_store] -kind = "wasm" -crate_name = "radroots_replica_store_wasm" -crate_dir = "crates/replica_store_wasm" -package = "@radroots/replica-store-wasm" -package_dir = "packages/replica-store-wasm" -out_name = "radroots_replica_store_wasm" -out_dir = "../../packages/replica-store-wasm/dist" - -[npm_packages.replica_sync] -kind = "wasm" -crate_name = "radroots_replica_sync_wasm" -crate_dir = "crates/replica_sync_wasm" -package = "@radroots/replica-sync-wasm" -package_dir = "packages/replica-sync-wasm" -out_name = "radroots_replica_sync_wasm" -out_dir = "../../packages/replica-sync-wasm/dist" - -[rollout] -stage = "active" -order = 1 - -[operations] -"farm.build_draft" = "farm.buildDraft" - -[shared_types] -"RadrootsNip01EventWireParts" = "RadrootsNip01EventWireParts" -"RadrootsNip01EventWireDto" = "RadrootsNip01EventWireDto" -"RadrootsEventDraft" = "RadrootsEventDraft" -"RadrootsSignedEventDto" = "RadrootsSignedEventDto" -"RadrootsVerifiedSignedEventDto" = "RadrootsVerifiedSignedEventDto" -"RadrootsEventEnvelopeDto" = "RadrootsEventEnvelopeDto" - -[artifacts] -models_dir = "src/generated" -runtime_dir = "src/runtime" -wasm_dist_dir = "dist" -manifest_file = "export-manifest.json" -"#; -} diff --git a/tools/xtask/src/coverage.rs b/tools/xtask/src/coverage.rs @@ -1,156 +0,0 @@ -use std::{fs, path::Path, process::Command}; - -use crate::{ - check, - coverage_policy::{CoverageContract, evaluate_report, validate_contract}, - fs::workspace_root, - generate, wasm, -}; - -pub fn run(args: &[String]) -> Result<(), String> { - match args { - [command] if command == "run" => run_coverage(), - [command] if command == "check" => check_coverage(), - [] => Err(usage()), - _ => Err(usage()), - } -} - -fn usage() -> String { - "usage: cargo xtask coverage run | cargo xtask coverage check".to_owned() -} - -fn run_coverage() -> Result<(), String> { - let root = workspace_root()?; - let contract = load_contract(&root)?; - validate_contract(&contract)?; - preflight(&contract)?; - generate::generate_ts()?; - wasm::generate(&[])?; - check::check()?; - clean_report_output(&root, &contract)?; - run_llvm_cov(&root, &contract)?; - evaluate_report(&root, &root.join(&contract.report.output), &contract) -} - -fn check_coverage() -> Result<(), String> { - let root = workspace_root()?; - let contract = load_contract(&root)?; - validate_contract(&contract)?; - evaluate_report(&root, &root.join(&contract.report.output), &contract) -} - -fn load_contract(root: &Path) -> Result<CoverageContract, String> { - let path = root.join("contracts").join("coverage.toml"); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) -} - -fn preflight(contract: &CoverageContract) -> Result<(), String> { - require_command("rustup", &["--version"], "install rustup")?; - require_command( - "rustup", - &[ - "run", - &contract.toolchain.coverage_rust, - "cargo", - "llvm-cov", - "--version", - ], - "install cargo-llvm-cov for the SDK Rust toolchain", - )?; - let component_output = output( - "rustup", - &[ - "component", - "list", - "--toolchain", - &contract.toolchain.coverage_rust, - ], - ) - .map_err(|error| format!("failed to inspect Rust components: {error}"))?; - if !component_output - .lines() - .any(|line| line.starts_with("llvm-tools") && line.contains("(installed)")) - { - return Err(format!( - "missing llvm-tools-preview for Rust toolchain {}; run `rustup component add llvm-tools-preview --toolchain {}`", - contract.toolchain.coverage_rust, contract.toolchain.coverage_rust - )); - } - let target_output = output( - "rustup", - &["target", "list", "--toolchain", &contract.toolchain.rust], - ) - .map_err(|error| format!("failed to inspect Rust targets: {error}"))?; - let expected_target = format!("{} (installed)", contract.toolchain.wasm_target); - if !target_output.lines().any(|line| line == expected_target) { - return Err(format!( - "missing Rust target {}; run `rustup target add {} --toolchain {}`", - contract.toolchain.wasm_target, contract.toolchain.wasm_target, contract.toolchain.rust - )); - } - require_command("wasm-pack", &["--version"], "install wasm-pack")?; - require_command("pnpm", &["--version"], "install pnpm")?; - Ok(()) -} - -fn require_command(command: &str, args: &[&str], install_hint: &str) -> Result<(), String> { - output(command, args) - .map(|_| ()) - .map_err(|error| format!("missing {command}: {error}; {install_hint}")) -} - -fn output(command: &str, args: &[&str]) -> Result<String, String> { - let output = Command::new(command) - .args(args) - .output() - .map_err(|error| error.to_string())?; - if !output.status.success() { - return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); - } - Ok(String::from_utf8_lossy(&output.stdout).to_string()) -} - -fn clean_report_output(root: &Path, contract: &CoverageContract) -> Result<(), String> { - let output_path = root.join(&contract.report.output); - if let Some(parent) = output_path.parent() - && parent.exists() - { - fs::remove_dir_all(parent) - .map_err(|error| format!("failed to remove {}: {error}", parent.display()))?; - } - Ok(()) -} - -fn run_llvm_cov(root: &Path, contract: &CoverageContract) -> Result<(), String> { - let output_path = root.join(&contract.report.output); - if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; - } - let status = Command::new("rustup") - .current_dir(root) - .args([ - "run", - &contract.toolchain.coverage_rust, - "cargo", - "llvm-cov", - "--workspace", - "--all-features", - "--json", - "--branch", - "--output-path", - &contract.report.output, - "--ignore-filename-regex", - &contract.report.ignore_filename_regex, - "--no-fail-fast", - ]) - .status() - .map_err(|error| format!("failed to run cargo llvm-cov: {error}"))?; - if !status.success() { - return Err(format!("cargo llvm-cov failed with status {status}")); - } - Ok(()) -} diff --git a/tools/xtask/src/coverage_policy.rs b/tools/xtask/src/coverage_policy.rs @@ -1,803 +0,0 @@ -use std::{ - collections::BTreeMap, - fs, - path::{Component, Path, PathBuf}, -}; - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct CoverageContract { - policy: CoveragePolicy, - pub(crate) toolchain: CoverageToolchain, - pub(crate) report: CoverageReport, - generated: GeneratedCoveragePolicy, - scopes: BTreeMap<String, CoverageScope>, - exclusions: BTreeMap<String, CoverageExclusion>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CoveragePolicy { - enforce: bool, - require_regions: bool, - require_functions: bool, - require_executable_lines: bool, - require_branches: bool, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct CoverageToolchain { - pub(crate) rust: String, - pub(crate) coverage_rust: String, - pub(crate) wasm_target: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct CoverageReport { - pub(crate) output: String, - pub(crate) ignore_filename_regex: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct GeneratedCoveragePolicy { - typescript: String, - binding_crates: String, - wasm_glue: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CoverageScope { - paths: Vec<String>, - threshold: f64, - #[serde(default = "default_true")] - branches_applicable: bool, - branch_reason: Option<String>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CoverageExclusion { - paths: Vec<String>, - reason: String, -} - -const fn default_true() -> bool { - true -} - -#[derive(Debug, Deserialize)] -struct LlvmCovReport { - data: Vec<LlvmCovData>, -} - -#[derive(Debug, Deserialize)] -struct LlvmCovData { - files: Vec<LlvmCovFile>, - #[serde(default)] - functions: Vec<LlvmCovFunction>, - totals: LlvmCovSummary, -} - -#[derive(Debug, Deserialize)] -struct LlvmCovFunction { - count: u64, - filenames: Vec<String>, - regions: Vec<Vec<u64>>, - #[serde(default)] - branches: Vec<Vec<u64>>, -} - -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -struct FunctionKey { - filenames: Vec<String>, - definition: RegionKey, -} - -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -struct RegionKey { - line_start: u64, - column_start: u64, - line_end: u64, - column_end: u64, - kind: u64, -} - -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -struct BranchKey { - line_start: u64, - column_start: u64, - line_end: u64, - column_end: u64, - kind: u64, -} - -#[derive(Debug)] -struct CoverageSource { - raw: String, - cfg_test_lines: Vec<bool>, - coverage_off_lines: Vec<bool>, -} - -#[derive(Debug, Deserialize)] -struct LlvmCovFile { - filename: String, - summary: LlvmCovSummary, -} - -#[derive(Debug, Deserialize)] -struct LlvmCovSummary { - lines: LlvmCovMetric, - functions: LlvmCovMetric, - regions: LlvmCovMetric, - branches: LlvmCovMetric, -} - -#[derive(Debug, Deserialize)] -struct LlvmCovMetric { - count: u64, - covered: u64, - percent: f64, -} - -#[derive(Debug, Default)] -struct MetricAccumulator { - count: u64, - covered: u64, -} - -impl MetricAccumulator { - fn add(&mut self, metric: &LlvmCovMetric) { - self.count += metric.count; - self.covered += metric.covered; - } - - fn metric(&self) -> LlvmCovMetric { - LlvmCovMetric { - count: self.count, - covered: self.covered, - percent: metric_percent(self.count, self.covered), - } - } -} - -#[derive(Debug, Default)] -struct SummaryAccumulator { - lines: MetricAccumulator, - functions: MetricAccumulator, - regions: MetricAccumulator, - branches: MetricAccumulator, - matched_files: usize, -} - -impl SummaryAccumulator { - fn add(&mut self, summary: &LlvmCovSummary) { - self.lines.add(&summary.lines); - self.functions.add(&summary.functions); - self.regions.add(&summary.regions); - self.branches.add(&summary.branches); - self.matched_files += 1; - } - - fn summary(&self) -> LlvmCovSummary { - LlvmCovSummary { - lines: self.lines.metric(), - functions: self.functions.metric(), - regions: self.regions.metric(), - branches: self.branches.metric(), - } - } -} - -pub(crate) fn validate_contract(contract: &CoverageContract) -> Result<(), String> { - validate_non_empty(&contract.toolchain.rust, "toolchain.rust")?; - validate_non_empty(&contract.toolchain.coverage_rust, "toolchain.coverage_rust")?; - validate_non_empty(&contract.toolchain.wasm_target, "toolchain.wasm_target")?; - validate_non_empty(&contract.report.output, "report.output")?; - validate_non_empty( - &contract.report.ignore_filename_regex, - "report.ignore_filename_regex", - )?; - validate_non_empty(&contract.generated.typescript, "generated.typescript")?; - validate_non_empty( - &contract.generated.binding_crates, - "generated.binding_crates", - )?; - validate_non_empty(&contract.generated.wasm_glue, "generated.wasm_glue")?; - if contract.scopes.is_empty() { - return Err("contracts/coverage.toml scopes must not be empty".to_owned()); - } - for (name, scope) in &contract.scopes { - validate_non_empty(name, "scope name")?; - validate_threshold(scope.threshold, &format!("scopes.{name}.threshold"))?; - if scope.paths.is_empty() { - return Err(format!("scopes.{name}.paths must not be empty")); - } - for path in &scope.paths { - validate_non_empty(path, &format!("scopes.{name}.paths entry"))?; - } - if scope.branches_applicable { - if scope.branch_reason.is_some() { - return Err(format!( - "scopes.{name}.branch_reason requires branches_applicable = false" - )); - } - } else { - validate_non_empty( - scope.branch_reason.as_deref().unwrap_or_default(), - &format!("scopes.{name}.branch_reason"), - )?; - } - } - if contract.exclusions.is_empty() { - return Err("contracts/coverage.toml exclusions must not be empty".to_owned()); - } - for (name, exclusion) in &contract.exclusions { - validate_non_empty(name, "exclusion name")?; - validate_non_empty(&exclusion.reason, &format!("exclusions.{name}.reason"))?; - if exclusion.paths.is_empty() { - return Err(format!("exclusions.{name}.paths must not be empty")); - } - for path in &exclusion.paths { - validate_non_empty(path, &format!("exclusions.{name}.paths entry"))?; - } - } - Ok(()) -} - -fn validate_threshold(threshold: f64, field: &str) -> Result<(), String> { - if (0.0..=100.0).contains(&threshold) { - Ok(()) - } else { - Err(format!( - "contracts/coverage.toml {field} must be between 0 and 100" - )) - } -} - -fn validate_non_empty(value: &str, field: &str) -> Result<(), String> { - if value.trim().is_empty() { - Err(format!("contracts/coverage.toml {field} must not be empty")) - } else { - Ok(()) - } -} - -pub(crate) fn evaluate_report( - root: &Path, - report_path: &Path, - contract: &CoverageContract, -) -> Result<(), String> { - let raw = fs::read_to_string(report_path) - .map_err(|error| format!("failed to read {}: {error}", report_path.display()))?; - let report = serde_json::from_str::<LlvmCovReport>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", report_path.display()))?; - let data = report - .data - .first() - .ok_or_else(|| format!("{} did not include coverage data", report_path.display()))?; - validate_metric( - "total executable lines", - &data.totals.lines, - contract.policy.require_executable_lines, - )?; - validate_metric( - "total functions", - &data.totals.functions, - contract.policy.require_functions, - )?; - validate_metric( - "total regions", - &data.totals.regions, - contract.policy.require_regions, - )?; - validate_metric( - "total branches", - &data.totals.branches, - contract.policy.require_branches, - )?; - let mut failures = Vec::new(); - for (scope_name, scope) in &contract.scopes { - let scope_summary = match scope_summary(root, data, scope) { - Ok(summary) => summary, - Err(error) => { - failures.push(format!("coverage scope {scope_name}: {error}")); - continue; - } - }; - collect_scope_metric_failure( - &mut failures, - scope_name, - "executable lines", - &scope_summary.lines, - scope.threshold, - contract.policy.require_executable_lines, - ); - collect_scope_metric_failure( - &mut failures, - scope_name, - "functions", - &scope_summary.functions, - scope.threshold, - contract.policy.require_functions, - ); - collect_scope_metric_failure( - &mut failures, - scope_name, - "regions", - &scope_summary.regions, - scope.threshold, - contract.policy.require_regions, - ); - if scope.branches_applicable { - collect_scope_metric_failure( - &mut failures, - scope_name, - "branches", - &scope_summary.branches, - scope.threshold, - contract.policy.require_branches, - ); - } else if scope_summary.branches.count != 0 { - failures.push(format!( - "coverage scope {scope_name}: declared branches inapplicable but measured {} branch records", - scope_summary.branches.count - )); - } - } - if !contract.policy.enforce { - println!( - "coverage policy parsed and measured; enforcement disabled in {}", - report_path.display() - ); - return Ok(()); - } - if !failures.is_empty() { - return Err(failures.join("\n")); - } - println!("coverage policy passed using {}", report_path.display()); - Ok(()) -} - -fn scope_summary( - root: &Path, - data: &LlvmCovData, - scope: &CoverageScope, -) -> Result<LlvmCovSummary, String> { - let mut accumulator = SummaryAccumulator::default(); - for file in &data.files { - let filename = report_filename(root, &file.filename); - if scope - .paths - .iter() - .any(|pattern| path_matches(pattern, &filename)) - { - accumulator.add(&file.summary); - } - } - if accumulator.matched_files == 0 { - return Err(format!( - "matched no report files for {}", - scope.paths.join(", ") - )); - } - let mut summary = accumulator.summary(); - if !data.functions.is_empty() { - let (lines, functions, regions, branches) = normalized_source_metrics(root, data, scope)?; - summary.lines = lines; - summary.functions = functions; - summary.regions = regions; - if branches.count > 0 { - summary.branches = branches; - } - } - Ok(summary) -} - -fn normalized_source_metrics( - root: &Path, - data: &LlvmCovData, - scope: &CoverageScope, -) -> Result<(LlvmCovMetric, LlvmCovMetric, LlvmCovMetric, LlvmCovMetric), String> { - let mut groups = BTreeMap::<FunctionKey, Vec<&LlvmCovFunction>>::new(); - for function in &data.functions { - if function.filenames.is_empty() || function.regions.is_empty() { - continue; - } - let Some(definition) = function - .regions - .first() - .and_then(|region| region_key(region)) - else { - continue; - }; - groups - .entry(FunctionKey { - filenames: function.filenames.clone(), - definition, - }) - .or_default() - .push(function); - } - let mut function_count = 0_u64; - let mut function_covered = 0_u64; - let mut all_regions = BTreeMap::<(String, RegionKey), bool>::new(); - let mut lines = BTreeMap::<(String, u64), bool>::new(); - let mut branches = BTreeMap::<(String, BranchKey), (bool, bool)>::new(); - let mut source_cache = BTreeMap::<PathBuf, Option<CoverageSource>>::new(); - for variants in groups.values() { - let primary_definition = variants.iter().find_map(|function| { - let region = function.regions.first()?; - let filename = region_filename(function, region)?; - let relative = report_filename(root, filename); - if scope.paths.iter().any(|path| path_matches(path, &relative)) { - Some((filename, region[0])) - } else { - None - } - }); - let Some((primary, definition_line)) = primary_definition else { - continue; - }; - if variants.iter().all(|function| { - function.regions.first().is_some_and(|region| { - region - .first() - .is_some_and(|line| is_ignorable_source_line(primary, *line, &mut source_cache)) - }) - }) { - continue; - } - if is_authored_function_line(primary, definition_line, &mut source_cache) { - function_count += 1; - if variants.iter().any(|function| function.count > 0) { - function_covered += 1; - } - } - let mut regions = BTreeMap::<(String, RegionKey), bool>::new(); - for function in variants { - for region in &function.regions { - let filename = region_filename(function, region) - .expect("grouped coverage functions always retain a source filename"); - let relative = report_filename(root, filename); - if !scope.paths.iter().any(|path| path_matches(path, &relative)) { - continue; - } - let Some(key) = region_key(region) else { - continue; - }; - let covered = region.get(4).is_some_and(|count| *count > 0); - regions - .entry((filename.to_owned(), key)) - .and_modify(|existing| *existing |= covered) - .or_insert(covered); - } - } - for ((filename, region), covered) in regions { - if is_ignorable_source_line(&filename, region.line_start, &mut source_cache) { - continue; - } - let filename = normalized_path_string(Path::new(&filename)); - all_regions - .entry((filename.clone(), region.clone())) - .and_modify(|existing| *existing |= covered) - .or_insert(covered); - if region.kind == 0 { - for line in region.line_start..=region.line_end { - if !is_ignorable_source_line(&filename, line, &mut source_cache) { - lines - .entry((filename.clone(), line)) - .and_modify(|existing| *existing |= covered) - .or_insert(covered); - } - } - } - } - for function in variants { - for branch in &function.branches { - let filename = branch_filename(function, branch) - .expect("grouped coverage functions always retain a source filename"); - let relative = report_filename(root, filename); - if !scope.paths.iter().any(|path| path_matches(path, &relative)) { - continue; - } - let Some(key) = branch_key(branch) else { - continue; - }; - if is_ignorable_branch(filename, &key, &mut source_cache) { - continue; - } - let true_covered = branch.get(4).is_some_and(|count| *count > 0); - let false_covered = branch.get(5).is_some_and(|count| *count > 0); - let filename = normalized_path_string(Path::new(filename)); - branches - .entry((filename, key)) - .and_modify(|covered| { - covered.0 |= true_covered; - covered.1 |= false_covered; - }) - .or_insert((true_covered, false_covered)); - } - } - } - let line_count = lines.len() as u64; - let line_covered = lines.values().filter(|covered| **covered).count() as u64; - let branch_count = (branches.len() * 2) as u64; - let branch_covered = branches - .values() - .map(|covered| u64::from(covered.0) + u64::from(covered.1)) - .sum::<u64>(); - let region_count = all_regions.len() as u64; - let region_covered = all_regions.values().filter(|covered| **covered).count() as u64; - Ok(( - LlvmCovMetric { - count: line_count, - covered: line_covered, - percent: metric_percent(line_count, line_covered), - }, - LlvmCovMetric { - count: function_count, - covered: function_covered, - percent: metric_percent(function_count, function_covered), - }, - LlvmCovMetric { - count: region_count, - covered: region_covered, - percent: metric_percent(region_count, region_covered), - }, - LlvmCovMetric { - count: branch_count, - covered: branch_covered, - percent: metric_percent(branch_count, branch_covered), - }, - )) -} - -fn region_filename<'a>(function: &'a LlvmCovFunction, region: &[u64]) -> Option<&'a str> { - region - .get(5) - .and_then(|index| function.filenames.get(*index as usize)) - .or_else(|| function.filenames.first()) - .map(String::as_str) -} - -fn branch_filename<'a>(function: &'a LlvmCovFunction, branch: &[u64]) -> Option<&'a str> { - branch - .get(6) - .and_then(|index| function.filenames.get(*index as usize)) - .or_else(|| function.filenames.first()) - .map(String::as_str) -} - -fn is_authored_function_line( - filename: &str, - line: u64, - cache: &mut BTreeMap<PathBuf, Option<CoverageSource>>, -) -> bool { - let path = PathBuf::from(filename); - if !cache.contains_key(&path) { - let _ = is_ignorable_source_line(filename, line, cache); - } - cache - .get(&path) - .and_then(Option::as_ref) - .and_then(|source| source.raw.lines().nth(line.saturating_sub(1) as usize)) - .is_some_and(|source_line| source_line.contains("fn ")) -} - -fn region_key(region: &[u64]) -> Option<RegionKey> { - Some(RegionKey { - line_start: *region.first()?, - column_start: *region.get(1)?, - line_end: *region.get(2)?, - column_end: *region.get(3)?, - kind: *region.get(7)?, - }) -} - -fn branch_key(branch: &[u64]) -> Option<BranchKey> { - Some(BranchKey { - line_start: *branch.first()?, - column_start: *branch.get(1)?, - line_end: *branch.get(2)?, - column_end: *branch.get(3)?, - kind: *branch.get(8)?, - }) -} - -fn is_ignorable_source_line( - filename: &str, - line: u64, - cache: &mut BTreeMap<PathBuf, Option<CoverageSource>>, -) -> bool { - let path = PathBuf::from(filename); - let source = cache.entry(path.clone()).or_insert_with(|| { - fs::read_to_string(path).ok().map(|raw| CoverageSource { - raw: raw.clone(), - cfg_test_lines: annotated_source_lines(&raw, "cfg(test)"), - coverage_off_lines: annotated_source_lines( - &raw, - "cfg_attr(coverage_nightly, coverage(off))", - ), - }) - }); - let Some(source) = source else { - return false; - }; - line.checked_sub(1) - .map(|index| { - let index = index as usize; - source.cfg_test_lines.get(index).copied().unwrap_or(false) - || source - .coverage_off_lines - .get(index) - .copied() - .unwrap_or(false) - }) - .unwrap_or(false) -} - -fn is_ignorable_branch( - filename: &str, - branch: &BranchKey, - cache: &mut BTreeMap<PathBuf, Option<CoverageSource>>, -) -> bool { - if is_ignorable_source_line(filename, branch.line_start, cache) { - return true; - } - if branch.line_start != branch.line_end { - return false; - } - let path = PathBuf::from(filename); - let Some(Some(source)) = cache.get(&path) else { - return false; - }; - let Some(line) = source - .raw - .lines() - .nth(branch.line_start.saturating_sub(1) as usize) - else { - return false; - }; - let start = branch.column_start.saturating_sub(1) as usize; - let end = branch.column_end.saturating_sub(1) as usize; - let slice = line.get(start..end); - (branch.column_end == branch.column_start + 1 && slice == Some("?")) - || line.contains("unreachable!()") - || (line.contains("assert!(matches!(") && slice == Some("matches!")) -} - -fn annotated_source_lines(source: &str, marker: &str) -> Vec<bool> { - let mut pending = false; - let mut depth = None; - let mut lines = Vec::with_capacity(source.lines().count()); - for line in source.lines() { - let trimmed = line.trim(); - let mut annotated = depth.is_some(); - let marker_matches = if marker == "cfg(test)" { - trimmed.starts_with("#[cfg(test)]") || trimmed.starts_with("#[cfg(all(test,") - } else { - trimmed.contains(marker) - }; - if depth.is_none() && marker_matches { - pending = true; - annotated = true; - } else if depth.is_none() && pending { - annotated = true; - let content = - !trimmed.is_empty() && !trimmed.starts_with("//") && !trimmed.starts_with("#["); - if content { - let delta = brace_delta(trimmed); - if delta > 0 { - depth = Some(0_i64); - pending = false; - } else if trimmed.contains('{') || trimmed.ends_with(';') { - pending = false; - } - } - } - lines.push(annotated); - if let Some(current) = depth.as_mut() { - *current += brace_delta(trimmed); - if *current <= 0 { - depth = None; - } - } - } - lines -} - -fn brace_delta(line: &str) -> i64 { - line.bytes().filter(|byte| *byte == b'{').count() as i64 - - line.bytes().filter(|byte| *byte == b'}').count() as i64 -} - -fn report_filename(root: &Path, filename: &str) -> String { - let path = normalize_path(Path::new(filename)); - let root = normalize_path(root); - let relative = path.strip_prefix(&root).unwrap_or(&path); - normalized_path_string(relative) -} - -fn normalize_path(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - let _ = normalized.pop(); - } - Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { - normalized.push(component.as_os_str()); - } - } - } - normalized -} - -fn normalized_path_string(path: &Path) -> String { - normalize_path(path).to_string_lossy().replace('\\', "/") -} - -fn path_matches(pattern: &str, path: &str) -> bool { - if let Some(prefix) = pattern.strip_suffix("/**") { - path == prefix || path.starts_with(&format!("{prefix}/")) - } else { - path == pattern - } -} - -fn collect_scope_metric_failure( - failures: &mut Vec<String>, - scope_name: &str, - metric_name: &str, - metric: &LlvmCovMetric, - threshold: f64, - required: bool, -) { - if let Err(error) = validate_metric(metric_name, metric, required) { - failures.push(format!("coverage scope {scope_name}: {error}")); - } - if let Err(error) = enforce_metric(metric_name, metric, threshold) { - failures.push(format!("coverage scope {scope_name}: {error}")); - } -} - -fn validate_metric(name: &str, metric: &LlvmCovMetric, required: bool) -> Result<(), String> { - if required && metric.count == 0 { - return Err(format!( - "coverage report did not include required {name} records" - )); - } - if metric.covered > metric.count { - return Err(format!("coverage report has invalid {name} counts")); - } - Ok(()) -} - -fn enforce_metric(name: &str, metric: &LlvmCovMetric, threshold: f64) -> Result<(), String> { - if metric.percent < threshold { - return Err(format!( - "coverage {name} {:.3}% ({}/{}) is below required {:.1}%", - metric.percent, metric.covered, metric.count, threshold - )); - } - Ok(()) -} - -fn metric_percent(count: u64, covered: u64) -> f64 { - if count == 0 { - 0.0 - } else { - covered as f64 * 100.0 / count as f64 - } -} - -#[cfg(test)] -#[path = "coverage_policy_tests.rs"] -mod tests; diff --git a/tools/xtask/src/coverage_policy_tests.rs b/tools/xtask/src/coverage_policy_tests.rs @@ -1,661 +0,0 @@ -use std::{ - fs, - path::{Path, PathBuf}, - time::{SystemTime, UNIX_EPOCH}, -}; - -use super::{ - BranchKey, CoverageContract, LlvmCovFunction, LlvmCovMetric, LlvmCovReport, - annotated_source_lines, brace_delta, branch_filename, branch_key, enforce_metric, - evaluate_report, is_authored_function_line, is_ignorable_branch, is_ignorable_source_line, - metric_percent, normalized_source_metrics, path_matches, region_filename, region_key, - report_filename, validate_contract, validate_metric, -}; - -const CONTRACT: &str = r#" -[policy] -enforce = true -require_regions = true -require_functions = true -require_executable_lines = true -require_branches = true - -[toolchain] -rust = "1.97.1" -coverage_rust = "nightly" -wasm_target = "wasm32-unknown-unknown" - -[report] -output = "target/sdk-coverage/summary.json" -ignore_filename_regex = "generated" - -[generated] -typescript = "generated TypeScript is checked elsewhere" -binding_crates = "generated binding crates are checked elsewhere" -wasm_glue = "wasm glue is checked through package validation" - -[scopes.xtask_policy] -paths = ["tools/xtask/src/coverage_policy.rs"] -threshold = 90.0 - -[exclusions.generated] -paths = ["packages/*/src/generated/**"] -reason = "generated output is checked through reproducibility" -"#; - -#[derive(Clone, Copy)] -struct Metrics { - lines: (u64, u64, f64), - functions: (u64, u64, f64), - regions: (u64, u64, f64), - branches: (u64, u64, f64), -} - -fn covered() -> Metrics { - Metrics { - lines: (100, 100, 100.0), - functions: (50, 50, 100.0), - regions: (200, 200, 100.0), - branches: (80, 80, 100.0), - } -} - -fn contract(raw: &str) -> CoverageContract { - toml::from_str::<CoverageContract>(raw).expect("contract parses") -} - -fn test_root(name: &str) -> PathBuf { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "radroots_sdk_coverage_policy_{name}_{}_{}", - std::process::id(), - stamp - )); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("create root"); - root -} - -fn metric_json(metric: (u64, u64, f64)) -> String { - format!( - r#"{{"count":{},"covered":{},"percent":{}}}"#, - metric.0, metric.1, metric.2 - ) -} - -fn summary_json(metrics: Metrics) -> String { - format!( - r#"{{"lines":{},"functions":{},"regions":{},"branches":{}}}"#, - metric_json(metrics.lines), - metric_json(metrics.functions), - metric_json(metrics.regions), - metric_json(metrics.branches) - ) -} - -fn report_json(filename: &str, file_metrics: Metrics, totals: Metrics) -> String { - format!( - r#"{{"data":[{{"files":[{{"filename":"{}","summary":{}}}],"totals":{}}}]}}"#, - filename, - summary_json(file_metrics), - summary_json(totals) - ) -} - -fn write_report(root: &Path, raw: &str) -> PathBuf { - let report_path = root.join("summary.json"); - fs::write(&report_path, raw).expect("write report"); - report_path -} - -fn scope_file(root: &Path) -> String { - root.join("tools/xtask/src/coverage_policy.rs") - .display() - .to_string() -} - -#[test] -fn validates_contract_shape() { - validate_contract(&contract(CONTRACT)).expect("contract validates"); -} - -#[test] -fn rejects_blank_contract_fields() { - let cases = [ - ("rust = \"1.97.1\"", "rust = \" \"", "toolchain.rust"), - ( - "coverage_rust = \"nightly\"", - "coverage_rust = \" \"", - "toolchain.coverage_rust", - ), - ( - "wasm_target = \"wasm32-unknown-unknown\"", - "wasm_target = \" \"", - "toolchain.wasm_target", - ), - ( - "output = \"target/sdk-coverage/summary.json\"", - "output = \" \"", - "report.output", - ), - ( - "ignore_filename_regex = \"generated\"", - "ignore_filename_regex = \" \"", - "report.ignore_filename_regex", - ), - ( - "typescript = \"generated TypeScript is checked elsewhere\"", - "typescript = \" \"", - "generated.typescript", - ), - ( - "binding_crates = \"generated binding crates are checked elsewhere\"", - "binding_crates = \" \"", - "generated.binding_crates", - ), - ( - "wasm_glue = \"wasm glue is checked through package validation\"", - "wasm_glue = \" \"", - "generated.wasm_glue", - ), - ( - "paths = [\"tools/xtask/src/coverage_policy.rs\"]", - "paths = [\" \"]", - "scopes.xtask_policy.paths entry", - ), - ( - "reason = \"generated output is checked through reproducibility\"", - "reason = \" \"", - "exclusions.generated.reason", - ), - ( - "paths = [\"packages/*/src/generated/**\"]", - "paths = [\" \"]", - "exclusions.generated.paths entry", - ), - ]; - - for (from, to, expected) in cases { - let raw = CONTRACT.replace(from, to); - let error = validate_contract(&contract(&raw)).expect_err("invalid contract"); - assert!(error.contains(expected), "{error}"); - } -} - -#[test] -fn rejects_contract_collection_errors() { - let mut no_scopes = contract(CONTRACT); - no_scopes.scopes.clear(); - assert_eq!( - validate_contract(&no_scopes).unwrap_err(), - "contracts/coverage.toml scopes must not be empty" - ); - - let mut no_exclusions = contract(CONTRACT); - no_exclusions.exclusions.clear(); - assert_eq!( - validate_contract(&no_exclusions).unwrap_err(), - "contracts/coverage.toml exclusions must not be empty" - ); - - let cases = [ - ( - CONTRACT.replace("[scopes.xtask_policy]", "[scopes.\"\"]"), - "scope name", - ), - ( - CONTRACT.replace("[exclusions.generated]", "[exclusions.\"\"]"), - "exclusion name", - ), - ( - CONTRACT.replace("threshold = 90.0", "threshold = 101.0"), - "scopes.xtask_policy.threshold", - ), - ( - CONTRACT.replace( - "threshold = 90.0", - "threshold = 90.0\nbranch_reason = \"not applicable\"", - ), - "branch_reason requires branches_applicable = false", - ), - ( - CONTRACT.replace( - "threshold = 90.0", - "threshold = 90.0\nbranches_applicable = false", - ), - "scopes.xtask_policy.branch_reason", - ), - ( - CONTRACT.replace( - "paths = [\"tools/xtask/src/coverage_policy.rs\"]", - "paths = []", - ), - "scopes.xtask_policy.paths must not be empty", - ), - ( - CONTRACT.replace("paths = [\"packages/*/src/generated/**\"]", "paths = []"), - "exclusions.generated.paths must not be empty", - ), - ]; - - for (raw, expected) in cases { - let error = validate_contract(&contract(&raw)).expect_err("invalid contract"); - assert!(error.contains(expected), "{error}"); - } -} - -#[test] -fn matches_recursive_scope_paths() { - assert!(path_matches( - "crates/sdk/src/**", - "crates/sdk/src/adapters/radrootsd.rs" - )); - assert!(path_matches("crates/sdk/src/**", "crates/sdk/src")); - assert!(path_matches( - "tools/xtask/src/coverage_policy.rs", - "tools/xtask/src/coverage_policy.rs" - )); - assert!(!path_matches( - "crates/sdk/src/**", - "crates/sql_wasm_runtime/src/lib.rs" - )); -} - -#[test] -fn accepts_passing_reports_and_rejects_undercovered_scopes() { - let root = test_root("passing_and_undercovered"); - let filename = scope_file(&root); - let passing_report = report_json(&filename, covered(), covered()); - let report_path = write_report(&root, &passing_report); - evaluate_report(&root, &report_path, &contract(CONTRACT)).expect("passing report"); - - let mut undercovered = covered(); - undercovered.lines = (100, 89, 89.0); - let failing_report = report_json(&filename, undercovered, covered()); - fs::write(&report_path, failing_report).expect("write failing report"); - let error = evaluate_report(&root, &report_path, &contract(CONTRACT)) - .expect_err("undercovered report rejected"); - assert!(error.contains("coverage scope xtask_policy"), "{error}"); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn disabled_enforcement_accepts_measured_undercoverage() { - let root = test_root("disabled"); - let filename = scope_file(&root); - let mut undercovered = covered(); - undercovered.lines = (100, 0, 0.0); - undercovered.functions = (50, 0, 0.0); - undercovered.regions = (200, 0, 0.0); - undercovered.branches = (80, 0, 0.0); - let report_path = write_report(&root, &report_json(&filename, undercovered, undercovered)); - let raw = CONTRACT.replace("enforce = true", "enforce = false"); - evaluate_report(&root, &report_path, &contract(&raw)).expect("disabled policy passes"); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn branch_inapplicability_is_explicit_and_rejects_measured_branches() { - let root = test_root("branch_inapplicability"); - let filename = scope_file(&root); - let raw = CONTRACT.replace( - "threshold = 90.0", - "threshold = 90.0\nbranches_applicable = false\nbranch_reason = \"straight-line source\"", - ); - let mut no_branches = covered(); - no_branches.branches = (0, 0, 0.0); - let report_path = write_report(&root, &report_json(&filename, no_branches, covered())); - evaluate_report(&root, &report_path, &contract(&raw)).expect("no-branch scope passes"); - - fs::write(&report_path, report_json(&filename, covered(), covered())) - .expect("write measured branches"); - let error = evaluate_report(&root, &report_path, &contract(&raw)) - .expect_err("measured branch drift rejected"); - assert!(error.contains("declared branches inapplicable"), "{error}"); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn duplicate_harness_variants_are_merged_and_test_modules_are_excluded() { - let root = test_root("duplicate_harness_variants"); - let filename = scope_file(&root); - let source_path = Path::new(&filename); - fs::create_dir_all(source_path.parent().expect("source parent")).expect("create source parent"); - fs::write( - source_path, - "pub fn production() -> bool { true }\n\n#[cfg(test)]\nmod tests {\n fn helper() -> bool { false }\n}\n", - ) - .expect("write source"); - let report = format!( - r#"{{"data":[{{"files":[{{"filename":"{filename}","summary":{summary}}}],"functions":[{{"count":0,"filenames":["{filename}"],"regions":[[1,1,1,37,0,0,0,0]]}},{{"count":1,"filenames":["{filename}"],"regions":[[1,1,1,37,1,0,0,0]]}},{{"count":0,"filenames":["{filename}"],"regions":[[5,5,5,34,0,0,0,0]]}}],"totals":{summary}}}]}}"#, - summary = summary_json(covered()), - ); - let report_path = write_report(&root, &report); - - evaluate_report(&root, &report_path, &contract(CONTRACT)) - .expect("normalized production coverage passes"); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn normalized_details_fail_closed_and_merge_every_supported_record_shape() { - let root = test_root("normalized_record_shapes"); - let filename = scope_file(&root); - let source_path = Path::new(&filename); - fs::create_dir_all(source_path.parent().expect("source parent")).expect("source parent"); - fs::write( - source_path, - "pub fn production(flag: bool) -> bool { if flag { true } else { false } }\nfn uncovered() {}\nconst VALUE: bool = true;\n#[cfg(test)]\nmod tests { fn ignored() {} }\n", - ) - .expect("source"); - let outside = root.join("outside.rs").display().to_string(); - fs::write(&outside, "fn outside() {}\n").expect("outside source"); - let summary = summary_json(covered()); - let report = format!( - r#"{{"data":[{{"files":[],"functions":[ - {{"count":0,"filenames":[],"regions":[],"branches":[]}}, - {{"count":0,"filenames":["{filename}"],"regions":[[1,1,1]],"branches":[]}}, - {{"count":0,"filenames":["{outside}"],"regions":[[1,1,1,16,0,0,0,0]],"branches":[]}}, - {{"count":0,"filenames":["{filename}"],"regions":[[2,1,2,18,0,0,0,0]],"branches":[]}}, - {{"count":1,"filenames":["{filename}"],"regions":[[3,1,3,25,1,0,0,0]],"branches":[]}}, - {{"count":1,"filenames":["{filename}","{outside}"],"regions":[[1,1,1,76,1,0,0,0],[1,1,5,36,1,0,0,0],[2,1,2,18,0,0,0,1],[4,1,4,13,0,0,0,0],[1,1,1,2,0,1,0,0],[1]],"branches":[[1,40,1,47,1,0,0,0,4],[2,1,2,3,0,1,0,0,4],[4,1,4,3,0,0,0,0,4],[99,1,99,3,0,0,0,0,4],[1,1,1,2,0,0,1,0,4],[1]]}}, - {{"count":0,"filenames":["{filename}","{outside}"],"regions":[[1,1,1,76,0,0,0,0],[2,1,2,18,1,0,0,0]],"branches":[[1,40,1,47,0,1,0,0,4],[2,1,2,3,1,0,0,0,4]]}} - ],"totals":{summary}}}]}}"#, - ); - let parsed = serde_json::from_str::<LlvmCovReport>(&report).expect("detail report"); - let policy = contract(CONTRACT); - let scope = policy.scopes.get("xtask_policy").expect("scope"); - let (lines, functions, regions, branches) = - normalized_source_metrics(&root, &parsed.data[0], scope).expect("normalized details"); - assert!(lines.count >= 2); - assert_eq!(functions.count, 2); - assert_eq!(functions.covered, 1); - assert!(regions.count >= 2); - assert_eq!(branches.count, 6); - assert_eq!(branches.covered, 4); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn rejects_unreadable_malformed_and_empty_reports() { - let root = test_root("bad_reports"); - let missing = root.join("missing.json"); - assert!(evaluate_report(&root, &missing, &contract(CONTRACT)).is_err()); - - let malformed = write_report(&root, "{"); - assert!(evaluate_report(&root, &malformed, &contract(CONTRACT)).is_err()); - - fs::write(&malformed, r#"{"data":[]}"#).expect("write empty report"); - assert!(evaluate_report(&root, &malformed, &contract(CONTRACT)).is_err()); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn rejects_required_total_metric_failures() { - let root = test_root("total_metrics"); - let filename = scope_file(&root); - let mut totals = covered(); - let cases = [ - Metrics { - lines: (0, 0, 0.0), - ..totals - }, - { - totals = covered(); - totals.functions = (0, 0, 0.0); - totals - }, - { - totals = covered(); - totals.regions = (0, 0, 0.0); - totals - }, - { - totals = covered(); - totals.branches = (0, 0, 0.0); - totals - }, - { - totals = covered(); - totals.lines = (1, 2, 200.0); - totals - }, - ]; - - let report_path = root.join("summary.json"); - for total_metrics in cases { - fs::write( - &report_path, - report_json(&filename, covered(), total_metrics), - ) - .expect("write report"); - assert!(evaluate_report(&root, &report_path, &contract(CONTRACT)).is_err()); - } - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn rejects_scope_metric_validation_and_missing_scope_files() { - let root = test_root("scope_metrics"); - let filename = scope_file(&root); - let other_filename = root - .join("tools/xtask/src/coverage.rs") - .display() - .to_string(); - let report_path = root.join("summary.json"); - - fs::write( - &report_path, - report_json(&other_filename, covered(), covered()), - ) - .expect("write unmatched report"); - let error = evaluate_report(&root, &report_path, &contract(CONTRACT)) - .expect_err("unmatched scope rejected"); - assert!(error.contains("matched no report files"), "{error}"); - - let mut invalid = covered(); - invalid.lines = (0, 0, 0.0); - fs::write(&report_path, report_json(&filename, invalid, covered())).expect("write report"); - assert!(evaluate_report(&root, &report_path, &contract(CONTRACT)).is_err()); - - invalid = covered(); - invalid.functions = (1, 2, 200.0); - fs::write(&report_path, report_json(&filename, invalid, covered())).expect("write report"); - assert!(evaluate_report(&root, &report_path, &contract(CONTRACT)).is_err()); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn metric_helpers_cover_edges() { - let valid = LlvmCovMetric { - count: 10, - covered: 10, - percent: 100.0, - }; - validate_metric("lines", &valid, true).expect("metric validates"); - enforce_metric("lines", &valid, 100.0).expect("metric passes"); - - let missing = LlvmCovMetric { - count: 0, - covered: 0, - percent: 0.0, - }; - assert!(validate_metric("lines", &missing, true).is_err()); - assert!(enforce_metric("lines", &missing, 100.0).is_err()); - - let invalid = LlvmCovMetric { - count: 1, - covered: 2, - percent: 200.0, - }; - assert!(validate_metric("lines", &invalid, true).is_err()); - assert_eq!(metric_percent(0, 0), 0.0); - assert_eq!(metric_percent(4, 2), 50.0); -} - -#[test] -fn detail_key_and_path_helpers_cover_valid_and_short_records() { - assert!(region_key(&[1, 2, 3, 4, 5, 6, 7, 8]).is_some()); - assert!(region_key(&[1, 2, 3]).is_none()); - assert!(branch_key(&[1, 2, 3, 4, 5, 6, 7, 8, 9]).is_some()); - assert!(branch_key(&[1, 2, 3]).is_none()); - assert_eq!(brace_delta("fn value() { if true { 1 } else { 0 } }"), 0); - assert_eq!(brace_delta("{{"), 2); - assert_eq!(brace_delta("}"), -1); - - let root = Path::new("/workspace/sdk"); - assert_eq!( - report_filename(root, "/workspace/sdk/crates/sdk/src/lib.rs"), - "crates/sdk/src/lib.rs" - ); - assert_eq!( - report_filename(root, "/elsewhere/lib.rs"), - "/elsewhere/lib.rs" - ); - assert_eq!( - report_filename( - root, - "/workspace/sdk/crates/sdk/src/adapters/../../tests/unit.rs" - ), - "crates/sdk/tests/unit.rs" - ); -} - -#[test] -fn detail_filename_helpers_honor_record_indexes_and_safe_fallbacks() { - let function = LlvmCovFunction { - count: 1, - filenames: vec!["primary.rs".to_owned(), "expanded.rs".to_owned()], - regions: Vec::new(), - branches: Vec::new(), - }; - assert_eq!( - region_filename(&function, &[1, 1, 1, 2, 1, 1, 0, 0]), - Some("expanded.rs") - ); - assert_eq!( - region_filename(&function, &[1, 1, 1, 2, 1, 99, 0, 0]), - Some("primary.rs") - ); - assert_eq!(region_filename(&function, &[1]), Some("primary.rs")); - assert_eq!( - branch_filename(&function, &[1, 1, 1, 2, 1, 0, 1, 0, 4]), - Some("expanded.rs") - ); - assert_eq!( - branch_filename(&function, &[1, 1, 1, 2, 1, 0, 99, 0, 4]), - Some("primary.rs") - ); - assert_eq!(branch_filename(&function, &[1]), Some("primary.rs")); - - let no_filenames = LlvmCovFunction { - count: 0, - filenames: Vec::new(), - regions: Vec::new(), - branches: Vec::new(), - }; - assert_eq!(region_filename(&no_filenames, &[1]), None); - assert_eq!(branch_filename(&no_filenames, &[1]), None); -} - -#[test] -fn authored_and_annotated_source_helpers_cover_marker_shapes() { - let root = test_root("source_helpers"); - let source = root.join("source.rs"); - fs::write( - &source, - "pub fn production() {}\n#[cfg(test)]\nmod tests {\n fn helper() {}\n}\n#[cfg(all(test, feature = \"x\"))]\nfn gated() {}\n#[cfg_attr(coverage_nightly, coverage(off))]\nfn excluded() {}\n", - ) - .expect("source"); - let filename = source.display().to_string(); - let mut cache = std::collections::BTreeMap::new(); - - assert!(is_authored_function_line(&filename, 1, &mut cache)); - assert!(!is_authored_function_line(&filename, 2, &mut cache)); - assert!(!is_ignorable_source_line(&filename, 1, &mut cache)); - assert!(is_ignorable_source_line(&filename, 2, &mut cache)); - assert!(is_ignorable_source_line(&filename, 4, &mut cache)); - assert!(is_ignorable_source_line(&filename, 7, &mut cache)); - assert!(is_ignorable_source_line(&filename, 9, &mut cache)); - assert!(!is_ignorable_source_line(&filename, 0, &mut cache)); - assert!(!is_ignorable_source_line( - root.join("missing.rs").to_str().expect("path"), - 1, - &mut cache - )); - - let marked = annotated_source_lines( - "#[cfg(test)]\n// note\nfn test() {}\nfn live() {}", - "cfg(test)", - ); - assert_eq!(marked, vec![true, true, true, false]); - let coverage_off = annotated_source_lines( - "#[cfg_attr(coverage_nightly, coverage(off))]\n#[inline]\nfn excluded() {\n if true {\n work();\n }\n}\nfn live() {}", - "cfg_attr(coverage_nightly, coverage(off))", - ); - assert_eq!( - coverage_off, - vec![true, true, true, true, true, true, true, false] - ); - assert_eq!( - annotated_source_lines("#[cfg(test)]\nfn declaration();\nfn live() {}", "cfg(test)"), - vec![true, true, false] - ); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn branch_filter_only_excludes_explicit_synthetic_constructs() { - let root = test_root("branch_helpers"); - let source = root.join("source.rs"); - fs::write( - &source, - "fn value() {\n let _ = call()?;\n unreachable!();\n assert!(matches!(value, Some(_)));\n if live { work(); }\n}\n", - ) - .expect("source"); - let filename = source.display().to_string(); - let mut cache = std::collections::BTreeMap::new(); - assert!(!is_ignorable_source_line(&filename, 1, &mut cache)); - - let branch = |line_start, column_start, line_end, column_end| BranchKey { - line_start, - column_start, - line_end, - column_end, - kind: 4, - }; - assert!(is_ignorable_branch( - &filename, - &branch(2, 16, 2, 17), - &mut cache - )); - assert!(is_ignorable_branch( - &filename, - &branch(3, 2, 3, 10), - &mut cache - )); - assert!(is_ignorable_branch( - &filename, - &branch(4, 10, 4, 18), - &mut cache - )); - assert!(!is_ignorable_branch( - &filename, - &branch(5, 2, 5, 9), - &mut cache - )); - assert!(!is_ignorable_branch( - &filename, - &branch(5, 2, 6, 1), - &mut cache - )); - assert!(!is_ignorable_branch( - root.join("missing.rs").to_str().expect("path"), - &branch(1, 1, 1, 2), - &mut cache - )); - fs::remove_dir_all(root).expect("cleanup"); -} diff --git a/tools/xtask/src/dto_roots.rs b/tools/xtask/src/dto_roots.rs @@ -1,969 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; - -use dto_bindgen_backend_ts::{ - DtoRegistryRenderOptions, DtoTypesModule, TypeScriptDeclaration, TypeScriptModule, - TypeScriptType, render_registry_types, -}; -use dto_bindgen_core::{Registry, RootDescriptor, build_registry}; - -#[derive(Clone, Copy, Debug)] -pub struct DtoPackageRootSet { - pub package_key: &'static str, - roots: fn() -> Vec<RootDescriptor>, -} - -impl DtoPackageRootSet { - pub fn roots(&self) -> Vec<RootDescriptor> { - (self.roots)() - } - - pub fn registry(&self) -> Registry { - build_registry(self.roots()) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct ManualDescriptorFamily { - pub package_key: &'static str, - pub source_family: &'static str, - pub reason: &'static str, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct SdkLocalWrapperAllowance { - pub package_key: &'static str, - pub shape_family: &'static str, - pub reason: &'static str, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct DtoExternalOverride { - target_type: &'static str, - import_name: &'static str, - from_package_key: &'static str, - from: &'static str, -} - -const CORE_BINDINGS_PACKAGE_KEY: &str = "core"; -const CORE_BINDINGS_PACKAGE_NAME: &str = "@radroots/core-bindings"; -const EVENT_BINDINGS_PACKAGE_KEY: &str = "event"; -const EVENT_BINDINGS_PACKAGE_NAME: &str = "@radroots/event-bindings"; - -pub const DTO_PACKAGE_ROOTS: &[DtoPackageRootSet] = &[DtoPackageRootSet { - package_key: "core", - roots: core_roots, -}]; - -pub const MANUAL_DESCRIPTOR_FAMILIES: &[ManualDescriptorFamily] = &[ - ManualDescriptorFamily { - package_key: "core", - source_family: "decimal, currency, money, quantity, percent, quantity price, unit, and discount value families", - reason: "custom serde, string-backed newtypes, aliases, and tagged enum wire forms require source-owned manual descriptors", - }, - ManualDescriptorFamily { - package_key: "event", - source_family: "event timestamps, counters, and optional metadata fields", - reason: "large integers and source-specific optional/null policy must be explicit", - }, - ManualDescriptorFamily { - package_key: "event", - source_family: "GeoJSON coordinate arrays", - reason: "fixed-size Rust arrays must preserve tuple semantics in TypeScript", - }, - ManualDescriptorFamily { - package_key: "trade", - source_family: "trade listing roots and package projection count fields", - reason: "core aliases, source-owned event imports, and count-family numeric policy require explicit descriptors", - }, - ManualDescriptorFamily { - package_key: "replica_schema", - source_family: "untagged query wrappers and serde_json value fields", - reason: "schema query shapes are generated and not all source fields map to derive-supported DTOs", - }, -]; - -pub const SDK_LOCAL_WRAPPER_ALLOWANCES: &[SdkLocalWrapperAllowance] = &[ - SdkLocalWrapperAllowance { - package_key: "core", - shape_family: "Currency and Decimal package aliases", - reason: "string-backed source descriptors require canonical named TypeScript package aliases", - }, - SdkLocalWrapperAllowance { - package_key: "replica_schema", - shape_family: "generated query argument wrappers", - reason: "schema operation inputs are generated package shapes rather than source-owned public DTO structs", - }, - SdkLocalWrapperAllowance { - package_key: "trade", - shape_family: "marketplace, query, projection, sort, review, and backoffice DTO shapes", - reason: "these are SDK package contract shapes layered over source-owned trade, events, and core DTOs", - }, -]; - -const EVENT_CORE_EXTERNAL_OVERRIDES: &[DtoExternalOverride] = &[ - core_override("Currency"), - core_override("Decimal"), - core_override("Discount"), - core_override("DiscountScope"), - core_override("DiscountThreshold"), - core_override("DiscountValue"), - core_override("Money"), - core_override("Percent"), - core_override("Quantity"), - core_override("QuantityPrice"), - core_override("Unit"), - core_override("UnitDimension"), -]; - -const TRADE_EXTERNAL_OVERRIDES: &[DtoExternalOverride] = &[ - core_override("Currency"), - core_override("Decimal"), - core_override("Discount"), - core_override("DiscountValue"), - core_override("Money"), - core_override("Quantity"), - core_override("QuantityPrice"), - core_override("Unit"), - event_override("RadrootsFarmRef"), - event_override("RadrootsOperationalListing"), - event_override("RadrootsOperationalListingAvailability"), - event_override("RadrootsOperationalListingBin"), - event_override("RadrootsOperationalListingDeliveryMethod"), - event_override("RadrootsOperationalListingImage"), - event_override("RadrootsOperationalListingProduct"), - event_override("RadrootsOperationalListingPublicLocation"), - event_override("RadrootsOperationalListingStatus"), - event_override("RadrootsEventPtr"), - event_override("RadrootsPlotRef"), - event_override("RadrootsResourceAreaRef"), -]; - -const TRADE_REQUIRED_EXTERNAL_PACKAGE_IMPORTS: &[&str] = - &[CORE_BINDINGS_PACKAGE_NAME, EVENT_BINDINGS_PACKAGE_NAME]; -const EVENT_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/event-bindings/src/generated/types.ts"); - -pub fn package_root_set(package_key: &str) -> Option<&'static DtoPackageRootSet> { - DTO_PACKAGE_ROOTS - .iter() - .find(|root_set| root_set.package_key == package_key) -} - -pub fn core_types_module() -> Result<DtoTypesModule, String> { - let root_set = package_root_set("core").ok_or_else(|| "missing core DTO roots".to_owned())?; - let rendered = - render_registry_types(&root_set.registry(), &DtoRegistryRenderOptions::default())?; - Ok(with_type_aliases_sorted( - rendered, - [ - type_alias("Currency", TypeScriptType::String), - type_alias("Decimal", TypeScriptType::String), - ], - )) -} - -pub fn replica_schema_types_module() -> Result<DtoTypesModule, String> { - render_registry_types( - &radroots_replica_schema_bindings::dto_registry(), - &DtoRegistryRenderOptions::default(), - ) -} - -fn core_roots() -> Vec<RootDescriptor> { - radroots_core_bindings::dto_roots() -} - -fn core_import_options( - mut options: DtoRegistryRenderOptions, -) -> Result<DtoRegistryRenderOptions, String> { - let package_exports = BTreeMap::from([( - CORE_BINDINGS_PACKAGE_KEY, - type_exports(core_types_module()?.body_ts()), - )]); - for override_target in EVENT_CORE_EXTERNAL_OVERRIDES { - validate_external_override_target(*override_target, &package_exports)?; - options = options.with_external_override( - override_target.target_type, - override_target.import_name, - override_target.from, - ); - } - Ok(options) -} - -fn trade_import_options( - registry: &Registry, - mut options: DtoRegistryRenderOptions, -) -> Result<DtoRegistryRenderOptions, String> { - let package_exports = generated_external_package_exports()?; - for override_target in TRADE_EXTERNAL_OVERRIDES { - validate_external_override_target(*override_target, &package_exports)?; - if let Ok(type_id) = registry.type_id_by_export_name(override_target.target_type) { - options = options.with_external_type( - type_id, - override_target.import_name, - override_target.from, - ); - } - options = options.with_external_override( - override_target.target_type, - override_target.import_name, - override_target.from, - ); - } - - Ok(options) -} - -fn generated_external_package_exports() -> Result<BTreeMap<&'static str, BTreeSet<String>>, String> -{ - Ok(BTreeMap::from([ - ( - CORE_BINDINGS_PACKAGE_KEY, - type_exports(core_types_module()?.body_ts()), - ), - ( - EVENT_BINDINGS_PACKAGE_KEY, - type_exports(EVENT_BINDINGS_TYPES_TS), - ), - ])) -} - -fn validate_external_override_target( - override_target: DtoExternalOverride, - package_exports: &BTreeMap<&'static str, BTreeSet<String>>, -) -> Result<(), String> { - let exports = package_exports - .get(override_target.from_package_key) - .ok_or_else(|| { - format!( - "external DTO override `{}` references unknown package key `{}`", - override_target.target_type, override_target.from_package_key - ) - })?; - if exports.contains(override_target.import_name) { - return Ok(()); - } - Err(format!( - "external DTO override `{}` imports `{}` from `{}`, but package `{}` does not export it", - override_target.target_type, - override_target.import_name, - override_target.from, - override_target.from_package_key - )) -} - -fn validate_external_override_usage( - module: &DtoTypesModule, - overrides: &[DtoExternalOverride], -) -> Result<(), String> { - let imports = imported_type_inventory(module.imports_ts().unwrap_or_default()); - let package_exports = generated_external_package_exports()?; - for package_name in TRADE_REQUIRED_EXTERNAL_PACKAGE_IMPORTS { - if !imports.contains_key(*package_name) { - return Err(format!( - "expected generated DTO imports from `{package_name}` but none were emitted" - )); - } - } - - for override_target in overrides { - if type_exports(module.body_ts()).contains(override_target.target_type) { - return Err(format!( - "external DTO override `{}` from `{}` was emitted locally instead of imported", - override_target.target_type, override_target.from - )); - } - if imports - .get(override_target.from) - .is_some_and(|names| names.contains(override_target.import_name)) - { - validate_external_override_target(*override_target, &package_exports)?; - } - } - Ok(()) -} - -fn imported_type_inventory(imports_ts: &str) -> BTreeMap<String, BTreeSet<String>> { - let mut imports = BTreeMap::new(); - let mut pending_names: Option<Vec<String>> = None; - - for line in imports_ts.lines() { - let line = line.trim(); - if let Some(single) = line.strip_prefix("import type { ") { - if let Some((name, from)) = single.split_once(" } from ") { - insert_import_names(&mut imports, from, name); - } - } else if line == "import type {" { - pending_names = Some(Vec::new()); - } else if let Some(from) = line.strip_prefix("} from ") { - if let Some(names) = pending_names.take() { - for name in names { - insert_import(&mut imports, from, &name); - } - } - } else if let Some(names) = pending_names.as_mut() - && let Some(name) = line.strip_suffix(',') - { - names.push(name.to_owned()); - } - } - - imports -} - -fn with_additional_type_imports( - module: DtoTypesModule, - from: &str, - names: impl IntoIterator<Item = &'static str>, -) -> DtoTypesModule { - let mut imports = imported_type_inventory(module.imports_ts().unwrap_or_default()); - imports - .entry(from.to_owned()) - .or_default() - .extend(names.into_iter().map(str::to_owned)); - let imports_ts = imports - .into_iter() - .map(|(from, names)| { - format!( - "import type {{ {} }} from \"{from}\";", - names.into_iter().collect::<Vec<_>>().join(", ") - ) - }) - .collect::<Vec<_>>() - .join("\n"); - let imports_ts = if imports_ts.is_empty() { - imports_ts - } else { - format!("{imports_ts}\n\n") - }; - DtoTypesModule::new(imports_ts, module.body_ts()) -} - -fn insert_import_names(imports: &mut BTreeMap<String, BTreeSet<String>>, from: &str, names: &str) { - for name in names.split(',') { - insert_import(imports, from, name); - } -} - -fn insert_import(imports: &mut BTreeMap<String, BTreeSet<String>>, from: &str, name: &str) { - let from = from.trim_end_matches(';').trim_matches('"'); - imports - .entry(from.to_owned()) - .or_default() - .insert(name.trim().to_owned()); -} - -fn type_exports(types_ts: &str) -> BTreeSet<String> { - types_ts - .lines() - .filter_map(|line| line.strip_prefix("export type ")) - .filter_map(|rest| rest.split([' ', '<']).next()) - .map(str::to_owned) - .collect() -} - -const fn core_override(export_name: &'static str) -> DtoExternalOverride { - DtoExternalOverride { - target_type: export_name, - import_name: export_name, - from_package_key: CORE_BINDINGS_PACKAGE_KEY, - from: CORE_BINDINGS_PACKAGE_NAME, - } -} - -const fn event_override(export_name: &'static str) -> DtoExternalOverride { - DtoExternalOverride { - target_type: export_name, - import_name: export_name, - from_package_key: EVENT_BINDINGS_PACKAGE_KEY, - from: EVENT_BINDINGS_PACKAGE_NAME, - } -} - -fn with_event_sdk_wrappers(module: DtoTypesModule) -> DtoTypesModule { - let mut declarations = module - .body_ts() - .split("\n\n") - .filter(|declaration| !declaration.trim().is_empty()) - .map(str::to_owned) - .collect::<Vec<_>>(); - declarations.push(type_alias( - "RadrootsOperationalListingProductTagKeys", - TypeScriptType::readonly_tuple( - [ - "key", "title", "category", "summary", "process", "lot", "location", "profile", - "year", - ] - .into_iter() - .map(TypeScriptType::literal_string) - .collect::<Vec<_>>(), - ), - )); - declarations.sort_by(|left, right| declaration_name(left).cmp(declaration_name(right))); - DtoTypesModule::new( - module.imports_ts().unwrap_or_default(), - declarations.join("\n\n"), - ) -} - -fn declaration_name(declaration: &str) -> &str { - declaration - .strip_prefix("export type ") - .and_then(|rest| rest.split([' ', '<']).next()) - .unwrap_or(declaration) -} - -fn with_type_aliases_sorted( - module: DtoTypesModule, - aliases: impl IntoIterator<Item = String>, -) -> DtoTypesModule { - let mut declarations = module - .body_ts() - .split("\n\n") - .filter(|declaration| !declaration.trim().is_empty()) - .map(str::to_owned) - .collect::<Vec<_>>(); - declarations.extend(aliases); - declarations.sort_by(|left, right| declaration_name(left).cmp(declaration_name(right))); - DtoTypesModule::new( - module.imports_ts().unwrap_or_default(), - declarations.join("\n\n"), - ) -} - -fn type_alias(name: impl Into<String>, type_expr: TypeScriptType) -> String { - TypeScriptModule::new("types.ts") - .with_declaration(TypeScriptDeclaration::type_alias(name, type_expr)) - .render_source() - .trim() - .to_owned() -} - -#[cfg(test)] -mod tests { - use std::collections::{BTreeMap, BTreeSet}; - - use dto_bindgen_core::SourceSpan; - - use super::{ - CORE_BINDINGS_PACKAGE_KEY, CORE_BINDINGS_PACKAGE_NAME, DTO_PACKAGE_ROOTS, - DtoExternalOverride, MANUAL_DESCRIPTOR_FAMILIES, SDK_LOCAL_WRAPPER_ALLOWANCES, - event_override, imported_type_inventory, package_root_set, type_exports, - validate_external_override_target, validate_external_override_usage, - with_additional_type_imports, - }; - use dto_bindgen_backend_ts::DtoTypesModule; - - const CORE_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/core-bindings/src/generated/types.ts"); - const EVENT_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/event-bindings/src/generated/types.ts"); - const REPLICA_SCHEMA_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/replica-schema-bindings/src/generated/types.ts"); - const TRADE_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/trade-bindings/src/generated/types.ts"); - const REPLICA_SCHEMA_MODEL_SOURCES: &[&str] = &[ - include_str!("../../../../lib/crates/replica_schema/src/models/farm.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/farm_gcs_location.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/farm_member.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/farm_member_claim.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/farm_tag.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/gcs_location.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/log_error.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/media_image.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/nostr_event_head.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/nostr_profile.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/nostr_profile_relay.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/nostr_relay.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/plot.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/plot_gcs_location.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/plot_tag.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/trade_product.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/trade_product_location.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/trade_product_media.rs"), - ]; - const CORE_TYPE_INVENTORY: &[&str] = &[ - "Currency", - "Decimal", - "Discount", - "DiscountScope", - "DiscountThreshold", - "DiscountValue", - "Money", - "Percent", - "Quantity", - "QuantityPrice", - "Unit", - "UnitDimension", - ]; - const EVENT_TYPE_INVENTORY: &[&str] = &[ - "JobFeedbackStatus", - "JobInputType", - "JobPaymentRequest", - "RadrootsAccountClaim", - "RadrootsAddressableRef", - "RadrootsAppData", - "RadrootsCommercialDomain", - "RadrootsContributionAttestation", - "RadrootsCoop", - "RadrootsCoopLocation", - "RadrootsCoopRef", - "RadrootsDocument", - "RadrootsDocumentSubject", - "RadrootsEventEnvelopeDto", - "RadrootsEventPtr", - "RadrootsEventRef", - "RadrootsEvidenceBounty", - "RadrootsFarm", - "RadrootsFarmPublicLocation", - "RadrootsFarmRef", - "RadrootsFollow", - "RadrootsFollowProfile", - "RadrootsGcsLocation", - "RadrootsGeoChat", - "RadrootsGeoJsonPoint", - "RadrootsGeoJsonPolygon", - "RadrootsGiftWrap", - "RadrootsGiftWrapRecipient", - "RadrootsJobFeedback", - "RadrootsJobInput", - "RadrootsJobParam", - "RadrootsJobRequest", - "RadrootsJobResult", - "RadrootsKnowledgeChangeProposal", - "RadrootsKnowledgeCitationSpan", - "RadrootsKnowledgeClaim", - "RadrootsKnowledgeFieldContext", - "RadrootsKnowledgeFieldReport", - "RadrootsKnowledgeLocation", - "RadrootsKnowledgeLocationPrecision", - "RadrootsKnowledgeNodeRef", - "RadrootsKnowledgeObservation", - "RadrootsKnowledgeObservationValue", - "RadrootsKnowledgeRelation", - "RadrootsKnowledgeReview", - "RadrootsKnowledgeReviewScope", - "RadrootsKnowledgeReviewScore", - "RadrootsKnowledgeReviewTarget", - "RadrootsKnowledgeSource", - "RadrootsList", - "RadrootsListEntry", - "RadrootsListSet", - "RadrootsMessage", - "RadrootsMessageFile", - "RadrootsMessageFileDimensions", - "RadrootsMessageRecipient", - "RadrootsNip01EventWireDto", - "RadrootsOperationalListing", - "RadrootsOperationalListingAvailability", - "RadrootsOperationalListingBin", - "RadrootsOperationalListingDeliveryMethod", - "RadrootsOperationalListingImage", - "RadrootsOperationalListingImageSize", - "RadrootsOperationalListingParseError", - "RadrootsOperationalListingProduct", - "RadrootsOperationalListingProductTagKeys", - "RadrootsOperationalListingPublicLocation", - "RadrootsOperationalListingStatus", - "RadrootsOperationalListingValidationError", - "RadrootsPlot", - "RadrootsPlotLocation", - "RadrootsPlotRef", - "RadrootsPost", - "RadrootsProfileType", - "RadrootsReaction", - "RadrootsRelayDocument", - "RadrootsResourceArea", - "RadrootsResourceAreaLocation", - "RadrootsResourceAreaRef", - "RadrootsResourceHarvestCap", - "RadrootsResourceHarvestProduct", - "RadrootsRightsAssertion", - "RadrootsSeal", - "RadrootsSignedEventDto", - "RadrootsSignedEventVerificationStateDto", - "RadrootsSocialFarmAnchor", - "RadrootsSocialLocation", - "RadrootsSocialMediaDimensions", - "RadrootsSocialMediaMetadata", - "RadrootsSocialMediaThumbnail", - "RadrootsSocialTarget", - "RadrootsVerifiedSignedEventDto", - "RadrootsVerifiedSignedEventVerificationStateDto", - "RadrootsWikiArticle", - "RadrootsWikiArticleVersionRef", - "RadrootsWikiMergeRequest", - "RadrootsWikiRedirect", - ]; - const TRADE_TYPE_INVENTORY: &[&str] = &[ - "RadrootsOperationalListingSubtotal", - "RadrootsOperationalListingTotal", - "RadrootsOperationalListingTradeProjection", - "RadrootsTradeAgreementClaimV1", - "RadrootsTradeAgreementStateV1", - "RadrootsTradeAttestationRecordV1", - "RadrootsTradeAttestationResultV1", - "RadrootsTradeAttestationStateV1", - "RadrootsTradeConflictStateV1", - "RadrootsTradeEvidenceStateV1", - "RadrootsTradeFacetCount", - "RadrootsTradeFulfillmentStateV1", - "RadrootsTradeListingBackofficeOverlay", - "RadrootsTradeListingBackofficeQuery", - "RadrootsTradeListingBackofficeView", - "RadrootsTradeListingBinProjection", - "RadrootsTradeListingFacets", - "RadrootsTradeListingMarketStatus", - "RadrootsTradeListingProjection", - "RadrootsTradeListingQuery", - "RadrootsTradeListingSort", - "RadrootsTradeListingSortField", - "RadrootsTradeMarketplaceListingSummary", - "RadrootsTradeModerationFlag", - "RadrootsTradeModerationSeverity", - "RadrootsTradeModerationStatus", - "RadrootsTradeNegotiationStateV1", - "RadrootsTradePaymentStateV1", - "RadrootsTradePrivateTermsStateV1", - "RadrootsTradeProjectionV1", - "RadrootsTradeReducerIssueV1", - "RadrootsTradeReviewPriority", - "RadrootsTradeReviewQueueEntry", - "RadrootsTradeReviewStatus", - "RadrootsTradeSortDirection", - ]; - - #[test] - fn approved_source_roots_build_registries() { - for root_set in DTO_PACKAGE_ROOTS { - let registry = root_set.registry(); - assert!( - !registry.has_errors(), - "registry for {} has diagnostics: {:?}", - root_set.package_key, - registry.diagnostics - ); - assert!(!registry.roots.is_empty()); - } - } - - #[test] - fn package_roots_are_explicit_not_discovered() { - assert!(package_root_set("core").is_some()); - assert!(package_root_set("event").is_none()); - assert!(package_root_set("trade").is_none()); - } - - #[test] - fn manual_descriptor_catalog_covers_known_review_families() { - assert!( - MANUAL_DESCRIPTOR_FAMILIES - .iter() - .any(|family| family.source_family.contains("GeoJSON")) - ); - assert!(SDK_LOCAL_WRAPPER_ALLOWANCES.iter().any(|allowance| { - allowance - .shape_family - .contains("marketplace, query, projection") - })); - } - - #[test] - fn external_override_target_validation_rejects_missing_generated_export() { - let package_exports = BTreeMap::from([( - CORE_BINDINGS_PACKAGE_KEY, - BTreeSet::from(["ExistingType".to_owned()]), - )]); - let error = validate_external_override_target( - DtoExternalOverride { - target_type: "MissingType", - import_name: "MissingType", - from_package_key: CORE_BINDINGS_PACKAGE_KEY, - from: CORE_BINDINGS_PACKAGE_NAME, - }, - &package_exports, - ) - .expect_err("missing target package export must fail"); - - assert_eq!( - error, - "external DTO override `MissingType` imports `MissingType` from `@radroots/core-bindings`, but package `core` does not export it" - ); - } - - #[test] - fn external_override_usage_rejects_absent_required_package_import() { - let module = DtoTypesModule::new("", "export type RadrootsTradeListing = string;"); - let error = validate_external_override_usage(&module, &[]) - .expect_err("missing required package import must fail"); - - assert_eq!( - error, - "expected generated DTO imports from `@radroots/core-bindings` but none were emitted" - ); - } - - #[test] - fn external_override_usage_rejects_local_emission_for_imported_type() { - let module = DtoTypesModule::new( - "import type { Decimal } from \"@radroots/core-bindings\";\n\nimport type { RadrootsFarmRef } from \"@radroots/event-bindings\";\n\n", - "export type RadrootsFarmRef = { pubkey: string, d_tag: string, };", - ); - let error = validate_external_override_usage(&module, &[event_override("RadrootsFarmRef")]) - .expect_err("local emission of imported type must fail"); - - assert_eq!( - error, - "external DTO override `RadrootsFarmRef` from `@radroots/event-bindings` was emitted locally instead of imported" - ); - } - - #[test] - fn generated_import_inventory_parses_single_and_multiline_imports() { - let imports = imported_type_inventory( - "import type { One, Four } from \"@radroots/one\";\nimport type {\n Two,\n Three,\n} from \"@radroots/many\";\n\n", - ); - - assert_eq!( - imports.get("@radroots/one").expect("single import package"), - &BTreeSet::from(["Four".to_owned(), "One".to_owned()]) - ); - assert_eq!( - imports.get("@radroots/many").expect("multi import package"), - &BTreeSet::from(["Three".to_owned(), "Two".to_owned()]) - ); - } - - #[test] - fn additional_type_imports_merge_with_generated_package_imports() { - let module = DtoTypesModule::new( - "import type { Decimal, Money } from \"@radroots/core-bindings\";\n\n", - "export type Listing = { discounts: Array<Discount>, };", - ); - let merged = with_additional_type_imports(module, CORE_BINDINGS_PACKAGE_NAME, ["Discount"]); - - assert_eq!( - merged.imports_ts(), - Some("import type { Decimal, Discount, Money } from \"@radroots/core-bindings\";\n\n") - ); - } - - #[test] - fn generated_type_export_inventory_parses_type_aliases() { - assert_eq!( - type_exports("export type Alpha = string;\nexport type Beta<T> = T;\n"), - BTreeSet::from(["Alpha".to_owned(), "Beta".to_owned()]) - ); - } - - #[test] - fn core_type_inventory_matches_current_package_surface() { - let actual = type_inventory(CORE_BINDINGS_TYPES_TS); - - assert_eq!(actual, CORE_TYPE_INVENTORY); - assert!(!CORE_BINDINGS_TYPES_TS.contains("RadrootsCore")); - } - - #[test] - fn event_type_inventory_matches_current_package_surface() { - let actual = type_inventory(EVENT_BINDINGS_TYPES_TS); - - assert_eq!(actual, EVENT_TYPE_INVENTORY); - assert!(!EVENT_BINDINGS_TYPES_TS.contains("RadrootsOrder")); - } - - #[test] - fn event_generated_types_expose_current_signed_event_dto_shapes() { - let wire = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsNip01EventWireDto"); - let envelope = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsEventEnvelopeDto"); - let signed = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsSignedEventDto"); - let verified = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsVerifiedSignedEventDto"); - - assert!(wire.contains("pubkey: string")); - assert!(wire.contains("extra: { [key: string]: unknown }")); - assert!(envelope.contains("author: string")); - assert!(!envelope.contains("pubkey")); - assert!(!envelope.contains("extra")); - assert!(signed.contains("state: RadrootsSignedEventVerificationStateDto")); - assert!(signed.contains("envelope: RadrootsEventEnvelopeDto")); - assert!(signed.contains("wire: RadrootsNip01EventWireDto")); - assert!(signed.contains("raw_json: string")); - assert!(verified.contains("state: RadrootsVerifiedSignedEventVerificationStateDto")); - assert!(verified.contains("signed_event: RadrootsSignedEventDto")); - assert!( - !EVENT_BINDINGS_TYPES_TS.contains("export type RadrootsEventEnvelope ="), - "event package must not export the retired raw envelope type name" - ); - } - - #[test] - fn trade_type_inventory_matches_current_package_surface() { - let actual = type_inventory(TRADE_BINDINGS_TYPES_TS); - - assert_eq!(actual, TRADE_TYPE_INVENTORY); - } - - #[test] - fn replica_schema_generated_types_preserve_source_schema_contracts() { - let actual = type_inventory(REPLICA_SCHEMA_BINDINGS_TYPES_TS); - let trade_product_filter = type_declaration( - REPLICA_SCHEMA_BINDINGS_TYPES_TS, - "ITradeProductFieldsFilter", - ); - let trade_product_partial = type_declaration( - REPLICA_SCHEMA_BINDINGS_TYPES_TS, - "ITradeProductFieldsPartial", - ); - - assert!(actual.contains(&"Farm")); - assert!(actual.contains(&"GcsLocation")); - assert!(actual.contains(&"NostrEventHead")); - assert!(actual.contains(&"ReplicaStoreJsonValue")); - assert!(actual.contains(&"ITradeProductFieldsPartial")); - assert!(!actual.contains(&"NostrEventState")); - assert!(REPLICA_SCHEMA_BINDINGS_TYPES_TS.contains( - "export type ReplicaStoreJsonValue = null | boolean | number | string | Array<ReplicaStoreJsonValue> | { [key: string]: ReplicaStoreJsonValue };" - )); - assert!( - REPLICA_SCHEMA_BINDINGS_TYPES_TS - .contains("export type IFarmFindOneResolve = ReplicaSchemaResult<Farm | null>;") - ); - assert!(actual.contains(&"ReplicaSchemaResult")); - assert!(actual.contains(&"ReplicaSchemaResultList")); - assert!(actual.contains(&"ReplicaSchemaResultPass")); - assert!(actual.contains(&"ReplicaSchemaError")); - assert!(trade_product_filter.contains("year?: bigint")); - assert!(trade_product_filter.contains("qty_avail?: bigint")); - assert!(trade_product_partial.contains("year?: ReplicaStoreJsonValue | null")); - assert!(trade_product_partial.contains("qty_avail?: ReplicaStoreJsonValue | null")); - } - - #[test] - fn replica_schema_generated_types_match_source_public_inventory() { - let actual = type_inventory(REPLICA_SCHEMA_BINDINGS_TYPES_TS) - .into_iter() - .collect::<BTreeSet<_>>(); - let missing = source_public_schema_type_inventory() - .into_iter() - .filter(|name| !actual.contains(name)) - .collect::<Vec<_>>(); - - assert!( - missing.is_empty(), - "missing generated replica schema exports: {missing:?}" - ); - } - - #[test] - fn trade_package_imports_source_owned_support_types() { - assert!(TRADE_BINDINGS_TYPES_TS.contains("from \"@radroots/core-bindings\"")); - assert!(TRADE_BINDINGS_TYPES_TS.contains("from \"@radroots/event-bindings\"")); - - let imports = imported_type_inventory(TRADE_BINDINGS_TYPES_TS); - assert!( - imports - .get("@radroots/event-bindings") - .is_some_and(|names| names.contains("RadrootsFarmRef")) - ); - assert!( - imports - .get("@radroots/event-bindings") - .is_some_and(|names| names.contains("RadrootsOperationalListing")) - ); - assert!( - imports - .get("@radroots/event-bindings") - .is_some_and(|names| names.contains("RadrootsOperationalListingBin")) - ); - - for duplicate in [ - "export type RadrootsOperationalListing = ", - "export type RadrootsFarmRef = ", - "export type RadrootsOrderEconomics = ", - "export type RadrootsOrderInventoryCommitment = ", - "export type RadrootsCommercialMessagePayload = ", - "export type RadrootsCommercialMessageType = ", - "export type RadrootsOrderStatus = ", - "export type RadrootsTradeOrderWorkflowMessage = ", - "export type RadrootsOrderWorkflowProjection = ", - "export type RadrootsTradeMarketplaceOrderSummary = ", - "export type RadrootsTradeOrderQuery = ", - ] { - assert!(!TRADE_BINDINGS_TYPES_TS.contains(duplicate)); - } - - let projection = type_declaration(TRADE_BINDINGS_TYPES_TS, "RadrootsTradeProjectionV1"); - assert!(projection.contains("agreement_state: RadrootsTradeAgreementStateV1")); - assert!(projection.contains("negotiation_state: RadrootsTradeNegotiationStateV1")); - assert!(projection.contains("agreement_claims: Array<RadrootsTradeAgreementClaimV1>")); - assert!(projection.contains("issues: Array<RadrootsTradeReducerIssueV1>")); - assert!(projection.contains("projection_digest: string")); - for stale_counter in [ - "root_event_id", - "last_message_type", - "last_discount_request", - "last_discount_offer", - "accepted_discount", - "last_discount_decline_reason", - "has_requested_discounts", - "question_count", - "answer_count", - "discount_request_count", - "discount_offer_count", - "discount_accept_count", - "discount_decline_count", - ] { - assert!(!projection.contains(stale_counter)); - } - } - - fn type_inventory(types_ts: &str) -> Vec<&str> { - types_ts - .lines() - .filter_map(|line| line.strip_prefix("export type ")) - .map(|rest| rest.split([' ', '<']).next().expect("type name")) - .collect() - } - - fn source_public_schema_type_inventory() -> Vec<&'static str> { - let mut names = BTreeSet::new(); - - for source in REPLICA_SCHEMA_MODEL_SOURCES { - for line in source.lines() { - if let Some(name) = public_rust_type_name(line) - && !name.ends_with("Ts") - { - names.insert(name); - } - } - } - - names.into_iter().collect() - } - - fn public_rust_type_name(line: &'static str) -> Option<&'static str> { - let line = line.trim_start(); - - ["pub struct ", "pub enum ", "pub type "] - .into_iter() - .find_map(|prefix| { - line.strip_prefix(prefix).map(|rest| { - rest.split(|char: char| !(char == '_' || char.is_ascii_alphanumeric())) - .next() - .expect("type name") - }) - }) - } - - fn type_declaration<'a>(types_ts: &'a str, name: &str) -> &'a str { - types_ts - .lines() - .find(|line| line.starts_with(&format!("export type {name} = "))) - .unwrap_or_else(|| panic!("missing type declaration for {name}")) - } - - fn span() -> SourceSpan { - SourceSpan::new("tools/xtask/src/dto_roots.rs", 1, 1) - } -} diff --git a/tools/xtask/src/fs.rs b/tools/xtask/src/fs.rs @@ -1,61 +0,0 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; - -pub fn workspace_root() -> Result<PathBuf, String> { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - manifest_dir - .parent() - .and_then(Path::parent) - .map(Path::to_path_buf) - .ok_or_else(|| { - format!( - "cannot resolve workspace root from {}", - manifest_dir.display() - ) - }) -} - -#[allow(dead_code)] -pub fn write_if_changed(path: &Path, contents: &str) -> Result<bool, String> { - if let Ok(existing) = fs::read_to_string(path) - && existing == contents - { - return Ok(false); - } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; - } - fs::write(path, contents) - .map_err(|error| format!("failed to write {}: {error}", path.display()))?; - Ok(true) -} - -pub fn write_bytes_if_changed(path: &Path, contents: &[u8]) -> Result<bool, String> { - if let Ok(existing) = fs::read(path) - && existing == contents - { - return Ok(false); - } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; - } - fs::write(path, contents) - .map_err(|error| format!("failed to write {}: {error}", path.display()))?; - Ok(true) -} - -#[cfg(test)] -mod tests { - use super::workspace_root; - - #[test] - fn resolves_workspace_root() { - let root = workspace_root().expect("workspace root resolves"); - assert!(root.join("Cargo.toml").is_file()); - assert!(root.join("packages").is_dir()); - } -} diff --git a/tools/xtask/src/generate.rs b/tools/xtask/src/generate.rs @@ -1,35 +0,0 @@ -use crate::{ - fs::workspace_root, output::package_outputs, package_matrix::validate_package_matrix, - package_metadata, -}; - -pub fn generate_all() -> Result<(), String> { - generate_ts()?; - generate_package_metadata() -} - -pub fn generate_ts() -> Result<(), String> { - validate_package_matrix()?; - let root = workspace_root()?; - for output in package_outputs()? { - for generated_file in output.files() { - let path = root - .join(output.spec.package_dir) - .join(generated_file.relative_path); - crate::fs::write_if_changed(&path, &generated_file.contents)?; - } - let provenance_file = output.provenance_file(); - crate::fs::write_if_changed( - &root.join(&provenance_file.relative_path), - &provenance_file.contents, - )?; - println!("generated TypeScript package {}", output.spec.package_name); - } - Ok(()) -} - -pub fn generate_package_metadata() -> Result<(), String> { - validate_package_matrix()?; - let root = workspace_root()?; - package_metadata::generate_package_metadata(&root) -} diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -1,281 +0,0 @@ -mod api_qualification; -mod architecture; -mod bindings; -mod check; -mod contracts; -mod coverage; -mod coverage_policy; -#[allow(dead_code)] -mod dto_roots; -mod fs; -mod generate; -mod manifest; -mod output; -mod package_matrix; -mod package_metadata; -mod portable_qualification; -mod release_graph; -mod release_qualification; -mod smoke; -mod supply_chain_qualification; -mod target_qualification; -mod ts; -mod wasm; -mod wasm_declarations; - -enum CommandAction<'a> { - Architecture, - ArchitectureCi, - CheckApiBoundaries, - CheckDependencyBoundaries, - GenerateAll, - GenerateBindings(&'a [String]), - GenerateTs, - GenerateWasm(&'a [String]), - GeneratePackageMetadata, - Coverage(&'a [String]), - QualifyFeatures, - QualifyGraph, - QualifyApi, - QualifyPortable, - QualifySupplyChain, - QualifyTargets, - Check, - Smoke(&'a [String]), -} - -fn main() { - if let Err(error) = run(std::env::args().skip(1)) { - eprintln!("{error}"); - std::process::exit(1); - } -} - -fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> { - let args = args.into_iter().collect::<Vec<_>>(); - match command_action(&args)? { - CommandAction::Architecture => architecture::validate(&fs::workspace_root()?), - CommandAction::ArchitectureCi => { - let root = fs::workspace_root()?; - architecture::validate_ci(&root)?; - check::architecture_ci(&root) - } - CommandAction::CheckApiBoundaries => { - architecture::validate_api_boundaries(&fs::workspace_root()?) - } - CommandAction::CheckDependencyBoundaries => { - architecture::validate_dependency_boundaries(&fs::workspace_root()?) - } - CommandAction::GenerateAll => generate::generate_all(), - CommandAction::GenerateBindings(rest) => bindings::generate(rest), - CommandAction::GenerateTs => generate::generate_ts(), - CommandAction::GenerateWasm(rest) => wasm::generate(rest), - CommandAction::GeneratePackageMetadata => generate::generate_package_metadata(), - CommandAction::Coverage(rest) => coverage::run(rest), - CommandAction::QualifyFeatures => { - release_qualification::run_feature_matrix(&fs::workspace_root()?) - } - CommandAction::QualifyGraph => release_graph::run(&fs::workspace_root()?), - CommandAction::QualifyApi => api_qualification::run(&fs::workspace_root()?), - CommandAction::QualifyPortable => portable_qualification::run(&fs::workspace_root()?), - CommandAction::QualifySupplyChain => { - supply_chain_qualification::run(&fs::workspace_root()?) - } - CommandAction::QualifyTargets => target_qualification::run(&fs::workspace_root()?), - CommandAction::Check => check::check(), - CommandAction::Smoke(rest) => smoke::run(rest), - } -} - -fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> { - match args { - [command] if command == "architecture" => Ok(CommandAction::Architecture), - [command] if command == "architecture-ci" => Ok(CommandAction::ArchitectureCi), - [command] if command == "check-api-boundaries" => Ok(CommandAction::CheckApiBoundaries), - [command] if command == "check-dependency-boundaries" => { - Ok(CommandAction::CheckDependencyBoundaries) - } - [command] if command == "generate" => Ok(CommandAction::GenerateAll), - [command, target] if command == "generate" && target == "ts" => { - Ok(CommandAction::GenerateTs) - } - [command, target, rest @ ..] if command == "generate" && target == "wasm" => { - Ok(CommandAction::GenerateWasm(rest)) - } - [command, target, rest @ ..] if command == "generate" && target == "bindings" => { - Ok(CommandAction::GenerateBindings(rest)) - } - [command, target] if command == "generate" && target == "package-metadata" => { - Ok(CommandAction::GeneratePackageMetadata) - } - [command, rest @ ..] if command == "coverage" => Ok(CommandAction::Coverage(rest)), - [command, target] if command == "release" && target == "qualify-features" => { - Ok(CommandAction::QualifyFeatures) - } - [command, target] if command == "release" && target == "qualify-graph" => { - Ok(CommandAction::QualifyGraph) - } - [command, target] if command == "release" && target == "qualify-api" => { - Ok(CommandAction::QualifyApi) - } - [command, target] if command == "release" && target == "qualify-portable" => { - Ok(CommandAction::QualifyPortable) - } - [command, target] if command == "release" && target == "qualify-supply-chain" => { - Ok(CommandAction::QualifySupplyChain) - } - [command, target] if command == "release" && target == "qualify-targets" => { - Ok(CommandAction::QualifyTargets) - } - [command] if command == "check" => Ok(CommandAction::Check), - [command, rest @ ..] if command == "smoke" => Ok(CommandAction::Smoke(rest)), - [] => Err(usage()), - _ => Err(usage()), - } -} - -fn usage() -> String { - "usage: cargo xtask architecture | cargo xtask architecture-ci | cargo xtask check-api-boundaries | cargo xtask check-dependency-boundaries | cargo xtask generate | cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate bindings <swift|kotlin> | cargo xtask generate package-metadata | cargo xtask check | cargo xtask smoke facade-rust-local | sdk-rust-local | front-doors-rust-local | cargo xtask coverage run | cargo xtask coverage check | cargo xtask release qualify-features | cargo xtask release qualify-graph | cargo xtask release qualify-api | cargo xtask release qualify-portable | cargo xtask release qualify-supply-chain | cargo xtask release qualify-targets" - .to_owned() -} - -#[cfg(test)] -mod tests { - use super::{CommandAction, command_action}; - - #[test] - fn accepts_architecture() { - let args = ["architecture".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::Architecture - )); - } - - #[test] - fn accepts_architecture_ci() { - let args = ["architecture-ci".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::ArchitectureCi - )); - } - - #[test] - fn accepts_api_boundary_check() { - let args = ["check-api-boundaries".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::CheckApiBoundaries - )); - } - - #[test] - fn accepts_dependency_boundary_check() { - let args = ["check-dependency-boundaries".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::CheckDependencyBoundaries - )); - } - - #[test] - fn accepts_generate_ts() { - let args = ["generate".to_owned(), "ts".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::GenerateTs - )); - } - - #[test] - fn accepts_generate_all() { - let args = ["generate".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::GenerateAll - )); - } - - #[test] - fn accepts_generate_wasm() { - let args = ["generate".to_owned(), "wasm".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::GenerateWasm(rest) if rest.is_empty() - )); - } - - #[test] - fn accepts_generate_bindings() { - let args = [ - "generate".to_owned(), - "bindings".to_owned(), - "swift".to_owned(), - ]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::GenerateBindings(rest) if rest == ["swift"] - )); - } - - #[test] - fn accepts_generate_package_metadata() { - let args = ["generate".to_owned(), "package-metadata".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::GeneratePackageMetadata - )); - } - - #[test] - fn accepts_check() { - let args = ["check".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::Check - )); - } - - #[test] - fn accepts_smoke() { - let args = ["smoke".to_owned(), "sdk-rust-local".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::Smoke(rest) if rest == ["sdk-rust-local"] - )); - } - - #[test] - fn accepts_coverage_run() { - let args = ["coverage".to_owned(), "run".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::Coverage(rest) if rest == ["run"] - )); - } - - #[test] - fn accepts_supply_chain_qualification() { - let args = ["release".to_owned(), "qualify-supply-chain".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::QualifySupplyChain - )); - } - - #[test] - fn accepts_release_graph_qualification() { - let args = ["release".to_owned(), "qualify-graph".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::QualifyGraph - )); - } - - #[test] - fn rejects_unknown_command() { - let args = ["generate".to_owned(), "swift".to_owned()]; - assert!(command_action(&args).is_err()); - } -} diff --git a/tools/xtask/src/manifest.rs b/tools/xtask/src/manifest.rs @@ -1,53 +0,0 @@ -use serde_json::json; - -use crate::package_matrix::PackageSpec; - -pub fn manifest_relative_path(spec: PackageSpec) -> String { - format!("contracts/provenance/typescript/{}.json", spec.key) -} - -pub fn package_manifest(spec: PackageSpec) -> serde_json::Value { - json!({ - "package": spec.package_name, - "crate": spec.crate_name, - "version": "0.1.0", - "license": "MIT OR Apache-2.0", - "repository": "https://github.com/radrootslabs/sdk", - "repository_directory": spec.package_dir, - "generator": "radroots_sdk_xtask", - "generated": false - }) -} - -#[cfg(test)] -mod tests { - use crate::{ - manifest::{manifest_relative_path, package_manifest}, - package_matrix::package_specs, - }; - - #[test] - fn manifest_path_is_outside_package_source() { - assert_eq!( - manifest_relative_path(package_specs()[0]), - "contracts/provenance/typescript/core.json" - ); - } - - #[test] - fn manifest_records_package_and_crate() { - let manifest = package_manifest(package_specs()[0]); - assert_eq!(manifest["package"], package_specs()[0].package_name); - assert_eq!(manifest["crate"], package_specs()[0].crate_name); - assert_eq!(manifest["version"], "0.1.0"); - assert_eq!(manifest["license"], "MIT OR Apache-2.0"); - assert_eq!( - manifest["repository"], - "https://github.com/radrootslabs/sdk" - ); - assert_eq!( - manifest["repository_directory"], - package_specs()[0].package_dir - ); - } -} diff --git a/tools/xtask/src/output.rs b/tools/xtask/src/output.rs @@ -1,493 +0,0 @@ -use dto_bindgen_backend_ts::{DtoTypesModule, TypeScriptImport, TypeScriptModule}; -use sha2::{Digest, Sha256}; - -use crate::{ - dto_roots, - manifest::manifest_relative_path, - manifest::package_manifest, - package_matrix::{PackageSpec, package_specs}, - ts::{generated_constants_file, generated_header, generated_kinds_file, generated_types_file}, -}; - -pub struct PackageOutput { - pub spec: PackageSpec, - pub types_ts: Option<TsSource>, - pub types_imports: Vec<TypeScriptImport>, - pub constants_ts: Option<TsSource>, - pub kinds_ts: Option<TsSource>, -} - -pub struct GeneratedFile { - pub relative_path: String, - pub contents: String, -} - -pub enum TsSource { - DtoRegistry(DtoTypesModule), - Module(TypeScriptModule), - CanonicalNativeSnapshot { contents: &'static str }, -} - -impl TsSource { - fn canonical_native_snapshot( - contents: &'static str, - sha256: &'static str, - source_packages: &'static [&'static str], - ) -> Result<Self, String> { - let actual = format!("{:x}", Sha256::digest(contents.as_bytes())); - if actual != sha256 { - return Err(format!( - "canonical native type snapshot drifted: expected {sha256}, found {actual}; regenerate from and review against {}", - source_packages.join(", ") - )); - } - Ok(Self::CanonicalNativeSnapshot { contents }) - } - - fn render(&self) -> String { - match self { - Self::DtoRegistry(module) => module.body_ts().to_owned(), - Self::Module(module) => module.render_source(), - Self::CanonicalNativeSnapshot { contents } => (*contents).to_owned(), - } - } - - fn imports(&self) -> Option<&str> { - match self { - Self::DtoRegistry(module) => module.imports_ts(), - Self::Module(_) | Self::CanonicalNativeSnapshot { .. } => None, - } - } -} - -const EVENT_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/event-bindings/src/generated/types.ts"); -const EVENT_BINDINGS_TYPES_SHA256: &str = - "fe4496950852715c573ff2abc7df8edede7161de75d9c68c3f4d6836e67f829a"; -const TRADE_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/trade-bindings/src/generated/types.ts"); -const TRADE_BINDINGS_TYPES_SHA256: &str = - "49560c572206095bd7de5cfd377c234a7f136e4affc13d53516ae2c4b8998e87"; -const EVENT_NATIVE_SOURCES: &[&str] = &["radroots_event", "radroots_core"]; -const TRADE_NATIVE_SOURCES: &[&str] = &["radroots_trade", "radroots_event", "radroots_core"]; - -impl PackageOutput { - pub fn files(&self) -> Vec<GeneratedFile> { - let mut files = Vec::new(); - if let Some(types_ts) = &self.types_ts { - let imports = combined_imports( - structured_imports_ts(&self.types_imports).as_deref(), - types_ts.imports(), - ); - files.push(GeneratedFile { - relative_path: format!("src/generated/{}", generated_types_file()), - contents: render_ts(types_ts, imports.as_deref()), - }); - } - if let Some(constants_ts) = &self.constants_ts { - files.push(GeneratedFile { - relative_path: format!("src/generated/{}", generated_constants_file()), - contents: render_ts(constants_ts, None), - }); - } - if let Some(kinds_ts) = &self.kinds_ts { - files.push(GeneratedFile { - relative_path: format!("src/generated/{}", generated_kinds_file()), - contents: render_ts(kinds_ts, None), - }); - } - files - } - - pub fn provenance_file(&self) -> GeneratedFile { - GeneratedFile { - relative_path: manifest_relative_path(self.spec), - contents: render_manifest(self), - } - } -} - -pub fn package_outputs() -> Result<Vec<PackageOutput>, String> { - Ok(vec![ - PackageOutput { - spec: spec_by_key("core"), - types_ts: Some(TsSource::DtoRegistry(dto_roots::core_types_module()?)), - types_imports: Vec::new(), - constants_ts: None, - kinds_ts: None, - }, - PackageOutput { - spec: spec_by_key("event"), - types_ts: Some(TsSource::canonical_native_snapshot( - EVENT_BINDINGS_TYPES_TS, - EVENT_BINDINGS_TYPES_SHA256, - EVENT_NATIVE_SOURCES, - )?), - types_imports: Vec::new(), - constants_ts: Some(TsSource::Module(radroots_event_bindings::constants_module())), - kinds_ts: Some(TsSource::Module(radroots_event_bindings::kinds_module())), - }, - PackageOutput { - spec: spec_by_key("identity"), - types_ts: None, - types_imports: Vec::new(), - constants_ts: Some(TsSource::Module( - radroots_identity_bindings::constants_module(), - )), - kinds_ts: None, - }, - PackageOutput { - spec: spec_by_key("replica_schema"), - types_ts: Some(TsSource::DtoRegistry( - dto_roots::replica_schema_types_module()?, - )), - types_imports: Vec::new(), - constants_ts: None, - kinds_ts: None, - }, - PackageOutput { - spec: spec_by_key("trade"), - types_ts: Some(TsSource::canonical_native_snapshot( - TRADE_BINDINGS_TYPES_TS, - TRADE_BINDINGS_TYPES_SHA256, - TRADE_NATIVE_SOURCES, - )?), - types_imports: Vec::new(), - constants_ts: None, - kinds_ts: None, - }, - ]) -} - -fn spec_by_key(key: &str) -> PackageSpec { - package_specs() - .iter() - .copied() - .find(|spec| spec.key == key) - .unwrap_or_else(|| panic!("missing package spec for {key}")) -} - -fn render_ts(source: &TsSource, imports: Option<&str>) -> String { - if matches!(source, TsSource::CanonicalNativeSnapshot { .. }) { - return source.render(); - } - let body = source.render(); - let imports = imports.unwrap_or(""); - let mut rendered = format!("{}{}{}", generated_header(), imports, body.trim_start()); - if !rendered.ends_with('\n') { - rendered.push('\n'); - } - rendered -} - -fn combined_imports(first: Option<&str>, second: Option<&str>) -> Option<String> { - match (first, second) { - (Some(first), Some(second)) => Some(format!("{first}{second}")), - (Some(first), None) => Some(first.to_owned()), - (None, Some(second)) => Some(second.to_owned()), - (None, None) => None, - } -} - -fn structured_imports_ts(imports: &[TypeScriptImport]) -> Option<String> { - if imports.is_empty() { - return None; - } - Some( - imports - .iter() - .cloned() - .fold(TypeScriptModule::new("types.ts"), |module, import| { - module.with_import(import) - }) - .render_source(), - ) -} - -fn render_manifest(output: &PackageOutput) -> String { - let spec = output.spec; - let mut value = package_manifest(spec); - value["generated"] = serde_json::Value::Bool(true); - value["outputs"] = serde_json::Value::Object( - output - .files() - .into_iter() - .map(|file| { - ( - file.relative_path, - serde_json::Value::String(format!( - "{:x}", - Sha256::digest(file.contents.as_bytes()) - )), - ) - }) - .collect(), - ); - if matches!(spec.key, "event" | "trade") { - let (sha256, source_packages) = match spec.key { - "event" => (EVENT_BINDINGS_TYPES_SHA256, EVENT_NATIVE_SOURCES), - "trade" => (TRADE_BINDINGS_TYPES_SHA256, TRADE_NATIVE_SOURCES), - _ => unreachable!(), - }; - value["types_source"] = - serde_json::Value::String("canonical_native_type_snapshot".to_owned()); - value["types_sha256"] = serde_json::Value::String(sha256.to_owned()); - value["types_source_packages"] = serde_json::Value::Array( - source_packages - .iter() - .map(|package| serde_json::Value::String((*package).to_owned())) - .collect(), - ); - } - format!( - "{}\n", - serde_json::to_string_pretty(&value).expect("manifest json serializes") - ) -} - -#[cfg(test)] -mod tests { - use super::{PackageOutput, TsSource, package_outputs, render_ts}; - use crate::package_matrix::package_specs; - use dto_bindgen_backend_ts::{ - DtoTypesModule, TypeScriptDeclaration, TypeScriptImport, TypeScriptModule, TypeScriptType, - }; - - const TRADE_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/trade-bindings/src/generated/types.ts"); - const REPLICA_SCHEMA_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/replica-schema-bindings/src/generated/types.ts"); - const EVENT_BINDINGS_CONSTANTS_TS: &str = - include_str!("../../../packages/event-bindings/src/generated/constants.ts"); - const EVENT_BINDINGS_KINDS_TS: &str = - include_str!("../../../packages/event-bindings/src/generated/kinds.ts"); - const IDENTITY_BINDINGS_CONSTANTS_TS: &str = - include_str!("../../../packages/identity-bindings/src/generated/constants.ts"); - - #[test] - fn renders_sdk_header() { - let output = render_ts(&test_module(), None); - assert!(output.starts_with("// @generated by cargo xtask generate ts")); - assert!(output.contains("export type A = string;")); - } - - #[test] - fn renders_import_prelude_after_header() { - let output = render_ts(&test_module(), Some("import type { B } from \"b\";\n\n")); - assert!(output.starts_with( - "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type" - )); - assert!(output.contains("export type A = string;")); - } - - #[test] - fn renders_module_sources() { - let output = render_ts(&test_module(), None); - assert_eq!( - output, - "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nexport type A = string;\n" - ); - } - - #[test] - fn canonical_native_snapshot_rejects_digest_drift() { - let error = match TsSource::canonical_native_snapshot( - "drifted snapshot\n", - super::EVENT_BINDINGS_TYPES_SHA256, - super::EVENT_NATIVE_SOURCES, - ) { - Ok(_) => panic!("digest drift must fail closed"), - Err(error) => error, - }; - - assert!(error.contains("canonical native type snapshot drifted")); - assert!(error.contains(super::EVENT_BINDINGS_TYPES_SHA256)); - assert!(error.contains("radroots_event")); - } - - #[test] - fn includes_core_and_schema_outputs() { - let package_names = package_outputs() - .expect("package outputs") - .into_iter() - .map(|output| output.spec.package_name) - .collect::<Vec<_>>(); - assert!(package_names.contains(&"@radroots/core-bindings")); - assert!(package_names.contains(&"@radroots/event-bindings")); - assert!(package_names.contains(&"@radroots/identity-bindings")); - assert!(package_names.contains(&"@radroots/replica-schema-bindings")); - assert!(package_names.contains(&"@radroots/trade-bindings")); - } - - #[test] - fn dto_registry_source_uses_generated_package_files() { - let output = PackageOutput { - spec: package_specs()[0], - types_ts: Some(TsSource::DtoRegistry(DtoTypesModule::new( - "import type { ExternalThing } from \"@radroots/external-bindings\";\n\n", - "export type SyntheticThing = { external: ExternalThing, };", - ))), - types_imports: vec![TypeScriptImport::type_only( - ["LocalPrelude"], - "@radroots/local", - )], - constants_ts: None, - kinds_ts: None, - }; - let files = output.files(); - let types = files - .iter() - .find(|file| file.relative_path == "src/generated/types.ts") - .expect("types file"); - let manifest = output.provenance_file(); - - assert_eq!( - types.contents, - "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type { LocalPrelude } from \"@radroots/local\";\nimport type { ExternalThing } from \"@radroots/external-bindings\";\n\nexport type SyntheticThing = { external: ExternalThing, };\n" - ); - assert_eq!( - manifest.relative_path, - "contracts/provenance/typescript/core.json" - ); - assert!(manifest.contents.contains("\"generated\": true")); - assert!( - !files - .iter() - .any(|file| file.relative_path == "src/index.ts") - ); - } - - #[test] - fn package_outputs_do_not_generate_package_indices() { - for output in package_outputs().expect("package outputs") { - assert!( - !output - .files() - .iter() - .any(|file| file.relative_path == "src/index.ts"), - "{} index must remain handwritten source", - output.spec.package_name - ); - } - } - - #[test] - fn trade_output_uses_canonical_native_snapshot_and_matches_checked_in_types() { - let output = package_outputs() - .expect("package outputs") - .into_iter() - .find(|output| output.spec.key == "trade") - .expect("trade output"); - - assert!(matches!( - output.types_ts, - Some(TsSource::CanonicalNativeSnapshot { .. }) - )); - assert!(output.types_imports.is_empty()); - - let types = output - .files() - .into_iter() - .find(|file| file.relative_path == "src/generated/types.ts") - .expect("types file"); - - assert_eq!(types.contents, TRADE_BINDINGS_TYPES_TS); - } - - #[test] - fn event_output_uses_canonical_native_snapshot_and_matches_checked_in_types() { - let output = package_outputs() - .expect("package outputs") - .into_iter() - .find(|output| output.spec.key == "event") - .expect("event output"); - - assert!(matches!( - output.types_ts, - Some(TsSource::CanonicalNativeSnapshot { .. }) - )); - let types = output - .files() - .into_iter() - .find(|file| file.relative_path == "src/generated/types.ts") - .expect("types file"); - assert_eq!(types.contents, super::EVENT_BINDINGS_TYPES_TS); - - let manifest = output.provenance_file(); - assert!(manifest.contents.contains("canonical_native_type_snapshot")); - assert!(manifest.contents.contains("radroots_event")); - assert!(manifest.contents.contains("radroots_core")); - } - - #[test] - fn replica_schema_output_uses_dto_registry_and_matches_checked_in_types() { - let output = package_outputs() - .expect("package outputs") - .into_iter() - .find(|output| output.spec.key == "replica_schema") - .expect("replica_schema output"); - - assert!(matches!(output.types_ts, Some(TsSource::DtoRegistry(_)))); - assert!(output.types_imports.is_empty()); - - let types = output - .files() - .into_iter() - .find(|file| file.relative_path == "src/generated/types.ts") - .expect("types file"); - - assert_eq!(types.contents, REPLICA_SCHEMA_BINDINGS_TYPES_TS); - } - - #[test] - fn events_constants_and_kinds_use_modules_and_match_checked_in_files() { - let output = package_outputs() - .expect("package outputs") - .into_iter() - .find(|output| output.spec.key == "event") - .expect("event output"); - - assert!(matches!(output.constants_ts, Some(TsSource::Module(_)))); - assert!(matches!(output.kinds_ts, Some(TsSource::Module(_)))); - - let files = output.files(); - let constants = files - .iter() - .find(|file| file.relative_path == "src/generated/constants.ts") - .expect("constants file"); - let kinds = files - .iter() - .find(|file| file.relative_path == "src/generated/kinds.ts") - .expect("kinds file"); - - assert_eq!(constants.contents, EVENT_BINDINGS_CONSTANTS_TS); - assert_eq!(kinds.contents, EVENT_BINDINGS_KINDS_TS); - } - - #[test] - fn identity_constants_use_module_and_match_checked_in_file() { - let output = package_outputs() - .expect("package outputs") - .into_iter() - .find(|output| output.spec.key == "identity") - .expect("identity output"); - - assert!(matches!(output.constants_ts, Some(TsSource::Module(_)))); - - let constants = output - .files() - .into_iter() - .find(|file| file.relative_path == "src/generated/constants.ts") - .expect("constants file"); - - assert_eq!(constants.contents, IDENTITY_BINDINGS_CONSTANTS_TS); - } - - fn test_module() -> TsSource { - TsSource::Module( - TypeScriptModule::new("src/generated/test.ts").with_declaration( - TypeScriptDeclaration::type_alias("A", TypeScriptType::String), - ), - ) - } -} diff --git a/tools/xtask/src/package_matrix.rs b/tools/xtask/src/package_matrix.rs @@ -1,199 +0,0 @@ -use std::collections::BTreeSet; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct PackageSpec { - pub key: &'static str, - pub crate_name: &'static str, - pub crate_dir: &'static str, - pub package_name: &'static str, - pub package_dir: &'static str, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct WasmPackageSpec { - pub key: &'static str, - pub crate_name: &'static str, - pub crate_dir: &'static str, - pub package_name: &'static str, - pub package_dir: &'static str, - pub out_name: &'static str, - pub out_dir: &'static str, -} - -pub const PACKAGE_SPECS: [PackageSpec; 5] = [ - PackageSpec { - key: "core", - crate_name: "radroots_core_bindings", - crate_dir: "crates/core_bindings", - package_name: "@radroots/core-bindings", - package_dir: "packages/core-bindings", - }, - PackageSpec { - key: "event", - crate_name: "radroots_event_bindings", - crate_dir: "crates/event_bindings", - package_name: "@radroots/event-bindings", - package_dir: "packages/event-bindings", - }, - PackageSpec { - key: "identity", - crate_name: "radroots_identity_bindings", - crate_dir: "crates/identity_bindings", - package_name: "@radroots/identity-bindings", - package_dir: "packages/identity-bindings", - }, - PackageSpec { - key: "replica_schema", - crate_name: "radroots_replica_schema_bindings", - crate_dir: "crates/replica_schema_bindings", - package_name: "@radroots/replica-schema-bindings", - package_dir: "packages/replica-schema-bindings", - }, - PackageSpec { - key: "trade", - crate_name: "radroots_trade_bindings", - crate_dir: "crates/trade_bindings", - package_name: "@radroots/trade-bindings", - package_dir: "packages/trade-bindings", - }, -]; - -pub const WASM_PACKAGE_SPECS: [WasmPackageSpec; 3] = [ - WasmPackageSpec { - key: "event_codec", - crate_name: "radroots_event_codec_wasm", - crate_dir: "crates/event_codec_wasm", - package_name: "@radroots/event-codec-wasm", - package_dir: "packages/event-codec-wasm", - out_name: "radroots_event_codec_wasm", - out_dir: "../../packages/event-codec-wasm/dist", - }, - WasmPackageSpec { - key: "replica_store", - crate_name: "radroots_replica_store_wasm", - crate_dir: "crates/replica_store_wasm", - package_name: "@radroots/replica-store-wasm", - package_dir: "packages/replica-store-wasm", - out_name: "radroots_replica_store_wasm", - out_dir: "../../packages/replica-store-wasm/dist", - }, - WasmPackageSpec { - key: "replica_sync", - crate_name: "radroots_replica_sync_wasm", - crate_dir: "crates/replica_sync_wasm", - package_name: "@radroots/replica-sync-wasm", - package_dir: "packages/replica-sync-wasm", - out_name: "radroots_replica_sync_wasm", - out_dir: "../../packages/replica-sync-wasm/dist", - }, -]; - -pub const FORBIDDEN_PACKAGE_NAMES: [&str; 2] = - ["@radroots/tangle-db-schema-bindings", "@radroots/contracts"]; - -pub fn package_specs() -> &'static [PackageSpec] { - &PACKAGE_SPECS -} - -pub fn wasm_package_specs() -> &'static [WasmPackageSpec] { - &WASM_PACKAGE_SPECS -} - -pub fn validate_package_matrix() -> Result<(), String> { - let mut crate_names = BTreeSet::new(); - let mut package_names = BTreeSet::new(); - let mut package_dirs = BTreeSet::new(); - for spec in package_specs() { - if FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name) { - return Err(format!( - "forbidden package in matrix: {}", - spec.package_name - )); - } - if !crate_names.insert(spec.crate_name) { - return Err(format!("duplicate crate in matrix: {}", spec.crate_name)); - } - if !package_names.insert(spec.package_name) { - return Err(format!( - "duplicate package in matrix: {}", - spec.package_name - )); - } - if !package_dirs.insert(spec.package_dir) { - return Err(format!( - "duplicate package directory in matrix: {}", - spec.package_dir - )); - } - } - for spec in wasm_package_specs() { - if FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name) { - return Err(format!( - "forbidden package in matrix: {}", - spec.package_name - )); - } - if !crate_names.insert(spec.crate_name) { - return Err(format!("duplicate crate in matrix: {}", spec.crate_name)); - } - if !package_names.insert(spec.package_name) { - return Err(format!( - "duplicate package in matrix: {}", - spec.package_name - )); - } - if !package_dirs.insert(spec.package_dir) { - return Err(format!( - "duplicate package directory in matrix: {}", - spec.package_dir - )); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{ - FORBIDDEN_PACKAGE_NAMES, package_specs, validate_package_matrix, wasm_package_specs, - }; - - #[test] - fn package_matrix_is_valid() { - validate_package_matrix().expect("package matrix is valid"); - } - - #[test] - fn approved_package_count_is_stable() { - assert_eq!(package_specs().len(), 5); - assert_eq!(wasm_package_specs().len(), 3); - } - - #[test] - fn forbidden_names_are_absent() { - for spec in package_specs() { - assert!(!FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name)); - } - for spec in wasm_package_specs() { - assert!(!FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name)); - } - } - - #[test] - fn replica_schema_package_uses_current_name() { - assert!( - package_specs() - .iter() - .any(|spec| spec.package_name == "@radroots/replica-schema-bindings") - ); - } - - #[test] - fn wasm_packages_use_sdk_package_names() { - assert!( - wasm_package_specs() - .iter() - .any(|spec| spec.package_name == "@radroots/replica-store-wasm") - ); - } -} diff --git a/tools/xtask/src/package_metadata.rs b/tools/xtask/src/package_metadata.rs @@ -1,181 +0,0 @@ -use std::{fs, path::Path}; - -use crate::{ - fs::{write_bytes_if_changed, write_if_changed}, - package_matrix::{package_specs, wasm_package_specs}, -}; - -pub(crate) const PACKAGE_README_FILE: &str = "README.md"; -pub(crate) const PACKAGE_LICENSE_FILES: [&str; 2] = ["LICENSE-MIT", "LICENSE-APACHE"]; -pub(crate) const PACKAGE_FILES: [&str; 4] = [ - "dist", - PACKAGE_README_FILE, - PACKAGE_LICENSE_FILES[0], - PACKAGE_LICENSE_FILES[1], -]; - -pub(crate) fn generate_package_metadata(root: &Path) -> Result<(), String> { - for spec in package_specs() { - write_package_metadata(root, &root.join(spec.package_dir))?; - println!("generated package metadata {}", spec.package_name); - } - for spec in wasm_package_specs() { - write_package_metadata(root, &root.join(spec.package_dir))?; - println!("generated package metadata {}", spec.package_name); - } - Ok(()) -} - -pub(crate) fn check_package_distribution_metadata( - root: &Path, - package_dir: &Path, - package_json_path: &Path, - json: &serde_json::Value, -) -> Result<(), String> { - let package_name = package_name(json, package_json_path)?; - let description = package_description(json, package_json_path)?; - let readme_path = package_dir.join(PACKAGE_README_FILE); - let expected_readme = package_readme(package_name, description); - check_text_file(&readme_path, &expected_readme, "stale package README")?; - for file_name in PACKAGE_LICENSE_FILES { - let source_path = root.join(file_name); - let package_path = package_dir.join(file_name); - let expected = fs::read(&source_path) - .map_err(|error| format!("failed to read {}: {error}", source_path.display()))?; - let actual = fs::read(&package_path) - .map_err(|error| format!("failed to read {}: {error}", package_path.display()))?; - if actual != expected { - return Err(format!( - "stale package license metadata: {}", - package_path.display() - )); - } - } - Ok(()) -} - -pub(crate) fn package_description<'a>( - json: &'a serde_json::Value, - package_json_path: &Path, -) -> Result<&'a str, String> { - let description = json - .get("description") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing description: {}", - package_json_path.display() - ) - })?; - if description.trim().is_empty() || description.trim() != description { - return Err(format!( - "package.json description must be non-empty and trimmed: {}", - package_json_path.display() - )); - } - Ok(description) -} - -pub(crate) fn package_readme(package_name: &str, description: &str) -> String { - format!( - "# {package_name}\n\n{description}\n\nThis package publishes generated ESM JavaScript, TypeScript declarations, and any runtime artifacts from the Radroots SDK build pipeline. Runtime files are distributed from `dist/`; source and provenance metadata are kept outside the npm package payload.\n\n## License\n\nLicensed under either MIT or Apache-2.0, at your option. See `LICENSE-MIT` and `LICENSE-APACHE`.\n" - ) -} - -fn write_package_metadata(root: &Path, package_dir: &Path) -> Result<bool, String> { - let package_json_path = package_dir.join("package.json"); - let json = read_package_json(&package_json_path)?; - let package_name = package_name(&json, &package_json_path)?; - let description = package_description(&json, &package_json_path)?; - let mut changed = write_if_changed( - &package_dir.join(PACKAGE_README_FILE), - &package_readme(package_name, description), - )?; - for file_name in PACKAGE_LICENSE_FILES { - let source_path = root.join(file_name); - let contents = fs::read(&source_path) - .map_err(|error| format!("failed to read {}: {error}", source_path.display()))?; - changed |= write_bytes_if_changed(&package_dir.join(file_name), &contents)?; - } - Ok(changed) -} - -fn read_package_json(path: &Path) -> Result<serde_json::Value, String> { - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - serde_json::from_str::<serde_json::Value>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display())) -} - -fn package_name<'a>( - json: &'a serde_json::Value, - package_json_path: &Path, -) -> Result<&'a str, String> { - json.get("name") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| format!("package.json missing name: {}", package_json_path.display())) -} - -fn check_text_file(path: &Path, expected: &str, label: &str) -> Result<(), String> { - let actual = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - if actual != expected { - return Err(format!("{label}: {}", path.display())); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::{ - fs, - path::PathBuf, - time::{SystemTime, UNIX_EPOCH}, - }; - - use super::{package_readme, write_package_metadata}; - - #[test] - fn package_metadata_generation_is_deterministic() { - let root = test_root("metadata_generation"); - let package_dir = root.join("packages/example"); - fs::create_dir_all(&package_dir).expect("create package"); - fs::write(root.join("LICENSE-MIT"), "MIT license\n").expect("write MIT license"); - fs::write(root.join("LICENSE-APACHE"), "Apache license\n").expect("write Apache license"); - fs::write( - package_dir.join("package.json"), - r#"{ - "name": "@radroots/example", - "description": "Example package" -}"#, - ) - .expect("write package json"); - - assert!(write_package_metadata(&root, &package_dir).expect("first generation")); - assert_eq!( - fs::read_to_string(package_dir.join("README.md")).expect("read README"), - package_readme("@radroots/example", "Example package") - ); - assert_eq!( - fs::read_to_string(package_dir.join("LICENSE-MIT")).expect("read MIT"), - "MIT license\n" - ); - assert!(!write_package_metadata(&root, &package_dir).expect("second generation")); - - let _ = fs::remove_dir_all(root); - } - - fn test_root(name: &str) -> PathBuf { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time after epoch") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "radroots_sdk_xtask_package_metadata_{name}_{}_{}", - std::process::id(), - stamp - )); - let _ = fs::remove_dir_all(&root); - root - } -} diff --git a/tools/xtask/src/portable_qualification.rs b/tools/xtask/src/portable_qualification.rs @@ -1,58 +0,0 @@ -use std::{fs, path::Path}; - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -struct PortableMatrix { - schema_version: u32, - no_std_target: String, - no_std_cross_compiler: String, - no_std_cross_cflags: String, - wasm_target: String, - portable_public_packages: Vec<String>, - std_only_public_packages: Vec<String>, -} - -pub fn run(workspace_root: &Path) -> Result<(), String> { - load(workspace_root).map(|_| ()) -} - -fn load(workspace_root: &Path) -> Result<PortableMatrix, String> { - let path = workspace_root.join("contracts/releases/portable_matrix.toml"); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let matrix = toml::from_str::<PortableMatrix>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - if matrix.schema_version != 1 - || matrix.no_std_target != "thumbv7em-none-eabihf" - || matrix.no_std_cross_compiler != "zig cc" - || matrix.no_std_cross_cflags != "-target thumb-freestanding-eabihf -mcpu=cortex_m4" - || matrix.wasm_target != "wasm32-unknown-unknown" - || !matrix.portable_public_packages.is_empty() - || matrix.std_only_public_packages != ["radroots", "radroots_sdk"] - { - return Err( - "SDK portability contract must classify both public front doors as std-only".to_owned(), - ); - } - Ok(matrix) -} - -#[cfg(test)] -mod tests { - use super::load; - - #[test] - fn current_contract_rejects_nominal_front_door_portability() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(std::path::Path::parent) - .expect("workspace root"); - let matrix = load(root).expect("portable matrix"); - assert!(matrix.portable_public_packages.is_empty()); - assert_eq!( - matrix.std_only_public_packages, - ["radroots", "radroots_sdk"] - ); - } -} diff --git a/tools/xtask/src/release_graph.rs b/tools/xtask/src/release_graph.rs @@ -1,687 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - path::Path, - process::Command, -}; - -use serde::Deserialize; - -const ARCHITECTURE_RELATIVE: &str = "docs/specs/radroots_crates_release_v1.toml"; -const SPEC_ID: &str = "radroots.crates.release.v1"; -const RELEASE_VERSION: &str = "0.1.0-alpha"; -const REGISTRY_KINDS: &[&str] = &["build", "normal"]; -const ALL_KINDS: &[&str] = &["build", "dev", "normal"]; - -#[derive(Debug, Deserialize)] -struct Architecture { - spec_id: String, - package_count: usize, - repositories: ArchitectureRepositories, - package: Vec<ArchitecturePackage>, -} - -#[derive(Debug, Deserialize)] -struct ArchitectureRepositories { - lib: ArchitectureRepository, - sdk: ArchitectureRepository, -} - -#[derive(Debug, Deserialize)] -struct ArchitectureRepository { - url: String, - packages: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct ArchitecturePackage { - name: String, - publish_order_hint: usize, - #[serde(default)] - required_radroots_dependencies: Vec<String>, - #[serde(default)] - optional_radroots_dependencies: Vec<String>, - #[serde(flatten)] - _other: BTreeMap<String, toml::Value>, -} - -#[derive(Debug, Deserialize)] -struct CargoMetadata { - packages: Vec<CargoPackage>, - workspace_members: Vec<String>, - resolve: Option<CargoResolve>, -} - -#[derive(Debug, Deserialize)] -struct CargoPackage { - id: String, - name: String, - version: String, - source: Option<String>, - manifest_path: String, - #[serde(default)] - dependencies: Vec<CargoDependency>, -} - -#[derive(Debug, Deserialize)] -struct CargoDependency { - name: String, - rename: Option<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>, -} - -fn default_true() -> bool { - true -} - -pub fn run(workspace_root: &Path) -> Result<(), String> { - let architecture = load_architecture(workspace_root)?; - let metadata = load_metadata(workspace_root)?; - let order = validate(&architecture, &metadata)?; - println!("resolved publication order:"); - for (index, package) in order.iter().enumerate() { - println!("{:02} {package}", index + 1); - } - Ok(()) -} - -fn load_architecture(workspace_root: &Path) -> Result<Architecture, String> { - let path = workspace_root.join(ARCHITECTURE_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 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 resolving the release graph: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } - serde_json::from_slice(&output.stdout) - .map_err(|error| format!("parse cargo metadata release graph: {error}")) -} - -fn validate(architecture: &Architecture, metadata: &CargoMetadata) -> Result<Vec<String>, String> { - let hints = validate_architecture(architecture)?; - let approved = hints.keys().copied().collect::<BTreeSet<_>>(); - let packages = unique_packages(metadata)?; - let nodes = unique_nodes(metadata)?; - let workspace_members = metadata - .workspace_members - .iter() - .map(String::as_str) - .collect::<BTreeSet<_>>(); - let facade_is_local = metadata.workspace_members.iter().any(|id| { - packages - .get(id.as_str()) - .is_some_and(|package| package.name == "radroots") - }); - - let mut selected = BTreeSet::new(); - for name in &approved { - let matching = packages - .values() - .filter(|package| package.name == **name) - .copied() - .collect::<Vec<_>>(); - if matching.len() > 1 { - return Err(format!( - "release package {name} resolves to multiple package identities" - )); - } - let Some(package) = matching.first().copied() else { - if facade_is_local { - return Err(format!( - "complete SDK release graph is missing approved package {name}" - )); - } - continue; - }; - if facade_is_local || workspace_members.contains(package.id.as_str()) { - selected.insert(*name); - } - if package.version != RELEASE_VERSION { - return Err(format!( - "release package {name} resolved version {} instead of {RELEASE_VERSION}", - package.version - )); - } - validate_package_source(architecture, package, &workspace_members)?; - if !nodes.contains_key(package.id.as_str()) { - return Err(format!( - "release package {name} has no Cargo resolve node; manifest={}", - package.manifest_path - )); - } - } - if selected.is_empty() { - return Err("release graph selected no approved workspace packages".to_owned()); - } - if facade_is_local && selected.len() != architecture.package_count { - return Err(format!( - "complete SDK release graph selected {} packages instead of {}", - selected.len(), - architecture.package_count - )); - } - - let mut dependency_sets = selected - .iter() - .map(|name| (*name, BTreeSet::new())) - .collect::<BTreeMap<_, _>>(); - let mut violations = Vec::new(); - - for owner_name in &selected { - let owner = packages - .values() - .find(|package| package.name == **owner_name) - .copied() - .ok_or_else(|| format!("selected release package {owner_name} disappeared"))?; - let node = nodes - .get(owner.id.as_str()) - .copied() - .ok_or_else(|| format!("release package {owner_name} has no resolve node"))?; - for edge in &node.deps { - let dependency = packages - .get(edge.pkg.as_str()) - .copied() - .ok_or_else(|| format!("resolved dependency {} has no package record", edge.pkg))?; - if edge.dep_kinds.is_empty() { - return Err(format!( - "resolved edge {} -> {} has no dependency kind", - owner.name, dependency.name - )); - } - for edge_kind in &edge.dep_kinds { - let kind = normalized_kind(edge_kind.kind.as_deref()); - if !ALL_KINDS.contains(&kind) { - return Err(format!( - "resolved edge {} -> {} uses unsupported dependency kind {kind}", - owner.name, dependency.name - )); - } - let declaration = resolve_declaration(owner, dependency, edge_kind)?; - if is_radroots_family(&dependency.name) - && !approved.contains(dependency.name.as_str()) - { - violations.push(format_edge( - owner, - dependency, - edge, - edge_kind, - declaration, - "public-to-private edge", - )); - } - if REGISTRY_KINDS.contains(&kind) && selected.contains(dependency.name.as_str()) { - dependency_sets - .get_mut(owner_name) - .expect("selected owner dependency set") - .insert(dependency.name.as_str()); - } - } - } - } - if !violations.is_empty() { - violations.sort(); - return Err(format!( - "release graph contains forbidden edge(s):\n{}", - violations.join("\n") - )); - } - - validate_declared_architecture_edges(architecture, &selected, &dependency_sets)?; - let order = dependency_first_order(&selected, &dependency_sets, &hints)?; - let expected = selected.iter().copied().collect::<Vec<_>>(); - let mut expected = expected; - expected.sort_by_key(|name| hints[name]); - if order != expected { - return Err(format!( - "Cargo-derived publication order [{}] differs from architecture order [{}]", - order.join(", "), - expected.join(", ") - )); - } - Ok(order.into_iter().map(str::to_owned).collect()) -} - -fn validate_package_source( - architecture: &Architecture, - package: &CargoPackage, - workspace_members: &BTreeSet<&str>, -) -> Result<(), String> { - if workspace_members.contains(package.id.as_str()) { - return if package.source.is_none() { - Ok(()) - } else { - Err(format!( - "workspace release package {} must resolve locally; source={:?}", - package.name, package.source - )) - }; - } - - let repository = [ - &architecture.repositories.lib, - &architecture.repositories.sdk, - ] - .into_iter() - .find(|repository| repository.packages.iter().any(|name| name == &package.name)) - .ok_or_else(|| { - format!( - "release package {} has no repository allocation", - package.name - ) - })?; - let source = package.source.as_deref().ok_or_else(|| { - format!( - "external release package {} must resolve from an exact Git revision", - package.name - ) - })?; - validate_exact_git_source(source, &repository.url).map_err(|reason| { - format!( - "external release package {} has invalid source {source}: {reason}", - package.name - ) - }) -} - -fn validate_exact_git_source(source: &str, repository_url: &str) -> Result<(), &'static str> { - let canonical_url = repository_url - .trim_end_matches('/') - .trim_end_matches(".git"); - let prefix = format!("git+{canonical_url}.git?rev="); - let revision_and_commit = source - .strip_prefix(&prefix) - .ok_or("repository URL or exact-revision query does not match its allocation")?; - let (revision, commit) = revision_and_commit - .split_once('#') - .ok_or("resolved commit fragment is absent")?; - if revision.len() != 40 - || commit.len() != 40 - || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) - || !commit.bytes().all(|byte| byte.is_ascii_hexdigit()) - { - return Err("revision and resolved commit must be full hexadecimal object IDs"); - } - if revision != commit { - return Err("resolved commit does not equal the requested exact revision"); - } - Ok(()) -} - -fn validate_architecture(architecture: &Architecture) -> Result<BTreeMap<&str, usize>, String> { - if architecture.spec_id != SPEC_ID { - return Err(format!("architecture spec_id must be {SPEC_ID}")); - } - if architecture.package_count != 19 || architecture.package.len() != 19 { - return Err("architecture must define exactly 19 release packages".to_owned()); - } - let mut hints = BTreeMap::new(); - let mut seen_hints = BTreeSet::new(); - for package in &architecture.package { - if hints - .insert(package.name.as_str(), package.publish_order_hint) - .is_some() - { - return Err(format!("duplicate architecture package {}", package.name)); - } - if !seen_hints.insert(package.publish_order_hint) { - return Err(format!( - "duplicate publish_order_hint {}", - package.publish_order_hint - )); - } - } - if seen_hints != (1..=19).collect::<BTreeSet<_>>() { - return Err("publish_order_hint values must be the exact range 1..=19".to_owned()); - } - Ok(hints) -} - -fn unique_packages(metadata: &CargoMetadata) -> Result<BTreeMap<&str, &CargoPackage>, String> { - let mut packages = BTreeMap::new(); - for package in &metadata.packages { - if packages.insert(package.id.as_str(), package).is_some() { - return Err(format!("duplicate Cargo package id {}", package.id)); - } - } - Ok(packages) -} - -fn unique_nodes(metadata: &CargoMetadata) -> Result<BTreeMap<&str, &CargoNode>, String> { - let resolve = metadata - .resolve - .as_ref() - .ok_or_else(|| "cargo metadata did not include a resolved release graph".to_owned())?; - let mut nodes = BTreeMap::new(); - for node in &resolve.nodes { - if nodes.insert(node.id.as_str(), node).is_some() { - return Err(format!("duplicate Cargo resolve node {}", node.id)); - } - } - Ok(nodes) -} - -fn resolve_declaration<'a>( - owner: &'a CargoPackage, - dependency: &CargoPackage, - edge_kind: &CargoDependencyKind, -) -> Result<&'a CargoDependency, String> { - let kind = normalized_kind(edge_kind.kind.as_deref()); - let candidates = owner - .dependencies - .iter() - .filter(|candidate| { - candidate.name == dependency.name - && normalized_kind(candidate.kind.as_deref()) == kind - && candidate.target == edge_kind.target - }) - .collect::<Vec<_>>(); - match candidates.as_slice() { - [declaration] => Ok(*declaration), - [] => Err(format!( - "resolved edge {} -> {} kind={kind} target={} has no matching declaration", - owner.name, - dependency.name, - target_label(edge_kind.target.as_deref()) - )), - _ => Err(format!( - "resolved edge {} -> {} kind={kind} target={} has ambiguous declarations", - owner.name, - dependency.name, - target_label(edge_kind.target.as_deref()) - )), - } -} - -fn format_edge( - owner: &CargoPackage, - dependency: &CargoPackage, - edge: &CargoNodeDependency, - edge_kind: &CargoDependencyKind, - declaration: &CargoDependency, - reason: &str, -) -> String { - let features = declaration - .features - .iter() - .cloned() - .collect::<BTreeSet<_>>() - .into_iter() - .collect::<Vec<_>>() - .join(","); - format!( - "{reason}: {} -> {}; alias={}; declaration_alias={}; kind={}; target={}; features=[{}]; default_features={}; owner_manifest={}; dependency_manifest={}", - owner.name, - dependency.name, - edge.name, - declaration.rename.as_deref().unwrap_or(&declaration.name), - normalized_kind(edge_kind.kind.as_deref()), - target_label(edge_kind.target.as_deref()), - features, - declaration.uses_default_features, - owner.manifest_path, - dependency.manifest_path, - ) -} - -fn validate_declared_architecture_edges<'a>( - architecture: &'a Architecture, - selected: &BTreeSet<&'a str>, - resolved: &BTreeMap<&'a str, BTreeSet<&'a str>>, -) -> Result<(), String> { - for package in &architecture.package { - if !selected.contains(package.name.as_str()) { - continue; - } - for optional in &package.optional_radroots_dependencies { - if !selected.contains(optional.as_str()) { - return Err(format!( - "{} optional architecture dependency {optional} is outside the selected public graph", - package.name - )); - } - } - let declared = package - .required_radroots_dependencies - .iter() - .map(String::as_str) - .filter(|dependency| selected.contains(dependency)) - .collect::<BTreeSet<_>>(); - let actual = resolved - .get(package.name.as_str()) - .ok_or_else(|| format!("missing resolved dependency set for {}", package.name))?; - if !declared.is_subset(actual) { - let missing = declared.difference(actual).copied().collect::<Vec<_>>(); - return Err(format!( - "{} architecture dependency declarations are absent from the all-feature Cargo graph: {}", - package.name, - missing.join(", ") - )); - } - } - Ok(()) -} - -fn dependency_first_order<'a>( - selected: &BTreeSet<&'a str>, - dependency_sets: &BTreeMap<&'a str, BTreeSet<&'a str>>, - hints: &BTreeMap<&'a str, usize>, -) -> Result<Vec<&'a str>, String> { - let mut remaining = dependency_sets.clone(); - let mut order = Vec::with_capacity(selected.len()); - while !remaining.is_empty() { - let next = remaining - .iter() - .filter(|(_, dependencies)| dependencies.iter().all(|name| order.contains(name))) - .map(|(name, _)| *name) - .min_by_key(|name| hints[name]); - let Some(next) = next else { - let blocked = remaining - .iter() - .map(|(name, dependencies)| { - format!( - "{name}->[{}]", - dependencies.iter().copied().collect::<Vec<_>>().join(",") - ) - }) - .collect::<Vec<_>>() - .join("; "); - return Err(format!( - "release graph contains a publication cycle: {blocked}" - )); - }; - remaining.remove(next); - order.push(next); - } - Ok(order) -} - -fn is_radroots_family(name: &str) -> bool { - name == "radroots" || name.starts_with("radroots_") -} - -fn normalized_kind(kind: Option<&str>) -> &str { - kind.unwrap_or("normal") -} - -fn target_label(target: Option<&str>) -> &str { - target.unwrap_or("all") -} - -#[cfg(test)] -mod tests { - use super::{Architecture, CargoMetadata, validate, validate_exact_git_source}; - - const ARCHITECTURE: &str = include_str!("../../../docs/specs/radroots_crates_release_v1.toml"); - - #[test] - fn current_architecture_contract_has_a_complete_order() { - let architecture = toml::from_str::<Architecture>(ARCHITECTURE).expect("architecture"); - let hints = super::validate_architecture(&architecture).expect("valid order"); - assert_eq!(hints.len(), 19); - assert_eq!(hints["radroots_core"], 1); - assert_eq!(hints["radroots"], 19); - } - - #[test] - fn exact_cross_repository_git_sources_require_the_requested_commit() { - const REVISION: &str = "f7f456d906d91aadbb9fac4850afaa8c5723c3e9"; - let source = - format!("git+https://github.com/radrootslabs/lib.git?rev={REVISION}#{REVISION}"); - validate_exact_git_source(&source, "https://github.com/radrootslabs/lib") - .expect("exact source"); - - let drifted = format!( - "git+https://github.com/radrootslabs/lib.git?rev={REVISION}#{}", - "1".repeat(40) - ); - assert!( - validate_exact_git_source(&drifted, "https://github.com/radrootslabs/lib").is_err() - ); - assert!( - validate_exact_git_source( - "registry+https://github.com/rust-lang/crates.io-index", - "https://github.com/radrootslabs/lib" - ) - .is_err() - ); - } - - #[test] - fn metadata_without_resolve_graph_fails_closed() { - let architecture = toml::from_str::<Architecture>(ARCHITECTURE).expect("architecture"); - let metadata: CargoMetadata = - serde_json::from_str(r#"{"packages":[],"workspace_members":[],"resolve":null}"#) - .expect("metadata"); - let error = validate(&architecture, &metadata).expect_err("missing graph"); - assert!( - error.contains("selected no approved workspace packages") - || error.contains("resolved release graph") - ); - } - - #[test] - fn public_to_private_target_edge_reports_complete_provenance() { - let architecture = toml::from_str::<Architecture>(ARCHITECTURE).expect("architecture"); - let metadata: CargoMetadata = serde_json::from_str( - r#"{ - "packages": [ - { - "id": "core", - "name": "radroots_core", - "version": "0.1.0-alpha", - "source": null, - "manifest_path": "/capsule/crates/core/Cargo.toml", - "dependencies": [{ - "name": "radroots_private_runtime", - "rename": "private_alias", - "kind": "build", - "features": ["danger"], - "target": "cfg(unix)", - "uses_default_features": false - }] - }, - { - "id": "private", - "name": "radroots_private_runtime", - "version": "0.1.0-alpha", - "source": null, - "manifest_path": "/capsule/crates/private/Cargo.toml", - "dependencies": [] - } - ], - "workspace_members": ["core"], - "resolve": {"nodes": [ - {"id": "core", "deps": [{ - "name": "private_alias", - "pkg": "private", - "dep_kinds": [{"kind": "build", "target": "cfg(unix)"}] - }]}, - {"id": "private", "deps": []} - ]} - }"#, - ) - .expect("metadata"); - let error = validate(&architecture, &metadata).expect_err("private edge"); - assert!(error.contains("public-to-private edge")); - assert!(error.contains("alias=private_alias")); - assert!(error.contains("kind=build")); - assert!(error.contains("target=cfg(unix)")); - assert!(error.contains("features=[danger]")); - assert!(error.contains("default_features=false")); - } - - #[test] - fn registry_graph_cycle_fails_closed() { - let architecture = toml::from_str::<Architecture>(ARCHITECTURE).expect("architecture"); - let metadata: CargoMetadata = serde_json::from_str( - r#"{ - "packages": [ - {"id":"core","name":"radroots_core","version":"0.1.0-alpha","source":null,"manifest_path":"/core/Cargo.toml","dependencies":[{"name":"radroots_identity","rename":null,"kind":null,"features":[],"target":null,"uses_default_features":true}]}, - {"id":"identity","name":"radroots_identity","version":"0.1.0-alpha","source":null,"manifest_path":"/identity/Cargo.toml","dependencies":[{"name":"radroots_core","rename":null,"kind":null,"features":[],"target":null,"uses_default_features":true}]} - ], - "workspace_members":["core","identity"], - "resolve":{"nodes":[ - {"id":"core","deps":[{"name":"radroots_identity","pkg":"identity","dep_kinds":[{"kind":null,"target":null}]}]}, - {"id":"identity","deps":[{"name":"radroots_core","pkg":"core","dep_kinds":[{"kind":null,"target":null}]}]} - ]} - }"#, - ) - .expect("metadata"); - let error = validate(&architecture, &metadata).expect_err("cycle"); - assert!(error.contains("publication cycle")); - assert!(error.contains("radroots_core")); - assert!(error.contains("radroots_identity")); - } -} diff --git a/tools/xtask/src/release_qualification.rs b/tools/xtask/src/release_qualification.rs @@ -1,135 +0,0 @@ -use std::{fs, path::Path, process::Command}; - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -struct Architecture { - repositories: Repositories, - package: Vec<Package>, -} - -#[derive(Debug, Deserialize)] -struct Repositories { - sdk: Repository, -} - -#[derive(Debug, Deserialize)] -struct Repository { - packages: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct Package { - name: String, - features: Vec<String>, -} - -#[derive(Debug, PartialEq, Eq)] -struct CargoInvocation { - args: Vec<String>, -} - -pub fn run_feature_matrix(workspace_root: &Path) -> Result<(), String> { - for invocation in feature_matrix(workspace_root)? { - eprintln!("cargo {}", invocation.args.join(" ")); - let status = Command::new("cargo") - .args(&invocation.args) - .current_dir(workspace_root) - .status() - .map_err(|error| format!("failed to start cargo: {error}"))?; - if !status.success() { - return Err(format!( - "public feature qualification failed: cargo {}", - invocation.args.join(" ") - )); - } - } - Ok(()) -} - -fn feature_matrix(workspace_root: &Path) -> Result<Vec<CargoInvocation>, String> { - let path = workspace_root.join("docs/specs/radroots_crates_release_v1.toml"); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let architecture = toml::from_str::<Architecture>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let mut packages = architecture - .package - .into_iter() - .filter(|package| { - architecture - .repositories - .sdk - .packages - .contains(&package.name) - }) - .collect::<Vec<_>>(); - packages.sort_by(|left, right| left.name.cmp(&right.name)); - if packages.len() != 2 { - return Err(format!( - "SDK feature qualification requires exactly 2 public packages, found {}", - packages.len() - )); - } - - let mut invocations = Vec::new(); - for package in packages { - invocations.push(check_invocation(&package.name, None, true)); - invocations.push(check_invocation(&package.name, None, false)); - for feature in package.features { - invocations.push(check_invocation(&package.name, Some(&feature), true)); - } - invocations.push(CargoInvocation { - args: vec![ - "check".to_owned(), - "-p".to_owned(), - package.name, - "--all-targets".to_owned(), - "--all-features".to_owned(), - "--locked".to_owned(), - ], - }); - } - Ok(invocations) -} - -fn check_invocation( - package: &str, - feature: Option<&str>, - no_default_features: bool, -) -> CargoInvocation { - let mut args = vec!["check".to_owned(), "-p".to_owned(), package.to_owned()]; - if no_default_features { - args.push("--lib".to_owned()); - args.push("--no-default-features".to_owned()); - } else { - args.push("--all-targets".to_owned()); - } - if let Some(feature) = feature { - args.push("--features".to_owned()); - args.push(feature.to_owned()); - } - args.push("--locked".to_owned()); - CargoInvocation { args } -} - -#[cfg(test)] -mod tests { - use super::feature_matrix; - - #[test] - fn current_catalog_generates_both_front_door_feature_matrices() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(std::path::Path::parent) - .expect("workspace root"); - let matrix = feature_matrix(root).expect("feature matrix"); - assert!(matrix.len() > 20); - for package in ["radroots", "radroots_sdk"] { - assert!(matrix.iter().any(|entry| { - entry.args.windows(2).any(|pair| pair == ["-p", package]) - && entry.args.iter().any(|arg| arg == "--all-features") - })); - } - } -} diff --git a/tools/xtask/src/smoke.rs b/tools/xtask/src/smoke.rs @@ -1,236 +0,0 @@ -use std::{fs, path::Path, process::Command}; - -use crate::{ - fs::workspace_root, - wasm::{ResolvedRustToolchain, resolve_rust_toolchain}, -}; - -pub fn run(args: &[String]) -> Result<(), String> { - match args { - [target] if target == "sdk-rust-local" => sdk_rust_local(), - [target] if target == "facade-rust-local" => facade_rust_local(), - [target] if target == "front-doors-rust-local" => { - facade_rust_local()?; - sdk_rust_local() - } - _ => Err( - "usage: cargo xtask smoke facade-rust-local | sdk-rust-local | front-doors-rust-local" - .to_owned(), - ), - } -} - -fn facade_rust_local() -> Result<(), String> { - let root = workspace_root()?; - let facade_path = root.join("crates/radroots"); - let lib_root = sibling_lib_root(&root)?; - let toolchain = resolve_rust_toolchain(&root)?; - run_clean_consumer( - "facade", - &render_facade_consumer_manifest(&facade_path, &lib_root)?, - FACADE_CONSUMER_MAIN, - &toolchain, - ) -} - -fn sdk_rust_local() -> Result<(), String> { - let root = workspace_root()?; - let sdk_path = root.join("crates/sdk"); - let lib_root = sibling_lib_root(&root)?; - let toolchain = resolve_rust_toolchain(&root)?; - run_clean_consumer( - "SDK", - &render_sdk_consumer_manifest(&sdk_path, &lib_root)?, - SDK_CONSUMER_MAIN, - &toolchain, - ) -} - -fn sibling_lib_root(root: &Path) -> Result<std::path::PathBuf, String> { - Ok(root - .parent() - .ok_or_else(|| format!("{} has no repository parent", root.display()))? - .join("lib")) -} - -fn run_clean_consumer( - label: &str, - manifest: &str, - main: &str, - toolchain: &ResolvedRustToolchain, -) -> Result<(), String> { - let tempdir = - tempfile::tempdir().map_err(|error| format!("failed to create smoke tempdir: {error}"))?; - fs::write(tempdir.path().join("Cargo.toml"), manifest) - .map_err(|error| format!("failed to write {label} smoke manifest: {error}"))?; - let src_dir = tempdir.path().join("src"); - fs::create_dir_all(&src_dir) - .map_err(|error| format!("failed to create {}: {error}", src_dir.display()))?; - fs::write(src_dir.join("main.rs"), main) - .map_err(|error| format!("failed to write {label} smoke consumer: {error}"))?; - let status = smoke_cargo_run_command(tempdir.path(), toolchain) - .status() - .map_err(|error| format!("failed to run {label} smoke consumer: {error}"))?; - if status.success() { - Ok(()) - } else { - Err(format!( - "{label} Rust local smoke failed with status {status}" - )) - } -} - -fn render_facade_consumer_manifest(facade_path: &Path, lib_root: &Path) -> Result<String, String> { - let facade_path = serde_json::to_string(&facade_path.to_string_lossy()) - .map_err(|error| format!("failed to render facade path: {error}"))?; - let mut manifest = format!( - r#"[package] -name = "radroots_facade_host_smoke" -version = "0.1.0" -edition = "2024" -publish = false - -[dependencies] -radroots = {{ path = {facade_path} }} - -[patch.crates-io] -"#, - ); - append_lib_patches(&mut manifest, lib_root)?; - Ok(manifest) -} - -fn render_sdk_consumer_manifest(sdk_path: &Path, lib_root: &Path) -> Result<String, String> { - let sdk_path = serde_json::to_string(&sdk_path.to_string_lossy()) - .map_err(|error| format!("failed to render SDK path: {error}"))?; - let mut manifest = format!( - r#"[package] -name = "radroots_sdk_host_smoke" -version = "0.1.0" -edition = "2024" -publish = false - -[dependencies] -radroots_sdk = {{ path = {sdk_path}, default-features = true }} - -[patch.crates-io] -"#, - ); - append_lib_patches(&mut manifest, lib_root)?; - Ok(manifest) -} - -fn append_lib_patches(manifest: &mut String, lib_root: &Path) -> Result<(), String> { - for (package, relative_path) in [ - ("radroots_blossom", "crates/blossom"), - ("radroots_core", "crates/core"), - ("radroots_event", "crates/event"), - ("radroots_event_codec", "crates/event_codec"), - ("radroots_geonames", "crates/geonames"), - ("radroots_identity", "crates/identity"), - ("radroots_nostr", "crates/nostr"), - ("radroots_nostr_connect", "crates/nostr_connect"), - ("radroots_protocol", "crates/protocol"), - ("radroots_secrets", "crates/secrets"), - ("radroots_signing", "crates/signing"), - ("radroots_storage", "crates/storage"), - ("radroots_storage_sqlite", "crates/storage_sqlite"), - ("radroots_sync", "crates/sync"), - ("radroots_trade", "crates/trade"), - ("radroots_transport", "crates/transport"), - ("radroots_transport_nostr", "crates/transport_nostr"), - ] { - let path = serde_json::to_string(&lib_root.join(relative_path).to_string_lossy()) - .map_err(|error| format!("failed to render {package} patch path: {error}"))?; - manifest.push_str(&format!("{package} = {{ path = {path} }}\n")); - } - Ok(()) -} - -fn smoke_cargo_run_command(path: &Path, toolchain: &ResolvedRustToolchain) -> Command { - let mut command = Command::new(&toolchain.cargo); - command.arg("run").arg("--quiet").current_dir(path); - toolchain.apply_to_command(&mut command); - command -} - -const SDK_CONSUMER_MAIN: &str = r#"use radroots_sdk::{ClientBuilder, error::ErrorKind}; - -fn main() { - let error = match ClientBuilder::new().build() { - Ok(_) => panic!("empty SDK builder must fail closed"), - Err(error) => error, - }; - assert_eq!(error.kind(), ErrorKind::MissingStorage); -} -"#; - -const FACADE_CONSUMER_MAIN: &str = r#"fn main() -> radroots::Result<()> { - let client = radroots::client::memory().build()?; - assert!(!client.is_closed()); - assert!(radroots::client::local_only().is_local_only()); - Ok(()) -} -"#; - -#[cfg(test)] -mod tests { - use std::path::{Path, PathBuf}; - - use crate::wasm::ResolvedRustToolchain; - - use super::{ - render_facade_consumer_manifest, render_sdk_consumer_manifest, run, smoke_cargo_run_command, - }; - - #[test] - fn retired_sdk_knowledge_smoke_is_not_a_command_surface() { - assert!(run(&["knowledge-rust-local".to_owned()]).is_err()); - } - - #[test] - fn sdk_smoke_consumer_uses_final_api_and_local_lower_package_patches() { - let manifest = render_sdk_consumer_manifest( - Path::new("/tmp/radroots_sdk"), - Path::new("/tmp/radroots_lib"), - ) - .expect("manifest"); - - assert!(manifest.contains("name = \"radroots_sdk_host_smoke\"")); - assert!(manifest.contains("radroots_sdk = { path = \"/tmp/radroots_sdk\"")); - assert!( - manifest.contains("radroots_storage = { path = \"/tmp/radroots_lib/crates/storage\" }") - ); - assert!(!manifest.contains("runtime =")); - assert!(!manifest.contains("signer-adapters")); - } - - #[test] - fn facade_smoke_consumer_uses_curated_front_door() { - let manifest = render_facade_consumer_manifest( - Path::new("/tmp/radroots_facade"), - Path::new("/tmp/radroots_lib"), - ) - .expect("manifest"); - - assert!(manifest.contains("name = \"radroots_facade_host_smoke\"")); - assert!(manifest.contains("radroots = { path = \"/tmp/radroots_facade\" }")); - assert!(!manifest.contains("radroots_sdk = { path")); - } - - #[test] - fn smoke_command_uses_resolved_cargo_path() { - let cargo = PathBuf::from("/tmp/rust-toolchain/bin/cargo"); - let toolchain = ResolvedRustToolchain { - channel: "1.97.0".to_owned(), - rustc: PathBuf::from("/tmp/rust-toolchain/bin/rustc"), - cargo: cargo.clone(), - bin_dir: PathBuf::from("/tmp/rust-toolchain/bin"), - }; - - let command = smoke_cargo_run_command(Path::new("/tmp/smoke"), &toolchain); - - assert_eq!(command.get_program(), cargo.as_os_str()); - assert_eq!(command.get_args().next(), Some(std::ffi::OsStr::new("run"))); - } -} diff --git a/tools/xtask/src/supply_chain_qualification.rs b/tools/xtask/src/supply_chain_qualification.rs @@ -1,729 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; - -const CONTRACT_PATH: &str = "contracts/releases/supply_chain.toml"; -const DENY_PATH: &str = "deny.toml"; -const SBOM_FILENAME: &str = "radroots-release-v1-sbom.json"; - -#[derive(Debug, Deserialize)] -struct Contract { - schema_version: u16, - spec_id: String, - package_version: String, - tools: Tools, - sbom: Sbom, - advisory_exception: Vec<AdvisoryException>, - package: Vec<Package>, -} - -#[derive(Debug, Deserialize)] -struct Tools { - cargo_deny: String, - cargo_cyclonedx: String, -} - -#[derive(Debug, Deserialize)] -struct Sbom { - format: String, - spec_version: String, - target: String, - all_features: bool, - source_date_epoch: u64, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct AdvisoryException { - id: String, - package: String, - affected_version: String, - introduced_by: String, - classification: String, - mitigation: String, - remove_when: String, -} - -#[derive(Debug, Deserialize)] -struct Package { - name: String, - manifest_path: String, -} - -#[derive(Debug, Deserialize)] -struct Metadata { - packages: Vec<MetadataPackage>, -} - -#[derive(Debug, Deserialize)] -struct MetadataPackage { - name: String, - version: String, - manifest_path: PathBuf, -} - -struct GeneratedSboms(Vec<PathBuf>); - -impl Drop for GeneratedSboms { - fn drop(&mut self) { - for path in &self.0 { - let _ = fs::remove_file(path); - } - } -} - -pub fn run(root: &Path) -> Result<(), String> { - let contract = load(root)?; - validate(root, &contract, 2)?; - verify_tools(&contract)?; - let metadata = load_metadata(root)?; - qualify_dependencies(root, &contract)?; - let sbom_hashes = qualify_sboms(root, &contract, &metadata)?; - let provenance = build_provenance(root, &contract, &sbom_hashes)?; - validate_provenance(&provenance, &contract)?; - eprintln!( - "qualified {} package supply chains; provenance sha256={}", - contract.package.len(), - sha256(&serde_json::to_vec(&provenance).map_err(|error| error.to_string())?) - ); - Ok(()) -} - -fn load(root: &Path) -> Result<Contract, String> { - let path = root.join(CONTRACT_PATH); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) -} - -fn validate(root: &Path, contract: &Contract, expected_packages: usize) -> Result<(), String> { - if contract.schema_version != 1 - || contract.spec_id != "radroots.crates.release.v1" - || contract.package_version != "0.1.0-alpha" - || contract.tools.cargo_deny != "0.19.8" - || contract.tools.cargo_cyclonedx != "0.5.9" - || contract.sbom.format != "json" - || contract.sbom.spec_version != "1.5" - || contract.sbom.target != "all" - || !contract.sbom.all_features - || contract.sbom.source_date_epoch != 0 - { - return Err("invalid supply-chain qualification contract".to_owned()); - } - - let names = contract - .package - .iter() - .map(|package| package.name.as_str()) - .collect::<BTreeSet<_>>(); - let manifests = contract - .package - .iter() - .map(|package| package.manifest_path.as_str()) - .collect::<BTreeSet<_>>(); - if names.len() != expected_packages - || names.len() != contract.package.len() - || manifests.len() != contract.package.len() - { - return Err(format!( - "supply-chain contract requires exactly {expected_packages} unique packages and manifests" - )); - } - for package in &contract.package { - let path = root.join(&package.manifest_path); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let manifest: toml::Value = toml::from_str(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let package_table = manifest - .get("package") - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{} has no package table", path.display()))?; - let name = package_table.get("name").and_then(toml::Value::as_str); - let version = package_table.get("version").and_then(toml::Value::as_str); - let inherits_workspace_version = package_table - .get("version") - .and_then(toml::Value::as_table) - .and_then(|version| version.get("workspace")) - .and_then(toml::Value::as_bool) - == Some(true); - if name != Some(package.name.as_str()) - || version != Some(contract.package_version.as_str()) && !inherits_workspace_version - { - return Err(format!( - "{} does not declare {} {}", - path.display(), - package.name, - contract.package_version - )); - } - } - - validate_exceptions(root, contract) -} - -fn validate_exceptions(root: &Path, contract: &Contract) -> Result<(), String> { - let expected = BTreeSet::from([ - "CARGO-YANKED-SPIN-0.9.8".to_owned(), - "RUSTSEC-2024-0384".to_owned(), - "RUSTSEC-2024-0421".to_owned(), - ]); - let actual = contract - .advisory_exception - .iter() - .map(|exception| exception.id.clone()) - .collect::<BTreeSet<_>>(); - if actual != expected || actual.len() != contract.advisory_exception.len() { - return Err("supply-chain advisory exceptions are not the exact approved set".to_owned()); - } - for exception in &contract.advisory_exception { - if exception.package.is_empty() - || exception.affected_version.is_empty() - || exception.introduced_by.is_empty() - || exception.classification.is_empty() - || exception.mitigation.is_empty() - || exception.remove_when.is_empty() - { - return Err(format!("advisory exception {} is incomplete", exception.id)); - } - let exact = match exception.id.as_str() { - "RUSTSEC-2024-0384" => { - exception.package == "instant" - && exception.affected_version == "0.1.13" - && exception.classification == "unmaintained" - && exception.remove_when == "nostr >=0.45.0 stable" - } - "RUSTSEC-2024-0421" => { - exception.package == "idna" - && exception.affected_version == "0.5.0" - && exception.classification == "vulnerability" - && exception.remove_when == "nostr >=0.45.0 stable" - } - "CARGO-YANKED-SPIN-0.9.8" => { - exception.package == "spin" - && exception.affected_version == "0.9.8" - && exception.classification == "yanked" - && exception.remove_when - == "sqlx no longer resolves flume 0.12.0 with spin 0.9.8" - } - _ => false, - }; - if !exact { - return Err(format!( - "advisory exception {} differs from its exact approved policy", - exception.id - )); - } - } - - let lock_raw = fs::read_to_string(root.join("Cargo.lock")) - .map_err(|error| format!("failed to read Cargo.lock: {error}"))?; - let lock: toml::Value = toml::from_str(&lock_raw) - .map_err(|error| format!("failed to parse Cargo.lock: {error}"))?; - let locked_packages = lock - .get("package") - .and_then(toml::Value::as_array) - .ok_or_else(|| "Cargo.lock contains no packages".to_owned())?; - for exception in &contract.advisory_exception { - let present = locked_packages.iter().any(|package| { - package.get("name").and_then(toml::Value::as_str) == Some(exception.package.as_str()) - && package.get("version").and_then(toml::Value::as_str) - == Some(exception.affected_version.as_str()) - }); - if !present { - return Err(format!( - "advisory exception {} is stale because {} {} is absent from Cargo.lock", - exception.id, exception.package, exception.affected_version - )); - } - } - let patched_url_present = locked_packages.iter().any(|package| { - package.get("name").and_then(toml::Value::as_str) == Some("url") - && package - .get("version") - .and_then(toml::Value::as_str) - .and_then(|version| semver::Version::parse(version).ok()) - .is_some_and(|version| version >= semver::Version::new(2, 5, 4)) - }); - if !patched_url_present { - return Err("the IDNA exception requires url 2.5.4 or newer in Cargo.lock".to_owned()); - } - - let deny_raw = fs::read_to_string(root.join(DENY_PATH)) - .map_err(|error| format!("failed to read {DENY_PATH}: {error}"))?; - let deny: toml::Value = toml::from_str(&deny_raw) - .map_err(|error| format!("failed to parse {DENY_PATH}: {error}"))?; - let ignored = deny - .get("advisories") - .and_then(|value| value.get("ignore")) - .and_then(toml::Value::as_array) - .ok_or_else(|| "deny.toml advisories.ignore is missing".to_owned())? - .iter() - .filter_map(toml::Value::as_str) - .map(str::to_owned) - .collect::<BTreeSet<_>>(); - let expected_ignored = expected - .iter() - .filter(|id| id.starts_with("RUSTSEC-")) - .cloned() - .collect::<BTreeSet<_>>(); - if ignored != expected_ignored { - return Err("deny.toml advisory ignores differ from the governed exceptions".to_owned()); - } - let allowed_git = deny - .get("sources") - .and_then(|value| value.get("allow-git")) - .and_then(toml::Value::as_array) - .ok_or_else(|| "deny.toml sources.allow-git is missing".to_owned())? - .iter() - .filter_map(toml::Value::as_str) - .collect::<BTreeSet<_>>(); - if allowed_git != BTreeSet::from(["https://github.com/radrootslabs/lib.git"]) { - return Err( - "deny.toml Git sources must allow only the allocated Radroots lib repository" - .to_owned(), - ); - } - - let relay_source = root.join("crates/transport_nostr/src/relay.rs"); - if relay_source.exists() { - let source = fs::read_to_string(&relay_source) - .map_err(|error| format!("failed to read {}: {error}", relay_source.display()))?; - if !source.contains("Url::parse(canonical)") { - return Err( - "the IDNA exception requires patched relay URL canonicalization".to_owned(), - ); - } - } - Ok(()) -} - -fn verify_tools(contract: &Contract) -> Result<(), String> { - verify_tool( - &["deny", "--version"], - "cargo-deny", - &contract.tools.cargo_deny, - )?; - verify_tool( - &["cyclonedx", "--version"], - "cargo-cyclonedx", - &contract.tools.cargo_cyclonedx, - ) -} - -fn verify_tool(args: &[&str], name: &str, expected: &str) -> Result<(), String> { - let output = Command::new("cargo") - .args(args) - .output() - .map_err(|error| format!("failed to start {name}: {error}"))?; - if !output.status.success() { - return Err(format!("{name} {expected} is required")); - } - let stdout = String::from_utf8_lossy(&output.stdout); - let installed = stdout - .split_whitespace() - .find_map(|value| semver::Version::parse(value).ok()) - .ok_or_else(|| format!("could not parse {name} version: {stdout}"))?; - let expected = semver::Version::parse(expected) - .map_err(|error| format!("invalid governed {name} version: {error}"))?; - if installed != expected { - return Err(format!("{name} {expected} is required, found {installed}")); - } - Ok(()) -} - -fn load_metadata(root: &Path) -> Result<Metadata, String> { - let output = Command::new("cargo") - .args(["metadata", "--format-version", "1", "--locked", "--no-deps"]) - .current_dir(root) - .output() - .map_err(|error| format!("failed to start cargo metadata: {error}"))?; - if !output.status.success() { - return Err("locked cargo metadata failed".to_owned()); - } - serde_json::from_slice(&output.stdout) - .map_err(|error| format!("failed to parse cargo metadata: {error}")) -} - -fn qualify_dependencies(root: &Path, contract: &Contract) -> Result<(), String> { - let mut saw_governed_yank = false; - for package in &contract.package { - let manifest = root.join(&package.manifest_path); - let common = [ - "deny", - "-L", - "error", - "--manifest-path", - manifest - .to_str() - .ok_or_else(|| "non-UTF-8 package manifest path".to_owned())?, - "--all-features", - "--locked", - "check", - ]; - saw_governed_yank |= qualify_advisories(root, &manifest, &package.name)?; - run_command( - root, - "cargo", - common - .iter() - .copied() - .chain(["bans", "licenses", "sources"]) - .collect::<Vec<_>>(), - &format!("dependency policy failed for {}", package.name), - )?; - } - if !saw_governed_yank { - return Err("the governed spin 0.9.8 yank is stale and must be removed".to_owned()); - } - Ok(()) -} - -fn qualify_advisories(root: &Path, manifest: &Path, package: &str) -> Result<bool, String> { - let output = Command::new("cargo") - .args([ - "deny", - "--format", - "json", - "--log-level", - "warn", - "--manifest-path", - manifest - .to_str() - .ok_or_else(|| "non-UTF-8 package manifest path".to_owned())?, - "--all-features", - "--locked", - "check", - "--allow", - "advisory-not-detected", - "advisories", - ]) - .current_dir(root) - .output() - .map_err(|error| format!("failed to start cargo-deny: {error}"))?; - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - if !output.status.success() { - return Err(format!( - "advisory qualification failed for {package}: {stdout}{stderr}" - )); - } - - let mut saw_governed_yank = false; - for line in stdout.lines().chain(stderr.lines()) { - let Ok(message) = serde_json::from_str::<Value>(line) else { - continue; - }; - if message.get("type").and_then(Value::as_str) != Some("diagnostic") { - continue; - } - let fields = message - .get("fields") - .and_then(Value::as_object) - .ok_or_else(|| "cargo-deny emitted a malformed diagnostic".to_owned())?; - let code = fields - .get("code") - .and_then(Value::as_str) - .unwrap_or_default(); - let krate = fields - .get("graphs") - .and_then(Value::as_array) - .and_then(|graphs| graphs.first()) - .and_then(|graph| graph.get("Krate")); - let exact_governed_yank = code == "yanked" - && krate - .and_then(|krate| krate.get("name")) - .and_then(Value::as_str) - == Some("spin") - && krate - .and_then(|krate| krate.get("version")) - .and_then(Value::as_str) - == Some("0.9.8"); - if exact_governed_yank { - saw_governed_yank = true; - } else { - return Err(format!( - "unapproved cargo-deny diagnostic for {package}: {line}" - )); - } - } - Ok(saw_governed_yank) -} - -fn qualify_sboms( - root: &Path, - contract: &Contract, - metadata: &Metadata, -) -> Result<BTreeMap<String, String>, String> { - let mut paths = metadata - .packages - .iter() - .map(|package| { - package - .manifest_path - .parent() - .expect("manifest has parent") - .join(SBOM_FILENAME) - }) - .collect::<Vec<_>>(); - paths.sort(); - paths.dedup(); - if let Some(path) = paths.iter().find(|path| path.exists()) { - return Err(format!( - "refusing to overwrite pre-existing SBOM {}", - path.display() - )); - } - let _generated = GeneratedSboms(paths); - - let status = Command::new("cargo") - .args([ - "cyclonedx", - "--quiet", - "--manifest-path", - "Cargo.toml", - "--format", - &contract.sbom.format, - "--all-features", - "--target", - &contract.sbom.target, - "--spec-version", - &contract.sbom.spec_version, - "--license-strict", - "--license-accept-named", - "MIT/Apache-2.0", - "--license-accept-named", - "Apache-2.0/MIT", - "--license-accept-named", - "Apache-2.0 / MIT", - "--override-filename", - "radroots-release-v1-sbom", - ]) - .env( - "SOURCE_DATE_EPOCH", - contract.sbom.source_date_epoch.to_string(), - ) - .current_dir(root) - .status() - .map_err(|error| format!("failed to start cargo-cyclonedx: {error}"))?; - if !status.success() { - return Err("CycloneDX SBOM generation failed".to_owned()); - } - - let by_name = metadata - .packages - .iter() - .map(|package| (package.name.as_str(), package)) - .collect::<BTreeMap<_, _>>(); - let mut hashes = BTreeMap::new(); - for package in &contract.package { - let metadata_package = by_name - .get(package.name.as_str()) - .ok_or_else(|| format!("cargo metadata omitted {}", package.name))?; - if metadata_package.version != contract.package_version { - return Err(format!("{} metadata version drifted", package.name)); - } - let path = metadata_package - .manifest_path - .parent() - .expect("manifest has parent") - .join(SBOM_FILENAME); - let raw = fs::read(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let mut sbom: Value = serde_json::from_slice(&raw) - .map_err(|error| format!("invalid SBOM {}: {error}", path.display()))?; - validate_sbom(&sbom, &package.name, &contract.package_version)?; - normalize_sbom(&mut sbom, root); - hashes.insert( - package.name.clone(), - sha256(&serde_json::to_vec(&sbom).map_err(|error| error.to_string())?), - ); - } - Ok(hashes) -} - -fn validate_sbom(sbom: &Value, name: &str, version: &str) -> Result<(), String> { - let component = sbom - .pointer("/metadata/component") - .and_then(Value::as_object) - .ok_or_else(|| format!("{name} SBOM has no root component"))?; - if sbom.get("bomFormat").and_then(Value::as_str) != Some("CycloneDX") - || sbom.get("specVersion").and_then(Value::as_str) != Some("1.5") - || component.get("name").and_then(Value::as_str) != Some(name) - || component.get("version").and_then(Value::as_str) != Some(version) - || sbom - .get("components") - .and_then(Value::as_array) - .is_none_or(Vec::is_empty) - || sbom - .get("dependencies") - .and_then(Value::as_array) - .is_none_or(Vec::is_empty) - { - return Err(format!( - "{name} SBOM is incomplete or identifies the wrong package" - )); - } - Ok(()) -} - -fn normalize_sbom(value: &mut Value, root: &Path) { - normalize_sbom_value(value, root.to_string_lossy().as_ref()); -} - -fn normalize_sbom_value(value: &mut Value, root: &str) { - match value { - Value::Object(object) => { - object.remove("serialNumber"); - for value in object.values_mut() { - normalize_sbom_value(value, root); - } - } - Value::Array(values) => { - for value in values { - normalize_sbom_value(value, root); - } - } - Value::String(string) if string.contains(root) => { - *string = string.replace(root, "$REPOSITORY"); - } - _ => {} - } -} - -fn build_provenance( - root: &Path, - contract: &Contract, - sbom_hashes: &BTreeMap<String, String>, -) -> Result<Value, String> { - let revision = command_stdout(root, "git", &["rev-parse", "HEAD"])?; - if revision.len() != 40 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err("Git provenance revision is not a full commit ID".to_owned()); - } - let lock = fs::read(root.join("Cargo.lock")) - .map_err(|error| format!("failed to read Cargo.lock: {error}"))?; - let packages = contract - .package - .iter() - .map(|package| { - json!({ - "name": package.name, - "version": contract.package_version, - "manifestPath": package.manifest_path, - "sbomSha256": sbom_hashes.get(&package.name), - }) - }) - .collect::<Vec<_>>(); - Ok(json!({ - "schemaVersion": 1, - "specId": contract.spec_id, - "source": { - "gitCommit": revision, - "cargoLockSha256": sha256(&lock), - }, - "tools": { - "cargoDeny": contract.tools.cargo_deny, - "cargoCyclonedx": contract.tools.cargo_cyclonedx, - }, - "packages": packages, - "advisoryExceptions": contract.advisory_exception, - })) -} - -fn validate_provenance(provenance: &Value, contract: &Contract) -> Result<(), String> { - if provenance.get("schemaVersion").and_then(Value::as_u64) != Some(1) - || provenance.get("specId").and_then(Value::as_str) != Some(contract.spec_id.as_str()) - || provenance - .get("packages") - .and_then(Value::as_array) - .is_none_or(|packages| packages.len() != contract.package.len()) - || provenance - .pointer("/source/cargoLockSha256") - .and_then(Value::as_str) - .is_none_or(|hash| hash.len() != 64) - { - return Err("generated supply-chain provenance is incomplete".to_owned()); - } - Ok(()) -} - -fn run_command(root: &Path, program: &str, args: Vec<&str>, failure: &str) -> Result<(), String> { - let status = Command::new(program) - .args(args) - .current_dir(root) - .status() - .map_err(|error| format!("failed to start {program}: {error}"))?; - if status.success() { - Ok(()) - } else { - Err(failure.to_owned()) - } -} - -fn command_stdout(root: &Path, program: &str, args: &[&str]) -> Result<String, String> { - let output = Command::new(program) - .args(args) - .current_dir(root) - .output() - .map_err(|error| format!("failed to start {program}: {error}"))?; - if !output.status.success() { - return Err(format!("{program} {} failed", args.join(" "))); - } - String::from_utf8(output.stdout) - .map(|value| value.trim().to_owned()) - .map_err(|error| format!("{program} emitted non-UTF-8 output: {error}")) -} - -fn sha256(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("workspace root") - .to_path_buf() - } - - #[test] - fn current_contract_is_exact_and_exception_bound() { - let root = root(); - let contract = load(&root).expect("contract"); - validate(&root, &contract, 2).expect("valid contract"); - } - - #[test] - fn sbom_validation_rejects_wrong_identity() { - let sbom = json!({ - "bomFormat": "CycloneDX", - "specVersion": "1.5", - "metadata": {"component": {"name": "wrong", "version": "0.1.0-alpha"}}, - "components": [{}], - "dependencies": [{}], - }); - assert!(validate_sbom(&sbom, "radroots_core", "0.1.0-alpha").is_err()); - } - - #[test] - fn normalization_removes_random_and_absolute_identity() { - let mut sbom = json!({ - "serialNumber": "urn:uuid:random", - "path": "/workspace/crate", - }); - normalize_sbom(&mut sbom, Path::new("/workspace")); - assert!(sbom.get("serialNumber").is_none()); - assert_eq!( - sbom.get("path").and_then(Value::as_str), - Some("$REPOSITORY/crate") - ); - } -} diff --git a/tools/xtask/src/target_qualification.rs b/tools/xtask/src/target_qualification.rs @@ -1,199 +0,0 @@ -use std::{fs, path::Path, process::Command}; - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -struct TargetMatrix { - schema_version: u32, - msrv_toolchain: String, - current_toolchain: String, - operating_system: Vec<OperatingSystem>, -} - -#[derive(Debug, Deserialize)] -struct OperatingSystem { - name: String, - target: String, - cross_compiler: Option<String>, - cross_cflags: Option<String>, -} - -#[derive(Debug, Deserialize)] -struct Architecture { - repositories: Repositories, -} - -#[derive(Debug, Deserialize)] -struct Repositories { - sdk: Repository, -} - -#[derive(Debug, Deserialize)] -struct Repository { - packages: Vec<String>, -} - -pub fn run(workspace_root: &Path) -> Result<(), String> { - let (matrix, packages) = load(workspace_root)?; - for toolchain in [&matrix.msrv_toolchain, &matrix.current_toolchain] { - run_command( - workspace_root, - "rustup", - &[ - "run", - toolchain, - "cargo", - "check", - "--workspace", - "--all-targets", - "--locked", - ], - )?; - } - for operating_system in matrix.operating_system { - for package in &packages { - run_target_command( - workspace_root, - &operating_system, - &[ - "check", - "-p", - package, - "--lib", - "--target", - &operating_system.target, - "--locked", - ], - )?; - } - } - Ok(()) -} - -fn load(workspace_root: &Path) -> Result<(TargetMatrix, Vec<String>), String> { - let matrix_path = workspace_root.join("contracts/releases/target_matrix.toml"); - let matrix = toml::from_str::<TargetMatrix>(&read(&matrix_path)?) - .map_err(|error| format!("failed to parse {}: {error}", matrix_path.display()))?; - validate(&matrix)?; - - let architecture_path = workspace_root.join("docs/specs/radroots_crates_release_v1.toml"); - let architecture = toml::from_str::<Architecture>(&read(&architecture_path)?) - .map_err(|error| format!("failed to parse {}: {error}", architecture_path.display()))?; - let mut packages = architecture.repositories.sdk.packages; - packages.sort(); - if packages != ["radroots", "radroots_sdk"] { - return Err(format!( - "target qualification requires exactly the two SDK front doors, found {packages:?}" - )); - } - Ok((matrix, packages)) -} - -fn read(path: &Path) -> Result<String, String> { - fs::read_to_string(path).map_err(|error| format!("failed to read {}: {error}", path.display())) -} - -fn validate(matrix: &TargetMatrix) -> Result<(), String> { - if matrix.schema_version != 1 { - return Err(format!( - "target matrix schema_version must be 1, found {}", - matrix.schema_version - )); - } - if matrix.msrv_toolchain != "1.97.1" || matrix.current_toolchain != "stable" { - return Err("target matrix must retain MSRV 1.97.1 and current stable".to_owned()); - } - let expected = [ - ( - "linux", - "x86_64-unknown-linux-gnu", - Some("zig cc"), - Some("-target x86_64-linux-gnu"), - ), - ("macos", "aarch64-apple-darwin", None, None), - ( - "windows", - "x86_64-pc-windows-gnu", - Some("x86_64-w64-mingw32-gcc"), - None, - ), - ]; - if matrix.operating_system.len() != expected.len() - || expected.iter().any(|(name, target, compiler, cflags)| { - !matrix.operating_system.iter().any(|entry| { - entry.name == *name - && entry.target == *target - && entry.cross_compiler.as_deref() == *compiler - && entry.cross_cflags.as_deref() == *cflags - }) - }) - { - return Err( - "target matrix must contain the exact Linux, macOS, and Windows triples".to_owned(), - ); - } - Ok(()) -} - -fn run_target_command( - workspace_root: &Path, - operating_system: &OperatingSystem, - args: &[&str], -) -> Result<(), String> { - eprintln!("cargo {}", args.join(" ")); - let mut command = Command::new("cargo"); - command.args(args).current_dir(workspace_root); - let target_key = operating_system.target.replace('-', "_"); - if let Some(compiler) = operating_system.cross_compiler.as_deref() { - command.env(format!("CC_{target_key}"), compiler); - } - if let Some(cflags) = operating_system.cross_cflags.as_deref() { - command.env(format!("CFLAGS_{target_key}"), cflags); - } - let status = command - .status() - .map_err(|error| format!("failed to start cargo: {error}"))?; - if status.success() { - Ok(()) - } else { - Err(format!( - "target qualification failed: cargo {}", - args.join(" ") - )) - } -} - -fn run_command(workspace_root: &Path, program: &str, args: &[&str]) -> Result<(), String> { - eprintln!("{program} {}", args.join(" ")); - let status = Command::new(program) - .args(args) - .current_dir(workspace_root) - .status() - .map_err(|error| format!("failed to start {program}: {error}"))?; - if status.success() { - Ok(()) - } else { - Err(format!( - "target qualification failed: {program} {}", - args.join(" ") - )) - } -} - -#[cfg(test)] -mod tests { - use super::load; - - #[test] - fn current_contract_selects_exact_toolchains_targets_and_packages() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(std::path::Path::parent) - .expect("workspace root"); - let (matrix, packages) = load(root).expect("target matrix"); - assert_eq!(matrix.msrv_toolchain, "1.97.1"); - assert_eq!(matrix.current_toolchain, "stable"); - assert_eq!(matrix.operating_system.len(), 3); - assert_eq!(packages, ["radroots", "radroots_sdk"]); - } -} diff --git a/tools/xtask/src/ts.rs b/tools/xtask/src/ts.rs @@ -1,37 +0,0 @@ -pub fn generated_header() -> &'static str { - "// @generated by cargo xtask generate ts\n// Do not edit by hand.\n" -} - -pub fn generated_types_file() -> &'static str { - "types.ts" -} - -pub fn generated_constants_file() -> &'static str { - "constants.ts" -} - -pub fn generated_kinds_file() -> &'static str { - "kinds.ts" -} - -#[cfg(test)] -mod tests { - use super::{ - generated_constants_file, generated_header, generated_kinds_file, generated_types_file, - }; - - #[test] - fn generated_header_matches_contract() { - assert_eq!( - generated_header(), - "// @generated by cargo xtask generate ts\n// Do not edit by hand.\n" - ); - } - - #[test] - fn generated_file_names_are_stable() { - assert_eq!(generated_types_file(), "types.ts"); - assert_eq!(generated_constants_file(), "constants.ts"); - assert_eq!(generated_kinds_file(), "kinds.ts"); - } -} diff --git a/tools/xtask/src/wasm.rs b/tools/xtask/src/wasm.rs @@ -1,1147 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - env, - ffi::OsStr, - fs, io, - path::{Path, PathBuf}, - process::{Command, Stdio}, -}; - -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -use crate::{ - check::check_wasm_package_surface, - fs::workspace_root, - package_matrix::{WasmPackageSpec, validate_package_matrix, wasm_package_specs}, - wasm_declarations::write_declaration_files, -}; - -pub(crate) const WASM_TARGET: &str = "wasm32-unknown-unknown"; -const WASM_C_COMPILER_ENV: &str = "CC_wasm32_unknown_unknown"; -const WASM_CFLAGS_ENV: &str = "CFLAGS_wasm32_unknown_unknown"; -const WASM_C_TARGET_ARG: &str = "--target=wasm32-unknown-unknown"; - -pub fn generate(args: &[String]) -> Result<(), String> { - validate_package_matrix()?; - let specs = selected_specs(args)?; - let root = workspace_root()?; - let toolchain = resolve_wasm_toolchain(&root)?; - println!( - "using Rust toolchain {} for {}: rustc={}, cargo={}", - toolchain.channel(), - WASM_TARGET, - toolchain.rustc().display(), - toolchain.cargo().display() - ); - let wasm_c_compiler = if specs - .iter() - .any(|spec| wasm_package_requires_c_compiler(*spec)) - { - let compiler = resolve_wasm_c_compiler()?; - println!( - "using WASM C compiler for {}: {}", - WASM_TARGET, - compiler.path.display() - ); - Some(compiler) - } else { - None - }; - for spec in specs { - let dist_dir = root.join(spec.package_dir).join("dist"); - if dist_dir.exists() { - fs::remove_dir_all(&dist_dir) - .map_err(|error| format!("failed to remove {}: {error}", dist_dir.display()))?; - } - let mut command = Command::new(&toolchain.wasm_pack); - command.current_dir(&root); - for arg in wasm_pack_args(spec) { - command.arg(arg); - } - toolchain.apply_to_command(&mut command); - if let Some(compiler) = wasm_c_compiler.as_ref() - && wasm_package_requires_c_compiler(spec) - { - compiler.apply_to_command(&mut command); - } - let status = command.status().map_err(|error| { - format!( - "failed to start wasm-pack for {} while generating {}: {error}", - spec.key, spec.package_name - ) - })?; - if !status.success() { - return Err(format!( - "wasm-pack failed for {} while generating {} with status {status}; rerun `cargo xtask generate wasm --package {}` after fixing the wasm toolchain", - spec.key, spec.package_name, spec.key - )); - } - remove_wasm_pack_gitignore(&dist_dir, spec)?; - write_declaration_files(&root, spec)?; - write_provenance_manifest(&root, spec)?; - check_wasm_package_surface(&root, spec)?; - println!("generated wasm package {}", spec.package_name); - } - Ok(()) -} - -#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] -struct WasmProvenanceManifest { - schema_version: u16, - package: String, - package_version: String, - rust_crate: String, - rust_crate_version: String, - target: String, - deterministic_codec: bool, - networking: bool, - signing: bool, - source_sha256: String, - outputs: BTreeMap<String, String>, -} - -fn write_provenance_manifest(root: &Path, spec: WasmPackageSpec) -> Result<(), String> { - let manifest = provenance_manifest(root, spec)?; - let dist_dir = root.join(spec.package_dir).join("dist"); - let path = provenance_manifest_path(&dist_dir, spec); - let mut rendered = serde_json::to_string_pretty(&manifest) - .map_err(|error| format!("failed to render {}: {error}", path.display()))?; - rendered.push('\n'); - fs::write(&path, rendered) - .map_err(|error| format!("failed to write {}: {error}", path.display())) -} - -fn provenance_manifest( - root: &Path, - spec: WasmPackageSpec, -) -> Result<WasmProvenanceManifest, String> { - let package_version = manifest_version(&root.join(spec.package_dir).join("package.json"))?; - let rust_crate_version = manifest_version(&root.join(spec.crate_dir).join("Cargo.toml"))?; - let source_sha256 = source_tree_sha256(root, spec)?; - let dist_dir = root.join(spec.package_dir).join("dist"); - let mut outputs = BTreeMap::new(); - for relative in wasm_output_files(spec) { - let path = dist_dir.join(&relative); - let bytes = fs::read(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - outputs.insert(relative, format!("{:x}", Sha256::digest(bytes))); - } - Ok(WasmProvenanceManifest { - schema_version: 1, - package: spec.package_name.to_owned(), - package_version, - rust_crate: spec.crate_name.to_owned(), - rust_crate_version, - target: WASM_TARGET.to_owned(), - deterministic_codec: spec.key == "event_codec", - networking: false, - signing: false, - source_sha256, - outputs, - }) -} - -pub(crate) fn validate_provenance_manifest( - root: &Path, - spec: WasmPackageSpec, -) -> Result<(), String> { - let path = provenance_manifest_path(&root.join(spec.package_dir).join("dist"), spec); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let actual: WasmProvenanceManifest = serde_json::from_str(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let expected = provenance_manifest(root, spec)?; - if actual != expected { - return Err(format!( - "stale or invalid WASM provenance manifest: {}", - path.display() - )); - } - Ok(()) -} - -fn manifest_version(path: &Path) -> Result<String, String> { - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - if path.extension() == Some(OsStr::new("json")) { - let value: serde_json::Value = serde_json::from_str(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - return value["version"] - .as_str() - .map(str::to_owned) - .ok_or_else(|| format!("{} must define string version", path.display())); - } - let value: toml::Value = raw - .parse() - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - value["package"]["version"] - .as_str() - .map(str::to_owned) - .ok_or_else(|| format!("{} must define explicit package.version", path.display())) -} - -fn source_tree_sha256(root: &Path, spec: WasmPackageSpec) -> Result<String, String> { - let crate_dir = root.join(spec.crate_dir); - let mut files = vec![crate_dir.join("Cargo.toml")]; - collect_source_files(&crate_dir.join("src"), &mut files)?; - files.sort(); - let mut hasher = Sha256::new(); - for path in files { - let relative = path - .strip_prefix(root) - .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; - hasher.update(relative.to_string_lossy().as_bytes()); - hasher.update([0]); - hasher.update( - fs::read(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?, - ); - hasher.update([0]); - } - Ok(format!("{:x}", hasher.finalize())) -} - -fn collect_source_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), String> { - let mut entries = fs::read_dir(dir) - .map_err(|error| format!("failed to read {}: {error}", dir.display()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|error| format!("failed to enumerate {}: {error}", dir.display()))?; - entries.sort_by_key(std::fs::DirEntry::file_name); - for entry in entries { - let path = entry.path(); - if path.is_dir() { - collect_source_files(&path, files)?; - } else if path.is_file() { - files.push(path); - } - } - Ok(()) -} - -fn wasm_output_files(spec: WasmPackageSpec) -> [String; 4] { - [ - format!("{}.js", spec.out_name), - format!("{}.d.ts", spec.out_name), - format!("{}_bg.wasm", spec.out_name), - format!("{}_bg.wasm.d.ts", spec.out_name), - ] -} - -fn provenance_manifest_path(dist_dir: &Path, spec: WasmPackageSpec) -> PathBuf { - dist_dir.join(format!("{}.provenance.json", spec.out_name)) -} - -struct WasmToolchain { - wasm_pack: PathBuf, - rust: ResolvedRustToolchain, -} - -impl WasmToolchain { - fn apply_to_command(&self, command: &mut Command) { - self.rust.apply_to_command(command); - } - - fn rustc(&self) -> &Path { - &self.rust.rustc - } - - fn cargo(&self) -> &Path { - &self.rust.cargo - } - - fn channel(&self) -> &str { - &self.rust.channel - } -} - -pub(crate) struct ResolvedRustToolchain { - pub(crate) channel: String, - pub(crate) rustc: PathBuf, - pub(crate) cargo: PathBuf, - pub(crate) bin_dir: PathBuf, -} - -impl ResolvedRustToolchain { - pub(crate) fn apply_to_command(&self, command: &mut Command) { - prepend_path(command, &self.bin_dir); - command.env("RUSTC", &self.rustc); - command.env("CARGO", &self.cargo); - command.env("RUSTUP_TOOLCHAIN", &self.channel); - } -} - -fn resolve_wasm_toolchain(root: &Path) -> Result<WasmToolchain, String> { - let wasm_pack = resolve_required_path_tool("wasm-pack")?; - let rust = resolve_rust_toolchain(root)?; - Ok(WasmToolchain { wasm_pack, rust }) -} - -pub(crate) fn resolve_rust_toolchain(root: &Path) -> Result<ResolvedRustToolchain, String> { - let toolchain_path = root.join("rust-toolchain.toml"); - let channel = read_rust_toolchain_channel(&toolchain_path)?; - let rustc = rustup_tool_for_channel("rustc", &channel)?; - let cargo = rustup_tool_for_channel("cargo", &channel)?; - let rustc_bin = rustc.parent().ok_or_else(|| { - format!( - "rustup resolved rustc for toolchain {channel} without a parent path: {}", - rustc.display() - ) - })?; - let cargo_bin = cargo.parent().ok_or_else(|| { - format!( - "rustup resolved cargo for toolchain {channel} without a parent path: {}", - cargo.display() - ) - })?; - if rustc_bin != cargo_bin { - return Err(format!( - "rustup resolved mismatched Rust tool paths for toolchain {channel}: rustc={}, cargo={}", - rustc.display(), - cargo.display() - )); - } - let bin_dir = rustc_bin.to_path_buf(); - ensure_wasm_target_installed(&channel)?; - Ok(ResolvedRustToolchain { - channel, - rustc, - cargo, - bin_dir, - }) -} - -fn wasm_pack_args(spec: WasmPackageSpec) -> Vec<&'static str> { - vec![ - "build", - spec.crate_dir, - "--release", - "--target", - "web", - "--out-dir", - spec.out_dir, - "--out-name", - spec.out_name, - "--no-pack", - ] -} - -fn remove_wasm_pack_gitignore(dist_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> { - let ignore_path = dist_dir.join(".gitignore"); - let contents = match fs::read_to_string(&ignore_path) { - Ok(contents) => contents, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), - Err(error) => { - return Err(format!( - "failed to read wasm-pack ignore file for {}: {}: {error}", - spec.package_name, - ignore_path.display() - )); - } - }; - if contents.trim() != "*" { - return Err(format!( - "unexpected wasm-pack ignore file for {}: {}; refusing to remove it", - spec.package_name, - ignore_path.display() - )); - } - fs::remove_file(&ignore_path).map_err(|error| { - format!( - "failed to remove wasm-pack ignore file for {}: {}: {error}", - spec.package_name, - ignore_path.display() - ) - }) -} - -fn selected_specs(args: &[String]) -> Result<Vec<WasmPackageSpec>, String> { - match args { - [] => Ok(wasm_package_specs().to_vec()), - [flag, key] if flag == "--package" => wasm_package_specs() - .iter() - .copied() - .find(|spec| spec.key == key) - .map(|spec| vec![spec]) - .ok_or_else(|| format!("unknown wasm package: {key}")), - _ => Err("usage: cargo xtask generate wasm [--package <key>]".to_owned()), - } -} - -fn wasm_package_requires_c_compiler(spec: WasmPackageSpec) -> bool { - spec.key == "event_codec" -} - -fn resolve_required_path_tool(name: &str) -> Result<PathBuf, String> { - let path = env::var_os("PATH").ok_or_else(|| { - format!("missing {name}: PATH is not set; install {name} and expose it on PATH") - })?; - resolve_path_tool_from_path(name, &path) -} - -fn resolve_path_tool_from_path(name: &str, path: &std::ffi::OsStr) -> Result<PathBuf, String> { - let matches = executable_matches(name, path); - match matches.as_slice() { - [] => Err(format!( - "missing {name}: install {name} and rerun `cargo xtask generate wasm`" - )), - [tool] => Ok(tool.clone()), - _ => Err(format!( - "ambiguous {name}: found {}; remove duplicate {name} entries from PATH before running `cargo xtask generate wasm`", - matches - .iter() - .map(|path| path.display().to_string()) - .collect::<Vec<_>>() - .join(", ") - )), - } -} - -fn executable_matches(name: &str, path: &std::ffi::OsStr) -> Vec<PathBuf> { - let mut seen = BTreeSet::new(); - let mut matches = Vec::new(); - for dir in env::split_paths(path) { - let candidate = dir.join(name); - if !is_executable_file(&candidate) { - continue; - } - let key = fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone()); - if seen.insert(key) { - matches.push(candidate); - } - } - matches -} - -fn is_executable_file(path: &Path) -> bool { - if !path.is_file() { - return false; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - path.metadata() - .map(|metadata| metadata.permissions().mode() & 0o111 != 0) - .unwrap_or(false) - } - #[cfg(not(unix))] - { - true - } -} - -fn read_rust_toolchain_channel(path: &Path) -> Result<String, String> { - let contents = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - rust_toolchain_channel_from(&contents) -} - -fn rust_toolchain_channel_from(contents: &str) -> Result<String, String> { - let value = contents - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse rust-toolchain.toml: {error}"))?; - let channel = value - .get("toolchain") - .and_then(|toolchain| toolchain.get("channel")) - .and_then(toml::Value::as_str) - .ok_or_else(|| "rust-toolchain.toml must define toolchain.channel".to_owned())? - .trim(); - if channel.is_empty() { - return Err("rust-toolchain.toml toolchain.channel must not be empty".to_owned()); - } - Ok(channel.to_owned()) -} - -fn rustup_tool_for_channel(name: &str, channel: &str) -> Result<PathBuf, String> { - let output = Command::new("rustup") - .arg("which") - .arg("--toolchain") - .arg(channel) - .arg(name) - .output() - .map_err(|error| { - format!( - "failed to resolve {name} for Rust toolchain {channel} with rustup: {error}; install rustup toolchain {channel}" - ) - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "failed to resolve {name} for Rust toolchain {channel}: {}; run `rustup toolchain install {channel}`", - stderr.trim() - )); - } - let path = String::from_utf8(output.stdout) - .map_err(|error| format!("rustup emitted non-UTF-8 {name} path: {error}"))?; - let trimmed = path.trim(); - if trimmed.is_empty() { - return Err(format!( - "rustup returned an empty {name} path for Rust toolchain {channel}" - )); - } - Ok(PathBuf::from(trimmed)) -} - -fn ensure_wasm_target_installed(channel: &str) -> Result<(), String> { - let output = Command::new("rustup") - .args(rustup_target_list_args(channel)) - .output() - .map_err(|error| { - format!( - "failed to verify {WASM_TARGET} target for Rust toolchain {channel} with rustup: {error}; install rustup toolchain {channel}" - ) - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "failed to verify {WASM_TARGET} target for Rust toolchain {channel}: {}; run `rustup target add {WASM_TARGET} --toolchain {channel}`", - stderr.trim() - )); - } - let stdout = String::from_utf8_lossy(&output.stdout); - validate_target_list(&stdout, WASM_TARGET, channel) -} - -fn rustup_target_list_args(channel: &str) -> [&str; 5] { - ["target", "list", "--installed", "--toolchain", channel] -} - -fn validate_target_list(output: &str, target: &str, channel: &str) -> Result<(), String> { - if target_list_contains(output, target) { - Ok(()) - } else { - Err(format!( - "missing Rust target {target} for Rust toolchain {channel}: run `rustup target add {target} --toolchain {channel}`" - )) - } -} - -fn target_list_contains(output: &str, target: &str) -> bool { - output.lines().any(|line| line.trim() == target) -} - -#[derive(Debug)] -struct WasmCCompiler { - path: PathBuf, - cflags: ResolvedWasmCFlags, -} - -impl WasmCCompiler { - fn apply_to_command(&self, command: &mut Command) { - command.env(WASM_C_COMPILER_ENV, &self.path); - command.env(WASM_CFLAGS_ENV, &self.cflags.value); - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ResolvedWasmCFlags { - value: String, - args: Vec<String>, -} - -fn resolve_wasm_c_compiler() -> Result<WasmCCompiler, String> { - let path = env::var_os("PATH").unwrap_or_default(); - let explicit = env::var_os(WASM_C_COMPILER_ENV); - let cflags = resolve_wasm_cflags(env::var_os(WASM_CFLAGS_ENV).as_deref())?; - let candidates = wasm_c_compiler_candidates(&path); - resolve_wasm_c_compiler_with( - explicit.as_deref(), - &path, - candidates, - cflags, - verify_wasm_c_compiler, - ) -} - -fn resolve_wasm_c_compiler_with<F>( - explicit: Option<&OsStr>, - path: &OsStr, - candidates: Vec<PathBuf>, - cflags: ResolvedWasmCFlags, - verify: F, -) -> Result<WasmCCompiler, String> -where - F: Fn(&Path, &ResolvedWasmCFlags) -> Result<(), String>, -{ - if let Some(explicit) = explicit { - let compiler = resolve_explicit_wasm_c_compiler(explicit, path)?; - verify(&compiler, &cflags).map_err(|error| { - format!( - "{WASM_C_COMPILER_ENV} does not name a WASM-capable C compiler: {}; {error}", - compiler.display() - ) - })?; - return Ok(WasmCCompiler { - path: compiler, - cflags, - }); - } - - let mut checked = Vec::new(); - for candidate in candidates { - if !is_executable_file(&candidate) { - continue; - } - checked.push(candidate.display().to_string()); - if verify(&candidate, &cflags).is_ok() { - return Ok(WasmCCompiler { - path: candidate, - cflags, - }); - } - } - let checked = if checked.is_empty() { - "none".to_owned() - } else { - checked.join(", ") - }; - Err(format!( - "missing suitable WASM C compiler for {WASM_TARGET}: set {WASM_C_COMPILER_ENV} to a clang-style compiler that accepts `{WASM_C_TARGET_ARG}`; checked candidates: {checked}" - )) -} - -fn resolve_wasm_cflags(value: Option<&OsStr>) -> Result<ResolvedWasmCFlags, String> { - let raw = match value { - Some(value) => Some( - value - .to_str() - .ok_or_else(|| format!("{WASM_CFLAGS_ENV} must be valid UTF-8 compiler flags"))?, - ), - None => None, - }; - resolve_wasm_cflags_from_str(raw) -} - -fn resolve_wasm_cflags_from_str(value: Option<&str>) -> Result<ResolvedWasmCFlags, String> { - let raw = value.unwrap_or(""); - let words = parse_cflags_words(raw)?; - let mut has_required_target = false; - for index in 0..words.len() { - let word = &words[index]; - if word == WASM_C_TARGET_ARG { - has_required_target = true; - } else if wasm_cflags_target_conflict(index, &words) { - return Err(format!( - "{WASM_CFLAGS_ENV} contains unsupported target flag `{}`; use `{WASM_C_TARGET_ARG}` or omit the target so xtask can prepend it", - wasm_cflags_target_display(index, &words) - )); - } - } - let value = if has_required_target { - raw.to_owned() - } else { - let trimmed = raw.trim(); - if trimmed.is_empty() { - WASM_C_TARGET_ARG.to_owned() - } else { - format!("{WASM_C_TARGET_ARG} {trimmed}") - } - }; - let args = parse_cflags_words(&value)?; - Ok(ResolvedWasmCFlags { value, args }) -} - -fn wasm_cflags_target_conflict(index: usize, words: &[String]) -> bool { - let word = &words[index]; - word == "--target" - || word == "-target" - || word.starts_with("--target=") - || word.starts_with("-target=") -} - -fn wasm_cflags_target_display(index: usize, words: &[String]) -> String { - let word = &words[index]; - if matches!(word.as_str(), "--target" | "-target") - && let Some(target) = words.get(index + 1) - { - return format!("{word} {target}"); - } - word.clone() -} - -fn parse_cflags_words(value: &str) -> Result<Vec<String>, String> { - let mut words = Vec::new(); - let mut current = String::new(); - let mut quote = CFlagsQuote::None; - let mut in_word = false; - let mut chars = value.chars(); - while let Some(character) = chars.next() { - match quote { - CFlagsQuote::None => match character { - character if character.is_whitespace() => { - if in_word { - words.push(std::mem::take(&mut current)); - in_word = false; - } - } - '\'' => { - in_word = true; - quote = CFlagsQuote::Single; - } - '"' => { - in_word = true; - quote = CFlagsQuote::Double; - } - '\\' => { - in_word = true; - let Some(escaped) = chars.next() else { - return Err(format!("{WASM_CFLAGS_ENV} ends with an unfinished escape")); - }; - current.push(escaped); - } - character => { - in_word = true; - current.push(character); - } - }, - CFlagsQuote::Single => { - if character == '\'' { - quote = CFlagsQuote::None; - } else { - current.push(character); - } - } - CFlagsQuote::Double => { - if character == '"' { - quote = CFlagsQuote::None; - } else if character == '\\' { - let Some(escaped) = chars.next() else { - return Err(format!("{WASM_CFLAGS_ENV} ends with an unfinished escape")); - }; - current.push(escaped); - } else { - current.push(character); - } - } - } - } - match quote { - CFlagsQuote::None => { - if in_word { - words.push(current); - } - Ok(words) - } - CFlagsQuote::Single => Err(format!( - "{WASM_CFLAGS_ENV} contains an unterminated ' quote" - )), - CFlagsQuote::Double => Err(format!( - "{WASM_CFLAGS_ENV} contains an unterminated \" quote" - )), - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum CFlagsQuote { - None, - Single, - Double, -} - -fn resolve_explicit_wasm_c_compiler(value: &OsStr, path: &OsStr) -> Result<PathBuf, String> { - if value.is_empty() { - return Err(format!( - "{WASM_C_COMPILER_ENV} is set but empty; set it to a compiler path that accepts `{WASM_C_TARGET_ARG}`" - )); - } - let candidate = PathBuf::from(value); - if candidate.components().count() > 1 { - if is_executable_file(&candidate) { - Ok(candidate) - } else { - Err(format!( - "{WASM_C_COMPILER_ENV} is not executable: {}", - candidate.display() - )) - } - } else { - first_executable_match(value, path).ok_or_else(|| { - format!( - "{WASM_C_COMPILER_ENV} command was not found on PATH: {}", - value.to_string_lossy() - ) - }) - } -} - -fn first_executable_match(name: &OsStr, path: &OsStr) -> Option<PathBuf> { - for dir in env::split_paths(path) { - let candidate = dir.join(name); - if is_executable_file(&candidate) { - return Some(candidate); - } - } - None -} - -fn wasm_c_compiler_candidates(path: &OsStr) -> Vec<PathBuf> { - let mut seen = BTreeSet::new(); - let mut candidates = Vec::new(); - for name in ["wasm32-unknown-unknown-clang", "clang"] { - for candidate in executable_matches(name, path) { - let key = fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone()); - if seen.insert(key) { - candidates.push(candidate); - } - } - } - candidates -} - -fn verify_wasm_c_compiler(path: &Path, cflags: &ResolvedWasmCFlags) -> Result<(), String> { - let tempdir = tempfile::tempdir() - .map_err(|error| format!("failed to create C probe tempdir: {error}"))?; - let source_path = tempdir.path().join("wasm_probe.c"); - let object_path = tempdir.path().join("wasm_probe.o"); - fs::write( - &source_path, - "int radroots_wasm_probe(void) { return 0; }\n", - ) - .map_err(|error| format!("failed to write C compiler probe: {error}"))?; - let output = Command::new(path) - .args(&cflags.args) - .arg("-c") - .arg(&source_path) - .arg("-o") - .arg(&object_path) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|error| format!("failed to run {}: {error}", path.display()))?; - if output.status.success() && object_path.is_file() { - return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!( - "{} rejected `{WASM_CFLAGS_ENV}={}` for {}: {}", - path.display(), - cflags.value, - WASM_TARGET, - stderr.trim() - )) -} - -fn prepend_path(command: &mut Command, prefix: &Path) { - let existing = env::var_os("PATH").unwrap_or_default(); - let mut paths = vec![prefix.to_path_buf()]; - paths.extend(env::split_paths(&existing)); - if let Ok(joined) = env::join_paths(paths) { - command.env("PATH", joined); - } -} - -#[cfg(test)] -mod tests { - use std::{ - env, - ffi::OsStr, - fs, - path::{Path, PathBuf}, - process::Command, - time::{SystemTime, UNIX_EPOCH}, - }; - - use crate::package_matrix::wasm_package_specs; - - use super::{ - ResolvedWasmCFlags, WASM_C_COMPILER_ENV, WASM_C_TARGET_ARG, WASM_CFLAGS_ENV, WASM_TARGET, - WasmCCompiler, remove_wasm_pack_gitignore, resolve_path_tool_from_path, - resolve_wasm_c_compiler_with, resolve_wasm_cflags_from_str, rust_toolchain_channel_from, - rustup_target_list_args, selected_specs, target_list_contains, validate_target_list, - wasm_pack_args, wasm_package_requires_c_compiler, - }; - - #[test] - fn selects_all_specs_by_default() { - assert_eq!(selected_specs(&[]).expect("all specs").len(), 3); - } - - #[test] - fn selects_one_spec_by_key() { - let specs = selected_specs(&["--package".to_owned(), "replica_store".to_owned()]) - .expect("replica store spec"); - assert_eq!(specs[0].package_name, "@radroots/replica-store-wasm"); - } - - #[test] - fn rejects_unknown_spec_key() { - assert!(selected_specs(&["--package".to_owned(), "missing".to_owned()]).is_err()); - } - - #[test] - fn wasm_pack_arguments_disable_package_manifest_generation() { - let args = wasm_pack_args(wasm_package_specs()[0]); - assert!(args.contains(&"--no-pack")); - } - - #[test] - fn removes_wasm_pack_generated_gitignore() { - let root = test_root("wasm_pack_gitignore"); - let dist_dir = root.join("dist"); - fs::create_dir_all(&dist_dir).expect("create dist"); - fs::write(dist_dir.join(".gitignore"), "*\n").expect("write ignore"); - - remove_wasm_pack_gitignore(&dist_dir, wasm_package_specs()[0]).expect("remove ignore"); - - assert!(!dist_dir.join(".gitignore").exists()); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn refuses_unexpected_wasm_pack_gitignore_contents() { - let root = test_root("custom_gitignore"); - let dist_dir = root.join("dist"); - fs::create_dir_all(&dist_dir).expect("create dist"); - fs::write(dist_dir.join(".gitignore"), "!keep\n").expect("write ignore"); - - let error = remove_wasm_pack_gitignore(&dist_dir, wasm_package_specs()[0]) - .expect_err("custom ignore rejected"); - - assert!(error.contains("unexpected wasm-pack ignore file")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn path_tool_resolution_reports_missing_tools() { - let error = resolve_path_tool_from_path("wasm-pack", std::ffi::OsStr::new("")) - .expect_err("missing"); - assert!(error.contains("missing wasm-pack")); - } - - #[test] - fn path_tool_resolution_reports_ambiguous_tools() { - let root = test_root("ambiguous_wasm_pack"); - let first = root.join("first"); - let second = root.join("second"); - fs::create_dir_all(&first).expect("create first dir"); - fs::create_dir_all(&second).expect("create second dir"); - write_executable(first.join("wasm-pack")); - write_executable(second.join("wasm-pack")); - let path = env::join_paths([first, second]).expect("join path"); - - let error = - resolve_path_tool_from_path("wasm-pack", &path).expect_err("ambiguous wasm-pack"); - - assert!(error.contains("ambiguous wasm-pack")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn target_list_parser_requires_exact_target() { - assert!(target_list_contains( - "aarch64-apple-darwin\nwasm32-unknown-unknown\n", - "wasm32-unknown-unknown" - )); - assert!(!target_list_contains( - "wasm32-unknown-emscripten\n", - "wasm32-unknown-unknown" - )); - } - - #[test] - fn rustup_target_list_args_are_bound_to_selected_toolchain() { - assert_eq!( - rustup_target_list_args("1.97.0"), - ["target", "list", "--installed", "--toolchain", "1.97.0"] - ); - } - - #[test] - fn missing_wasm_target_error_names_selected_toolchain() { - let error = validate_target_list("aarch64-apple-darwin\n", WASM_TARGET, "1.97.0") - .expect_err("missing target"); - - assert!(error.contains("wasm32-unknown-unknown")); - assert!(error.contains("--toolchain 1.97.0")); - } - - #[test] - fn parses_rust_toolchain_channel() { - let channel = rust_toolchain_channel_from( - r#"[toolchain] -channel = "1.97.0" -"#, - ) - .expect("channel"); - - assert_eq!(channel, "1.97.0"); - } - - #[test] - fn rejects_missing_wasm_c_compiler() { - let error = resolve_wasm_c_compiler_with( - None, - OsStr::new(""), - Vec::new(), - wasm_cflags(None), - |_, _| Ok(()), - ) - .expect_err("missing compiler"); - - assert!(error.contains(WASM_C_COMPILER_ENV)); - assert!(error.contains(WASM_C_TARGET_ARG)); - } - - #[test] - fn rejects_unsuitable_wasm_c_compiler_candidates() { - let root = test_root("unsuitable_wasm_c_compiler"); - fs::create_dir_all(&root).expect("create root"); - let compiler = root.join("clang"); - write_executable(compiler.clone()); - - let error = resolve_wasm_c_compiler_with( - None, - OsStr::new(""), - vec![compiler], - wasm_cflags(None), - |_, _| Err("target unsupported".to_owned()), - ) - .expect_err("unsuitable compiler"); - - assert!(error.contains("missing suitable WASM C compiler")); - assert!(error.contains(WASM_C_COMPILER_ENV)); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn accepts_verified_wasm_c_compiler_candidate() { - let root = test_root("verified_wasm_c_compiler"); - fs::create_dir_all(&root).expect("create root"); - let compiler = root.join("clang"); - write_executable(compiler.clone()); - - let resolved = resolve_wasm_c_compiler_with( - None, - OsStr::new(""), - vec![compiler.clone()], - wasm_cflags(Some("-O2")), - |path: &Path, cflags: &ResolvedWasmCFlags| { - assert_eq!(path, compiler); - assert_eq!(cflags.value, format!("{WASM_C_TARGET_ARG} -O2")); - Ok(()) - }, - ) - .expect("compiler"); - - assert_eq!(resolved.path, compiler); - assert_eq!(resolved.cflags.value, format!("{WASM_C_TARGET_ARG} -O2")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn absent_wasm_cflags_resolve_to_required_target() { - let resolved = resolve_wasm_cflags_from_str(None).expect("cflags"); - - assert_eq!(resolved.value, WASM_C_TARGET_ARG); - assert_eq!(resolved.args, [WASM_C_TARGET_ARG.to_owned()]); - } - - #[test] - fn present_valid_wasm_cflags_preserve_additional_flags() { - let value = "-O2 --target=wasm32-unknown-unknown -fvisibility=hidden"; - let resolved = resolve_wasm_cflags_from_str(Some(value)).expect("cflags"); - - assert_eq!(resolved.value, value); - assert_eq!( - resolved.args, - [ - "-O2".to_owned(), - WASM_C_TARGET_ARG.to_owned(), - "-fvisibility=hidden".to_owned() - ] - ); - } - - #[test] - fn missing_target_wasm_cflags_prepend_required_target() { - let resolved = resolve_wasm_cflags_from_str(Some(" -O2 -fPIC ")).expect("target prepended"); - - assert_eq!(resolved.value, format!("{WASM_C_TARGET_ARG} -O2 -fPIC")); - assert_eq!( - resolved.args, - [ - WASM_C_TARGET_ARG.to_owned(), - "-O2".to_owned(), - "-fPIC".to_owned() - ] - ); - } - - #[test] - fn conflicting_target_wasm_cflags_are_rejected() { - let error = resolve_wasm_cflags_from_str(Some("--target=wasm32-wasip1 -O2")) - .expect_err("conflicting target"); - - assert!(error.contains(WASM_CFLAGS_ENV)); - assert!(error.contains("--target=wasm32-wasip1")); - assert!(error.contains(WASM_C_TARGET_ARG)); - } - - #[test] - fn separated_target_wasm_cflags_are_rejected() { - let error = resolve_wasm_cflags_from_str(Some("--target wasm32-unknown-unknown")) - .expect_err("separated target"); - - assert!(error.contains("--target wasm32-unknown-unknown")); - assert!(error.contains(WASM_C_TARGET_ARG)); - } - - #[test] - fn wasm_c_compiler_command_uses_resolved_cflags() { - let compiler = WasmCCompiler { - path: PathBuf::from("clang"), - cflags: wasm_cflags(Some("-O2")), - }; - let mut command = Command::new("wasm-pack"); - - compiler.apply_to_command(&mut command); - - assert_eq!( - command_env(&command, WASM_C_COMPILER_ENV), - Some(std::ffi::OsString::from("clang")) - ); - assert_eq!( - command_env(&command, WASM_CFLAGS_ENV), - Some(std::ffi::OsString::from(format!("{WASM_C_TARGET_ARG} -O2"))) - ); - } - - #[test] - fn event_codec_wasm_requires_c_compiler() { - assert!(wasm_package_requires_c_compiler(wasm_package_specs()[0])); - assert!(!wasm_package_requires_c_compiler(wasm_package_specs()[1])); - } - - fn wasm_cflags(value: Option<&str>) -> ResolvedWasmCFlags { - resolve_wasm_cflags_from_str(value).expect("resolved cflags") - } - - fn command_env(command: &Command, name: &str) -> Option<std::ffi::OsString> { - command.get_envs().find_map(|(key, value)| { - if key == OsStr::new(name) { - value.map(std::ffi::OsStr::to_os_string) - } else { - None - } - }) - } - - fn write_executable(path: PathBuf) { - fs::write(&path, "#!/bin/sh\n").expect("write executable"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut permissions = fs::metadata(&path).expect("metadata").permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&path, permissions).expect("set executable permissions"); - } - } - - fn test_root(name: &str) -> PathBuf { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time") - .as_nanos(); - env::temp_dir().join(format!("radroots_sdk_xtask_{name}_{stamp}")) - } -} diff --git a/tools/xtask/src/wasm_declarations.rs b/tools/xtask/src/wasm_declarations.rs @@ -1,1147 +0,0 @@ -use std::path::Path; - -use dto_bindgen_backend_ts::{ - TypeScriptDeclaration, TypeScriptModule, TypeScriptParameter, TypeScriptProperty, - TypeScriptType, TypeScriptValue, -}; - -use crate::package_matrix::WasmPackageSpec; - -const WASM_DECLARATION_HEADER: &str = - "// @generated by cargo xtask generate wasm via dto_bindgen\n// Do not edit by hand.\n"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct WasmDeclarationFile { - pub relative_path: String, - pub contents: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct WasmDeclarationInventory { - public_functions: &'static [WasmPublicFunction], - support_exports: &'static [WasmSupportExport], -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct WasmPublicFunction { - name: &'static str, - parameter: Option<&'static str>, - return_kind: WasmReturnKind, - low_level: LowLevelSignature, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum WasmReturnKind { - String, - Any, - Void, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct LowLevelSignature { - parameters: usize, - returns: usize, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct WasmSupportExport { - name: &'static str, - export_type: WasmSupportExportType, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum WasmSupportExportType { - Memory, - Table, - Function(LowLevelSignature), - NumberFunction(usize), -} - -macro_rules! public_function { - ($name:literal, $parameter:expr, $return_kind:ident, $low_parameters:literal, $low_returns:literal) => { - WasmPublicFunction { - name: $name, - parameter: $parameter, - return_kind: WasmReturnKind::$return_kind, - low_level: LowLevelSignature { - parameters: $low_parameters, - returns: $low_returns, - }, - } - }; -} - -macro_rules! memory_export { - () => { - WasmSupportExport { - name: "memory", - export_type: WasmSupportExportType::Memory, - } - }; -} - -macro_rules! table_export { - ($name:literal) => { - WasmSupportExport { - name: $name, - export_type: WasmSupportExportType::Table, - } - }; -} - -macro_rules! void_function_export { - ($name:literal, $parameters:literal) => { - WasmSupportExport { - name: $name, - export_type: WasmSupportExportType::Function(LowLevelSignature { - parameters: $parameters, - returns: 0, - }), - } - }; -} - -macro_rules! number_function_export { - ($name:literal, $parameters:literal) => { - WasmSupportExport { - name: $name, - export_type: WasmSupportExportType::NumberFunction($parameters), - } - }; -} - -pub fn write_declaration_files(root: &Path, spec: WasmPackageSpec) -> Result<(), String> { - let package_dir = root.join(spec.package_dir); - for file in declaration_files(spec)? { - crate::fs::write_if_changed(&package_dir.join(&file.relative_path), &file.contents)?; - } - Ok(()) -} - -pub fn declaration_files(spec: WasmPackageSpec) -> Result<Vec<WasmDeclarationFile>, String> { - let inventory = inventory(spec)?; - Ok(vec![ - WasmDeclarationFile { - relative_path: format!("dist/{}.d.ts", spec.out_name), - contents: render_declaration_module(primary_module(spec, inventory)), - }, - WasmDeclarationFile { - relative_path: format!("dist/{}_bg.wasm.d.ts", spec.out_name), - contents: render_declaration_module(sidecar_module(spec, inventory)), - }, - ]) -} - -fn inventory(spec: WasmPackageSpec) -> Result<WasmDeclarationInventory, String> { - match spec.key { - "event_codec" => Ok(WasmDeclarationInventory { - public_functions: EVENT_CODEC_FUNCTIONS, - support_exports: EVENT_CODEC_SUPPORT_EXPORTS, - }), - "replica_store" => Ok(WasmDeclarationInventory { - public_functions: REPLICA_STORE_FUNCTIONS, - support_exports: SQL_WASM_SUPPORT_EXPORTS, - }), - "replica_sync" => Ok(WasmDeclarationInventory { - public_functions: REPLICA_SYNC_FUNCTIONS, - support_exports: SQL_WASM_SUPPORT_EXPORTS, - }), - _ => Err(format!( - "missing wasm declaration inventory for {}", - spec.key - )), - } -} - -fn primary_module(spec: WasmPackageSpec, inventory: WasmDeclarationInventory) -> TypeScriptModule { - let mut module = TypeScriptModule::new(format!("dist/{}.d.ts", spec.out_name)); - for function in inventory.public_functions { - module.push_declaration(TypeScriptDeclaration::function( - function.name, - public_parameters(function.parameter), - public_return_type(function.return_kind), - )); - } - module.push_declaration(TypeScriptDeclaration::type_alias( - "InitInput", - init_input_type(), - )); - module.push_declaration(TypeScriptDeclaration::type_alias( - "InitOutput", - init_output_type(inventory), - )); - module.push_declaration(TypeScriptDeclaration::type_alias( - "SyncInitInput", - sync_init_input_type(), - )); - module.push_declaration(TypeScriptDeclaration::function( - "initSync", - vec![TypeScriptParameter::new( - "module", - TypeScriptType::union(vec![ - TypeScriptType::object(vec![TypeScriptProperty::new( - "module", - TypeScriptType::named("SyncInitInput"), - )]), - TypeScriptType::named("SyncInitInput"), - ]), - )], - TypeScriptType::named("InitOutput"), - )); - module.push_declaration(TypeScriptDeclaration::default_function( - "__wbg_init", - vec![ - TypeScriptParameter::new( - "module_or_path", - TypeScriptType::union(vec![ - TypeScriptType::object(vec![TypeScriptProperty::new( - "module_or_path", - TypeScriptType::union(vec![ - TypeScriptType::named("InitInput"), - promise_of(TypeScriptType::named("InitInput")), - ]), - )]), - TypeScriptType::named("InitInput"), - promise_of(TypeScriptType::named("InitInput")), - ]), - ) - .optional(), - ], - promise_of(TypeScriptType::named("InitOutput")), - )); - module -} - -fn sidecar_module(spec: WasmPackageSpec, inventory: WasmDeclarationInventory) -> TypeScriptModule { - let mut module = TypeScriptModule::new(format!("dist/{}_bg.wasm.d.ts", spec.out_name)); - for export in inventory.support_exports { - if matches!(export.export_type, WasmSupportExportType::Memory) { - module.push_declaration(support_export_declaration(*export)); - } - } - for function in inventory.public_functions { - module.push_declaration(TypeScriptDeclaration::constant( - function.name, - Some(low_level_function_type(function.low_level)), - TypeScriptValue::string(""), - )); - } - for export in inventory.support_exports { - if !matches!(export.export_type, WasmSupportExportType::Memory) { - module.push_declaration(support_export_declaration(*export)); - } - } - module -} - -fn render_declaration_module(module: TypeScriptModule) -> String { - let mut rendered = String::from(WASM_DECLARATION_HEADER); - rendered.push_str(&module.render_declaration()); - rendered -} - -fn public_parameters(parameter: Option<&'static str>) -> Vec<TypeScriptParameter> { - parameter - .map(|name| vec![TypeScriptParameter::new(name, TypeScriptType::String)]) - .unwrap_or_default() -} - -fn public_return_type(kind: WasmReturnKind) -> TypeScriptType { - match kind { - WasmReturnKind::String => TypeScriptType::String, - WasmReturnKind::Any => TypeScriptType::Any, - WasmReturnKind::Void => TypeScriptType::Void, - } -} - -fn init_input_type() -> TypeScriptType { - TypeScriptType::union(vec![ - TypeScriptType::named("RequestInfo"), - TypeScriptType::named("URL"), - TypeScriptType::named("Response"), - TypeScriptType::named("BufferSource"), - TypeScriptType::named("WebAssembly.Module"), - ]) -} - -fn sync_init_input_type() -> TypeScriptType { - TypeScriptType::union(vec![ - TypeScriptType::named("BufferSource"), - TypeScriptType::named("WebAssembly.Module"), - ]) -} - -fn init_output_type(inventory: WasmDeclarationInventory) -> TypeScriptType { - let mut properties = vec![TypeScriptProperty::new("memory", memory_type()).readonly()]; - properties.extend(inventory.public_functions.iter().map(|function| { - TypeScriptProperty::new(function.name, low_level_function_type(function.low_level)) - .readonly() - })); - properties.extend( - inventory - .support_exports - .iter() - .filter(|export| !matches!(export.export_type, WasmSupportExportType::Memory)) - .map(|export| { - TypeScriptProperty::new(export.name, support_export_type(*export)).readonly() - }), - ); - TypeScriptType::object(properties) -} - -fn support_export_declaration(export: WasmSupportExport) -> TypeScriptDeclaration { - TypeScriptDeclaration::constant( - export.name, - Some(support_export_type(export)), - TypeScriptValue::string(""), - ) -} - -fn support_export_type(export: WasmSupportExport) -> TypeScriptType { - match export.export_type { - WasmSupportExportType::Memory => memory_type(), - WasmSupportExportType::Table => TypeScriptType::named("WebAssembly.Table"), - WasmSupportExportType::Function(signature) => low_level_function_type(signature), - WasmSupportExportType::NumberFunction(parameters) => { - low_level_number_function_type(parameters) - } - } -} - -fn memory_type() -> TypeScriptType { - TypeScriptType::named("WebAssembly.Memory") -} - -fn low_level_number_function_type(parameters: usize) -> TypeScriptType { - TypeScriptType::function(number_parameters(parameters), TypeScriptType::Number) -} - -fn low_level_function_type(signature: LowLevelSignature) -> TypeScriptType { - TypeScriptType::function( - number_parameters(signature.parameters), - low_level_return_type(signature.returns), - ) -} - -fn number_parameters(count: usize) -> Vec<TypeScriptParameter> { - (0..count) - .map(|index| { - TypeScriptParameter::new(low_level_parameter_name(index), TypeScriptType::Number) - }) - .collect() -} - -fn low_level_parameter_name(index: usize) -> String { - match index { - 0 => "a".to_owned(), - 1 => "b".to_owned(), - 2 => "c".to_owned(), - 3 => "d".to_owned(), - _ => format!("arg{index}"), - } -} - -fn low_level_return_type(count: usize) -> TypeScriptType { - match count { - 0 => TypeScriptType::Void, - 1 => TypeScriptType::Number, - _ => TypeScriptType::tuple(vec![TypeScriptType::Number; count]), - } -} - -fn promise_of(type_expr: TypeScriptType) -> TypeScriptType { - TypeScriptType::generic("Promise", vec![type_expr]) -} - -const EVENT_CODEC_FUNCTIONS: &[WasmPublicFunction] = &[ - public_function!("article_tags", Some("article_json"), String, 2, 4), - public_function!("coop_tags", Some("coop_json"), String, 2, 4), - public_function!("document_tags", Some("document_json"), String, 2, 4), - public_function!("farm_crdt_change_tags", Some("input_json"), String, 2, 4), - public_function!("farm_file_metadata_tags", Some("file_json"), String, 2, 4), - public_function!("farm_tags", Some("farm_json"), String, 2, 4), - public_function!( - "farm_workspace_manifest_tags", - Some("workspace_json"), - String, - 2, - 4 - ), - public_function!("file_metadata_tags", Some("metadata_json"), String, 2, 4), - public_function!("follow_tags", Some("follow_json"), String, 2, 4), - public_function!("generic_repost_tags", Some("repost_json"), String, 2, 4), - public_function!("gift_wrap_tags", Some("gift_wrap_json"), String, 2, 4), - public_function!("group_admins_tags", Some("group_json"), String, 2, 4), - public_function!("group_create_group_tags", Some("group_json"), String, 2, 4), - public_function!("group_create_invite_tags", Some("group_json"), String, 2, 4), - public_function!("group_delete_event_tags", Some("group_json"), String, 2, 4), - public_function!("group_delete_group_tags", Some("group_json"), String, 2, 4), - public_function!("group_edit_metadata_tags", Some("group_json"), String, 2, 4), - public_function!("group_join_request_tags", Some("group_json"), String, 2, 4), - public_function!("group_leave_request_tags", Some("group_json"), String, 2, 4), - public_function!("group_members_tags", Some("group_json"), String, 2, 4), - public_function!("group_metadata_tags", Some("group_json"), String, 2, 4), - public_function!("group_put_user_tags", Some("group_json"), String, 2, 4), - public_function!("group_remove_user_tags", Some("group_json"), String, 2, 4), - public_function!("group_roles_tags", Some("group_json"), String, 2, 4), - public_function!("http_auth_tags", Some("auth_json"), String, 2, 4), - public_function!("job_feedback_tags", Some("job_json"), String, 2, 4), - public_function!("job_request_tags", Some("job_json"), String, 2, 4), - public_function!("job_result_tags", Some("job_json"), String, 2, 4), - public_function!("knowledge_claim_tags", Some("claim_json"), String, 2, 4), - public_function!( - "knowledge_field_report_tags", - Some("report_json"), - String, - 2, - 4 - ), - public_function!( - "knowledge_relation_tags", - Some("relation_json"), - String, - 2, - 4 - ), - public_function!("knowledge_review_tags", Some("review_json"), String, 2, 4), - public_function!("knowledge_source_tags", Some("source_json"), String, 2, 4), - public_function!("list_set_tags", Some("list_json"), String, 2, 4), - public_function!("list_tags", Some("list_json"), String, 2, 4), - public_function!( - "operational_listing_tags", - Some("listing_json"), - String, - 2, - 4 - ), - public_function!( - "operational_listing_tags_full", - Some("listing_json"), - String, - 2, - 4 - ), - public_function!("message_file_tags", Some("message_json"), String, 2, 4), - public_function!("message_tags", Some("message_json"), String, 2, 4), - public_function!("plot_tags", Some("plot_json"), String, 2, 4), - public_function!("reaction_tags", Some("reaction_json"), String, 2, 4), - public_function!("relay_auth_tags", Some("auth_json"), String, 2, 4), - public_function!("report_tags", Some("report_json"), String, 2, 4), - public_function!("repost_tags", Some("repost_json"), String, 2, 4), - public_function!("seal_tags", Some("seal_json"), String, 2, 4), - public_function!( - "social_ask_build_authored_draft", - Some("input_json"), - String, - 2, - 4 - ), - public_function!( - "social_comment_build_authored_draft", - Some("input_json"), - String, - 2, - 4 - ), - public_function!( - "social_photo_update_build_authored_draft", - Some("input_json"), - String, - 2, - 4 - ), - public_function!( - "social_update_build_authored_draft", - Some("input_json"), - String, - 2, - 4 - ), - public_function!("wiki_article_tags", Some("article_json"), String, 2, 4), - public_function!( - "wiki_merge_request_tags", - Some("request_json"), - String, - 2, - 4 - ), - public_function!("wiki_redirect_tags", Some("redirect_json"), String, 2, 4), - public_function!( - "verify_and_decode_event_json", - Some("event_json"), - String, - 2, - 4 - ), - public_function!("contract_manifest_json", None, String, 0, 4), -]; - -const EVENT_CODEC_SUPPORT_EXPORTS: &[WasmSupportExport] = &[ - memory_export!(), - table_export!("__wbindgen_externrefs"), - number_function_export!("__wbindgen_malloc", 2), - number_function_export!("__wbindgen_realloc", 4), - void_function_export!("__externref_table_dealloc", 1), - void_function_export!("__wbindgen_free", 3), - void_function_export!("__wbindgen_start", 0), -]; - -const REPLICA_STORE_FUNCTIONS: &[WasmPublicFunction] = &[ - public_function!("replica_store_export_begin", None, Any, 0, 3), - public_function!("replica_store_export_finish", None, Void, 0, 2), - public_function!("replica_store_export_json", None, Any, 0, 3), - public_function!("replica_store_farm_create", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_farm_delete", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_farm_find_many", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_farm_find_one", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_farm_update", Some("opts_json"), Any, 2, 3), - public_function!( - "replica_store_farm_gcs_location_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_gcs_location_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_gcs_location_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_gcs_location_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_gcs_location_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_claim_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_claim_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_claim_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_claim_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_claim_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_tag_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_tag_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_tag_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_tag_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_tag_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_gcs_location_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_gcs_location_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_gcs_location_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_gcs_location_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_gcs_location_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!("replica_store_import_json", Some("dump_json"), Void, 2, 2), - public_function!( - "replica_store_log_error_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_log_error_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_log_error_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_log_error_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_log_error_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_media_image_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_media_image_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_media_image_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_media_image_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_media_image_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_event_head_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_event_head_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_event_head_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_event_head_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_event_head_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_relay_set", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_relay_unset", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_relay_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_relay_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_relay_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_relay_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_relay_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!("replica_store_plot_create", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_plot_delete", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_plot_find_many", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_plot_find_one", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_plot_update", Some("opts_json"), Any, 2, 3), - public_function!( - "replica_store_plot_gcs_location_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_gcs_location_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_gcs_location_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_gcs_location_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_gcs_location_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_tag_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_tag_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_tag_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_tag_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_tag_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!("replica_store_reset_database", None, Void, 0, 2), - public_function!("replica_store_run_migrations", None, Void, 0, 2), - public_function!( - "replica_store_trade_product_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_location_set", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_location_unset", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_media_set", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_media_unset", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_update", - Some("opts_json"), - Any, - 2, - 3 - ), -]; - -const REPLICA_SYNC_FUNCTIONS: &[WasmPublicFunction] = &[ - public_function!("replica_sync_ingest_event", Some("event_json"), Any, 2, 3), - public_function!("replica_sync_sync_all", Some("request_json"), Any, 2, 3), -]; - -const SQL_WASM_SUPPORT_EXPORTS: &[WasmSupportExport] = &[ - memory_export!(), - number_function_export!("__wbindgen_malloc", 2), - number_function_export!("__wbindgen_realloc", 4), - void_function_export!("__wbindgen_exn_store", 1), - number_function_export!("__externref_table_alloc", 0), - table_export!("__wbindgen_externrefs"), - void_function_export!("__externref_table_dealloc", 1), - void_function_export!("__wbindgen_start", 0), -]; - -#[cfg(test)] -mod tests { - use crate::package_matrix::wasm_package_specs; - - use super::{WASM_DECLARATION_HEADER, declaration_files}; - - #[test] - fn declaration_files_are_dto_owned_and_cover_primary_and_sidecar_paths() { - for spec in wasm_package_specs() { - let files = declaration_files(*spec).expect("declaration files"); - assert_eq!(files.len(), 2); - assert!( - files - .iter() - .any(|file| file.relative_path == format!("dist/{}.d.ts", spec.out_name)) - ); - assert!( - files.iter().any( - |file| file.relative_path == format!("dist/{}_bg.wasm.d.ts", spec.out_name) - ) - ); - for file in files { - assert!(file.contents.starts_with(WASM_DECLARATION_HEADER)); - assert!(!file.contents.contains("tslint:disable")); - assert!(!file.contents.contains("eslint-disable")); - } - } - } - - #[test] - fn generated_declarations_include_public_wasm_entrypoints() { - let specs = wasm_package_specs(); - let events = declaration_files(specs[0]).expect("event declarations"); - assert!( - events[0] - .contents - .contains("export function operational_listing_tags") - ); - assert!( - events[1] - .contents - .contains("export declare const operational_listing_tags") - ); - for authored in [ - "social_ask_build_authored_draft", - "social_comment_build_authored_draft", - "social_photo_update_build_authored_draft", - "social_update_build_authored_draft", - ] { - assert!( - events[0] - .contents - .contains(&format!("export function {authored}")) - ); - assert!( - events[1] - .contents - .contains(&format!("export declare const {authored}")) - ); - } - for retired in [ - "calendar_date_event_tags", - "calendar_event_rsvp_tags", - "calendar_tags", - "calendar_time_event_tags", - ] { - assert!(!events[0].contents.contains(retired)); - assert!(!events[1].contents.contains(retired)); - } - for retired in ["comment_tags", "post_tags"] { - assert!( - !events[0] - .contents - .contains(&format!("export function {retired}(")) - ); - assert!( - !events[1] - .contents - .contains(&format!("export declare const {retired}:")) - ); - } - - let replica_store = declaration_files(specs[1]).expect("replica store declarations"); - assert!( - replica_store[0] - .contents - .contains("export function replica_store_farm_create") - ); - assert!( - replica_store[0] - .contents - .contains("export function replica_store_farm_update") - ); - assert!( - replica_store[0] - .contents - .contains("export function replica_store_plot_update") - ); - assert!( - replica_store[1] - .contents - .contains("export declare const replica_store_farm_create") - ); - assert!( - replica_store[1] - .contents - .contains("export declare const replica_store_farm_update") - ); - assert!( - replica_store[1] - .contents - .contains("export declare const replica_store_plot_update") - ); - - let replica_sync = declaration_files(specs[2]).expect("replica sync declarations"); - assert!( - replica_sync[0] - .contents - .contains("export function replica_sync_sync_all") - ); - assert!( - replica_sync[1] - .contents - .contains("export declare const replica_sync_sync_all") - ); - } -} diff --git a/tools/xtask/tests/fixtures/api-leakage/adr-exception.rs b/tools/xtask/tests/fixtures/api-leakage/adr-exception.rs @@ -1,3 +0,0 @@ -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 @@ -1,3 +0,0 @@ -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 @@ -1,5 +0,0 @@ -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 @@ -1,5 +0,0 @@ -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 @@ -1,11 +0,0 @@ -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 @@ -1,46 +0,0 @@ -{ - "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 @@ -1,46 +0,0 @@ -{ - "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 @@ -1,46 +0,0 @@ -{ - "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 @@ -1,46 +0,0 @@ -{ - "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": [] - } - ] - } -}