lib

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

commit a64fe38236edd357c9ad4d1b055b1bf6af4bd29e
parent 773bc8afb53eae5efb44506e892f62a89b275fc4
Author: triesap <tyson@radroots.org>
Date:   Wed,  9 Sep 2026 22:54:37 +0000

workspace: Retire extracted mobile application packages

- Remove the four transferred application packages and active producer routes
- Preserve package attribution and reject application dependency reintroduction
- Regenerate coherent inventories without changing retained dependency versions
- Verify full coverage, workspace, SDK generators and release preflight

Diffstat:
MAGENTS.md | 6++++--
MCargo.lock | 289+++++--------------------------------------------------------------------------
MCargo.toml | 10----------
Acontracts/architecture/decisions/tera_application_ownership.v1.json | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/coverage.toml | 9---------
Mcontracts/crates/catalog.v2.toml | 130++++----------------------------------------------------------------------------
Mcontracts/crates/generated/package_groups.v1.toml | 20+++++++-------------
Mcontracts/crates/generated/platform_inventory.v1.toml | 14+++-----------
Mcontracts/crates/generated/release_inventory.v2.toml | 4++--
Mcontracts/releases/publish_policy.toml | 4----
Dcrates/mobile_bindgen/Cargo.toml | 23-----------------------
Dcrates/mobile_bindgen/src/main.rs | 19-------------------
Dcrates/mobile_core/Cargo.toml | 78------------------------------------------------------------------------------
Dcrates/mobile_core/build.rs | 59-----------------------------------------------------------
Dcrates/mobile_core/src/error.rs | 187-------------------------------------------------------------------------------
Dcrates/mobile_core/src/lib.rs | 12------------
Dcrates/mobile_core/src/provenance.rs | 26--------------------------
Dcrates/mobile_core/src/runtime/app_info.rs | 26--------------------------
Dcrates/mobile_core/src/runtime/builder.rs | 250-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/info.rs | 79-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/mod.rs | 223-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/product_surface.rs | 154-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/product_surface/authoring.rs | 310-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/product_surface/context.rs | 466-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/product_surface/cursor.rs | 457-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/product_surface/identity.rs | 185-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/product_surface/media.rs | 2172-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/product_surface/model.rs | 251-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/product_surface/outbox.rs | 4402-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/product_surface/projection.rs | 646-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/product_surface/ranking.rs | 259-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/product_surface/settings.rs | 1669-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/product_surface/today.rs | 3776-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/sdk.rs | 532-------------------------------------------------------------------------------
Dcrates/mobile_core/src/runtime/store.rs | 257-------------------------------------------------------------------------------
Dcrates/mobile_core/tests/durable_runtime.rs | 111-------------------------------------------------------------------------------
Dcrates/mobile_core/tests/package_boundary.rs | 101-------------------------------------------------------------------------------
Dcrates/mobile_core/tests/sdk_runtime.rs | 55-------------------------------------------------------
Dcrates/mobile_core/tests/support/mod.rs | 33---------------------------------
Dcrates/mobile_ffi/Cargo.toml | 54------------------------------------------------------
Dcrates/mobile_ffi/src/dto.rs | 2999-------------------------------------------------------------------------------
Dcrates/mobile_ffi/src/error.rs | 483-------------------------------------------------------------------------------
Dcrates/mobile_ffi/src/lib.rs | 46----------------------------------------------
Dcrates/mobile_ffi/src/logging.rs | 284-------------------------------------------------------------------------------
Dcrates/mobile_ffi/src/logging/writer.rs | 436-------------------------------------------------------------------------------
Dcrates/mobile_ffi/src/operations.rs | 1008-------------------------------------------------------------------------------
Dcrates/mobile_ffi/src/runtime.rs | 1190-------------------------------------------------------------------------------
Dcrates/mobile_ffi/src/signer.rs | 404-------------------------------------------------------------------------------
Dcrates/mobile_ffi/src/subscription.rs | 321-------------------------------------------------------------------------------
Dcrates/mobile_ffi/tests/local_mvp_real_io.rs | 964-------------------------------------------------------------------------------
Dcrates/mobile_ffi/tests/logging_error.rs | 13-------------
Dcrates/mobile_ffi/tests/runtime_delegation.rs | 850-------------------------------------------------------------------------------
Dcrates/mobile_ffi/tests/runtime_lifecycle.rs | 44--------------------------------------------
Dcrates/mobile_ffi/tests/subscription_contract.rs | 67-------------------------------------------------------------------
Dcrates/mobile_ffi/tests/support/mod.rs | 24------------------------
Dcrates/mobile_ffi/tests/uniffi_contract.rs | 258-------------------------------------------------------------------------------
Dcrates/mobile_ffi/uniffi.toml | 3---
Dcrates/mobile_wasm/Cargo.toml | 21---------------------
Dcrates/mobile_wasm/src/lib.rs | 39---------------------------------------
Mtools/xtask/src/build_control.rs | 42++++++++++++++++++++++++++++++++++--------
Mtools/xtask/src/catalog.rs | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtools/xtask/src/main.rs | 42++++++++++++------------------------------
Mtools/xtask/src/sdk_generation/bindings.rs | 12++++++++----
63 files changed, 521 insertions(+), 26818 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -33,9 +33,11 @@ This file exists for compatibility with tools that look for AGENTS.md. Service-host and service-owned operator contracts must implement or narrow that boundary without adding a second transport, exit map, or readiness authority. -- Source-lock consumer identities include `sdk`, `mobile`, `myc`, and `rhi`. - Only the first two are generated-artifact product identities; +- Source-lock consumer identities include `sdk`, `myc`, and `rhi`. + Only `sdk` is a generated-artifact product identity; accepting a service consumer marker must not expose an artifact route. + Tera owns its application packages and native/WASM artifact routes. Lib must + not regain an application package or a dependency on the Tera application. - The canonical service source lock is the bounded, canonical `radroots.service.source-lock.v2.toml` model. It binds the exact active public Lib repository and full revision, Lib source-archive and workspace-catalog diff --git a/Cargo.lock b/Cargo.lock @@ -240,19 +240,6 @@ dependencies = [ ] [[package]] -name = "async-compat" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" -dependencies = [ - "futures-core", - "futures-io", - "once_cell", - "pin-project-lite", - "tokio", -] - -[[package]] name = "async-trait" version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -817,15 +804,6 @@ dependencies = [ ] [[package]] -name = "crossbeam-channel" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] name = "crossbeam-queue" version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2396,29 +2374,6 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.1" -source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" -dependencies = [ - "base64", - "bech32", - "bip39", - "bitcoin_hashes", - "cbc", - "chacha20 0.9.1", - "chacha20poly1305", - "getrandom 0.2.17", - "hex", - "instant", - "scrypt", - "secp256k1", - "serde", - "serde_json", - "unicode-normalization", - "url", -] - -[[package]] -name = "nostr" version = "0.44.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" @@ -2450,17 +2405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" dependencies = [ "lru", - "nostr 0.44.7", - "tokio", -] - -[[package]] -name = "nostr-database" -version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" -dependencies = [ - "lru", - "nostr 0.44.1", + "nostr", "tokio", ] @@ -2470,48 +2415,7 @@ version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" dependencies = [ - "nostr 0.44.7", -] - -[[package]] -name = "nostr-gossip" -version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" -dependencies = [ - "nostr 0.44.1", -] - -[[package]] -name = "nostr-relay-builder" -version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" -dependencies = [ - "async-utility", - "async-wsocket", - "atomic-destructor", - "hex", - "negentropy", - "nostr 0.44.1", - "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)", - "tokio", - "tracing", -] - -[[package]] -name = "nostr-relay-pool" -version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" -dependencies = [ - "async-utility", - "async-wsocket", - "atomic-destructor", - "hex", - "lru", - "negentropy", - "nostr 0.44.1", - "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)", - "tokio", - "tracing", + "nostr", ] [[package]] @@ -2526,22 +2430,8 @@ dependencies = [ "hex", "lru", "negentropy", - "nostr 0.44.7", - "nostr-database 0.44.0 (registry+https://github.com/rust-lang/crates.io-index)", - "tokio", - "tracing", -] - -[[package]] -name = "nostr-sdk" -version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" -dependencies = [ - "async-utility", - "nostr 0.44.1", - "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)", - "nostr-gossip 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)", - "nostr-relay-pool 0.44.0", + "nostr", + "nostr-database", "tokio", "tracing", ] @@ -2553,10 +2443,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" dependencies = [ "async-utility", - "nostr 0.44.7", - "nostr-database 0.44.0 (registry+https://github.com/rust-lang/crates.io-index)", - "nostr-gossip 0.44.0 (registry+https://github.com/rust-lang/crates.io-index)", - "nostr-relay-pool 0.44.3", + "nostr", + "nostr-database", + "nostr-gossip", + "nostr-relay-pool", "tokio", "tracing", ] @@ -3091,7 +2981,7 @@ name = "radroots_event_codec" version = "0.1.0-alpha" dependencies = [ "hex", - "nostr 0.44.7", + "nostr", "radroots_blossom", "radroots_core", "radroots_event", @@ -3108,7 +2998,7 @@ name = "radroots_event_codec_wasm" version = "0.1.0-alpha" dependencies = [ "hex", - "nostr 0.44.7", + "nostr", "radroots_blossom", "radroots_core", "radroots_event", @@ -3180,84 +3070,11 @@ dependencies = [ ] [[package]] -name = "radroots_mobile_bindgen" -version = "0.1.0-alpha" -dependencies = [ - "uniffi", -] - -[[package]] -name = "radroots_mobile_core" -version = "0.1.0-alpha" -dependencies = [ - "chrono", - "hex", - "nostr 0.44.7", - "radroots_blossom", - "radroots_event", - "radroots_event_codec", - "radroots_identity", - "radroots_nostr", - "radroots_protocol", - "radroots_sdk", - "radroots_signing", - "radroots_storage", - "radroots_sync", - "radroots_transport", - "radroots_transport_nostr", - "serde", - "serde_json", - "sha2", - "tempfile", - "thiserror 1.0.69", - "tokio", - "url", - "uuid", -] - -[[package]] -name = "radroots_mobile_ffi" -version = "0.1.0-alpha" -dependencies = [ - "async-trait", - "hex", - "libc", - "nostr 0.44.1", - "nostr-relay-builder", - "nostr-sdk 0.44.0", - "radroots_blossom", - "radroots_event", - "radroots_mobile_core", - "radroots_sdk", - "radroots_signing", - "radroots_storage", - "rustix 1.1.4", - "secp256k1", - "serde_json", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tracing", - "tracing-appender", - "tracing-subscriber", - "uniffi", -] - -[[package]] -name = "radroots_mobile_wasm" -version = "0.1.0-alpha" -dependencies = [ - "radroots_mobile_core", - "serde_json", - "wasm-bindgen", -] - -[[package]] name = "radroots_nostr" version = "0.1.0-alpha" dependencies = [ "base64", - "nostr 0.44.7", + "nostr", "radroots_blossom", "radroots_event", "radroots_event_codec", @@ -3274,7 +3091,7 @@ dependencies = [ name = "radroots_nostr_connect" version = "0.1.0-alpha" dependencies = [ - "nostr 0.44.7", + "nostr", "radroots_event", "radroots_identity", "radroots_nostr", @@ -3292,7 +3109,7 @@ version = "0.1.0-alpha" dependencies = [ "futures", "hex", - "nostr 0.44.7", + "nostr", "nostrdb", "radroots_nostr", "serde_json", @@ -3361,7 +3178,7 @@ version = "0.1.0-alpha" dependencies = [ "base64", "hex", - "nostr 0.44.7", + "nostr", "radroots_core", "radroots_event", "radroots_event_codec", @@ -3431,7 +3248,7 @@ dependencies = [ name = "radroots_sdk" version = "0.1.0-alpha" dependencies = [ - "nostr 0.44.7", + "nostr", "radroots_blossom", "radroots_core", "radroots_event", @@ -3542,7 +3359,7 @@ name = "radroots_signing" version = "0.1.0-alpha" dependencies = [ "hex", - "nostr 0.44.7", + "nostr", "radroots_blossom", "radroots_event", "radroots_event_codec", @@ -3734,8 +3551,8 @@ version = "0.1.0-alpha" dependencies = [ "async-wsocket", "futures", - "nostr-relay-pool 0.44.3", - "nostr-sdk 0.44.1", + "nostr-relay-pool", + "nostr-sdk", "radroots_event_codec", "radroots_nostr", "radroots_protocol", @@ -4410,15 +4227,6 @@ dependencies = [ ] [[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4650,12 +4458,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] -name = "symlink" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" - -[[package]] name = "syn" version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4771,15 +4573,6 @@ dependencies = [ ] [[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - -[[package]] name = "time" version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5035,19 +4828,6 @@ dependencies = [ ] [[package]] -name = "tracing-appender" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" -dependencies = [ - "crossbeam-channel", - "symlink", - "thiserror 2.0.18", - "time", - "tracing-subscriber", -] - -[[package]] name = "tracing-attributes" version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5065,32 +4845,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "tracing-core", - "tracing-log", ] [[package]] @@ -5212,7 +4966,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" dependencies = [ "anyhow", - "async-compat", "bytes", "once_cell", "static_assertions", @@ -5391,12 +5144,6 @@ dependencies = [ ] [[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -38,10 +38,6 @@ members = [ "crates/mesh", "crates/mesh_agent_client", "crates/mesh_agent_proto", - "crates/mobile_bindgen", - "crates/mobile_core", - "crates/mobile_ffi", - "crates/mobile_wasm", "crates/core_bindings", "crates/event_bindings", "crates/event_codec_wasm", @@ -157,10 +153,6 @@ radroots_transport = { package = "radroots_transport", path = "crates/transport" radroots_mesh = { path = "crates/mesh", version = "=0.1.0-alpha", default-features = false } radroots_mesh_agent_client = { path = "crates/mesh_agent_client", version = "=0.1.0-alpha", default-features = false } radroots_mesh_agent_proto = { path = "crates/mesh_agent_proto", version = "=0.1.0-alpha", default-features = false } -radroots_mobile_bindgen = { path = "crates/mobile_bindgen", version = "=0.1.0-alpha" } -radroots_mobile_core = { path = "crates/mobile_core", version = "=0.1.0-alpha", default-features = false } -radroots_mobile_ffi = { path = "crates/mobile_ffi", version = "=0.1.0-alpha" } -radroots_mobile_wasm = { path = "crates/mobile_wasm", version = "=0.1.0-alpha" } radroots_simplex_app_store = { path = "crates/simplex_app_store", version = "=0.1.0-alpha", default-features = false } radroots_sdk = { path = "crates/sdk", version = "=0.1.0-alpha", default-features = false } radroots_sdk_ffi = { path = "crates/sdk_ffi", version = "=0.1.0-alpha" } @@ -268,9 +260,7 @@ tokio-tungstenite = { version = "0.26.2", default-features = false, features = [ ] } toml = { version = "0.8" } tracing = { version = "0.1", default-features = false } -tracing-appender = { version = "0.2" } tracing-log = { version = "0.2" } -tracing-subscriber = { version = "0.3" } unicode-general-category = { version = "=1.1.0" } url = { version = "2" } url_nostd = { package = "url", version = "2", default-features = false } diff --git a/contracts/architecture/decisions/tera_application_ownership.v1.json b/contracts/architecture/decisions/tera_application_ownership.v1.json @@ -0,0 +1,222 @@ +{ + "schema": "radroots.tera.application-ownership.v1", + "source_repository": "https://github.com/radrootslabs/lib", + "source_catalog_sha256": "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4", + "target_repository": "https://github.com/radrootslabs/tera", + "target_revision": "2b33144e628bf10e1ddd29829f801ae94cf922b6", + "transfer_evidence_sha256": "9b8d3f5c295a899be45f4a15b4711a5828b4c1140d3388f3d63b0c597a2b72b9", + "package": [ + { + "former_catalog_entry": { + "name": "radroots_mobile_core", + "path": "crates/mobile_core", + "state": "active", + "tier": "runtime", + "visibility": "private_runtime", + "publish": false, + "version": "0.1.0-alpha", + "license": "GPL-3.0-or-later", + "platforms": [ + "native", + "wasm32" + ], + "groups": [ + "coverage_required", + "mobile" + ], + "owners": [ + "mobile" + ], + "permitted_dependency_tiers": [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "runtime" + ], + "provenance_kind": "imported", + "source_repository": "https://github.com/radrootslabs/app_rt", + "source_revision": "7ab1a8624d50890d6d18545ffb47d8083afa8c67", + "source_path": "crates/core", + "source_tree_sha256": "8ae546ee60a97bfc145c12ea94dcd6d528bf5f49e974159442738c29eb3d6ee1", + "compatibility": [ + "product", + "lifecycle", + "package_private" + ], + "replaces": [ + "radroots_app_core" + ] + }, + "target_name": "tera_core", + "target_path": "core/crates/tera_core", + "projected_commit": "d2f667e0de3b05f68c7735a0733854bd30ba4fe5", + "projected_tree": "312c0c4c6b157302ed5944169fefa34671bad3af" + }, + { + "former_catalog_entry": { + "name": "radroots_mobile_ffi", + "path": "crates/mobile_ffi", + "state": "active", + "tier": "boundary", + "visibility": "private_boundary", + "publish": false, + "version": "0.1.0-alpha", + "license": "GPL-3.0-or-later", + "platforms": [ + "apple", + "android" + ], + "groups": [ + "coverage_required", + "mobile", + "boundaries" + ], + "owners": [ + "mobile" + ], + "permitted_dependency_tiers": [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "runtime", + "boundary" + ], + "provenance_kind": "imported", + "source_repository": "https://github.com/radrootslabs/app_rt", + "source_revision": "7ab1a8624d50890d6d18545ffb47d8083afa8c67", + "source_path": "crates/ffi", + "source_tree_sha256": "9a38699ad6d39b9f8764fed6a67bd55908ff54cb2e3cae9a3890ce1a46789b2b", + "compatibility": [ + "ffi", + "swift", + "kotlin", + "lifecycle", + "package_private" + ], + "replaces": [ + "radroots_app_ffi" + ] + }, + "target_name": "tera_ffi", + "target_path": "core/crates/tera_ffi", + "projected_commit": "eaeffef63efd0f23383c3320fb889bb85451a5ab", + "projected_tree": "3d76114aa1a63dce814c38bc2cc97652965608d5" + }, + { + "former_catalog_entry": { + "name": "radroots_mobile_wasm", + "path": "crates/mobile_wasm", + "state": "active", + "tier": "boundary", + "visibility": "private_boundary", + "publish": false, + "version": "0.1.0-alpha", + "license": "GPL-3.0-or-later", + "platforms": [ + "wasm32" + ], + "groups": [ + "coverage_required", + "mobile", + "wasm", + "boundaries" + ], + "owners": [ + "mobile" + ], + "permitted_dependency_tiers": [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "runtime", + "boundary" + ], + "provenance_kind": "imported", + "source_repository": "https://github.com/radrootslabs/app_rt", + "source_revision": "7ab1a8624d50890d6d18545ffb47d8083afa8c67", + "source_path": "crates/wasm", + "source_tree_sha256": "8c65c867eafc0643ca9fd86150766af4f4ab2f0f8321d66306418261a8f43124", + "compatibility": [ + "wasm", + "lifecycle", + "package_private" + ], + "replaces": [ + "radroots_app_wasm" + ] + }, + "target_name": "tera_wasm", + "target_path": "core/crates/tera_wasm", + "projected_commit": "5aa29339dbc51376015090a6c3ad9acf5ce0d3ad", + "projected_tree": "b2a3a71ed8d7de20e4a894a1a6c5e7b531c2fe29" + }, + { + "former_catalog_entry": { + "name": "radroots_mobile_bindgen", + "path": "crates/mobile_bindgen", + "state": "active", + "tier": "codegen", + "visibility": "private_codegen", + "publish": false, + "version": "0.1.0-alpha", + "license": "GPL-3.0-or-later", + "platforms": [ + "native" + ], + "groups": [ + "coverage_required", + "mobile", + "boundaries" + ], + "owners": [ + "mobile" + ], + "permitted_dependency_tiers": [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "runtime", + "boundary", + "codegen" + ], + "provenance_kind": "imported", + "source_repository": "https://github.com/radrootslabs/app_rt", + "source_revision": "7ab1a8624d50890d6d18545ffb47d8083afa8c67", + "source_path": "crates/bindgen", + "source_tree_sha256": "f0a9d7ec9794257bc56063117208e6e83a34efe1b852e8af6d6a56a1693d27fa", + "compatibility": [ + "generated", + "swift", + "kotlin", + "package_private" + ], + "replaces": [ + "radroots_app_bindgen" + ] + }, + "target_name": "tera_bindgen", + "target_path": "core/crates/tera_bindgen", + "projected_commit": "979f6dab4d47f8dbeae487e948906ea3e2f29984", + "projected_tree": "b2b8bedfa87e99907ce98e18e2aff4252dd7958b" + } + ], + "source_catalog_revision": "ec17de580ec174b2c0915d9b915e5cdf9272dfe8", + "transfer_donor_commit": "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb", + "transfer_donor_tree": "3d8cdf4b928d37e29a2dbc8aa785e7e4e4938a62" +} diff --git a/contracts/coverage.toml b/contracts/coverage.toml @@ -45,11 +45,6 @@ require_branches = false temporary = true reason = "branch coverage is not applicable while the UniFFI facade has no measured branch records" -[overrides.radroots_mobile_bindgen] -require_branches = false -temporary = true -reason = "branch coverage is not applicable while the coverage-only bindgen entry point has no measured branch records" - [overrides.radroots_test_fixtures] require_branches = false temporary = true @@ -71,10 +66,6 @@ crates = [ "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", - "radroots_mobile_bindgen", - "radroots_mobile_core", - "radroots_mobile_ffi", - "radroots_mobile_wasm", "radroots_nostr", "radroots_nostr_connect", "radroots_nostrdb", diff --git a/contracts/crates/catalog.v2.toml b/contracts/crates/catalog.v2.toml @@ -7,13 +7,17 @@ rust_version = "1.97.1" edition = "2024" resolver = "3" public_package_count = 19 -package_count = 55 +package_count = 51 digest_algorithm = "sha256-raw-bytes-v1" source_tree_digest_algorithm = "sha256-git-ls-tree-r-v1" native_introduction_tree_digest_algorithm = "sha256-git-tree-records-z-v1" source_provenance_policy = "imported_revision_tree_or_native_introduction_tree" provenance_correction_contract = "approved_correction_record_required" retired_packages = [ + "radroots_mobile_bindgen", + "radroots_mobile_core", + "radroots_mobile_ffi", + "radroots_mobile_wasm", "radroots_app_core", "radroots_app_ffi", "radroots_app_wasm", @@ -1363,127 +1367,3 @@ source_path = "crates/sql_wasm_runtime" source_tree_sha256 = "6ca1dca6f248c50b6bcd4c684b4ad31acd17b8bfc36a8b0072872f6db927c303" compatibility = ["wasm", "data", "package_private"] replaces = [] - -[[package]] -name = "radroots_mobile_core" -path = "crates/mobile_core" -state = "active" -tier = "runtime" -visibility = "private_runtime" -publish = false -version = "0.1.0-alpha" -license = "GPL-3.0-or-later" -platforms = ["native", "wasm32"] -groups = ["coverage_required", "mobile"] -owners = ["mobile"] -permitted_dependency_tiers = [ - "foundation", - "domain", - "spi", - "adapter", - "orchestration", - "sdk", - "facade", - "runtime", -] -provenance_kind = "imported" -source_repository = "https://github.com/radrootslabs/app_rt" -source_revision = "7ab1a8624d50890d6d18545ffb47d8083afa8c67" -source_path = "crates/core" -source_tree_sha256 = "8ae546ee60a97bfc145c12ea94dcd6d528bf5f49e974159442738c29eb3d6ee1" -compatibility = ["product", "lifecycle", "package_private"] -replaces = ["radroots_app_core"] - -[[package]] -name = "radroots_mobile_ffi" -path = "crates/mobile_ffi" -state = "active" -tier = "boundary" -visibility = "private_boundary" -publish = false -version = "0.1.0-alpha" -license = "GPL-3.0-or-later" -platforms = ["apple", "android"] -groups = ["coverage_required", "mobile", "boundaries"] -owners = ["mobile"] -permitted_dependency_tiers = [ - "foundation", - "domain", - "spi", - "adapter", - "orchestration", - "sdk", - "facade", - "runtime", - "boundary", -] -provenance_kind = "imported" -source_repository = "https://github.com/radrootslabs/app_rt" -source_revision = "7ab1a8624d50890d6d18545ffb47d8083afa8c67" -source_path = "crates/ffi" -source_tree_sha256 = "9a38699ad6d39b9f8764fed6a67bd55908ff54cb2e3cae9a3890ce1a46789b2b" -compatibility = ["ffi", "swift", "kotlin", "lifecycle", "package_private"] -replaces = ["radroots_app_ffi"] - -[[package]] -name = "radroots_mobile_wasm" -path = "crates/mobile_wasm" -state = "active" -tier = "boundary" -visibility = "private_boundary" -publish = false -version = "0.1.0-alpha" -license = "GPL-3.0-or-later" -platforms = ["wasm32"] -groups = ["coverage_required", "mobile", "wasm", "boundaries"] -owners = ["mobile"] -permitted_dependency_tiers = [ - "foundation", - "domain", - "spi", - "adapter", - "orchestration", - "sdk", - "facade", - "runtime", - "boundary", -] -provenance_kind = "imported" -source_repository = "https://github.com/radrootslabs/app_rt" -source_revision = "7ab1a8624d50890d6d18545ffb47d8083afa8c67" -source_path = "crates/wasm" -source_tree_sha256 = "8c65c867eafc0643ca9fd86150766af4f4ab2f0f8321d66306418261a8f43124" -compatibility = ["wasm", "lifecycle", "package_private"] -replaces = ["radroots_app_wasm"] - -[[package]] -name = "radroots_mobile_bindgen" -path = "crates/mobile_bindgen" -state = "active" -tier = "codegen" -visibility = "private_codegen" -publish = false -version = "0.1.0-alpha" -license = "GPL-3.0-or-later" -platforms = ["native"] -groups = ["coverage_required", "mobile", "boundaries"] -owners = ["mobile"] -permitted_dependency_tiers = [ - "foundation", - "domain", - "spi", - "adapter", - "orchestration", - "sdk", - "facade", - "runtime", - "boundary", - "codegen", -] -provenance_kind = "imported" -source_repository = "https://github.com/radrootslabs/app_rt" -source_revision = "7ab1a8624d50890d6d18545ffb47d8083afa8c67" -source_path = "crates/bindgen" -source_tree_sha256 = "f0a9d7ec9794257bc56063117208e6e83a34efe1b852e8af6d6a56a1693d27fa" -compatibility = ["generated", "swift", "kotlin", "package_private"] -replaces = ["radroots_app_bindgen"] diff --git a/contracts/crates/generated/package_groups.v1.toml b/contracts/crates/generated/package_groups.v1.toml @@ -1,22 +1,16 @@ schema = "radroots.workspace.package-groups.v1" -catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4" +catalog_sha256 = "ee295f2352e2577a4052d980624415aec9871197d4fc9910a4c21c83a9179200" [[group]] id = "boundaries" -packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_mobile_bindgen", "radroots_mobile_ffi", "radroots_mobile_wasm", "radroots_replica_schema_bindings", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_ffi", "radroots_trade_bindings"] -active_packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_mobile_bindgen", "radroots_mobile_ffi", "radroots_mobile_wasm", "radroots_replica_schema_bindings", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_ffi", "radroots_trade_bindings"] +packages = ["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_ffi", "radroots_trade_bindings"] +active_packages = ["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_ffi", "radroots_trade_bindings"] reserved_packages = [] [[group]] id = "coverage_required" -packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_core_bindings", "radroots_event", "radroots_event_bindings", "radroots_event_codec", "radroots_event_codec_wasm", "radroots_geonames", "radroots_identity", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_mobile_bindgen", "radroots_mobile_core", "radroots_mobile_ffi", "radroots_mobile_wasm", "radroots_nostr", "radroots_nostr_connect", "radroots_nostrdb", "radroots_protocol", "radroots_replica_schema", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_store_wasm", "radroots_replica_sync", "radroots_replica_sync_wasm", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", "radroots_signing", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_test_fixtures", "radroots_trade", "radroots_trade_bindings", "radroots_transport", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"] -active_packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_core_bindings", "radroots_event", "radroots_event_bindings", "radroots_event_codec", "radroots_event_codec_wasm", "radroots_geonames", "radroots_identity", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_mobile_bindgen", "radroots_mobile_core", "radroots_mobile_ffi", "radroots_mobile_wasm", "radroots_nostr", "radroots_nostr_connect", "radroots_nostrdb", "radroots_protocol", "radroots_replica_schema", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_store_wasm", "radroots_replica_sync", "radroots_replica_sync_wasm", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", "radroots_signing", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_test_fixtures", "radroots_trade", "radroots_trade_bindings", "radroots_transport", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"] -reserved_packages = [] - -[[group]] -id = "mobile" -packages = ["radroots_mobile_bindgen", "radroots_mobile_core", "radroots_mobile_ffi", "radroots_mobile_wasm"] -active_packages = ["radroots_mobile_bindgen", "radroots_mobile_core", "radroots_mobile_ffi", "radroots_mobile_wasm"] +packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_core_bindings", "radroots_event", "radroots_event_bindings", "radroots_event_codec", "radroots_event_codec_wasm", "radroots_geonames", "radroots_identity", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_nostr", "radroots_nostr_connect", "radroots_nostrdb", "radroots_protocol", "radroots_replica_schema", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_store_wasm", "radroots_replica_sync", "radroots_replica_sync_wasm", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", "radroots_signing", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_test_fixtures", "radroots_trade", "radroots_trade_bindings", "radroots_transport", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"] +active_packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_core_bindings", "radroots_event", "radroots_event_bindings", "radroots_event_codec", "radroots_event_codec_wasm", "radroots_geonames", "radroots_identity", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_nostr", "radroots_nostr_connect", "radroots_nostrdb", "radroots_protocol", "radroots_replica_schema", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_store_wasm", "radroots_replica_sync", "radroots_replica_sync_wasm", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", "radroots_signing", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_test_fixtures", "radroots_trade", "radroots_trade_bindings", "radroots_transport", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"] reserved_packages = [] [[group]] @@ -51,6 +45,6 @@ reserved_packages = [] [[group]] id = "wasm" -packages = ["radroots_event_codec_wasm", "radroots_mobile_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", "radroots_sql_core"] -active_packages = ["radroots_event_codec_wasm", "radroots_mobile_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", "radroots_sql_core"] +packages = ["radroots_event_codec_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", "radroots_sql_core"] +active_packages = ["radroots_event_codec_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", "radroots_sql_core"] reserved_packages = [] diff --git a/contracts/crates/generated/platform_inventory.v1.toml b/contracts/crates/generated/platform_inventory.v1.toml @@ -1,22 +1,14 @@ schema = "radroots.workspace.platform-inventory.v1" -catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4" - -[[platform]] -id = "android" -packages = ["radroots_mobile_ffi"] +catalog_sha256 = "ee295f2352e2577a4052d980624415aec9871197d4fc9910a4c21c83a9179200" [[platform]] id = "any" packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", "radroots_identity", "radroots_mesh_agent_proto", "radroots_nostr", "radroots_nostr_connect", "radroots_protocol", "radroots_replica_schema", "radroots_sdk", "radroots_signing", "radroots_simplex_agent_proto", "radroots_simplex_chat_proto", "radroots_simplex_smp_crypto", "radroots_simplex_smp_proto", "radroots_test_fixtures", "radroots_trade", "radroots_transport"] [[platform]] -id = "apple" -packages = ["radroots_mobile_ffi"] - -[[platform]] id = "native" -packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_geonames", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mobile_bindgen", "radroots_mobile_core", "radroots_nostrdb", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_sync", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk_ffi", "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", "radroots_simplex_app_store", "radroots_simplex_smp_transport", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_trade_bindings", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"] +packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_geonames", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_nostrdb", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_sync", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk_ffi", "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", "radroots_simplex_app_store", "radroots_simplex_smp_transport", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_trade_bindings", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"] [[platform]] id = "wasm32" -packages = ["radroots_event_codec_wasm", "radroots_mobile_core", "radroots_mobile_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", "radroots_sql_core"] +packages = ["radroots_event_codec_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", "radroots_sql_core"] diff --git a/contracts/crates/generated/release_inventory.v2.toml b/contracts/crates/generated/release_inventory.v2.toml @@ -1,7 +1,7 @@ schema = "radroots.workspace.release-inventory.v2" -catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4" +catalog_sha256 = "ee295f2352e2577a4052d980624415aec9871197d4fc9910a4c21c83a9179200" architecture = "radroots.crates.release.v2" version = "0.1.0-alpha" public_packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", "radroots_geonames", "radroots_identity", "radroots_nostr", "radroots_nostr_connect", "radroots_protocol", "radroots_sdk", "radroots_secrets", "radroots_signing", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_trade", "radroots_transport", "radroots_transport_nostr"] -private_packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_mobile_bindgen", "radroots_mobile_core", "radroots_mobile_ffi", "radroots_mobile_wasm", "radroots_nostrdb", "radroots_replica_schema", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_store_wasm", "radroots_replica_sync", "radroots_replica_sync_wasm", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_service_host", "radroots_service_sqlite", "radroots_simplex_agent_proto", "radroots_simplex_app_store", "radroots_simplex_chat_proto", "radroots_simplex_smp_crypto", "radroots_simplex_smp_proto", "radroots_simplex_smp_transport", "radroots_sql_core", "radroots_test_fixtures", "radroots_trade_bindings", "radroots_transport_reticulum", "xtask"] +private_packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_nostrdb", "radroots_replica_schema", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_store_wasm", "radroots_replica_sync", "radroots_replica_sync_wasm", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_service_host", "radroots_service_sqlite", "radroots_simplex_agent_proto", "radroots_simplex_app_store", "radroots_simplex_chat_proto", "radroots_simplex_smp_crypto", "radroots_simplex_smp_proto", "radroots_simplex_smp_transport", "radroots_sql_core", "radroots_test_fixtures", "radroots_trade_bindings", "radroots_transport_reticulum", "xtask"] reserved_packages = [] diff --git a/contracts/releases/publish_policy.toml b/contracts/releases/publish_policy.toml @@ -52,7 +52,6 @@ external_packages = [] [workspace_classification] private = [ - "radroots_mobile_core", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", @@ -66,9 +65,6 @@ build_codegen = [ "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", - "radroots_mobile_bindgen", - "radroots_mobile_ffi", - "radroots_mobile_wasm", "radroots_replica_schema_bindings", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", diff --git a/crates/mobile_bindgen/Cargo.toml b/crates/mobile_bindgen/Cargo.toml @@ -1,23 +0,0 @@ -[package] -name = "radroots_mobile_bindgen" -description = "Private UniFFI binding generator for Radroots mobile artifacts" -version = "0.1.0-alpha" -edition.workspace = true -authors = ["Radroots Authors"] -rust-version.workspace = true -license = "GPL-3.0-or-later" -repository.workspace = true -homepage.workspace = true -readme.workspace = true -publish = false -include = ["src/**", "Cargo.toml"] - -[[bin]] -name = "radroots-mobile-bindgen" -path = "src/main.rs" - -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } - -[dependencies] -uniffi = { workspace = true, features = ["cli"] } diff --git a/crates/mobile_bindgen/src/main.rs b/crates/mobile_bindgen/src/main.rs @@ -1,19 +0,0 @@ -fn main() { - run_bindgen(); -} - -#[cfg(not(coverage_nightly))] -fn run_bindgen() { - uniffi::uniffi_bindgen_main() -} - -#[cfg(coverage_nightly)] -fn run_bindgen() {} - -#[cfg(all(test, coverage_nightly))] -mod tests { - #[test] - fn main_is_callable_in_coverage_builds() { - super::main(); - } -} diff --git a/crates/mobile_core/Cargo.toml b/crates/mobile_core/Cargo.toml @@ -1,78 +0,0 @@ -[package] -name = "radroots_mobile_core" -version = "0.1.0-alpha" -edition.workspace = true -authors = ["Radroots Authors"] -rust-version.workspace = true -license = "GPL-3.0-or-later" -description = "Application core runtime for Radroots apps" -repository.workspace = true -homepage.workspace = true -readme.workspace = true -publish = false -include = ["src/**", "tests/**", "build.rs", "Cargo.toml"] - -[lib] -crate-type = ["rlib"] - -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } - -[features] -default = [] -mobile-social = [ - "radroots_sdk/blossom", - "radroots_sdk/nostr", - "radroots_sdk/sync", - "dep:tokio", -] - -[dependencies] -radroots_blossom = { workspace = true, default-features = false, features = [ - "serde", - "std", -] } -radroots_sdk = { workspace = true, features = ["sqlite"] } -radroots_event = { workspace = true, default-features = false, features = [ - "std", -] } -radroots_event_codec = { workspace = true, default-features = false, features = [ - "json", - "std", -] } -radroots_identity = { workspace = true, default-features = false, features = [ - "std", -] } -radroots_protocol = { workspace = true, default-features = false, features = [ - "std", -] } -radroots_signing = { workspace = true, default-features = false, features = [ - "std", -] } -radroots_storage = { workspace = true, default-features = false } -radroots_sync = { workspace = true, default-features = false } -radroots_transport = { workspace = true, default-features = false, features = [ - "std", -] } -radroots_transport_nostr = { workspace = true } -chrono = { workspace = true } -hex = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -sha2 = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true, optional = true, features = [ - "fs", - "io-util", - "rt", - "sync", -] } -uuid = { workspace = true, features = ["v4"] } -url = { workspace = true } - -[dev-dependencies] -nostr = { workspace = true, features = ["std"] } -radroots_nostr = { workspace = true, features = ["blossom", "signing"] } -radroots_sdk = { workspace = true, features = ["memory", "sqlite"] } -tempfile = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/mobile_core/build.rs b/crates/mobile_core/build.rs @@ -1,59 +0,0 @@ -use std::{env, process::Command}; - -#[path = "src/provenance.rs"] -mod provenance; - -fn main() { - println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-env-changed=RUSTC"); - println!("cargo:rerun-if-env-changed=PROFILE"); - println!("cargo:rerun-if-env-changed=RADROOTS_LIB_REVISION"); - println!("cargo:rerun-if-env-changed=RADROOTS_CONSUMER_REVISION"); - println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); - - let rustc = env::var("RUSTC").expect("missing required env var RUSTC"); - if let Ok(output) = Command::new(rustc).arg("--version").output() - && output.status.success() - && let Ok(version) = String::from_utf8(output.stdout) - { - println!("cargo:rustc-env=RUSTC_VERSION={}", version.trim()); - } - - let lib_revision = optional_full_revision("RADROOTS_LIB_REVISION"); - let consumer_revision = optional_full_revision("RADROOTS_CONSUMER_REVISION"); - assert!( - consumer_revision.is_none() || lib_revision.is_some(), - "RADROOTS_CONSUMER_REVISION requires RADROOTS_LIB_REVISION" - ); - if let Some(revision) = lib_revision { - println!("cargo:rustc-env=RADROOTS_LIB_REVISION={revision}"); - } - if let Some(revision) = consumer_revision { - println!("cargo:rustc-env=RADROOTS_CONSUMER_REVISION={revision}"); - } - - let profile = env::var("PROFILE").expect("missing required env var PROFILE"); - println!("cargo:rustc-env=PROFILE={profile}"); - - if let Some(epoch) = optional_source_date_epoch() { - println!("cargo:rustc-env=BUILD_TIME_UNIX={epoch}"); - } -} - -fn optional_full_revision(name: &str) -> Option<String> { - let value = env::var(name).ok()?; - assert!( - provenance::is_full_revision(&value), - "{name} must contain exactly 40 lowercase hexadecimal characters" - ); - Some(value) -} - -fn optional_source_date_epoch() -> Option<u64> { - let value = env::var("SOURCE_DATE_EPOCH").ok()?; - Some( - value - .parse() - .expect("SOURCE_DATE_EPOCH must be an unsigned Unix timestamp"), - ) -} diff --git a/crates/mobile_core/src/error.rs b/crates/mobile_core/src/error.rs @@ -1,187 +0,0 @@ -use thiserror::Error; - -/// Versioned, secret-safe SDK failure exposed to mobile hosts. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SdkErrorRecord { - pub schema_version: u16, - pub code: String, - pub class: String, - pub retryable: bool, - pub recovery_actions: Vec<String>, - pub operation_id: Option<String>, - pub capability_id: Option<String>, - pub message: String, -} - -/// Versioned, path-redacted mobile store failure exposed to native hosts. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StoreErrorRecord { - pub schema_version: u16, - pub code: String, - pub class: String, - pub retryable: bool, - pub recovery_actions: Vec<String>, - pub message: String, -} - -#[derive(Debug, Error)] -pub enum RadrootsAppError { - #[error("initialization: {0}")] - Initialization(String), - #[error("sdk: {report:?}")] - Sdk { report: SdkErrorRecord }, - #[error("store: {report:?}")] - Store { report: StoreErrorRecord }, - #[error("runtime: {0}")] - Runtime(String), - #[error("unsupported: {0}")] - Unsupported(String), - #[error("internal: {0}")] - Internal(String), -} - -impl RadrootsAppError { - /// Returns the stable store report when this is a mobile storage failure. - pub const fn store_report(&self) -> Option<&StoreErrorRecord> { - match self { - Self::Store { report } => Some(report), - _ => None, - } - } - - pub(crate) fn from_sdk(error: radroots_sdk::Error) -> Self { - let report = error.to_report(); - Self::Sdk { - report: SdkErrorRecord { - schema_version: report.schema_version(), - code: report.code().as_str().to_owned(), - class: report.class().as_str().to_owned(), - retryable: report.retryable(), - recovery_actions: report - .recovery_actions() - .iter() - .map(|action| action.as_str().to_owned()) - .collect(), - operation_id: report.operation_id().map(|id| id.as_str().to_owned()), - capability_id: report.capability_id().map(|id| id.as_str().to_owned()), - message: report.message().as_str().to_owned(), - }, - } - } - - pub fn initialization(message: impl Into<String>) -> Self { - Self::Initialization(message.into()) - } - - pub fn runtime(message: impl Into<String>) -> Self { - Self::Runtime(message.into()) - } - - pub fn unsupported(message: impl Into<String>) -> Self { - Self::Unsupported(message.into()) - } - - pub fn internal(message: impl Into<String>) -> Self { - Self::Internal(message.into()) - } - - pub(crate) fn store_invalid_configuration() -> Self { - Self::Store { - report: StoreErrorRecord { - schema_version: 1, - code: "invalid_store_configuration".to_owned(), - class: "validation".to_owned(), - retryable: false, - recovery_actions: vec!["configure_user_store".to_owned()], - message: "mobile user store configuration is invalid".to_owned(), - }, - } - } - - pub(crate) fn protected_data_unavailable() -> Self { - Self::Store { - report: StoreErrorRecord { - schema_version: 1, - code: "protected_data_unavailable".to_owned(), - class: "storage".to_owned(), - retryable: true, - recovery_actions: vec!["retry_after_protected_data_available".to_owned()], - message: "Apple protected data is unavailable".to_owned(), - }, - } - } - - pub(crate) fn store_path_unavailable() -> Self { - Self::Store { - report: StoreErrorRecord { - schema_version: 1, - code: "store_path_unavailable".to_owned(), - class: "storage".to_owned(), - retryable: true, - recovery_actions: vec!["prepare_application_support_directory".to_owned()], - message: "mobile user store directory is unavailable".to_owned(), - }, - } - } -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::{RadrootsAppError, SdkErrorRecord, StoreErrorRecord}; - - #[test] - fn sdk_error_records_are_versioned_stable_and_secret_safe() { - let error = radroots_sdk::ClientBuilder::new() - .build() - .expect_err("storage is required"); - let RadrootsAppError::Sdk { report } = RadrootsAppError::from_sdk(error) else { - panic!("expected SDK report"); - }; - assert_eq!( - report, - SdkErrorRecord { - schema_version: 1, - code: "missing_storage".to_owned(), - class: "capability".to_owned(), - retryable: false, - recovery_actions: vec!["configure_storage".to_owned()], - operation_id: None, - capability_id: Some("storage.canonical".to_owned()), - message: "SDK storage capability is not configured".to_owned(), - } - ); - assert!(!format!("{report:?}").contains("source")); - } - - #[test] - fn public_error_constructors_preserve_typed_variants() { - assert!(matches!( - RadrootsAppError::initialization("init"), - RadrootsAppError::Initialization(message) if message == "init" - )); - assert!(matches!( - RadrootsAppError::runtime("runtime"), - RadrootsAppError::Runtime(message) if message == "runtime" - )); - assert!(matches!( - RadrootsAppError::unsupported("unsupported"), - RadrootsAppError::Unsupported(message) if message == "unsupported" - )); - assert!(matches!( - RadrootsAppError::internal("internal"), - RadrootsAppError::Internal(message) if message == "internal" - )); - assert_eq!( - RadrootsAppError::protected_data_unavailable().store_report(), - Some(&StoreErrorRecord { - schema_version: 1, - code: "protected_data_unavailable".to_owned(), - class: "storage".to_owned(), - retryable: true, - recovery_actions: vec!["retry_after_protected_data_available".to_owned()], - message: "Apple protected data is unavailable".to_owned(), - }) - ); - } -} diff --git a/crates/mobile_core/src/lib.rs b/crates/mobile_core/src/lib.rs @@ -1,12 +0,0 @@ -// Mobile errors retain the complete stable SDK report. Preserving that typed -// value is more important than optimizing the Rust enum's in-process size. -#![allow(clippy::result_large_err)] -#![cfg_attr(coverage_nightly, feature(coverage_attribute))] - -pub mod error; -#[cfg(test)] -mod provenance; -pub mod runtime; - -pub use error::{RadrootsAppError, SdkErrorRecord, StoreErrorRecord}; -pub use runtime::RadrootsRuntime; diff --git a/crates/mobile_core/src/provenance.rs b/crates/mobile_core/src/provenance.rs @@ -1,26 +0,0 @@ -pub(crate) fn is_full_revision(value: &str) -> bool { - value.len() == 40 - && value - .bytes() - .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) -} - -#[cfg(test)] -mod tests { - use super::is_full_revision; - - #[test] - fn only_full_lowercase_git_revisions_are_accepted() { - assert!(is_full_revision("0123456789abcdef0123456789abcdef01234567")); - assert!(!is_full_revision("0123456")); - assert!(!is_full_revision( - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - )); - assert!(!is_full_revision( - "0123456789ABCDEF0123456789abcdef01234567" - )); - assert!(!is_full_revision( - "g123456789abcdef0123456789abcdef01234567" - )); - } -} diff --git a/crates/mobile_core/src/runtime/app_info.rs b/crates/mobile_core/src/runtime/app_info.rs @@ -1,26 +0,0 @@ -#[derive(Debug, Clone, Default, serde::Serialize)] -pub struct AppInfoPlatform { - pub platform: Option<String>, - pub bundle_id: Option<String>, - pub version: Option<String>, - pub build_number: Option<String>, - pub build_sha: Option<String>, -} - -impl AppInfoPlatform { - pub fn new( - platform: Option<String>, - bundle_id: Option<String>, - version: Option<String>, - build_number: Option<String>, - build_sha: Option<String>, - ) -> Self { - Self { - platform, - bundle_id, - version, - build_number, - build_sha, - } - } -} diff --git a/crates/mobile_core/src/runtime/builder.rs b/crates/mobile_core/src/runtime/builder.rs @@ -1,250 +0,0 @@ -use crate::runtime::store::{MobileUserStoreConfig, ProtectedDataAvailability}; -use crate::{RadrootsAppError, RadrootsRuntime}; - -/// Host-owned construction boundary for the shared SDK-backed runtime. -pub struct RuntimeBuilder { - store: MobileUserStoreConfig, - #[cfg(feature = "mobile-social")] - signer: Option<std::sync::Arc<dyn radroots_signing::Signer>>, - #[cfg(feature = "mobile-social")] - relay_profile: radroots_sdk::transport::RelayProfile, - #[cfg(feature = "mobile-social")] - blossom_config: Option<radroots_sdk::transport::BlossomConfig>, -} - -impl RuntimeBuilder { - #[must_use] - pub fn new(store: MobileUserStoreConfig) -> Self { - Self { - store, - #[cfg(feature = "mobile-social")] - signer: None, - #[cfg(feature = "mobile-social")] - relay_profile: radroots_sdk::transport::RelayProfile::explicit( - radroots_sdk::transport::RelayProfileKind::Public, - [radroots_sdk::transport::RelayEndpoint::new( - "wss://radroots.org", - radroots_sdk::transport::RelayUrlPolicy::Public, - radroots_sdk::transport::RelayAccess::ReadWrite, - ) - .expect("bundled public relay endpoint is valid")], - ) - .expect("bundled public relay profile is valid"), - #[cfg(feature = "mobile-social")] - blossom_config: None, - } - } - - /// Installs one opaque host signer without transferring secret material. - #[cfg(feature = "mobile-social")] - #[must_use] - pub fn signer(mut self, signer: std::sync::Arc<dyn radroots_signing::Signer>) -> Self { - self.signer = Some(signer); - self - } - - /// Replaces the bundled read-only public profile with one validated host - /// environment profile. Construction remains inert. - #[cfg(feature = "mobile-social")] - #[must_use] - pub fn relay_profile(mut self, relay_profile: radroots_sdk::transport::RelayProfile) -> Self { - self.relay_profile = relay_profile; - self - } - - /// Installs one validated inert Blossom environment profile. - #[cfg(feature = "mobile-social")] - #[must_use] - pub fn blossom_config( - mut self, - blossom_config: radroots_sdk::transport::BlossomConfig, - ) -> Self { - self.blossom_config = Some(blossom_config); - self - } - - /// Opens the exact authenticated user's durable SQLite store. - pub async fn build(self) -> Result<RadrootsRuntime, RadrootsAppError> { - if self.store.protected_data() == ProtectedDataAvailability::Unavailable { - return Err(RadrootsAppError::protected_data_unavailable()); - } - self.store.validate_host_filesystem()?; - let options = self.store.sqlite_options()?; - #[cfg(feature = "mobile-social")] - let inbound_media_directory = self.store.owner_directory().join("inbound_media.v1"); - let builder = radroots_sdk::ClientBuilder::sqlite(options) - .await - .map_err(RadrootsAppError::from_sdk)?; - RadrootsRuntime::from_client_builder( - builder, - Some(self.store.public_key()), - #[cfg(feature = "mobile-social")] - Some(inbound_media_directory), - #[cfg(feature = "mobile-social")] - self.signer, - #[cfg(feature = "mobile-social")] - Some(self.relay_profile), - #[cfg(feature = "mobile-social")] - self.blossom_config, - ) - } -} - -#[cfg(test)] -mod tests { - use super::RuntimeBuilder; - #[cfg(feature = "mobile-social")] - use crate::runtime::sdk::SdkRelayAccessRecord; - use crate::runtime::store::{MobileUserStoreConfig, ProtectedDataAvailability}; - - const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - const GENERATION: &str = "0202020202020202020202020202020202020202020202020202020202020202"; - - fn store( - root: &std::path::Path, - protected_data: ProtectedDataAvailability, - ) -> MobileUserStoreConfig { - let store = MobileUserStoreConfig::from_encoded( - root, - PUBLIC_KEY, - GENERATION, - 1_800_000_000_000, - protected_data, - ) - .expect("store config"); - std::fs::create_dir_all(store.owner_directory()).expect("owner directory"); - store - } - - #[tokio::test] - async fn builder_constructs_a_durable_sdk_backed_runtime() { - let root = tempfile::tempdir().expect("tempdir"); - let runtime = RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Available)) - .build() - .await - .expect("runtime"); - assert!(!runtime.info().sdk_closed); - assert_eq!( - runtime.sdk_storage_status().await.expect("status").backend, - "sqlite" - ); - #[cfg(feature = "mobile-social")] - { - let report = runtime - .sdk_relay_status() - .expect("relay status") - .expect("configured profile"); - assert_eq!(report.profile, "public"); - assert_eq!(report.state, "configured"); - assert_eq!(report.relays.len(), 1); - assert_eq!(report.relays[0].relay_url, "wss://radroots.org"); - assert_eq!(report.relays[0].access, SdkRelayAccessRecord::ReadWrite); - assert_eq!(report.relays[0].read_state, "unobserved"); - assert_eq!(report.relays[0].write_state, "unobserved"); - } - runtime.shutdown().await.expect("shutdown"); - } - - #[cfg(feature = "mobile-social")] - #[tokio::test] - async fn runtime_reconfiguration_preserves_profile_network_boundaries() { - let root = tempfile::tempdir().expect("tempdir"); - let runtime = RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Available)) - .build() - .await - .expect("runtime"); - assert!( - runtime - .configure_simulator_relays(vec!["ws://127.0.0.1:8080".to_owned()]) - .is_ok() - ); - let report = runtime - .sdk_relay_status() - .expect("simulator status") - .expect("configured profile"); - assert_eq!(report.profile, "simulator_local"); - assert_eq!(report.relays.len(), 1); - assert_eq!(report.relays[0].access, SdkRelayAccessRecord::ReadWrite); - assert!( - runtime - .configure_public_relays(vec!["ws://127.0.0.1:8080".to_owned()]) - .is_err() - ); - assert_eq!( - runtime - .sdk_relay_status() - .expect("unchanged status") - .expect("configured profile"), - report - ); - assert!( - runtime - .configure_blossom( - radroots_sdk::transport::BlossomHostKind::Simulator, - radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment, - "http://127.0.0.1:3000".to_owned(), - vec![], - ) - .is_ok() - ); - assert_eq!( - runtime - .sdk_blossom_configuration() - .expect("Blossom profile") - .expect("configured") - .host_kind, - "simulator" - ); - let evidence = runtime - .sdk_blossom_evidence() - .expect("Blossom evidence") - .expect("configured evidence"); - assert_eq!(evidence.schema_version, 2); - assert_eq!(evidence.state, "configured_unobserved"); - assert_eq!(evidence.last_successful_state, "configured_unobserved"); - assert_eq!(evidence.transport_security, "development_cleartext"); - assert!(evidence.observed_at_unix_ms.is_none()); - assert!(evidence.error_code.is_none()); - assert!(evidence.server_error_code.is_none()); - assert!( - runtime - .configure_blossom( - radroots_sdk::transport::BlossomHostKind::PhysicalDevice, - radroots_sdk::transport::BlossomEndpointAuthority::PublicWebPki, - "http://127.0.0.1:3000".to_owned(), - vec![], - ) - .is_err() - ); - assert_eq!( - runtime - .sdk_blossom_configuration() - .expect("unchanged profile") - .expect("configured") - .host_kind, - "simulator" - ); - runtime.shutdown().await.expect("shutdown"); - } - - #[tokio::test] - async fn protected_data_unavailability_is_retryable_and_reopen_recovers() { - let root = tempfile::tempdir().expect("tempdir"); - let unavailable = - RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Unavailable)) - .build() - .await; - let Err(unavailable) = unavailable else { - panic!("protected data unavailability must fail"); - }; - let report = unavailable.store_report().expect("store report"); - assert_eq!(report.code, "protected_data_unavailable"); - assert!(report.retryable); - - let runtime = RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Available)) - .build() - .await - .expect("recovered runtime"); - runtime.shutdown().await.expect("shutdown"); - } -} diff --git a/crates/mobile_core/src/runtime/info.rs b/crates/mobile_core/src/runtime/info.rs @@ -1,79 +0,0 @@ -use super::RadrootsRuntime; -use chrono::Utc; -use serde::Serialize; - -#[derive(Debug, Clone, Serialize, Default)] -pub struct RuntimeBuildInfo { - pub crate_name: String, - pub crate_version: String, - pub rustc: Option<String>, - pub profile: Option<String>, - pub lib_revision: Option<String>, - pub consumer_revision: Option<String>, - pub build_time_unix: Option<u64>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AppInfo { - pub build: RuntimeBuildInfo, - pub started_unix_ms: i64, - pub uptime_millis: i64, - pub shutting_down: bool, - pub platform: Option<super::app_info::AppInfoPlatform>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RuntimeInfo { - pub app: AppInfo, - pub sdk: RuntimeBuildInfo, - pub sdk_closed: bool, -} - -pub fn gather_runtime_info(runtime: &RadrootsRuntime) -> RuntimeInfo { - let now_ms = Utc::now().timestamp_millis(); - RuntimeInfo { - app: AppInfo { - build: app_build_info(), - started_unix_ms: runtime.started_unix_ms, - uptime_millis: now_ms - runtime.started_unix_ms, - shutting_down: runtime - .shutting_down - .load(std::sync::atomic::Ordering::SeqCst), - platform: runtime - .platform_app - .read() - .ok() - .and_then(|value| (*value).clone()), - }, - sdk: RuntimeBuildInfo { - crate_name: "radroots_sdk".to_owned(), - crate_version: "0.1.0-alpha".to_owned(), - ..RuntimeBuildInfo::default() - }, - sdk_closed: runtime.client.is_closed(), - } -} - -pub fn app_build_info() -> RuntimeBuildInfo { - RuntimeBuildInfo { - crate_name: env!("CARGO_PKG_NAME").to_owned(), - crate_version: env!("CARGO_PKG_VERSION").to_owned(), - rustc: option_env!("RUSTC_VERSION").map(str::to_owned), - profile: option_env!("PROFILE").map(str::to_owned), - lib_revision: option_env!("RADROOTS_LIB_REVISION").map(str::to_owned), - consumer_revision: option_env!("RADROOTS_CONSUMER_REVISION").map(str::to_owned), - build_time_unix: option_env!("BUILD_TIME_UNIX").and_then(|value| value.parse().ok()), - } -} - -#[cfg(test)] -mod tests { - #[test] - fn build_info_uses_sdk_identity_without_lower_runtime_metadata() { - let runtime = super::RadrootsRuntime::test_memory().expect("runtime"); - let info = runtime.info(); - assert_eq!(info.sdk.crate_name, "radroots_sdk"); - assert_eq!(info.sdk.crate_version, "0.1.0-alpha"); - assert!(!info.sdk_closed); - } -} diff --git a/crates/mobile_core/src/runtime/mod.rs b/crates/mobile_core/src/runtime/mod.rs @@ -1,223 +0,0 @@ -pub mod app_info; -pub mod builder; -pub mod info; -pub mod product_surface; -pub mod sdk; -pub mod store; - -use chrono::Utc; -use radroots_identity::PublicKey; -use radroots_sdk::{Client, ClientBuilder}; -#[cfg(feature = "mobile-social")] -use std::path::PathBuf; -use std::sync::{ - RwLock, - atomic::{AtomicBool, Ordering}, -}; - -use self::{ - app_info::AppInfoPlatform, - info::{RuntimeInfo, gather_runtime_info}, -}; -use crate::RadrootsAppError; - -pub struct RadrootsRuntime { - pub(crate) client: Client, - pub(crate) started_unix_ms: i64, - pub(crate) shutting_down: AtomicBool, - pub(crate) platform_app: RwLock<Option<AppInfoPlatform>>, - pub(crate) store_public_key: Option<PublicKey>, - #[cfg(feature = "mobile-social")] - pub(crate) settings_lock: tokio::sync::Mutex<()>, - #[cfg(feature = "mobile-social")] - pub(crate) identity_session: tokio::sync::RwLock<Option<(u64, product_surface::IdentityState)>>, - #[cfg(feature = "mobile-social")] - pub(crate) inbound_media_directory: Option<PathBuf>, - #[cfg(feature = "mobile-social")] - pub(crate) inbound_media_lock: tokio::sync::Mutex<()>, -} - -impl RadrootsRuntime { - pub(crate) fn from_client_builder( - builder: ClientBuilder, - store_public_key: Option<PublicKey>, - #[cfg(feature = "mobile-social")] inbound_media_directory: Option<PathBuf>, - #[cfg(feature = "mobile-social")] signer: Option< - std::sync::Arc<dyn radroots_signing::Signer>, - >, - #[cfg(feature = "mobile-social")] relay_profile: Option< - radroots_sdk::transport::RelayProfile, - >, - #[cfg(feature = "mobile-social")] blossom_config: Option< - radroots_sdk::transport::BlossomConfig, - >, - ) -> Result<Self, RadrootsAppError> { - #[cfg(feature = "mobile-social")] - let builder = { - let nostr_slot = radroots_sdk::transport::NostrSlot::new(); - if let Some(profile) = relay_profile { - nostr_slot - .configure(profile) - .map_err(RadrootsAppError::from_sdk)?; - } - let builder = builder - .nostr(nostr_slot) - .blossom({ - let slot = radroots_sdk::transport::BlossomSlot::new(); - if let Some(config) = blossom_config { - slot.configure(config) - .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?; - } - slot - }) - .host_sync(radroots_sdk::sync::HostPolicy::standard()); - match signer { - Some(signer) => builder.signing(radroots_sdk::signing::Provider::host(signer)), - None => builder, - } - }; - let client = builder.build().map_err(RadrootsAppError::from_sdk)?; - - Ok(Self { - client, - started_unix_ms: Utc::now().timestamp_millis(), - shutting_down: AtomicBool::new(false), - platform_app: RwLock::new(None), - store_public_key, - #[cfg(feature = "mobile-social")] - settings_lock: tokio::sync::Mutex::new(()), - #[cfg(feature = "mobile-social")] - identity_session: tokio::sync::RwLock::new(None), - #[cfg(feature = "mobile-social")] - inbound_media_directory, - #[cfg(feature = "mobile-social")] - inbound_media_lock: tokio::sync::Mutex::new(()), - }) - } - - #[cfg(test)] - pub(crate) fn test_memory() -> Result<Self, RadrootsAppError> { - Self::from_client_builder( - ClientBuilder::memory_default(), - None, - #[cfg(feature = "mobile-social")] - None, - #[cfg(feature = "mobile-social")] - None, - #[cfg(feature = "mobile-social")] - None, - #[cfg(feature = "mobile-social")] - None, - ) - } - - /// Closes SDK resources asynchronously across every runtime reference. - /// - /// Dropping the returned future before its first poll has no effect. If a - /// host cancels after close begins, it must call `shutdown` again; the SDK - /// remains unavailable and resumes the explicit close attempt. Completed - /// calls are idempotent and no blocking destructor is installed. - pub async fn shutdown(&self) -> Result<sdk::SdkShutdownRecord, RadrootsAppError> { - let already_closed = self.client.is_closed(); - self.shutting_down.store(true, Ordering::Release); - self.client - .close() - .await - .map_err(RadrootsAppError::from_sdk)?; - Ok(sdk::SdkShutdownRecord { - state: "closed".to_owned(), - already_closed, - }) - } - - pub fn uptime_millis(&self) -> i64 { - Utc::now().timestamp_millis() - self.started_unix_ms - } - - /// Returns the canonical public identity that scopes durable storage. - /// Explicit unit-test memory runtimes are the only runtimes without one. - pub fn authenticated_store_public_key_hex(&self) -> Option<String> { - self.store_public_key.map(|key| key.to_hex()) - } - - pub fn info(&self) -> RuntimeInfo { - gather_runtime_info(self) - } - - pub fn info_json(&self) -> String { - serde_json::to_string_pretty(&self.info()) - .unwrap_or_else(|error| format!(r#"{{"error":"serialize RuntimeInfo: {error}"}}"#)) - } - - pub fn set_app_info_platform( - &self, - platform: Option<String>, - bundle_id: Option<String>, - version: Option<String>, - build_number: Option<String>, - build_sha: Option<String>, - ) { - let platform_info = - AppInfoPlatform::new(platform, bundle_id, version, build_number, build_sha); - if let Ok(mut guard) = self.platform_app.write() { - *guard = Some(platform_info); - } - } -} - -#[cfg(test)] -mod tests { - use super::RadrootsRuntime; - use radroots_sdk::capability::{Availability, CapabilityId}; - use std::panic::{AssertUnwindSafe, catch_unwind}; - - fn poison_platform_lock(runtime: &RadrootsRuntime) { - let _ = catch_unwind(AssertUnwindSafe(|| { - let _guard = runtime.platform_app.write().expect("lock platform"); - panic!("poison platform lock"); - })); - } - - #[test] - fn runtime_owns_one_sdk_client() { - let runtime = RadrootsRuntime::test_memory().expect("runtime"); - let storage = runtime - .client - .capabilities() - .get(CapabilityId::CANONICAL_STORAGE) - .expect("storage capability"); - assert_eq!(storage.availability(), Availability::Available); - assert!(!runtime.client.is_closed()); - } - - #[test] - fn set_platform_info_handles_poisoned_lock() { - let runtime = RadrootsRuntime::test_memory().expect("runtime"); - runtime.set_app_info_platform( - Some("ios".to_owned()), - Some("org.radroots.app".to_owned()), - Some("1.0.0".to_owned()), - Some("100".to_owned()), - Some("abc123".to_owned()), - ); - assert_eq!( - runtime - .info() - .app - .platform - .as_ref() - .and_then(|value| value.platform.clone()), - Some("ios".to_owned()) - ); - poison_platform_lock(&runtime); - runtime.set_app_info_platform(None, None, None, None, None); - } - - #[test] - fn runtime_metadata_helpers_are_host_safe() { - let runtime = RadrootsRuntime::test_memory().expect("runtime"); - assert!(runtime.uptime_millis() >= 0); - let json = runtime.info_json(); - assert!(json.contains("sdk")); - } -} diff --git a/crates/mobile_core/src/runtime/product_surface.rs b/crates/mobile_core/src/runtime/product_surface.rs @@ -1,154 +0,0 @@ -//! Focused Phase 1 social-product domain for native Radroots clients. -//! -//! This module owns presentation-neutral product semantics. Protocol parsing -//! and admission remain in lower event crates; persistence and live query -//! composition remain in the runtime slices that consume these types. - -mod authoring; -mod context; -mod cursor; -mod identity; -mod media; -mod model; -#[cfg(feature = "mobile-social")] -mod outbox; -mod projection; -mod ranking; -#[cfg(feature = "mobile-social")] -mod settings; -mod today; - -pub use authoring::{ - CreateAsk, CreateEvent, CreateFoodAvailability, CreatePhotoUpdate, CreateUpdate, - Phase1AddCommand, Phase1ReplacementPolicy, phase1_retraction_plan, -}; -pub use context::{ - ContextAdmission, ContextRank, LocalNetwork, LocalNetworkAdmission, LocalNetworkError, - LocalNetworkRelayPolicy, LocalityEvidence, -}; -pub use cursor::{CursorError, CursorScope, TodayCursor, TodayCursorPosition}; -pub use identity::{CARD_ID_SCHEMA_VERSION, CardId, CardIdError, CardSourceIdentity}; -#[cfg(feature = "mobile-social")] -pub use media::Phase1LocalMediaArtifact; -pub use media::{ - MediaReference, Phase1InboundMediaError, Phase1InboundMediaFailure, Phase1InboundMediaPending, - Phase1InboundMediaState, Phase1MediaArtifactId, Phase1MediaCacheIndex, Phase1MediaCachePolicy, - Phase1MediaCacheStatus, Phase1MediaConfigurationFingerprint, Phase1StructuralMediaReference, - Phase1VerifiedMediaReceipt, -}; -pub use model::{ - AddCommandType, CANONICAL_ADD_COMMAND_TYPES, CANONICAL_CARD_ADD_PARITY, - CANONICAL_TODAY_CARD_TYPES, CardAddParity, CardLifecycleState, ClassifiedCard, - LocalAuthorOverlay, MeSnapshot, ProfileSummary, SearchResult, SearchResultType, - SupportingProfile, ThreadEntry, ThreadReference, TodayCard, TodayCardType, TodayPage, -}; -#[cfg(feature = "mobile-social")] -pub use outbox::{ - Phase1AddIntent, Phase1CancellationPolicy, Phase1DraftError, Phase1DraftEventTiming, - Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus, - Phase1ExistingDraft, Phase1MediaOrphanRecord, Phase1MediaPrerequisite, Phase1MediaStage, - Phase1NativeUploadJob, Phase1OutboxState, Phase1ProfileStatus, Phase1QueueIntent, - Phase1QueuePolicy, Phase1RelaySatisfaction, Phase1ReviseIntent, Phase1RevisionPhase, - Phase1RevisionPolicy, Phase1RevisionStatus, Phase1RevisionTarget, Phase1UploadIntent, - Phase1UploadPlan, phase1_new_addressable_identifier, phase1_new_operation_id, - phase1_operation_now_unix_ms, -}; -pub use projection::{ProductEventClassification, ProductEventExclusion, classify_admitted_event}; -pub use ranking::{RankError, TODAY_RANK_SCHEMA_VERSION, TimeRelevance, TodayRank, TodayRankInput}; -#[cfg(feature = "mobile-social")] -pub use settings::{ - BlossomEndpointAuthorityPreference, BlossomPreferences, DEFAULT_PUBLIC_BLOSSOM_ORIGIN, - DEFAULT_PUBLIC_RELAY, DEFAULT_SIMULATOR_BLOSSOM_ORIGIN, DEFAULT_SIMULATOR_RELAY, - IdentityCommand, IdentityLockState, IdentityRecord, IdentitySettingsError, IdentityState, - LocalStoragePolicy, MOBILE_SETTINGS_SCHEMA_VERSION, MediaNetworkPolicy, - MobileNetworkEnvironment, MobileSettings, ProfileMetadataCommand, ProfileMetadataError, - RelayAccessPreference, RelayEndpointPreference, RelayPreferences, ReplaceMobileSettings, - SettingsError, SettingsTransition, -}; -pub use today::{ - TodayError, TodayIngestReceipt, TodayPageRequest, TodayProjectionUpdate, TodayRefreshReceipt, -}; -#[cfg(feature = "mobile-social")] -pub use today::{TodayRelaySyncState, TodaySyncReceipt}; - -use super::RadrootsRuntime; - -impl RadrootsRuntime { - /// Returns the exact five Phase 1 Today card types in contract order. - pub fn phase1_card_types(&self) -> Vec<TodayCardType> { - CANONICAL_TODAY_CARD_TYPES.to_vec() - } - - /// Returns the exact five Phase 1 Add commands in card-parity order. - pub fn phase1_add_command_types(&self) -> Vec<AddCommandType> { - CANONICAL_ADD_COMMAND_TYPES.to_vec() - } - - /// Returns the closed one-to-one Today/Add mapping. - pub fn phase1_card_add_parity(&self) -> Vec<CardAddParity> { - CANONICAL_CARD_ADD_PARITY.to_vec() - } - - /// Constructs a validated local query/composer context. - pub fn phase1_local_network( - &self, - id: String, - label: String, - relay_urls: Vec<String>, - locality: Option<String>, - followed_authors: Vec<String>, - generation: u64, - ) -> Result<LocalNetwork, crate::RadrootsAppError> { - LocalNetwork::new( - id, - label, - relay_urls, - locality, - followed_authors, - generation, - ) - .map_err(|error| crate::RadrootsAppError::runtime(error.to_string())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn runtime_exposes_only_the_locked_card_and_add_catalogs() { - let runtime = RadrootsRuntime::test_memory().expect("runtime"); - assert_eq!(runtime.phase1_card_types(), CANONICAL_TODAY_CARD_TYPES); - assert_eq!( - runtime.phase1_add_command_types(), - CANONICAL_ADD_COMMAND_TYPES - ); - assert_eq!(runtime.phase1_card_add_parity(), CANONICAL_CARD_ADD_PARITY); - assert_eq!( - runtime - .phase1_local_network( - "nearby".into(), - "Near me".into(), - vec!["wss://relay.example".into()], - Some("u10h".into()), - vec!["a".repeat(64)], - 1, - ) - .expect("network") - .id, - "nearby" - ); - assert!( - runtime - .phase1_local_network( - "nearby".into(), - "Near me".into(), - Vec::new(), - None, - Vec::new(), - 1, - ) - .is_err() - ); - } -} diff --git a/crates/mobile_core/src/runtime/product_surface/authoring.rs b/crates/mobile_core/src/runtime/product_surface/authoring.rs @@ -1,310 +0,0 @@ -use radroots_event::{ - calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent}, - food::availability::FoodAvailabilityDetails, - post::{ - AuthoredAsk, AuthoredPhotoUpdate, AuthoredPostError, AuthoredPostImage, AuthoredUpdate, - deletion::AuthoredNip09DeletionRequest, - }, -}; -use radroots_event_codec::authoring::{AuthoredEventPlan, AuthoredPlanError}; - -use super::AddCommandType; - -/// A strict `CreateUpdate` command. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CreateUpdate(AuthoredUpdate); - -impl CreateUpdate { - pub fn new(content: impl Into<String>) -> Result<Self, AuthoredPostError> { - AuthoredUpdate::new(content).map(Self) - } - - pub const fn authored(&self) -> &AuthoredUpdate { - &self.0 - } -} - -/// A strict `CreatePhotoUpdate` command. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CreatePhotoUpdate(AuthoredPhotoUpdate); - -impl CreatePhotoUpdate { - pub fn new( - content: impl Into<String>, - images: Vec<AuthoredPostImage>, - ) -> Result<Self, AuthoredPostError> { - AuthoredPhotoUpdate::new(content, images).map(Self) - } - - pub const fn authored(&self) -> &AuthoredPhotoUpdate { - &self.0 - } -} - -/// A strict `CreateAsk` command. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CreateAsk(AuthoredAsk); - -impl CreateAsk { - pub fn new( - question: impl Into<String>, - images: Vec<AuthoredPostImage>, - ) -> Result<Self, AuthoredPostError> { - AuthoredAsk::new(question, images).map(Self) - } - - pub const fn authored(&self) -> &AuthoredAsk { - &self.0 - } -} - -/// A strict `CreateEvent` command with an explicit all-day or timed profile. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CreateEvent(Box<CreateEventProfile>); - -#[derive(Clone, Debug, PartialEq, Eq)] -enum CreateEventProfile { - Date(AuthoredCalendarDateEvent), - Time(AuthoredCalendarTimeEvent), -} - -impl CreateEvent { - pub fn date(event: AuthoredCalendarDateEvent) -> Self { - Self(Box::new(CreateEventProfile::Date(event))) - } - - pub fn time(event: AuthoredCalendarTimeEvent) -> Self { - Self(Box::new(CreateEventProfile::Time(event))) - } -} - -/// A strict `CreateFoodAvailability` command. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CreateFoodAvailability(FoodAvailabilityDetails); - -impl CreateFoodAvailability { - pub const fn new(details: FoodAvailabilityDetails) -> Self { - Self(details) - } - - pub const fn authored(&self) -> &FoodAvailabilityDetails { - &self.0 - } -} - -/// The only five Phase 1 Add commands accepted by focused mobile authoring. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Phase1AddCommand { - CreateUpdate(CreateUpdate), - CreatePhotoUpdate(CreatePhotoUpdate), - CreateAsk(CreateAsk), - CreateEvent(CreateEvent), - CreateFoodAvailability(CreateFoodAvailability), -} - -/// Standard revision behavior for a Phase 1 authored profile. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Phase1ReplacementPolicy { - /// Regular kind-1 events have no edit convention. The corrected event must - /// settle before an independent retraction is allowed to begin. - CreateThenRetract, - /// Addressable events replace the current head by reusing their stable `d`. - AddressableReplacement, -} - -impl Phase1AddCommand { - pub const fn command_type(&self) -> AddCommandType { - match self { - Self::CreateUpdate(_) => AddCommandType::CreateUpdate, - Self::CreatePhotoUpdate(_) => AddCommandType::CreatePhotoUpdate, - Self::CreateAsk(_) => AddCommandType::CreateAsk, - Self::CreateEvent(_) => AddCommandType::CreateEvent, - Self::CreateFoodAvailability(_) => AddCommandType::CreateFoodAvailability, - } - } - - pub const fn replacement_policy(&self) -> Phase1ReplacementPolicy { - match self { - Self::CreateUpdate(_) | Self::CreatePhotoUpdate(_) | Self::CreateAsk(_) => { - Phase1ReplacementPolicy::CreateThenRetract - } - Self::CreateEvent(_) | Self::CreateFoodAvailability(_) => { - Phase1ReplacementPolicy::AddressableReplacement - } - } - } - - /// Binds the validated command to one exact timestamp and expected author. - pub fn authored_plan( - &self, - created_at: u64, - expected_author: impl AsRef<str>, - ) -> Result<AuthoredEventPlan, AuthoredPlanError> { - let expected_author = expected_author.as_ref(); - match self { - Self::CreateUpdate(command) => { - AuthoredEventPlan::from_update(command.authored(), created_at, expected_author) - } - Self::CreatePhotoUpdate(command) => AuthoredEventPlan::from_photo_update( - command.authored(), - created_at, - expected_author, - ), - Self::CreateAsk(command) => { - AuthoredEventPlan::from_ask(command.authored(), created_at, expected_author) - } - Self::CreateEvent(command) => match command.0.as_ref() { - CreateEventProfile::Date(event) => { - AuthoredEventPlan::from_calendar_date_event(event, created_at, expected_author) - } - CreateEventProfile::Time(event) => { - AuthoredEventPlan::from_calendar_time_event(event, created_at, expected_author) - } - }, - Self::CreateFoodAvailability(command) => AuthoredEventPlan::from_food_availability( - command.authored(), - created_at, - expected_author, - ), - } - } -} - -/// Builds the independent strict NIP-09 plan used for retraction or withdrawal. -/// -/// This function intentionally does not combine retraction and replacement -/// into one purportedly atomic operation. -pub fn phase1_retraction_plan( - request: &AuthoredNip09DeletionRequest, - created_at: u64, - expected_author: impl AsRef<str>, -) -> Result<AuthoredEventPlan, AuthoredPlanError> { - AuthoredEventPlan::from_nip09_deletion_request(request, created_at, expected_author) -} - -#[cfg(test)] -mod tests { - use super::*; - use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; - use radroots_event::{ - calendar::CalendarDate, - envelope::kind::{ - KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_CLASSIFIED_LISTING, KIND_POST, - }, - food::availability::{ - FoodAvailabilityDetailsParts, FoodAvailabilityStatus, FoodContent, FoodCurrency, - FoodIdentifier, FoodPrice, FoodPublishedAt, FoodText, FoodUnit, - }, - media::AuthoredImage, - post::PostImageDimensions, - post::deletion::{AuthoredNip09DeletionRequest, Nip09DeletionEventTarget}, - }; - - const AUTHOR: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - #[test] - fn focused_commands_bind_only_locked_wire_profiles() { - let date = AuthoredCalendarDateEvent::new( - "market-day", - "Saturday Market", - CalendarDate::parse("2026-08-08").unwrap(), - ) - .unwrap(); - let time = AuthoredCalendarTimeEvent::new("farm-tour", "Farm Tour", 1_784_380_800).unwrap(); - let image = post_image(); - let commands = [ - Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), - Phase1AddCommand::CreatePhotoUpdate( - CreatePhotoUpdate::new(format!("Harvest photo {}", image.url()), vec![image]) - .unwrap(), - ), - Phase1AddCommand::CreateAsk(CreateAsk::new("Who has basil?", Vec::new()).unwrap()), - Phase1AddCommand::CreateEvent(CreateEvent::date(date)), - Phase1AddCommand::CreateEvent(CreateEvent::time(time)), - Phase1AddCommand::CreateFoodAvailability(CreateFoodAvailability::new(food())), - ]; - let expected = [ - (AddCommandType::CreateUpdate, KIND_POST), - (AddCommandType::CreatePhotoUpdate, KIND_POST), - (AddCommandType::CreateAsk, KIND_POST), - (AddCommandType::CreateEvent, KIND_CALENDAR_DATE_EVENT), - (AddCommandType::CreateEvent, KIND_CALENDAR_TIME_EVENT), - ( - AddCommandType::CreateFoodAvailability, - KIND_CLASSIFIED_LISTING, - ), - ]; - for (command, (command_type, kind)) in commands.iter().zip(expected) { - let plan = command.authored_plan(1_784_347_200, AUTHOR).unwrap(); - assert_eq!(command.command_type(), command_type); - assert_eq!(plan.body().kind(), kind); - assert_ne!(plan.body().kind(), 20); - } - } - - #[test] - fn revision_and_retraction_semantics_are_explicit() { - let update = Phase1AddCommand::CreateUpdate(CreateUpdate::new("new post").unwrap()); - assert_eq!( - update.replacement_policy(), - Phase1ReplacementPolicy::CreateThenRetract - ); - let event = Phase1AddCommand::CreateEvent(CreateEvent::time( - AuthoredCalendarTimeEvent::new("farm-tour", "Farm Tour", 1_784_380_800).unwrap(), - )); - assert_eq!( - event.replacement_policy(), - Phase1ReplacementPolicy::AddressableReplacement - ); - - let request = AuthoredNip09DeletionRequest::new( - "retracted", - vec![Nip09DeletionEventTarget::parse("b".repeat(64), KIND_POST).unwrap()], - Vec::new(), - ) - .unwrap(); - let plan = phase1_retraction_plan(&request, 1_784_347_201, AUTHOR).unwrap(); - assert_eq!(plan.body().kind(), 5); - } - - fn food() -> FoodAvailabilityDetails { - FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts { - content: FoodContent::new("Carrots available this week.").unwrap(), - identifier: FoodIdentifier::parse("nantes-carrots").unwrap(), - title: FoodText::new("Nantes Carrots").unwrap(), - summary: FoodText::new("Fresh bunches").unwrap(), - published_at: FoodPublishedAt::new(1_784_347_100).unwrap(), - location: FoodText::new("Central Saanich, BC").unwrap(), - price: FoodPrice::new("3", FoodCurrency::parse("CAD").unwrap(), FoodUnit::Pound) - .unwrap(), - quantity: None, - status: FoodAvailabilityStatus::Active, - images: Vec::new(), - }) - .unwrap() - } - - fn post_image() -> AuthoredPostImage { - let bytes = b"harvest-photo"; - let hash = Sha256::digest(bytes); - let media_type = MediaType::parse("image/webp").unwrap(); - let descriptor = BlobDescriptor::new( - BlobUrl::parse(&format!("https://media.example/{hash}.webp")).unwrap(), - hash, - bytes.len() as u64, - media_type.clone(), - 1_784_347_100, - ) - .unwrap() - .approve_reference() - .unwrap() - .verify_bytes(bytes, &media_type) - .unwrap(); - AuthoredPostImage::new( - AuthoredImage::try_from(descriptor).unwrap(), - PostImageDimensions::new(1200, 900).unwrap(), - "Harvest", - ) - .unwrap() - } -} diff --git a/crates/mobile_core/src/runtime/product_surface/context.rs b/crates/mobile_core/src/runtime/product_surface/context.rs @@ -1,466 +0,0 @@ -use std::collections::BTreeSet; - -use radroots_transport_nostr::{RelayUrl, RelayUrlPolicy}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -const CONTEXT_TEXT_MAX_BYTES: usize = 256; -const RELAY_URL_MAX_BYTES: usize = 2_048; - -/// A validated local query/composer context. It has no Nostr event identity. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LocalNetwork { - pub id: String, - pub label: String, - pub relay_urls: Vec<String>, - pub locality: Option<String>, - pub followed_authors: Vec<String>, - pub generation: u64, -} - -#[derive(Clone, Debug, Error, Eq, PartialEq)] -pub enum LocalNetworkError { - #[error("local network {field} is invalid")] - InvalidText { field: &'static str }, - #[error("local network requires at least one relay")] - MissingRelay, - #[error("local network relay URL is invalid")] - InvalidRelay, - #[error("local network relay URLs must be unique")] - DuplicateRelay, - #[error("local network followed author is invalid")] - InvalidAuthor, - #[error("local network followed authors must be unique")] - DuplicateAuthor, -} - -/// Host environment whose destination policy governs LocalNetwork relays. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "PascalCase")] -pub enum LocalNetworkRelayPolicy { - Public, - Simulator, - Device, -} - -impl LocalNetwork { - pub fn new( - id: String, - label: String, - relay_urls: Vec<String>, - locality: Option<String>, - followed_authors: Vec<String>, - generation: u64, - ) -> Result<Self, LocalNetworkError> { - Self::new_for_relay_policy( - id, - label, - relay_urls, - locality, - followed_authors, - generation, - LocalNetworkRelayPolicy::Public, - ) - } - - /// Constructs a context under the exact host relay destination policy. - #[allow(clippy::too_many_arguments)] - pub fn new_for_relay_policy( - id: String, - label: String, - relay_urls: Vec<String>, - locality: Option<String>, - followed_authors: Vec<String>, - generation: u64, - relay_policy: LocalNetworkRelayPolicy, - ) -> Result<Self, LocalNetworkError> { - validate_text(&id, "id")?; - validate_text(&label, "label")?; - if let Some(locality) = locality.as_deref() { - validate_text(locality, "locality")?; - } - if relay_urls.is_empty() { - return Err(LocalNetworkError::MissingRelay); - } - let mut relays = BTreeSet::new(); - let mut canonical_relay_urls = Vec::with_capacity(relay_urls.len()); - for relay in relay_urls { - if relay.is_empty() || relay.len() > RELAY_URL_MAX_BYTES { - return Err(LocalNetworkError::InvalidRelay); - } - let relay = RelayUrl::parse( - relay, - match relay_policy { - LocalNetworkRelayPolicy::Public => RelayUrlPolicy::Public, - LocalNetworkRelayPolicy::Simulator => RelayUrlPolicy::Local, - LocalNetworkRelayPolicy::Device => RelayUrlPolicy::PrivateNetwork, - }, - ) - .map_err(|_| LocalNetworkError::InvalidRelay)?; - if !relays.insert(relay.clone()) { - return Err(LocalNetworkError::DuplicateRelay); - } - canonical_relay_urls.push(relay.to_string()); - } - let mut authors = BTreeSet::new(); - for author in &followed_authors { - if author.len() != 64 - || !author - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(LocalNetworkError::InvalidAuthor); - } - if !authors.insert(author) { - return Err(LocalNetworkError::DuplicateAuthor); - } - } - Ok(Self { - id, - label, - relay_urls: canonical_relay_urls, - locality, - followed_authors, - generation, - }) - } - - /// Applies the locked locality policy to the selected local context. - pub const fn admit(&self, evidence: LocalityEvidence) -> LocalNetworkAdmission { - match evidence { - LocalityEvidence::Match => LocalNetworkAdmission::Included(ContextAdmission { - rank: ContextRank::LocalityMatch, - reason: "locality_match", - }), - LocalityEvidence::Missing => LocalNetworkAdmission::Included(ContextAdmission { - rank: ContextRank::MissingLocalityFallback, - reason: "locality_missing_fallback", - }), - LocalityEvidence::Nonmatch => LocalNetworkAdmission::Excluded { - reason: "locality_nonmatch", - }, - } - } -} - -fn validate_text(value: &str, field: &'static str) -> Result<(), LocalNetworkError> { - if value.is_empty() - || value.trim() != value - || value.len() > CONTEXT_TEXT_MAX_BYTES - || value.chars().any(char::is_control) - { - return Err(LocalNetworkError::InvalidText { field }); - } - Ok(()) -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "PascalCase")] -pub enum LocalityEvidence { - Match, - Missing, - Nonmatch, -} - -/// The only admitted context-rank values. -#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] -#[serde(rename_all = "PascalCase")] -pub enum ContextRank { - MissingLocalityFallback = 1, - LocalityMatch = 2, -} - -impl ContextRank { - pub const fn value(self) -> u8 { - self as u8 - } - - pub const fn from_value(value: u8) -> Option<Self> { - match value { - 1 => Some(Self::MissingLocalityFallback), - 2 => Some(Self::LocalityMatch), - _ => None, - } - } -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ContextAdmission { - pub rank: ContextRank, - pub reason: &'static str, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum LocalNetworkAdmission { - Included(ContextAdmission), - Excluded { reason: &'static str }, -} - -#[cfg(test)] -mod tests { - use super::*; - - fn network() -> LocalNetwork { - LocalNetwork::new( - "local-network".into(), - "Near me".into(), - vec!["wss://relay.example".into()], - Some("u10h".into()), - vec!["a".repeat(64)], - 7, - ) - .expect("network") - } - - #[test] - fn locality_policy_has_exact_rank_and_exclusion_outcomes() { - assert_eq!( - network().admit(LocalityEvidence::Match), - LocalNetworkAdmission::Included(ContextAdmission { - rank: ContextRank::LocalityMatch, - reason: "locality_match", - }) - ); - assert_eq!( - network().admit(LocalityEvidence::Missing), - LocalNetworkAdmission::Included(ContextAdmission { - rank: ContextRank::MissingLocalityFallback, - reason: "locality_missing_fallback", - }) - ); - assert!(matches!( - network().admit(LocalityEvidence::Nonmatch), - LocalNetworkAdmission::Excluded { .. } - )); - } - - #[test] - fn local_network_fields_are_bounded_and_unique() { - assert_eq!(network().generation, 7); - for invalid in [ - LocalNetwork::new( - "".into(), - "label".into(), - vec!["wss://r".into()], - None, - vec![], - 0, - ), - LocalNetwork::new("id".into(), "label".into(), vec![], None, vec![], 0), - LocalNetwork::new( - "id".into(), - "label".into(), - vec!["".into()], - None, - vec![], - 0, - ), - LocalNetwork::new( - "id".into(), - "label".into(), - vec![format!("wss://{}", "r".repeat(RELAY_URL_MAX_BYTES))], - None, - vec![], - 0, - ), - LocalNetwork::new( - "id".into(), - "label".into(), - vec!["wss://relay example".into()], - None, - vec![], - 0, - ), - LocalNetwork::new( - "id".into(), - "label".into(), - vec!["wss://relay\u{7f}".into()], - None, - vec![], - 0, - ), - LocalNetwork::new( - "id".into(), - "label".into(), - vec!["https://r".into()], - None, - vec![], - 0, - ), - LocalNetwork::new( - "id".into(), - "label".into(), - vec!["wss://".into()], - None, - vec![], - 0, - ), - LocalNetwork::new( - "id".into(), - "label".into(), - vec!["wss://user@relay.example".into()], - None, - vec![], - 0, - ), - LocalNetwork::new( - "id".into(), - "label".into(), - vec!["wss://relay.example#fragment".into()], - None, - vec![], - 0, - ), - LocalNetwork::new( - "id".into(), - "label".into(), - vec!["wss://r".into(), "wss://r".into()], - None, - vec![], - 0, - ), - LocalNetwork::new( - "id".into(), - "label".into(), - vec!["wss://r".into()], - None, - vec!["A".repeat(64)], - 0, - ), - LocalNetwork::new( - "id".into(), - "label".into(), - vec!["wss://r".into()], - None, - vec!["a".repeat(63)], - 0, - ), - LocalNetwork::new( - "id".into(), - "label".into(), - vec!["wss://r".into()], - None, - vec!["a".repeat(64), "a".repeat(64)], - 0, - ), - LocalNetwork::new( - " id ".into(), - "label".into(), - vec!["wss://r".into()], - None, - vec![], - 0, - ), - LocalNetwork::new( - "i".repeat(CONTEXT_TEXT_MAX_BYTES + 1), - "label".into(), - vec!["wss://r".into()], - None, - vec![], - 0, - ), - LocalNetwork::new( - "id".into(), - "la\u{7f}bel".into(), - vec!["wss://r".into()], - None, - vec![], - 0, - ), - ] { - assert!(invalid.is_err()); - } - let canonical = LocalNetwork::new( - "id".into(), - "label".into(), - vec!["WSS://RELAY.EXAMPLE:443/".into()], - None, - vec![], - 0, - ) - .expect("canonical relay"); - assert_eq!(canonical.relay_urls, vec!["wss://relay.example"]); - assert!(matches!( - LocalNetwork::new( - "id".into(), - "label".into(), - vec![ - "wss://relay.example".into(), - "WSS://RELAY.EXAMPLE:443/".into(), - ], - None, - vec![], - 0, - ), - Err(LocalNetworkError::DuplicateRelay) - )); - assert!(matches!( - LocalNetwork::new( - "id".into(), - "label".into(), - vec!["wss://127.0.0.1:7447".into()], - None, - vec![], - 0, - ), - Err(LocalNetworkError::InvalidRelay) - )); - assert!( - LocalNetwork::new_for_relay_policy( - "id".into(), - "label".into(), - vec!["ws://127.0.0.1:7447".into()], - None, - vec![], - 0, - LocalNetworkRelayPolicy::Simulator, - ) - .is_ok() - ); - assert!( - LocalNetwork::new_for_relay_policy( - "id".into(), - "label".into(), - vec!["wss://192.168.1.7:7447".into()], - None, - vec![], - 0, - LocalNetworkRelayPolicy::Device, - ) - .is_ok() - ); - assert!( - LocalNetwork::new_for_relay_policy( - "id".into(), - "label".into(), - vec!["ws://192.168.1.7:7447".into()], - None, - vec![], - 0, - LocalNetworkRelayPolicy::Device, - ) - .is_ok() - ); - for denied in [ - "wss://relay.example", - "ws://127.0.0.1:7447", - "ws://169.254.1.7:7447", - "ws://8.8.8.8:7447", - ] { - assert!(matches!( - LocalNetwork::new_for_relay_policy( - "id".into(), - "label".into(), - vec![denied.into()], - None, - vec![], - 0, - LocalNetworkRelayPolicy::Device, - ), - Err(LocalNetworkError::InvalidRelay) - )); - } - } -} diff --git a/crates/mobile_core/src/runtime/product_surface/cursor.rs b/crates/mobile_core/src/runtime/product_surface/cursor.rs @@ -1,457 +0,0 @@ -use sha2::{Digest, Sha256}; -use thiserror::Error; - -use super::{CardId, ContextRank, TODAY_RANK_SCHEMA_VERSION, TodayRank}; -use crate::runtime::product_surface::ranking::TODAY_RANK_ALGORITHM_VERSION; - -const CURSOR_PREFIX: &str = "rrtc1:"; -const CURSOR_DOMAIN: &[u8] = b"radroots.today-cursor.v1\0"; -const CURSOR_SCHEMA_VERSION: u16 = 1; -const MAX_CONTEXT_ID_BYTES: usize = 256; -const FIXED_PAYLOAD_BYTES: usize = 2 + 2 + 2 + 2 + 8 + 8 + 32 + 8 + 1 + 1 + 8 + 32; -const DIGEST_BYTES: usize = 32; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CursorScope { - pub context_id: String, - pub context_generation: u64, - pub as_of: u64, - pub store_generation: [u8; 32], - pub projection_generation: u64, -} - -impl CursorScope { - pub fn new( - context_id: String, - context_generation: u64, - as_of: u64, - store_generation: [u8; 32], - projection_generation: u64, - ) -> Result<Self, CursorError> { - validate_context_id(&context_id)?; - Ok(Self { - context_id, - context_generation, - as_of, - store_generation, - projection_generation, - }) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct TodayCursorPosition { - pub rank: TodayRank, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TodayCursor(String); - -#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] -pub enum CursorError { - #[error("today cursor context id is invalid")] - InvalidContext, - #[error("today cursor encoding is malformed")] - Malformed, - #[error("today cursor integrity check failed")] - Integrity, - #[error("today cursor version is unsupported")] - Version, - #[error("today cursor belongs to another context")] - ContextMismatch, - #[error("today cursor belongs to another frozen snapshot")] - SnapshotMismatch, - #[error("today cursor belongs to a retired store or projection generation")] - Stale, - #[error("today cursor position is invalid")] - InvalidPosition, -} - -impl TodayCursor { - pub fn encode(scope: &CursorScope, position: TodayCursorPosition) -> Result<Self, CursorError> { - if position.rank.schema_version != TODAY_RANK_SCHEMA_VERSION - || position.rank.algorithm_version != TODAY_RANK_ALGORITHM_VERSION - { - return Err(CursorError::Version); - } - if position.rank.time_relevance_rank > 4 { - return Err(CursorError::InvalidPosition); - } - let context_bytes = scope.context_id.as_bytes(); - let mut payload = Vec::with_capacity(FIXED_PAYLOAD_BYTES + context_bytes.len()); - payload.extend_from_slice(&CURSOR_SCHEMA_VERSION.to_be_bytes()); - payload.extend_from_slice(&TODAY_RANK_SCHEMA_VERSION.to_be_bytes()); - payload.extend_from_slice(&TODAY_RANK_ALGORITHM_VERSION.to_be_bytes()); - payload.extend_from_slice( - &u16::try_from(context_bytes.len()) - .expect("validated context length fits u16") - .to_be_bytes(), - ); - payload.extend_from_slice(context_bytes); - payload.extend_from_slice(&scope.context_generation.to_be_bytes()); - payload.extend_from_slice(&scope.as_of.to_be_bytes()); - payload.extend_from_slice(&scope.store_generation); - payload.extend_from_slice(&scope.projection_generation.to_be_bytes()); - payload.push(position.rank.context_rank.value()); - payload.push(position.rank.time_relevance_rank); - payload.extend_from_slice(&position.rank.effective_at.to_be_bytes()); - payload.extend_from_slice(position.rank.card_id.as_bytes()); - let digest = cursor_digest(&payload); - payload.extend_from_slice(&digest); - Ok(Self(format!("{CURSOR_PREFIX}{}", hex::encode(payload)))) - } - - pub fn decode(value: &str, expected: &CursorScope) -> Result<TodayCursorPosition, CursorError> { - let (scope, position) = decode_unbound(value)?; - if scope.context_id != expected.context_id - || scope.context_generation != expected.context_generation - { - return Err(CursorError::ContextMismatch); - } - if scope.as_of != expected.as_of { - return Err(CursorError::SnapshotMismatch); - } - if scope.store_generation != expected.store_generation - || scope.projection_generation != expected.projection_generation - { - return Err(CursorError::Stale); - } - Ok(position) - } - - /// Recovers the integrity-checked frozen scope carried by an opaque cursor. - pub fn scope(value: &str) -> Result<CursorScope, CursorError> { - decode_unbound(value).map(|(scope, _)| scope) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -fn decode_unbound(value: &str) -> Result<(CursorScope, TodayCursorPosition), CursorError> { - let encoded = value - .strip_prefix(CURSOR_PREFIX) - .ok_or(CursorError::Malformed)?; - if encoded.len() % 2 != 0 - || !encoded - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(CursorError::Malformed); - } - let bytes = hex::decode(encoded).map_err(|_| CursorError::Malformed)?; - if bytes.len() < FIXED_PAYLOAD_BYTES + DIGEST_BYTES { - return Err(CursorError::Malformed); - } - let (payload, observed_digest) = bytes.split_at(bytes.len() - DIGEST_BYTES); - if cursor_digest(payload).as_slice() != observed_digest { - return Err(CursorError::Integrity); - } - decode_payload(payload) -} - -fn decode_payload(payload: &[u8]) -> Result<(CursorScope, TodayCursorPosition), CursorError> { - let mut decoder = Decoder::new(payload); - let cursor_version = decoder.u16()?; - let rank_schema_version = decoder.u16()?; - let rank_algorithm_version = decoder.u16()?; - if cursor_version != CURSOR_SCHEMA_VERSION - || rank_schema_version != TODAY_RANK_SCHEMA_VERSION - || rank_algorithm_version != TODAY_RANK_ALGORITHM_VERSION - { - return Err(CursorError::Version); - } - let context_len = usize::from(decoder.u16()?); - let context_id = - core::str::from_utf8(decoder.bytes(context_len)?).map_err(|_| CursorError::Malformed)?; - validate_context_id(context_id)?; - let context_generation = decoder.u64()?; - let as_of = decoder.u64()?; - let store_generation = decoder.array_32()?; - let projection_generation = decoder.u64()?; - let context_rank = ContextRank::from_value(decoder.u8()?).ok_or(CursorError::Malformed)?; - let time_relevance_rank = decoder.u8()?; - if time_relevance_rank > 4 { - return Err(CursorError::Malformed); - } - let effective_at = decoder.u64()?; - let card_id = - CardId::parse(&hex::encode(decoder.array_32()?)).map_err(|_| CursorError::Malformed)?; - if !decoder.is_finished() { - return Err(CursorError::Malformed); - } - Ok(( - CursorScope { - context_id: context_id.to_owned(), - context_generation, - as_of, - store_generation, - projection_generation, - }, - TodayCursorPosition { - rank: TodayRank { - schema_version: rank_schema_version, - algorithm_version: rank_algorithm_version, - context_rank, - time_relevance_rank, - effective_at, - card_id, - }, - }, - )) -} - -fn validate_context_id(value: &str) -> Result<(), CursorError> { - if value.is_empty() - || value.len() > MAX_CONTEXT_ID_BYTES - || value.trim() != value - || value.chars().any(char::is_control) - { - return Err(CursorError::InvalidContext); - } - Ok(()) -} - -fn cursor_digest(payload: &[u8]) -> [u8; 32] { - let mut digest = Sha256::new(); - digest.update(CURSOR_DOMAIN); - digest.update(payload); - digest.finalize().into() -} - -struct Decoder<'a> { - remaining: &'a [u8], -} - -impl<'a> Decoder<'a> { - const fn new(value: &'a [u8]) -> Self { - Self { remaining: value } - } - - fn bytes(&mut self, length: usize) -> Result<&'a [u8], CursorError> { - if self.remaining.len() < length { - return Err(CursorError::Malformed); - } - let (value, remaining) = self.remaining.split_at(length); - self.remaining = remaining; - Ok(value) - } - - fn u8(&mut self) -> Result<u8, CursorError> { - Ok(self.bytes(1)?[0]) - } - - fn u16(&mut self) -> Result<u16, CursorError> { - Ok(u16::from_be_bytes( - self.bytes(2)?.try_into().expect("exact length"), - )) - } - - fn u64(&mut self) -> Result<u64, CursorError> { - Ok(u64::from_be_bytes( - self.bytes(8)?.try_into().expect("exact length"), - )) - } - - fn array_32(&mut self) -> Result<[u8; 32], CursorError> { - Ok(self.bytes(32)?.try_into().expect("exact length")) - } - - const fn is_finished(&self) -> bool { - self.remaining.is_empty() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn scope() -> CursorScope { - CursorScope::new("nearby".into(), 4, 2_000_000_000, [7; 32], 9).expect("scope") - } - - fn position() -> TodayCursorPosition { - TodayCursorPosition { - rank: TodayRank { - schema_version: TODAY_RANK_SCHEMA_VERSION, - algorithm_version: TODAY_RANK_ALGORITHM_VERSION, - context_rank: ContextRank::LocalityMatch, - time_relevance_rank: 3, - effective_at: 1_999_999_000, - card_id: CardId::parse(&"a".repeat(64)).expect("card"), - }, - } - } - - fn payload(cursor: &TodayCursor) -> Vec<u8> { - let bytes = - hex::decode(cursor.as_str().strip_prefix(CURSOR_PREFIX).expect("prefix")).expect("hex"); - bytes[..bytes.len() - DIGEST_BYTES].to_vec() - } - - fn signed_payload(mut payload: Vec<u8>) -> String { - payload.extend_from_slice(&cursor_digest(&payload)); - format!("{CURSOR_PREFIX}{}", hex::encode(payload)) - } - - #[test] - fn cursor_vector_round_trips_and_is_fixed() { - let cursor = TodayCursor::encode(&scope(), position()).expect("cursor"); - assert_eq!( - cursor.as_str(), - "rrtc1:00010001000100066e6561726279000000000000000400000000773594000707070707070707070707070707070707070707070707070707070707070707000000000000000902030000000077359018aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaedf305be41633dfc2f7d621e067c3d33a71c3548c6a1fcf68a6707a1d8664b11" - ); - assert_eq!( - TodayCursor::decode(cursor.as_str(), &scope()).expect("decode"), - position() - ); - assert_eq!(TodayCursor::scope(cursor.as_str()).expect("scope"), scope()); - } - - #[test] - fn cursor_rejects_tamper_context_snapshot_and_stale_generations() { - let cursor = TodayCursor::encode(&scope(), position()).expect("cursor"); - let mut tampered = cursor.as_str().as_bytes().to_vec(); - *tampered.last_mut().expect("byte") = b'0'; - assert_eq!( - TodayCursor::decode(core::str::from_utf8(&tampered).expect("utf8"), &scope()), - Err(CursorError::Integrity) - ); - let other_context = - CursorScope::new("other".into(), 4, 2_000_000_000, [7; 32], 9).expect("scope"); - assert_eq!( - TodayCursor::decode(cursor.as_str(), &other_context), - Err(CursorError::ContextMismatch) - ); - let other_context_generation = - CursorScope::new("nearby".into(), 5, 2_000_000_000, [7; 32], 9).expect("scope"); - assert_eq!( - TodayCursor::decode(cursor.as_str(), &other_context_generation), - Err(CursorError::ContextMismatch) - ); - let other_snapshot = - CursorScope::new("nearby".into(), 4, 2_000_000_001, [7; 32], 9).expect("scope"); - assert_eq!( - TodayCursor::decode(cursor.as_str(), &other_snapshot), - Err(CursorError::SnapshotMismatch) - ); - let stale = CursorScope::new("nearby".into(), 4, 2_000_000_000, [8; 32], 9).expect("scope"); - assert_eq!( - TodayCursor::decode(cursor.as_str(), &stale), - Err(CursorError::Stale) - ); - let stale_projection = - CursorScope::new("nearby".into(), 4, 2_000_000_000, [7; 32], 10).expect("scope"); - assert_eq!( - TodayCursor::decode(cursor.as_str(), &stale_projection), - Err(CursorError::Stale) - ); - } - - #[test] - fn malformed_and_versioned_cursor_inputs_fail_closed() { - assert_eq!( - TodayCursor::decode("nope", &scope()), - Err(CursorError::Malformed) - ); - for malformed in ["rrtc1:0", "rrtc1:GG", "rrtc1:00"] { - assert_eq!( - TodayCursor::decode(malformed, &scope()), - Err(CursorError::Malformed) - ); - } - assert_eq!( - TodayCursor::decode( - &TodayCursor::encode(&scope(), position()) - .expect("cursor") - .as_str() - .to_uppercase(), - &scope() - ), - Err(CursorError::Malformed) - ); - assert!(CursorScope::new("".into(), 0, 0, [0; 32], 0).is_err()); - assert!(CursorScope::new("x".repeat(257), 0, 0, [0; 32], 0).is_err()); - assert!(CursorScope::new(" nearby ".into(), 0, 0, [0; 32], 0).is_err()); - assert!(CursorScope::new("near\u{7f}by".into(), 0, 0, [0; 32], 0).is_err()); - let cursor = TodayCursor::encode(&scope(), position()).expect("cursor"); - for version_offset in [1, 3, 5] { - let mut unsupported = payload(&cursor); - unsupported[version_offset] = 2; - assert_eq!( - TodayCursor::decode(&signed_payload(unsupported), &scope()), - Err(CursorError::Version) - ); - } - let mut invalid_utf8 = payload(&cursor); - invalid_utf8[8] = 0xff; - assert_eq!( - TodayCursor::decode(&signed_payload(invalid_utf8), &scope()), - Err(CursorError::Malformed) - ); - let mut invalid_context = payload(&cursor); - invalid_context[8] = b' '; - assert_eq!( - TodayCursor::decode(&signed_payload(invalid_context), &scope()), - Err(CursorError::InvalidContext) - ); - let mut trailing = payload(&cursor); - trailing.push(0); - assert_eq!( - TodayCursor::decode(&signed_payload(trailing), &scope()), - Err(CursorError::Malformed) - ); - let mut invalid_context_rank = payload(&cursor); - invalid_context_rank[70] = 3; - assert_eq!( - TodayCursor::decode(&signed_payload(invalid_context_rank), &scope()), - Err(CursorError::Malformed) - ); - let mut invalid_time_rank = payload(&cursor); - invalid_time_rank[71] = 5; - assert_eq!( - TodayCursor::decode(&signed_payload(invalid_time_rank), &scope()), - Err(CursorError::Malformed) - ); - let mut truncated_field = vec![0; FIXED_PAYLOAD_BYTES]; - truncated_field[1] = 1; - truncated_field[3] = 1; - truncated_field[5] = 1; - truncated_field[6] = 1; - assert_eq!( - TodayCursor::decode(&signed_payload(truncated_field), &scope()), - Err(CursorError::Malformed) - ); - let invalid_version = TodayCursorPosition { - rank: TodayRank { - schema_version: 2, - ..position().rank - }, - }; - assert_eq!( - TodayCursor::encode(&scope(), invalid_version), - Err(CursorError::Version) - ); - let invalid_algorithm = TodayCursorPosition { - rank: TodayRank { - algorithm_version: 2, - ..position().rank - }, - }; - assert_eq!( - TodayCursor::encode(&scope(), invalid_algorithm), - Err(CursorError::Version) - ); - let invalid_rank = TodayCursorPosition { - rank: TodayRank { - time_relevance_rank: 5, - ..position().rank - }, - }; - assert_eq!( - TodayCursor::encode(&scope(), invalid_rank), - Err(CursorError::InvalidPosition) - ); - } -} diff --git a/crates/mobile_core/src/runtime/product_surface/identity.rs b/crates/mobile_core/src/runtime/product_surface/identity.rs @@ -1,185 +0,0 @@ -use core::fmt; - -use radroots_event::EventId; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use thiserror::Error; - -use super::TodayCardType; - -pub const CARD_ID_SCHEMA_VERSION: u16 = 1; -const CARD_ID_DOMAIN: &[u8] = b"radroots.today-card.v1\0"; - -/// Canonical source identity used to derive a stable card identifier. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum CardSourceIdentity { - Event(EventId), - Address { - kind: u32, - author_pubkey: String, - identifier: String, - }, -} - -#[derive(Clone, Debug, Error, Eq, PartialEq)] -pub enum CardIdError { - #[error("card address kind must be parameterized replaceable")] - InvalidAddressKind, - #[error("card address author must be canonical lowercase hexadecimal")] - InvalidAuthor, - #[error("card address identifier is invalid")] - InvalidIdentifier, - #[error("card identifier must be 64 lowercase hexadecimal characters")] - InvalidCardId, -} - -impl CardSourceIdentity { - pub fn address( - kind: u32, - author_pubkey: impl Into<String>, - identifier: impl Into<String>, - ) -> Result<Self, CardIdError> { - if !(30_000..40_000).contains(&kind) { - return Err(CardIdError::InvalidAddressKind); - } - let author_pubkey = author_pubkey.into(); - if author_pubkey.len() != 64 - || !author_pubkey - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(CardIdError::InvalidAuthor); - } - let identifier = identifier.into(); - if identifier.is_empty() - || identifier.len() > 512 - || identifier.chars().any(char::is_control) - { - return Err(CardIdError::InvalidIdentifier); - } - Ok(Self::Address { - kind, - author_pubkey, - identifier, - }) - } - - pub fn canonical_string(&self) -> String { - match self { - Self::Event(event_id) => format!("event:{}", event_id.to_hex()), - Self::Address { - kind, - author_pubkey, - identifier, - } => format!("address:{kind}:{author_pubkey}:{identifier}"), - } - } -} - -/// Lowercase SHA-256 stable identity for one top-level Today card. -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct CardId([u8; 32]); - -impl CardId { - pub fn derive(card_type: TodayCardType, source: &CardSourceIdentity) -> Self { - let mut digest = Sha256::new(); - digest.update(CARD_ID_DOMAIN); - digest.update(card_type.label().as_bytes()); - digest.update(b"\0"); - digest.update(source.canonical_string().as_bytes()); - Self(digest.finalize().into()) - } - - pub fn parse(value: &str) -> Result<Self, CardIdError> { - if value.len() != 64 - || !value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(CardIdError::InvalidCardId); - } - let mut bytes = [0; 32]; - hex::decode_to_slice(value, &mut bytes).map_err(|_| CardIdError::InvalidCardId)?; - Ok(Self(bytes)) - } - - pub const fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } - - pub fn to_hex(self) -> String { - hex::encode(self.0) - } -} - -impl fmt::Display for CardId { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&hex::encode(self.0)) - } -} - -impl Serialize for CardId { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - serializer.serialize_str(&self.to_hex()) - } -} - -impl<'de> Deserialize<'de> for CardId { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::parse(&value).map_err(serde::de::Error::custom) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stable_card_id_vectors_cover_regular_and_addressable_sources() { - let event = EventId::parse("a".repeat(64)).expect("event"); - assert_eq!( - CardId::derive(TodayCardType::Update, &CardSourceIdentity::Event(event)).to_hex(), - "36bf89dc7a6759143986b1f339870ec792f7bee865c9738b0adb50ac9c5197be" - ); - let address = CardSourceIdentity::address(31_923, "b".repeat(64), "farmers-market-2026") - .expect("address"); - assert_eq!( - CardId::derive(TodayCardType::Event, &address).to_hex(), - "75f6127161783c583368b6c76ed779ae5e02cd7bc9f71ef55ff39e32a59274fc" - ); - let replacement = CardId::derive(TodayCardType::Event, &address); - assert_eq!(replacement, CardId::derive(TodayCardType::Event, &address)); - } - - #[test] - fn card_identity_rejects_noncanonical_addresses_and_ids() { - assert!(CardSourceIdentity::address(1, "a".repeat(64), "id").is_err()); - assert!(CardSourceIdentity::address(40_000, "a".repeat(64), "id").is_err()); - assert!(CardSourceIdentity::address(30_402, "a".repeat(63), "id").is_err()); - assert!(CardSourceIdentity::address(30_402, "A".repeat(64), "id").is_err()); - assert!(CardSourceIdentity::address(30_402, "a".repeat(64), "").is_err()); - assert!(CardSourceIdentity::address(30_402, "a".repeat(64), "i".repeat(513)).is_err()); - assert!(CardSourceIdentity::address(30_402, "a".repeat(64), "bad\nid").is_err()); - let opaque = CardSourceIdentity::address(31_923, "a".repeat(64), " market day ") - .expect("opaque d value"); - assert!(opaque.canonical_string().ends_with(": market day ")); - assert!(CardId::parse(&"a".repeat(63)).is_err()); - assert!(CardId::parse(&"A".repeat(64)).is_err()); - - let card = CardId::parse(&"c".repeat(64)).expect("card"); - assert_eq!(card.to_string(), "c".repeat(64)); - let encoded = serde_json::to_string(&card).expect("serialize"); - assert_eq!( - serde_json::from_str::<CardId>(&encoded).expect("deserialize"), - card - ); - assert!(serde_json::from_str::<CardId>(&format!("\"{}\"", "G".repeat(64))).is_err()); - } -} diff --git a/crates/mobile_core/src/runtime/product_surface/media.rs b/crates/mobile_core/src/runtime/product_surface/media.rs @@ -1,2172 +0,0 @@ -//! Typed inbound-media trust, receipt, and bounded cache metadata. -//! -//! Structural Nostr references are intentionally distinct from locally -//! verified artifacts. A caller cannot represent renderable media with a URL -//! and a boolean: the `Verified` state always contains a receipt derived from -//! an actual byte commitment and bound to the active retrieval configuration. - -use std::collections::BTreeMap; -#[cfg(feature = "mobile-social")] -use std::path::{Path, PathBuf}; - -use radroots_blossom::{BlobUrl, MediaType, Sha256, descriptor::ByteCommitment}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256 as Sha256Hasher}; -use thiserror::Error; -#[cfg(feature = "mobile-social")] -use tokio::io::AsyncWriteExt; - -const MEDIA_REFERENCE_SCHEMA_VERSION: u16 = 1; -const MEDIA_RECEIPT_SCHEMA_VERSION: u16 = 1; -const MEDIA_CACHE_SCHEMA_VERSION: u16 = 1; -const MEDIA_URL_MAX_BYTES: usize = 8_192; -const MEDIA_ALT_MAX_BYTES: usize = 2_048; -const MEDIA_FAILURE_CODE_MAX_BYTES: usize = 96; -const MEDIA_DIMENSION_MAX_EDGE: u32 = 16_384; -const MEDIA_DIMENSION_MAX_PIXELS: u64 = 100_000_000; -const MEDIA_REFERENCE_FINGERPRINT_DOMAIN: &[u8] = b"radroots.inbound-media-reference.v1\0"; -#[cfg(feature = "mobile-social")] -const MEDIA_CACHE_EXTENSIONS: &[&str] = &["gif", "jpg", "png", "webp"]; - -#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] -#[serde(transparent)] -pub struct Phase1MediaConfigurationFingerprint([u8; 32]); - -impl Phase1MediaConfigurationFingerprint { - pub fn new(value: [u8; 32]) -> Result<Self, Phase1InboundMediaError> { - (value != [0; 32]) - .then_some(Self(value)) - .ok_or(Phase1InboundMediaError::InvalidConfiguration) - } - - pub fn parse(value: &str) -> Result<Self, Phase1InboundMediaError> { - let decoded = - hex::decode(value).map_err(|_| Phase1InboundMediaError::InvalidConfiguration)?; - let bytes: [u8; 32] = decoded - .try_into() - .map_err(|_| Phase1InboundMediaError::InvalidConfiguration)?; - Self::new(bytes) - } - - pub const fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } - - pub fn to_hex(self) -> String { - hex::encode(self.0) - } - - fn validate(self) -> Result<(), Phase1InboundMediaError> { - Self::new(self.0).map(|_| ()) - } -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] -#[serde(transparent)] -pub struct Phase1MediaArtifactId([u8; 32]); - -impl Phase1MediaArtifactId { - pub fn parse(value: &str) -> Result<Self, Phase1InboundMediaError> { - let hash = Sha256::from_hex(value).map_err(|_| Phase1InboundMediaError::InvalidDigest)?; - Ok(Self(*hash.as_bytes())) - } - - pub const fn from_sha256(value: Sha256) -> Self { - Self(*value.as_bytes()) - } - - pub const fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } - - pub fn to_hex(self) -> String { - hex::encode(self.0) - } -} - -/// Signed-event media facts. These facts do not imply that any bytes were -/// fetched, trusted, stored, or rendered. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct Phase1StructuralMediaReference { - schema_version: u16, - source_url: String, - expected_sha256: Option<String>, - expected_media_type: Option<String>, - expected_width: Option<u32>, - expected_height: Option<u32>, - expected_byte_size: Option<u64>, - alt: Option<String>, - fingerprint: [u8; 32], -} - -impl Phase1StructuralMediaReference { - #[allow(clippy::too_many_arguments)] - pub fn new( - source_url: impl Into<String>, - expected_sha256: Option<String>, - expected_media_type: Option<String>, - expected_width: Option<u32>, - expected_height: Option<u32>, - expected_byte_size: Option<u64>, - alt: Option<String>, - ) -> Result<Self, Phase1InboundMediaError> { - let source_url = source_url.into(); - validate_url_text(&source_url)?; - let parsed = - url::Url::parse(&source_url).map_err(|_| Phase1InboundMediaError::InvalidReference)?; - if !matches!(parsed.scheme(), "http" | "https") - || parsed.host_str().is_none() - || !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.as_str() != source_url - { - return Err(Phase1InboundMediaError::InvalidReference); - } - let path_digest = BlobUrl::parse(&source_url) - .ok() - .map(|value| value.hash_path().hash().to_hex()); - let expected_sha256 = match expected_sha256 { - Some(value) => { - let digest = - Sha256::from_hex(&value).map_err(|_| Phase1InboundMediaError::InvalidDigest)?; - if digest.to_hex() != value - || path_digest - .as_deref() - .is_some_and(|path| digest.to_hex() != path) - { - return Err(Phase1InboundMediaError::MetadataMismatch); - } - Some(value) - } - None => path_digest, - }; - let expected_media_type = expected_media_type - .map(|value| { - MediaType::parse(&value) - .map(|parsed| parsed.to_string()) - .map_err(|_| Phase1InboundMediaError::InvalidMediaType) - }) - .transpose()?; - validate_dimensions(expected_width, expected_height)?; - if expected_byte_size == Some(0) { - return Err(Phase1InboundMediaError::InvalidByteSize); - } - if alt.as_deref().is_some_and(|value| { - value.len() > MEDIA_ALT_MAX_BYTES || value.chars().any(char::is_control) - }) { - return Err(Phase1InboundMediaError::InvalidAlt); - } - let mut value = Self { - schema_version: MEDIA_REFERENCE_SCHEMA_VERSION, - source_url: parsed.to_string(), - expected_sha256, - expected_media_type, - expected_width, - expected_height, - expected_byte_size, - alt, - fingerprint: [0; 32], - }; - value.fingerprint = value.derive_fingerprint(); - Ok(value) - } - - fn validate(&self) -> Result<(), Phase1InboundMediaError> { - if self.schema_version != MEDIA_REFERENCE_SCHEMA_VERSION { - return Err(Phase1InboundMediaError::UnsupportedSchema); - } - let canonical = Self::new( - self.source_url.clone(), - self.expected_sha256.clone(), - self.expected_media_type.clone(), - self.expected_width, - self.expected_height, - self.expected_byte_size, - self.alt.clone(), - )?; - (canonical == *self) - .then_some(()) - .ok_or(Phase1InboundMediaError::CorruptState) - } - - fn derive_fingerprint(&self) -> [u8; 32] { - let mut digest = Sha256Hasher::new(); - digest.update(MEDIA_REFERENCE_FINGERPRINT_DOMAIN); - digest.update(self.source_url.as_bytes()); - update_optional(&mut digest, self.expected_sha256.as_deref()); - update_optional(&mut digest, self.expected_media_type.as_deref()); - update_optional_u64(&mut digest, self.expected_width.map(u64::from)); - update_optional_u64(&mut digest, self.expected_height.map(u64::from)); - update_optional_u64(&mut digest, self.expected_byte_size); - update_optional(&mut digest, self.alt.as_deref()); - digest.finalize().into() - } - - pub fn source_url(&self) -> &str { - self.source_url.as_str() - } - - pub fn expected_sha256(&self) -> Option<&str> { - self.expected_sha256.as_deref() - } - - pub fn expected_media_type(&self) -> Option<&str> { - self.expected_media_type.as_deref() - } - - pub const fn expected_width(&self) -> Option<u32> { - self.expected_width - } - - pub const fn expected_height(&self) -> Option<u32> { - self.expected_height - } - - pub const fn expected_byte_size(&self) -> Option<u64> { - self.expected_byte_size - } - - pub fn alt(&self) -> Option<&str> { - self.alt.as_deref() - } - - pub const fn fingerprint(&self) -> &[u8; 32] { - &self.fingerprint - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct Phase1InboundMediaPending { - operation_id: [u8; 16], - configuration: Phase1MediaConfigurationFingerprint, - started_at_unix_ms: u64, -} - -impl Phase1InboundMediaPending { - pub fn new( - operation_id: [u8; 16], - configuration: Phase1MediaConfigurationFingerprint, - started_at_unix_ms: u64, - ) -> Result<Self, Phase1InboundMediaError> { - configuration.validate()?; - if operation_id == [0; 16] || started_at_unix_ms == 0 { - return Err(Phase1InboundMediaError::InvalidOperation); - } - Ok(Self { - operation_id, - configuration, - started_at_unix_ms, - }) - } - - pub const fn operation_id(&self) -> &[u8; 16] { - &self.operation_id - } - - pub const fn configuration(&self) -> Phase1MediaConfigurationFingerprint { - self.configuration - } - - pub const fn started_at_unix_ms(&self) -> u64 { - self.started_at_unix_ms - } - - fn validate(&self) -> Result<(), Phase1InboundMediaError> { - Self::new( - self.operation_id, - self.configuration, - self.started_at_unix_ms, - ) - .map(|_| ()) - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct Phase1InboundMediaFailure { - operation_id: [u8; 16], - safe_code: String, - retryable: bool, - failed_at_unix_ms: u64, -} - -impl Phase1InboundMediaFailure { - pub fn new( - operation_id: [u8; 16], - safe_code: impl Into<String>, - retryable: bool, - failed_at_unix_ms: u64, - ) -> Result<Self, Phase1InboundMediaError> { - let safe_code = safe_code.into(); - if operation_id == [0; 16] - || failed_at_unix_ms == 0 - || safe_code.is_empty() - || safe_code.len() > MEDIA_FAILURE_CODE_MAX_BYTES - || !safe_code - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') - { - return Err(Phase1InboundMediaError::InvalidFailure); - } - Ok(Self { - operation_id, - safe_code, - retryable, - failed_at_unix_ms, - }) - } - - pub fn safe_code(&self) -> &str { - self.safe_code.as_str() - } - - pub const fn retryable(&self) -> bool { - self.retryable - } - - fn validate(&self) -> Result<(), Phase1InboundMediaError> { - Self::new( - self.operation_id, - self.safe_code.clone(), - self.retryable, - self.failed_at_unix_ms, - ) - .map(|_| ()) - } -} - -/// Exact-byte verification evidence. Construction requires a byte commitment, -/// binds every signed expected field, and derives the artifact identity from -/// the observed digest rather than caller input. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct Phase1VerifiedMediaReceipt { - schema_version: u16, - reference_fingerprint: [u8; 32], - source_url: String, - canonical_final_url: String, - expected_sha256: String, - observed_sha256: String, - byte_size: u64, - media_type: String, - extension: String, - width: u32, - height: u32, - artifact_id: Phase1MediaArtifactId, - configuration: Phase1MediaConfigurationFingerprint, - verified_at_unix_ms: u64, -} - -impl Phase1VerifiedMediaReceipt { - pub fn from_commitment( - reference: &Phase1StructuralMediaReference, - canonical_final_url: BlobUrl, - commitment: &ByteCommitment, - width: u32, - height: u32, - configuration: Phase1MediaConfigurationFingerprint, - verified_at_unix_ms: u64, - ) -> Result<Self, Phase1InboundMediaError> { - reference.validate()?; - configuration.validate()?; - if verified_at_unix_ms == 0 { - return Err(Phase1InboundMediaError::InvalidVerificationTime); - } - BlobUrl::parse(reference.source_url()) - .and_then(BlobUrl::approve) - .map_err(|_| Phase1InboundMediaError::InvalidReference)?; - canonical_final_url - .clone() - .approve() - .map_err(|_| Phase1InboundMediaError::InvalidReference)?; - validate_dimensions(Some(width), Some(height))?; - let observed_sha256 = commitment.sha256().to_hex(); - let expected_sha256 = reference - .expected_sha256() - .ok_or(Phase1InboundMediaError::MissingDigest)?; - if observed_sha256 != expected_sha256 - || reference - .expected_byte_size() - .is_some_and(|value| value != commitment.size()) - || reference - .expected_media_type() - .is_some_and(|value| value != commitment.media_type().to_string()) - || reference - .expected_width() - .is_some_and(|value| value != width) - || reference - .expected_height() - .is_some_and(|value| value != height) - { - return Err(Phase1InboundMediaError::MetadataMismatch); - } - let extension = canonical_final_url - .hash_path() - .extension() - .ok_or(Phase1InboundMediaError::InvalidReference)? - .as_str() - .to_owned(); - if canonical_extension(commitment.media_type()) != Some(extension.as_str()) { - return Err(Phase1InboundMediaError::MetadataMismatch); - } - if canonical_final_url.hash_path().hash() != commitment.sha256() { - return Err(Phase1InboundMediaError::MetadataMismatch); - } - let receipt = Self { - schema_version: MEDIA_RECEIPT_SCHEMA_VERSION, - reference_fingerprint: *reference.fingerprint(), - source_url: reference.source_url().to_owned(), - canonical_final_url: canonical_final_url.to_string(), - expected_sha256: expected_sha256.to_owned(), - observed_sha256, - byte_size: commitment.size(), - media_type: commitment.media_type().to_string(), - extension, - width, - height, - artifact_id: Phase1MediaArtifactId::from_sha256(commitment.sha256()), - configuration, - verified_at_unix_ms, - }; - receipt.validate(reference)?; - Ok(receipt) - } - - fn validate( - &self, - reference: &Phase1StructuralMediaReference, - ) -> Result<(), Phase1InboundMediaError> { - self.validate_intrinsic()?; - if self.reference_fingerprint != *reference.fingerprint() - || self.source_url != reference.source_url() - || reference.expected_sha256() != Some(self.expected_sha256.as_str()) - { - return Err(Phase1InboundMediaError::CorruptReceipt); - } - BlobUrl::parse(reference.source_url()) - .and_then(BlobUrl::approve) - .map_err(|_| Phase1InboundMediaError::CorruptReceipt)?; - if reference - .expected_byte_size() - .is_some_and(|value| value != self.byte_size) - || reference - .expected_media_type() - .is_some_and(|value| value != self.media_type) - || reference - .expected_width() - .is_some_and(|value| value != self.width) - || reference - .expected_height() - .is_some_and(|value| value != self.height) - { - return Err(Phase1InboundMediaError::CorruptReceipt); - } - Ok(()) - } - - fn validate_intrinsic(&self) -> Result<(), Phase1InboundMediaError> { - if self.schema_version != MEDIA_RECEIPT_SCHEMA_VERSION - || self.expected_sha256 != self.observed_sha256 - || self.expected_sha256 != self.artifact_id.to_hex() - || self.byte_size == 0 - || self.verified_at_unix_ms == 0 - { - return Err(Phase1InboundMediaError::CorruptReceipt); - } - self.configuration - .validate() - .map_err(|_| Phase1InboundMediaError::CorruptReceipt)?; - let final_url = BlobUrl::parse(&self.canonical_final_url) - .map_err(|_| Phase1InboundMediaError::CorruptReceipt)?; - final_url - .clone() - .approve() - .map_err(|_| Phase1InboundMediaError::CorruptReceipt)?; - if final_url.to_string() != self.canonical_final_url - || final_url.hash_path().hash().to_hex() != self.observed_sha256 - || final_url - .hash_path() - .extension() - .is_none_or(|value| value.as_str() != self.extension) - || !canonical_media_type(&self.media_type) - || MediaType::parse(&self.media_type) - .ok() - .and_then(|value| canonical_extension(&value)) - != Some(self.extension.as_str()) - || validate_dimensions(Some(self.width), Some(self.height)).is_err() - { - return Err(Phase1InboundMediaError::CorruptReceipt); - } - Ok(()) - } - - pub const fn artifact_id(&self) -> Phase1MediaArtifactId { - self.artifact_id - } - - pub const fn configuration(&self) -> Phase1MediaConfigurationFingerprint { - self.configuration - } - - pub fn canonical_final_url(&self) -> &str { - self.canonical_final_url.as_str() - } - - pub fn observed_sha256(&self) -> &str { - self.observed_sha256.as_str() - } - - pub const fn byte_size(&self) -> u64 { - self.byte_size - } - - pub fn media_type(&self) -> &str { - self.media_type.as_str() - } - - pub fn extension(&self) -> &str { - self.extension.as_str() - } - - pub const fn width(&self) -> u32 { - self.width - } - - pub const fn height(&self) -> u32 { - self.height - } - - pub const fn verified_at_unix_ms(&self) -> u64 { - self.verified_at_unix_ms - } -} - -/// One immutable exact-byte artifact in the authenticated user's local cache. -#[cfg(feature = "mobile-social")] -#[derive(Clone, Eq, PartialEq)] -pub struct Phase1LocalMediaArtifact { - artifact_id: Phase1MediaArtifactId, - local_path: PathBuf, - bytes: Vec<u8>, - byte_size: u64, - media_type: String, - width: u32, - height: u32, -} - -#[cfg(feature = "mobile-social")] -impl std::fmt::Debug for Phase1LocalMediaArtifact { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("Phase1LocalMediaArtifact") - .field("artifact_id", &self.artifact_id) - .field("local_path", &"<redacted>") - .field("byte_size", &self.byte_size) - .field("media_type", &self.media_type) - .field("width", &self.width) - .field("height", &self.height) - .finish() - } -} - -#[cfg(feature = "mobile-social")] -impl Phase1LocalMediaArtifact { - pub const fn artifact_id(&self) -> Phase1MediaArtifactId { - self.artifact_id - } - - pub fn local_path(&self) -> &Path { - self.local_path.as_path() - } - - pub fn bytes(&self) -> &[u8] { - self.bytes.as_slice() - } - - pub const fn byte_size(&self) -> u64 { - self.byte_size - } - - pub fn media_type(&self) -> &str { - self.media_type.as_str() - } - - pub const fn width(&self) -> u32 { - self.width - } - - pub const fn height(&self) -> u32 { - self.height - } -} - -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", tag = "state", content = "evidence")] -pub enum Phase1InboundMediaState { - #[default] - Unavailable, - Pending(Phase1InboundMediaPending), - Failed(Phase1InboundMediaFailure), - Verified(Box<Phase1VerifiedMediaReceipt>), -} - -/// Public media model: signed structure plus local retrieval evidence. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct MediaReference { - structural: Phase1StructuralMediaReference, - retrieval: Phase1InboundMediaState, -} - -impl MediaReference { - pub fn new( - structural: Phase1StructuralMediaReference, - ) -> Result<Self, Phase1InboundMediaError> { - structural.validate()?; - Ok(Self { - structural, - retrieval: Phase1InboundMediaState::Unavailable, - }) - } - - pub(crate) fn legacy_unavailable( - source_url: String, - expected_sha256: Option<String>, - expected_media_type: Option<String>, - expected_width: Option<u32>, - expected_height: Option<u32>, - expected_byte_size: Option<u64>, - alt: Option<String>, - ) -> Result<Self, Phase1InboundMediaError> { - Self::new(Phase1StructuralMediaReference::new( - source_url, - expected_sha256, - expected_media_type, - expected_width, - expected_height, - expected_byte_size, - alt, - )?) - } - - pub fn structural(&self) -> &Phase1StructuralMediaReference { - &self.structural - } - - pub const fn retrieval(&self) -> &Phase1InboundMediaState { - &self.retrieval - } - - pub(crate) fn validate(&self) -> Result<(), Phase1InboundMediaError> { - self.structural.validate()?; - match &self.retrieval { - Phase1InboundMediaState::Unavailable => Ok(()), - Phase1InboundMediaState::Pending(value) => value.validate(), - Phase1InboundMediaState::Failed(value) => value.validate(), - Phase1InboundMediaState::Verified(value) => value.validate(&self.structural), - } - } - - pub(crate) fn restore( - &mut self, - retrieval: Phase1InboundMediaState, - cache: &Phase1MediaCacheIndex, - ) -> Result<(), Phase1InboundMediaError> { - let mut candidate = self.clone(); - candidate.retrieval = retrieval; - candidate.validate()?; - if let Phase1InboundMediaState::Verified(receipt) = &candidate.retrieval - && !cache.contains(receipt) - { - candidate.retrieval = Phase1InboundMediaState::Unavailable; - } - *self = candidate; - Ok(()) - } - - pub fn begin( - &mut self, - pending: Phase1InboundMediaPending, - ) -> Result<(), Phase1InboundMediaError> { - self.structural.validate()?; - pending.validate()?; - self.retrieval = Phase1InboundMediaState::Pending(pending); - Ok(()) - } - - pub fn fail( - &mut self, - failure: Phase1InboundMediaFailure, - ) -> Result<(), Phase1InboundMediaError> { - failure.validate()?; - match &self.retrieval { - Phase1InboundMediaState::Pending(pending) - if pending.operation_id == failure.operation_id => - { - self.retrieval = Phase1InboundMediaState::Failed(failure); - Ok(()) - } - _ => Err(Phase1InboundMediaError::OperationMismatch), - } - } - - pub fn verify( - &mut self, - operation_id: [u8; 16], - receipt: Phase1VerifiedMediaReceipt, - ) -> Result<(), Phase1InboundMediaError> { - let Phase1InboundMediaState::Pending(pending) = &self.retrieval else { - return Err(Phase1InboundMediaError::OperationMismatch); - }; - if pending.operation_id != operation_id || pending.configuration != receipt.configuration { - return Err(Phase1InboundMediaError::OperationMismatch); - } - receipt.validate(&self.structural)?; - self.retrieval = Phase1InboundMediaState::Verified(Box::new(receipt)); - Ok(()) - } - - pub fn invalidate(&mut self) -> Option<Phase1MediaArtifactId> { - let artifact = match &self.retrieval { - Phase1InboundMediaState::Verified(receipt) => Some(receipt.artifact_id), - _ => None, - }; - self.retrieval = Phase1InboundMediaState::Unavailable; - artifact - } - - pub fn is_renderable_with( - &self, - cache: &Phase1MediaCacheIndex, - configuration: Phase1MediaConfigurationFingerprint, - ) -> bool { - match &self.retrieval { - Phase1InboundMediaState::Verified(receipt) - if receipt.configuration == configuration => - { - cache.contains(receipt) - } - _ => false, - } - } -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct Phase1MediaCachePolicy { - max_bytes: u64, - max_artifacts: u32, -} - -impl Phase1MediaCachePolicy { - pub fn new(max_bytes: u64, max_artifacts: u32) -> Result<Self, Phase1InboundMediaError> { - if max_bytes == 0 || max_artifacts == 0 { - return Err(Phase1InboundMediaError::InvalidCachePolicy); - } - Ok(Self { - max_bytes, - max_artifacts, - }) - } - - pub const fn max_bytes(&self) -> u64 { - self.max_bytes - } - - pub const fn max_artifacts(&self) -> u32 { - self.max_artifacts - } -} - -impl Default for Phase1MediaCachePolicy { - fn default() -> Self { - Self { - max_bytes: 256 * 1024 * 1024, - max_artifacts: 2_000, - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct Phase1MediaCacheEntry { - artifact_id: Phase1MediaArtifactId, - byte_size: u64, - media_type: String, - extension: String, - width: u32, - height: u32, - cached_at_unix_ms: u64, - last_accessed_at_unix_ms: u64, -} - -impl Phase1MediaCacheEntry { - fn from_receipt( - receipt: &Phase1VerifiedMediaReceipt, - cached_at_unix_ms: u64, - ) -> Result<Self, Phase1InboundMediaError> { - if cached_at_unix_ms < receipt.verified_at_unix_ms { - return Err(Phase1InboundMediaError::InvalidCacheObservation); - } - Ok(Self { - artifact_id: receipt.artifact_id, - byte_size: receipt.byte_size, - media_type: receipt.media_type.clone(), - extension: receipt.extension.clone(), - width: receipt.width, - height: receipt.height, - cached_at_unix_ms, - last_accessed_at_unix_ms: cached_at_unix_ms, - }) - } - - fn matches(&self, receipt: &Phase1VerifiedMediaReceipt) -> bool { - self.artifact_id == receipt.artifact_id - && self.byte_size == receipt.byte_size - && self.media_type == receipt.media_type - && self.extension == receipt.extension - && self.width == receipt.width - && self.height == receipt.height - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct Phase1MediaCacheIndex { - schema_version: u16, - configuration: Option<Phase1MediaConfigurationFingerprint>, - entries: BTreeMap<String, Phase1MediaCacheEntry>, -} - -impl Default for Phase1MediaCacheIndex { - fn default() -> Self { - Self { - schema_version: MEDIA_CACHE_SCHEMA_VERSION, - configuration: None, - entries: BTreeMap::new(), - } - } -} - -impl Phase1MediaCacheIndex { - pub fn admit( - &mut self, - receipt: &Phase1VerifiedMediaReceipt, - policy: Phase1MediaCachePolicy, - cached_at_unix_ms: u64, - ) -> Result<Vec<Phase1MediaArtifactId>, Phase1InboundMediaError> { - self.validate()?; - receipt.validate_intrinsic()?; - if receipt.byte_size > policy.max_bytes { - return Err(Phase1InboundMediaError::CacheQuotaExceeded); - } - if self - .configuration - .is_some_and(|value| value != receipt.configuration) - { - return Err(Phase1InboundMediaError::ConfigurationMismatch); - } - self.configuration = Some(receipt.configuration); - let key = receipt.artifact_id.to_hex(); - let entry = Phase1MediaCacheEntry::from_receipt(receipt, cached_at_unix_ms)?; - if self - .entries - .get(&key) - .is_some_and(|existing| !existing.matches(receipt)) - { - return Err(Phase1InboundMediaError::ArtifactCollision); - } - self.entries.insert(key.clone(), entry); - let mut evicted = Vec::new(); - while self.entries.len() > policy.max_artifacts as usize - || self.total_bytes()? > policy.max_bytes - { - let oldest = self - .entries - .iter() - .filter(|(candidate, _)| candidate.as_str() != key) - .min_by_key(|(key, entry)| (entry.last_accessed_at_unix_ms, key.as_str())) - .map(|(key, _)| key.clone()) - .ok_or(Phase1InboundMediaError::CorruptState)?; - let removed = self - .entries - .remove(&oldest) - .ok_or(Phase1InboundMediaError::CorruptState)?; - evicted.push(removed.artifact_id); - } - Ok(evicted) - } - - pub fn contains(&self, receipt: &Phase1VerifiedMediaReceipt) -> bool { - self.schema_version == MEDIA_CACHE_SCHEMA_VERSION - && self.configuration == Some(receipt.configuration) - && self - .entries - .get(&receipt.artifact_id.to_hex()) - .is_some_and(|entry| entry.matches(receipt)) - } - - pub fn touch( - &mut self, - artifact_id: Phase1MediaArtifactId, - observed_at_unix_ms: u64, - ) -> Result<bool, Phase1InboundMediaError> { - if observed_at_unix_ms == 0 { - return Err(Phase1InboundMediaError::InvalidCacheObservation); - } - let Some(entry) = self.entries.get_mut(&artifact_id.to_hex()) else { - return Ok(false); - }; - entry.last_accessed_at_unix_ms = entry.last_accessed_at_unix_ms.max(observed_at_unix_ms); - Ok(true) - } - - pub fn invalidate_artifact(&mut self, artifact_id: Phase1MediaArtifactId) -> bool { - self.entries.remove(&artifact_id.to_hex()).is_some() - } - - pub fn invalidate_configuration( - &mut self, - configuration: Phase1MediaConfigurationFingerprint, - ) -> Vec<Phase1MediaArtifactId> { - if self - .configuration - .is_none_or(|current| current == configuration) - { - self.configuration = Some(configuration); - return Vec::new(); - } - let removed = self - .entries - .values() - .map(|entry| entry.artifact_id) - .collect(); - self.entries.clear(); - self.configuration = Some(configuration); - removed - } - - pub fn artifact_count(&self) -> u32 { - self.entries.len().try_into().unwrap_or(u32::MAX) - } - - pub fn total_bytes(&self) -> Result<u64, Phase1InboundMediaError> { - self.entries.values().try_fold(0_u64, |total, entry| { - total - .checked_add(entry.byte_size) - .ok_or(Phase1InboundMediaError::CorruptState) - }) - } - - fn validate(&self) -> Result<(), Phase1InboundMediaError> { - if self.schema_version != MEDIA_CACHE_SCHEMA_VERSION - || (self.configuration.is_none() && !self.entries.is_empty()) - || self.entries.iter().any(|(key, entry)| { - key != &entry.artifact_id.to_hex() - || key != &hex::encode(entry.artifact_id.as_bytes()) - || entry.byte_size == 0 - || entry.cached_at_unix_ms == 0 - || entry.last_accessed_at_unix_ms < entry.cached_at_unix_ms - || !canonical_media_type(&entry.media_type) - || entry.extension.is_empty() - || validate_dimensions(Some(entry.width), Some(entry.height)).is_err() - }) - { - return Err(Phase1InboundMediaError::CorruptState); - } - if self - .configuration - .is_some_and(|value| value.validate().is_err()) - { - return Err(Phase1InboundMediaError::CorruptState); - } - self.total_bytes().map(|_| ()) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct Phase1MediaCacheStatus { - pub artifacts: u32, - pub bytes: u64, - pub configuration: Option<Phase1MediaConfigurationFingerprint>, -} - -impl Phase1MediaCacheIndex { - pub fn status(&self) -> Result<Phase1MediaCacheStatus, Phase1InboundMediaError> { - self.validate()?; - Ok(Phase1MediaCacheStatus { - artifacts: self.artifact_count(), - bytes: self.total_bytes()?, - configuration: self.configuration, - }) - } -} - -#[derive(Clone, Debug, Error, Eq, PartialEq)] -pub enum Phase1InboundMediaError { - #[error("inbound media reference is invalid")] - InvalidReference, - #[error("inbound media digest is invalid")] - InvalidDigest, - #[error("inbound media reference requires a digest")] - MissingDigest, - #[error("inbound media type is invalid")] - InvalidMediaType, - #[error("inbound media dimensions are invalid")] - InvalidDimensions, - #[error("inbound media byte size is invalid")] - InvalidByteSize, - #[error("inbound media alternative text is invalid")] - InvalidAlt, - #[error("inbound media metadata does not match verified bytes")] - MetadataMismatch, - #[error("inbound media operation is invalid")] - InvalidOperation, - #[error("inbound media operation identity does not match")] - OperationMismatch, - #[error("inbound media failure evidence is invalid")] - InvalidFailure, - #[error("inbound media configuration is invalid")] - InvalidConfiguration, - #[error("inbound media configuration changed")] - ConfigurationMismatch, - #[error("inbound media verification time is invalid")] - InvalidVerificationTime, - #[error("inbound media cache policy is invalid")] - InvalidCachePolicy, - #[error("inbound media cache observation is invalid")] - InvalidCacheObservation, - #[error("inbound media artifact exceeds cache quota")] - CacheQuotaExceeded, - #[error("inbound media artifact identity collides with different metadata")] - ArtifactCollision, - #[error("inbound media receipt is corrupt")] - CorruptReceipt, - #[error("inbound media state is corrupt")] - CorruptState, - #[error("inbound media schema version is unsupported")] - UnsupportedSchema, - #[error("inbound media cache directory is unavailable")] - CacheUnavailable, - #[error("inbound media cache filesystem operation failed")] - CacheIo, - #[error("inbound media cache artifact is corrupt")] - CorruptArtifact, -} - -#[cfg(feature = "mobile-social")] -pub(crate) async fn write_verified_artifact( - directory: &Path, - receipt: &Phase1VerifiedMediaReceipt, - bytes: &[u8], -) -> Result<Phase1LocalMediaArtifact, Phase1InboundMediaError> { - receipt.validate_intrinsic()?; - if bytes.len() as u64 != receipt.byte_size - || Sha256::digest(bytes).to_hex() != receipt.observed_sha256 - { - return Err(Phase1InboundMediaError::CorruptArtifact); - } - ensure_cache_directory(directory).await?; - let final_path = artifact_path(directory, receipt.artifact_id, receipt.extension.as_str())?; - match tokio::fs::symlink_metadata(&final_path).await { - Ok(_) => { - let verified_bytes = verify_artifact_file(&final_path, receipt).await?; - return Ok(local_artifact(final_path, receipt, verified_bytes)); - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return Err(Phase1InboundMediaError::CacheIo), - } - - let temporary_path = directory.join(format!( - ".{}.{}.tmp", - receipt.artifact_id.to_hex(), - uuid::Uuid::new_v4().simple() - )); - let mut temporary = tokio::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&temporary_path) - .await - .map_err(|_| Phase1InboundMediaError::CacheIo)?; - let write_result = async { - temporary - .write_all(bytes) - .await - .map_err(|_| Phase1InboundMediaError::CacheIo)?; - temporary - .flush() - .await - .map_err(|_| Phase1InboundMediaError::CacheIo)?; - temporary - .sync_all() - .await - .map_err(|_| Phase1InboundMediaError::CacheIo)?; - drop(temporary); - match tokio::fs::hard_link(&temporary_path, &final_path).await { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - verify_artifact_file(&final_path, receipt).await?; - } - Err(_) => return Err(Phase1InboundMediaError::CacheIo), - } - tokio::fs::remove_file(&temporary_path) - .await - .map_err(|_| Phase1InboundMediaError::CacheIo)?; - sync_cache_directory(directory).await?; - verify_artifact_file(&final_path, receipt).await - } - .await; - if write_result.is_err() { - let _ = tokio::fs::remove_file(&temporary_path).await; - } - let verified_bytes = write_result?; - Ok(local_artifact(final_path, receipt, verified_bytes)) -} - -#[cfg(feature = "mobile-social")] -pub(crate) async fn remove_artifact_files( - directory: &Path, - artifact_id: Phase1MediaArtifactId, -) -> Result<(), Phase1InboundMediaError> { - ensure_cache_directory(directory).await?; - for extension in MEDIA_CACHE_EXTENSIONS { - let path = artifact_path(directory, artifact_id, extension)?; - match tokio::fs::symlink_metadata(&path).await { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - return Err(Phase1InboundMediaError::CorruptArtifact); - } - Ok(_) => tokio::fs::remove_file(path) - .await - .map_err(|_| Phase1InboundMediaError::CacheIo)?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return Err(Phase1InboundMediaError::CacheIo), - } - } - sync_cache_directory(directory).await -} - -#[cfg(feature = "mobile-social")] -pub(crate) async fn verified_artifact( - directory: &Path, - receipt: &Phase1VerifiedMediaReceipt, -) -> Result<Phase1LocalMediaArtifact, Phase1InboundMediaError> { - receipt.validate_intrinsic()?; - ensure_cache_directory(directory).await?; - let path = artifact_path(directory, receipt.artifact_id, receipt.extension.as_str())?; - let verified_bytes = verify_artifact_file(&path, receipt).await?; - Ok(local_artifact(path, receipt, verified_bytes)) -} - -#[cfg(feature = "mobile-social")] -async fn ensure_cache_directory(directory: &Path) -> Result<(), Phase1InboundMediaError> { - match tokio::fs::create_dir(directory).await { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(_) => return Err(Phase1InboundMediaError::CacheIo), - } - let metadata = tokio::fs::symlink_metadata(directory) - .await - .map_err(|_| Phase1InboundMediaError::CacheIo)?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(Phase1InboundMediaError::CorruptArtifact); - } - Ok(()) -} - -#[cfg(feature = "mobile-social")] -fn artifact_path( - directory: &Path, - artifact_id: Phase1MediaArtifactId, - extension: &str, -) -> Result<PathBuf, Phase1InboundMediaError> { - if !MEDIA_CACHE_EXTENSIONS.contains(&extension) { - return Err(Phase1InboundMediaError::CorruptArtifact); - } - Ok(directory.join(format!("{}.{}", artifact_id.to_hex(), extension))) -} - -#[cfg(feature = "mobile-social")] -async fn verify_artifact_file( - path: &Path, - receipt: &Phase1VerifiedMediaReceipt, -) -> Result<Vec<u8>, Phase1InboundMediaError> { - let metadata = tokio::fs::symlink_metadata(path) - .await - .map_err(|_| Phase1InboundMediaError::CorruptArtifact)?; - if metadata.file_type().is_symlink() - || !metadata.is_file() - || metadata.len() != receipt.byte_size - { - return Err(Phase1InboundMediaError::CorruptArtifact); - } - let bytes = tokio::fs::read(path) - .await - .map_err(|_| Phase1InboundMediaError::CacheIo)?; - if Sha256::digest(bytes.as_slice()).to_hex() != receipt.observed_sha256 { - return Err(Phase1InboundMediaError::CorruptArtifact); - } - Ok(bytes) -} - -#[cfg(feature = "mobile-social")] -async fn sync_cache_directory(directory: &Path) -> Result<(), Phase1InboundMediaError> { - let directory = directory.to_path_buf(); - tokio::task::spawn_blocking(move || std::fs::File::open(directory)?.sync_all()) - .await - .map_err(|_| Phase1InboundMediaError::CacheIo)? - .map_err(|_| Phase1InboundMediaError::CacheIo) -} - -#[cfg(feature = "mobile-social")] -fn local_artifact( - local_path: PathBuf, - receipt: &Phase1VerifiedMediaReceipt, - bytes: Vec<u8>, -) -> Phase1LocalMediaArtifact { - Phase1LocalMediaArtifact { - artifact_id: receipt.artifact_id, - local_path, - bytes, - byte_size: receipt.byte_size, - media_type: receipt.media_type.clone(), - width: receipt.width, - height: receipt.height, - } -} - -fn validate_url_text(value: &str) -> Result<(), Phase1InboundMediaError> { - if value.is_empty() - || value.len() > MEDIA_URL_MAX_BYTES - || value.chars().any(|character| character.is_control()) - { - return Err(Phase1InboundMediaError::InvalidReference); - } - Ok(()) -} - -fn canonical_media_type(value: &str) -> bool { - MediaType::parse(value).is_ok_and(|parsed| parsed.to_string() == value) -} - -fn canonical_extension(media_type: &MediaType) -> Option<&'static str> { - match media_type.as_str() { - "image/gif" => Some("gif"), - "image/jpeg" => Some("jpg"), - "image/png" => Some("png"), - "image/webp" => Some("webp"), - _ => None, - } -} - -fn validate_dimensions( - width: Option<u32>, - height: Option<u32>, -) -> Result<(), Phase1InboundMediaError> { - match (width, height) { - (None, None) => Ok(()), - (Some(width), Some(height)) - if width != 0 - && height != 0 - && width <= MEDIA_DIMENSION_MAX_EDGE - && height <= MEDIA_DIMENSION_MAX_EDGE - && u64::from(width) * u64::from(height) <= MEDIA_DIMENSION_MAX_PIXELS => - { - Ok(()) - } - _ => Err(Phase1InboundMediaError::InvalidDimensions), - } -} - -fn update_optional(digest: &mut Sha256Hasher, value: Option<&str>) { - match value { - Some(value) => { - digest.update([1]); - digest.update((value.len() as u64).to_be_bytes()); - digest.update(value.as_bytes()); - } - None => digest.update([0]), - } -} - -fn update_optional_u64(digest: &mut Sha256Hasher, value: Option<u64>) { - match value { - Some(value) => { - digest.update([1]); - digest.update(value.to_be_bytes()); - } - None => digest.update([0]), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - type ReceiptMutation = Box<dyn Fn(&mut Phase1VerifiedMediaReceipt)>; - type CacheMutation = Box<dyn Fn(&mut Phase1MediaCacheIndex)>; - - const HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; - - fn reference(alt: Option<&str>) -> Phase1StructuralMediaReference { - Phase1StructuralMediaReference::new( - format!("https://media.example/{HASH}.jpg"), - Some(HASH.to_owned()), - Some("image/jpeg".to_owned()), - Some(2), - Some(3), - Some(5), - alt.map(str::to_owned), - ) - .expect("reference") - } - - fn configuration(value: u8) -> Phase1MediaConfigurationFingerprint { - Phase1MediaConfigurationFingerprint::new([value; 32]).expect("configuration") - } - - fn receipt( - reference: &Phase1StructuralMediaReference, - configuration: Phase1MediaConfigurationFingerprint, - ) -> Phase1VerifiedMediaReceipt { - let commitment = - ByteCommitment::from_bytes(b"hello", MediaType::parse("image/jpeg").unwrap()); - Phase1VerifiedMediaReceipt::from_commitment( - reference, - BlobUrl::parse(&format!("https://cdn.example/{HASH}.jpg")).unwrap(), - &commitment, - 2, - 3, - configuration, - 10, - ) - .expect("receipt") - } - - #[test] - fn structural_reference_is_canonical_and_metadata_sensitive() { - let first = reference(Some("Harvest")); - let second = reference(Some("Harvest detail")); - assert_ne!(first.fingerprint(), second.fingerprint()); - assert_eq!(first.expected_sha256(), Some(HASH)); - assert!( - Phase1StructuralMediaReference::new( - format!("https://media.example/{}.jpg", "a".repeat(64)), - Some(HASH.to_owned()), - Some("image/jpeg".to_owned()), - Some(2), - Some(3), - Some(5), - None, - ) - .is_err() - ); - let interoperable = Phase1StructuralMediaReference::new( - "https://cdn.example/harvest.jpg", - Some(HASH.to_owned()), - Some("image/jpeg".to_owned()), - Some(2), - Some(3), - Some(5), - None, - ) - .expect("non-Blossom NIP-92 reference remains structural"); - assert!( - Phase1VerifiedMediaReceipt::from_commitment( - &interoperable, - BlobUrl::parse(&format!("https://cdn.example/{HASH}.jpg")).unwrap(), - &ByteCommitment::from_bytes(b"hello", MediaType::parse("image/jpeg").unwrap()), - 2, - 3, - configuration(1), - 1, - ) - .is_err() - ); - } - - #[test] - fn structural_reference_rejects_each_noncanonical_field_independently() { - for source_url in [ - "", - "ftp://media.example/file.jpg", - "https:///file.jpg", - "https://user@media.example/file.jpg", - "https://user:password@media.example/file.jpg", - "https://media.example/file.jpg\n", - ] { - assert!( - Phase1StructuralMediaReference::new( - source_url, - None, - None, - None, - None, - None, - None, - ) - .is_err(), - "source URL should fail: {source_url:?}" - ); - } - assert!( - Phase1StructuralMediaReference::new( - format!("https://media.example/{}", "x".repeat(MEDIA_URL_MAX_BYTES)), - None, - None, - None, - None, - None, - None, - ) - .is_err() - ); - for digest in ["not-hex".to_owned(), HASH.to_ascii_uppercase()] { - assert!( - Phase1StructuralMediaReference::new( - "https://media.example/file.jpg", - Some(digest), - None, - None, - None, - None, - None, - ) - .is_err() - ); - } - assert!( - Phase1StructuralMediaReference::new( - "https://media.example/file.jpg", - Some(HASH.to_owned()), - Some("not a media type".to_owned()), - None, - None, - None, - None, - ) - .is_err() - ); - for (width, height) in [ - (Some(1), None), - (None, Some(1)), - (Some(0), Some(1)), - (Some(1), Some(0)), - (Some(MEDIA_DIMENSION_MAX_EDGE + 1), Some(1)), - (Some(1), Some(MEDIA_DIMENSION_MAX_EDGE + 1)), - (Some(10_001), Some(10_000)), - ] { - assert!( - Phase1StructuralMediaReference::new( - "https://media.example/file.jpg", - Some(HASH.to_owned()), - Some("image/jpeg".to_owned()), - width, - height, - Some(5), - None, - ) - .is_err() - ); - } - for alt in [ - "x".repeat(MEDIA_ALT_MAX_BYTES + 1), - "line\nbreak".to_owned(), - ] { - assert!( - Phase1StructuralMediaReference::new( - "https://media.example/file.jpg", - Some(HASH.to_owned()), - Some("image/jpeg".to_owned()), - None, - None, - Some(5), - Some(alt), - ) - .is_err() - ); - } - assert!( - Phase1StructuralMediaReference::new( - "https://media.example/file.jpg", - Some(HASH.to_owned()), - Some("image/jpeg".to_owned()), - None, - None, - Some(0), - None, - ) - .is_err() - ); - - let mut unsupported = reference(None); - unsupported.schema_version = 2; - assert_eq!( - unsupported.validate(), - Err(Phase1InboundMediaError::UnsupportedSchema) - ); - let mut corrupt = reference(None); - corrupt.fingerprint = [9; 32]; - assert_eq!( - corrupt.validate(), - Err(Phase1InboundMediaError::CorruptState) - ); - } - - #[test] - fn operation_and_failure_evidence_reject_each_invalid_field() { - assert!(Phase1MediaConfigurationFingerprint::new([0; 32]).is_err()); - assert!(Phase1MediaConfigurationFingerprint::parse("not-hex").is_err()); - assert!(Phase1MediaConfigurationFingerprint::parse("00").is_err()); - assert!(Phase1MediaArtifactId::parse("not-hex").is_err()); - - assert!(Phase1InboundMediaPending::new([0; 16], configuration(1), 1).is_err()); - assert!(Phase1InboundMediaPending::new([1; 16], configuration(1), 0).is_err()); - assert!( - Phase1InboundMediaPending::new( - [1; 16], - Phase1MediaConfigurationFingerprint([0; 32]), - 1, - ) - .is_err() - ); - - for (operation_id, code, failed_at) in [ - ([0; 16], "failed".to_owned(), 1), - ([1; 16], "failed".to_owned(), 0), - ([1; 16], String::new(), 1), - ([1; 16], "x".repeat(MEDIA_FAILURE_CODE_MAX_BYTES + 1), 1), - ([1; 16], "Not_Safe".to_owned(), 1), - ] { - assert!(Phase1InboundMediaFailure::new(operation_id, code, true, failed_at).is_err()); - } - let evidence = Phase1InboundMediaFailure::new([2; 16], "retry_2", true, 9).unwrap(); - assert_eq!(evidence.safe_code(), "retry_2"); - assert!(evidence.retryable()); - } - - #[test] - fn media_helper_vocabularies_cover_every_closed_outcome() { - assert!(validate_url_text("https://example.test/media").is_ok()); - assert_eq!( - validate_url_text(""), - Err(Phase1InboundMediaError::InvalidReference) - ); - assert_eq!( - validate_url_text(&"x".repeat(MEDIA_URL_MAX_BYTES + 1)), - Err(Phase1InboundMediaError::InvalidReference) - ); - assert_eq!( - validate_url_text("bad\nurl"), - Err(Phase1InboundMediaError::InvalidReference) - ); - assert!(canonical_media_type("image/jpeg")); - assert!(!canonical_media_type("IMAGE/JPEG")); - assert_eq!( - canonical_extension(&MediaType::parse("image/gif").unwrap()), - Some("gif") - ); - assert_eq!( - canonical_extension(&MediaType::parse("image/jpeg").unwrap()), - Some("jpg") - ); - assert_eq!( - canonical_extension(&MediaType::parse("image/png").unwrap()), - Some("png") - ); - assert_eq!( - canonical_extension(&MediaType::parse("image/webp").unwrap()), - Some("webp") - ); - assert_eq!( - canonical_extension(&MediaType::parse("image/svg+xml").unwrap()), - None - ); - assert!(validate_dimensions(None, None).is_ok()); - assert!(validate_dimensions(Some(10_000), Some(10_000)).is_ok()); - } - - #[test] - fn verified_state_requires_matching_operation_bytes_and_configuration() { - let structural = reference(None); - let mut media = MediaReference::new(structural.clone()).unwrap(); - let pending = Phase1InboundMediaPending::new([7; 16], configuration(3), 9).unwrap(); - media.begin(pending).unwrap(); - assert_eq!( - media.verify([8; 16], receipt(&structural, configuration(3))), - Err(Phase1InboundMediaError::OperationMismatch) - ); - media - .verify([7; 16], receipt(&structural, configuration(3))) - .unwrap(); - assert!(matches!( - media.retrieval(), - Phase1InboundMediaState::Verified(_) - )); - media - .restore( - Phase1InboundMediaState::Unavailable, - &Phase1MediaCacheIndex::default(), - ) - .unwrap(); - assert!(matches!( - media.retrieval(), - Phase1InboundMediaState::Unavailable - )); - } - - #[test] - fn media_state_transitions_and_renderability_cover_every_state() { - let structural = reference(None); - let config = configuration(3); - let verified = receipt(&structural, config); - let pending = Phase1InboundMediaPending::new([7; 16], config, 9).unwrap(); - let failure = Phase1InboundMediaFailure::new([7; 16], "network", true, 10).unwrap(); - let wrong_failure = Phase1InboundMediaFailure::new([8; 16], "network", true, 10).unwrap(); - - let mut media = MediaReference::new(structural.clone()).unwrap(); - assert_eq!(media.invalidate(), None); - assert!(!media.is_renderable_with(&Phase1MediaCacheIndex::default(), config)); - assert_eq!( - media.fail(failure.clone()), - Err(Phase1InboundMediaError::OperationMismatch) - ); - assert_eq!( - media.verify([7; 16], verified.clone()), - Err(Phase1InboundMediaError::OperationMismatch) - ); - - media.begin(pending.clone()).unwrap(); - assert_eq!( - media.fail(wrong_failure), - Err(Phase1InboundMediaError::OperationMismatch) - ); - assert_eq!( - media.verify([7; 16], receipt(&structural, configuration(4))), - Err(Phase1InboundMediaError::OperationMismatch) - ); - media.fail(failure).unwrap(); - assert!(matches!( - media.retrieval(), - Phase1InboundMediaState::Failed(_) - )); - assert!(media.validate().is_ok()); - - media.begin(pending).unwrap(); - media.verify([7; 16], verified.clone()).unwrap(); - assert!(!media.is_renderable_with(&Phase1MediaCacheIndex::default(), config)); - let mut cache = Phase1MediaCacheIndex::default(); - cache - .admit(&verified, Phase1MediaCachePolicy::new(5, 1).unwrap(), 10) - .unwrap(); - assert!(media.is_renderable_with(&cache, config)); - assert!(!media.is_renderable_with(&cache, configuration(4))); - assert_eq!(media.invalidate(), Some(verified.artifact_id())); - - media - .restore( - Phase1InboundMediaState::Verified(Box::new(verified.clone())), - &Phase1MediaCacheIndex::default(), - ) - .unwrap(); - assert!(matches!( - media.retrieval(), - Phase1InboundMediaState::Unavailable - )); - media - .restore( - Phase1InboundMediaState::Verified(Box::new(verified)), - &cache, - ) - .unwrap(); - assert!(matches!( - media.retrieval(), - Phase1InboundMediaState::Verified(_) - )); - } - - #[test] - fn receipt_rejects_hash_size_type_and_dimension_mismatch() { - let expected = reference(None); - let wrong_bytes = - ByteCommitment::from_bytes(b"other", MediaType::parse("image/jpeg").unwrap()); - assert!( - Phase1VerifiedMediaReceipt::from_commitment( - &expected, - BlobUrl::parse(&format!("https://cdn.example/{}.jpg", wrong_bytes.sha256())) - .unwrap(), - &wrong_bytes, - 2, - 3, - configuration(1), - 1, - ) - .is_err() - ); - let commitment = - ByteCommitment::from_bytes(b"hello", MediaType::parse("image/png").unwrap()); - assert!( - Phase1VerifiedMediaReceipt::from_commitment( - &expected, - BlobUrl::parse(&format!("https://cdn.example/{HASH}.jpg")).unwrap(), - &commitment, - 2, - 3, - configuration(1), - 1, - ) - .is_err() - ); - } - - #[test] - fn receipt_validation_rejects_each_bound_field_independently() { - let expected = reference(None); - let commitment = - ByteCommitment::from_bytes(b"hello", MediaType::parse("image/jpeg").unwrap()); - let final_url = BlobUrl::parse(&format!("https://cdn.example/{HASH}.jpg")).unwrap(); - assert!( - Phase1VerifiedMediaReceipt::from_commitment( - &expected, - final_url.clone(), - &commitment, - 2, - 3, - configuration(1), - 0, - ) - .is_err() - ); - - let mutations: [fn(&mut Phase1StructuralMediaReference); 4] = [ - |value: &mut Phase1StructuralMediaReference| value.expected_byte_size = Some(6), - |value: &mut Phase1StructuralMediaReference| { - value.expected_media_type = Some("image/png".to_owned()) - }, - |value: &mut Phase1StructuralMediaReference| value.expected_width = Some(3), - |value: &mut Phase1StructuralMediaReference| value.expected_height = Some(4), - ]; - for mutate in mutations { - let mut changed = expected.clone(); - mutate(&mut changed); - changed.fingerprint = changed.derive_fingerprint(); - assert!( - Phase1VerifiedMediaReceipt::from_commitment( - &changed, - final_url.clone(), - &commitment, - 2, - 3, - configuration(1), - 1, - ) - .is_err() - ); - } - - let mut without_digest = Phase1StructuralMediaReference::new( - "https://media.example/file.jpg", - None, - Some("image/jpeg".to_owned()), - Some(2), - Some(3), - Some(5), - None, - ) - .unwrap(); - without_digest.expected_sha256 = None; - without_digest.fingerprint = without_digest.derive_fingerprint(); - assert!( - Phase1VerifiedMediaReceipt::from_commitment( - &without_digest, - final_url, - &commitment, - 2, - 3, - configuration(1), - 1, - ) - .is_err() - ); - - let valid = receipt(&expected, configuration(1)); - let mut corruptions: Vec<ReceiptMutation> = vec![ - Box::new(|value| value.schema_version = 2), - Box::new(|value| value.expected_sha256 = "a".repeat(64)), - Box::new(|value| value.artifact_id = Phase1MediaArtifactId([8; 32])), - Box::new(|value| value.byte_size = 0), - Box::new(|value| value.verified_at_unix_ms = 0), - Box::new(|value| value.configuration = Phase1MediaConfigurationFingerprint([0; 32])), - Box::new(|value| value.canonical_final_url = "not a url".to_owned()), - Box::new(|value| value.canonical_final_url.push_str("?changed=1")), - Box::new(|value| value.extension = "png".to_owned()), - Box::new(|value| value.media_type = "not a media type".to_owned()), - Box::new(|value| value.media_type = "image/svg+xml".to_owned()), - Box::new(|value| value.width = 0), - ]; - for mutate in corruptions.drain(..) { - let mut changed = valid.clone(); - mutate(&mut changed); - assert_eq!( - changed.validate_intrinsic(), - Err(Phase1InboundMediaError::CorruptReceipt) - ); - } - assert_eq!(valid.artifact_id().to_hex(), HASH); - assert_eq!(valid.configuration(), configuration(1)); - assert_eq!( - valid.canonical_final_url(), - format!("https://cdn.example/{HASH}.jpg") - ); - assert_eq!(valid.observed_sha256(), HASH); - assert_eq!(valid.byte_size(), 5); - assert_eq!(valid.media_type(), "image/jpeg"); - assert_eq!(valid.extension(), "jpg"); - assert_eq!( - (valid.width(), valid.height(), valid.verified_at_unix_ms()), - (2, 3, 10) - ); - - let wrong_extension = BlobUrl::parse(&format!("https://cdn.example/{HASH}.png")).unwrap(); - assert_eq!( - Phase1VerifiedMediaReceipt::from_commitment( - &expected, - wrong_extension, - &commitment, - 2, - 3, - configuration(1), - 1, - ), - Err(Phase1InboundMediaError::MetadataMismatch) - ); - let wrong_path_hash = "a".repeat(64); - assert_eq!( - Phase1VerifiedMediaReceipt::from_commitment( - &expected, - BlobUrl::parse(&format!("https://cdn.example/{wrong_path_hash}.jpg")).unwrap(), - &commitment, - 2, - 3, - configuration(1), - 1, - ), - Err(Phase1InboundMediaError::MetadataMismatch) - ); - - let reference_mutations: [fn(&mut Phase1StructuralMediaReference); 7] = [ - |value| value.fingerprint = [8; 32], - |value| value.source_url = "https://other.example/file.jpg".to_owned(), - |value| value.expected_sha256 = Some("a".repeat(64)), - |value| value.expected_byte_size = Some(6), - |value| value.expected_media_type = Some("image/png".to_owned()), - |value| value.expected_width = Some(3), - |value| value.expected_height = Some(4), - ]; - for mutate in reference_mutations { - let mut changed = expected.clone(); - mutate(&mut changed); - assert_eq!( - valid.validate(&changed), - Err(Phase1InboundMediaError::CorruptReceipt) - ); - } - } - - #[test] - fn cache_validation_rejects_each_invalid_field_independently() { - assert!(Phase1MediaCachePolicy::new(0, 1).is_err()); - assert!(Phase1MediaCachePolicy::new(1, 0).is_err()); - let policy = Phase1MediaCachePolicy::default(); - assert_eq!(policy.max_bytes(), 256 * 1024 * 1024); - assert_eq!(policy.max_artifacts(), 2_000); - - let structural = reference(None); - let verified = receipt(&structural, configuration(4)); - assert!(Phase1MediaCacheEntry::from_receipt(&verified, 9).is_err()); - let mut cache = Phase1MediaCacheIndex::default(); - assert!(!cache.touch(verified.artifact_id(), 1).unwrap()); - assert!(cache.touch(verified.artifact_id(), 0).is_err()); - assert!(!cache.invalidate_artifact(verified.artifact_id())); - assert!(cache.invalidate_configuration(configuration(4)).is_empty()); - cache - .admit(&verified, Phase1MediaCachePolicy::new(5, 1).unwrap(), 10) - .unwrap(); - assert!(cache.touch(verified.artifact_id(), 11).unwrap()); - assert!(cache.invalidate_artifact(verified.artifact_id())); - - let mut baseline = Phase1MediaCacheIndex::default(); - baseline - .admit(&verified, Phase1MediaCachePolicy::new(5, 1).unwrap(), 10) - .unwrap(); - let key = verified.artifact_id().to_hex(); - let mutations: Vec<CacheMutation> = vec![ - Box::new(|value| value.schema_version = 2), - Box::new(|value| value.configuration = None), - Box::new({ - let key = key.clone(); - move |value| value.entries.get_mut(&key).unwrap().byte_size = 0 - }), - Box::new({ - let key = key.clone(); - move |value| value.entries.get_mut(&key).unwrap().cached_at_unix_ms = 0 - }), - Box::new({ - let key = key.clone(); - move |value| { - value - .entries - .get_mut(&key) - .unwrap() - .last_accessed_at_unix_ms = 9 - } - }), - Box::new({ - let key = key.clone(); - move |value| value.entries.get_mut(&key).unwrap().media_type = "bad".to_owned() - }), - Box::new({ - let key = key.clone(); - move |value| value.entries.get_mut(&key).unwrap().extension.clear() - }), - Box::new({ - let key = key.clone(); - move |value| value.entries.get_mut(&key).unwrap().width = 0 - }), - Box::new(|value| { - let entry = value.entries.pop_first().unwrap().1; - value.entries.insert("wrong-key".to_owned(), entry); - }), - Box::new(|value| { - value.configuration = Some(Phase1MediaConfigurationFingerprint([0; 32])); - }), - ]; - for mutate in mutations { - let mut changed = baseline.clone(); - mutate(&mut changed); - assert_eq!(changed.status(), Err(Phase1InboundMediaError::CorruptState)); - } - assert_eq!( - baseline.admit(&verified, Phase1MediaCachePolicy::new(4, 1).unwrap(), 10), - Err(Phase1InboundMediaError::CacheQuotaExceeded) - ); - - let mut wrong_schema = baseline.clone(); - wrong_schema.schema_version = 2; - assert!(!wrong_schema.contains(&verified)); - let mut wrong_configuration = baseline.clone(); - wrong_configuration.configuration = Some(configuration(5)); - assert!(!wrong_configuration.contains(&verified)); - let mut missing = baseline.clone(); - missing.entries.clear(); - assert!(!missing.contains(&verified)); - let mut mismatched = baseline.clone(); - mismatched.entries.get_mut(&key).unwrap().height = 4; - assert!(!mismatched.contains(&verified)); - - let receipt_mutations: [fn(&mut Phase1VerifiedMediaReceipt); 6] = [ - |value| value.artifact_id = Phase1MediaArtifactId([8; 32]), - |value| value.byte_size = 6, - |value| value.media_type = "image/png".to_owned(), - |value| value.extension = "png".to_owned(), - |value| value.width = 3, - |value| value.height = 4, - ]; - let entry = baseline.entries.get(&key).unwrap(); - for mutate in receipt_mutations { - let mut changed = verified.clone(); - mutate(&mut changed); - assert!(!entry.matches(&changed)); - } - - let mut collision = baseline.clone(); - collision.entries.get_mut(&key).unwrap().byte_size = 4; - assert_eq!( - collision.admit(&verified, Phase1MediaCachePolicy::new(10, 2).unwrap(), 10), - Err(Phase1InboundMediaError::ArtifactCollision) - ); - - let second_hash = Sha256::digest(b"world").to_hex(); - let second_reference = Phase1StructuralMediaReference::new( - format!("https://media.example/{second_hash}.jpg"), - Some(second_hash.clone()), - Some("image/jpeg".to_owned()), - Some(2), - Some(3), - Some(5), - None, - ) - .unwrap(); - let second = Phase1VerifiedMediaReceipt::from_commitment( - &second_reference, - BlobUrl::parse(&format!("https://cdn.example/{second_hash}.jpg")).unwrap(), - &ByteCommitment::from_bytes(b"world", MediaType::parse("image/jpeg").unwrap()), - 2, - 3, - configuration(4), - 11, - ) - .unwrap(); - let mut byte_limited = baseline.clone(); - assert_eq!( - byte_limited - .admit(&second, Phase1MediaCachePolicy::new(8, 2).unwrap(), 11) - .unwrap(), - vec![verified.artifact_id()] - ); - - let mut overflow = Phase1MediaCacheIndex { - configuration: Some(configuration(4)), - ..Phase1MediaCacheIndex::default() - }; - let mut first_entry = Phase1MediaCacheEntry::from_receipt(&verified, 10).unwrap(); - first_entry.byte_size = u64::MAX; - overflow.entries.insert(key, first_entry); - let second_key = second.artifact_id().to_hex(); - overflow.entries.insert( - second_key, - Phase1MediaCacheEntry::from_receipt(&second, 11).unwrap(), - ); - assert_eq!( - overflow.total_bytes(), - Err(Phase1InboundMediaError::CorruptState) - ); - } - - #[test] - fn cache_is_content_addressed_bounded_lru_and_configuration_scoped() { - let config = configuration(4); - let first_reference = reference(None); - let first = receipt(&first_reference, config); - let second_hash = Sha256::digest(b"world").to_hex(); - let second_reference = Phase1StructuralMediaReference::new( - format!("https://media.example/{second_hash}.jpg"), - Some(second_hash.clone()), - Some("image/jpeg".to_owned()), - Some(2), - Some(3), - Some(5), - None, - ) - .unwrap(); - let second_commitment = - ByteCommitment::from_bytes(b"world", MediaType::parse("image/jpeg").unwrap()); - let second = Phase1VerifiedMediaReceipt::from_commitment( - &second_reference, - BlobUrl::parse(&format!("https://cdn.example/{second_hash}.jpg")).unwrap(), - &second_commitment, - 2, - 3, - config, - 11, - ) - .unwrap(); - let mut cache = Phase1MediaCacheIndex::default(); - let policy = Phase1MediaCachePolicy::new(5, 1).unwrap(); - assert!(cache.admit(&first, policy, 10).unwrap().is_empty()); - let evicted = cache.admit(&second, policy, 11).unwrap(); - assert_eq!(evicted, vec![first.artifact_id()]); - assert!(!cache.contains(&first)); - assert!(cache.contains(&second)); - assert_eq!(cache.status().unwrap().artifacts, 1); - assert_eq!( - cache.admit(&second, policy, 12), - Ok(Vec::new()), - "idempotent cache admission remains bounded" - ); - assert_eq!( - cache.admit(&receipt(&first_reference, configuration(5)), policy, 13,), - Err(Phase1InboundMediaError::ConfigurationMismatch) - ); - assert_eq!( - cache.invalidate_configuration(configuration(5)), - vec![second.artifact_id()] - ); - assert_eq!(cache.status().unwrap().artifacts, 0); - } - - #[test] - fn persisted_receipt_and_cache_tamper_fail_closed() { - let structural = reference(None); - let config = configuration(7); - let mut media = MediaReference::new(structural.clone()).unwrap(); - media - .begin(Phase1InboundMediaPending::new([8; 16], config, 1).unwrap()) - .unwrap(); - let verified = receipt(&structural, config); - media.verify([8; 16], verified.clone()).unwrap(); - let mut media_value = serde_json::to_value(&media).unwrap(); - media_value["retrieval"]["evidence"]["observedSha256"] = serde_json::json!("a".repeat(64)); - let corrupt: MediaReference = serde_json::from_value(media_value).unwrap(); - assert_eq!( - corrupt.validate(), - Err(Phase1InboundMediaError::CorruptReceipt) - ); - - let mut cache = Phase1MediaCacheIndex::default(); - cache - .admit(&verified, Phase1MediaCachePolicy::new(10, 1).unwrap(), 12) - .unwrap(); - let mut cache_value = serde_json::to_value(cache).unwrap(); - let entry = cache_value["entries"] - .as_object_mut() - .unwrap() - .values_mut() - .next() - .unwrap(); - entry["byteSize"] = serde_json::json!(0); - let corrupt: Phase1MediaCacheIndex = serde_json::from_value(cache_value).unwrap(); - assert_eq!(corrupt.status(), Err(Phase1InboundMediaError::CorruptState)); - } - - #[cfg(feature = "mobile-social")] - #[tokio::test] - async fn atomic_artifact_writes_converge_and_corruption_fails_closed() { - let bytes = b"GIF89a\x02\0\x03\0"; - let hash = Sha256::digest(bytes).to_hex(); - let structural = Phase1StructuralMediaReference::new( - format!("https://media.example/{hash}.gif"), - Some(hash.clone()), - Some("image/gif".to_owned()), - Some(2), - Some(3), - Some(bytes.len() as u64), - None, - ) - .unwrap(); - let receipt = Phase1VerifiedMediaReceipt::from_commitment( - &structural, - BlobUrl::parse(&format!("https://media.example/{hash}.gif")).unwrap(), - &ByteCommitment::from_bytes(bytes, MediaType::parse("image/gif").unwrap()), - 2, - 3, - configuration(4), - 1, - ) - .unwrap(); - let root = tempfile::tempdir().unwrap(); - let directory = root.path().join("cache"); - let (left, right) = tokio::join!( - write_verified_artifact(&directory, &receipt, bytes), - write_verified_artifact(&directory, &receipt, bytes), - ); - let left = left.unwrap(); - let right = right.unwrap(); - assert_eq!(left, right); - assert_eq!(left.bytes(), bytes); - assert_eq!(tokio::fs::read(left.local_path()).await.unwrap(), bytes); - assert_eq!( - std::fs::read_dir(&directory).unwrap().count(), - 1, - "no temporary file survives a converged write" - ); - tokio::fs::write(left.local_path(), b"GIF89a\x03\0\x03\0") - .await - .unwrap(); - assert_eq!( - verified_artifact(&directory, &receipt).await, - Err(Phase1InboundMediaError::CorruptArtifact) - ); - } -} diff --git a/crates/mobile_core/src/runtime/product_surface/model.rs b/crates/mobile_core/src/runtime/product_surface/model.rs @@ -1,251 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::{CardId, ContextRank, MediaReference, TodayRank}; - -/// The closed Phase 1 top-level Today taxonomy. -#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] -#[serde(rename_all = "PascalCase")] -pub enum TodayCardType { - Update, - PhotoUpdate, - Ask, - Event, - FoodAvailability, -} - -impl TodayCardType { - pub const fn label(self) -> &'static str { - match self { - Self::Update => "Update", - Self::PhotoUpdate => "PhotoUpdate", - Self::Ask => "Ask", - Self::Event => "Event", - Self::FoodAvailability => "FoodAvailability", - } - } -} - -/// The closed Phase 1 Add command taxonomy. -#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] -#[serde(rename_all = "PascalCase")] -pub enum AddCommandType { - CreateUpdate, - CreatePhotoUpdate, - CreateAsk, - CreateEvent, - CreateFoodAvailability, -} - -/// One exact top-level card to Add-command mapping. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CardAddParity { - pub card_type: TodayCardType, - pub add_command_type: AddCommandType, -} - -pub const CANONICAL_TODAY_CARD_TYPES: [TodayCardType; 5] = [ - TodayCardType::Update, - TodayCardType::PhotoUpdate, - TodayCardType::Ask, - TodayCardType::Event, - TodayCardType::FoodAvailability, -]; - -pub const CANONICAL_ADD_COMMAND_TYPES: [AddCommandType; 5] = [ - AddCommandType::CreateUpdate, - AddCommandType::CreatePhotoUpdate, - AddCommandType::CreateAsk, - AddCommandType::CreateEvent, - AddCommandType::CreateFoodAvailability, -]; - -pub const CANONICAL_CARD_ADD_PARITY: [CardAddParity; 5] = [ - CardAddParity { - card_type: TodayCardType::Update, - add_command_type: AddCommandType::CreateUpdate, - }, - CardAddParity { - card_type: TodayCardType::PhotoUpdate, - add_command_type: AddCommandType::CreatePhotoUpdate, - }, - CardAddParity { - card_type: TodayCardType::Ask, - add_command_type: AddCommandType::CreateAsk, - }, - CardAddParity { - card_type: TodayCardType::Event, - add_command_type: AddCommandType::CreateEvent, - }, - CardAddParity { - card_type: TodayCardType::FoodAvailability, - add_command_type: AddCommandType::CreateFoodAvailability, - }, -]; - -/// Supporting standard profiles that enrich the product without creating cards. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "PascalCase")] -pub enum SupportingProfile { - Profile, - Reply, - Comment, - Deletion, -} - -/// Tolerant profile attribution attached to cards and Me results. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProfileSummary { - pub author_pubkey: String, - pub name: Option<String>, - pub display_name: Option<String>, - pub about: Option<String>, - pub picture: Option<MediaReference>, - pub banner: Option<MediaReference>, - pub nip05: Option<String>, - pub website: Option<String>, - pub lightning_address: Option<String>, -} - -/// Thread enrichment identity; replies and comments never become top-level cards. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ThreadReference { - pub profile: SupportingProfile, - pub root: String, - pub parent_event_id: String, -} - -/// One admitted reply or comment attached to its canonical root. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ThreadEntry { - pub event_id: String, - pub author_pubkey: String, - pub content: String, - pub authored_at: u64, - pub reference: ThreadReference, - pub author_profile: Option<ProfileSummary>, -} - -/// Durable local-only authored state overlaid without changing event truth. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LocalAuthorOverlay { - pub operation_id: String, - pub state: String, -} - -/// Current rendering state derived from standard event semantics. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "PascalCase")] -pub enum CardLifecycleState { - Active, - Sold, - Past, -} - -/// Verified, visible, context-admitted source facts for one top-level card. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ClassifiedCard { - pub schema_version: u16, - pub card_id: CardId, - pub card_type: TodayCardType, - pub source_event_id: String, - pub source_address: Option<String>, - pub author_pubkey: String, - pub contract_id: String, - pub title: Option<String>, - pub content: String, - pub authored_at: u64, - pub effective_at: u64, - pub event_start: Option<u64>, - pub event_end: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub location: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub price_amount: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub price_currency: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub price_unit: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub quantity: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub food_summary: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub food_published_at: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub food_status: Option<String>, - pub context_rank: ContextRank, - pub inclusion_reason: String, - pub media: Vec<MediaReference>, - pub lifecycle: CardLifecycleState, - pub rank: Option<TodayRank>, -} - -/// One fully enriched Today card returned to a host. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TodayCard { - pub card: ClassifiedCard, - pub author_profile: Option<ProfileSummary>, - pub thread: Vec<ThreadEntry>, - pub local_overlay: Option<LocalAuthorOverlay>, -} - -/// One frozen, cursor-addressable Today page. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TodayPage { - pub as_of: u64, - pub items: Vec<TodayCard>, - pub next_cursor: Option<String>, -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "PascalCase")] -pub enum SearchResultType { - Card, - Profile, -} - -/// One local search result governed by the same current projection as Today. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SearchResult { - pub result_type: SearchResultType, - pub stable_id: String, - pub card: Option<TodayCard>, - pub profile: Option<ProfileSummary>, -} - -/// Active identity attribution and its current visible Phase 1 cards. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MeSnapshot { - pub public_key: String, - pub profile: Option<ProfileSummary>, - pub cards: Vec<TodayCard>, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn card_and_add_taxonomies_are_exact_and_serialized_stably() { - assert_eq!(CANONICAL_TODAY_CARD_TYPES.len(), 5); - assert_eq!(CANONICAL_ADD_COMMAND_TYPES.len(), 5); - for (index, parity) in CANONICAL_CARD_ADD_PARITY.iter().enumerate() { - assert_eq!(parity.card_type, CANONICAL_TODAY_CARD_TYPES[index]); - assert_eq!(parity.add_command_type, CANONICAL_ADD_COMMAND_TYPES[index]); - } - assert_eq!( - serde_json::to_string(&CANONICAL_TODAY_CARD_TYPES).expect("cards"), - r#"["Update","PhotoUpdate","Ask","Event","FoodAvailability"]"# - ); - } -} diff --git a/crates/mobile_core/src/runtime/product_surface/outbox.rs b/crates/mobile_core/src/runtime/product_surface/outbox.rs @@ -1,4402 +0,0 @@ -use std::{ - collections::BTreeSet, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use radroots_blossom::{ - BlobUrl, ByteVerifiedDescriptor, MediaType, authorization::AuthoredUploadClaim, -}; -use radroots_event::{ - contract::AuthorRole, - post::deletion::{ - AuthoredNip09DeletionRequest, Nip09DeletionAddressTarget, Nip09DeletionEventTarget, - }, -}; -use radroots_event_codec::authoring::{AuthoredEventPlan, PlanWireV1}; -use radroots_identity::PublicKey; -use radroots_signing::{ - Actor, AuthoredArtifactId, SigningIntentId, SigningOperationId, - actor::ActorSource, - request::{CancellationPolicy, SignPolicy}, -}; -use radroots_storage::{ - authored::{AdmissionState, SigningState}, - authored_delivery::{AuthoredDeliveryState, DeliveryAttemptOutcome}, - authored_draft::{ - AuthoredDraft, AuthoredDraftId, AuthoredDraftRevision, AuthoredDraftStage, - AuthoredDraftStore, - }, - journal::{IdempotencyKey, OperationInstanceId}, -}; -use radroots_sync::{PushRequest, PushStatus, policy::SyncId}; -use radroots_transport::{ - Target, TargetSet, - outcome::DeliveryOutcomeKind, - policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, -}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use thiserror::Error; - -use super::{ - AddCommandType, CardId, CardSourceIdentity, LocalAuthorOverlay, LocalNetwork, Phase1AddCommand, - ProfileMetadataCommand, TodayCardType, TodayError, phase1_retraction_plan, -}; -use crate::runtime::RadrootsRuntime; - -const DRAFT_PAYLOAD_SCHEMA: &str = "radroots.mobile.phase1-draft.v1"; -const PROFILE_PAYLOAD_SCHEMA: &str = "radroots.mobile.phase1-profile.v1"; -const DRAFT_SCHEMA_VERSION: u16 = 1; -const DRAFT_MEDIA_MAX: usize = 20; -const DRAFT_LOCAL_REFERENCE_MAX_BYTES: usize = 4_096; -const DRAFT_FAILURE_CODE_MAX_BYTES: usize = 96; -const DRAFT_OPERATION_DOMAIN: &[u8] = b"radroots.mobile.phase1-draft-operation.v1\0"; -const ADD_DELIVERY_TIMEOUT_MS: u64 = 24 * 60 * 60 * 1_000; -const BLOSSOM_AUTHORIZATION_BACKDATE_SECONDS: u64 = 5; -const BLOSSOM_AUTHORIZATION_LIFETIME_SECONDS: u64 = 5 * 60; -const BLOSSOM_SIGNING_TIMEOUT_MS: u64 = 60 * 1_000; -const BLOSSOM_AUTHORIZATION_CONTENT: &str = "Upload exact Radroots image"; -const REVISION_RETRACTION_REASON: &str = "Replaced by a corrected Radroots event"; - -/// Product intent represented by one durable draft/outbox item. -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum Phase1DraftKind { - #[default] - Add, - Retraction, -} - -/// Event timing profile retained for a reopenable Add form. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum Phase1DraftEventTiming { - AllDay, - Timed, -} - -/// Secret-free, restart-safe media fields retained with an Add form. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct Phase1DraftMediaSnapshot { - pub opaque_reference: String, - pub url: String, - pub sha256: String, - pub media_type: String, - pub byte_size: u64, - pub width: u32, - pub height: u32, - pub alt: String, - pub prepared_at_unix_s: u64, -} - -/// Immutable, validated presentation input used to reopen a durable draft. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct Phase1DraftFormSnapshot { - pub command_type: AddCommandType, - pub content: String, - pub identifier: Option<String>, - pub title: Option<String>, - pub summary: Option<String>, - pub location: Option<String>, - pub event_timing: Option<Phase1DraftEventTiming>, - pub event_start_date: Option<String>, - pub event_end_date: Option<String>, - pub event_start_unix_s: Option<u64>, - pub event_end_unix_s: Option<u64>, - pub event_timezone: Option<String>, - pub price_amount: Option<String>, - pub currency: Option<String>, - pub unit: Option<String>, - pub quantity: Option<String>, - #[serde(default)] - pub food_published_at_unix_s: Option<u64>, - pub food_status: Option<String>, - pub media: Vec<Phase1DraftMediaSnapshot>, -} - -impl Phase1DraftFormSnapshot { - fn validate( - &self, - command_type: AddCommandType, - media: &[Phase1MediaPrerequisite], - ) -> Result<(), Phase1DraftError> { - let bounded = |value: &str, maximum: usize| { - value.len() <= maximum && !value.chars().any(char::is_control) - }; - if self.command_type != command_type - || self.content.len() > 65_535 - || self.media.len() != media.len() - || [ - self.identifier.as_deref(), - self.title.as_deref(), - self.summary.as_deref(), - self.location.as_deref(), - self.event_start_date.as_deref(), - self.event_end_date.as_deref(), - self.event_timezone.as_deref(), - self.price_amount.as_deref(), - self.currency.as_deref(), - self.unit.as_deref(), - self.quantity.as_deref(), - self.food_status.as_deref(), - ] - .into_iter() - .flatten() - .any(|value| !bounded(value, 1_024)) - { - return Err(Phase1DraftError::InvalidDraft); - } - for (snapshot, prerequisite) in self.media.iter().zip(media) { - if snapshot.opaque_reference.is_empty() - || snapshot.opaque_reference != prerequisite.local_reference() - || snapshot.url != prerequisite.url - || snapshot.sha256 != prerequisite.sha256() - || snapshot.media_type != prerequisite.media_type() - || snapshot.byte_size != prerequisite.byte_size() - || snapshot.width == 0 - || snapshot.height == 0 - || snapshot.alt.trim().is_empty() - || !bounded(&snapshot.alt, 1_024) - || snapshot.prepared_at_unix_s == 0 - { - return Err(Phase1DraftError::InvalidMedia); - } - } - Ok(()) - } -} - -/// Durable state of one media prerequisite referenced by an Add command. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum Phase1MediaStage { - Pending, - Preparing, - Uploading, - Verified, - Failed, - Orphaned, -} - -/// Exact local and remote identity of one Add media prerequisite. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct Phase1MediaPrerequisite { - local_reference: String, - url: String, - sha256: String, - media_type: String, - byte_size: u64, - stage: Phase1MediaStage, - failure_code: Option<String>, - upload_attempts: u8, - verified_at_unix_ms: Option<u64>, - orphan: Option<Phase1MediaOrphanRecord>, -} - -/// Durable, secret-safe evidence that a remote blob may be unreferenced. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct Phase1MediaOrphanRecord { - reason_code: String, - recorded_at_unix_ms: u64, -} - -impl Phase1MediaOrphanRecord { - pub fn reason_code(&self) -> &str { - self.reason_code.as_str() - } - - pub const fn recorded_at_unix_ms(&self) -> u64 { - self.recorded_at_unix_ms - } -} - -impl Phase1MediaPrerequisite { - pub fn new( - local_reference: impl Into<String>, - descriptor: &ByteVerifiedDescriptor, - ) -> Result<Self, Phase1DraftError> { - let value = Self { - local_reference: local_reference.into(), - url: descriptor.url().as_blob_url().as_str().to_owned(), - sha256: descriptor.sha256().to_hex(), - media_type: descriptor.media_type().as_str().to_owned(), - byte_size: descriptor.size(), - stage: Phase1MediaStage::Pending, - failure_code: None, - upload_attempts: 0, - verified_at_unix_ms: None, - orphan: None, - }; - value.validate()?; - Ok(value) - } - - fn validate(&self) -> Result<(), Phase1DraftError> { - let blob = BlobUrl::parse(self.url.as_str()).map_err(|_| Phase1DraftError::InvalidMedia)?; - let hash = blob.hash_path().hash().to_string(); - if self.local_reference.is_empty() - || self.local_reference.len() > DRAFT_LOCAL_REFERENCE_MAX_BYTES - || self.local_reference != self.local_reference.trim() - || self.local_reference.chars().any(char::is_control) - || self.sha256 != hash - || MediaType::parse(self.media_type.as_str()).is_err() - || self.byte_size == 0 - || self.failure_code.as_ref().is_some_and(|code| { - code.is_empty() - || code.len() > DRAFT_FAILURE_CODE_MAX_BYTES - || code != code.trim() - || code.chars().any(char::is_control) - }) - || self.orphan.as_ref().is_some_and(|record| { - record.reason_code.is_empty() - || record.reason_code.len() > DRAFT_FAILURE_CODE_MAX_BYTES - || record.reason_code != record.reason_code.trim() - || record.reason_code.chars().any(char::is_control) - || record.recorded_at_unix_ms == 0 - }) - || match self.stage { - Phase1MediaStage::Pending | Phase1MediaStage::Preparing => { - self.failure_code.is_some() - || self.upload_attempts != 0 - || self.verified_at_unix_ms.is_some() - || self.orphan.is_some() - } - Phase1MediaStage::Uploading => { - self.failure_code.is_some() - || self.verified_at_unix_ms.is_some() - || self.orphan.is_some() - } - Phase1MediaStage::Verified => { - self.failure_code.is_some() - || self.upload_attempts == 0 - || self.verified_at_unix_ms.is_none() - || self.orphan.is_some() - } - Phase1MediaStage::Failed => { - self.failure_code.is_none() || self.verified_at_unix_ms.is_some() - } - Phase1MediaStage::Orphaned => self.failure_code.is_some() || self.orphan.is_none(), - } - { - return Err(Phase1DraftError::InvalidMedia); - } - Ok(()) - } - - pub fn url(&self) -> &str { - self.url.as_str() - } - pub fn local_reference(&self) -> &str { - self.local_reference.as_str() - } - pub fn sha256(&self) -> &str { - self.sha256.as_str() - } - pub fn media_type(&self) -> &str { - self.media_type.as_str() - } - pub const fn byte_size(&self) -> u64 { - self.byte_size - } - pub const fn stage(&self) -> Phase1MediaStage { - self.stage - } - - pub const fn upload_attempts(&self) -> u8 { - self.upload_attempts - } - - pub const fn verified_at_unix_ms(&self) -> Option<u64> { - self.verified_at_unix_ms - } - - pub const fn orphan(&self) -> Option<&Phase1MediaOrphanRecord> { - self.orphan.as_ref() - } - - fn matches_receipt(&self, receipt: &radroots_sdk::transport::BlossomUploadReceipt) -> bool { - let descriptor = receipt.descriptor(); - self.url == descriptor.url().as_blob_url().as_str() - && self.sha256 == descriptor.sha256().to_hex() - && self.media_type == descriptor.media_type().as_str() - && self.byte_size == descriptor.size() - && receipt.attempts() > 0 - && receipt.verified_at_unix_ms() > 0 - } - - fn is_remote_verified(&self) -> bool { - self.stage == Phase1MediaStage::Verified - && self.upload_attempts > 0 - && self.verified_at_unix_ms.is_some() - } -} - -/// Closed delivery-satisfaction profiles available to Phase 1 Add. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum Phase1RelaySatisfaction { - AnyAccepted, - AllAccepted, - AnyDelivered, - AllDelivered, -} - -/// Stable product-level cancellation policy persisted with queue intent. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum Phase1CancellationPolicy { - PreservePublishedRequest, - LocalCooperative, -} - -impl Phase1CancellationPolicy { - const fn signing(self) -> CancellationPolicy { - match self { - Self::PreservePublishedRequest => CancellationPolicy::PreservePublishedRequest, - Self::LocalCooperative => CancellationPolicy::LocalCooperative, - } - } -} - -/// Exact relay and deadline intent frozen before an operation is prepared. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct Phase1QueuePolicy { - relay_urls: Vec<String>, - satisfaction: Phase1RelaySatisfaction, - delivery_deadline_unix_ms: u64, - cancellation: Phase1CancellationPolicy, -} - -impl Phase1QueuePolicy { - pub fn new( - relay_urls: Vec<String>, - satisfaction: Phase1RelaySatisfaction, - delivery_deadline_unix_ms: u64, - cancellation: Phase1CancellationPolicy, - ) -> Result<Self, Phase1DraftError> { - let value = Self { - relay_urls, - satisfaction, - delivery_deadline_unix_ms, - cancellation, - }; - value.materialize()?; - Ok(value) - } - - fn materialize( - &self, - ) -> Result<(TargetSet, SatisfactionPolicy, CancellationPolicy), Phase1DraftError> { - if self.delivery_deadline_unix_ms == 0 || self.relay_urls.is_empty() { - return Err(Phase1DraftError::InvalidQueuePolicy); - } - let mut canonical = BTreeSet::new(); - let targets = self - .relay_urls - .iter() - .map(|url| { - let target = - Target::nostr_relay(url).map_err(|_| Phase1DraftError::InvalidQueuePolicy)?; - if target.uri().as_str() != url || !canonical.insert(url.as_str()) { - return Err(Phase1DraftError::InvalidQueuePolicy); - } - Ok(target) - }) - .collect::<Result<Vec<_>, _>>()?; - let targets = TargetSet::new(targets).map_err(|_| Phase1DraftError::InvalidQueuePolicy)?; - let (class, target_policy) = match self.satisfaction { - Phase1RelaySatisfaction::AnyAccepted => { - (SatisfactionClass::Accepted, TargetPolicy::any()) - } - Phase1RelaySatisfaction::AllAccepted => { - (SatisfactionClass::Accepted, TargetPolicy::all()) - } - Phase1RelaySatisfaction::AnyDelivered => { - (SatisfactionClass::Delivered, TargetPolicy::any()) - } - Phase1RelaySatisfaction::AllDelivered => { - (SatisfactionClass::Delivered, TargetPolicy::all()) - } - }; - let cancellation = match self.cancellation { - Phase1CancellationPolicy::PreservePublishedRequest => { - CancellationPolicy::PreservePublishedRequest - } - Phase1CancellationPolicy::LocalCooperative => CancellationPolicy::LocalCooperative, - }; - Ok(( - targets, - SatisfactionPolicy::new(class, target_policy), - cancellation, - )) - } -} - -/// Existing durable draft selected for an optimistic replacement. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct Phase1ExistingDraft { - draft_id: [u8; 16], - expected_revision: u64, -} - -impl Phase1ExistingDraft { - pub fn new(draft_id: [u8; 16], expected_revision: u64) -> Result<Self, Phase1DraftError> { - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - AuthoredDraftRevision::new(expected_revision) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - Ok(Self { - draft_id, - expected_revision, - }) - } -} - -/// One typed Add save intent. Rust supplies the creation identifier and all -/// policy timestamps; a caller can only name an existing revision to replace. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Phase1AddIntent { - command: Phase1AddCommand, - media: Vec<Phase1MediaPrerequisite>, - form: Phase1DraftFormSnapshot, - existing: Option<Phase1ExistingDraft>, -} - -impl Phase1AddIntent { - pub fn new( - command: Phase1AddCommand, - media: Vec<Phase1MediaPrerequisite>, - form: Phase1DraftFormSnapshot, - existing: Option<Phase1ExistingDraft>, - ) -> Result<Self, Phase1DraftError> { - if media.len() > DRAFT_MEDIA_MAX { - return Err(Phase1DraftError::InvalidMedia); - } - form.validate(command.command_type(), &media)?; - Ok(Self { - command, - media, - form, - existing, - }) - } -} - -/// Minimal queue intent. Relay selection, settlement, deadline, and -/// cancellation are derived from the active typed Rust transport profile. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct Phase1QueueIntent { - draft_id: [u8; 16], - expected_revision: u64, -} - -impl Phase1QueueIntent { - pub fn new(draft_id: [u8; 16], expected_revision: u64) -> Result<Self, Phase1DraftError> { - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - AuthoredDraftRevision::new(expected_revision) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - Ok(Self { - draft_id, - expected_revision, - }) - } -} - -/// Bounded exact-byte input for one Rust-planned Blossom upload attempt. -#[derive(Clone)] -pub struct Phase1UploadIntent { - draft_id: [u8; 16], - expected_revision: u64, - bytes: Arc<[u8]>, - media_type: MediaType, - dimensions: radroots_sdk::transport::BlossomImageDimensions, -} - -impl Phase1UploadIntent { - pub fn new( - draft_id: [u8; 16], - expected_revision: u64, - bytes: Arc<[u8]>, - media_type: MediaType, - width: u32, - height: u32, - ) -> Result<Self, Phase1DraftError> { - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - AuthoredDraftRevision::new(expected_revision) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - if bytes.is_empty() { - return Err(Phase1DraftError::InvalidMedia); - } - let dimensions = radroots_sdk::transport::BlossomImageDimensions::new(width, height) - .map_err(|_| Phase1DraftError::InvalidMedia)?; - Ok(Self { - draft_id, - expected_revision, - bytes, - media_type, - dimensions, - }) - } -} - -/// Immutable Rust-derived policy for one upload attempt. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Phase1UploadPlan { - pub authorization_content: String, - pub authorization_created_at_unix_s: u64, - pub authorization_lifetime_seconds: u64, - pub operation_id: [u8; 16], - pub artifact_id: [u8; 16], - pub signing_deadline_unix_ms: u64, - pub cancellation: Phase1CancellationPolicy, - pub updated_at_unix_ms: u64, -} - -/// Immutable Rust-authorized upload job handed to a native background -/// transfer implementation after the durable draft enters `media_uploading`. -#[derive(Clone)] -pub struct Phase1NativeUploadJob { - operation_id: [u8; 16], - remote_url: String, - authorization_header: String, - expected_sha256: String, - media_type: String, - byte_size: u64, -} - -impl Phase1NativeUploadJob { - pub const fn operation_id(&self) -> [u8; 16] { - self.operation_id - } - pub fn remote_url(&self) -> &str { - self.remote_url.as_str() - } - pub fn authorization_header(&self) -> &str { - self.authorization_header.as_str() - } - pub fn expected_sha256(&self) -> &str { - self.expected_sha256.as_str() - } - pub fn media_type(&self) -> &str { - self.media_type.as_str() - } - pub const fn byte_size(&self) -> u64 { - self.byte_size - } -} - -/// Existing published card selected for one lossless revision operation. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct Phase1RevisionTarget { - command_type: AddCommandType, - card_id: CardId, - source_event_id: String, - source_kind: u32, - source_address: Option<String>, - author_public_key: String, -} - -impl Phase1RevisionTarget { - pub fn new( - command_type: AddCommandType, - card_id: CardId, - source_event_id: impl Into<String>, - source_kind: u32, - source_address: Option<String>, - author_public_key: impl Into<String>, - ) -> Result<Self, Phase1DraftError> { - let value = Self { - command_type, - card_id, - source_event_id: source_event_id.into(), - source_kind, - source_address, - author_public_key: author_public_key.into(), - }; - value.validate()?; - Ok(value) - } - - /// Builds a revision target from its canonical source identity while - /// keeping Nostr kind parsing inside the Rust protocol boundary. - pub fn from_source( - command_type: AddCommandType, - card_id: CardId, - source_event_id: impl Into<String>, - source_address: Option<String>, - author_public_key: impl Into<String>, - ) -> Result<Self, Phase1DraftError> { - let source_kind = match source_address.as_deref() { - Some(address) => parse_address(address)?.0, - None => 1, - }; - Self::new( - command_type, - card_id, - source_event_id, - source_kind, - source_address, - author_public_key, - ) - } - - fn validate(&self) -> Result<(), Phase1DraftError> { - let author = PublicKey::from_hex(&self.author_public_key) - .map_err(|_| Phase1DraftError::InvalidRevision)?; - if author.to_hex() != self.author_public_key { - return Err(Phase1DraftError::InvalidRevision); - } - let event_id = radroots_event::EventId::parse(&self.source_event_id) - .map_err(|_| Phase1DraftError::InvalidRevision)?; - if event_id.to_hex() != self.source_event_id { - return Err(Phase1DraftError::InvalidRevision); - } - Nip09DeletionEventTarget::parse(&self.source_event_id, self.source_kind) - .map_err(|_| Phase1DraftError::InvalidRevision)?; - let addressable = matches!(self.source_kind, 30_402 | 31_922 | 31_923); - let source = if addressable { - let address = self - .source_address - .as_deref() - .ok_or(Phase1DraftError::InvalidRevision)?; - Nip09DeletionAddressTarget::parse(address) - .map_err(|_| Phase1DraftError::InvalidRevision)?; - let (kind, author, _) = parse_address(address)?; - if kind != self.source_kind || author != self.author_public_key { - return Err(Phase1DraftError::InvalidRevision); - } - let (_, _, identifier) = parse_address(address)?; - if format!("{kind}:{author}:{identifier}") != address { - return Err(Phase1DraftError::InvalidRevision); - } - CardSourceIdentity::address(kind, author, identifier) - .map_err(|_| Phase1DraftError::InvalidRevision)? - } else if self.source_kind != 1 || self.source_address.is_some() { - return Err(Phase1DraftError::InvalidRevision); - } else { - CardSourceIdentity::Event(event_id) - }; - let command_matches = match self.command_type { - AddCommandType::CreateUpdate - | AddCommandType::CreatePhotoUpdate - | AddCommandType::CreateAsk => self.source_kind == 1, - AddCommandType::CreateEvent => matches!(self.source_kind, 31_922 | 31_923), - AddCommandType::CreateFoodAvailability => self.source_kind == 30_402, - }; - let card_type = match self.command_type { - AddCommandType::CreateUpdate => TodayCardType::Update, - AddCommandType::CreatePhotoUpdate => TodayCardType::PhotoUpdate, - AddCommandType::CreateAsk => TodayCardType::Ask, - AddCommandType::CreateEvent => TodayCardType::Event, - AddCommandType::CreateFoodAvailability => TodayCardType::FoodAvailability, - }; - (command_matches && CardId::derive(card_type, &source) == self.card_id) - .then_some(()) - .ok_or(Phase1DraftError::InvalidRevision) - } - - pub const fn command_type(&self) -> AddCommandType { - self.command_type - } - - pub const fn card_id(&self) -> CardId { - self.card_id - } - - pub fn source_event_id(&self) -> &str { - self.source_event_id.as_str() - } - - pub const fn source_kind(&self) -> u32 { - self.source_kind - } - - pub fn source_address(&self) -> Option<&str> { - self.source_address.as_deref() - } -} - -/// Protocol-correct ordering selected from the source event kind. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum Phase1RevisionPolicy { - ReplaceThenRetract, - AddressableReplacement, -} - -/// One complete replacement intent. The form is the canonical lossless reopen -/// snapshot; the command is its validated authored representation. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Phase1ReviseIntent { - target: Phase1RevisionTarget, - command: Phase1AddCommand, - media: Vec<Phase1MediaPrerequisite>, - form: Phase1DraftFormSnapshot, -} - -impl Phase1ReviseIntent { - pub fn new( - target: Phase1RevisionTarget, - command: Phase1AddCommand, - media: Vec<Phase1MediaPrerequisite>, - form: Phase1DraftFormSnapshot, - ) -> Result<Self, Phase1DraftError> { - target.validate()?; - if media.len() > DRAFT_MEDIA_MAX { - return Err(Phase1DraftError::InvalidMedia); - } - form.validate(command.command_type(), &media)?; - let same_family = match (target.command_type(), command.command_type()) { - ( - AddCommandType::CreateUpdate - | AddCommandType::CreatePhotoUpdate - | AddCommandType::CreateAsk, - AddCommandType::CreateUpdate - | AddCommandType::CreatePhotoUpdate - | AddCommandType::CreateAsk, - ) => true, - (left, right) => left == right, - }; - if !same_family { - return Err(Phase1DraftError::InvalidRevision); - } - Ok(Self { - target, - command, - media, - form, - }) - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -struct Phase1RevisionRecord { - target: Phase1RevisionTarget, - policy: Phase1RevisionPolicy, - retraction_draft_id: Option<[u8; 16]>, -} - -/// Honest aggregate state for a durable revision and its ordered child work. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Phase1RevisionPhase { - ReplacementPending, - ReplacementFailed, - RetractionPending, - Complete, - PartialEffect, - Cancelled, -} - -#[derive(Clone, Debug)] -pub struct Phase1RevisionStatus { - replacement: Phase1DraftStatus, - retraction: Option<Phase1DraftStatus>, - target: Phase1RevisionTarget, - policy: Phase1RevisionPolicy, - phase: Phase1RevisionPhase, -} - -/// Durable kind-0 profile publication state. The profile fields remain inside -/// the canonical authored plan and are never duplicated in outbox metadata. -#[derive(Clone, Debug)] -pub struct Phase1ProfileStatus { - draft: AuthoredDraft, - state: Phase1OutboxState, - push: Option<PushStatus>, -} - -impl Phase1ProfileStatus { - pub const fn draft(&self) -> &AuthoredDraft { - &self.draft - } - - pub const fn state(&self) -> Phase1OutboxState { - self.state - } - - pub const fn push(&self) -> Option<&PushStatus> { - self.push.as_ref() - } -} - -impl Phase1RevisionStatus { - pub const fn replacement(&self) -> &Phase1DraftStatus { - &self.replacement - } - - pub const fn retraction(&self) -> Option<&Phase1DraftStatus> { - self.retraction.as_ref() - } - - pub const fn target(&self) -> &Phase1RevisionTarget { - &self.target - } - - pub const fn policy(&self) -> Phase1RevisionPolicy { - self.policy - } - - pub const fn phase(&self) -> Phase1RevisionPhase { - self.phase - } -} - -impl Phase1UploadPlan { - fn derive( - now_unix_ms: u64, - operation_id: [u8; 16], - artifact_id: [u8; 16], - ) -> Result<Self, Phase1DraftError> { - let now_unix_s = now_unix_ms / 1_000; - if now_unix_s == 0 { - return Err(Phase1DraftError::ClockUnavailable); - } - Ok(Self { - authorization_content: BLOSSOM_AUTHORIZATION_CONTENT.to_owned(), - authorization_created_at_unix_s: now_unix_s - .saturating_sub(BLOSSOM_AUTHORIZATION_BACKDATE_SECONDS), - authorization_lifetime_seconds: BLOSSOM_AUTHORIZATION_LIFETIME_SECONDS, - operation_id, - artifact_id, - signing_deadline_unix_ms: now_unix_ms - .checked_add(BLOSSOM_SIGNING_TIMEOUT_MS) - .ok_or(Phase1DraftError::DeadlineOverflow)?, - cancellation: Phase1CancellationPolicy::LocalCooperative, - updated_at_unix_ms: now_unix_ms, - }) - } -} - -/// Honest aggregate state for a local draft and its durable authored operation. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Phase1OutboxState { - Draft, - MediaPreparing, - MediaUploading, - ReadyToSign, - Signing, - Signed, - Queued, - Delivering, - PartiallyDelivered, - Retryable, - Terminal, - Cancelled, - Complete, -} - -impl Phase1OutboxState { - pub const fn label(self) -> &'static str { - match self { - Self::Draft => "draft", - Self::MediaPreparing => "media_preparing", - Self::MediaUploading => "media_uploading", - Self::ReadyToSign => "ready_to_sign", - Self::Signing => "signing", - Self::Signed => "signed", - Self::Queued => "queued", - Self::Delivering => "delivering", - Self::PartiallyDelivered => "partially_delivered", - Self::Retryable => "retryable", - Self::Terminal => "terminal", - Self::Cancelled => "cancelled", - Self::Complete => "complete", - } - } -} - -/// Current reconstructable product view of one Add draft. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Phase1DraftStatus { - draft: AuthoredDraft, - kind: Phase1DraftKind, - command_type: AddCommandType, - form: Option<Phase1DraftFormSnapshot>, - media: Vec<Phase1MediaPrerequisite>, - state: Phase1OutboxState, - card_id: CardId, - push: Option<PushStatus>, - revision_policy: Option<Phase1RevisionPolicy>, -} - -impl Phase1DraftStatus { - pub const fn draft(&self) -> &AuthoredDraft { - &self.draft - } - pub const fn command_type(&self) -> AddCommandType { - self.command_type - } - pub const fn kind(&self) -> Phase1DraftKind { - self.kind - } - pub const fn form(&self) -> Option<&Phase1DraftFormSnapshot> { - self.form.as_ref() - } - pub fn media(&self) -> &[Phase1MediaPrerequisite] { - self.media.as_slice() - } - pub const fn state(&self) -> Phase1OutboxState { - self.state - } - pub const fn card_id(&self) -> CardId { - self.card_id - } - pub const fn push(&self) -> Option<&PushStatus> { - self.push.as_ref() - } - pub const fn revision_policy(&self) -> Option<Phase1RevisionPolicy> { - self.revision_policy - } -} - -#[derive(Clone, Debug, Error, Eq, PartialEq)] -pub enum Phase1DraftError { - #[error("authenticated draft identity is unavailable")] - IdentityUnavailable, - #[error("phase 1 draft input is invalid")] - InvalidDraft, - #[error("phase 1 media prerequisite is invalid")] - InvalidMedia, - #[error("phase 1 queue policy is invalid")] - InvalidQueuePolicy, - #[error("phase 1 draft revision conflicts with durable state")] - RevisionConflict, - #[error("phase 1 draft is not found")] - NotFound, - #[error("phase 1 draft is terminal")] - Terminal, - #[error("phase 1 draft media is not ready")] - MediaNotReady, - #[error("phase 1 authored operation is unavailable")] - OperationUnavailable, - #[error("phase 1 draft persistence failed")] - Storage, - #[error("phase 1 draft payload is corrupt")] - Corrupt, - #[error("phase 1 authored operation failed")] - Operation, - #[error("phase 1 Today overlay failed")] - Overlay, - #[error("phase 1 operation clock is unavailable")] - ClockUnavailable, - #[error("phase 1 operation deadline overflowed")] - DeadlineOverflow, - #[error("no configured relay authorizes publication")] - NoWritableRelay, - #[error("phase 1 revision input or ordering is invalid")] - InvalidRevision, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -struct Phase1DraftPayload { - schema_version: u16, - #[serde(default)] - kind: Phase1DraftKind, - command_type: AddCommandType, - #[serde(default)] - form: Option<Phase1DraftFormSnapshot>, - #[serde(default)] - target_card_id: Option<CardId>, - plan_wire_json: Vec<u8>, - media: Vec<Phase1MediaPrerequisite>, - queue: Option<Phase1QueuePolicy>, - #[serde(default)] - revision: Option<Phase1RevisionRecord>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -struct Phase1ProfilePayload { - schema_version: u16, - plan_wire_json: Vec<u8>, - queue: Option<Phase1QueuePolicy>, -} - -impl Phase1ProfilePayload { - fn new(plan_wire_json: Vec<u8>) -> Result<Self, Phase1DraftError> { - let value = Self { - schema_version: DRAFT_SCHEMA_VERSION, - plan_wire_json, - queue: None, - }; - value.validate()?; - Ok(value) - } - - fn validate(&self) -> Result<(), Phase1DraftError> { - if self.schema_version != DRAFT_SCHEMA_VERSION { - return Err(Phase1DraftError::Corrupt); - } - let plan = PlanWireV1::from_json(self.plan_wire_json.as_slice()) - .map_err(|_| Phase1DraftError::Corrupt)?; - if plan.plan().body().kind() != 0 { - return Err(Phase1DraftError::Corrupt); - } - if let Some(queue) = &self.queue { - queue.materialize()?; - } - Ok(()) - } - - fn decode(draft: &AuthoredDraft) -> Result<Self, Phase1DraftError> { - if draft.payload_schema() != PROFILE_PAYLOAD_SCHEMA { - return Err(Phase1DraftError::Corrupt); - } - let value = serde_json::from_slice::<Self>(draft.payload()) - .map_err(|_| Phase1DraftError::Corrupt)?; - value.validate()?; - let plan = PlanWireV1::from_json(value.plan_wire_json.as_slice()) - .map_err(|_| Phase1DraftError::Corrupt)?; - if plan.plan().author().as_bytes() != draft.author() { - return Err(Phase1DraftError::Corrupt); - } - Ok(value) - } - - fn encode(&self) -> Result<Vec<u8>, Phase1DraftError> { - self.validate()?; - serde_json::to_vec(self).map_err(|_| Phase1DraftError::InvalidDraft) - } -} - -impl Phase1DraftPayload { - fn new( - command: &Phase1AddCommand, - plan_wire_json: Vec<u8>, - media: Vec<Phase1MediaPrerequisite>, - form: Option<Phase1DraftFormSnapshot>, - ) -> Result<Self, Phase1DraftError> { - let value = Self { - schema_version: DRAFT_SCHEMA_VERSION, - kind: Phase1DraftKind::Add, - command_type: command.command_type(), - form, - target_card_id: None, - plan_wire_json, - media, - queue: None, - revision: None, - }; - value.validate()?; - Ok(value) - } - - fn retraction( - command_type: AddCommandType, - target_card_id: CardId, - plan_wire_json: Vec<u8>, - ) -> Result<Self, Phase1DraftError> { - let value = Self { - schema_version: DRAFT_SCHEMA_VERSION, - kind: Phase1DraftKind::Retraction, - command_type, - form: None, - target_card_id: Some(target_card_id), - plan_wire_json, - media: Vec::new(), - queue: None, - revision: None, - }; - value.validate()?; - Ok(value) - } - - fn validate(&self) -> Result<(), Phase1DraftError> { - if self.schema_version != DRAFT_SCHEMA_VERSION || self.media.len() > DRAFT_MEDIA_MAX { - return Err(Phase1DraftError::Corrupt); - } - match self.kind { - Phase1DraftKind::Add => { - if self.target_card_id.is_some() { - return Err(Phase1DraftError::Corrupt); - } - if let Some(form) = &self.form { - form.validate(self.command_type, &self.media)?; - } - } - Phase1DraftKind::Retraction => { - if self.form.is_some() - || self.target_card_id.is_none() - || !self.media.is_empty() - || self.revision.is_some() - { - return Err(Phase1DraftError::Corrupt); - } - } - } - let integrity = PlanWireV1::from_json(self.plan_wire_json.as_slice()) - .map_err(|_| Phase1DraftError::Corrupt)?; - let plan = integrity.plan(); - if let Some(revision) = &self.revision { - revision.target.validate()?; - if self.kind != Phase1DraftKind::Add { - return Err(Phase1DraftError::Corrupt); - } - let expected_policy = if revision.target.source_kind == 1 { - Phase1RevisionPolicy::ReplaceThenRetract - } else { - Phase1RevisionPolicy::AddressableReplacement - }; - if revision.policy != expected_policy - || (expected_policy == Phase1RevisionPolicy::ReplaceThenRetract) - != revision.retraction_draft_id.is_some() - { - return Err(Phase1DraftError::Corrupt); - } - if let Some(child_id) = revision.retraction_draft_id { - AuthoredDraftId::new(child_id).map_err(|_| Phase1DraftError::Corrupt)?; - } - if expected_policy == Phase1RevisionPolicy::AddressableReplacement - && (plan.body().kind() != revision.target.source_kind - || card_id(self.command_type, plan)? != revision.target.card_id) - { - return Err(Phase1DraftError::InvalidRevision); - } - if expected_policy == Phase1RevisionPolicy::ReplaceThenRetract - && plan.body().kind() != 1 - { - return Err(Phase1DraftError::InvalidRevision); - } - } - let expected_media = media_urls(plan.body().tags())?; - let actual_media = self - .media - .iter() - .map(|media| { - media.validate()?; - Ok(media.url.as_str()) - }) - .collect::<Result<BTreeSet<_>, Phase1DraftError>>()?; - if expected_media != actual_media || actual_media.len() != self.media.len() { - return Err(Phase1DraftError::InvalidMedia); - } - if let Some(queue) = &self.queue { - queue.materialize()?; - } - Ok(()) - } - - fn decode(draft: &AuthoredDraft) -> Result<Self, Phase1DraftError> { - if draft.payload_schema() != DRAFT_PAYLOAD_SCHEMA { - return Err(Phase1DraftError::Corrupt); - } - let value = serde_json::from_slice::<Self>(draft.payload()) - .map_err(|_| Phase1DraftError::Corrupt)?; - value.validate()?; - let plan = PlanWireV1::from_json(value.plan_wire_json.as_slice()) - .map_err(|_| Phase1DraftError::Corrupt)?; - if plan.plan().author().as_bytes() != draft.author() { - return Err(Phase1DraftError::Corrupt); - } - Ok(value) - } - - fn encode(&self) -> Result<Vec<u8>, Phase1DraftError> { - self.validate()?; - serde_json::to_vec(self).map_err(|_| Phase1DraftError::InvalidDraft) - } -} - -impl RadrootsRuntime { - /// Persists a strict kind-0 profile intent before any signing or network - /// side effect. Identity and timestamps remain Rust-owned. - pub async fn phase1_save_profile_metadata( - &self, - command: ProfileMetadataCommand, - ) -> Result<Phase1ProfileStatus, Phase1DraftError> { - let now_unix_ms = phase1_operation_now_unix_ms()?; - let author = self.draft_author()?; - let draft_id = AuthoredDraftId::new(phase1_random_id()?) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let plan = AuthoredEventPlan::from_profile( - command.authored(), - now_unix_ms / 1_000, - hex::encode(author), - ) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let wire = PlanWireV1::from_plan(&plan) - .to_json() - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let payload = Phase1ProfilePayload::new(wire)?; - let draft = AuthoredDraft::initial( - draft_id, - author, - PROFILE_PAYLOAD_SCHEMA, - payload.encode()?, - AuthoredDraftStage::Draft, - None, - now_unix_ms, - ) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let receipt = self - .storage()? - .append_authored_draft(draft, None) - .await - .map_err(map_draft_storage_error)?; - self.profile_status_from(receipt.draft().clone()).await - } - - pub async fn phase1_profile_status( - &self, - draft_id: [u8; 16], - ) -> Result<Phase1ProfileStatus, Phase1DraftError> { - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let head = self - .storage()? - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - self.profile_status_from(head).await - } - - /// Recovers and advances one profile operation through the same durable - /// sign/admit/deliver engine used by Add without exposing queue policy. - pub async fn phase1_advance_profile( - &self, - draft_id: [u8; 16], - ) -> Result<Phase1ProfileStatus, Phase1DraftError> { - let mut status = self.phase1_profile_status(draft_id).await?; - if status.draft.stage() == AuthoredDraftStage::Draft { - status = self - .phase1_queue_profile(draft_id, status.draft.revision().get()) - .await?; - } else if status.draft.stage() == AuthoredDraftStage::ReadyToSign { - status = self - .finish_profile_queue(status.draft.clone(), phase1_operation_now_unix_ms()?) - .await?; - } - if status.draft.stage() != AuthoredDraftStage::Queued - || matches!( - status.state, - Phase1OutboxState::Complete - | Phase1OutboxState::Terminal - | Phase1OutboxState::Cancelled - ) - { - return Ok(status); - } - let request = profile_push_request(&status.draft)?; - let operation_id = request.operation_id(); - let sync = self.sync()?; - let mut push = sync - .push_status(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)? - .ok_or(Phase1DraftError::Corrupt)?; - if matches!( - push.artifact().signing_state(), - SigningState::Planned | SigningState::Retryable - ) { - sync.sign_prepared(request) - .await - .map_err(|_| Phase1DraftError::Operation)?; - push = sync - .push_status(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)? - .ok_or(Phase1DraftError::Corrupt)?; - } - if push.artifact().signing_state() == SigningState::Signed - && matches!( - push.artifact().admission_state(), - AdmissionState::Pending | AdmissionState::Retryable - ) - { - sync.admit_signed(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)?; - push = sync - .push_status(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)? - .ok_or(Phase1DraftError::Corrupt)?; - } - if push.artifact().admission_state().is_admitted() - && matches!( - push.delivery_plan().state(), - AuthoredDeliveryState::Pending | AuthoredDeliveryState::Retryable - ) - { - sync.deliver_push(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)?; - } - self.phase1_profile_status(draft_id).await - } - - pub async fn phase1_cancel_profile( - &self, - draft_id: [u8; 16], - expected_revision: u64, - ) -> Result<Phase1ProfileStatus, Phase1DraftError> { - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let expected = AuthoredDraftRevision::new(expected_revision) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let storage = self.storage()?; - let head = storage - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - Phase1ProfilePayload::decode(&head)?; - if head.stage() == AuthoredDraftStage::Cancelled { - return self.profile_status_from(head).await; - } - if head.revision() != expected { - return Err(Phase1DraftError::RevisionConflict); - } - let push = self.profile_push_status_for(&head).await?; - if let Some(status) = push { - if status.settlement().is_successful() { - return Err(Phase1DraftError::Terminal); - } - self.sync()? - .cancel_push(sync_id_for(&head)?) - .await - .map_err(|_| Phase1DraftError::Operation)?; - } - let next = head - .successor( - head.payload().to_vec(), - AuthoredDraftStage::Cancelled, - head.operation_id(), - phase1_operation_now_unix_ms()?, - ) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let receipt = storage - .append_authored_draft(next, Some(expected)) - .await - .map_err(map_draft_storage_error)?; - self.profile_status_from(receipt.draft().clone()).await - } - - /// Persists one complete Add intent with Rust-owned identity and time. - pub async fn phase1_save_add_intent( - &self, - intent: Phase1AddIntent, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let now_unix_ms = phase1_operation_now_unix_ms()?; - let authored_at_unix_s = now_unix_ms / 1_000; - let (draft_id, expected_revision) = match intent.existing { - Some(existing) => (existing.draft_id, Some(existing.expected_revision)), - None => (phase1_random_id()?, None), - }; - self.phase1_save_draft_with_form( - draft_id, - intent.command, - authored_at_unix_s, - intent.media, - intent.form, - expected_revision, - now_unix_ms, - ) - .await - } - - /// Freezes the canonical Rust-owned queue policy for the active relay - /// profile before preparing the durable outbox operation. - pub async fn phase1_queue_add_intent( - &self, - intent: Phase1QueueIntent, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let now_unix_ms = phase1_operation_now_unix_ms()?; - let policy = self.active_queue_policy(now_unix_ms)?; - self.phase1_queue_draft( - intent.draft_id, - intent.expected_revision, - policy, - now_unix_ms, - ) - .await - } - - fn active_queue_policy(&self, now_unix_ms: u64) -> Result<Phase1QueuePolicy, Phase1DraftError> { - let report = self - .client - .nostr_status() - .map_err(|_| Phase1DraftError::OperationUnavailable)? - .ok_or(Phase1DraftError::OperationUnavailable)?; - let relay_urls = report - .relays() - .iter() - .filter(|relay| relay.endpoint().access().can_write()) - .map(|relay| relay.endpoint().url().as_str().to_owned()) - .collect::<Vec<_>>(); - if relay_urls.is_empty() { - return Err(Phase1DraftError::NoWritableRelay); - } - let deadline = now_unix_ms - .checked_add(ADD_DELIVERY_TIMEOUT_MS) - .ok_or(Phase1DraftError::DeadlineOverflow)?; - Phase1QueuePolicy::new( - relay_urls, - Phase1RelaySatisfaction::AllAccepted, - deadline, - Phase1CancellationPolicy::LocalCooperative, - ) - } - - /// Resumes a durable queue checkpoint with a Rust-owned recovery time. - pub async fn phase1_recover_add_intent( - &self, - draft_id: [u8; 16], - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - self.phase1_recover_draft_queue(draft_id, phase1_operation_now_unix_ms()?) - .await - } - - /// Plans authorization, signing, and state timestamps in Rust, then runs - /// one complete exact-byte upload attempt. - pub async fn phase1_upload_add_media_intent( - &self, - intent: Phase1UploadIntent, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let now_unix_ms = phase1_operation_now_unix_ms()?; - let plan = Phase1UploadPlan::derive(now_unix_ms, phase1_random_id()?, phase1_random_id()?)?; - let request = radroots_sdk::transport::BlossomUploadRequest::new( - intent.bytes, - intent.media_type, - intent.dimensions, - now_unix_ms, - ) - .map_err(|_| Phase1DraftError::InvalidMedia)?; - let content = radroots_blossom::authorization::AuthorizationContent::parse( - &plan.authorization_content, - ) - .map_err(|_| Phase1DraftError::InvalidMedia)?; - self.phase1_upload_draft_media( - intent.draft_id, - intent.expected_revision, - request, - content, - plan.authorization_created_at_unix_s, - plan.authorization_lifetime_seconds, - plan.operation_id, - plan.artifact_id, - plan.signing_deadline_unix_ms, - plan.cancellation, - radroots_sdk::transport::BlossomCancellation::default(), - plan.updated_at_unix_ms, - ) - .await - } - - /// Persists the upload transition and returns one immutable native job. - /// The authorization header is deliberately absent from durable draft - /// state and must never be persisted by the host. - pub async fn phase1_prepare_native_upload( - &self, - intent: Phase1UploadIntent, - ) -> Result<(Phase1DraftStatus, Phase1NativeUploadJob), Phase1DraftError> { - let now_unix_ms = phase1_operation_now_unix_ms()?; - let plan = Phase1UploadPlan::derive(now_unix_ms, phase1_random_id()?, phase1_random_id()?)?; - let request = radroots_sdk::transport::BlossomUploadRequest::new( - intent.bytes, - intent.media_type, - intent.dimensions, - now_unix_ms, - ) - .map_err(|_| Phase1DraftError::InvalidMedia)?; - let blossom = self - .client - .blossom() - .map_err(|_| Phase1DraftError::OperationUnavailable)? - .ok_or(Phase1DraftError::OperationUnavailable)?; - let transaction = blossom - .prepare_upload(request) - .map_err(|_| Phase1DraftError::Operation)?; - let remote_url = transaction.expected_url().as_str().to_owned(); - let content = radroots_blossom::authorization::AuthorizationContent::parse( - &plan.authorization_content, - ) - .map_err(|_| Phase1DraftError::InvalidMedia)?; - let claim = blossom - .authored_upload_claim( - &transaction, - content, - plan.authorization_created_at_unix_s, - plan.authorization_lifetime_seconds, - ) - .map_err(|_| Phase1DraftError::Operation)?; - let authorization = self - .phase1_authorize_blossom_upload( - plan.operation_id, - plan.artifact_id, - claim, - plan.signing_deadline_unix_ms, - plan.cancellation, - ) - .await?; - let uploading = self - .phase1_update_draft_media( - intent.draft_id, - intent.expected_revision, - remote_url.as_str(), - Phase1MediaStage::Uploading, - None, - now_unix_ms, - ) - .await?; - Ok(( - uploading, - Phase1NativeUploadJob { - operation_id: plan.operation_id, - remote_url, - authorization_header: authorization.into_string(), - expected_sha256: transaction.request().sha256().to_string(), - media_type: transaction.request().media_type().as_str().to_owned(), - byte_size: transaction.request().byte_size(), - }, - )) - } - - /// Verifies a native BUD-02 response and canonical BUD-01 retrieval before - /// advancing the durable media prerequisite. - pub async fn phase1_complete_native_upload( - &self, - intent: Phase1UploadIntent, - status_code: u16, - response_media_type: Option<&str>, - response_content_encoding: Option<&str>, - response_body: &[u8], - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let now_unix_ms = phase1_operation_now_unix_ms()?; - let request = radroots_sdk::transport::BlossomUploadRequest::new( - intent.bytes, - intent.media_type, - intent.dimensions, - now_unix_ms, - ) - .map_err(|_| Phase1DraftError::InvalidMedia)?; - let blossom = self - .client - .blossom() - .map_err(|_| Phase1DraftError::OperationUnavailable)? - .ok_or(Phase1DraftError::OperationUnavailable)?; - let transaction = blossom - .prepare_upload(request) - .map_err(|_| Phase1DraftError::Operation)?; - let url = transaction.expected_url().as_str().to_owned(); - match blossom - .complete_native_upload( - transaction, - status_code, - response_media_type, - response_content_encoding, - response_body, - radroots_sdk::transport::BlossomCancellation::default(), - ) - .await - { - Ok(receipt) => { - self.phase1_complete_draft_media( - intent.draft_id, - intent.expected_revision, - url.as_str(), - receipt, - now_unix_ms, - ) - .await - } - Err(error) => { - self.phase1_fail_draft_media( - intent.draft_id, - intent.expected_revision, - url.as_str(), - &error, - now_unix_ms, - ) - .await?; - Err(Phase1DraftError::Operation) - } - } - } - - /// Cancels local work with a Rust-owned transition timestamp. - pub async fn phase1_cancel_add_intent( - &self, - draft_id: [u8; 16], - expected_revision: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - self.phase1_cancel_draft(draft_id, expected_revision, phase1_operation_now_unix_ms()?) - .await - } - - /// Persists the replacement half of one revision before any retraction can - /// exist. Standard kind-1 revisions receive a deterministic child draft ID - /// that remains inert until replacement settlement succeeds. - pub async fn phase1_save_revision_intent( - &self, - intent: Phase1ReviseIntent, - ) -> Result<Phase1RevisionStatus, Phase1DraftError> { - let now_unix_ms = phase1_operation_now_unix_ms()?; - let authored_at_unix_s = now_unix_ms / 1_000; - let author = self.draft_author()?; - if intent.target.author_public_key != hex::encode(author) { - return Err(Phase1DraftError::InvalidRevision); - } - let draft_id = AuthoredDraftId::new(phase1_random_id()?) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let plan = intent - .command - .authored_plan(authored_at_unix_s, hex::encode(author)) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let wire = PlanWireV1::from_plan(&plan) - .to_json() - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let policy = if intent.target.source_kind == 1 { - Phase1RevisionPolicy::ReplaceThenRetract - } else { - Phase1RevisionPolicy::AddressableReplacement - }; - let retraction_draft_id = match policy { - Phase1RevisionPolicy::ReplaceThenRetract => { - let child_id = phase1_random_id()?; - if child_id == *draft_id.as_bytes() { - return Err(Phase1DraftError::OperationUnavailable); - } - Some(child_id) - } - Phase1RevisionPolicy::AddressableReplacement => None, - }; - let mut payload = - Phase1DraftPayload::new(&intent.command, wire, intent.media, Some(intent.form))?; - payload.revision = Some(Phase1RevisionRecord { - target: intent.target, - policy, - retraction_draft_id, - }); - let bytes = payload.encode()?; - let draft = AuthoredDraft::initial( - draft_id, - author, - DRAFT_PAYLOAD_SCHEMA, - bytes, - draft_stage_for_media(&payload.media), - None, - now_unix_ms, - ) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - self.storage()? - .append_authored_draft(draft, None) - .await - .map_err(map_draft_storage_error)?; - self.phase1_revision_status(*draft_id.as_bytes()).await - } - - /// Reconstructs the complete ordered revision from durable replacement and - /// optional retraction child state after any process boundary. - pub async fn phase1_revision_status( - &self, - replacement_draft_id: [u8; 16], - ) -> Result<Phase1RevisionStatus, Phase1DraftError> { - let replacement = self.phase1_draft_status(replacement_draft_id).await?; - let payload = Phase1DraftPayload::decode(replacement.draft())?; - let revision = payload.revision.ok_or(Phase1DraftError::InvalidRevision)?; - let retraction = match revision.retraction_draft_id { - Some(id) => match self.phase1_draft_status(id).await { - Ok(status) => { - validate_revision_retraction(&status, &revision.target)?; - Some(status) - } - Err(Phase1DraftError::NotFound) => None, - Err(error) => return Err(error), - }, - None => None, - }; - let phase = revision_phase(&replacement, retraction.as_ref(), revision.policy); - Ok(Phase1RevisionStatus { - replacement, - retraction, - target: revision.target, - policy: revision.policy, - phase, - }) - } - - /// Advances the replacement first and creates the NIP-09 child only after - /// all configured replacement delivery targets accepted it. - pub async fn phase1_advance_revision( - &self, - replacement_draft_id: [u8; 16], - ) -> Result<Phase1RevisionStatus, Phase1DraftError> { - let mut status = self.phase1_revision_status(replacement_draft_id).await?; - if matches!( - status.replacement.state(), - Phase1OutboxState::Draft | Phase1OutboxState::ReadyToSign - ) { - self.phase1_queue_add_intent(Phase1QueueIntent::new( - replacement_draft_id, - status.replacement.draft().revision().get(), - )?) - .await?; - status = self.phase1_revision_status(replacement_draft_id).await?; - } - if matches!( - status.replacement.state(), - Phase1OutboxState::Queued - | Phase1OutboxState::Retryable - | Phase1OutboxState::PartiallyDelivered - ) { - self.phase1_advance_draft( - replacement_draft_id, - status.replacement.draft().revision().get(), - ) - .await?; - status = self.phase1_revision_status(replacement_draft_id).await?; - } - if status.replacement.state() != Phase1OutboxState::Complete - || status.policy != Phase1RevisionPolicy::ReplaceThenRetract - { - return Ok(status); - } - - let child_id = - revision_child_id(status.replacement.draft())?.ok_or(Phase1DraftError::Corrupt)?; - if status.retraction.is_none() { - self.phase1_create_revision_retraction(&status.target, child_id) - .await?; - status = self.phase1_revision_status(replacement_draft_id).await?; - } - let child = status - .retraction - .as_ref() - .ok_or(Phase1DraftError::Corrupt)?; - if matches!( - child.state(), - Phase1OutboxState::Draft | Phase1OutboxState::ReadyToSign - ) { - self.phase1_queue_add_intent(Phase1QueueIntent::new( - child_id, - child.draft().revision().get(), - )?) - .await?; - status = self.phase1_revision_status(replacement_draft_id).await?; - } - let child = status - .retraction - .as_ref() - .ok_or(Phase1DraftError::Corrupt)?; - if matches!( - child.state(), - Phase1OutboxState::Queued - | Phase1OutboxState::Retryable - | Phase1OutboxState::PartiallyDelivered - ) { - self.phase1_advance_draft(child_id, child.draft().revision().get()) - .await?; - } - self.phase1_revision_status(replacement_draft_id).await - } - - /// Cancels only still-pending work. If a kind-1 replacement is already - /// visible, a cancelled child records the deliberate partial effect and - /// prevents a later recovery from retracting the original unexpectedly. - pub async fn phase1_cancel_revision( - &self, - replacement_draft_id: [u8; 16], - ) -> Result<Phase1RevisionStatus, Phase1DraftError> { - let mut status = self.phase1_revision_status(replacement_draft_id).await?; - if !matches!( - status.replacement.state(), - Phase1OutboxState::Complete - | Phase1OutboxState::Terminal - | Phase1OutboxState::Cancelled - ) { - self.phase1_cancel_add_intent( - replacement_draft_id, - status.replacement.draft().revision().get(), - ) - .await?; - return self.phase1_revision_status(replacement_draft_id).await; - } - if status.replacement.state() == Phase1OutboxState::Complete - && status.policy == Phase1RevisionPolicy::ReplaceThenRetract - { - let child_id = - revision_child_id(status.replacement.draft())?.ok_or(Phase1DraftError::Corrupt)?; - if status.retraction.is_none() { - self.phase1_create_revision_retraction(&status.target, child_id) - .await?; - status = self.phase1_revision_status(replacement_draft_id).await?; - } - let child = status - .retraction - .as_ref() - .ok_or(Phase1DraftError::Corrupt)?; - if !matches!( - child.state(), - Phase1OutboxState::Complete - | Phase1OutboxState::Terminal - | Phase1OutboxState::Cancelled - ) { - self.phase1_cancel_add_intent(child_id, child.draft().revision().get()) - .await?; - } - } - self.phase1_revision_status(replacement_draft_id).await - } - - async fn phase1_create_revision_retraction( - &self, - target: &Phase1RevisionTarget, - draft_id: [u8; 16], - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - match self.phase1_draft_status(draft_id).await { - Ok(existing) => return Ok(existing), - Err(Phase1DraftError::NotFound) => {} - Err(error) => return Err(error), - } - let now_unix_ms = phase1_operation_now_unix_ms()?; - self.phase1_save_retraction_draft( - draft_id, - target.command_type, - target.card_id, - &target.source_event_id, - target.source_kind, - target.source_address.as_deref(), - REVISION_RETRACTION_REASON, - now_unix_ms / 1_000, - now_unix_ms, - ) - .await - } - - /// Creates or replaces the editable content of one immutable-revision draft. - #[allow(clippy::too_many_arguments)] - pub async fn phase1_save_draft( - &self, - draft_id: [u8; 16], - command: Phase1AddCommand, - authored_at_unix_s: u64, - media: Vec<Phase1MediaPrerequisite>, - expected_revision: Option<u64>, - persisted_at_unix_ms: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - self.phase1_save_draft_inner( - draft_id, - command, - authored_at_unix_s, - media, - None, - expected_revision, - persisted_at_unix_ms, - ) - .await - } - - /// Creates or replaces a draft while retaining its validated, reopenable form. - #[allow(clippy::too_many_arguments)] - pub async fn phase1_save_draft_with_form( - &self, - draft_id: [u8; 16], - command: Phase1AddCommand, - authored_at_unix_s: u64, - media: Vec<Phase1MediaPrerequisite>, - form: Phase1DraftFormSnapshot, - expected_revision: Option<u64>, - persisted_at_unix_ms: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - self.phase1_save_draft_inner( - draft_id, - command, - authored_at_unix_s, - media, - Some(form), - expected_revision, - persisted_at_unix_ms, - ) - .await - } - - #[allow(clippy::too_many_arguments)] - async fn phase1_save_draft_inner( - &self, - draft_id: [u8; 16], - command: Phase1AddCommand, - authored_at_unix_s: u64, - media: Vec<Phase1MediaPrerequisite>, - form: Option<Phase1DraftFormSnapshot>, - expected_revision: Option<u64>, - persisted_at_unix_ms: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let author = self.draft_author()?; - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let plan = command - .authored_plan(authored_at_unix_s, hex::encode(author)) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let wire = PlanWireV1::from_plan(&plan) - .to_json() - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let payload = Phase1DraftPayload::new(&command, wire, media, form)?; - let bytes = payload.encode()?; - let storage = self.storage()?; - let expected = expected_revision - .map(AuthoredDraftRevision::new) - .transpose() - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let draft = if let Some(expected) = expected { - let head = storage - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - if head.revision() != expected - || head.stage().is_terminal() - || matches!( - head.stage(), - AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued - ) - { - return Err(Phase1DraftError::RevisionConflict); - } - head.successor( - bytes, - draft_stage_for_media(&payload.media), - None, - persisted_at_unix_ms, - ) - .map_err(|_| Phase1DraftError::RevisionConflict)? - } else { - AuthoredDraft::initial( - draft_id, - author, - DRAFT_PAYLOAD_SCHEMA, - bytes, - draft_stage_for_media(&payload.media), - None, - persisted_at_unix_ms, - ) - .map_err(|_| Phase1DraftError::InvalidDraft)? - }; - let receipt = storage - .append_authored_draft(draft, expected) - .await - .map_err(map_draft_storage_error)?; - self.draft_status_from(receipt.draft().clone()).await - } - - /// Persists an independent strict NIP-09 retraction as a normal durable outbox item. - #[allow(clippy::too_many_arguments)] - pub async fn phase1_save_retraction_draft( - &self, - draft_id: [u8; 16], - command_type: AddCommandType, - target_card_id: CardId, - target_event_id: &str, - target_kind: u32, - target_address: Option<&str>, - reason: &str, - authored_at_unix_s: u64, - persisted_at_unix_ms: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let target_shape_valid = match command_type { - AddCommandType::CreateUpdate - | AddCommandType::CreatePhotoUpdate - | AddCommandType::CreateAsk => target_kind == 1 && target_address.is_none(), - AddCommandType::CreateEvent => { - matches!(target_kind, 31_922 | 31_923) && target_address.is_some() - } - AddCommandType::CreateFoodAvailability => { - target_kind == 30_402 && target_address.is_some() - } - }; - if !target_shape_valid || authored_at_unix_s == 0 || persisted_at_unix_ms == 0 { - return Err(Phase1DraftError::InvalidDraft); - } - let author = self.draft_author()?; - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let event_target = Nip09DeletionEventTarget::parse(target_event_id, target_kind) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let address_targets = target_address - .map(Nip09DeletionAddressTarget::parse) - .transpose() - .map_err(|_| Phase1DraftError::InvalidDraft)? - .into_iter() - .collect(); - let request = - AuthoredNip09DeletionRequest::new(reason, vec![event_target], address_targets) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let plan = phase1_retraction_plan(&request, authored_at_unix_s, hex::encode(author)) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let wire = PlanWireV1::from_plan(&plan) - .to_json() - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let payload = Phase1DraftPayload::retraction(command_type, target_card_id, wire)?; - let bytes = payload.encode()?; - let draft = AuthoredDraft::initial( - draft_id, - author, - DRAFT_PAYLOAD_SCHEMA, - bytes, - AuthoredDraftStage::Draft, - None, - persisted_at_unix_ms, - ) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let receipt = self - .storage()? - .append_authored_draft(draft, None) - .await - .map_err(map_draft_storage_error)?; - self.draft_status_from(receipt.draft().clone()).await - } - - /// Advances one media prerequisite without mutating any prior revision. - pub async fn phase1_update_draft_media( - &self, - draft_id: [u8; 16], - expected_revision: u64, - url: &str, - stage: Phase1MediaStage, - failure_code: Option<String>, - updated_at_unix_ms: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let expected = AuthoredDraftRevision::new(expected_revision) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let storage = self.storage()?; - let head = storage - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - if head.revision() != expected - || head.stage().is_terminal() - || matches!( - head.stage(), - AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued - ) - { - return Err(Phase1DraftError::RevisionConflict); - } - let mut payload = Phase1DraftPayload::decode(&head)?; - let media = payload - .media - .iter_mut() - .find(|media| media.url == url) - .ok_or(Phase1DraftError::InvalidMedia)?; - if !valid_media_transition(media.stage, stage) - || matches!( - stage, - Phase1MediaStage::Verified | Phase1MediaStage::Orphaned - ) - { - return Err(Phase1DraftError::InvalidMedia); - } - media.stage = stage; - media.failure_code = failure_code; - if stage != Phase1MediaStage::Failed { - media.failure_code = None; - } - media.validate()?; - let next_stage = draft_stage_for_media(&payload.media); - let next = head - .successor(payload.encode()?, next_stage, None, updated_at_unix_ms) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let receipt = storage - .append_authored_draft(next, Some(expected)) - .await - .map_err(map_draft_storage_error)?; - self.draft_status_from(receipt.draft().clone()).await - } - - /// Records the only proof that can advance media to remote-byte-verified. - pub async fn phase1_complete_draft_media( - &self, - draft_id: [u8; 16], - expected_revision: u64, - url: &str, - receipt: radroots_sdk::transport::BlossomUploadReceipt, - updated_at_unix_ms: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let expected = AuthoredDraftRevision::new(expected_revision) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let storage = self.storage()?; - let head = storage - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - if head.revision() != expected - || head.stage().is_terminal() - || matches!( - head.stage(), - AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued - ) - { - return Err(Phase1DraftError::RevisionConflict); - } - let mut payload = Phase1DraftPayload::decode(&head)?; - let media = payload - .media - .iter_mut() - .find(|media| media.url == url) - .ok_or(Phase1DraftError::InvalidMedia)?; - if media.stage != Phase1MediaStage::Uploading || !media.matches_receipt(&receipt) { - return Err(Phase1DraftError::InvalidMedia); - } - media.stage = Phase1MediaStage::Verified; - media.failure_code = None; - media.upload_attempts = receipt.attempts(); - media.verified_at_unix_ms = Some(receipt.verified_at_unix_ms()); - media.orphan = None; - media.validate()?; - let next_stage = draft_stage_for_media(&payload.media); - let next = head - .successor(payload.encode()?, next_stage, None, updated_at_unix_ms) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let receipt = storage - .append_authored_draft(next, Some(expected)) - .await - .map_err(map_draft_storage_error)?; - self.draft_status_from(receipt.draft().clone()).await - } - - /// Persists a redacted recoverable Blossom failure and possible-orphan evidence. - pub async fn phase1_fail_draft_media( - &self, - draft_id: [u8; 16], - expected_revision: u64, - url: &str, - error: &radroots_sdk::transport::BlossomError, - updated_at_unix_ms: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - if updated_at_unix_ms == 0 { - return Err(Phase1DraftError::InvalidMedia); - } - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let expected = AuthoredDraftRevision::new(expected_revision) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let storage = self.storage()?; - let head = storage - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - if head.revision() != expected - || head.stage().is_terminal() - || matches!( - head.stage(), - AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued - ) - { - return Err(Phase1DraftError::RevisionConflict); - } - let mut payload = Phase1DraftPayload::decode(&head)?; - let media = payload - .media - .iter_mut() - .find(|media| media.url == url) - .ok_or(Phase1DraftError::InvalidMedia)?; - if !matches!( - media.stage, - Phase1MediaStage::Pending - | Phase1MediaStage::Preparing - | Phase1MediaStage::Uploading - | Phase1MediaStage::Failed - ) { - return Err(Phase1DraftError::InvalidMedia); - } - media.stage = Phase1MediaStage::Failed; - media.failure_code = Some(error.code().to_owned()); - media.upload_attempts = error.attempts(); - media.verified_at_unix_ms = None; - media.orphan = error.possible_orphan().then(|| Phase1MediaOrphanRecord { - reason_code: error.code().to_owned(), - recorded_at_unix_ms: updated_at_unix_ms, - }); - media.validate()?; - let next_stage = draft_stage_for_media(&payload.media); - let next = head - .successor(payload.encode()?, next_stage, None, updated_at_unix_ms) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let receipt = storage - .append_authored_draft(next, Some(expected)) - .await - .map_err(map_draft_storage_error)?; - self.draft_status_from(receipt.draft().clone()).await - } - - /// Freezes queue intent and atomically prepares the canonical outbox before - /// returning `queued`. Network connectivity is neither read nor required. - pub async fn phase1_queue_draft( - &self, - draft_id: [u8; 16], - expected_revision: u64, - policy: Phase1QueuePolicy, - queued_at_unix_ms: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let expected = AuthoredDraftRevision::new(expected_revision) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let storage = self.storage()?; - let head = storage - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - if head.revision() != expected { - return Err(Phase1DraftError::RevisionConflict); - } - let mut payload = Phase1DraftPayload::decode(&head)?; - let ready = match head.stage() { - AuthoredDraftStage::ReadyToSign => { - if payload.queue.as_ref() != Some(&policy) { - return Err(Phase1DraftError::RevisionConflict); - } - head - } - AuthoredDraftStage::Queued => { - if payload.queue.as_ref() != Some(&policy) { - return Err(Phase1DraftError::RevisionConflict); - } - return self.draft_status_from(head).await; - } - AuthoredDraftStage::Cancelled => return Err(Phase1DraftError::Terminal), - AuthoredDraftStage::Draft - | AuthoredDraftStage::MediaPreparing - | AuthoredDraftStage::MediaUploading => { - if payload - .media - .iter() - .any(|media| !media.is_remote_verified()) - { - return Err(Phase1DraftError::MediaNotReady); - } - policy.materialize()?; - payload.queue = Some(policy); - let bytes = payload.encode()?; - let operation_id = operation_id(draft_id, bytes.as_slice())?; - let operation_id = OperationInstanceId::new(*operation_id.as_bytes()) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let ready = head - .successor( - bytes, - AuthoredDraftStage::ReadyToSign, - Some(operation_id), - queued_at_unix_ms, - ) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - storage - .append_authored_draft(ready.clone(), Some(expected)) - .await - .map_err(map_draft_storage_error)?; - ready - } - }; - self.finish_queue(ready, queued_at_unix_ms).await - } - - /// Resumes a queue transition interrupted after its durable ready-to-sign - /// checkpoint, including the crash window after outbox preparation. - pub async fn phase1_recover_draft_queue( - &self, - draft_id: [u8; 16], - recovered_at_unix_ms: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let head = self - .storage()? - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - match head.stage() { - AuthoredDraftStage::ReadyToSign => self.finish_queue(head, recovered_at_unix_ms).await, - AuthoredDraftStage::Queued | AuthoredDraftStage::Cancelled => { - self.draft_status_from(head).await - } - AuthoredDraftStage::Draft - | AuthoredDraftStage::MediaPreparing - | AuthoredDraftStage::MediaUploading => Err(Phase1DraftError::InvalidDraft), - } - } - - /// Invokes the configured opaque host signer for one durably queued draft. - /// - /// The canonical sync engine verifies author, event ID, exact fields, - /// signature, deadline, cancellation, and operation binding before the - /// signed artifact can be persisted. Delivery remains a separate phase. - pub async fn phase1_sign_queued_draft( - &self, - draft_id: [u8; 16], - expected_revision: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let expected = AuthoredDraftRevision::new(expected_revision) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let head = self - .storage()? - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - if head.revision() != expected || head.stage() != AuthoredDraftStage::Queued { - return Err(Phase1DraftError::RevisionConflict); - } - self.sync()? - .sign_prepared(push_request(&head)?) - .await - .map_err(|_| Phase1DraftError::Operation)?; - self.draft_status_from(head).await - } - - /// Advances one durably queued draft through signing, local admission, and - /// at most one bounded relay-delivery attempt. - pub async fn phase1_advance_draft( - &self, - draft_id: [u8; 16], - expected_revision: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let expected = AuthoredDraftRevision::new(expected_revision) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let head = self - .storage()? - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - if head.revision() != expected || head.stage() != AuthoredDraftStage::Queued { - return Err(Phase1DraftError::RevisionConflict); - } - let request = push_request(&head)?; - let operation_id = request.operation_id(); - let sync = self.sync()?; - let mut status = sync - .push_status(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)? - .ok_or(Phase1DraftError::Corrupt)?; - - if matches!( - status.artifact().signing_state(), - SigningState::Planned | SigningState::Retryable - ) { - sync.sign_prepared(request) - .await - .map_err(|_| Phase1DraftError::Operation)?; - status = sync - .push_status(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)? - .ok_or(Phase1DraftError::Corrupt)?; - } - if status.artifact().signing_state() == SigningState::Signed - && matches!( - status.artifact().admission_state(), - AdmissionState::Pending | AdmissionState::Retryable - ) - { - sync.admit_signed(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)?; - status = sync - .push_status(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)? - .ok_or(Phase1DraftError::Corrupt)?; - } - if status.artifact().admission_state().is_admitted() - && matches!( - status.delivery_plan().state(), - AuthoredDeliveryState::Pending | AuthoredDeliveryState::Retryable - ) - { - sync.deliver_push(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)?; - } - self.draft_status_from(head).await - } - - /// Signs one short-lived BUD-11 upload credential for HTTP use only. - /// - /// The returned value is not persisted and its distinct plan type cannot - /// enter the relay push pipeline. - #[allow(clippy::too_many_arguments)] - pub async fn phase1_authorize_blossom_upload( - &self, - operation_id: [u8; 16], - artifact_id: [u8; 16], - claim: AuthoredUploadClaim, - deadline_unix_ms: u64, - cancellation: Phase1CancellationPolicy, - ) -> Result<radroots_sdk::signing::AuthorizationHeader, Phase1DraftError> { - let public_key = self - .store_public_key - .ok_or(Phase1DraftError::IdentityUnavailable)?; - let actor = Actor::new(public_key, ActorSource::ExplicitPublicKey, AuthorRole::ALL) - .map_err(|_| Phase1DraftError::IdentityUnavailable)?; - let plan = radroots_sdk::signing::BlossomAuthorizationPlan::for_upload(&claim, public_key) - .map_err(|_| Phase1DraftError::InvalidMedia)?; - let operation_id = - SigningOperationId::new(operation_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let artifact_id = - AuthoredArtifactId::new(artifact_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let policy = SignPolicy::new(deadline_unix_ms, cancellation.signing()) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let request = radroots_sdk::signing::blossom_upload_request( - radroots_protocol::runtime::v1::OperationId::SyncPush, - SigningIntentId::new(operation_id, artifact_id), - actor, - plan, - policy, - ) - .map_err(|_| Phase1DraftError::Operation)?; - self.client - .signing() - .map_err(|_| Phase1DraftError::OperationUnavailable)? - .ok_or(Phase1DraftError::OperationUnavailable)? - .authorize_blossom_upload(request) - .await - .map_err(|_| Phase1DraftError::Operation) - } - - /// Runs the complete durable BUD-11/BUD-02/BUD-01 media transaction. - /// - /// Final image bytes are bound before authorization. The draft becomes - /// verified only after the upload descriptor and a full retrieval agree. - #[allow(clippy::too_many_arguments)] - pub async fn phase1_upload_draft_media( - &self, - draft_id: [u8; 16], - expected_revision: u64, - request: radroots_sdk::transport::BlossomUploadRequest, - authorization_content: radroots_blossom::authorization::AuthorizationContent, - authorization_created_at_unix_s: u64, - authorization_lifetime_seconds: u64, - operation_id: [u8; 16], - artifact_id: [u8; 16], - signing_deadline_unix_ms: u64, - signing_cancellation: Phase1CancellationPolicy, - transfer_cancellation: radroots_sdk::transport::BlossomCancellation, - updated_at_unix_ms: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let blossom = self - .client - .blossom() - .map_err(|_| Phase1DraftError::OperationUnavailable)? - .ok_or(Phase1DraftError::OperationUnavailable)?; - let transaction = blossom - .prepare_upload(request) - .map_err(|_| Phase1DraftError::Operation)?; - let url = transaction.expected_url().as_str().to_owned(); - let uploading = self - .phase1_update_draft_media( - draft_id, - expected_revision, - url.as_str(), - Phase1MediaStage::Uploading, - None, - updated_at_unix_ms, - ) - .await?; - let revision = uploading.draft().revision().get(); - let claim = match blossom.authored_upload_claim( - &transaction, - authorization_content, - authorization_created_at_unix_s, - authorization_lifetime_seconds, - ) { - Ok(claim) => claim, - Err(_) => { - self.phase1_update_draft_media( - draft_id, - revision, - url.as_str(), - Phase1MediaStage::Failed, - Some("blossom_authorization_failed".to_owned()), - updated_at_unix_ms, - ) - .await?; - return Err(Phase1DraftError::Operation); - } - }; - let authorization = match self - .phase1_authorize_blossom_upload( - operation_id, - artifact_id, - claim, - signing_deadline_unix_ms, - signing_cancellation, - ) - .await - { - Ok(authorization) => authorization, - Err(error) => { - self.phase1_update_draft_media( - draft_id, - revision, - url.as_str(), - Phase1MediaStage::Failed, - Some("blossom_authorization_failed".to_owned()), - updated_at_unix_ms, - ) - .await?; - return Err(error); - } - }; - match blossom - .upload(transaction, authorization, transfer_cancellation) - .await - { - Ok(receipt) => { - self.phase1_complete_draft_media( - draft_id, - revision, - url.as_str(), - receipt, - updated_at_unix_ms, - ) - .await - } - Err(error) => { - self.phase1_fail_draft_media( - draft_id, - revision, - url.as_str(), - &error, - updated_at_unix_ms, - ) - .await?; - Err(Phase1DraftError::Operation) - } - } - } - - /// Returns durable draft state composed with canonical authored-operation state. - pub async fn phase1_draft_status( - &self, - draft_id: [u8; 16], - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let head = self - .storage()? - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - self.draft_status_from(head).await - } - - /// Lists the newest immutable revision of each draft for the active author. - pub async fn phase1_draft_heads( - &self, - limit: u16, - ) -> Result<Vec<Phase1DraftStatus>, Phase1DraftError> { - let drafts = self - .storage()? - .authored_draft_heads(self.draft_author()?, limit) - .await - .map_err(map_draft_storage_error)?; - let mut statuses = Vec::with_capacity(drafts.len()); - for draft in drafts { - statuses.push(self.draft_status_from(draft).await?); - } - Ok(statuses) - } - - /// Cancels still-pending authored work and records uploaded-but-unreferenced - /// media as possible orphans without deleting any evidence. - pub async fn phase1_cancel_draft( - &self, - draft_id: [u8; 16], - expected_revision: u64, - cancelled_at_unix_ms: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let expected = AuthoredDraftRevision::new(expected_revision) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let storage = self.storage()?; - let head = storage - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - if head.stage() == AuthoredDraftStage::Cancelled { - return self.draft_status_from(head).await; - } - if head.revision() != expected { - return Err(Phase1DraftError::RevisionConflict); - } - let mut payload = Phase1DraftPayload::decode(&head)?; - let push = self.push_status_for(&head).await?; - if let Some(status) = &push { - self.sync()? - .cancel_push(sync_id_for(&head)?) - .await - .map_err(|_| Phase1DraftError::Operation)?; - if status.artifact().signed().is_none() { - mark_possible_orphans(&mut payload.media, cancelled_at_unix_ms); - } - } else { - mark_possible_orphans(&mut payload.media, cancelled_at_unix_ms); - } - let next = head - .successor( - payload.encode()?, - AuthoredDraftStage::Cancelled, - head.operation_id(), - cancelled_at_unix_ms, - ) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let receipt = storage - .append_authored_draft(next, Some(expected)) - .await - .map_err(map_draft_storage_error)?; - self.draft_status_from(receipt.draft().clone()).await - } - - /// Applies the current durable operation state to an already-projected - /// active-author card. The overlay remains local and never changes event truth. - pub async fn phase1_apply_draft_overlay( - &self, - context: &LocalNetwork, - draft_id: [u8; 16], - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let status = self.phase1_draft_status(draft_id).await?; - let operation_id = status - .draft - .operation_id() - .map(|id| hex::encode(id.as_bytes())) - .ok_or(Phase1DraftError::OperationUnavailable)?; - self.phase1_set_local_author_overlay( - context, - status.card_id, - Some(LocalAuthorOverlay { - operation_id, - state: status.state.label().to_owned(), - }), - ) - .await - .map_err(map_overlay_error)?; - Ok(status) - } - - async fn phase1_queue_profile( - &self, - draft_id: [u8; 16], - expected_revision: u64, - ) -> Result<Phase1ProfileStatus, Phase1DraftError> { - let now_unix_ms = phase1_operation_now_unix_ms()?; - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let expected = AuthoredDraftRevision::new(expected_revision) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let storage = self.storage()?; - let head = storage - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - if head.revision() != expected { - return Err(Phase1DraftError::RevisionConflict); - } - let mut payload = Phase1ProfilePayload::decode(&head)?; - let ready = match head.stage() { - AuthoredDraftStage::Draft => { - payload.queue = Some(self.active_queue_policy(now_unix_ms)?); - let bytes = payload.encode()?; - let operation_id = operation_id(draft_id, bytes.as_slice())?; - let operation_id = OperationInstanceId::new(*operation_id.as_bytes()) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - let ready = head - .successor( - bytes, - AuthoredDraftStage::ReadyToSign, - Some(operation_id), - now_unix_ms, - ) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - storage - .append_authored_draft(ready.clone(), Some(expected)) - .await - .map_err(map_draft_storage_error)?; - ready - } - AuthoredDraftStage::ReadyToSign => head, - AuthoredDraftStage::Queued => return self.profile_status_from(head).await, - AuthoredDraftStage::Cancelled => return Err(Phase1DraftError::Terminal), - AuthoredDraftStage::MediaPreparing | AuthoredDraftStage::MediaUploading => { - return Err(Phase1DraftError::Corrupt); - } - }; - self.finish_profile_queue(ready, now_unix_ms).await - } - - async fn finish_profile_queue( - &self, - ready: AuthoredDraft, - queued_at_unix_ms: u64, - ) -> Result<Phase1ProfileStatus, Phase1DraftError> { - let request = profile_push_request(&ready)?; - self.sync()? - .prepare_push(request) - .await - .map_err(|_| Phase1DraftError::Operation)?; - let queued = ready - .successor( - ready.payload().to_vec(), - AuthoredDraftStage::Queued, - ready.operation_id(), - queued_at_unix_ms.max(ready.updated_at_unix_ms()), - ) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let receipt = self - .storage()? - .append_authored_draft(queued, Some(ready.revision())) - .await - .map_err(map_draft_storage_error)?; - self.profile_status_from(receipt.draft().clone()).await - } - - async fn profile_status_from( - &self, - draft: AuthoredDraft, - ) -> Result<Phase1ProfileStatus, Phase1DraftError> { - if draft.author() != &self.draft_author()? { - return Err(Phase1DraftError::Corrupt); - } - Phase1ProfilePayload::decode(&draft)?; - let push = self.profile_push_status_for(&draft).await?; - if draft.stage() == AuthoredDraftStage::Queued && push.is_none() { - return Err(Phase1DraftError::Corrupt); - } - let state = aggregate_state(&draft, push.as_ref()); - Ok(Phase1ProfileStatus { draft, state, push }) - } - - async fn profile_push_status_for( - &self, - draft: &AuthoredDraft, - ) -> Result<Option<PushStatus>, Phase1DraftError> { - let Some(_) = draft.operation_id() else { - return Ok(None); - }; - self.sync()? - .push_status(sync_id_for(draft)?) - .await - .map_err(|_| Phase1DraftError::Operation) - } - - async fn finish_queue( - &self, - ready: AuthoredDraft, - queued_at_unix_ms: u64, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let request = push_request(&ready)?; - self.sync()? - .prepare_push(request) - .await - .map_err(|_| Phase1DraftError::Operation)?; - let queued = ready - .successor( - ready.payload().to_vec(), - AuthoredDraftStage::Queued, - ready.operation_id(), - queued_at_unix_ms.max(ready.updated_at_unix_ms()), - ) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let receipt = self - .storage()? - .append_authored_draft(queued, Some(ready.revision())) - .await - .map_err(map_draft_storage_error)?; - self.draft_status_from(receipt.draft().clone()).await - } - - async fn draft_status_from( - &self, - draft: AuthoredDraft, - ) -> Result<Phase1DraftStatus, Phase1DraftError> { - if draft.author() != &self.draft_author()? { - return Err(Phase1DraftError::Corrupt); - } - let payload = Phase1DraftPayload::decode(&draft)?; - let integrity = PlanWireV1::from_json(payload.plan_wire_json.as_slice()) - .map_err(|_| Phase1DraftError::Corrupt)?; - let card_id = payload - .target_card_id - .map(Ok) - .unwrap_or_else(|| card_id(payload.command_type, integrity.plan()))?; - let revision_policy = payload.revision.as_ref().map(|value| value.policy); - let push = self.push_status_for(&draft).await?; - if draft.stage() == AuthoredDraftStage::Queued && push.is_none() { - return Err(Phase1DraftError::Corrupt); - } - let state = aggregate_state(&draft, push.as_ref()); - Ok(Phase1DraftStatus { - draft, - kind: payload.kind, - command_type: payload.command_type, - form: payload.form, - media: payload.media, - state, - card_id, - push, - revision_policy, - }) - } - - async fn push_status_for( - &self, - draft: &AuthoredDraft, - ) -> Result<Option<PushStatus>, Phase1DraftError> { - let Some(_) = draft.operation_id() else { - return Ok(None); - }; - self.sync()? - .push_status(sync_id_for(draft)?) - .await - .map_err(|_| Phase1DraftError::Operation) - } - - fn storage(&self) -> Result<&dyn AuthoredDraftStore, Phase1DraftError> { - self.client - .storage() - .map(|storage| storage as &dyn AuthoredDraftStore) - .map_err(|_| Phase1DraftError::Storage) - } - - fn sync(&self) -> Result<radroots_sdk::sync::Operations<'_>, Phase1DraftError> { - self.client - .sync() - .map_err(|_| Phase1DraftError::OperationUnavailable)? - .ok_or(Phase1DraftError::OperationUnavailable) - } - - fn draft_author(&self) -> Result<[u8; 32], Phase1DraftError> { - self.store_public_key - .map(|key| *key.as_bytes()) - .ok_or(Phase1DraftError::IdentityUnavailable) - } -} - -fn push_request(draft: &AuthoredDraft) -> Result<PushRequest, Phase1DraftError> { - let payload = Phase1DraftPayload::decode(draft)?; - let policy = payload.queue.ok_or(Phase1DraftError::InvalidQueuePolicy)?; - let (targets, satisfaction, cancellation) = policy.materialize()?; - let plan = PlanWireV1::from_json(payload.plan_wire_json.as_slice()) - .map_err(|_| Phase1DraftError::Corrupt)? - .into_plan(); - let public_key = PublicKey::from_bytes(*draft.author()) - .map_err(|_| Phase1DraftError::IdentityUnavailable)?; - let actor = Actor::new(public_key, ActorSource::ExplicitPublicKey, AuthorRole::ALL) - .map_err(|_| Phase1DraftError::IdentityUnavailable)?; - let sync_id = sync_id_for(draft)?; - let idempotency = IdempotencyKey::parse(format!( - "phase1-draft-{}", - hex::encode(draft.draft_id().as_bytes()) - )) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - PushRequest::new( - sync_id, - idempotency, - actor, - plan, - targets, - satisfaction, - policy.delivery_deadline_unix_ms, - cancellation, - ) - .map_err(|_| Phase1DraftError::InvalidQueuePolicy) -} - -fn profile_push_request(draft: &AuthoredDraft) -> Result<PushRequest, Phase1DraftError> { - let payload = Phase1ProfilePayload::decode(draft)?; - let policy = payload.queue.ok_or(Phase1DraftError::InvalidQueuePolicy)?; - let (targets, satisfaction, cancellation) = policy.materialize()?; - let plan = PlanWireV1::from_json(payload.plan_wire_json.as_slice()) - .map_err(|_| Phase1DraftError::Corrupt)? - .into_plan(); - let public_key = PublicKey::from_bytes(*draft.author()) - .map_err(|_| Phase1DraftError::IdentityUnavailable)?; - let actor = Actor::new(public_key, ActorSource::ExplicitPublicKey, AuthorRole::ALL) - .map_err(|_| Phase1DraftError::IdentityUnavailable)?; - let sync_id = sync_id_for(draft)?; - let idempotency = IdempotencyKey::parse(format!( - "phase1-profile-{}", - hex::encode(draft.draft_id().as_bytes()) - )) - .map_err(|_| Phase1DraftError::InvalidDraft)?; - PushRequest::new( - sync_id, - idempotency, - actor, - plan, - targets, - satisfaction, - policy.delivery_deadline_unix_ms, - cancellation, - ) - .map_err(|_| Phase1DraftError::InvalidQueuePolicy) -} - -fn operation_id( - draft_id: AuthoredDraftId, - ready_payload: &[u8], -) -> Result<SyncId, Phase1DraftError> { - let mut hasher = Sha256::new(); - hasher.update(DRAFT_OPERATION_DOMAIN); - hasher.update(draft_id.as_bytes()); - hasher.update( - u64::try_from(ready_payload.len()) - .map_err(|_| Phase1DraftError::InvalidDraft)? - .to_be_bytes(), - ); - hasher.update(ready_payload); - let digest: [u8; 32] = hasher.finalize().into(); - let mut value = [0_u8; 16]; - value.copy_from_slice(&digest[..16]); - if value.iter().all(|byte| *byte == 0) { - value[15] = 1; - } - SyncId::new(value).map_err(|_| Phase1DraftError::InvalidDraft) -} - -fn sync_id_for(draft: &AuthoredDraft) -> Result<SyncId, Phase1DraftError> { - draft - .operation_id() - .ok_or(Phase1DraftError::OperationUnavailable) - .and_then(|id| SyncId::new(*id.as_bytes()).map_err(|_| Phase1DraftError::Corrupt)) -} - -fn media_urls(tags: &[Vec<String>]) -> Result<BTreeSet<&str>, Phase1DraftError> { - let mut urls = BTreeSet::new(); - for tag in tags { - let candidate = match tag.first().map(String::as_str) { - Some("imeta") => tag - .iter() - .skip(1) - .find_map(|value| value.strip_prefix("url ")), - Some("image") => tag.get(1).map(String::as_str), - _ => None, - }; - if let Some(candidate) = candidate - && BlobUrl::parse(candidate).is_ok() - && !urls.insert(candidate) - { - return Err(Phase1DraftError::InvalidMedia); - } - } - Ok(urls) -} - -fn draft_stage_for_media(media: &[Phase1MediaPrerequisite]) -> AuthoredDraftStage { - if media.is_empty() { - AuthoredDraftStage::Draft - } else if media - .iter() - .any(|media| media.stage == Phase1MediaStage::Uploading) - { - AuthoredDraftStage::MediaUploading - } else { - AuthoredDraftStage::MediaPreparing - } -} - -fn mark_possible_orphans(media: &mut [Phase1MediaPrerequisite], recorded_at_unix_ms: u64) { - for media in media { - if media.stage == Phase1MediaStage::Verified || media.orphan.is_some() { - media.stage = Phase1MediaStage::Orphaned; - media.failure_code = None; - media.orphan = Some(Phase1MediaOrphanRecord { - reason_code: "draft_cancelled_after_upload".to_owned(), - recorded_at_unix_ms, - }); - } - } -} - -const fn valid_media_transition(previous: Phase1MediaStage, next: Phase1MediaStage) -> bool { - match previous { - Phase1MediaStage::Pending => matches!( - next, - Phase1MediaStage::Pending - | Phase1MediaStage::Preparing - | Phase1MediaStage::Uploading - | Phase1MediaStage::Failed - ), - Phase1MediaStage::Preparing => matches!( - next, - Phase1MediaStage::Preparing | Phase1MediaStage::Uploading | Phase1MediaStage::Failed - ), - Phase1MediaStage::Uploading => matches!( - next, - Phase1MediaStage::Uploading | Phase1MediaStage::Verified | Phase1MediaStage::Failed - ), - Phase1MediaStage::Failed => matches!( - next, - Phase1MediaStage::Preparing | Phase1MediaStage::Uploading | Phase1MediaStage::Failed - ), - Phase1MediaStage::Verified => matches!(next, Phase1MediaStage::Verified), - Phase1MediaStage::Orphaned => matches!(next, Phase1MediaStage::Orphaned), - } -} - -fn aggregate_state(draft: &AuthoredDraft, push: Option<&PushStatus>) -> Phase1OutboxState { - if draft.stage() == AuthoredDraftStage::Cancelled { - return Phase1OutboxState::Cancelled; - } - let Some(push) = push else { - return match draft.stage() { - AuthoredDraftStage::Draft => Phase1OutboxState::Draft, - AuthoredDraftStage::MediaPreparing => Phase1OutboxState::MediaPreparing, - AuthoredDraftStage::MediaUploading => Phase1OutboxState::MediaUploading, - AuthoredDraftStage::ReadyToSign => Phase1OutboxState::ReadyToSign, - AuthoredDraftStage::Queued => Phase1OutboxState::Queued, - AuthoredDraftStage::Cancelled => Phase1OutboxState::Cancelled, - }; - }; - if push.settlement().is_successful() { - return Phase1OutboxState::Complete; - } - if push.settlement().has_failures() { - return if has_delivery_success(push) { - Phase1OutboxState::PartiallyDelivered - } else if push.settlement().retryable() != 0 || push.settlement().delivery_retryable() != 0 - { - Phase1OutboxState::Retryable - } else if push.settlement().cancelled() != 0 || push.settlement().delivery_cancelled() != 0 - { - Phase1OutboxState::Cancelled - } else { - Phase1OutboxState::Terminal - }; - } - match push.artifact().signing_state() { - SigningState::Planned if push.artifact().signing_claim().is_some() => { - Phase1OutboxState::Signing - } - SigningState::Planned => Phase1OutboxState::Queued, - SigningState::Retryable => Phase1OutboxState::Retryable, - SigningState::Indeterminate | SigningState::FailedTerminal => Phase1OutboxState::Terminal, - SigningState::Cancelled => Phase1OutboxState::Cancelled, - SigningState::Signed => match push.artifact().admission_state() { - AdmissionState::Pending => Phase1OutboxState::Signed, - AdmissionState::Retryable => Phase1OutboxState::Retryable, - AdmissionState::Rejected => Phase1OutboxState::Terminal, - AdmissionState::Cancelled => Phase1OutboxState::Cancelled, - AdmissionState::Inserted | AdmissionState::Duplicate => match push - .delivery_plan() - .state() - { - AuthoredDeliveryState::Pending - if push.delivery_plan().claim_evidence().is_some() => - { - Phase1OutboxState::Delivering - } - AuthoredDeliveryState::Pending if push.delivery_plan().attempts().is_empty() => { - Phase1OutboxState::Queued - } - AuthoredDeliveryState::Pending => Phase1OutboxState::PartiallyDelivered, - AuthoredDeliveryState::Retryable if has_delivery_success(push) => { - Phase1OutboxState::PartiallyDelivered - } - AuthoredDeliveryState::Retryable => Phase1OutboxState::Retryable, - AuthoredDeliveryState::Satisfied => Phase1OutboxState::Complete, - AuthoredDeliveryState::Exhausted | AuthoredDeliveryState::FailedTerminal => { - Phase1OutboxState::Terminal - } - AuthoredDeliveryState::Cancelled => Phase1OutboxState::Cancelled, - }, - }, - } -} - -fn has_delivery_success(push: &PushStatus) -> bool { - push.delivery_plan().attempts().iter().any(|attempt| { - let evidence = match attempt.outcome() { - DeliveryAttemptOutcome::Receipt(receipt) => receipt.target_receipts(), - DeliveryAttemptOutcome::SinkFailure(failure) => failure.partial_evidence(), - }; - evidence.iter().any(|receipt| { - matches!( - receipt.outcome().kind(), - DeliveryOutcomeKind::Accepted | DeliveryOutcomeKind::Delivered - ) - }) - }) -} - -fn revision_phase( - replacement: &Phase1DraftStatus, - retraction: Option<&Phase1DraftStatus>, - policy: Phase1RevisionPolicy, -) -> Phase1RevisionPhase { - match replacement.state() { - Phase1OutboxState::Cancelled => return Phase1RevisionPhase::Cancelled, - Phase1OutboxState::Terminal => return Phase1RevisionPhase::ReplacementFailed, - Phase1OutboxState::Complete => {} - _ => return Phase1RevisionPhase::ReplacementPending, - } - if policy == Phase1RevisionPolicy::AddressableReplacement { - return Phase1RevisionPhase::Complete; - } - match retraction.map(Phase1DraftStatus::state) { - Some(Phase1OutboxState::Complete) => Phase1RevisionPhase::Complete, - Some(Phase1OutboxState::Cancelled | Phase1OutboxState::Terminal) => { - Phase1RevisionPhase::PartialEffect - } - Some(_) | None => Phase1RevisionPhase::RetractionPending, - } -} - -fn revision_child_id(draft: &AuthoredDraft) -> Result<Option<[u8; 16]>, Phase1DraftError> { - Ok(Phase1DraftPayload::decode(draft)? - .revision - .ok_or(Phase1DraftError::InvalidRevision)? - .retraction_draft_id) -} - -fn validate_revision_retraction( - status: &Phase1DraftStatus, - target: &Phase1RevisionTarget, -) -> Result<(), Phase1DraftError> { - if status.kind() != Phase1DraftKind::Retraction - || status.command_type() != target.command_type - || status.card_id() != target.card_id - { - return Err(Phase1DraftError::Corrupt); - } - let payload = Phase1DraftPayload::decode(status.draft())?; - let plan = PlanWireV1::from_json(payload.plan_wire_json.as_slice()) - .map_err(|_| Phase1DraftError::Corrupt)?; - if plan.plan().body().kind() != 5 - || !plan.plan().body().tags().iter().any(|tag| { - tag.first().map(String::as_str) == Some("e") - && tag.get(1).map(String::as_str) == Some(target.source_event_id()) - }) - || target.source_address().is_some_and(|address| { - !plan.plan().body().tags().iter().any(|tag| { - tag.first().map(String::as_str) == Some("a") - && tag.get(1).map(String::as_str) == Some(address) - }) - }) - { - return Err(Phase1DraftError::Corrupt); - } - Ok(()) -} - -fn parse_address(address: &str) -> Result<(u32, &str, &str), Phase1DraftError> { - let mut parts = address.splitn(3, ':'); - let kind = parts - .next() - .and_then(|value| value.parse::<u32>().ok()) - .ok_or(Phase1DraftError::InvalidRevision)?; - let author = parts.next().ok_or(Phase1DraftError::InvalidRevision)?; - let identifier = parts.next().ok_or(Phase1DraftError::InvalidRevision)?; - if author.len() != 64 || identifier.is_empty() { - return Err(Phase1DraftError::InvalidRevision); - } - Ok((kind, author, identifier)) -} - -fn card_id( - command_type: AddCommandType, - plan: &radroots_event_codec::authoring::AuthoredEventPlan, -) -> Result<CardId, Phase1DraftError> { - let card_type = match command_type { - AddCommandType::CreateUpdate => TodayCardType::Update, - AddCommandType::CreatePhotoUpdate => TodayCardType::PhotoUpdate, - AddCommandType::CreateAsk => TodayCardType::Ask, - AddCommandType::CreateEvent => TodayCardType::Event, - AddCommandType::CreateFoodAvailability => TodayCardType::FoodAvailability, - }; - let source = if (30_000..40_000).contains(&plan.body().kind()) { - let identifier = plan - .body() - .tags() - .iter() - .find(|tag| tag.first().map(String::as_str) == Some("d") && tag.len() == 2) - .and_then(|tag| tag.get(1)) - .ok_or(Phase1DraftError::Corrupt)?; - CardSourceIdentity::address( - plan.body().kind(), - plan.author().to_hex(), - identifier.clone(), - ) - .map_err(|_| Phase1DraftError::Corrupt)? - } else { - CardSourceIdentity::Event(*plan.expected_event_id()) - }; - Ok(CardId::derive(card_type, &source)) -} - -fn map_draft_storage_error(error: radroots_storage::Error) -> Phase1DraftError { - match error { - radroots_storage::Error::DraftRevisionConflict => Phase1DraftError::RevisionConflict, - radroots_storage::Error::DraftNotFound => Phase1DraftError::NotFound, - radroots_storage::Error::CorruptAuthoredDraft => Phase1DraftError::Corrupt, - _ => Phase1DraftError::Storage, - } -} - -fn map_overlay_error(_: TodayError) -> Phase1DraftError { - Phase1DraftError::Overlay -} - -/// Captures the canonical wall-clock input for Rust-owned Phase 1 policy. -pub fn phase1_operation_now_unix_ms() -> Result<u64, Phase1DraftError> { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .and_then(|duration| u64::try_from(duration.as_millis()).ok()) - .filter(|value| *value >= 1_000) - .ok_or(Phase1DraftError::ClockUnavailable) -} - -/// Generates a canonical public identifier for an addressable Add form. -pub fn phase1_new_addressable_identifier() -> String { - uuid::Uuid::new_v4().simple().to_string() -} - -/// Generates one opaque operation identity for host-visible cancellation and -/// receipt correlation without delegating identity policy to the host. -pub fn phase1_new_operation_id() -> Result<[u8; 16], Phase1DraftError> { - phase1_random_id() -} - -fn phase1_random_id() -> Result<[u8; 16], Phase1DraftError> { - let value = *uuid::Uuid::new_v4().as_bytes(); - if value.iter().all(|byte| *byte == 0) { - return Err(Phase1DraftError::OperationUnavailable); - } - Ok(value) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::runtime::product_surface::{ - CANONICAL_ADD_COMMAND_TYPES, CreateAsk, CreateEvent, CreateFoodAvailability, - CreatePhotoUpdate, CreateUpdate, - }; - use crate::runtime::{ - builder::RuntimeBuilder, - store::{MobileUserStoreConfig, ProtectedDataAvailability}, - }; - use radroots_blossom::{BlobDescriptor, Sha256 as BlossomSha256}; - use radroots_event::{ - calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate}, - food::availability::{ - FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityStatus, - FoodContent, FoodCurrency, FoodIdentifier, FoodPrice, FoodPublishedAt, FoodText, - FoodUnit, - }, - media::AuthoredImage, - post::{AuthoredPostImage, PostImageDimensions}, - }; - use radroots_sdk::ClientBuilder; - - const AUTHOR: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - const SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; - - fn runtime() -> RadrootsRuntime { - RadrootsRuntime::from_client_builder( - ClientBuilder::memory_default(), - Some(PublicKey::from_hex(AUTHOR).unwrap()), - None, - None, - None, - None, - ) - .unwrap() - } - - fn profiled_runtime(profile: radroots_sdk::transport::RelayProfile) -> RadrootsRuntime { - RadrootsRuntime::from_client_builder( - ClientBuilder::memory_default(), - Some(PublicKey::from_hex(AUTHOR).unwrap()), - None, - None, - Some(profile), - None, - ) - .unwrap() - } - - fn signing_runtime() -> RadrootsRuntime { - let signer = radroots_nostr::signing::LocalSigner::new( - radroots_nostr::key::SecretKey::parse(SECRET).unwrap(), - ) - .unwrap(); - RadrootsRuntime::from_client_builder( - ClientBuilder::memory_default(), - Some(PublicKey::from_hex(AUTHOR).unwrap()), - None, - Some(std::sync::Arc::new(signer)), - None, - None, - ) - .unwrap() - } - - fn policy() -> Phase1QueuePolicy { - Phase1QueuePolicy::new( - vec![ - "wss://relay-one.example".to_owned(), - "wss://relay-two.example".to_owned(), - ], - Phase1RelaySatisfaction::AllAccepted, - 2_000_000_000_000, - Phase1CancellationPolicy::LocalCooperative, - ) - .unwrap() - } - - fn update_form() -> Phase1DraftFormSnapshot { - Phase1DraftFormSnapshot { - command_type: AddCommandType::CreateUpdate, - content: "Harvest update".to_owned(), - identifier: None, - title: None, - summary: None, - location: None, - event_timing: None, - event_start_date: None, - event_end_date: None, - event_start_unix_s: None, - event_end_unix_s: None, - event_timezone: None, - price_amount: None, - currency: None, - unit: None, - quantity: None, - food_published_at_unix_s: None, - food_status: None, - media: Vec::new(), - } - } - - #[test] - fn upload_policy_derivation_is_exact_and_bounded() { - let plan = Phase1UploadPlan::derive(1_800_000_000_000, [7; 16], [8; 16]).unwrap(); - assert_eq!(plan.authorization_content, BLOSSOM_AUTHORIZATION_CONTENT); - assert_eq!(plan.authorization_created_at_unix_s, 1_799_999_995); - assert_eq!(plan.authorization_lifetime_seconds, 300); - assert_eq!(plan.operation_id, [7; 16]); - assert_eq!(plan.artifact_id, [8; 16]); - assert_eq!(plan.signing_deadline_unix_ms, 1_800_000_060_000); - assert_eq!( - plan.cancellation, - Phase1CancellationPolicy::LocalCooperative - ); - assert_eq!(plan.updated_at_unix_ms, 1_800_000_000_000); - assert_eq!( - Phase1UploadPlan::derive(u64::MAX, [7; 16], [8; 16]).unwrap_err(), - Phase1DraftError::DeadlineOverflow - ); - } - - #[tokio::test] - async fn add_intent_owns_draft_identity_time_and_writable_relay_policy() { - let profile = radroots_sdk::transport::RelayProfile::explicit( - radroots_sdk::transport::RelayProfileKind::Public, - [ - radroots_sdk::transport::RelayEndpoint::new( - "wss://read.example", - radroots_sdk::transport::RelayUrlPolicy::Public, - radroots_sdk::transport::RelayAccess::ReadOnly, - ) - .unwrap(), - radroots_sdk::transport::RelayEndpoint::new( - "wss://write.example", - radroots_sdk::transport::RelayUrlPolicy::Public, - radroots_sdk::transport::RelayAccess::ReadWrite, - ) - .unwrap(), - ], - ) - .unwrap(); - let runtime = profiled_runtime(profile); - let saved = runtime - .phase1_save_add_intent( - Phase1AddIntent::new( - Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), - Vec::new(), - update_form(), - None, - ) - .unwrap(), - ) - .await - .unwrap(); - assert_ne!(saved.draft().draft_id().as_bytes(), &[0; 16]); - assert!(saved.draft().created_at_unix_ms() >= 1_700_000_000_000); - - let queued = runtime - .phase1_queue_add_intent( - Phase1QueueIntent::new( - *saved.draft().draft_id().as_bytes(), - saved.draft().revision().get(), - ) - .unwrap(), - ) - .await - .unwrap(); - let payload = Phase1DraftPayload::decode(queued.draft()).unwrap(); - let queue = payload.queue.unwrap(); - assert_eq!(queue.relay_urls, vec!["wss://write.example"]); - assert_eq!(queue.satisfaction, Phase1RelaySatisfaction::AllAccepted); - assert_eq!( - queue.cancellation, - Phase1CancellationPolicy::LocalCooperative - ); - assert!( - queue.delivery_deadline_unix_ms - >= queued.draft().updated_at_unix_ms() + ADD_DELIVERY_TIMEOUT_MS - ); - } - - #[tokio::test] - async fn profile_metadata_uses_the_durable_outbox_with_stable_operation_identity() { - let profile = radroots_sdk::transport::RelayProfile::explicit( - radroots_sdk::transport::RelayProfileKind::Public, - [radroots_sdk::transport::RelayEndpoint::new( - "wss://write.example", - radroots_sdk::transport::RelayUrlPolicy::Public, - radroots_sdk::transport::RelayAccess::ReadWrite, - ) - .unwrap()], - ) - .unwrap(); - let runtime = profiled_runtime(profile); - let saved = runtime - .phase1_save_profile_metadata( - ProfileMetadataCommand::new( - "grower".to_owned(), - Some("Local Grower".to_owned()), - Some("Seasonal produce".to_owned()), - None, - None, - Some("grower@farm.example".to_owned()), - Some(false), - ) - .unwrap(), - ) - .await - .unwrap(); - let operation_id = *saved.draft().draft_id().as_bytes(); - assert_eq!(saved.state(), Phase1OutboxState::Draft); - assert_eq!( - PlanWireV1::from_json( - Phase1ProfilePayload::decode(saved.draft()) - .unwrap() - .plan_wire_json - .as_slice(), - ) - .unwrap() - .plan() - .body() - .kind(), - 0 - ); - - let queued = runtime - .phase1_queue_profile(operation_id, saved.draft().revision().get()) - .await - .unwrap(); - assert_eq!(queued.state(), Phase1OutboxState::Queued); - assert_eq!(*queued.draft().draft_id().as_bytes(), operation_id); - let cancelled = runtime - .phase1_cancel_profile(operation_id, queued.draft().revision().get()) - .await - .unwrap(); - assert_eq!(cancelled.state(), Phase1OutboxState::Cancelled); - assert_eq!(*cancelled.draft().draft_id().as_bytes(), operation_id); - } - - #[tokio::test] - async fn add_queue_intent_fails_closed_without_a_writable_relay() { - let profile = radroots_sdk::transport::RelayProfile::explicit( - radroots_sdk::transport::RelayProfileKind::Public, - [radroots_sdk::transport::RelayEndpoint::new( - "wss://read.example", - radroots_sdk::transport::RelayUrlPolicy::Public, - radroots_sdk::transport::RelayAccess::ReadOnly, - ) - .unwrap()], - ) - .unwrap(); - let runtime = profiled_runtime(profile); - let saved = runtime - .phase1_save_add_intent( - Phase1AddIntent::new( - Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), - Vec::new(), - update_form(), - None, - ) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!( - runtime - .phase1_queue_add_intent( - Phase1QueueIntent::new( - *saved.draft().draft_id().as_bytes(), - saved.draft().revision().get(), - ) - .unwrap(), - ) - .await - .unwrap_err(), - Phase1DraftError::NoWritableRelay - ); - } - - #[tokio::test] - async fn addressable_revision_preserves_every_form_field_and_colon_identifier() { - let identifier = "market:summer:2026"; - let source = CardSourceIdentity::address(31_923, AUTHOR, identifier).unwrap(); - let target_card = CardId::derive(TodayCardType::Event, &source); - let target = Phase1RevisionTarget::from_source( - AddCommandType::CreateEvent, - target_card, - "b".repeat(64), - Some(format!("31923:{AUTHOR}:{identifier}")), - AUTHOR, - ) - .unwrap(); - let event = AuthoredCalendarTimeEvent::new(identifier, "Evening market", 1_900_003_600) - .unwrap() - .with_end(1_900_007_200) - .unwrap() - .with_start_tzid("America/Vancouver") - .unwrap() - .with_end_tzid("America/Vancouver") - .unwrap() - .with_locations(vec!["Town square".to_owned()]) - .unwrap() - .with_description("Bring reusable bags") - .unwrap(); - let form = Phase1DraftFormSnapshot { - command_type: AddCommandType::CreateEvent, - content: "Bring reusable bags".to_owned(), - identifier: Some(identifier.to_owned()), - title: Some("Evening market".to_owned()), - summary: Some("Local farms and neighbours".to_owned()), - location: Some("Town square".to_owned()), - event_timing: Some(Phase1DraftEventTiming::Timed), - event_start_date: None, - event_end_date: None, - event_start_unix_s: Some(1_900_003_600), - event_end_unix_s: Some(1_900_007_200), - event_timezone: Some("America/Vancouver".to_owned()), - price_amount: Some("5".to_owned()), - currency: Some("CAD".to_owned()), - unit: Some("entry".to_owned()), - quantity: Some("100".to_owned()), - food_published_at_unix_s: Some(1_900_000_000), - food_status: Some("active".to_owned()), - media: Vec::new(), - }; - let runtime = runtime(); - let saved = runtime - .phase1_save_revision_intent( - Phase1ReviseIntent::new( - target.clone(), - Phase1AddCommand::CreateEvent(CreateEvent::time(event)), - Vec::new(), - form.clone(), - ) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(saved.policy(), Phase1RevisionPolicy::AddressableReplacement); - assert_eq!(saved.phase(), Phase1RevisionPhase::ReplacementPending); - assert_eq!(saved.target(), &target); - assert_eq!(saved.replacement().card_id(), target_card); - assert_eq!(saved.replacement().form(), Some(&form)); - assert!(saved.retraction().is_none()); - assert_eq!( - parse_address(saved.target().source_address().unwrap()) - .unwrap() - .2, - identifier - ); - } - - #[tokio::test] - async fn addressable_revision_rejects_identity_change_and_preserves_date_boundary() { - let identifier = "winter:market"; - let target_card = CardId::derive( - TodayCardType::Event, - &CardSourceIdentity::address(31_922, AUTHOR, identifier).unwrap(), - ); - let target = Phase1RevisionTarget::new( - AddCommandType::CreateEvent, - target_card, - "d".repeat(64), - 31_922, - Some(format!("31922:{AUTHOR}:{identifier}")), - AUTHOR, - ) - .unwrap(); - let form = Phase1DraftFormSnapshot { - command_type: AddCommandType::CreateEvent, - content: "New Year farm market".to_owned(), - identifier: Some(identifier.to_owned()), - title: Some("Winter market".to_owned()), - summary: None, - location: Some("Barn".to_owned()), - event_timing: Some(Phase1DraftEventTiming::AllDay), - event_start_date: Some("2026-12-31".to_owned()), - event_end_date: Some("2027-01-01".to_owned()), - event_start_unix_s: None, - event_end_unix_s: None, - event_timezone: None, - price_amount: None, - currency: None, - unit: None, - quantity: None, - food_published_at_unix_s: None, - food_status: None, - media: Vec::new(), - }; - let event = AuthoredCalendarDateEvent::new( - identifier, - "Winter market", - CalendarDate::parse("2026-12-31").unwrap(), - ) - .unwrap() - .with_end(CalendarDate::parse("2027-01-01").unwrap()) - .unwrap() - .with_description("New Year farm market") - .unwrap() - .with_locations(vec!["Barn".to_owned()]) - .unwrap(); - let runtime = runtime(); - let saved = runtime - .phase1_save_revision_intent( - Phase1ReviseIntent::new( - target.clone(), - Phase1AddCommand::CreateEvent(CreateEvent::date(event)), - Vec::new(), - form.clone(), - ) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(saved.replacement().form(), Some(&form)); - - let changed_identity = AuthoredCalendarDateEvent::new( - "different-market", - "Winter market", - CalendarDate::parse("2026-12-31").unwrap(), - ) - .unwrap(); - assert_eq!( - runtime - .phase1_save_revision_intent( - Phase1ReviseIntent::new( - target, - Phase1AddCommand::CreateEvent(CreateEvent::date(changed_identity)), - Vec::new(), - Phase1DraftFormSnapshot { - identifier: Some("different-market".to_owned()), - ..form - }, - ) - .unwrap(), - ) - .await - .unwrap_err(), - Phase1DraftError::InvalidRevision - ); - } - - #[tokio::test] - async fn kind_one_revision_never_creates_retraction_before_replacement_acceptance() { - let profile = radroots_sdk::transport::RelayProfile::explicit( - radroots_sdk::transport::RelayProfileKind::Public, - [radroots_sdk::transport::RelayEndpoint::new( - "wss://offline.example", - radroots_sdk::transport::RelayUrlPolicy::Public, - radroots_sdk::transport::RelayAccess::ReadWrite, - ) - .unwrap()], - ) - .unwrap(); - let signer = radroots_nostr::signing::LocalSigner::new( - radroots_nostr::key::SecretKey::parse(SECRET).unwrap(), - ) - .unwrap(); - let runtime = RadrootsRuntime::from_client_builder( - ClientBuilder::memory_default(), - Some(PublicKey::from_hex(AUTHOR).unwrap()), - None, - Some(std::sync::Arc::new(signer)), - Some(profile), - None, - ) - .unwrap(); - let source_event_id = "b".repeat(64); - let source = - CardSourceIdentity::Event(radroots_event::EventId::parse(&source_event_id).unwrap()); - let target = Phase1RevisionTarget::new( - AddCommandType::CreatePhotoUpdate, - CardId::derive(TodayCardType::PhotoUpdate, &source), - source_event_id, - 1, - None, - AUTHOR, - ) - .unwrap(); - let (command, media) = photo_command(); - let replacement_content = format!("Harvest photo {}", media.url()); - let media_form = Phase1DraftMediaSnapshot { - opaque_reference: media.local_reference().to_owned(), - url: media.url().to_owned(), - sha256: media.sha256().to_owned(), - media_type: media.media_type().to_owned(), - byte_size: media.byte_size(), - width: 1200, - height: 900, - alt: "Harvest".to_owned(), - prepared_at_unix_s: 1_784_347_100, - }; - let saved = runtime - .phase1_save_revision_intent( - Phase1ReviseIntent::new( - target, - command, - vec![media], - Phase1DraftFormSnapshot { - command_type: AddCommandType::CreatePhotoUpdate, - content: replacement_content, - media: vec![media_form], - ..update_form() - }, - ) - .unwrap(), - ) - .await - .unwrap(); - let replacement_id = *saved.replacement().draft().draft_id().as_bytes(); - assert_eq!(saved.policy(), Phase1RevisionPolicy::ReplaceThenRetract); - assert_eq!(saved.replacement().media().len(), 1); - assert_eq!(saved.replacement().form().unwrap().media.len(), 1); - assert!(saved.retraction().is_none()); - - let queued = runtime - .phase1_queue_add_intent( - Phase1QueueIntent::new( - replacement_id, - saved.replacement().draft().revision().get(), - ) - .unwrap(), - ) - .await - .unwrap(); - runtime - .phase1_sign_queued_draft(replacement_id, queued.draft().revision().get()) - .await - .unwrap(); - let recovered = runtime - .phase1_revision_status(replacement_id) - .await - .unwrap(); - assert!(recovered.retraction().is_none()); - assert_eq!(recovered.phase(), Phase1RevisionPhase::ReplacementPending); - - let cancelled = runtime - .phase1_cancel_revision(replacement_id) - .await - .unwrap(); - assert_eq!(cancelled.phase(), Phase1RevisionPhase::Cancelled); - assert!(cancelled.retraction().is_none()); - } - - #[tokio::test] - async fn revision_coordinator_reopens_from_sqlite_without_losing_ordering() { - let root = tempfile::tempdir().unwrap(); - let store = MobileUserStoreConfig::from_encoded( - root.path(), - AUTHOR, - "0404040404040404040404040404040404040404040404040404040404040404", - 1_900_000_000_000, - ProtectedDataAvailability::Available, - ) - .unwrap(); - std::fs::create_dir_all(store.owner_directory()).unwrap(); - let runtime = RuntimeBuilder::new(store.clone()).build().await.unwrap(); - let source_event_id = "c".repeat(64); - let target = Phase1RevisionTarget::new( - AddCommandType::CreateAsk, - CardId::derive( - TodayCardType::Ask, - &CardSourceIdentity::Event( - radroots_event::EventId::parse(&source_event_id).unwrap(), - ), - ), - source_event_id, - 1, - None, - AUTHOR, - ) - .unwrap(); - let saved = runtime - .phase1_save_revision_intent( - Phase1ReviseIntent::new( - target.clone(), - Phase1AddCommand::CreateAsk( - CreateAsk::new("Who has seed potatoes now?", Vec::new()).unwrap(), - ), - Vec::new(), - Phase1DraftFormSnapshot { - command_type: AddCommandType::CreateAsk, - content: "Who has seed potatoes now?".to_owned(), - ..update_form() - }, - ) - .unwrap(), - ) - .await - .unwrap(); - let id = *saved.replacement().draft().draft_id().as_bytes(); - let queued = runtime - .phase1_queue_draft( - id, - saved.replacement().draft().revision().get(), - policy(), - saved.replacement().draft().updated_at_unix_ms() + 1, - ) - .await - .unwrap(); - runtime.shutdown().await.unwrap(); - drop(runtime); - - let reopened = RuntimeBuilder::new(store).build().await.unwrap(); - let recovered = reopened.phase1_revision_status(id).await.unwrap(); - assert_eq!(recovered.target(), &target); - assert_eq!(recovered.policy(), Phase1RevisionPolicy::ReplaceThenRetract); - assert_eq!(recovered.phase(), Phase1RevisionPhase::ReplacementPending); - assert_eq!( - recovered.replacement().draft().revision(), - queued.draft().revision() - ); - assert_eq!(recovered.replacement().state(), Phase1OutboxState::Queued); - assert!(recovered.retraction().is_none()); - assert_eq!( - recovered.replacement().form().unwrap().content, - "Who has seed potatoes now?" - ); - reopened.shutdown().await.unwrap(); - } - - #[tokio::test] - async fn form_snapshots_reopen_exactly_and_freeze_after_queue() { - let runtime = runtime(); - let id = [6; 16]; - let saved = runtime - .phase1_save_draft_with_form( - id, - Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), - 1_900_000_000, - Vec::new(), - update_form(), - None, - 10, - ) - .await - .unwrap(); - assert_eq!(saved.kind(), Phase1DraftKind::Add); - assert_eq!(saved.form(), Some(&update_form())); - assert_eq!( - runtime.phase1_draft_status(id).await.unwrap().form(), - Some(&update_form()) - ); - - let queued = runtime - .phase1_queue_draft(id, 1, policy(), 11) - .await - .unwrap(); - assert_eq!(queued.form(), Some(&update_form())); - assert_eq!( - runtime - .phase1_save_draft_with_form( - id, - Phase1AddCommand::CreateUpdate(CreateUpdate::new("Changed").unwrap()), - 1_900_000_001, - Vec::new(), - update_form(), - Some(queued.draft().revision().get()), - 12, - ) - .await - .unwrap_err(), - Phase1DraftError::RevisionConflict - ); - } - - #[tokio::test] - async fn retraction_is_independent_and_add_advance_attempts_delivery() { - let runtime = signing_runtime(); - let target = CardId::parse(&"a".repeat(64)).unwrap(); - let retraction = runtime - .phase1_save_retraction_draft( - [5; 16], - AddCommandType::CreateUpdate, - target, - &"b".repeat(64), - 1, - None, - "Replaced by a corrected copy", - 1_900_000_000, - 20, - ) - .await - .unwrap(); - assert_eq!(retraction.kind(), Phase1DraftKind::Retraction); - assert_eq!(retraction.card_id(), target); - assert!(retraction.form().is_none()); - let queued = runtime - .phase1_queue_draft([5; 16], retraction.draft().revision().get(), policy(), 21) - .await - .unwrap(); - let signed_retraction = runtime - .phase1_sign_queued_draft([5; 16], queued.draft().revision().get()) - .await; - let signed_retraction = signed_retraction.unwrap(); - assert_eq!(signed_retraction.kind(), Phase1DraftKind::Retraction); - let push = signed_retraction - .push() - .expect("durable retraction operation"); - assert_eq!( - push.artifact() - .signed() - .expect("signed retraction") - .event() - .kind(), - 5 - ); - - let saved = runtime - .phase1_save_draft( - [4; 16], - Phase1AddCommand::CreateUpdate(CreateUpdate::new("Deliver me").unwrap()), - 1_900_000_001, - Vec::new(), - None, - 30, - ) - .await - .unwrap(); - let queued = runtime - .phase1_queue_draft([4; 16], saved.draft().revision().get(), policy(), 31) - .await - .unwrap(); - let _ = runtime - .phase1_advance_draft([4; 16], queued.draft().revision().get()) - .await; - let advanced = runtime.phase1_draft_status([4; 16]).await.unwrap(); - let push = advanced.push().expect("durable add operation"); - assert!(push.artifact().admission_state().is_admitted()); - assert!(!push.delivery_plan().attempts().is_empty()); - assert!(matches!( - advanced.state(), - Phase1OutboxState::Retryable - | Phase1OutboxState::PartiallyDelivered - | Phase1OutboxState::Complete - | Phase1OutboxState::Terminal - )); - } - - #[tokio::test] - async fn media_free_draft_queues_offline_and_recovers_exactly() { - let runtime = runtime(); - let id = [7; 16]; - let draft = runtime - .phase1_save_draft( - id, - Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest").unwrap()), - 1_900_000_000, - Vec::new(), - None, - 10, - ) - .await - .unwrap(); - assert_eq!(draft.state(), Phase1OutboxState::Draft); - let queued = runtime - .phase1_queue_draft(id, 1, policy(), 11) - .await - .unwrap(); - assert_eq!(queued.state(), Phase1OutboxState::Queued); - assert_eq!(queued.draft().revision().get(), 3); - assert!(queued.push().is_some()); - let recovered = runtime.phase1_recover_draft_queue(id, 12).await.unwrap(); - assert_eq!(recovered.draft(), queued.draft()); - assert_eq!(recovered.push(), queued.push()); - } - - #[tokio::test] - async fn queue_recovery_closes_both_preparation_crash_windows() { - let runtime = runtime(); - for (id_byte, prepare_before_recovery) in [(10, false), (11, true)] { - let id = [id_byte; 16]; - let saved = runtime - .phase1_save_draft( - id, - Phase1AddCommand::CreateUpdate(CreateUpdate::new("Recover").unwrap()), - 1_900_000_010, - Vec::new(), - None, - 40, - ) - .await - .unwrap(); - let mut payload = Phase1DraftPayload::decode(saved.draft()).unwrap(); - payload.queue = Some(policy()); - let bytes = payload.encode().unwrap(); - let draft_id = saved.draft().draft_id(); - let operation = operation_id(draft_id, bytes.as_slice()).unwrap(); - let operation = OperationInstanceId::new(*operation.as_bytes()).unwrap(); - let ready = saved - .draft() - .successor(bytes, AuthoredDraftStage::ReadyToSign, Some(operation), 41) - .unwrap(); - runtime - .storage() - .unwrap() - .append_authored_draft(ready.clone(), Some(saved.draft().revision())) - .await - .unwrap(); - if prepare_before_recovery { - runtime - .sync() - .unwrap() - .prepare_push(push_request(&ready).unwrap()) - .await - .unwrap(); - } - let recovered = runtime.phase1_recover_draft_queue(id, 42).await.unwrap(); - assert_eq!(recovered.draft().stage(), AuthoredDraftStage::Queued); - assert_eq!(recovered.draft().revision().get(), 3); - assert!(recovered.push().is_some()); - } - } - - #[tokio::test] - async fn all_five_add_flows_queue_and_sign_without_network_access() { - let runtime = signing_runtime(); - let (photo, media) = photo_command(); - let commands = [ - ( - Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), - Vec::new(), - ), - (photo, vec![media]), - ( - Phase1AddCommand::CreateAsk(CreateAsk::new("Who has basil?", Vec::new()).unwrap()), - Vec::new(), - ), - ( - Phase1AddCommand::CreateEvent(CreateEvent::date( - AuthoredCalendarDateEvent::new( - "market-day", - "Saturday Market", - CalendarDate::parse("2026-08-08").unwrap(), - ) - .unwrap(), - )), - Vec::new(), - ), - ( - Phase1AddCommand::CreateFoodAvailability(CreateFoodAvailability::new(food())), - Vec::new(), - ), - ]; - for (index, (command, media)) in commands.into_iter().enumerate() { - let mut id = [30; 16]; - id[15] = u8::try_from(index + 1).unwrap(); - let saved = runtime - .phase1_save_draft( - id, - command, - 1_784_347_200, - media, - None, - 100 + u64::try_from(index).unwrap() * 10, - ) - .await - .unwrap(); - let queued = runtime - .phase1_queue_draft( - id, - saved.draft().revision().get(), - policy(), - 101 + u64::try_from(index).unwrap() * 10, - ) - .await - .unwrap(); - assert_eq!(queued.state(), Phase1OutboxState::Queued); - assert_eq!(queued.command_type(), CANONICAL_ADD_COMMAND_TYPES[index]); - let signed = runtime - .phase1_sign_queued_draft(id, queued.draft().revision().get()) - .await - .unwrap(); - assert_eq!(signed.state(), Phase1OutboxState::Signed); - assert_eq!( - signed - .push() - .and_then(|push| push.artifact().signed()) - .expect("signed artifact") - .event() - .kind(), - match index { - 0..=2 => 1, - 3 => 31_922, - 4 => 30_402, - _ => unreachable!(), - } - ); - } - } - - #[tokio::test] - async fn blossom_authorization_uses_the_same_opaque_signer_but_never_the_outbox() { - use radroots_blossom::authorization::{ - AuthorizationContent, AuthorizationTarget, AuthorizationValidation, ServerDomain, - }; - - let runtime = signing_runtime(); - let hash = BlossomSha256::digest(b"exact upload bytes"); - let server = ServerDomain::parse("media.example").unwrap(); - let claim = AuthoredUploadClaim::new( - AuthorizationContent::parse("Upload exact Radroots image").unwrap(), - server.clone(), - hash, - 1_900_000_000, - 60, - ) - .unwrap(); - let header = runtime - .phase1_authorize_blossom_upload( - [71; 16], - [72; 16], - claim, - u64::MAX, - Phase1CancellationPolicy::LocalCooperative, - ) - .await - .unwrap(); - let verified = radroots_nostr::blossom::decode_verify_authorization_header( - header.as_str(), - &AuthorizationValidation::bud11( - AuthorizationTarget::Upload(hash), - server, - 1_900_000_001, - ), - ) - .unwrap(); - assert_eq!(verified.claim().hashes(), &[hash]); - assert!(runtime.phase1_draft_heads(10).await.unwrap().is_empty()); - } - - #[tokio::test] - async fn cancellation_is_terminal_and_preserves_operation_evidence() { - let runtime = runtime(); - let id = [8; 16]; - runtime - .phase1_save_draft( - id, - Phase1AddCommand::CreateUpdate(CreateUpdate::new("Cancelled").unwrap()), - 1_900_000_001, - Vec::new(), - None, - 20, - ) - .await - .unwrap(); - let queued = runtime - .phase1_queue_draft(id, 1, policy(), 21) - .await - .unwrap(); - let cancelled = runtime - .phase1_cancel_draft(id, queued.draft().revision().get(), 22) - .await - .unwrap(); - assert_eq!(cancelled.state(), Phase1OutboxState::Cancelled); - let push = cancelled.push().expect("retained push evidence"); - assert_eq!(push.artifact().signing_state(), SigningState::Cancelled); - assert_eq!( - push.delivery_plan().state(), - AuthoredDeliveryState::Cancelled - ); - assert!(push.settlement().is_settled()); - } - - #[tokio::test] - async fn media_phase_revisions_gate_queue_and_reject_forged_verification() { - let runtime = runtime(); - let id = [9; 16]; - let (command, mut media) = photo_command(); - media.stage = Phase1MediaStage::Pending; - media.upload_attempts = 0; - media.verified_at_unix_ms = None; - media.validate().unwrap(); - let saved = runtime - .phase1_save_draft(id, command, 1_784_347_200, vec![media], None, 30) - .await - .unwrap(); - assert_eq!(saved.state(), Phase1OutboxState::MediaPreparing); - assert_eq!( - runtime - .phase1_queue_draft(id, 1, policy(), 31) - .await - .unwrap_err(), - Phase1DraftError::MediaNotReady - ); - let preparing = runtime - .phase1_update_draft_media( - id, - 1, - saved.media()[0].url(), - Phase1MediaStage::Preparing, - None, - 31, - ) - .await - .unwrap(); - let uploading = runtime - .phase1_update_draft_media( - id, - 2, - preparing.media()[0].url(), - Phase1MediaStage::Uploading, - None, - 32, - ) - .await - .unwrap(); - assert_eq!( - runtime - .phase1_update_draft_media( - id, - 3, - uploading.media()[0].url(), - Phase1MediaStage::Verified, - None, - 33, - ) - .await - .unwrap_err(), - Phase1DraftError::InvalidMedia - ); - assert_eq!( - runtime - .phase1_queue_draft(id, 3, policy(), 34) - .await - .unwrap_err(), - Phase1DraftError::MediaNotReady - ); - assert_eq!(runtime.phase1_draft_heads(10).await.unwrap().len(), 1); - } - - #[test] - fn queue_policy_rejects_duplicates_and_noncanonical_relays() { - assert!( - Phase1QueuePolicy::new( - vec!["wss://relay.example".into(), "wss://relay.example".into()], - Phase1RelaySatisfaction::AnyAccepted, - 1, - Phase1CancellationPolicy::LocalCooperative, - ) - .is_err() - ); - assert!( - Phase1QueuePolicy::new( - vec!["WSS://relay.example".into()], - Phase1RelaySatisfaction::AnyAccepted, - 1, - Phase1CancellationPolicy::LocalCooperative, - ) - .is_err() - ); - } - - fn photo_command() -> (Phase1AddCommand, Phase1MediaPrerequisite) { - let bytes = b"harvest-photo"; - let hash = BlossomSha256::digest(bytes); - let url = format!("https://media.example/{hash}.webp"); - let media_type = MediaType::parse("image/webp").unwrap(); - let descriptor = BlobDescriptor::new( - BlobUrl::parse(url.as_str()).unwrap(), - hash, - bytes.len() as u64, - media_type.clone(), - 1_784_347_100, - ) - .unwrap() - .approve_reference() - .unwrap() - .verify_bytes(bytes, &media_type) - .unwrap(); - let mut prerequisite = - Phase1MediaPrerequisite::new("protected://draft/photo-1", &descriptor).unwrap(); - let image = AuthoredPostImage::new( - AuthoredImage::try_from(descriptor).unwrap(), - PostImageDimensions::new(1200, 900).unwrap(), - "Harvest", - ) - .unwrap(); - let command = Phase1AddCommand::CreatePhotoUpdate( - CreatePhotoUpdate::new(format!("Harvest photo {url}"), vec![image]).unwrap(), - ); - prerequisite.stage = Phase1MediaStage::Verified; - prerequisite.upload_attempts = 2; - prerequisite.verified_at_unix_ms = Some(1_784_347_100_000); - prerequisite.validate().unwrap(); - (command, prerequisite) - } - - fn food() -> FoodAvailabilityDetails { - FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts { - content: FoodContent::new("Carrots available this week.").unwrap(), - identifier: FoodIdentifier::parse("nantes-carrots").unwrap(), - title: FoodText::new("Nantes Carrots").unwrap(), - summary: FoodText::new("Fresh bunches").unwrap(), - published_at: FoodPublishedAt::new(1_784_347_100).unwrap(), - location: FoodText::new("Central Saanich, BC").unwrap(), - price: FoodPrice::new("3", FoodCurrency::parse("CAD").unwrap(), FoodUnit::Pound) - .unwrap(), - quantity: None, - status: FoodAvailabilityStatus::Active, - images: Vec::new(), - }) - .unwrap() - } -} diff --git a/crates/mobile_core/src/runtime/product_surface/projection.rs b/crates/mobile_core/src/runtime/product_surface/projection.rs @@ -1,646 +0,0 @@ -use radroots_event::food::availability::FoodAvailabilityStatus; -use radroots_event_codec::{ - admission::RadrootsAdmittedEvent, decode::post::RadrootsPostClassification, -}; -use serde::{Deserialize, Serialize}; - -use super::{ - CardId, CardLifecycleState, CardSourceIdentity, ClassifiedCard, ContextAdmission, - LocalNetworkAdmission, MediaReference, Phase1StructuralMediaReference, SupportingProfile, - TodayCardType, -}; - -const CLASSIFIED_CARD_SCHEMA_VERSION: u16 = 1; - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum ProductEventClassification { - Card(Box<ClassifiedCard>), - Supporting(SupportingProfile), - Excluded(ProductEventExclusion), -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "PascalCase")] -pub enum ProductEventExclusion { - LocalityNonmatch, - UnsupportedProfile, - InvalidSourceIdentity, -} - -/// Classifies an already signature/id-verified and standard-profile-admitted event. -/// -/// The caller must supply the result of the selected LocalNetwork admission. -/// Replacement and deletion are applied by storage before the event reaches -/// this boundary. No content prose or remote media retrieval influences type. -pub fn classify_admitted_event( - admitted: &RadrootsAdmittedEvent, - context: LocalNetworkAdmission, -) -> ProductEventClassification { - let context = match context { - LocalNetworkAdmission::Included(context) => context, - LocalNetworkAdmission::Excluded { .. } => { - return ProductEventClassification::Excluded(ProductEventExclusion::LocalityNonmatch); - } - }; - - match admitted { - RadrootsAdmittedEvent::Profile(_) => supporting(SupportingProfile::Profile), - RadrootsAdmittedEvent::Reply(_) => supporting(SupportingProfile::Reply), - RadrootsAdmittedEvent::Comment(_) => supporting(SupportingProfile::Comment), - RadrootsAdmittedEvent::DeletionRequest(_) => supporting(SupportingProfile::Deletion), - RadrootsAdmittedEvent::RootPost(event) => { - let card_type = match event.projection().classification() { - RadrootsPostClassification::Update => TodayCardType::Update, - RadrootsPostClassification::PhotoUpdate => TodayCardType::PhotoUpdate, - RadrootsPostClassification::Ask => TodayCardType::Ask, - RadrootsPostClassification::ThreadExcluded => { - return ProductEventClassification::Excluded( - ProductEventExclusion::UnsupportedProfile, - ); - } - _ => { - return ProductEventClassification::Excluded( - ProductEventExclusion::UnsupportedProfile, - ); - } - }; - card( - admitted, - card_type, - context, - post_media(event.projection()), - CardLifecycleState::Active, - ) - } - RadrootsAdmittedEvent::FoodAvailability(event) => { - let lifecycle = match event.projection().status() { - FoodAvailabilityStatus::Active => CardLifecycleState::Active, - FoodAvailabilityStatus::Sold => CardLifecycleState::Sold, - }; - card( - admitted, - TodayCardType::FoodAvailability, - context, - food_media(event.projection()), - lifecycle, - ) - } - RadrootsAdmittedEvent::ContractValidated(event) => match event.contract_id() { - "radroots.calendar.date_event.v1" | "radroots.calendar.time_event.v1" => card( - admitted, - TodayCardType::Event, - context, - calendar_media(event.event().tags_as_vec()), - CardLifecycleState::Active, - ), - _ => ProductEventClassification::Excluded(ProductEventExclusion::UnsupportedProfile), - }, - _ => ProductEventClassification::Excluded(ProductEventExclusion::UnsupportedProfile), - } -} - -const fn supporting(profile: SupportingProfile) -> ProductEventClassification { - ProductEventClassification::Supporting(profile) -} - -fn card( - admitted: &RadrootsAdmittedEvent, - card_type: TodayCardType, - context: ContextAdmission, - media: Vec<MediaReference>, - lifecycle: CardLifecycleState, -) -> ProductEventClassification { - let event = admitted.event(); - let source = match card_type { - TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask => { - CardSourceIdentity::Event(*event.id()) - } - TodayCardType::Event | TodayCardType::FoodAvailability => { - let identifier = event - .tags_as_vec() - .into_iter() - .find(|tag| tag.first().map(String::as_str) == Some("d")) - .and_then(|tag| tag.get(1).cloned()); - let Some(identifier) = identifier else { - return ProductEventClassification::Excluded( - ProductEventExclusion::InvalidSourceIdentity, - ); - }; - let Ok(source) = - CardSourceIdentity::address(event.kind_u32(), event.author().to_hex(), identifier) - else { - return ProductEventClassification::Excluded( - ProductEventExclusion::InvalidSourceIdentity, - ); - }; - source - } - }; - let source_address = match &source { - CardSourceIdentity::Event(_) => None, - CardSourceIdentity::Address { - kind, - author_pubkey, - identifier, - } => Some(format!("{kind}:{author_pubkey}:{identifier}")), - }; - let tags = event.tags_as_vec(); - let title = tag_value(&tags, &["title", "name"]); - let location = matches!( - card_type, - TodayCardType::Event | TodayCardType::FoodAvailability - ) - .then(|| tag_value(&tags, &["location"])) - .flatten(); - let price = matches!(card_type, TodayCardType::FoodAvailability) - .then(|| tag_values(&tags, "price")) - .flatten(); - let price_amount = price.as_ref().and_then(|values| values.first().cloned()); - let price_currency = price.as_ref().and_then(|values| values.get(1).cloned()); - let price_unit = matches!(card_type, TodayCardType::FoodAvailability) - .then(|| tag_value(&tags, &["radroots:price_unit"])) - .flatten(); - let quantity = matches!(card_type, TodayCardType::FoodAvailability) - .then(|| tag_values(&tags, "radroots:quantity")) - .flatten() - .and_then(|values| values.first().cloned()); - let food_summary = matches!(card_type, TodayCardType::FoodAvailability) - .then(|| tag_value(&tags, &["summary"])) - .flatten(); - let food_published_at = matches!(card_type, TodayCardType::FoodAvailability) - .then(|| tag_time(&tags, "published_at")) - .flatten(); - let food_status = matches!(card_type, TodayCardType::FoodAvailability) - .then(|| tag_value(&tags, &["status"])) - .flatten(); - let (effective_at, event_start, event_end) = match card_type { - TodayCardType::Event => { - let start = tag_time(&tags, "start").unwrap_or_else(|| event.created_at_u64()); - (start, Some(start), tag_time(&tags, "end")) - } - TodayCardType::FoodAvailability => ( - tag_time(&tags, "published_at").unwrap_or_else(|| event.created_at_u64()), - None, - None, - ), - TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask => { - (event.created_at_u64(), None, None) - } - }; - ProductEventClassification::Card(Box::new(ClassifiedCard { - schema_version: CLASSIFIED_CARD_SCHEMA_VERSION, - card_id: CardId::derive(card_type, &source), - card_type, - source_event_id: event.id_hex(), - source_address, - author_pubkey: event.author().to_hex(), - contract_id: admitted.contract_id().to_owned(), - title, - content: event.content().to_owned(), - authored_at: event.created_at_u64(), - effective_at, - event_start, - event_end, - location, - price_amount, - price_currency, - price_unit, - quantity, - food_summary, - food_published_at, - food_status, - context_rank: context.rank, - inclusion_reason: context.reason.to_owned(), - media, - lifecycle, - rank: None, - })) -} - -fn tag_value(tags: &[Vec<String>], names: &[&str]) -> Option<String> { - tags.iter().find_map(|tag| { - names - .contains(&tag.first()?.as_str()) - .then(|| tag.get(1).cloned()) - .flatten() - }) -} - -fn tag_values(tags: &[Vec<String>], name: &str) -> Option<Vec<String>> { - tags.iter().find_map(|tag| { - (tag.first().map(String::as_str) == Some(name) && tag.len() > 1).then(|| tag[1..].to_vec()) - }) -} - -fn tag_time(tags: &[Vec<String>], name: &str) -> Option<u64> { - let value = tag_value(tags, &[name])?; - value.parse().ok().or_else(|| { - chrono::NaiveDate::parse_from_str(&value, "%Y-%m-%d") - .ok()? - .and_hms_opt(0, 0, 0)? - .and_utc() - .timestamp() - .try_into() - .ok() - }) -} - -fn post_media( - projection: &radroots_event_codec::decode::post::RadrootsInboundPostProjection, -) -> Vec<MediaReference> { - projection - .imeta() - .iter() - .filter_map(|media| { - let dimensions = media.dimensions(); - media_reference( - media.url()?, - media.sha256().map(str::to_owned), - media.media_type().map(str::to_owned), - dimensions.map(|value| value.width()), - dimensions.map(|value| value.height()), - media.size(), - media.alt().map(str::to_owned), - ) - }) - .collect() -} - -fn food_media( - projection: &radroots_event_codec::decode::food_availability::RadrootsInboundFoodAvailabilityProjection, -) -> Vec<MediaReference> { - projection - .images() - .iter() - .filter_map(|media| { - let dimensions = media.dimensions(); - media_reference( - media.url()?, - blossom_digest(media.url()?), - None, - dimensions.map(|value| value.width()), - dimensions.map(|value| value.height()), - None, - None, - ) - }) - .collect() -} - -fn calendar_media(tags: Vec<Vec<String>>) -> Vec<MediaReference> { - tags.into_iter() - .find(|tag| tag.first().map(String::as_str) == Some("image")) - .and_then(|tag| tag.get(1).cloned()) - .and_then(|url| media_reference(&url, blossom_digest(&url), None, None, None, None, None)) - .into_iter() - .collect() -} - -#[allow(clippy::too_many_arguments)] -fn media_reference( - url: &str, - sha256: Option<String>, - media_type: Option<String>, - width: Option<u32>, - height: Option<u32>, - byte_size: Option<u64>, - alt: Option<String>, -) -> Option<MediaReference> { - Phase1StructuralMediaReference::new(url, sha256, media_type, width, height, byte_size, alt) - .and_then(MediaReference::new) - .ok() -} - -fn blossom_digest(url: &str) -> Option<String> { - let path = url.split_once("://")?.1.split_once('/')?.1; - let candidate = path.split(['.', '/', '?', '#']).next()?; - (candidate.len() == 64 - && candidate - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) - .then(|| candidate.to_owned()) -} - -#[cfg(test)] -mod tests { - use super::*; - use nostr::secp256k1::Message; - use nostr::{Keys, SECP256K1}; - use radroots_event::{ - Event, envelope::EventEnvelopeParts, wire::compute_canonical_nip01_event_id, - }; - use radroots_event_codec::{admission::admit_verified_event, verify::verify_nip01_event}; - - const SECRET: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; - - fn admitted(kind: u32, tags: Vec<Vec<&str>>, content: &str) -> RadrootsAdmittedEvent { - admitted_owned( - kind, - tags.into_iter() - .map(|tag| tag.into_iter().map(str::to_owned).collect()) - .collect(), - content, - ) - } - - fn admitted_owned(kind: u32, tags: Vec<Vec<String>>, content: &str) -> RadrootsAdmittedEvent { - let keys = Keys::parse(SECRET).expect("key"); - let author = keys.public_key().to_string(); - let created_at = 2_000_000_000; - let id = compute_canonical_nip01_event_id(&author, created_at, kind, &tags, content) - .expect("id"); - let message = Message::from_digest(*id.as_bytes()); - let signature = SECP256K1.sign_schnorr_no_aux_rand( - &message, - &nostr::secp256k1::Keypair::from_secret_key(SECP256K1, keys.secret_key()), - ); - let event = Event::new(EventEnvelopeParts { - id: id.to_hex(), - author, - created_at, - kind, - tags, - content: content.into(), - sig: signature.to_string(), - }) - .expect("event"); - let verified = verify_nip01_event(event).expect("verified"); - admit_verified_event(verified).expect("admitted") - } - - const fn context() -> LocalNetworkAdmission { - LocalNetworkAdmission::Included(ContextAdmission { - rank: super::super::ContextRank::MissingLocalityFallback, - reason: "locality_missing_fallback", - }) - } - - fn card_type(event: RadrootsAdmittedEvent) -> TodayCardType { - match classify_admitted_event(&event, context()) { - ProductEventClassification::Card(card) => card.card_type, - other => panic!("expected card, got {other:?}"), - } - } - - fn card(event: RadrootsAdmittedEvent) -> ClassifiedCard { - match classify_admitted_event(&event, context()) { - ProductEventClassification::Card(card) => *card, - other => panic!("expected card, got {other:?}"), - } - } - - #[test] - fn exact_five_card_classifier_precedence_is_protocol_structural() { - assert_eq!( - card_type(admitted(1, vec![], "ordinary note")), - TodayCardType::Update - ); - let digest_tag = format!("x {}", "a".repeat(64)); - assert_eq!( - card_type(admitted( - 1, - vec![vec![ - "imeta", - "url https://media.example/a.jpg", - &digest_tag, - "m image/jpeg", - "dim 10x20", - "size 123", - "alt field photo" - ]], - "photo https://media.example/a.jpg", - )), - TodayCardType::PhotoUpdate - ); - assert_eq!( - card_type(admitted( - 1, - vec![vec!["t", " RADROOTS-ASK "], vec!["imeta", "broken"]], - "Anyone have carrots", - )), - TodayCardType::Ask - ); - assert_eq!( - card_type(admitted( - 31_923, - vec![ - vec!["d", "market-2026"], - vec!["title", "Saturday market"], - vec!["start", "2000000100"], - vec!["end", "2000000200"], - vec!["D", "23148"], - ], - "Farm market", - )), - TodayCardType::Event - ); - assert_eq!( - card_type(admitted( - 30_402, - vec![ - vec!["d", "carrots"], - vec!["title", "Carrots"], - vec!["summary", "Fresh bunches"], - vec!["published_at", "1999999999"], - vec!["location", "Saanich"], - vec!["price", "3", "CAD"], - vec!["radroots:price_unit", "lb"], - vec!["status", "active"], - ], - "Carrots available", - )), - TodayCardType::FoodAvailability - ); - } - - #[test] - fn event_and_food_cards_preserve_required_rendering_fields() { - let event = card(admitted( - 31_923, - vec![ - vec!["d", "market-2026"], - vec!["title", "Saturday market"], - vec!["start", "2000000100"], - vec!["D", "23148"], - vec!["location", "Town square"], - ], - "Farm market", - )); - assert_eq!(event.location.as_deref(), Some("Town square")); - assert_eq!(event.price_amount, None); - - let food = card(admitted( - 30_402, - vec![ - vec!["d", "carrots"], - vec!["title", "Carrots"], - vec!["summary", "Fresh bunches"], - vec!["published_at", "1999999999"], - vec!["location", "Saanich"], - vec!["price", "3", "CAD"], - vec!["radroots:price_unit", "lb"], - vec!["radroots:quantity", "12", "lb"], - vec!["status", "active"], - ], - "Carrots available", - )); - assert_eq!(food.location.as_deref(), Some("Saanich")); - assert_eq!(food.price_amount.as_deref(), Some("3")); - assert_eq!(food.price_currency.as_deref(), Some("CAD")); - assert_eq!(food.price_unit.as_deref(), Some("lb")); - assert_eq!(food.quantity.as_deref(), Some("12")); - assert_eq!(food.food_summary.as_deref(), Some("Fresh bunches")); - assert_eq!(food.food_published_at, Some(1_999_999_999)); - assert_eq!(food.food_status.as_deref(), Some("active")); - } - - #[test] - fn ordinary_standard_kind_one_needs_no_product_marker() { - let event = admitted(1, vec![vec!["t", "gardening"]], "Seedlings are ready"); - let ProductEventClassification::Card(card) = classify_admitted_event(&event, context()) - else { - panic!("standard kind-1 must remain admitted"); - }; - assert_eq!(card.card_type, TodayCardType::Update); - assert_eq!( - card.context_rank, - super::super::ContextRank::MissingLocalityFallback - ); - } - - #[test] - fn malformed_address_and_media_helpers_fail_closed() { - assert_eq!(tag_value(&[Vec::new()], &["title"]), None); - assert_eq!(tag_value(&[vec!["title".to_owned()]], &["title"]), None); - assert_eq!(tag_values(&[Vec::new()], "price"), None); - assert_eq!(tag_values(&[vec!["price".to_owned()]], "price"), None); - assert_eq!( - tag_values( - &[vec!["price".to_owned(), "3".to_owned(), "CAD".to_owned()]], - "price" - ), - Some(vec!["3".to_owned(), "CAD".to_owned()]) - ); - assert_eq!( - tag_time(&[vec!["start".to_owned(), "bad".to_owned()]], "start"), - None - ); - assert!( - tag_time( - &[vec!["start".to_owned(), "2026-08-13".to_owned()]], - "start" - ) - .is_some() - ); - - assert_eq!(blossom_digest("not-a-url"), None); - assert_eq!(blossom_digest("https://example.test"), None); - assert_eq!(blossom_digest("https://example.test/short"), None); - assert_eq!( - blossom_digest(&format!("https://example.test/{}", "G".repeat(64))), - None - ); - assert_eq!( - blossom_digest(&format!("https://example.test/{}/file.jpg", "a".repeat(64))), - Some("a".repeat(64)) - ); - assert_eq!( - media_reference("not-a-url", None, None, None, None, None, None,), - None - ); - } - - #[test] - fn supporting_profiles_never_become_cards_and_nonmatches_are_excluded() { - let profile = admitted(0, vec![], r#"{"name":"Farm"}"#); - let reply = admitted(1, vec![vec!["e", &"a".repeat(64), "", "root"]], "Reply"); - let author = Keys::parse(SECRET).expect("key").public_key().to_string(); - let root_id = "a".repeat(64); - let comment = admitted_owned( - 1_111, - vec![ - vec!["E".into(), root_id.clone(), String::new(), author.clone()], - vec!["K".into(), "30402".into()], - vec!["P".into(), author.clone()], - vec!["e".into(), root_id.clone(), String::new(), author.clone()], - vec!["k".into(), "30402".into()], - vec!["p".into(), author], - ], - "Comment", - ); - let deletion = admitted(5, vec![vec!["e", &root_id]], "Superseded"); - for (event, expected) in [ - (profile, SupportingProfile::Profile), - (reply, SupportingProfile::Reply), - (comment, SupportingProfile::Comment), - (deletion, SupportingProfile::Deletion), - ] { - assert_eq!( - classify_admitted_event(&event, context()), - ProductEventClassification::Supporting(expected) - ); - } - - let nip98 = admitted( - 27_235, - vec![ - vec!["u", "https://media.example/upload"], - vec!["method", "GET"], - ], - "{}", - ); - assert_eq!( - classify_admitted_event(&nip98, context()), - ProductEventClassification::Excluded(ProductEventExclusion::UnsupportedProfile) - ); - - let update = admitted(1, vec![], "ordinary note"); - assert_eq!( - classify_admitted_event( - &update, - LocalNetworkAdmission::Excluded { - reason: "locality_nonmatch" - } - ), - ProductEventClassification::Excluded(ProductEventExclusion::LocalityNonmatch) - ); - } - - #[test] - fn addressable_replacements_keep_stable_card_identity() { - let first = admitted( - 30_402, - vec![ - vec!["d", "carrots"], - vec!["title", "Carrots"], - vec!["summary", "Fresh"], - vec!["published_at", "1999999999"], - vec!["location", "Saanich"], - vec!["price", "3", "CAD"], - vec!["radroots:price_unit", "lb"], - vec!["status", "active"], - ], - "First", - ); - let second = admitted( - 30_402, - vec![ - vec!["d", "carrots"], - vec!["title", "Carrots"], - vec!["summary", "Fresh"], - vec!["published_at", "1999999999"], - vec!["location", "Saanich"], - vec!["price", "4", "CAD"], - vec!["radroots:price_unit", "lb"], - vec!["status", "active"], - ], - "Second", - ); - let ids = [first, second].map(|event| match classify_admitted_event(&event, context()) { - ProductEventClassification::Card(card) => card.card_id, - other => panic!("expected card, got {other:?}"), - }); - assert_eq!(ids[0], ids[1]); - } -} diff --git a/crates/mobile_core/src/runtime/product_surface/ranking.rs b/crates/mobile_core/src/runtime/product_surface/ranking.rs @@ -1,259 +0,0 @@ -use core::cmp::Ordering; - -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use thiserror::Error; - -use super::{CardId, ContextRank, TodayCardType}; - -pub const TODAY_RANK_SCHEMA_VERSION: u16 = 1; -pub const TODAY_RANK_ALGORITHM_VERSION: u16 = 1; -const RANK_DIGEST_DOMAIN: &[u8] = b"radroots.today-rank.v1\0"; -const UPCOMING_EVENT_WINDOW_SECONDS: u64 = 7 * 24 * 60 * 60; - -/// Exact time inputs used by the deliberately small Phase 1 ranking function. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum TimeRelevance { - Published, - Event { start: u64, end: Option<u64> }, - FoodAvailability { active: bool }, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct TodayRankInput { - pub card_type: TodayCardType, - pub context_rank: ContextRank, - pub as_of: u64, - pub effective_at: u64, - pub time: TimeRelevance, - pub card_id: CardId, -} - -#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] -pub enum RankError { - #[error("card type and time-relevance input do not match")] - MismatchedTimeProfile, - #[error("event end must be later than its start")] - InvalidEventRange, -} - -/// Lexicographic Today order key. -/// -/// Its [`Ord`] implementation sorts directly into feed order: higher context, -/// time relevance, and effective time first, then lower card ID. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TodayRank { - pub schema_version: u16, - pub algorithm_version: u16, - pub context_rank: ContextRank, - pub time_relevance_rank: u8, - pub effective_at: u64, - pub card_id: CardId, -} - -impl TodayRank { - pub fn derive(input: TodayRankInput) -> Result<Self, RankError> { - let time_relevance_rank = time_relevance_rank(input)?; - Ok(Self { - schema_version: TODAY_RANK_SCHEMA_VERSION, - algorithm_version: TODAY_RANK_ALGORITHM_VERSION, - context_rank: input.context_rank, - time_relevance_rank, - effective_at: input.effective_at, - card_id: input.card_id, - }) - } - - pub fn digest(self) -> [u8; 32] { - let mut digest = Sha256::new(); - digest.update(RANK_DIGEST_DOMAIN); - digest.update(self.schema_version.to_be_bytes()); - digest.update(self.algorithm_version.to_be_bytes()); - digest.update([self.context_rank.value()]); - digest.update([self.time_relevance_rank]); - digest.update(self.effective_at.to_be_bytes()); - digest.update(self.card_id.as_bytes()); - digest.finalize().into() - } - - pub fn digest_hex(self) -> String { - hex::encode(self.digest()) - } -} - -impl Ord for TodayRank { - fn cmp(&self, other: &Self) -> Ordering { - other - .context_rank - .cmp(&self.context_rank) - .then_with(|| other.time_relevance_rank.cmp(&self.time_relevance_rank)) - .then_with(|| other.effective_at.cmp(&self.effective_at)) - .then_with(|| self.card_id.cmp(&other.card_id)) - } -} - -impl PartialOrd for TodayRank { - fn partial_cmp(&self, other: &Self) -> Option<Ordering> { - Some(self.cmp(other)) - } -} - -fn time_relevance_rank(input: TodayRankInput) -> Result<u8, RankError> { - match (input.card_type, input.time) { - ( - TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask, - TimeRelevance::Published, - ) => Ok(1), - (TodayCardType::FoodAvailability, TimeRelevance::FoodAvailability { active }) => { - Ok(if active { 3 } else { 0 }) - } - (TodayCardType::Event, TimeRelevance::Event { start, end }) => { - if end.is_some_and(|end| end <= start) { - return Err(RankError::InvalidEventRange); - } - if end.is_some_and(|end| input.as_of >= end) { - return Ok(0); - } - if input.as_of >= start { - return Ok(4); - } - if start.saturating_sub(input.as_of) <= UPCOMING_EVENT_WINDOW_SECONDS { - Ok(3) - } else { - Ok(2) - } - } - _ => Err(RankError::MismatchedTimeProfile), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn id(value: char) -> CardId { - CardId::parse(&value.to_string().repeat(64)).expect("card id") - } - - fn input(card_type: TodayCardType, time: TimeRelevance) -> TodayRankInput { - TodayRankInput { - card_type, - context_rank: ContextRank::LocalityMatch, - as_of: 2_000_000_000, - effective_at: 1_999_999_900, - time, - card_id: id('a'), - } - } - - #[test] - fn time_relevance_boundaries_are_exact() { - assert_eq!( - TodayRank::derive(input(TodayCardType::Update, TimeRelevance::Published)) - .expect("update") - .time_relevance_rank, - 1 - ); - assert_eq!( - TodayRank::derive(input( - TodayCardType::FoodAvailability, - TimeRelevance::FoodAvailability { active: true } - )) - .expect("food") - .time_relevance_rank, - 3 - ); - assert_eq!( - TodayRank::derive(input( - TodayCardType::FoodAvailability, - TimeRelevance::FoodAvailability { active: false } - )) - .expect("sold food") - .time_relevance_rank, - 0 - ); - for (start, end, expected) in [ - (1_999_999_900, Some(2_000_000_100), 4), - (1_999_999_900, None, 4), - (2_000_604_800, Some(2_000_604_900), 3), - (2_000_604_801, None, 2), - (1_999_999_000, Some(2_000_000_000), 0), - ] { - assert_eq!( - TodayRank::derive(input( - TodayCardType::Event, - TimeRelevance::Event { start, end } - )) - .expect("event") - .time_relevance_rank, - expected - ); - } - } - - #[test] - fn tuple_sorts_in_locked_feed_order_and_has_a_fixed_digest() { - let exact = TodayRank::derive(input(TodayCardType::Update, TimeRelevance::Published)) - .expect("rank"); - assert_eq!( - exact.digest_hex(), - "c7792876c8177f6f5420cc0f9aa84fb3c478f0bc6555c94ea5a7288502d6e4db" - ); - let fallback = TodayRank { - context_rank: ContextRank::MissingLocalityFallback, - time_relevance_rank: 4, - effective_at: exact.effective_at + 100, - card_id: id('e'), - ..exact - }; - let lower_time = TodayRank { - time_relevance_rank: 0, - effective_at: exact.effective_at + 200, - card_id: id('d'), - ..exact - }; - let older = TodayRank { - effective_at: exact.effective_at - 1, - card_id: id('c'), - ..exact - }; - let tie_high_id = TodayRank { - effective_at: exact.effective_at + 1, - card_id: id('b'), - ..exact - }; - let tie_low_id = TodayRank { - effective_at: exact.effective_at + 1, - card_id: id('a'), - ..exact - }; - let mut values = vec![fallback, lower_time, older, tie_high_id, tie_low_id]; - values.sort(); - assert_eq!( - values, - vec![tie_low_id, tie_high_id, older, lower_time, fallback] - ); - } - - #[test] - fn mismatched_and_invalid_time_inputs_fail_closed() { - assert_eq!( - TodayRank::derive(input( - TodayCardType::Update, - TimeRelevance::FoodAvailability { active: true } - )), - Err(RankError::MismatchedTimeProfile) - ); - assert_eq!( - TodayRank::derive(input( - TodayCardType::Event, - TimeRelevance::Event { - start: 10, - end: Some(10), - } - )), - Err(RankError::InvalidEventRange) - ); - } -} diff --git a/crates/mobile_core/src/runtime/product_surface/settings.rs b/crates/mobile_core/src/runtime/product_surface/settings.rs @@ -1,1669 +0,0 @@ -//! Versioned, secret-safe mobile identity and product configuration policy. - -use std::collections::BTreeSet; - -use radroots_event::{ - media::AuthoredImage, - profile::{AuthoredProfile, Nip05Identifier}, -}; -use radroots_identity::PublicKey; -use radroots_storage::projection::{ - ProjectionDocument, ProjectionGeneration, ProjectionId, ProjectionStore, -}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -use super::super::RadrootsRuntime; - -pub const DEFAULT_PUBLIC_RELAY: &str = "wss://radroots.org"; - -pub const MOBILE_SETTINGS_SCHEMA_VERSION: u16 = 1; -pub const DEFAULT_PUBLIC_BLOSSOM_ORIGIN: &str = "https://blossom.radroots.org"; -pub const DEFAULT_SIMULATOR_RELAY: &str = "ws://127.0.0.1:21000"; -pub const DEFAULT_SIMULATOR_BLOSSOM_ORIGIN: &str = "http://127.0.0.1:21100"; - -const SETTINGS_PROJECTION_ID: &str = "radroots.mobile.settings.v1"; -const SETTINGS_DOCUMENT_KEY: &str = "settings.current"; -const SETTINGS_GENERATION_DOMAIN: &[u8] = b"radroots.mobile.settings.generation.v1"; -const IDENTITY_ID_MAX_BYTES: usize = 128; -const OPERATION_ID_MAX_BYTES: usize = 128; -const PROFILE_NAME_MAX_BYTES: usize = 256; -const PROFILE_DISPLAY_NAME_MAX_BYTES: usize = 512; -const PROFILE_ABOUT_MAX_BYTES: usize = 8 * 1024; -const RELAY_ENDPOINT_MAX: usize = 32; -const BLOSSOM_FALLBACK_MAX: usize = 15; -const MEDIA_CACHE_MIN_BYTES: u64 = 16 * 1024 * 1024; -const MEDIA_CACHE_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024; -const MEDIA_CACHE_MAX_ARTIFACTS: u32 = 10_000; - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum IdentityLockState { - Locked, - Unlocked, -} - -/// Secret-safe reference to one Apple-custodied identity. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct IdentityRecord { - id: String, - public_key_hex: String, -} - -impl IdentityRecord { - pub fn new(id: impl Into<String>, public_key_hex: &str) -> Result<Self, IdentitySettingsError> { - let id = id.into(); - validate_identifier(&id, IDENTITY_ID_MAX_BYTES) - .then_some(()) - .ok_or(IdentitySettingsError::InvalidIdentityId)?; - let public_key = PublicKey::from_hex(public_key_hex) - .map_err(|_| IdentitySettingsError::InvalidPublicKey)?; - Ok(Self { - id, - public_key_hex: public_key.to_hex(), - }) - } - - pub fn id(&self) -> &str { - self.id.as_str() - } - - pub fn public_key_hex(&self) -> &str { - self.public_key_hex.as_str() - } -} - -/// Durable identity selection. It contains public metadata only; key material -/// and user-presence state remain in the native custody provider. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct IdentityState { - identities: Vec<IdentityRecord>, - active_identity_id: Option<String>, - lock_state: IdentityLockState, - pending_import_operation_id: Option<String>, -} - -impl Default for IdentityState { - fn default() -> Self { - Self { - identities: Vec::new(), - active_identity_id: None, - lock_state: IdentityLockState::Locked, - pending_import_operation_id: None, - } - } -} - -impl IdentityState { - pub fn new( - identities: Vec<IdentityRecord>, - active_identity_id: Option<String>, - lock_state: IdentityLockState, - pending_import_operation_id: Option<String>, - ) -> Result<Self, IdentitySettingsError> { - let mut ids = BTreeSet::new(); - let mut public_keys = BTreeSet::new(); - for identity in &identities { - if !ids.insert(identity.id()) { - return Err(IdentitySettingsError::DuplicateIdentityId); - } - if !public_keys.insert(identity.public_key_hex()) { - return Err(IdentitySettingsError::DuplicatePublicKey); - } - } - if let Some(active) = active_identity_id.as_deref() - && !ids.contains(active) - { - return Err(IdentitySettingsError::UnknownIdentity); - } - if let Some(operation_id) = pending_import_operation_id.as_deref() - && !validate_identifier(operation_id, OPERATION_ID_MAX_BYTES) - { - return Err(IdentitySettingsError::InvalidOperationId); - } - if active_identity_id.is_none() && lock_state == IdentityLockState::Unlocked { - return Err(IdentitySettingsError::NoActiveIdentity); - } - Ok(Self { - identities, - active_identity_id, - lock_state, - pending_import_operation_id, - }) - } - - pub fn identities(&self) -> &[IdentityRecord] { - self.identities.as_slice() - } - - pub fn active_identity_id(&self) -> Option<&str> { - self.active_identity_id.as_deref() - } - - pub const fn lock_state(&self) -> IdentityLockState { - self.lock_state - } - - pub fn pending_import_operation_id(&self) -> Option<&str> { - self.pending_import_operation_id.as_deref() - } - - /// Applies a secret-free identity state transition after the native host - /// has completed any required Keychain or user-presence operation. - pub fn apply(&self, command: IdentityCommand) -> Result<Self, IdentitySettingsError> { - let mut next = self.clone(); - match command { - IdentityCommand::BeginImport { operation_id } => { - if next.pending_import_operation_id.is_some() { - return Err(IdentitySettingsError::ImportAlreadyPending); - } - if !validate_identifier(&operation_id, OPERATION_ID_MAX_BYTES) { - return Err(IdentitySettingsError::InvalidOperationId); - } - next.pending_import_operation_id = Some(operation_id); - } - IdentityCommand::CompleteImport { - operation_id, - identity, - } => { - if next.pending_import_operation_id.as_deref() != Some(operation_id.as_str()) { - return Err(IdentitySettingsError::ImportOperationMismatch); - } - if next - .identities - .iter() - .any(|value| value.id() == identity.id()) - { - return Err(IdentitySettingsError::DuplicateIdentityId); - } - if next - .identities - .iter() - .any(|value| value.public_key_hex() == identity.public_key_hex()) - { - return Err(IdentitySettingsError::DuplicatePublicKey); - } - next.active_identity_id = Some(identity.id().to_owned()); - next.identities.push(identity); - next.lock_state = IdentityLockState::Locked; - next.pending_import_operation_id = None; - } - IdentityCommand::CancelImport { operation_id } => { - if next.pending_import_operation_id.as_deref() != Some(operation_id.as_str()) { - return Err(IdentitySettingsError::ImportOperationMismatch); - } - next.pending_import_operation_id = None; - } - IdentityCommand::Select { identity_id } => { - if !next - .identities - .iter() - .any(|identity| identity.id() == identity_id) - { - return Err(IdentitySettingsError::UnknownIdentity); - } - next.active_identity_id = Some(identity_id); - next.lock_state = IdentityLockState::Locked; - } - IdentityCommand::Lock => { - if next.active_identity_id.is_none() { - return Err(IdentitySettingsError::NoActiveIdentity); - } - next.lock_state = IdentityLockState::Locked; - } - IdentityCommand::Unlock => { - if next.active_identity_id.is_none() { - return Err(IdentitySettingsError::NoActiveIdentity); - } - next.lock_state = IdentityLockState::Unlocked; - } - IdentityCommand::Recover => { - if next.active_identity_id.is_none() { - return Err(IdentitySettingsError::NoActiveIdentity); - } - next.lock_state = IdentityLockState::Locked; - next.pending_import_operation_id = None; - } - } - Ok(next) - } -} - -/// Secret-free intent/result commands. `CompleteImport` carries only the -/// public identity returned by the Apple custody provider. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum IdentityCommand { - BeginImport { - operation_id: String, - }, - CompleteImport { - operation_id: String, - identity: IdentityRecord, - }, - CancelImport { - operation_id: String, - }, - Select { - identity_id: String, - }, - Lock, - Unlock, - Recover, -} - -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] -pub enum IdentitySettingsError { - #[error("identity id is invalid")] - InvalidIdentityId, - #[error("identity public key is invalid")] - InvalidPublicKey, - #[error("identity operation id is invalid")] - InvalidOperationId, - #[error("identity id is duplicated")] - DuplicateIdentityId, - #[error("identity public key is duplicated")] - DuplicatePublicKey, - #[error("identity is unknown")] - UnknownIdentity, - #[error("no active identity exists")] - NoActiveIdentity, - #[error("an identity import is already pending")] - ImportAlreadyPending, - #[error("identity import operation does not match")] - ImportOperationMismatch, -} - -impl IdentitySettingsError { - pub const fn code(&self) -> &'static str { - match self { - Self::InvalidIdentityId => "invalid_identity_id", - Self::InvalidPublicKey => "invalid_public_key", - Self::InvalidOperationId => "invalid_identity_operation_id", - Self::DuplicateIdentityId => "duplicate_identity_id", - Self::DuplicatePublicKey => "duplicate_identity_public_key", - Self::UnknownIdentity => "unknown_identity", - Self::NoActiveIdentity => "no_active_identity", - Self::ImportAlreadyPending => "identity_import_already_pending", - Self::ImportOperationMismatch => "identity_import_operation_mismatch", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RelayAccessPreference { - ReadOnly, - ReadWrite, -} - -impl RelayAccessPreference { - pub const fn can_write(self) -> bool { - matches!(self, Self::ReadWrite) - } - - fn parse(value: &str) -> Result<Self, SettingsError> { - match value { - "read_only" => Ok(Self::ReadOnly), - "read_write" => Ok(Self::ReadWrite), - _ => Err(SettingsError::UnknownRelayAccess), - } - } - - fn as_str(self) -> &'static str { - match self { - Self::ReadOnly => "read_only", - Self::ReadWrite => "read_write", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MobileNetworkEnvironment { - Public, - Simulator, - PhysicalDevice, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RelayEndpointPreference { - url: String, - access: RelayAccessPreference, -} - -impl RelayEndpointPreference { - pub fn new( - environment: MobileNetworkEnvironment, - url: impl AsRef<str>, - access: RelayAccessPreference, - ) -> Result<Self, SettingsError> { - let policy = match environment { - MobileNetworkEnvironment::Public => radroots_sdk::transport::RelayUrlPolicy::Public, - MobileNetworkEnvironment::Simulator => radroots_sdk::transport::RelayUrlPolicy::Local, - MobileNetworkEnvironment::PhysicalDevice => { - radroots_sdk::transport::RelayUrlPolicy::PrivateNetwork - } - }; - let url = radroots_sdk::transport::RelayUrl::parse(url, policy) - .map_err(|_| SettingsError::InvalidRelayEndpoint)?; - Ok(Self { - url: url.to_string(), - access, - }) - } - - pub fn url(&self) -> &str { - self.url.as_str() - } - - pub const fn access(&self) -> RelayAccessPreference { - self.access - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RelayPreferences { - environment: MobileNetworkEnvironment, - endpoints: Vec<RelayEndpointPreference>, -} - -impl RelayPreferences { - pub fn new( - environment: MobileNetworkEnvironment, - endpoints: Vec<RelayEndpointPreference>, - ) -> Result<Self, SettingsError> { - if endpoints.is_empty() || endpoints.len() > RELAY_ENDPOINT_MAX { - return Err(SettingsError::InvalidRelayEndpointCount); - } - let mut seen = BTreeSet::new(); - for endpoint in &endpoints { - let validated = - RelayEndpointPreference::new(environment, endpoint.url(), endpoint.access())?; - if validated != *endpoint || !seen.insert(endpoint.url()) { - return Err(SettingsError::DuplicateRelayEndpoint); - } - } - Ok(Self { - environment, - endpoints, - }) - } - - pub fn production_default() -> Self { - Self::new( - MobileNetworkEnvironment::Public, - vec![ - RelayEndpointPreference::new( - MobileNetworkEnvironment::Public, - DEFAULT_PUBLIC_RELAY, - RelayAccessPreference::ReadWrite, - ) - .expect("bundled public relay is valid"), - ], - ) - .expect("bundled public relay profile is valid") - } - - pub fn simulator_default() -> Self { - Self::new( - MobileNetworkEnvironment::Simulator, - vec![ - RelayEndpointPreference::new( - MobileNetworkEnvironment::Simulator, - DEFAULT_SIMULATOR_RELAY, - RelayAccessPreference::ReadWrite, - ) - .expect("bundled simulator relay is valid"), - ], - ) - .expect("bundled simulator relay profile is valid") - } - - pub const fn environment(&self) -> MobileNetworkEnvironment { - self.environment - } - - pub fn endpoints(&self) -> &[RelayEndpointPreference] { - self.endpoints.as_slice() - } - - pub fn sdk_profile(&self) -> Result<radroots_sdk::transport::RelayProfile, SettingsError> { - let kind = match self.environment { - MobileNetworkEnvironment::Public => radroots_sdk::transport::RelayProfileKind::Public, - MobileNetworkEnvironment::Simulator => { - radroots_sdk::transport::RelayProfileKind::Simulator - } - MobileNetworkEnvironment::PhysicalDevice => { - radroots_sdk::transport::RelayProfileKind::Device - } - }; - let policy = match self.environment { - MobileNetworkEnvironment::Public => radroots_sdk::transport::RelayUrlPolicy::Public, - MobileNetworkEnvironment::Simulator => radroots_sdk::transport::RelayUrlPolicy::Local, - MobileNetworkEnvironment::PhysicalDevice => { - radroots_sdk::transport::RelayUrlPolicy::PrivateNetwork - } - }; - let endpoints = self - .endpoints - .iter() - .map(|endpoint| { - let access = match endpoint.access { - RelayAccessPreference::ReadOnly => { - radroots_sdk::transport::RelayAccess::ReadOnly - } - RelayAccessPreference::ReadWrite => { - radroots_sdk::transport::RelayAccess::ReadWrite - } - }; - radroots_sdk::transport::RelayEndpoint::new(endpoint.url.as_str(), policy, access) - }) - .collect::<Result<Vec<_>, _>>() - .map_err(|_| SettingsError::InvalidRelayEndpoint)?; - radroots_sdk::transport::RelayProfile::explicit(kind, endpoints) - .map_err(|_| SettingsError::InvalidRelayEndpoint) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BlossomEndpointAuthorityPreference { - PublicWebPki, - LoopbackDevelopment, - PrivateNetworkDevelopment, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct BlossomPreferences { - environment: MobileNetworkEnvironment, - authority: BlossomEndpointAuthorityPreference, - primary_origin: String, - fallback_origins: Vec<String>, -} - -impl BlossomPreferences { - pub fn new( - environment: MobileNetworkEnvironment, - authority: BlossomEndpointAuthorityPreference, - primary_origin: impl Into<String>, - fallback_origins: Vec<String>, - ) -> Result<Self, SettingsError> { - if fallback_origins.len() > BLOSSOM_FALLBACK_MAX { - return Err(SettingsError::InvalidBlossomEndpointCount); - } - let candidate = Self { - environment, - authority, - primary_origin: primary_origin.into(), - fallback_origins, - }; - let profile = candidate.sdk_profile()?; - Ok(Self { - primary_origin: profile.primary().origin().to_owned(), - fallback_origins: profile - .fallbacks() - .iter() - .map(|endpoint| endpoint.origin().to_owned()) - .collect(), - ..candidate - }) - } - - pub fn production_default() -> Self { - Self::new( - MobileNetworkEnvironment::Public, - BlossomEndpointAuthorityPreference::PublicWebPki, - DEFAULT_PUBLIC_BLOSSOM_ORIGIN, - Vec::new(), - ) - .expect("bundled public Blossom origin is valid") - } - - pub fn simulator_default() -> Self { - Self::new( - MobileNetworkEnvironment::Simulator, - BlossomEndpointAuthorityPreference::LoopbackDevelopment, - DEFAULT_SIMULATOR_BLOSSOM_ORIGIN, - Vec::new(), - ) - .expect("bundled simulator Blossom origin is valid") - } - - pub const fn environment(&self) -> MobileNetworkEnvironment { - self.environment - } - - pub const fn authority(&self) -> BlossomEndpointAuthorityPreference { - self.authority - } - - pub fn primary_origin(&self) -> &str { - self.primary_origin.as_str() - } - - pub fn fallback_origins(&self) -> &[String] { - self.fallback_origins.as_slice() - } - - pub fn sdk_profile(&self) -> Result<radroots_sdk::transport::BlossomProfile, SettingsError> { - let host_kind = match self.environment { - MobileNetworkEnvironment::Public => radroots_sdk::transport::BlossomHostKind::Native, - MobileNetworkEnvironment::Simulator => { - radroots_sdk::transport::BlossomHostKind::Simulator - } - MobileNetworkEnvironment::PhysicalDevice => { - radroots_sdk::transport::BlossomHostKind::PhysicalDevice - } - }; - let authority = match self.authority { - BlossomEndpointAuthorityPreference::PublicWebPki => { - radroots_sdk::transport::BlossomEndpointAuthority::PublicWebPki - } - BlossomEndpointAuthorityPreference::LoopbackDevelopment => { - radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment - } - BlossomEndpointAuthorityPreference::PrivateNetworkDevelopment => { - radroots_sdk::transport::BlossomEndpointAuthority::PrivateNetworkDevelopment - } - }; - radroots_sdk::transport::BlossomProfile::new( - host_kind, - authority, - self.primary_origin.as_str(), - self.fallback_origins.iter().map(String::as_str), - ) - .map_err(|_| SettingsError::InvalidBlossomEndpoint) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MediaNetworkPolicy { - allow_cellular_downloads: bool, - allow_cellular_uploads: bool, - allow_background_transfers: bool, -} - -impl MediaNetworkPolicy { - pub const fn new( - allow_cellular_downloads: bool, - allow_cellular_uploads: bool, - allow_background_transfers: bool, - ) -> Self { - Self { - allow_cellular_downloads, - allow_cellular_uploads, - allow_background_transfers, - } - } - - pub const fn allow_cellular_downloads(&self) -> bool { - self.allow_cellular_downloads - } - - pub const fn allow_cellular_uploads(&self) -> bool { - self.allow_cellular_uploads - } - - pub const fn allow_background_transfers(&self) -> bool { - self.allow_background_transfers - } -} - -impl Default for MediaNetworkPolicy { - fn default() -> Self { - Self::new(true, true, true) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct LocalStoragePolicy { - media_cache_bytes: u64, - media_cache_artifacts: u32, -} - -impl LocalStoragePolicy { - pub fn new(media_cache_bytes: u64, media_cache_artifacts: u32) -> Result<Self, SettingsError> { - if !(MEDIA_CACHE_MIN_BYTES..=MEDIA_CACHE_MAX_BYTES).contains(&media_cache_bytes) { - return Err(SettingsError::InvalidMediaCacheBytes); - } - if media_cache_artifacts == 0 || media_cache_artifacts > MEDIA_CACHE_MAX_ARTIFACTS { - return Err(SettingsError::InvalidMediaCacheArtifacts); - } - Ok(Self { - media_cache_bytes, - media_cache_artifacts, - }) - } - - pub const fn media_cache_bytes(&self) -> u64 { - self.media_cache_bytes - } - - pub const fn media_cache_artifacts(&self) -> u32 { - self.media_cache_artifacts - } -} - -impl Default for LocalStoragePolicy { - fn default() -> Self { - Self::new(256 * 1024 * 1024, 2_000).expect("default storage policy is valid") - } -} - -/// Complete replacement command for the adopted kind-0 metadata surface. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProfileMetadataCommand(AuthoredProfile); - -impl ProfileMetadataCommand { - #[allow(clippy::too_many_arguments)] - pub fn new( - name: String, - display_name: Option<String>, - about: Option<String>, - picture: Option<AuthoredImage>, - banner: Option<AuthoredImage>, - nip05: Option<String>, - bot: Option<bool>, - ) -> Result<Self, ProfileMetadataError> { - validate_profile_text(&name, PROFILE_NAME_MAX_BYTES, false) - .then_some(()) - .ok_or(ProfileMetadataError::InvalidName)?; - if display_name.as_deref().is_some_and(|value| { - !validate_profile_text(value, PROFILE_DISPLAY_NAME_MAX_BYTES, true) - }) { - return Err(ProfileMetadataError::InvalidDisplayName); - } - if about - .as_deref() - .is_some_and(|value| !validate_profile_text(value, PROFILE_ABOUT_MAX_BYTES, true)) - { - return Err(ProfileMetadataError::InvalidAbout); - } - let nip05 = nip05 - .as_deref() - .map(Nip05Identifier::parse) - .transpose() - .map_err(|_| ProfileMetadataError::InvalidNip05)?; - let mut profile = - AuthoredProfile::new(name).map_err(|_| ProfileMetadataError::InvalidName)?; - if let Some(value) = display_name { - profile = profile.with_display_name(value); - } - if let Some(value) = about { - profile = profile.with_about(value); - } - if let Some(value) = picture { - profile = profile.with_picture(value); - } - if let Some(value) = banner { - profile = profile.with_banner(value); - } - if let Some(value) = nip05 { - profile = profile.with_nip05(value); - } - if let Some(value) = bot { - profile = profile.with_bot(value); - } - Ok(Self(profile)) - } - - pub const fn authored(&self) -> &AuthoredProfile { - &self.0 - } -} - -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] -pub enum ProfileMetadataError { - #[error("profile name is invalid")] - InvalidName, - #[error("profile display name is invalid")] - InvalidDisplayName, - #[error("profile about text is invalid")] - InvalidAbout, - #[error("profile NIP-05 identifier is invalid")] - InvalidNip05, -} - -impl ProfileMetadataError { - pub const fn code(&self) -> &'static str { - match self { - Self::InvalidName => "invalid_profile_name", - Self::InvalidDisplayName => "invalid_profile_display_name", - Self::InvalidAbout => "invalid_profile_about", - Self::InvalidNip05 => "invalid_profile_nip05", - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MobileSettings { - revision: u64, - identity: IdentityState, - relays: RelayPreferences, - blossom: BlossomPreferences, - media_network: MediaNetworkPolicy, - local_storage: LocalStoragePolicy, -} - -impl Default for MobileSettings { - fn default() -> Self { - Self { - revision: 1, - identity: IdentityState::default(), - relays: RelayPreferences::production_default(), - blossom: BlossomPreferences::production_default(), - media_network: MediaNetworkPolicy::default(), - local_storage: LocalStoragePolicy::default(), - } - } -} - -impl MobileSettings { - pub const fn revision(&self) -> u64 { - self.revision - } - - pub const fn identity(&self) -> &IdentityState { - &self.identity - } - - pub const fn relays(&self) -> &RelayPreferences { - &self.relays - } - - pub const fn blossom(&self) -> &BlossomPreferences { - &self.blossom - } - - pub const fn media_network(&self) -> &MediaNetworkPolicy { - &self.media_network - } - - pub const fn local_storage(&self) -> &LocalStoragePolicy { - &self.local_storage - } - - #[must_use] - pub fn with_identity(mut self, identity: IdentityState) -> Self { - self.identity = identity; - self - } - - #[must_use] - pub fn with_relays(mut self, relays: RelayPreferences) -> Self { - self.relays = relays; - self - } - - #[must_use] - pub fn with_blossom(mut self, blossom: BlossomPreferences) -> Self { - self.blossom = blossom; - self - } - - #[must_use] - pub fn with_media_network(mut self, media_network: MediaNetworkPolicy) -> Self { - self.media_network = media_network; - self - } - - #[must_use] - pub fn with_local_storage(mut self, local_storage: LocalStoragePolicy) -> Self { - self.local_storage = local_storage; - self - } - - fn validate(&self) -> Result<(), SettingsError> { - if self.relays.environment() != self.blossom.environment() { - return Err(SettingsError::NetworkEnvironmentMismatch); - } - Ok(()) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ReplaceMobileSettings { - expected_revision: u64, - settings: MobileSettings, -} - -impl ReplaceMobileSettings { - pub fn new(expected_revision: u64, settings: MobileSettings) -> Result<Self, SettingsError> { - if expected_revision == 0 || settings.revision != expected_revision { - return Err(SettingsError::RevisionConflict); - } - settings.validate()?; - Ok(Self { - expected_revision, - settings, - }) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SettingsTransition { - pub settings: MobileSettings, - pub runtime_restart_required: bool, - pub outbox_requeue_required: bool, - pub media_cache_invalidation_required: bool, -} - -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] -pub enum SettingsError { - #[error("relay access value is unknown")] - UnknownRelayAccess, - #[error("relay endpoint is invalid")] - InvalidRelayEndpoint, - #[error("relay endpoint count is invalid")] - InvalidRelayEndpointCount, - #[error("relay endpoint is duplicated")] - DuplicateRelayEndpoint, - #[error("Blossom endpoint is invalid")] - InvalidBlossomEndpoint, - #[error("Blossom endpoint count is invalid")] - InvalidBlossomEndpointCount, - #[error("relay and Blossom network environments do not match")] - NetworkEnvironmentMismatch, - #[error("media cache byte quota is invalid")] - InvalidMediaCacheBytes, - #[error("media cache artifact quota is invalid")] - InvalidMediaCacheArtifacts, - #[error("settings revision conflicts with durable state")] - RevisionConflict, - #[error("settings revision is exhausted")] - RevisionExhausted, - #[error("settings schema version is unsupported")] - UnsupportedSchema, - #[error("settings document is corrupt")] - CorruptDocument, - #[error("settings storage is unavailable")] - Storage, - #[error("identity settings are invalid: {0}")] - Identity(#[from] IdentitySettingsError), -} - -impl SettingsError { - pub const fn code(&self) -> &'static str { - match self { - Self::UnknownRelayAccess => "unknown_relay_access", - Self::InvalidRelayEndpoint => "invalid_relay_endpoint", - Self::InvalidRelayEndpointCount => "invalid_relay_endpoint_count", - Self::DuplicateRelayEndpoint => "duplicate_relay_endpoint", - Self::InvalidBlossomEndpoint => "invalid_blossom_endpoint", - Self::InvalidBlossomEndpointCount => "invalid_blossom_endpoint_count", - Self::NetworkEnvironmentMismatch => "network_environment_mismatch", - Self::InvalidMediaCacheBytes => "invalid_media_cache_bytes", - Self::InvalidMediaCacheArtifacts => "invalid_media_cache_artifacts", - Self::RevisionConflict => "settings_revision_conflict", - Self::RevisionExhausted => "settings_revision_exhausted", - Self::UnsupportedSchema => "unsupported_settings_schema", - Self::CorruptDocument => "corrupt_settings_document", - Self::Storage => "settings_storage_unavailable", - Self::Identity(error) => error.code(), - } - } -} - -impl RadrootsRuntime { - pub async fn phase1_settings(&self) -> Result<MobileSettings, SettingsError> { - let storage = self.client.storage().map_err(|_| SettingsError::Storage)?; - let mut settings = load_settings(storage).await?; - let session = self.identity_session.read().await; - if let Some((revision, identity)) = session.as_ref() - && *revision == settings.revision - { - settings.identity = identity.clone(); - } - Ok(settings) - } - - pub async fn phase1_replace_settings( - &self, - command: ReplaceMobileSettings, - ) -> Result<SettingsTransition, SettingsError> { - let _guard = self.settings_lock.lock().await; - let storage = self.client.storage().map_err(|_| SettingsError::Storage)?; - let transition = replace_settings(storage, command).await?; - *self.identity_session.write().await = None; - Ok(transition) - } - - /// Applies one secret-free identity transition atomically against the - /// current settings revision. Unlock evidence is process-local: it is - /// observable for the current runtime but never written to durable state. - pub async fn phase1_apply_identity_command( - &self, - expected_revision: u64, - command: IdentityCommand, - ) -> Result<SettingsTransition, SettingsError> { - let _guard = self.settings_lock.lock().await; - let storage = self.client.storage().map_err(|_| SettingsError::Storage)?; - let mut prior = load_settings(storage).await?; - if prior.revision != expected_revision { - return Err(SettingsError::RevisionConflict); - } - if let Some((revision, identity)) = self.identity_session.read().await.as_ref() - && *revision == prior.revision - { - prior.identity = identity.clone(); - } - let next_identity = prior.identity.apply(command.clone())?; - if matches!(command, IdentityCommand::Unlock) { - *self.identity_session.write().await = Some((prior.revision, next_identity.clone())); - return Ok(SettingsTransition { - settings: prior.with_identity(next_identity), - runtime_restart_required: false, - outbox_requeue_required: false, - media_cache_invalidation_required: false, - }); - } - let command = - ReplaceMobileSettings::new(prior.revision, prior.with_identity(next_identity))?; - let transition = replace_settings(storage, command).await?; - *self.identity_session.write().await = None; - Ok(transition) - } -} - -async fn load_settings( - storage: &dyn radroots_storage::Storage, -) -> Result<MobileSettings, SettingsError> { - let document = ProjectionStore::projection_document( - storage, - settings_projection_id()?, - settings_generation()?, - SETTINGS_DOCUMENT_KEY.to_owned(), - ) - .await - .map_err(|_| SettingsError::Storage)?; - document - .map(|document| decode_settings(document.value())) - .transpose() - .map(Option::unwrap_or_default) -} - -async fn replace_settings( - storage: &dyn radroots_storage::Storage, - command: ReplaceMobileSettings, -) -> Result<SettingsTransition, SettingsError> { - let prior = load_settings(storage).await?; - if prior.revision != command.expected_revision { - return Err(SettingsError::RevisionConflict); - } - let mut next = command.settings; - next.revision = prior - .revision - .checked_add(1) - .ok_or(SettingsError::RevisionExhausted)?; - // Unlock is a native, process-local custody result. A durable settings - // write must never make a later process assume user presence succeeded. - next.identity.lock_state = IdentityLockState::Locked; - let transition = settings_transition(&prior, next); - ProjectionStore::put_projection_document( - storage, - settings_projection_id()?, - settings_generation()?, - ProjectionDocument::new( - SETTINGS_DOCUMENT_KEY.to_owned(), - encode_settings(&transition.settings)?, - ) - .map_err(|_| SettingsError::CorruptDocument)?, - ) - .await - .map_err(|_| SettingsError::Storage)?; - Ok(transition) -} - -fn settings_transition(prior: &MobileSettings, settings: MobileSettings) -> SettingsTransition { - let identity_changed = prior.identity != settings.identity; - let relay_changed = prior.relays != settings.relays; - let blossom_changed = prior.blossom != settings.blossom; - let media_changed = prior.media_network != settings.media_network; - let storage_changed = prior.local_storage != settings.local_storage; - SettingsTransition { - runtime_restart_required: identity_changed || relay_changed || blossom_changed, - outbox_requeue_required: identity_changed || relay_changed || blossom_changed, - media_cache_invalidation_required: identity_changed - || blossom_changed - || media_changed - || storage_changed, - settings, - } -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct StoredSettingsV1 { - schema_version: u16, - revision: u64, - identity: StoredIdentity, - relays: StoredRelays, - blossom: StoredBlossom, - media_network: MediaNetworkPolicy, - local_storage: StoredLocalStorage, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct StoredSettingsV0 { - schema_version: u16, - revision: u64, - identity: StoredIdentity, - relays: StoredRelays, - blossom: StoredBlossom, - allow_cellular_downloads: bool, - allow_cellular_uploads: bool, - media_cache_bytes: u64, - media_cache_artifacts: u32, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct StoredIdentity { - identities: Vec<StoredIdentityRecord>, - active_identity_id: Option<String>, - lock_state: IdentityLockState, - pending_import_operation_id: Option<String>, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct StoredIdentityRecord { - id: String, - public_key_hex: String, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct StoredRelays { - environment: MobileNetworkEnvironment, - endpoints: Vec<StoredRelayEndpoint>, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct StoredRelayEndpoint { - url: String, - access: String, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct StoredBlossom { - environment: MobileNetworkEnvironment, - authority: BlossomEndpointAuthorityPreference, - primary_origin: String, - fallback_origins: Vec<String>, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct StoredLocalStorage { - media_cache_bytes: u64, - media_cache_artifacts: u32, -} - -#[derive(Deserialize)] -struct VersionProbe { - schema_version: u16, -} - -fn encode_settings(settings: &MobileSettings) -> Result<Vec<u8>, SettingsError> { - serde_json::to_vec(&StoredSettingsV1::from(settings)) - .map_err(|_| SettingsError::CorruptDocument) -} - -fn decode_settings(value: &[u8]) -> Result<MobileSettings, SettingsError> { - let version = serde_json::from_slice::<VersionProbe>(value) - .map_err(|_| SettingsError::CorruptDocument)? - .schema_version; - match version { - 0 => serde_json::from_slice::<StoredSettingsV0>(value) - .map_err(|_| SettingsError::CorruptDocument)? - .try_into(), - MOBILE_SETTINGS_SCHEMA_VERSION => serde_json::from_slice::<StoredSettingsV1>(value) - .map_err(|_| SettingsError::CorruptDocument)? - .try_into(), - _ => Err(SettingsError::UnsupportedSchema), - } -} - -impl From<&MobileSettings> for StoredSettingsV1 { - fn from(value: &MobileSettings) -> Self { - Self { - schema_version: MOBILE_SETTINGS_SCHEMA_VERSION, - revision: value.revision, - identity: StoredIdentity::from(&value.identity), - relays: StoredRelays::from(&value.relays), - blossom: StoredBlossom::from(&value.blossom), - media_network: value.media_network.clone(), - local_storage: StoredLocalStorage::from(&value.local_storage), - } - } -} - -impl TryFrom<StoredSettingsV1> for MobileSettings { - type Error = SettingsError; - - fn try_from(value: StoredSettingsV1) -> Result<Self, Self::Error> { - if value.schema_version != MOBILE_SETTINGS_SCHEMA_VERSION || value.revision == 0 { - return Err(SettingsError::CorruptDocument); - } - let settings = Self { - revision: value.revision, - identity: value.identity.try_into()?, - relays: value.relays.try_into()?, - blossom: value.blossom.try_into()?, - media_network: value.media_network, - local_storage: value.local_storage.try_into()?, - }; - settings.validate()?; - Ok(settings) - } -} - -impl TryFrom<StoredSettingsV0> for MobileSettings { - type Error = SettingsError; - - fn try_from(value: StoredSettingsV0) -> Result<Self, Self::Error> { - if value.schema_version != 0 || value.revision == 0 { - return Err(SettingsError::CorruptDocument); - } - let settings = Self { - revision: value.revision, - identity: value.identity.try_into()?, - relays: value.relays.try_into()?, - blossom: value.blossom.try_into()?, - media_network: MediaNetworkPolicy::new( - value.allow_cellular_downloads, - value.allow_cellular_uploads, - false, - ), - local_storage: LocalStoragePolicy::new( - value.media_cache_bytes, - value.media_cache_artifacts, - )?, - }; - settings.validate()?; - Ok(settings) - } -} - -impl From<&IdentityState> for StoredIdentity { - fn from(value: &IdentityState) -> Self { - Self { - identities: value - .identities - .iter() - .map(|identity| StoredIdentityRecord { - id: identity.id.clone(), - public_key_hex: identity.public_key_hex.clone(), - }) - .collect(), - active_identity_id: value.active_identity_id.clone(), - lock_state: IdentityLockState::Locked, - pending_import_operation_id: value.pending_import_operation_id.clone(), - } - } -} - -impl TryFrom<StoredIdentity> for IdentityState { - type Error = SettingsError; - - fn try_from(value: StoredIdentity) -> Result<Self, Self::Error> { - let identities = value - .identities - .into_iter() - .map(|identity| IdentityRecord::new(identity.id, &identity.public_key_hex)) - .collect::<Result<Vec<_>, _>>()?; - IdentityState::new( - identities, - value.active_identity_id, - IdentityLockState::Locked, - value.pending_import_operation_id, - ) - .map_err(SettingsError::from) - } -} - -impl From<&RelayPreferences> for StoredRelays { - fn from(value: &RelayPreferences) -> Self { - Self { - environment: value.environment, - endpoints: value - .endpoints - .iter() - .map(|endpoint| StoredRelayEndpoint { - url: endpoint.url.clone(), - access: endpoint.access.as_str().to_owned(), - }) - .collect(), - } - } -} - -impl TryFrom<StoredRelays> for RelayPreferences { - type Error = SettingsError; - - fn try_from(value: StoredRelays) -> Result<Self, Self::Error> { - let endpoints = value - .endpoints - .into_iter() - .map(|endpoint| { - RelayEndpointPreference::new( - value.environment, - endpoint.url, - RelayAccessPreference::parse(&endpoint.access)?, - ) - }) - .collect::<Result<Vec<_>, _>>()?; - Self::new(value.environment, endpoints) - } -} - -impl From<&BlossomPreferences> for StoredBlossom { - fn from(value: &BlossomPreferences) -> Self { - Self { - environment: value.environment, - authority: value.authority, - primary_origin: value.primary_origin.clone(), - fallback_origins: value.fallback_origins.clone(), - } - } -} - -impl TryFrom<StoredBlossom> for BlossomPreferences { - type Error = SettingsError; - - fn try_from(value: StoredBlossom) -> Result<Self, Self::Error> { - Self::new( - value.environment, - value.authority, - value.primary_origin, - value.fallback_origins, - ) - } -} - -impl From<&LocalStoragePolicy> for StoredLocalStorage { - fn from(value: &LocalStoragePolicy) -> Self { - Self { - media_cache_bytes: value.media_cache_bytes, - media_cache_artifacts: value.media_cache_artifacts, - } - } -} - -impl TryFrom<StoredLocalStorage> for LocalStoragePolicy { - type Error = SettingsError; - - fn try_from(value: StoredLocalStorage) -> Result<Self, Self::Error> { - Self::new(value.media_cache_bytes, value.media_cache_artifacts) - } -} - -fn settings_projection_id() -> Result<ProjectionId, SettingsError> { - ProjectionId::parse(SETTINGS_PROJECTION_ID).map_err(|_| SettingsError::CorruptDocument) -} - -fn settings_generation() -> Result<ProjectionGeneration, SettingsError> { - ProjectionGeneration::new(Sha256::digest(SETTINGS_GENERATION_DOMAIN).into()) - .map_err(|_| SettingsError::CorruptDocument) -} - -fn validate_identifier(value: &str, max_bytes: usize) -> bool { - !value.is_empty() - && value.len() <= max_bytes - && value == value.trim() - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':')) -} - -fn validate_profile_text(value: &str, max_bytes: usize, allow_empty: bool) -> bool { - value.len() <= max_bytes - && (allow_empty || !value.trim().is_empty()) - && !value.chars().any(char::is_control) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::runtime::{ - builder::RuntimeBuilder, - store::{MobileUserStoreConfig, ProtectedDataAvailability}, - }; - - const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - - #[test] - fn identity_transitions_never_accept_or_serialize_private_key_material() { - let state = IdentityState::default() - .apply(IdentityCommand::BeginImport { - operation_id: "import:1".to_owned(), - }) - .unwrap() - .apply(IdentityCommand::CompleteImport { - operation_id: "import:1".to_owned(), - identity: IdentityRecord::new("primary", PUBLIC_KEY).unwrap(), - }) - .unwrap(); - assert_eq!(state.active_identity_id(), Some("primary")); - assert_eq!(state.lock_state(), IdentityLockState::Locked); - let settings = MobileSettings::default().with_identity(state); - let encoded = String::from_utf8(encode_settings(&settings).unwrap()).unwrap(); - assert!(encoded.contains(PUBLIC_KEY)); - assert!(!encoded.contains("private")); - assert!(!encoded.contains("secret")); - } - - #[test] - fn durable_identity_state_always_reopens_locked() { - let identity = IdentityRecord::new("primary", PUBLIC_KEY).unwrap(); - let state = IdentityState::new( - vec![identity], - Some("primary".to_owned()), - IdentityLockState::Unlocked, - None, - ) - .unwrap(); - let settings = MobileSettings::default().with_identity(state); - let encoded = encode_settings(&settings).unwrap(); - assert!(!String::from_utf8_lossy(&encoded).contains("unlocked")); - assert_eq!( - decode_settings(&encoded).unwrap().identity().lock_state(), - IdentityLockState::Locked - ); - } - - #[test] - fn unknown_relay_access_fails_closed_during_decode() { - let mut stored = StoredSettingsV1::from(&MobileSettings::default()); - stored.relays.endpoints[0].access = "write".to_owned(); - let encoded = serde_json::to_vec(&stored).unwrap(); - assert_eq!( - decode_settings(&encoded).unwrap_err(), - SettingsError::UnknownRelayAccess - ); - } - - #[test] - fn validated_relay_preferences_preserve_read_only_access() { - let preferences = RelayPreferences::new( - MobileNetworkEnvironment::Public, - vec![ - RelayEndpointPreference::new( - MobileNetworkEnvironment::Public, - "wss://read.example", - RelayAccessPreference::ReadOnly, - ) - .unwrap(), - ], - ) - .unwrap(); - let profile = preferences.sdk_profile().unwrap(); - assert_eq!(profile.endpoints().len(), 1); - assert!(!profile.endpoints()[0].access().can_write()); - } - - #[test] - fn production_and_simulator_defaults_use_canonical_origins() { - let production = MobileSettings::default(); - assert_eq!( - production.relays().endpoints()[0].url(), - DEFAULT_PUBLIC_RELAY - ); - assert_eq!( - production.blossom().primary_origin(), - DEFAULT_PUBLIC_BLOSSOM_ORIGIN - ); - assert_eq!( - RelayPreferences::simulator_default().endpoints()[0].url(), - DEFAULT_SIMULATOR_RELAY - ); - assert_eq!( - BlossomPreferences::simulator_default().primary_origin(), - DEFAULT_SIMULATOR_BLOSSOM_ORIGIN - ); - } - - #[test] - fn version_zero_migrates_background_transfers_to_disabled() { - let current = StoredSettingsV1::from(&MobileSettings::default()); - let legacy = serde_json::json!({ - "schema_version": 0, - "revision": current.revision, - "identity": current.identity, - "relays": current.relays, - "blossom": current.blossom, - "allow_cellular_downloads": true, - "allow_cellular_uploads": false, - "media_cache_bytes": current.local_storage.media_cache_bytes, - "media_cache_artifacts": current.local_storage.media_cache_artifacts, - }); - let migrated = decode_settings(&serde_json::to_vec(&legacy).unwrap()).unwrap(); - assert!(!migrated.media_network().allow_background_transfers()); - assert!(!migrated.media_network().allow_cellular_uploads()); - } - - #[test] - fn future_or_unknown_fields_fail_safely() { - let mut future = - serde_json::to_value(StoredSettingsV1::from(&MobileSettings::default())).unwrap(); - future["schema_version"] = serde_json::json!(2); - assert_eq!( - decode_settings(&serde_json::to_vec(&future).unwrap()).unwrap_err(), - SettingsError::UnsupportedSchema - ); - - let mut unknown = - serde_json::to_value(StoredSettingsV1::from(&MobileSettings::default())).unwrap(); - unknown["write_all_relays"] = serde_json::json!(true); - assert_eq!( - decode_settings(&serde_json::to_vec(&unknown).unwrap()).unwrap_err(), - SettingsError::CorruptDocument - ); - } - - #[test] - fn profile_metadata_command_validates_the_adopted_kind_zero_fields() { - let command = ProfileMetadataCommand::new( - "grower".to_owned(), - Some("Local Grower".to_owned()), - Some("Seasonal produce".to_owned()), - None, - None, - Some("grower@farm.example".to_owned()), - Some(false), - ) - .unwrap(); - assert_eq!(command.authored().name(), "grower"); - assert_eq!( - command.authored().nip05().map(Nip05Identifier::as_str), - Some("grower@farm.example") - ); - assert_eq!( - ProfileMetadataCommand::new( - "grower".to_owned(), - None, - None, - None, - None, - Some("GROWER@farm.example".to_owned()), - None, - ) - .unwrap_err() - .code(), - "invalid_profile_nip05" - ); - } - - #[tokio::test] - async fn settings_are_revision_checked_persisted_and_report_exact_effects() { - let runtime = RadrootsRuntime::test_memory().unwrap(); - let settings = runtime.phase1_settings().await.unwrap(); - let next = settings - .clone() - .with_media_network(MediaNetworkPolicy::new(false, true, false)); - let transition = runtime - .phase1_replace_settings(ReplaceMobileSettings::new(settings.revision(), next).unwrap()) - .await - .unwrap(); - assert_eq!(transition.settings.revision(), 2); - assert!(!transition.runtime_restart_required); - assert!(!transition.outbox_requeue_required); - assert!(transition.media_cache_invalidation_required); - assert_eq!( - runtime.phase1_settings().await.unwrap(), - transition.settings - ); - - let conflict = runtime - .phase1_replace_settings( - ReplaceMobileSettings::new(settings.revision(), settings).unwrap(), - ) - .await - .unwrap_err(); - assert_eq!(conflict, SettingsError::RevisionConflict); - } - - #[test] - fn settings_reject_mixed_network_environments() { - let settings = - MobileSettings::default().with_blossom(BlossomPreferences::simulator_default()); - assert_eq!( - ReplaceMobileSettings::new(settings.revision(), settings).unwrap_err(), - SettingsError::NetworkEnvironmentMismatch - ); - } - - #[tokio::test] - async fn atomic_identity_commands_persist_public_state_but_keep_unlock_process_local() { - let runtime = RadrootsRuntime::test_memory().unwrap(); - let begun = runtime - .phase1_apply_identity_command( - 1, - IdentityCommand::BeginImport { - operation_id: "import-1".to_owned(), - }, - ) - .await - .unwrap(); - assert_eq!(begun.settings.revision(), 2); - assert_eq!( - begun.settings.identity().pending_import_operation_id(), - Some("import-1") - ); - - let completed = runtime - .phase1_apply_identity_command( - 2, - IdentityCommand::CompleteImport { - operation_id: "import-1".to_owned(), - identity: IdentityRecord::new( - "primary", - "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(), - }, - ) - .await - .unwrap(); - assert_eq!(completed.settings.revision(), 3); - assert_eq!( - completed.settings.identity().active_identity_id(), - Some("primary") - ); - - let unlocked = runtime - .phase1_apply_identity_command(3, IdentityCommand::Unlock) - .await - .unwrap(); - assert_eq!(unlocked.settings.revision(), 3); - assert_eq!( - unlocked.settings.identity().lock_state(), - IdentityLockState::Unlocked - ); - assert_eq!( - runtime - .phase1_settings() - .await - .unwrap() - .identity() - .lock_state(), - IdentityLockState::Unlocked - ); - - let locked = runtime - .phase1_apply_identity_command(3, IdentityCommand::Lock) - .await - .unwrap(); - assert_eq!(locked.settings.revision(), 4); - assert_eq!( - locked.settings.identity().lock_state(), - IdentityLockState::Locked - ); - } - - #[tokio::test] - async fn concurrent_replacements_cannot_both_commit_the_same_revision() { - let runtime = RadrootsRuntime::test_memory().unwrap(); - let settings = runtime.phase1_settings().await.unwrap(); - let first = ReplaceMobileSettings::new( - settings.revision(), - settings - .clone() - .with_media_network(MediaNetworkPolicy::new(false, true, true)), - ) - .unwrap(); - let second = ReplaceMobileSettings::new( - settings.revision(), - settings.with_media_network(MediaNetworkPolicy::new(true, false, true)), - ) - .unwrap(); - - let (first, second) = tokio::join!( - runtime.phase1_replace_settings(first), - runtime.phase1_replace_settings(second) - ); - assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1); - let failure = first.err().or_else(|| second.err()).unwrap(); - assert_eq!(failure, SettingsError::RevisionConflict); - } - - #[tokio::test] - async fn sqlite_settings_survive_a_runtime_restart() { - let root = tempfile::tempdir().unwrap(); - let store = MobileUserStoreConfig::from_encoded( - root.path(), - PUBLIC_KEY, - "0303030303030303030303030303030303030303030303030303030303030303", - 1_800_000_000_000, - ProtectedDataAvailability::Available, - ) - .unwrap(); - std::fs::create_dir_all(store.owner_directory()).unwrap(); - let runtime = RuntimeBuilder::new(store.clone()).build().await.unwrap(); - let settings = runtime.phase1_settings().await.unwrap(); - let transition = runtime - .phase1_replace_settings( - ReplaceMobileSettings::new( - settings.revision(), - settings.with_media_network(MediaNetworkPolicy::new(false, false, false)), - ) - .unwrap(), - ) - .await - .unwrap(); - runtime.shutdown().await.unwrap(); - drop(runtime); - - let reopened = RuntimeBuilder::new(store).build().await.unwrap(); - assert_eq!( - reopened.phase1_settings().await.unwrap(), - transition.settings - ); - reopened.shutdown().await.unwrap(); - } -} diff --git a/crates/mobile_core/src/runtime/product_surface/today.rs b/crates/mobile_core/src/runtime/product_surface/today.rs @@ -1,3776 +0,0 @@ -use std::collections::BTreeMap; - -use radroots_event_codec::{ - admission::{RadrootsAdmittedEvent, admit_verified_event}, - verify::verify_nip01_event, -}; -use radroots_storage::{ - EventStore, ProjectionStore, - event::{ - AdmissionReceipt, EventAdmission, EventPosition, EventQuery, EventQueryBounds, - EventSequence, - }, - projection::{ - ProjectionCheckpoint, ProjectionDocument, ProjectionGeneration, ProjectionId, - ProjectionSnapshot, - }, -}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use thiserror::Error; - -#[cfg(feature = "mobile-social")] -use radroots_blossom::{BlobUrl, MediaType}; -#[cfg(feature = "mobile-social")] -use radroots_event::admission::ContractValidatedEvent; -#[cfg(feature = "mobile-social")] -use radroots_sdk::transport::{ - BlossomCancellation, BlossomError, BlossomImageDimensions, BlossomInboundRequest, -}; -#[cfg(feature = "mobile-social")] -use radroots_sync::{ - PullRequest, - ingest::{AdmissionDecision, AdmissionPolicy}, - pull::PullTermination, -}; -#[cfg(feature = "mobile-social")] -use radroots_transport::{ - Target, outcome::FetchTargetState, source::FetchSelector, target::TargetSet, -}; - -#[cfg(feature = "mobile-social")] -use super::Phase1LocalMediaArtifact; -use super::{ - CardId, CardLifecycleState, ClassifiedCard, CursorError, CursorScope, LocalAuthorOverlay, - LocalNetwork, LocalityEvidence, MeSnapshot, MediaReference, Phase1InboundMediaError, - Phase1InboundMediaFailure, Phase1InboundMediaPending, Phase1InboundMediaState, - Phase1MediaArtifactId, Phase1MediaCacheIndex, Phase1MediaCacheStatus, - Phase1MediaConfigurationFingerprint, Phase1StructuralMediaReference, - ProductEventClassification, ProfileSummary, SearchResult, SearchResultType, SupportingProfile, - ThreadEntry, ThreadReference, TimeRelevance, TodayCard, TodayCardType, TodayCursor, - TodayCursorPosition, TodayPage, TodayRank, TodayRankInput, classify_admitted_event, -}; -#[cfg(any(feature = "mobile-social", test))] -use super::{Phase1MediaCachePolicy, Phase1VerifiedMediaReceipt}; -use crate::runtime::RadrootsRuntime; - -const TODAY_PROJECTION_ID: &str = "radroots.today.v1"; -const TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION: u16 = 1; -const TODAY_SNAPSHOT_SCHEMA_VERSION: u16 = 1; -const TODAY_PAGE_LIMIT_MAX: u16 = 100; -const TODAY_SEARCH_LIMIT_MAX: u16 = 100; -#[cfg(feature = "mobile-social")] -const TODAY_SYNC_PAGE_LIMIT: u16 = 500; -#[cfg(feature = "mobile-social")] -const TODAY_SYNC_MAX_PAGES: u16 = 8; -#[cfg(feature = "mobile-social")] -const TODAY_SYNC_KINDS: [u32; 7] = [0, 1, 5, 1111, 30_402, 31_922, 31_923]; -const PROJECTION_GENERATION_DOMAIN: &[u8] = b"radroots.today-projection.v1\0"; -const PROJECTION_CONTENT_DOMAIN: &[u8] = b"radroots.today-content-generation.v1\0"; -const PROJECTION_DOCUMENT_KEY_DOMAIN: &[u8] = b"radroots.today-document-key.v1\0"; -const SNAPSHOT_ID_DOMAIN: &[u8] = b"radroots.today-snapshot-id.v1\0"; - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "PascalCase")] -pub enum TodayProjectionUpdate { - Incremental, - Rebuild, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TodayRefreshReceipt { - pub update: TodayProjectionUpdate, - pub source_events: u64, - pub visible_cards: u64, - pub profiles: u64, - pub thread_entries: u64, - pub content_generation: u64, - pub changed: bool, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TodayIngestReceipt { - pub event_id: String, - pub disposition: String, - pub source_sequence: u64, - pub projection: TodayRefreshReceipt, -} - -#[cfg(feature = "mobile-social")] -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "PascalCase")] -pub enum TodayRelaySyncState { - Complete, - Partial, - Offline, -} - -#[cfg(feature = "mobile-social")] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TodaySyncReceipt { - pub relay_state: TodayRelaySyncState, - pub pages_fetched: u16, - pub events_observed: u64, - pub events_admitted: u64, - pub events_rejected: u64, - pub projection: TodayRefreshReceipt, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TodayPageRequest { - pub limit: u16, - pub as_of: Option<u64>, - pub cursor: Option<String>, -} - -impl TodayPageRequest { - pub const fn first(limit: u16, as_of: u64) -> Self { - Self { - limit, - as_of: Some(as_of), - cursor: None, - } - } - - pub fn after(limit: u16, cursor: String) -> Self { - Self { - limit, - as_of: None, - cursor: Some(cursor), - } - } -} - -#[derive(Debug, Error)] -pub enum TodayError { - #[error("today runtime is unavailable")] - RuntimeUnavailable, - #[error("today request is invalid")] - InvalidRequest, - #[error("today projection has not been refreshed")] - ProjectionMissing, - #[error("today frozen snapshot is unavailable")] - SnapshotMissing, - #[error("today cursor position is absent from its frozen snapshot")] - CursorPositionMissing, - #[error("today event was not admitted as visible")] - EventNotVisible, - #[error("today projection state is corrupt")] - CorruptProjection, - #[error(transparent)] - Cursor(#[from] CursorError), - #[error(transparent)] - Storage(#[from] radroots_storage::Error), - #[error("today projection serialization failed")] - Serialization, - #[error(transparent)] - InboundMedia(#[from] Phase1InboundMediaError), - #[cfg(feature = "mobile-social")] - #[error(transparent)] - InboundRetrieval(#[from] BlossomError), -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct ProjectedCard { - card: ClassifiedCard, - locality: Vec<LocalityTag>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)] -#[serde(rename_all = "camelCase")] -struct LocalityTag { - kind: String, - value: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct TodayProjectionState { - schema_version: u16, - context_id: String, - context_generation: u64, - store_generation: [u8; 32], - source_events: u64, - content_generation: u64, - cards: Vec<ProjectedCard>, - profiles: BTreeMap<String, ProfileSummary>, - thread: Vec<ThreadEntry>, - overlays: BTreeMap<String, LocalAuthorOverlay>, - #[serde(default)] - media_cache: Phase1MediaCacheIndex, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct FrozenTodaySnapshot { - schema_version: u16, - context_id: String, - context_generation: u64, - as_of: u64, - store_generation: [u8; 32], - projection_generation: u64, - items: Vec<TodayCard>, -} - -impl RadrootsRuntime { - /// Pulls bounded Today-relevant relay pages, canonically admits valid - /// observations, and materializes the selected LocalNetwork projection. - #[cfg(feature = "mobile-social")] - pub async fn phase1_sync_today( - &self, - context: &LocalNetwork, - now_unix_seconds: u64, - update: TodayProjectionUpdate, - ) -> Result<TodaySyncReceipt, TodayError> { - if now_unix_seconds == 0 { - return Err(TodayError::InvalidRequest); - } - let targets = context - .relay_urls - .iter() - .map(Target::nostr_relay) - .collect::<Result<Vec<_>, _>>() - .map_err(|_| TodayError::InvalidRequest)?; - let targets = TargetSet::new(targets).map_err(|_| TodayError::InvalidRequest)?; - let selector = FetchSelector::all() - .with_kinds(TODAY_SYNC_KINDS.to_vec()) - .map_err(|_| TodayError::InvalidRequest)?; - let request = PullRequest::new(targets, TODAY_SYNC_PAGE_LIMIT, TODAY_SYNC_MAX_PAGES) - .map_err(|_| TodayError::RuntimeUnavailable)? - .with_selector(selector); - let sync = self - .client - .sync() - .map_err(|_| TodayError::RuntimeUnavailable)? - .ok_or(TodayError::RuntimeUnavailable)?; - let pull = sync - .pull(request, &TodayAdmissionPolicy) - .await - .map_err(|_| TodayError::RuntimeUnavailable)?; - let projection = self - .phase1_refresh_today(context, now_unix_seconds, update) - .await?; - let events_admitted = pull - .ingest_outcomes() - .iter() - .filter(|outcome| outcome.is_ok()) - .count() as u64; - let events_observed = u64::try_from(pull.events_observed()).unwrap_or(u64::MAX); - let events_rejected = events_observed.saturating_sub(events_admitted); - let target_complete = !pull.target_outcomes().is_empty() - && pull - .target_outcomes() - .iter() - .all(|outcome| outcome.state() == FetchTargetState::Complete); - let relay_state = match pull.termination() { - PullTermination::Complete if target_complete => TodayRelaySyncState::Complete, - PullTermination::SourceFailed if pull.pages_fetched() == 0 => { - TodayRelaySyncState::Offline - } - _ => TodayRelaySyncState::Partial, - }; - Ok(TodaySyncReceipt { - relay_state, - pages_fetched: pull.pages_fetched(), - events_observed, - events_admitted, - events_rejected, - projection, - }) - } - - /// Durably admits one already verified and visibility-authorized relay observation, - /// then advances the selected LocalNetwork projection. - pub async fn phase1_ingest_visible( - &self, - admission: EventAdmission, - context: &LocalNetwork, - now_unix_seconds: u64, - ) -> Result<TodayIngestReceipt, TodayError> { - if admission.visible_event().is_none() { - return Err(TodayError::EventNotVisible); - } - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let receipt = EventStore::admit(storage, admission).await?; - let projection = self - .phase1_refresh_today( - context, - now_unix_seconds, - TodayProjectionUpdate::Incremental, - ) - .await?; - Ok(ingest_receipt(receipt, projection)) - } - - /// Materializes current visible event truth for one LocalNetwork. - pub async fn phase1_refresh_today( - &self, - context: &LocalNetwork, - now_unix_seconds: u64, - update: TodayProjectionUpdate, - ) -> Result<TodayRefreshReceipt, TodayError> { - if now_unix_seconds == 0 { - return Err(TodayError::InvalidRequest); - } - let requested_updated_at_unix_ms = now_unix_seconds - .checked_mul(1_000) - .ok_or(TodayError::InvalidRequest)?; - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - if update == TodayProjectionUpdate::Rebuild { - EventStore::rebuild_visibility(storage).await?; - } - let event_status = EventStore::status(storage).await?; - let generation = projection_generation()?; - let projection_id = projection_id()?; - let key = projection_document_key(context); - let prior = load_state(storage, context, generation).await?; - - if update == TodayProjectionUpdate::Incremental - && prior - .as_ref() - .is_some_and(|state| state.source_events == event_status.raw_events()) - { - let state = prior.expect("checked present"); - return Ok(refresh_receipt(update, &state, false)); - } - - let visible = query_all_visible(storage).await?; - let local_media = prior.as_ref().map(local_media_evidence).unwrap_or_default(); - let overlays = prior - .as_ref() - .map_or_else(BTreeMap::new, |state| state.overlays.clone()); - let media_cache = - prior.map_or_else(Phase1MediaCacheIndex::default, |state| state.media_cache); - let mut state = project_state( - context, - event_status.generation().as_bytes(), - event_status.raw_events(), - visible, - overlays, - )?; - state.media_cache = media_cache; - apply_local_media_evidence(&mut state, &local_media); - state.content_generation = content_generation(&state)?; - let encoded = encode(&state)?; - let changed = ProjectionStore::projection_document( - storage, - projection_id.clone(), - generation, - key.clone(), - ) - .await? - .is_none_or(|document| document.value() != encoded); - ProjectionStore::put_projection_document( - storage, - projection_id.clone(), - generation, - ProjectionDocument::new(key, encoded)?, - ) - .await?; - - let source_position = if event_status.raw_events() == 0 { - None - } else { - Some(EventPosition::new( - event_status.generation(), - EventSequence::new(event_status.raw_events())?, - )) - }; - let prior_updated_at = ProjectionStore::status(storage, projection_id.clone()) - .await? - .and_then(|status| { - status - .checkpoint() - .map(ProjectionCheckpoint::updated_at_unix_ms) - }) - .unwrap_or(0); - let updated_at_unix_ms = requested_updated_at_unix_ms.max(prior_updated_at); - ProjectionStore::checkpoint( - storage, - ProjectionCheckpoint::new( - projection_id, - generation, - source_position, - event_status.raw_events(), - updated_at_unix_ms, - )?, - ) - .await?; - Ok(refresh_receipt(update, &state, changed)) - } - - /// Returns one page from a durable frozen Today snapshot. - pub async fn phase1_today_page( - &self, - context: &LocalNetwork, - request: TodayPageRequest, - ) -> Result<TodayPage, TodayError> { - if request.limit == 0 || request.limit > TODAY_PAGE_LIMIT_MAX { - return Err(TodayError::InvalidRequest); - } - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let event_status = EventStore::status(storage).await?; - let algorithm_generation = projection_generation()?; - let projection_id = projection_id()?; - - let (scope, snapshot, after) = if let Some(cursor) = request.cursor.as_deref() { - let scope = TodayCursor::scope(cursor)?; - if scope.context_id != context.id || scope.context_generation != context.generation { - return Err(CursorError::ContextMismatch.into()); - } - if request.as_of.is_some_and(|as_of| as_of != scope.as_of) { - return Err(CursorError::SnapshotMismatch.into()); - } - if scope.store_generation != *event_status.generation().as_bytes() { - return Err(CursorError::Stale.into()); - } - let position = TodayCursor::decode(cursor, &scope)?; - let mut snapshot = load_snapshot(storage, projection_id, algorithm_generation, &scope) - .await? - .ok_or(TodayError::SnapshotMissing)?; - let current = load_state(storage, context, algorithm_generation) - .await? - .ok_or(TodayError::ProjectionMissing)?; - sanitize_snapshot_media(&mut snapshot, &current.media_cache); - (scope, snapshot, Some(position.rank)) - } else { - let as_of = request - .as_of - .filter(|value| *value != 0) - .ok_or(TodayError::InvalidRequest)?; - let state = load_state(storage, context, algorithm_generation) - .await? - .ok_or(TodayError::ProjectionMissing)?; - if state.store_generation != *event_status.generation().as_bytes() { - return Err(CursorError::Stale.into()); - } - let scope = CursorScope::new( - context.id.clone(), - context.generation, - as_of, - state.store_generation, - state.content_generation, - )?; - let snapshot = frozen_snapshot(&state, context, as_of)?; - persist_snapshot(storage, algorithm_generation, &scope, &snapshot).await?; - (scope, snapshot, None) - }; - - page_from_snapshot(snapshot, scope, after, request.limit) - } - - /// Searches the current local projection using Today visibility and context rules. - pub async fn phase1_search( - &self, - context: &LocalNetwork, - query: &str, - limit: u16, - as_of: u64, - ) -> Result<Vec<SearchResult>, TodayError> { - if limit == 0 || limit > TODAY_SEARCH_LIMIT_MAX || as_of == 0 { - return Err(TodayError::InvalidRequest); - } - let needle = query.trim().to_lowercase(); - if needle.is_empty() || needle.len() > 256 || query.chars().any(char::is_control) { - return Err(TodayError::InvalidRequest); - } - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let state = load_state(storage, context, projection_generation()?) - .await? - .ok_or(TodayError::ProjectionMissing)?; - let cards = ranked_cards(&state, context, as_of)?; - let mut results = Vec::new(); - for card in cards { - let searchable = format!( - "{} {} {} {}", - card.card.title.as_deref().unwrap_or(""), - card.card.content, - card.card.author_pubkey, - card.author_profile - .as_ref() - .and_then(|profile| profile.display_name.as_deref().or(profile.name.as_deref())) - .unwrap_or("") - ) - .to_lowercase(); - if searchable.contains(&needle) { - results.push(SearchResult { - result_type: SearchResultType::Card, - stable_id: card.card.card_id.to_hex(), - card: Some(card), - profile: None, - }); - if results.len() == usize::from(limit) { - return Ok(results); - } - } - } - for profile in state.profiles.values() { - let searchable = format!( - "{} {} {} {} {}", - profile.name.as_deref().unwrap_or(""), - profile.display_name.as_deref().unwrap_or(""), - profile.about.as_deref().unwrap_or(""), - profile.website.as_deref().unwrap_or(""), - profile.lightning_address.as_deref().unwrap_or("") - ) - .to_lowercase(); - if searchable.contains(&needle) { - results.push(SearchResult { - result_type: SearchResultType::Profile, - stable_id: profile.author_pubkey.clone(), - card: None, - profile: Some(profile.clone()), - }); - if results.len() == usize::from(limit) { - break; - } - } - } - Ok(results) - } - - /// Returns current active-identity attribution and visible Phase 1 content. - pub async fn phase1_me( - &self, - context: &LocalNetwork, - public_key: &str, - as_of: u64, - ) -> Result<MeSnapshot, TodayError> { - if !valid_public_key(public_key) || as_of == 0 { - return Err(TodayError::InvalidRequest); - } - if self - .authenticated_store_public_key_hex() - .is_some_and(|store_key| store_key != public_key) - { - return Err(TodayError::InvalidRequest); - } - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let state = load_state(storage, context, projection_generation()?) - .await? - .ok_or(TodayError::ProjectionMissing)?; - let cards = ranked_cards(&state, context, as_of)? - .into_iter() - .filter(|card| card.card.author_pubkey == public_key) - .collect(); - Ok(MeSnapshot { - public_key: public_key.to_owned(), - profile: state.profiles.get(public_key).cloned(), - cards, - }) - } - - /// Starts one typed retrieval for every occurrence of the exact structural - /// reference. URL equality alone is deliberately insufficient. - pub async fn phase1_begin_media_retrieval( - &self, - context: &LocalNetwork, - reference_fingerprint: [u8; 32], - pending: Phase1InboundMediaPending, - ) -> Result<bool, TodayError> { - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let generation = projection_generation()?; - let mut state = load_state(storage, context, generation) - .await? - .ok_or(TodayError::ProjectionMissing)?; - let mut trial = state.clone(); - let prior_configuration = trial.media_cache.status()?.configuration; - if prior_configuration.is_some_and(|value| value != pending.configuration()) { - return Err(Phase1InboundMediaError::ConfigurationMismatch.into()); - } - trial - .media_cache - .invalidate_configuration(pending.configuration()); - let changed = mutate_matching_media(&mut trial, reference_fingerprint, |media| { - media.begin(pending.clone()) - })?; - if changed { - state = trial; - persist_media_state(storage, context, generation, &mut state).await?; - } - Ok(changed) - } - - /// Records a bounded, safe retrieval failure for the active operation. - pub async fn phase1_fail_media_retrieval( - &self, - context: &LocalNetwork, - reference_fingerprint: [u8; 32], - failure: Phase1InboundMediaFailure, - ) -> Result<bool, TodayError> { - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let generation = projection_generation()?; - let mut state = load_state(storage, context, generation) - .await? - .ok_or(TodayError::ProjectionMissing)?; - let changed = mutate_matching_media(&mut state, reference_fingerprint, |media| { - media.fail(failure.clone()) - })?; - if changed { - persist_media_state(storage, context, generation, &mut state).await?; - } - Ok(changed) - } - - /// Atomically binds exact-byte evidence to the matching reference and - /// admits its content-addressed cache entry under the active LRU quota. - #[cfg(any(feature = "mobile-social", test))] - pub(crate) async fn phase1_commit_media_receipt( - &self, - context: &LocalNetwork, - reference_fingerprint: [u8; 32], - operation_id: [u8; 16], - receipt: Phase1VerifiedMediaReceipt, - policy: Phase1MediaCachePolicy, - cached_at_unix_ms: u64, - ) -> Result<Vec<Phase1MediaArtifactId>, TodayError> { - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let generation = projection_generation()?; - let mut state = load_state(storage, context, generation) - .await? - .ok_or(TodayError::ProjectionMissing)?; - let mut trial = state.clone(); - let changed = mutate_matching_media(&mut trial, reference_fingerprint, |media| { - media.verify(operation_id, receipt.clone()) - })?; - if !changed { - return Err(TodayError::InvalidRequest); - } - let evicted = trial - .media_cache - .admit(&receipt, policy, cached_at_unix_ms)?; - for artifact_id in &evicted { - invalidate_artifact_references(&mut trial, *artifact_id); - } - state = trial; - persist_media_state(storage, context, generation, &mut state).await?; - Ok(evicted) - } - - /// Records one successful local artifact access for deterministic LRU. - pub async fn phase1_touch_media_artifact( - &self, - context: &LocalNetwork, - artifact_id: Phase1MediaArtifactId, - observed_at_unix_ms: u64, - ) -> Result<bool, TodayError> { - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let generation = projection_generation()?; - let mut state = load_state(storage, context, generation) - .await? - .ok_or(TodayError::ProjectionMissing)?; - let changed = state.media_cache.touch(artifact_id, observed_at_unix_ms)?; - if changed { - persist_media_state(storage, context, generation, &mut state).await?; - } - Ok(changed) - } - - /// Invalidates a missing, corrupt, or explicitly evicted local artifact. - pub async fn phase1_invalidate_media_artifact( - &self, - context: &LocalNetwork, - artifact_id: Phase1MediaArtifactId, - ) -> Result<bool, TodayError> { - #[cfg(feature = "mobile-social")] - let _guard = self.inbound_media_lock.lock().await; - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let generation = projection_generation()?; - let mut state = load_state(storage, context, generation) - .await? - .ok_or(TodayError::ProjectionMissing)?; - let cache_changed = state.media_cache.invalidate_artifact(artifact_id); - let references_changed = invalidate_artifact_references(&mut state, artifact_id); - if cache_changed || references_changed { - persist_media_state(storage, context, generation, &mut state).await?; - } - #[cfg(feature = "mobile-social")] - if let Some(directory) = self.inbound_media_directory.as_deref() { - super::media::remove_artifact_files(directory, artifact_id).await?; - } - Ok(cache_changed || references_changed) - } - - /// Clears all trust derived under an obsolete endpoint/network/cache - /// configuration and records the new configuration generation. - pub async fn phase1_invalidate_media_configuration( - &self, - context: &LocalNetwork, - configuration: Phase1MediaConfigurationFingerprint, - ) -> Result<Vec<Phase1MediaArtifactId>, TodayError> { - #[cfg(feature = "mobile-social")] - let _guard = self.inbound_media_lock.lock().await; - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let generation = projection_generation()?; - let mut state = load_state(storage, context, generation) - .await? - .ok_or(TodayError::ProjectionMissing)?; - let prior_configuration = state.media_cache.status()?.configuration; - let removed = state.media_cache.invalidate_configuration(configuration); - if prior_configuration != Some(configuration) { - for_each_media_mut(&mut state, |media| { - media.invalidate(); - }); - persist_media_state(storage, context, generation, &mut state).await?; - } - #[cfg(feature = "mobile-social")] - if let Some(directory) = self.inbound_media_directory.as_deref() { - for artifact_id in &removed { - super::media::remove_artifact_files(directory, *artifact_id).await?; - } - } - Ok(removed) - } - - pub async fn phase1_media_cache_status( - &self, - context: &LocalNetwork, - ) -> Result<Phase1MediaCacheStatus, TodayError> { - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let state = load_state(storage, context, projection_generation()?) - .await? - .ok_or(TodayError::ProjectionMissing)?; - state.media_cache.status().map_err(TodayError::from) - } - - /// Resolves one renderable artifact only after rechecking the exact local - /// file. Missing or corrupt bytes atomically revoke all matching receipts. - #[cfg(feature = "mobile-social")] - pub async fn phase1_verified_media_artifact( - &self, - context: &LocalNetwork, - artifact_id: Phase1MediaArtifactId, - observed_at_unix_ms: u64, - ) -> Result<Option<Phase1LocalMediaArtifact>, TodayError> { - let _guard = self.inbound_media_lock.lock().await; - let directory = self - .inbound_media_directory - .as_deref() - .ok_or(Phase1InboundMediaError::CacheUnavailable)?; - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let generation = projection_generation()?; - let mut state = load_state(storage, context, generation) - .await? - .ok_or(TodayError::ProjectionMissing)?; - let Some(receipt) = verified_receipt(&state, artifact_id) else { - return Ok(None); - }; - match super::media::verified_artifact(directory, &receipt).await { - Ok(artifact) => { - if state.media_cache.touch(artifact_id, observed_at_unix_ms)? { - persist_media_state(storage, context, generation, &mut state).await?; - } - Ok(Some(artifact)) - } - Err(error) => { - state.media_cache.invalidate_artifact(artifact_id); - invalidate_artifact_references(&mut state, artifact_id); - persist_media_state(storage, context, generation, &mut state).await?; - let _ = super::media::remove_artifact_files(directory, artifact_id).await; - Err(error.into()) - } - } - } - - /// Completes one bounded BUD-01 retrieval, exact-byte verification, and - /// atomic content-addressed cache commit under the configured Blossom slot. - #[cfg(feature = "mobile-social")] - pub async fn phase1_retrieve_media( - &self, - context: &LocalNetwork, - reference_fingerprint: [u8; 32], - operation_id: [u8; 16], - policy: Phase1MediaCachePolicy, - cancellation: BlossomCancellation, - ) -> Result<Phase1LocalMediaArtifact, TodayError> { - let _guard = self.inbound_media_lock.lock().await; - let directory = self - .inbound_media_directory - .as_deref() - .ok_or(Phase1InboundMediaError::CacheUnavailable)?; - let blossom = self - .client - .blossom() - .map_err(|_| TodayError::RuntimeUnavailable)? - .cloned() - .ok_or(TodayError::RuntimeUnavailable)?; - let sdk_configuration = blossom - .config_fingerprint() - .ok_or(TodayError::RuntimeUnavailable)?; - let configuration = - Phase1MediaConfigurationFingerprint::new(*sdk_configuration.as_bytes())?; - let structural = load_structural_reference(self, context, reference_fingerprint).await?; - let started_at_unix_ms = inbound_now_unix_ms()?; - self.phase1_begin_media_retrieval( - context, - reference_fingerprint, - Phase1InboundMediaPending::new(operation_id, configuration, started_at_unix_ms)?, - ) - .await?; - let request = match inbound_request(&structural) { - Ok(request) => request, - Err(error) => { - record_inbound_failure( - self, - context, - reference_fingerprint, - operation_id, - "invalid_reference", - false, - ) - .await; - return Err(error); - } - }; - let sdk_receipt = match blossom.retrieve(request, cancellation).await { - Ok(receipt) => receipt, - Err(error) => { - record_inbound_failure( - self, - context, - reference_fingerprint, - operation_id, - error.code().trim_start_matches("blossom_"), - error.retryable(), - ) - .await; - return Err(error.into()); - } - }; - if sdk_receipt.config_fingerprint() != sdk_configuration { - record_inbound_failure( - self, - context, - reference_fingerprint, - operation_id, - "configuration_changed", - false, - ) - .await; - return Err(Phase1InboundMediaError::ConfigurationMismatch.into()); - } - let dimensions = sdk_receipt.dimensions(); - let receipt = match Phase1VerifiedMediaReceipt::from_commitment( - &structural, - sdk_receipt.final_url().clone(), - sdk_receipt.commitment(), - dimensions.width(), - dimensions.height(), - configuration, - sdk_receipt.verified_at_unix_ms(), - ) { - Ok(receipt) => receipt, - Err(error) => { - record_inbound_failure( - self, - context, - reference_fingerprint, - operation_id, - "verification_failed", - false, - ) - .await; - return Err(error.into()); - } - }; - let artifact = - match super::media::write_verified_artifact(directory, &receipt, sdk_receipt.bytes()) - .await - { - Ok(artifact) => artifact, - Err(error) => { - record_inbound_failure( - self, - context, - reference_fingerprint, - operation_id, - "cache_write_failed", - true, - ) - .await; - return Err(error.into()); - } - }; - let evicted = match self - .phase1_commit_media_receipt( - context, - reference_fingerprint, - operation_id, - receipt, - policy, - sdk_receipt.verified_at_unix_ms(), - ) - .await - { - Ok(evicted) => evicted, - Err(error) => { - record_inbound_failure( - self, - context, - reference_fingerprint, - operation_id, - "cache_commit_failed", - true, - ) - .await; - return Err(error); - } - }; - for artifact_id in evicted { - super::media::remove_artifact_files(directory, artifact_id).await?; - } - Ok(artifact) - } - - /// Persists active-author delivery state as a local-only Today overlay. - pub async fn phase1_set_local_author_overlay( - &self, - context: &LocalNetwork, - card_id: CardId, - overlay: Option<LocalAuthorOverlay>, - ) -> Result<(), TodayError> { - let storage = self - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let generation = projection_generation()?; - let mut state = load_state(storage, context, generation) - .await? - .ok_or(TodayError::ProjectionMissing)?; - if overlay.as_ref().is_some_and(|overlay| { - overlay.operation_id.is_empty() - || overlay.operation_id.len() > 256 - || overlay.state.is_empty() - || overlay.state.len() > 96 - || overlay.state.chars().any(char::is_control) - }) { - return Err(TodayError::InvalidRequest); - } - let key = card_id.to_hex(); - let card = state - .cards - .iter() - .find(|projected| projected.card.card_id == card_id) - .ok_or(TodayError::InvalidRequest)?; - if self - .authenticated_store_public_key_hex() - .is_some_and(|store_key| store_key != card.card.author_pubkey) - { - return Err(TodayError::InvalidRequest); - } - if let Some(overlay) = overlay { - state.overlays.insert(key, overlay); - } else { - state.overlays.remove(&key); - } - state.content_generation = content_generation(&state)?; - store_state(storage, context, generation, &state).await - } -} - -#[cfg(feature = "mobile-social")] -async fn load_structural_reference( - runtime: &RadrootsRuntime, - context: &LocalNetwork, - reference_fingerprint: [u8; 32], -) -> Result<Phase1StructuralMediaReference, TodayError> { - let storage = runtime - .client - .storage() - .map_err(|_| TodayError::RuntimeUnavailable)?; - let state = load_state(storage, context, projection_generation()?) - .await? - .ok_or(TodayError::ProjectionMissing)?; - state - .cards - .iter() - .flat_map(|projected| projected.card.media.iter()) - .chain( - state - .profiles - .values() - .flat_map(|profile| [&profile.picture, &profile.banner].into_iter().flatten()), - ) - .find(|media| media.structural().fingerprint() == &reference_fingerprint) - .map(|media| media.structural().clone()) - .ok_or(TodayError::InvalidRequest) -} - -#[cfg(feature = "mobile-social")] -fn inbound_request( - structural: &Phase1StructuralMediaReference, -) -> Result<BlossomInboundRequest, TodayError> { - let url = BlobUrl::parse(structural.source_url()) - .map_err(|_| Phase1InboundMediaError::InvalidReference)?; - let media_type = structural - .expected_media_type() - .map(MediaType::parse) - .transpose() - .map_err(|_| Phase1InboundMediaError::InvalidMediaType)?; - let dimensions = match (structural.expected_width(), structural.expected_height()) { - (Some(width), Some(height)) => Some(BlossomImageDimensions::new(width, height)?), - (None, None) => None, - _ => return Err(Phase1InboundMediaError::InvalidDimensions.into()), - }; - BlossomInboundRequest::new(url, media_type, structural.expected_byte_size(), dimensions) - .map_err(TodayError::from) -} - -#[cfg(feature = "mobile-social")] -fn inbound_now_unix_ms() -> Result<u64, TodayError> { - use std::time::{SystemTime, UNIX_EPOCH}; - - SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .and_then(|duration| u64::try_from(duration.as_millis()).ok()) - .filter(|value| *value != 0) - .ok_or(TodayError::RuntimeUnavailable) -} - -#[cfg(feature = "mobile-social")] -async fn record_inbound_failure( - runtime: &RadrootsRuntime, - context: &LocalNetwork, - reference_fingerprint: [u8; 32], - operation_id: [u8; 16], - safe_code: &str, - retryable: bool, -) { - let Ok(failed_at_unix_ms) = inbound_now_unix_ms() else { - return; - }; - let Ok(failure) = - Phase1InboundMediaFailure::new(operation_id, safe_code, retryable, failed_at_unix_ms) - else { - return; - }; - let _ = runtime - .phase1_fail_media_retrieval(context, reference_fingerprint, failure) - .await; -} - -#[cfg(feature = "mobile-social")] -struct TodayAdmissionPolicy; - -#[cfg(feature = "mobile-social")] -impl AdmissionPolicy for TodayAdmissionPolicy { - fn policy_id(&self) -> &'static str { - "radroots.mobile.today.v1" - } - - fn select_contract( - &self, - event: &radroots_event::admission::SignatureVerifiedEvent, - ) -> Option<&'static str> { - verify_nip01_event(event.event().clone()) - .ok() - .and_then(|event| admit_verified_event(event).ok()) - .map(|event| event.contract().id) - } - - fn decide(&self, event: &ContractValidatedEvent) -> AdmissionDecision { - let admitted = verify_nip01_event(event.event().clone()) - .ok() - .and_then(|event| admit_verified_event(event).ok()); - if admitted.is_some() { - AdmissionDecision::Visible - } else { - AdmissionDecision::Reject - } - } -} - -fn ingest_receipt( - receipt: AdmissionReceipt, - projection: TodayRefreshReceipt, -) -> TodayIngestReceipt { - TodayIngestReceipt { - event_id: receipt.event_id().to_hex(), - disposition: format!("{:?}", receipt.disposition()).to_lowercase(), - source_sequence: receipt.position().sequence().get(), - projection, - } -} - -fn refresh_receipt( - update: TodayProjectionUpdate, - state: &TodayProjectionState, - changed: bool, -) -> TodayRefreshReceipt { - TodayRefreshReceipt { - update, - source_events: state.source_events, - visible_cards: state.cards.len().try_into().unwrap_or(u64::MAX), - profiles: state.profiles.len().try_into().unwrap_or(u64::MAX), - thread_entries: state.thread.len().try_into().unwrap_or(u64::MAX), - content_generation: state.content_generation, - changed, - } -} - -fn local_media_evidence( - state: &TodayProjectionState, -) -> BTreeMap<[u8; 32], Phase1InboundMediaState> { - let mut evidence = state - .cards - .iter() - .flat_map(|projected| projected.card.media.iter()) - .filter(|media| !matches!(media.retrieval(), Phase1InboundMediaState::Unavailable)) - .map(|media| (*media.structural().fingerprint(), media.retrieval().clone())) - .collect::<BTreeMap<_, _>>(); - for profile in state.profiles.values() { - for media in [&profile.picture, &profile.banner].into_iter().flatten() { - if !matches!(media.retrieval(), Phase1InboundMediaState::Unavailable) { - evidence.insert(*media.structural().fingerprint(), media.retrieval().clone()); - } - } - } - evidence -} - -fn apply_local_media_evidence( - state: &mut TodayProjectionState, - evidence: &BTreeMap<[u8; 32], Phase1InboundMediaState>, -) { - let cache = state.media_cache.clone(); - for_each_media_mut(state, |media| { - if let Some(retrieval) = evidence.get(media.structural().fingerprint()) - && media.restore(retrieval.clone(), &cache).is_err() - { - media.invalidate(); - } - }); - refresh_thread_profiles(state); -} - -fn for_each_media_mut( - state: &mut TodayProjectionState, - mut action: impl FnMut(&mut MediaReference), -) { - for projected in &mut state.cards { - for media in &mut projected.card.media { - action(media); - } - } - for profile in state.profiles.values_mut() { - for media in [&mut profile.picture, &mut profile.banner] - .into_iter() - .flatten() - { - action(media); - } - } -} - -fn mutate_matching_media( - state: &mut TodayProjectionState, - reference_fingerprint: [u8; 32], - mut action: impl FnMut(&mut MediaReference) -> Result<(), Phase1InboundMediaError>, -) -> Result<bool, TodayError> { - let mut found = false; - let mut failure = None; - for_each_media_mut(state, |media| { - if failure.is_none() && media.structural().fingerprint() == &reference_fingerprint { - found = true; - if let Err(error) = action(media) { - failure = Some(error); - } - } - }); - if let Some(error) = failure { - return Err(error.into()); - } - if found { - refresh_thread_profiles(state); - } - Ok(found) -} - -fn invalidate_artifact_references( - state: &mut TodayProjectionState, - artifact_id: Phase1MediaArtifactId, -) -> bool { - let mut changed = false; - for_each_media_mut(state, |media| { - if matches!( - media.retrieval(), - Phase1InboundMediaState::Verified(receipt) if receipt.artifact_id() == artifact_id - ) { - media.invalidate(); - changed = true; - } - }); - if changed { - refresh_thread_profiles(state); - } - changed -} - -#[cfg(feature = "mobile-social")] -fn verified_receipt( - state: &TodayProjectionState, - artifact_id: Phase1MediaArtifactId, -) -> Option<Phase1VerifiedMediaReceipt> { - state - .cards - .iter() - .flat_map(|projected| projected.card.media.iter()) - .chain( - state - .profiles - .values() - .flat_map(|profile| [&profile.picture, &profile.banner].into_iter().flatten()), - ) - .find_map(|media| match media.retrieval() { - Phase1InboundMediaState::Verified(receipt) if receipt.artifact_id() == artifact_id => { - Some((**receipt).clone()) - } - _ => None, - }) -} - -async fn persist_media_state( - storage: &dyn radroots_storage::Storage, - context: &LocalNetwork, - generation: ProjectionGeneration, - state: &mut TodayProjectionState, -) -> Result<(), TodayError> { - refresh_thread_profiles(state); - validate_media_state(state)?; - state.content_generation = content_generation(state)?; - store_state(storage, context, generation, state).await -} - -fn refresh_thread_profiles(state: &mut TodayProjectionState) { - for entry in &mut state.thread { - entry.author_profile = state.profiles.get(&entry.author_pubkey).cloned(); - } -} - -async fn query_all_visible( - storage: &dyn radroots_storage::Storage, -) -> Result<Vec<radroots_storage::event::StoredVisibleEvent>, TodayError> { - let mut items = Vec::new(); - let mut after = None; - loop { - let mut bounds = EventQueryBounds::first(radroots_storage::event::EVENT_QUERY_LIMIT_MAX)?; - if let Some(cursor) = after { - bounds = bounds.after(cursor); - } - let page = EventStore::query_visible(storage, EventQuery::all(bounds)).await?; - items.extend_from_slice(page.items()); - let Some(next) = page.next_cursor() else { - break; - }; - after = Some(next); - } - Ok(items) -} - -fn project_state( - context: &LocalNetwork, - store_generation: &[u8; 32], - source_events: u64, - visible: Vec<radroots_storage::event::StoredVisibleEvent>, - overlays: BTreeMap<String, LocalAuthorOverlay>, -) -> Result<TodayProjectionState, TodayError> { - let mut cards = Vec::new(); - let mut profiles = BTreeMap::new(); - let mut thread = Vec::new(); - for stored in visible { - let verified = verify_nip01_event(stored.event().envelope().clone()) - .map_err(|_| TodayError::CorruptProjection)?; - let admitted = admit_verified_event(verified).map_err(|_| TodayError::CorruptProjection)?; - match &admitted { - RadrootsAdmittedEvent::Profile(profile) => { - profiles.insert( - profile.event().author().to_hex(), - profile_summary(&admitted)?, - ); - } - RadrootsAdmittedEvent::Reply(_) => { - thread.push(reply_entry(&admitted)?); - } - RadrootsAdmittedEvent::Comment(_) => { - if let Some(entry) = comment_entry(&admitted) { - thread.push(entry); - } - } - _ => { - let locality = locality_tags(admitted.event().tags_as_vec()); - let evidence = locality_evidence(context.locality.as_deref(), &locality); - if let ProductEventClassification::Card(card) = - classify_admitted_event(&admitted, context.admit(evidence)) - { - cards.push(ProjectedCard { - card: *card, - locality, - }); - } - } - } - } - cards.sort_by_key(|projected| projected.card.card_id); - thread.sort_by(|left, right| left.event_id.cmp(&right.event_id)); - for entry in &mut thread { - entry.author_profile = profiles.get(&entry.author_pubkey).cloned(); - } - Ok(TodayProjectionState { - schema_version: TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION, - context_id: context.id.clone(), - context_generation: context.generation, - store_generation: *store_generation, - source_events, - content_generation: 0, - cards, - profiles, - thread, - overlays, - media_cache: Phase1MediaCacheIndex::default(), - }) -} - -fn profile_summary(admitted: &RadrootsAdmittedEvent) -> Result<ProfileSummary, TodayError> { - let RadrootsAdmittedEvent::Profile(profile) = admitted else { - return Err(TodayError::CorruptProjection); - }; - let metadata = profile.metadata(); - Ok(ProfileSummary { - author_pubkey: profile.event().author().to_hex(), - name: metadata.name().map(str::to_owned), - display_name: metadata.display_name().map(str::to_owned), - about: metadata.about().map(str::to_owned), - picture: metadata - .picture() - .map(|value| unverified_media(value.as_str())) - .transpose()?, - banner: metadata - .banner() - .map(|value| unverified_media(value.as_str())) - .transpose()?, - nip05: metadata.nip05().map(|value| value.as_str().to_owned()), - website: typed_profile_extra(metadata.raw_fields(), "website"), - lightning_address: typed_profile_extra(metadata.raw_fields(), "lud16"), - }) -} - -fn typed_profile_extra(fields: &BTreeMap<String, serde_json::Value>, key: &str) -> Option<String> { - fields - .get(key) - .and_then(serde_json::Value::as_str) - .filter(|value| { - !value.is_empty() && value.len() <= 2_048 && !value.chars().any(char::is_control) - }) - .map(str::to_owned) -} - -fn unverified_media(url: &str) -> Result<MediaReference, TodayError> { - MediaReference::new(Phase1StructuralMediaReference::new( - url, - blossom_digest(url), - None, - None, - None, - None, - None, - )?) - .map_err(TodayError::from) -} - -fn reply_entry(admitted: &RadrootsAdmittedEvent) -> Result<ThreadEntry, TodayError> { - let RadrootsAdmittedEvent::Reply(reply) = admitted else { - return Err(TodayError::CorruptProjection); - }; - Ok(ThreadEntry { - event_id: reply.event().id_hex(), - author_pubkey: reply.event().author().to_hex(), - content: reply.event().content().to_owned(), - authored_at: reply.event().created_at_u64(), - reference: ThreadReference { - profile: SupportingProfile::Reply, - root: reply.projection().root().event_id().to_hex(), - parent_event_id: reply.projection().parent().event_id().to_hex(), - }, - author_profile: None, - }) -} - -fn comment_entry(admitted: &RadrootsAdmittedEvent) -> Option<ThreadEntry> { - let RadrootsAdmittedEvent::Comment(comment) = admitted else { - return None; - }; - let tags = comment.event().tags_as_vec(); - let root = tag_value(&tags, &["E", "A"])?; - let parent = tag_value(&tags, &["e", "a"]).unwrap_or_else(|| root.clone()); - Some(ThreadEntry { - event_id: comment.event().id_hex(), - author_pubkey: comment.event().author().to_hex(), - content: comment.event().content().to_owned(), - authored_at: comment.event().created_at_u64(), - reference: ThreadReference { - profile: SupportingProfile::Comment, - root, - parent_event_id: parent, - }, - author_profile: None, - }) -} - -fn locality_tags(tags: Vec<Vec<String>>) -> Vec<LocalityTag> { - let mut locality = tags - .into_iter() - .filter_map(|tag| { - let kind = tag.first()?.as_str(); - if !matches!(kind, "g" | "location") { - return None; - } - let value = tag.get(1)?.trim().to_lowercase(); - (!value.is_empty()).then(|| LocalityTag { - kind: kind.to_owned(), - value, - }) - }) - .collect::<Vec<_>>(); - locality.sort(); - locality.dedup(); - locality -} - -fn locality_evidence(selected: Option<&str>, locality: &[LocalityTag]) -> LocalityEvidence { - let Some(selected) = selected else { - return LocalityEvidence::Missing; - }; - if locality.is_empty() { - return LocalityEvidence::Missing; - } - let selected = selected.trim().to_lowercase(); - if locality.iter().any(|tag| { - tag.value == selected - || (tag.kind == "g" - && (tag.value.starts_with(&selected) || selected.starts_with(&tag.value))) - }) { - LocalityEvidence::Match - } else { - LocalityEvidence::Nonmatch - } -} - -fn ranked_cards( - state: &TodayProjectionState, - context: &LocalNetwork, - as_of: u64, -) -> Result<Vec<TodayCard>, TodayError> { - let mut cards = Vec::new(); - for projected in &state.cards { - let evidence = locality_evidence(context.locality.as_deref(), &projected.locality); - let admission = match context.admit(evidence) { - super::LocalNetworkAdmission::Included(admission) => admission, - super::LocalNetworkAdmission::Excluded { .. } => continue, - }; - let mut card = projected.card.clone(); - card.context_rank = admission.rank; - card.inclusion_reason = admission.reason.to_owned(); - let time = match card.card_type { - TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask => { - TimeRelevance::Published - } - TodayCardType::FoodAvailability => TimeRelevance::FoodAvailability { - active: card.lifecycle == CardLifecycleState::Active, - }, - TodayCardType::Event => TimeRelevance::Event { - start: card.event_start.unwrap_or(card.effective_at), - end: card.event_end, - }, - }; - let rank = TodayRank::derive(TodayRankInput { - card_type: card.card_type, - context_rank: card.context_rank, - as_of, - effective_at: card.effective_at, - time, - card_id: card.card_id, - }) - .map_err(|_| TodayError::CorruptProjection)?; - if card.card_type == TodayCardType::Event && rank.time_relevance_rank == 0 { - card.lifecycle = CardLifecycleState::Past; - } - card.rank = Some(rank); - let roots = [ - card.source_event_id.as_str(), - card.source_address.as_deref().unwrap_or(""), - ]; - let card_thread = state - .thread - .iter() - .filter(|entry| roots.contains(&entry.reference.root.as_str())) - .cloned() - .collect(); - cards.push(TodayCard { - author_profile: state.profiles.get(&card.author_pubkey).cloned(), - local_overlay: state.overlays.get(&card.card_id.to_hex()).cloned(), - card, - thread: card_thread, - }); - } - cards.sort_by_key(|card| card.card.rank.expect("assigned rank")); - Ok(cards) -} - -fn frozen_snapshot( - state: &TodayProjectionState, - context: &LocalNetwork, - as_of: u64, -) -> Result<FrozenTodaySnapshot, TodayError> { - Ok(FrozenTodaySnapshot { - schema_version: TODAY_SNAPSHOT_SCHEMA_VERSION, - context_id: context.id.clone(), - context_generation: context.generation, - as_of, - store_generation: state.store_generation, - projection_generation: state.content_generation, - items: ranked_cards(state, context, as_of)?, - }) -} - -fn page_from_snapshot( - snapshot: FrozenTodaySnapshot, - scope: CursorScope, - after: Option<TodayRank>, - limit: u16, -) -> Result<TodayPage, TodayError> { - validate_snapshot(&snapshot, &scope)?; - let start = if let Some(after) = after { - snapshot - .items - .iter() - .position(|card| card.card.rank == Some(after)) - .map(|index| index + 1) - .ok_or(TodayError::CursorPositionMissing)? - } else { - 0 - }; - let end = start - .saturating_add(usize::from(limit)) - .min(snapshot.items.len()); - let items = snapshot.items[start..end].to_vec(); - let next_cursor = if end < snapshot.items.len() { - items - .last() - .and_then(|card| card.card.rank) - .map(|rank| TodayCursor::encode(&scope, TodayCursorPosition { rank })) - .transpose()? - .map(|cursor| cursor.as_str().to_owned()) - } else { - None - }; - Ok(TodayPage { - as_of: snapshot.as_of, - items, - next_cursor, - }) -} - -fn validate_snapshot( - snapshot: &FrozenTodaySnapshot, - scope: &CursorScope, -) -> Result<(), TodayError> { - if snapshot.schema_version != TODAY_SNAPSHOT_SCHEMA_VERSION - || snapshot.context_id != scope.context_id - || snapshot.context_generation != scope.context_generation - || snapshot.as_of != scope.as_of - || snapshot.store_generation != scope.store_generation - || snapshot.projection_generation != scope.projection_generation - { - return Err(TodayError::CorruptProjection); - } - Ok(()) -} - -async fn load_state( - storage: &dyn radroots_storage::Storage, - context: &LocalNetwork, - generation: ProjectionGeneration, -) -> Result<Option<TodayProjectionState>, TodayError> { - let document = ProjectionStore::projection_document( - storage, - projection_id()?, - generation, - projection_document_key(context), - ) - .await?; - let Some(document) = document else { - return Ok(None); - }; - let (state, migrated) = decode_state_document(document.value())?; - if migrated { - store_state(storage, context, generation, &state).await?; - } - Ok(Some(state)) -} - -async fn store_state( - storage: &dyn radroots_storage::Storage, - context: &LocalNetwork, - generation: ProjectionGeneration, - state: &TodayProjectionState, -) -> Result<(), TodayError> { - ProjectionStore::put_projection_document( - storage, - projection_id()?, - generation, - ProjectionDocument::new(projection_document_key(context), encode(state)?)?, - ) - .await?; - Ok(()) -} - -async fn persist_snapshot( - storage: &dyn radroots_storage::Storage, - generation: ProjectionGeneration, - scope: &CursorScope, - snapshot: &FrozenTodaySnapshot, -) -> Result<(), TodayError> { - ProjectionStore::put_projection_snapshot( - storage, - ProjectionSnapshot::new( - projection_id()?, - snapshot_id(scope), - generation, - scope - .as_of - .checked_mul(1_000) - .ok_or(TodayError::InvalidRequest)?, - encode(snapshot)?, - )?, - ) - .await?; - Ok(()) -} - -async fn load_snapshot( - storage: &dyn radroots_storage::Storage, - projection_id: ProjectionId, - generation: ProjectionGeneration, - scope: &CursorScope, -) -> Result<Option<FrozenTodaySnapshot>, TodayError> { - ProjectionStore::projection_snapshot(storage, projection_id, snapshot_id(scope)) - .await? - .map(|snapshot| { - if snapshot.generation() != generation { - return Err(TodayError::CorruptProjection); - } - decode_snapshot(snapshot.value()) - }) - .transpose() -} - -#[cfg(test)] -fn decode_state(value: &[u8]) -> Result<TodayProjectionState, TodayError> { - decode_state_document(value).map(|(state, _)| state) -} - -fn decode_state_document(value: &[u8]) -> Result<(TodayProjectionState, bool), TodayError> { - if let Ok(state) = serde_json::from_slice::<TodayProjectionState>(value) - && state.schema_version == TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION - && state.content_generation != 0 - && content_generation(&state)? == state.content_generation - && validate_media_state(&state).is_ok() - { - return Ok((state, false)); - } - let state = migrate_legacy_state(value)?; - if state.schema_version != TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION - || state.content_generation == 0 - || content_generation(&state)? != state.content_generation - || validate_media_state(&state).is_err() - { - return Err(TodayError::CorruptProjection); - } - Ok((state, true)) -} - -fn validate_media_state(state: &TodayProjectionState) -> Result<(), Phase1InboundMediaError> { - state.media_cache.status()?; - for projected in &state.cards { - for media in &projected.card.media { - media.validate()?; - if let Phase1InboundMediaState::Verified(receipt) = media.retrieval() - && !state.media_cache.contains(receipt) - { - return Err(Phase1InboundMediaError::CorruptState); - } - } - } - for profile in state.profiles.values() { - for media in [&profile.picture, &profile.banner].into_iter().flatten() { - media.validate()?; - if let Phase1InboundMediaState::Verified(receipt) = media.retrieval() - && !state.media_cache.contains(receipt) - { - return Err(Phase1InboundMediaError::CorruptState); - } - } - } - if state - .thread - .iter() - .any(|entry| entry.author_profile.as_ref() != state.profiles.get(&entry.author_pubkey)) - { - return Err(Phase1InboundMediaError::CorruptState); - } - Ok(()) -} - -fn sanitize_snapshot_media(snapshot: &mut FrozenTodaySnapshot, cache: &Phase1MediaCacheIndex) { - for item in &mut snapshot.items { - for media in &mut item.card.media { - if let Phase1InboundMediaState::Verified(receipt) = media.retrieval() - && !cache.contains(receipt) - { - media.invalidate(); - } - } - if let Some(profile) = &mut item.author_profile { - for media in [&mut profile.picture, &mut profile.banner] - .into_iter() - .flatten() - { - if let Phase1InboundMediaState::Verified(receipt) = media.retrieval() - && !cache.contains(receipt) - { - media.invalidate(); - } - } - } - for entry in &mut item.thread { - if let Some(profile) = &mut entry.author_profile { - for media in [&mut profile.picture, &mut profile.banner] - .into_iter() - .flatten() - { - if let Phase1InboundMediaState::Verified(receipt) = media.retrieval() - && !cache.contains(receipt) - { - media.invalidate(); - } - } - } - } - } -} - -fn decode_snapshot(value: &[u8]) -> Result<FrozenTodaySnapshot, TodayError> { - let snapshot: FrozenTodaySnapshot = match serde_json::from_slice(value) { - Ok(snapshot) => snapshot, - Err(_) => { - let mut legacy: serde_json::Value = decode(value)?; - migrate_legacy_media_values(&mut legacy)?; - serde_json::from_value(legacy).map_err(|_| TodayError::CorruptProjection)? - } - }; - if snapshot.schema_version != TODAY_SNAPSHOT_SCHEMA_VERSION { - return Err(TodayError::CorruptProjection); - } - Ok(snapshot) -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct LegacyMediaReference { - url: String, - sha256: Option<String>, - media_type: Option<String>, - width: Option<u32>, - height: Option<u32>, - byte_size: Option<u64>, - alt: Option<String>, - verification: LegacyMediaVerificationState, -} - -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -enum LegacyMediaVerificationState { - Pending, - Verified, - Failed, - Unavailable, -} - -fn migrate_legacy_state(value: &[u8]) -> Result<TodayProjectionState, TodayError> { - verify_legacy_content_generation(value)?; - let mut legacy: serde_json::Value = decode(value)?; - migrate_legacy_media_values(&mut legacy)?; - let object = legacy - .as_object_mut() - .ok_or(TodayError::CorruptProjection)?; - if object.contains_key("mediaCache") { - return Err(TodayError::CorruptProjection); - } - object.insert( - "mediaCache".to_owned(), - serde_json::to_value(Phase1MediaCacheIndex::default()) - .map_err(|_| TodayError::Serialization)?, - ); - object.insert("contentGeneration".to_owned(), serde_json::json!(0)); - let mut state: TodayProjectionState = - serde_json::from_value(legacy).map_err(|_| TodayError::CorruptProjection)?; - state.content_generation = content_generation(&state)?; - Ok(state) -} - -fn verify_legacy_content_generation(value: &[u8]) -> Result<(), TodayError> { - let parsed: serde_json::Value = decode(value)?; - let expected = parsed - .get("contentGeneration") - .and_then(serde_json::Value::as_u64) - .filter(|value| *value != 0) - .ok_or(TodayError::CorruptProjection)?; - if parsed - .get("schemaVersion") - .and_then(serde_json::Value::as_u64) - != Some(u64::from(TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION)) - { - return Err(TodayError::CorruptProjection); - } - let marker = b"\"contentGeneration\":"; - let starts = value - .windows(marker.len()) - .enumerate() - .filter_map(|(index, bytes)| (bytes == marker).then_some(index)) - .collect::<Vec<_>>(); - let [start] = starts.as_slice() else { - return Err(TodayError::CorruptProjection); - }; - let number_start = start + marker.len(); - let number_end = value[number_start..] - .iter() - .position(|byte| !byte.is_ascii_digit()) - .map(|offset| number_start + offset) - .ok_or(TodayError::CorruptProjection)?; - if number_end == number_start { - return Err(TodayError::CorruptProjection); - } - let mut canonical = Vec::with_capacity(value.len()); - canonical.extend_from_slice(&value[..number_start]); - canonical.push(b'0'); - canonical.extend_from_slice(&value[number_end..]); - let digest = Sha256::digest([PROJECTION_CONTENT_DOMAIN, canonical.as_slice()].concat()); - let observed = u64::from_be_bytes(digest[..8].try_into().expect("digest prefix")).max(1); - (observed == expected) - .then_some(()) - .ok_or(TodayError::CorruptProjection) -} - -fn migrate_legacy_media_values(value: &mut serde_json::Value) -> Result<(), TodayError> { - match value { - serde_json::Value::Array(values) => { - for value in values { - migrate_legacy_media_values(value)?; - } - } - serde_json::Value::Object(object) - if object.contains_key("verification") && object.contains_key("url") => - { - let legacy: LegacyMediaReference = - serde_json::from_value(value.clone()).map_err(|_| TodayError::CorruptProjection)?; - let _legacy_verification = legacy.verification; - let migrated = MediaReference::legacy_unavailable( - legacy.url, - legacy.sha256, - legacy.media_type, - legacy.width, - legacy.height, - legacy.byte_size, - legacy.alt, - )?; - *value = serde_json::to_value(migrated).map_err(|_| TodayError::Serialization)?; - } - serde_json::Value::Object(object) => { - for value in object.values_mut() { - migrate_legacy_media_values(value)?; - } - } - _ => {} - } - Ok(()) -} - -fn content_generation(state: &TodayProjectionState) -> Result<u64, TodayError> { - let mut canonical = state.clone(); - canonical.content_generation = 0; - let digest = - Sha256::digest([PROJECTION_CONTENT_DOMAIN, encode(&canonical)?.as_slice()].concat()); - let generation = u64::from_be_bytes(digest[..8].try_into().expect("digest prefix")); - Ok(generation.max(1)) -} - -fn projection_generation() -> Result<ProjectionGeneration, TodayError> { - ProjectionGeneration::new(Sha256::digest(PROJECTION_GENERATION_DOMAIN).into()) - .map_err(TodayError::from) -} - -fn projection_id() -> Result<ProjectionId, TodayError> { - ProjectionId::parse(TODAY_PROJECTION_ID).map_err(TodayError::from) -} - -fn projection_document_key(context: &LocalNetwork) -> String { - let mut digest = Sha256::new(); - digest.update(PROJECTION_DOCUMENT_KEY_DOMAIN); - digest.update(context.id.as_bytes()); - digest.update(context.generation.to_be_bytes()); - format!("context.{}", hex::encode(digest.finalize())) -} - -fn snapshot_id(scope: &CursorScope) -> [u8; 32] { - let mut digest = Sha256::new(); - digest.update(SNAPSHOT_ID_DOMAIN); - digest.update(scope.context_id.as_bytes()); - digest.update(scope.context_generation.to_be_bytes()); - digest.update(scope.as_of.to_be_bytes()); - digest.update(scope.store_generation); - digest.update(scope.projection_generation.to_be_bytes()); - digest.finalize().into() -} - -fn tag_value(tags: &[Vec<String>], names: &[&str]) -> Option<String> { - tags.iter().find_map(|tag| { - names - .contains(&tag.first()?.as_str()) - .then(|| tag.get(1).cloned()) - .flatten() - }) -} - -fn blossom_digest(url: &str) -> Option<String> { - let path = url.split_once("://")?.1.split_once('/')?.1; - let candidate = path.split(['.', '/', '?', '#']).next()?; - (candidate.len() == 64 - && candidate - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) - .then(|| candidate.to_owned()) -} - -fn valid_public_key(value: &str) -> bool { - value.len() == 64 - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) -} - -fn encode(value: &impl Serialize) -> Result<Vec<u8>, TodayError> { - serde_json::to_vec(value).map_err(|_| TodayError::Serialization) -} - -fn decode<T: for<'de> Deserialize<'de>>(value: &[u8]) -> Result<T, TodayError> { - serde_json::from_slice(value).map_err(|_| TodayError::CorruptProjection) -} - -#[cfg(test)] -mod tests { - use super::*; - #[cfg(feature = "mobile-social")] - use std::sync::{Arc, Mutex, RwLock, atomic::AtomicBool}; - #[cfg(feature = "mobile-social")] - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - #[cfg(feature = "mobile-social")] - use tokio::net::TcpListener; - - use nostr::secp256k1::Message; - use nostr::{Keys, SECP256K1}; - use radroots_blossom::{ - BlobUrl, MediaType, Sha256 as BlossomSha256, descriptor::ByteCommitment, - }; - use radroots_event::{ - SignedEvent, - admission::{AdmissionPolicy, RawEvent, VisibilityPolicy}, - wire::{Nip01EventWire, compute_canonical_nip01_event_id}, - }; - use radroots_event_codec::verify::Nip01SignatureVerifier; - #[cfg(feature = "mobile-social")] - use radroots_transport::{ - Error as TransportError, EventSource, FetchPage, FetchRequest, SourceStatus, - outcome::{FetchTargetOutcome, FetchTargetState}, - source::NextPage, - }; - use radroots_transport::{ - Target, TransportId, - source::{EventProvenance, ObservedEvent}, - }; - - const SECRET: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; - - struct Allow; - - #[cfg(feature = "mobile-social")] - struct TodaySource { - event: SignedEvent, - requested_kinds: Mutex<Vec<Vec<u32>>>, - } - - #[cfg(feature = "mobile-social")] - impl EventSource for TodaySource { - fn status( - &self, - ) -> radroots_transport::BoxFuture<'_, Result<SourceStatus, TransportError>> { - Box::pin(async { unreachable!("Today sync does not inspect source status") }) - } - - fn fetch( - &self, - request: FetchRequest, - ) -> radroots_transport::BoxFuture<'_, Result<FetchPage, TransportError>> { - Box::pin(async move { - self.requested_kinds - .lock() - .expect("requested kinds") - .push(request.selector().kinds().to_vec()); - let target = request.target_set().targets()[0].clone(); - let provenance = EventProvenance::new( - TransportId::NOSTR, - target.fingerprint().clone(), - 2_000_000_100_000, - )?; - FetchPage::for_request( - &request, - vec![ObservedEvent::new(self.event.clone(), provenance)], - vec![FetchTargetOutcome::new( - target.fingerprint().clone(), - FetchTargetState::Complete, - )], - NextPage::Complete, - ) - }) - } - } - - impl AdmissionPolicy for Allow { - type Error = core::convert::Infallible; - - fn policy_id(&self) -> &'static str { - "test.today.admission.v1" - } - - fn admit( - &self, - _event: &radroots_event::admission::ContractValidatedEvent, - ) -> Result<(), Self::Error> { - Ok(()) - } - } - - impl VisibilityPolicy for Allow { - type Error = core::convert::Infallible; - - fn policy_id(&self) -> &'static str { - "test.today.visibility.v1" - } - - fn make_visible( - &self, - _event: &radroots_event::admission::AdmittedEvent, - ) -> Result<(), Self::Error> { - Ok(()) - } - } - - fn context(locality: Option<&str>, generation: u64) -> LocalNetwork { - LocalNetwork::new( - "victoria".into(), - "Victoria".into(), - vec!["wss://relay.example".into()], - locality.map(str::to_owned), - Vec::new(), - generation, - ) - .expect("context") - } - - fn keys() -> Keys { - Keys::parse(SECRET).expect("keys") - } - - fn signed(kind: u32, tags: Vec<Vec<&str>>, content: &str, created_at: u64) -> SignedEvent { - signed_owned( - kind, - tags.into_iter() - .map(|tag| tag.into_iter().map(str::to_owned).collect()) - .collect(), - content, - created_at, - ) - } - - fn signed_owned( - kind: u32, - tags: Vec<Vec<String>>, - content: &str, - created_at: u64, - ) -> SignedEvent { - let keys = keys(); - let author = keys.public_key().to_string(); - let id = compute_canonical_nip01_event_id(&author, created_at, kind, &tags, content) - .expect("id"); - let message = Message::from_digest(*id.as_bytes()); - let signature = SECP256K1.sign_schnorr_no_aux_rand( - &message, - &nostr::secp256k1::Keypair::from_secret_key(SECP256K1, keys.secret_key()), - ); - let wire = Nip01EventWire { - id: id.to_hex(), - pubkey: author, - created_at, - kind, - tags, - content: content.to_owned(), - sig: signature.to_string(), - extra: Default::default(), - }; - let raw = serde_json::json!({ - "id": &wire.id, - "pubkey": &wire.pubkey, - "created_at": wire.created_at, - "kind": wire.kind, - "tags": &wire.tags, - "content": &wire.content, - "sig": &wire.sig, - }) - .to_string(); - SignedEvent::from_wire_verified_id(wire, raw).expect("signed event") - } - - fn visible_admission(event: SignedEvent, observed_at: u64) -> EventAdmission { - let selected = admit_verified_event( - verify_nip01_event(event.envelope().clone()).expect("codec verification"), - ) - .expect("codec admission") - .contract_id(); - let verified = RawEvent::new(event.envelope().clone()) - .verify_id() - .expect("id") - .verify_signature(&Nip01SignatureVerifier) - .expect("signature"); - let visible = verified - .validate_contract_for_admission(selected) - .expect("selected contract") - .admit_with(&Allow) - .expect("admission") - .make_visible_with(&Allow) - .expect("visibility"); - let target = Target::new(TransportId::NOSTR, "wss://relay.example").expect("target"); - let provenance = EventProvenance::new( - TransportId::NOSTR, - target.fingerprint().clone(), - observed_at, - ) - .expect("provenance"); - EventAdmission::visible(ObservedEvent::new(event, provenance), visible) - .expect("visible admission") - } - - fn raw_admission(event: SignedEvent, observed_at: u64) -> EventAdmission { - let target = Target::new(TransportId::NOSTR, "wss://relay.example").expect("target"); - let provenance = EventProvenance::new( - TransportId::NOSTR, - target.fingerprint().clone(), - observed_at, - ) - .expect("provenance"); - EventAdmission::raw(ObservedEvent::new(event, provenance)) - } - - fn admitted(event: &SignedEvent) -> RadrootsAdmittedEvent { - admit_verified_event( - verify_nip01_event(event.envelope().clone()).expect("codec verification"), - ) - .expect("codec admission") - } - - async fn ingest( - runtime: &RadrootsRuntime, - context: &LocalNetwork, - event: SignedEvent, - at: u64, - ) -> TodayIngestReceipt { - runtime - .phase1_ingest_visible(visible_admission(event, at * 1_000), context, at) - .await - .expect("ingest") - } - - #[allow(clippy::too_many_arguments)] - async fn verify_inbound_media( - runtime: &RadrootsRuntime, - context: &LocalNetwork, - source_url: &str, - bytes: &[u8], - media_type: &str, - width: u32, - height: u32, - operation_id: [u8; 16], - ) { - let storage = runtime.client.storage().expect("storage"); - let state = load_state(storage, context, projection_generation().unwrap()) - .await - .unwrap() - .expect("projection"); - let reference = state - .cards - .iter() - .flat_map(|value| value.card.media.iter()) - .chain( - state - .profiles - .values() - .flat_map(|profile| [&profile.picture, &profile.banner].into_iter().flatten()), - ) - .find(|media| media.structural().source_url() == source_url) - .expect("structural media") - .structural() - .clone(); - let configuration = Phase1MediaConfigurationFingerprint::new([9; 32]).unwrap(); - runtime - .phase1_begin_media_retrieval( - context, - *reference.fingerprint(), - Phase1InboundMediaPending::new(operation_id, configuration, 10).unwrap(), - ) - .await - .expect("begin retrieval"); - let commitment = ByteCommitment::from_bytes(bytes, MediaType::parse(media_type).unwrap()); - let receipt = Phase1VerifiedMediaReceipt::from_commitment( - &reference, - BlobUrl::parse(source_url).unwrap(), - &commitment, - width, - height, - configuration, - 11, - ) - .expect("byte receipt"); - runtime - .phase1_commit_media_receipt( - context, - *reference.fingerprint(), - operation_id, - receipt, - Phase1MediaCachePolicy::new(1_024, 8).unwrap(), - 12, - ) - .await - .expect("commit receipt"); - } - - fn downgrade_media_to_legacy(value: &mut serde_json::Value) { - match value { - serde_json::Value::Array(values) => { - for value in values { - downgrade_media_to_legacy(value); - } - } - serde_json::Value::Object(object) - if object.contains_key("structural") && object.contains_key("retrieval") => - { - let structural = object - .get("structural") - .and_then(serde_json::Value::as_object) - .expect("structural object"); - *value = serde_json::json!({ - "url": structural.get("sourceUrl").cloned().unwrap(), - "sha256": structural.get("expectedSha256").cloned().unwrap(), - "mediaType": structural.get("expectedMediaType").cloned().unwrap(), - "width": structural.get("expectedWidth").cloned().unwrap(), - "height": structural.get("expectedHeight").cloned().unwrap(), - "byteSize": structural.get("expectedByteSize").cloned().unwrap(), - "alt": structural.get("alt").cloned().unwrap(), - "verification": "Verified", - }); - } - serde_json::Value::Object(object) => { - for value in object.values_mut() { - downgrade_media_to_legacy(value); - } - } - _ => {} - } - } - - fn legacy_state_bytes(state: &TodayProjectionState) -> Vec<u8> { - let mut value = serde_json::to_value(state).expect("state value"); - let object = value.as_object_mut().expect("state object"); - object.remove("mediaCache").expect("new cache field"); - object.insert("contentGeneration".to_owned(), serde_json::json!(0)); - downgrade_media_to_legacy(&mut value); - let canonical = serde_json::to_vec(&value).expect("legacy canonical"); - let digest = - sha2::Sha256::digest([PROJECTION_CONTENT_DOMAIN, canonical.as_slice()].concat()); - let generation = u64::from_be_bytes(digest[..8].try_into().expect("digest prefix")).max(1); - value["contentGeneration"] = serde_json::json!(generation); - serde_json::to_vec(&value).expect("legacy state") - } - - #[cfg(feature = "mobile-social")] - fn png(width: u32, height: u32) -> Vec<u8> { - let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); - bytes.extend_from_slice(&width.to_be_bytes()); - bytes.extend_from_slice(&height.to_be_bytes()); - bytes - } - - #[cfg(feature = "mobile-social")] - async fn serve_one_blob(listener: TcpListener, bytes: Vec<u8>) { - let (mut stream, _) = listener.accept().await.expect("accept"); - let mut request = Vec::new(); - while !request.windows(4).any(|value| value == b"\r\n\r\n") { - let mut chunk = [0_u8; 1024]; - let read = stream.read(&mut chunk).await.expect("request read"); - assert_ne!(read, 0); - request.extend_from_slice(&chunk[..read]); - assert!(request.len() <= 64 * 1024); - } - let headers = String::from_utf8_lossy(&request); - assert!(headers.starts_with("GET /")); - assert!( - headers - .to_ascii_lowercase() - .contains("accept-encoding: identity") - ); - assert!(!headers.to_ascii_lowercase().contains("authorization:")); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - bytes.len() - ); - stream.write_all(response.as_bytes()).await.expect("head"); - stream.write_all(&bytes).await.expect("body"); - stream.shutdown().await.expect("close"); - } - - #[cfg(feature = "mobile-social")] - #[tokio::test] - async fn relay_sync_fetches_the_exact_today_selector_and_projects_real_events() { - let source = Arc::new(TodaySource { - event: signed(1, Vec::new(), "Fresh from the field", 2_000_000_000), - requested_kinds: Mutex::new(Vec::new()), - }); - let client = radroots_sdk::ClientBuilder::memory_default() - .source(source.clone()) - .host_sync(radroots_sdk::sync::HostPolicy::standard()) - .build() - .expect("client"); - let runtime = RadrootsRuntime { - client, - started_unix_ms: 1, - shutting_down: AtomicBool::new(false), - platform_app: RwLock::new(None), - store_public_key: None, - settings_lock: tokio::sync::Mutex::new(()), - identity_session: tokio::sync::RwLock::new(None), - inbound_media_directory: None, - inbound_media_lock: tokio::sync::Mutex::new(()), - }; - let context = context(None, 1); - - let receipt = runtime - .phase1_sync_today(&context, 2_000_000_200, TodayProjectionUpdate::Incremental) - .await - .expect("Today sync"); - assert_eq!(receipt.relay_state, TodayRelaySyncState::Complete); - assert_eq!(receipt.pages_fetched, 1); - assert_eq!(receipt.events_observed, 1); - assert_eq!(receipt.events_admitted, 1); - assert_eq!(receipt.events_rejected, 0); - assert_eq!(receipt.projection.visible_cards, 1); - assert_eq!( - source - .requested_kinds - .lock() - .expect("requested kinds") - .as_slice(), - &[TODAY_SYNC_KINDS.to_vec()] - ); - let page = runtime - .phase1_today_page(&context, TodayPageRequest::first(20, 2_000_000_200)) - .await - .expect("Today page"); - assert_eq!(page.items.len(), 1); - assert_eq!(page.items[0].card.card_type, TodayCardType::Update); - assert_eq!(page.items[0].card.content, "Fresh from the field"); - } - - #[tokio::test] - async fn equal_timestamp_pages_are_complete_and_remain_frozen_across_ingest() { - let runtime = RadrootsRuntime::test_memory().expect("runtime"); - let context = context(None, 1); - for content in ["alpha", "bravo", "charlie"] { - ingest( - &runtime, - &context, - signed(1, Vec::new(), content, 2_000_000_000), - 2_000_000_100, - ) - .await; - } - let first = runtime - .phase1_today_page(&context, TodayPageRequest::first(1, 2_000_000_200)) - .await - .expect("first page"); - assert_eq!(first.items.len(), 1); - let frozen_cursor = first.next_cursor.clone().expect("cursor"); - - ingest( - &runtime, - &context, - signed(1, Vec::new(), "delta", 2_000_000_001), - 2_000_000_101, - ) - .await; - - let mut ids = first - .items - .iter() - .map(|card| card.card.card_id.to_hex()) - .collect::<Vec<_>>(); - let mut cursor = Some(frozen_cursor); - while let Some(value) = cursor { - let page = runtime - .phase1_today_page(&context, TodayPageRequest::after(1, value)) - .await - .expect("continued frozen page"); - ids.extend(page.items.iter().map(|card| card.card.card_id.to_hex())); - cursor = page.next_cursor; - } - ids.sort(); - ids.dedup(); - assert_eq!(ids.len(), 3, "frozen snapshot has no loss or duplicates"); - - let current = runtime - .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_201)) - .await - .expect("current page"); - assert_eq!(current.items.len(), 4); - } - - #[tokio::test] - async fn profile_thread_media_search_me_context_and_rebuild_share_one_projection() { - let runtime = RadrootsRuntime::test_memory().expect("runtime"); - let context = context(Some("victoria"), 7); - let author = keys().public_key().to_string(); - let profile_bytes = b"profile picture"; - let profile_digest = BlossomSha256::digest(profile_bytes).to_hex(); - let profile_url = format!("https://blob.example/{profile_digest}.jpg"); - let profile = signed( - 0, - Vec::new(), - &format!( - r#"{{"name":"moss","display_name":"Moss Farm","about":"Local roots","picture":"{profile_url}","website":"https://moss.example","lud16":"moss@example.com"}}"# - ), - 2_000_000_000, - ); - ingest(&runtime, &context, profile, 2_000_000_100).await; - - let photo_bytes = b"photo"; - let digest = BlossomSha256::digest(photo_bytes).to_hex(); - let photo_url = format!("https://blob.example/{digest}.jpg"); - let photo_content = format!("Fresh field photo {photo_url}"); - let photo = signed_owned( - 1, - vec![ - vec!["location".into(), "Victoria".into()], - vec![ - "imeta".into(), - format!("url {photo_url}"), - format!("x {digest}"), - "m image/jpeg".into(), - "dim 120x80".into(), - format!("size {}", photo_bytes.len()), - "alt Fresh field photo".into(), - ], - ], - &photo_content, - 2_000_000_001, - ); - let root_id = photo.id().to_hex(); - ingest(&runtime, &context, photo, 2_000_000_101).await; - - let nonmatch = signed( - 1, - vec![vec!["location", "Elsewhere"]], - "far away", - 2_000_000_002, - ); - ingest(&runtime, &context, nonmatch, 2_000_000_102).await; - - let reply = signed_owned( - 1, - vec![ - vec![ - "e".into(), - root_id.clone(), - "wss://relay.example".into(), - "root".into(), - ], - vec!["p".into(), author.clone()], - ], - "Looks good", - 2_000_000_003, - ); - ingest(&runtime, &context, reply, 2_000_000_103).await; - - let food = signed( - 30_402, - vec![ - vec!["d", "today-carrots"], - vec!["title", "Today carrots"], - vec!["summary", "Fresh"], - vec!["published_at", "2000000004"], - vec!["location", "Victoria"], - vec!["price", "3", "CAD"], - vec!["radroots:price_unit", "lb"], - vec!["status", "active"], - ], - "Fresh carrots", - 2_000_000_004, - ); - let food_id = food.id().to_hex(); - ingest(&runtime, &context, food, 2_000_000_104).await; - - let comment = signed_owned( - 1_111, - vec![ - vec![ - "E".into(), - food_id.clone(), - "wss://relay.example".into(), - author.clone(), - ], - vec!["K".into(), "30402".into()], - vec!["P".into(), author.clone(), "wss://relay.example".into()], - vec![ - "e".into(), - food_id, - "wss://relay.example".into(), - author.clone(), - ], - vec!["k".into(), "30402".into()], - vec!["p".into(), author.clone(), "wss://relay.example".into()], - ], - "A NIP-22 comment", - 2_000_000_005, - ); - ingest(&runtime, &context, comment, 2_000_000_105).await; - - verify_inbound_media( - &runtime, - &context, - &photo_url, - photo_bytes, - "image/jpeg", - 120, - 80, - [1; 16], - ) - .await; - verify_inbound_media( - &runtime, - &context, - &profile_url, - profile_bytes, - "image/jpeg", - 24, - 24, - [2; 16], - ) - .await; - let live = runtime - .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_200)) - .await - .expect("page"); - assert_eq!(live.items.len(), 2, "known locality nonmatch is excluded"); - let card = live - .items - .iter() - .find(|card| card.card.card_type == TodayCardType::PhotoUpdate) - .unwrap_or_else(|| panic!("photo card missing from {:#?}", live.items)); - assert_eq!(card.card.card_type, TodayCardType::PhotoUpdate); - assert!(matches!( - card.card.media[0].retrieval(), - Phase1InboundMediaState::Verified(_) - )); - assert_eq!(card.thread.len(), 1); - assert_eq!( - live.items - .iter() - .find(|card| card.card.card_type == TodayCardType::FoodAvailability) - .expect("food card") - .thread - .len(), - 1 - ); - let profile = card.author_profile.as_ref().expect("profile enrichment"); - assert_eq!(profile.website.as_deref(), Some("https://moss.example")); - assert_eq!( - profile.lightning_address.as_deref(), - Some("moss@example.com") - ); - assert!(matches!( - profile - .picture - .as_ref() - .expect("profile picture") - .retrieval(), - Phase1InboundMediaState::Verified(_) - )); - - runtime - .phase1_set_local_author_overlay( - &context, - card.card.card_id, - Some(LocalAuthorOverlay { - operation_id: "publish-photo-1".into(), - state: "delivered".into(), - }), - ) - .await - .expect("active author overlay"); - assert!(matches!( - runtime - .phase1_set_local_author_overlay( - &context, - CardId::parse(&"f".repeat(64)).expect("unknown card id"), - None, - ) - .await, - Err(TodayError::InvalidRequest) - )); - - let search = runtime - .phase1_search(&context, "moss farm", 10, 2_000_000_200) - .await - .expect("search"); - assert!(search.iter().any(|result| result.profile.is_some())); - let card_search = runtime - .phase1_search(&context, "fresh field photo", 10, 2_000_000_200) - .await - .expect("card search"); - assert!(card_search.iter().any(|result| result.card.is_some())); - let profile_limited = runtime - .phase1_search(&context, "moss@example.com", 1, 2_000_000_200) - .await - .expect("profile-limited search"); - assert_eq!(profile_limited.len(), 1); - assert!(profile_limited[0].profile.is_some()); - let me = runtime - .phase1_me(&context, &author, 2_000_000_200) - .await - .expect("me"); - assert_eq!(me.cards.len(), 2); - assert_eq!( - me.profile.expect("me profile").name.as_deref(), - Some("moss") - ); - - let rebuilt = runtime - .phase1_refresh_today(&context, 2_000_000_201, TodayProjectionUpdate::Rebuild) - .await - .expect("rebuild"); - assert!(!rebuilt.changed, "rebuild is byte-equivalent to live state"); - let after = runtime - .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_202)) - .await - .expect("rebuilt page"); - let rebuilt_photo = after - .items - .iter() - .find(|card| card.card.card_type == TodayCardType::PhotoUpdate) - .expect("rebuilt photo"); - assert!(matches!( - rebuilt_photo.card.media[0].retrieval(), - Phase1InboundMediaState::Verified(_) - )); - assert!(matches!( - rebuilt_photo - .author_profile - .as_ref() - .and_then(|profile| profile.picture.as_ref()) - .expect("rebuilt profile picture") - .retrieval(), - Phase1InboundMediaState::Verified(_) - )); - assert_eq!( - rebuilt_photo - .local_overlay - .as_ref() - .map(|overlay| overlay.state.as_str()), - Some("delivered") - ); - assert_eq!(rebuilt_photo.thread.len(), 1); - - let photo_reference = rebuilt_photo.card.media[0].structural().clone(); - let photo_receipt = match rebuilt_photo.card.media[0].retrieval() { - Phase1InboundMediaState::Verified(receipt) => (**receipt).clone(), - state => panic!("unexpected photo state: {state:?}"), - }; - let profile_artifact = match rebuilt_photo - .author_profile - .as_ref() - .and_then(|profile| profile.picture.as_ref()) - .expect("profile picture before eviction") - .retrieval() - { - Phase1InboundMediaState::Verified(receipt) => receipt.artifact_id(), - state => panic!("unexpected profile state: {state:?}"), - }; - assert!(matches!( - runtime - .phase1_fail_media_retrieval( - &context, - *photo_reference.fingerprint(), - Phase1InboundMediaFailure::new([3; 16], "network", true, 29).unwrap(), - ) - .await, - Err(TodayError::InboundMedia( - Phase1InboundMediaError::OperationMismatch - )) - )); - assert!(matches!( - runtime - .phase1_begin_media_retrieval( - &context, - *photo_reference.fingerprint(), - Phase1InboundMediaPending::new( - [3; 16], - Phase1MediaConfigurationFingerprint::new([8; 32]).unwrap(), - 29, - ) - .unwrap(), - ) - .await, - Err(TodayError::InboundMedia( - Phase1InboundMediaError::ConfigurationMismatch - )) - )); - assert!(matches!( - runtime - .phase1_commit_media_receipt( - &context, - [0; 32], - [3; 16], - photo_receipt.clone(), - Phase1MediaCachePolicy::new(1_024, 8).unwrap(), - 29, - ) - .await, - Err(TodayError::InvalidRequest) - )); - runtime - .phase1_begin_media_retrieval( - &context, - *photo_reference.fingerprint(), - Phase1InboundMediaPending::new( - [3; 16], - Phase1MediaConfigurationFingerprint::new([9; 32]).unwrap(), - 30, - ) - .unwrap(), - ) - .await - .expect("repeat retrieval"); - let evicted = runtime - .phase1_commit_media_receipt( - &context, - *photo_reference.fingerprint(), - [3; 16], - photo_receipt, - Phase1MediaCachePolicy::new(1_024, 1).unwrap(), - 31, - ) - .await - .expect("quota commit"); - assert_eq!(evicted, vec![profile_artifact]); - let quota_page = runtime - .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_203)) - .await - .expect("quota page"); - let quota_photo = quota_page - .items - .iter() - .find(|card| card.card.card_type == TodayCardType::PhotoUpdate) - .expect("quota photo"); - assert!(matches!( - quota_photo.card.media[0].retrieval(), - Phase1InboundMediaState::Verified(_) - )); - assert!(matches!( - quota_photo - .author_profile - .as_ref() - .and_then(|profile| profile.picture.as_ref()) - .expect("evicted profile picture") - .retrieval(), - Phase1InboundMediaState::Unavailable - )); - - let removed = runtime - .phase1_invalidate_media_configuration( - &context, - Phase1MediaConfigurationFingerprint::new([8; 32]).unwrap(), - ) - .await - .expect("configuration invalidation"); - assert_eq!(removed.len(), 1); - assert!( - runtime - .phase1_invalidate_media_configuration( - &context, - Phase1MediaConfigurationFingerprint::new([8; 32]).unwrap(), - ) - .await - .expect("idempotent configuration invalidation") - .is_empty() - ); - assert!( - !runtime - .phase1_invalidate_media_artifact( - &context, - Phase1MediaArtifactId::parse(&"f".repeat(64)).unwrap(), - ) - .await - .expect("missing artifact invalidation") - ); - let invalidated = runtime - .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_203)) - .await - .expect("page after configuration change"); - let invalidated_photo = invalidated - .items - .iter() - .find(|card| card.card.card_type == TodayCardType::PhotoUpdate) - .expect("invalidated photo"); - assert!(matches!( - invalidated_photo.card.media[0].retrieval(), - Phase1InboundMediaState::Unavailable - )); - assert!(matches!( - invalidated_photo - .author_profile - .as_ref() - .and_then(|profile| profile.picture.as_ref()) - .expect("invalidated profile picture") - .retrieval(), - Phase1InboundMediaState::Unavailable - )); - } - - #[tokio::test] - async fn legacy_enum_only_media_migrates_to_unavailable_and_repersists() { - let runtime = RadrootsRuntime::test_memory().expect("runtime"); - let context = context(None, 1); - let bytes = b"legacy profile"; - let hash = BlossomSha256::digest(bytes).to_hex(); - let url = format!("https://blob.example/{hash}.jpg"); - ingest( - &runtime, - &context, - signed( - 0, - Vec::new(), - &format!(r#"{{"name":"legacy","picture":"{url}"}}"#), - 2_000_000_000, - ), - 2_000_000_100, - ) - .await; - let storage = runtime.client.storage().expect("storage"); - let generation = projection_generation().unwrap(); - let state = load_state(storage, &context, generation) - .await - .unwrap() - .expect("state"); - let legacy = legacy_state_bytes(&state); - ProjectionStore::put_projection_document( - storage, - projection_id().unwrap(), - generation, - ProjectionDocument::new(projection_document_key(&context), legacy.clone()).unwrap(), - ) - .await - .unwrap(); - - let migrated = load_state(storage, &context, generation) - .await - .unwrap() - .expect("migrated state"); - let picture = migrated - .profiles - .values() - .next() - .and_then(|profile| profile.picture.as_ref()) - .expect("picture"); - assert!(matches!( - picture.retrieval(), - Phase1InboundMediaState::Unavailable - )); - assert_eq!(migrated.media_cache.status().unwrap().artifacts, 0); - let stored = ProjectionStore::projection_document( - storage, - projection_id().unwrap(), - generation, - projection_document_key(&context), - ) - .await - .unwrap() - .expect("repersisted state"); - let text = std::str::from_utf8(stored.value()).unwrap(); - assert!(!text.contains("\"verification\"")); - assert!(text.contains("\"mediaCache\"")); - - let mut tampered: serde_json::Value = serde_json::from_slice(&legacy).unwrap(); - tampered["profiles"] - .as_object_mut() - .unwrap() - .values_mut() - .next() - .unwrap()["name"] = serde_json::json!("tampered"); - assert!(matches!( - decode_state(&serde_json::to_vec(&tampered).unwrap()), - Err(TodayError::CorruptProjection) - )); - } - - #[tokio::test] - async fn replacement_and_deletion_change_only_current_today_truth() { - let runtime = RadrootsRuntime::test_memory().expect("runtime"); - let context = context(None, 1); - let active = signed( - 30_402, - vec![ - vec!["d", "carrots"], - vec!["title", "Carrots"], - vec!["summary", "Fresh"], - vec!["published_at", "2000000000"], - vec!["location", "Victoria"], - vec!["price", "3", "CAD"], - vec!["radroots:price_unit", "lb"], - vec!["status", "active"], - ], - "available", - 2_000_000_000, - ); - ingest(&runtime, &context, active, 2_000_000_100).await; - let sold = signed( - 30_402, - vec![ - vec!["d", "carrots"], - vec!["title", "Carrots"], - vec!["summary", "Gone"], - vec!["published_at", "2000000001"], - vec!["location", "Victoria"], - vec!["price", "3", "CAD"], - vec!["radroots:price_unit", "lb"], - vec!["status", "sold"], - ], - "sold out", - 2_000_000_001, - ); - let sold_id = sold.id().to_hex(); - ingest(&runtime, &context, sold, 2_000_000_101).await; - let current = runtime - .phase1_today_page(&context, TodayPageRequest::first(10, 2_000_000_200)) - .await - .expect("current"); - assert_eq!(current.items.len(), 1); - assert_eq!(current.items[0].card.source_event_id, sold_id); - assert_eq!(current.items[0].card.lifecycle, CardLifecycleState::Sold); - - let deletion = signed_owned(5, vec![vec!["e".into(), sold_id]], "", 2_000_000_002); - ingest(&runtime, &context, deletion, 2_000_000_102).await; - let deleted = runtime - .phase1_today_page(&context, TodayPageRequest::first(10, 2_000_000_201)) - .await - .expect("deleted"); - assert!(deleted.items.is_empty()); - } - - #[tokio::test] - async fn sqlite_reopen_preserves_materialized_state_and_frozen_cursor_pages() { - use crate::runtime::{ - builder::RuntimeBuilder, - store::{MobileUserStoreConfig, ProtectedDataAvailability}, - }; - - let root = tempfile::tempdir().expect("root"); - let store = MobileUserStoreConfig::from_encoded( - root.path(), - &keys().public_key().to_string(), - "3131313131313131313131313131313131313131313131313131313131313131", - 2_000_000_000_000, - ProtectedDataAvailability::Available, - ) - .expect("store"); - std::fs::create_dir_all(store.owner_directory()).expect("owner directory"); - let context = context(None, 1); - let runtime = RuntimeBuilder::new(store.clone()) - .build() - .await - .expect("runtime"); - ingest( - &runtime, - &context, - signed(1, Vec::new(), "persisted alpha", 2_000_000_000), - 2_000_000_100, - ) - .await; - ingest( - &runtime, - &context, - signed(1, Vec::new(), "persisted bravo", 2_000_000_000), - 2_000_000_101, - ) - .await; - let first = runtime - .phase1_today_page(&context, TodayPageRequest::first(1, 2_000_000_200)) - .await - .expect("first page"); - let cursor = first.next_cursor.expect("frozen cursor"); - runtime.shutdown().await.expect("shutdown"); - - let reopened = RuntimeBuilder::new(store).build().await.expect("reopen"); - let second = reopened - .phase1_today_page(&context, TodayPageRequest::after(1, cursor)) - .await - .expect("continued page after reopen"); - assert_eq!(second.items.len(), 1); - assert_ne!(first.items[0].card.card_id, second.items[0].card.card_id); - assert!(second.next_cursor.is_none()); - let search = reopened - .phase1_search(&context, "persisted", 10, 2_000_000_200) - .await - .expect("search after reopen"); - assert_eq!(search.len(), 2); - reopened.shutdown().await.expect("shutdown reopened"); - } - - #[tokio::test] - async fn sqlite_reopen_preserves_receipts_and_missing_artifacts_fail_closed() { - use crate::runtime::{ - builder::RuntimeBuilder, - store::{MobileUserStoreConfig, ProtectedDataAvailability}, - }; - - let root = tempfile::tempdir().expect("root"); - let public_key = keys().public_key().to_string(); - let store = MobileUserStoreConfig::from_encoded( - root.path(), - &public_key, - "4141414141414141414141414141414141414141414141414141414141414141", - 2_000_000_000_000, - ProtectedDataAvailability::Available, - ) - .expect("store"); - std::fs::create_dir_all(store.owner_directory()).expect("owner directory"); - let context = context(None, 1); - let runtime = RuntimeBuilder::new(store.clone()) - .build() - .await - .expect("runtime"); - let bytes = b"sqlite profile"; - let hash = BlossomSha256::digest(bytes).to_hex(); - let url = format!("https://blob.example/{hash}.jpg"); - ingest( - &runtime, - &context, - signed( - 0, - Vec::new(), - &format!(r#"{{"name":"sqlite","picture":"{url}"}}"#), - 2_000_000_000, - ), - 2_000_000_100, - ) - .await; - verify_inbound_media( - &runtime, - &context, - &url, - bytes, - "image/jpeg", - 20, - 20, - [6; 16], - ) - .await; - assert_eq!( - runtime - .phase1_media_cache_status(&context) - .await - .unwrap() - .artifacts, - 1 - ); - runtime.shutdown().await.expect("shutdown"); - - let reopened = RuntimeBuilder::new(store).build().await.expect("reopen"); - let profile = reopened - .phase1_me(&context, &public_key, 2_000_000_200) - .await - .unwrap() - .profile - .expect("profile"); - let picture = profile.picture.expect("picture"); - let artifact_id = match picture.retrieval() { - Phase1InboundMediaState::Verified(receipt) => receipt.artifact_id(), - state => panic!("unexpected state: {state:?}"), - }; - assert!( - reopened - .phase1_invalidate_media_artifact(&context, artifact_id) - .await - .unwrap() - ); - assert_eq!( - reopened - .phase1_media_cache_status(&context) - .await - .unwrap() - .artifacts, - 0 - ); - let profile = reopened - .phase1_me(&context, &public_key, 2_000_000_201) - .await - .unwrap() - .profile - .expect("profile after invalidation"); - assert!(matches!( - profile.picture.unwrap().retrieval(), - Phase1InboundMediaState::Unavailable - )); - reopened.shutdown().await.expect("shutdown reopened"); - } - - #[cfg(feature = "mobile-social")] - #[tokio::test] - async fn hardened_retrieval_atomically_writes_and_invalidates_the_local_artifact() { - use crate::runtime::{ - builder::RuntimeBuilder, - store::{MobileUserStoreConfig, ProtectedDataAvailability}, - }; - use radroots_sdk::transport::{ - BlossomCancellation, BlossomConfig, BlossomEndpointAuthority, BlossomHostKind, - BlossomProfile, - }; - - let bytes = png(2, 3); - let hash = BlossomSha256::digest(bytes.as_slice()).to_hex(); - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let origin = format!("http://{}", listener.local_addr().expect("address")); - let url = format!("{origin}/{hash}.png"); - let server = tokio::spawn(serve_one_blob(listener, bytes.clone())); - let root = tempfile::tempdir().expect("root"); - let public_key = keys().public_key().to_string(); - let store = MobileUserStoreConfig::from_encoded( - root.path(), - &public_key, - "6161616161616161616161616161616161616161616161616161616161616161", - 2_000_000_000_000, - ProtectedDataAvailability::Available, - ) - .expect("store"); - std::fs::create_dir_all(store.owner_directory()).expect("owner directory"); - let owner_directory = store.owner_directory().to_path_buf(); - let blossom = BlossomConfig::from_profile( - BlossomProfile::new( - BlossomHostKind::Simulator, - BlossomEndpointAuthority::LoopbackDevelopment, - origin, - std::iter::empty::<String>(), - ) - .expect("profile"), - ) - .with_network_policy( - std::time::Duration::from_millis(100), - std::time::Duration::from_millis(100), - 1, - std::time::Duration::from_millis(1), - ) - .expect("network policy"); - let runtime = RuntimeBuilder::new(store) - .blossom_config(blossom) - .build() - .await - .expect("runtime"); - let context = context(None, 1); - ingest( - &runtime, - &context, - signed( - 0, - Vec::new(), - &format!(r#"{{"name":"retrieved","picture":"{url}"}}"#), - 2_000_000_000, - ), - 2_000_000_100, - ) - .await; - let picture = runtime - .phase1_me(&context, &public_key, 2_000_000_200) - .await - .expect("me") - .profile - .expect("profile") - .picture - .expect("picture"); - let artifact = runtime - .phase1_retrieve_media( - &context, - *picture.structural().fingerprint(), - [9; 16], - Phase1MediaCachePolicy::new(1_024, 8).unwrap(), - BlossomCancellation::default(), - ) - .await - .expect("verified retrieval"); - server.await.expect("server"); - assert!( - artifact - .local_path() - .starts_with(owner_directory.join("inbound_media.v1")) - ); - assert_eq!(tokio::fs::read(artifact.local_path()).await.unwrap(), bytes); - assert_eq!(artifact.bytes(), bytes); - assert!( - !tokio::fs::symlink_metadata(artifact.local_path()) - .await - .unwrap() - .file_type() - .is_symlink() - ); - let verified = runtime - .phase1_me(&context, &public_key, 2_000_000_201) - .await - .expect("verified me") - .profile - .expect("verified profile") - .picture - .expect("verified picture"); - assert!(matches!( - verified.retrieval(), - Phase1InboundMediaState::Verified(_) - )); - let mut corrupt = bytes.clone(); - let last = corrupt.len() - 1; - corrupt[last] ^= 1; - tokio::fs::write(artifact.local_path(), corrupt) - .await - .expect("corrupt cached bytes"); - assert!(matches!( - runtime - .phase1_verified_media_artifact( - &context, - artifact.artifact_id(), - 2_000_000_203_000, - ) - .await, - Err(TodayError::InboundMedia( - Phase1InboundMediaError::CorruptArtifact - )) - )); - assert!(!artifact.local_path().exists()); - let current_configuration = runtime - .phase1_media_cache_status(&context) - .await - .unwrap() - .configuration - .expect("configuration"); - let replacement_bytes = if current_configuration.as_bytes() == &[1; 32] { - [2; 32] - } else { - [1; 32] - }; - let replacement_configuration = - Phase1MediaConfigurationFingerprint::new(replacement_bytes).unwrap(); - runtime - .phase1_invalidate_media_configuration(&context, replacement_configuration) - .await - .expect("invalidate configuration"); - let unavailable = runtime - .phase1_me(&context, &public_key, 2_000_000_202) - .await - .expect("unavailable me") - .profile - .expect("unavailable profile") - .picture - .expect("unavailable picture"); - assert!(matches!( - unavailable.retrieval(), - Phase1InboundMediaState::Unavailable - )); - runtime.shutdown().await.expect("shutdown"); - } - - #[tokio::test] - async fn fail_closed_requests_cursors_overlays_and_projection_guards_are_executable() { - let mut runtime = RadrootsRuntime::test_memory().expect("runtime"); - let context = context(None, 1); - let note = signed(1, Vec::new(), "guarded alpha", 2_000_000_000); - assert!(matches!( - runtime - .phase1_ingest_visible(raw_admission(note.clone(), 2_000_000_000_000), &context, 1) - .await, - Err(TodayError::EventNotVisible) - )); - assert!(matches!( - runtime - .phase1_refresh_today(&context, 0, TodayProjectionUpdate::Incremental) - .await, - Err(TodayError::InvalidRequest) - )); - assert!(matches!( - runtime - .phase1_refresh_today(&context, u64::MAX, TodayProjectionUpdate::Incremental) - .await, - Err(TodayError::InvalidRequest) - )); - let empty = runtime - .phase1_refresh_today(&context, 1, TodayProjectionUpdate::Incremental) - .await - .expect("empty projection"); - assert_eq!(empty.source_events, 0); - assert!(empty.changed); - assert!( - !runtime - .phase1_refresh_today(&context, 2, TodayProjectionUpdate::Incremental) - .await - .expect("unchanged projection") - .changed - ); - - for request in [ - TodayPageRequest::first(0, 1), - TodayPageRequest::first(TODAY_PAGE_LIMIT_MAX + 1, 1), - TodayPageRequest { - limit: 1, - as_of: None, - cursor: None, - }, - TodayPageRequest::first(1, 0), - ] { - assert!(matches!( - runtime.phase1_today_page(&context, request).await, - Err(TodayError::InvalidRequest) - )); - } - for (query, limit, as_of) in [ - ("valid", 0, 1), - ("valid", TODAY_SEARCH_LIMIT_MAX + 1, 1), - ("valid", 1, 0), - (" ", 1, 1), - (&"x".repeat(257), 1, 1), - ("bad\nquery", 1, 1), - ] { - assert!(matches!( - runtime.phase1_search(&context, query, limit, as_of).await, - Err(TodayError::InvalidRequest) - )); - } - assert!(matches!( - runtime.phase1_me(&context, "bad", 1).await, - Err(TodayError::InvalidRequest) - )); - assert!(matches!( - runtime - .phase1_me(&context, &keys().public_key().to_string(), 0) - .await, - Err(TodayError::InvalidRequest) - )); - assert!( - !runtime - .phase1_begin_media_retrieval( - &context, - [3; 32], - Phase1InboundMediaPending::new( - [4; 16], - Phase1MediaConfigurationFingerprint::new([5; 32]).unwrap(), - 1, - ) - .unwrap(), - ) - .await - .expect("unmatched media") - ); - - ingest(&runtime, &context, note.clone(), 2_000_000_100).await; - let page = runtime - .phase1_today_page(&context, TodayPageRequest::first(1, 2_000_000_200)) - .await - .expect("page"); - let card_id = page.items[0].card.card_id; - let rank = page.items[0].card.rank.expect("rank"); - let generation = projection_generation().expect("generation"); - let storage = runtime.client.storage().expect("storage"); - let state = load_state(storage, &context, generation) - .await - .expect("load state") - .expect("state"); - let scope = CursorScope::new( - context.id.clone(), - context.generation, - 2_000_000_200, - state.store_generation, - state.content_generation, - ) - .expect("scope"); - - let cursor_for = |scope: CursorScope| { - TodayCursor::encode(&scope, TodayCursorPosition { rank }) - .expect("cursor") - .as_str() - .to_owned() - }; - let mut wrong_context = scope.clone(); - wrong_context.context_id = "elsewhere".into(); - assert!(matches!( - runtime - .phase1_today_page( - &context, - TodayPageRequest::after(1, cursor_for(wrong_context)) - ) - .await, - Err(TodayError::Cursor(CursorError::ContextMismatch)) - )); - let mut wrong_context_generation = scope.clone(); - wrong_context_generation.context_generation += 1; - assert!(matches!( - runtime - .phase1_today_page( - &context, - TodayPageRequest::after(1, cursor_for(wrong_context_generation)), - ) - .await, - Err(TodayError::Cursor(CursorError::ContextMismatch)) - )); - assert!(matches!( - runtime - .phase1_today_page( - &context, - TodayPageRequest { - limit: 1, - as_of: Some(scope.as_of + 1), - cursor: Some(cursor_for(scope.clone())), - }, - ) - .await, - Err(TodayError::Cursor(CursorError::SnapshotMismatch)) - )); - let mut stale = scope.clone(); - stale.store_generation = [9; 32]; - assert!(matches!( - runtime - .phase1_today_page(&context, TodayPageRequest::after(1, cursor_for(stale))) - .await, - Err(TodayError::Cursor(CursorError::Stale)) - )); - let mut missing = scope.clone(); - missing.projection_generation = missing.projection_generation.wrapping_add(1).max(1); - assert!(matches!( - runtime - .phase1_today_page(&context, TodayPageRequest::after(1, cursor_for(missing))) - .await, - Err(TodayError::SnapshotMissing) - )); - - let snapshot = frozen_snapshot(&state, &context, scope.as_of).expect("snapshot"); - assert!(validate_snapshot(&snapshot, &scope).is_ok()); - for invalid in [ - { - let mut value = snapshot.clone(); - value.schema_version += 1; - value - }, - { - let mut value = snapshot.clone(); - value.context_id = "other".into(); - value - }, - { - let mut value = snapshot.clone(); - value.context_generation += 1; - value - }, - { - let mut value = snapshot.clone(); - value.as_of += 1; - value - }, - { - let mut value = snapshot.clone(); - value.store_generation = [8; 32]; - value - }, - { - let mut value = snapshot.clone(); - value.projection_generation = value.projection_generation.wrapping_add(1); - value - }, - ] { - assert!(matches!( - validate_snapshot(&invalid, &scope), - Err(TodayError::CorruptProjection) - )); - } - let mut absent_rank = rank; - absent_rank.card_id = CardId::parse(&"f".repeat(64)).expect("absent card id"); - assert!(matches!( - page_from_snapshot(snapshot.clone(), scope.clone(), Some(absent_rank), 1), - Err(TodayError::CursorPositionMissing) - )); - - for invalid in [ - { - let mut value = state.clone(); - value.schema_version += 1; - value - }, - { - let mut value = state.clone(); - value.content_generation = 0; - value - }, - { - let mut value = state.clone(); - value.source_events += 1; - value - }, - ] { - assert!(matches!( - decode_state(&encode(&invalid).expect("encode invalid state")), - Err(TodayError::CorruptProjection) - )); - } - let mut invalid_snapshot = snapshot.clone(); - invalid_snapshot.schema_version += 1; - assert!(matches!( - decode_snapshot(&encode(&invalid_snapshot).expect("encode invalid snapshot")), - Err(TodayError::CorruptProjection) - )); - assert!(matches!( - decode_state(b"not-json"), - Err(TodayError::CorruptProjection) - )); - - for overlay in [ - LocalAuthorOverlay { - operation_id: String::new(), - state: "queued".into(), - }, - LocalAuthorOverlay { - operation_id: "x".repeat(257), - state: "queued".into(), - }, - LocalAuthorOverlay { - operation_id: "operation".into(), - state: String::new(), - }, - LocalAuthorOverlay { - operation_id: "operation".into(), - state: "x".repeat(97), - }, - LocalAuthorOverlay { - operation_id: "operation".into(), - state: "bad\nstate".into(), - }, - ] { - assert!(matches!( - runtime - .phase1_set_local_author_overlay(&context, card_id, Some(overlay)) - .await, - Err(TodayError::InvalidRequest) - )); - } - let other_keys = Keys::generate(); - runtime.store_public_key = Some( - radroots_identity::PublicKey::from_hex(&other_keys.public_key().to_string()) - .expect("other public key"), - ); - assert!(matches!( - runtime - .phase1_me(&context, &keys().public_key().to_string(), 1) - .await, - Err(TodayError::InvalidRequest) - )); - assert!(matches!( - runtime - .phase1_set_local_author_overlay( - &context, - card_id, - Some(LocalAuthorOverlay { - operation_id: "operation".into(), - state: "queued".into(), - }), - ) - .await, - Err(TodayError::InvalidRequest) - )); - runtime.store_public_key = None; - runtime - .phase1_set_local_author_overlay( - &context, - card_id, - Some(LocalAuthorOverlay { - operation_id: "operation".into(), - state: "queued".into(), - }), - ) - .await - .expect("set overlay"); - runtime - .phase1_set_local_author_overlay(&context, card_id, None) - .await - .expect("remove overlay"); - assert_eq!( - runtime - .phase1_search(&context, "guarded", 1, 2_000_000_200) - .await - .expect("card-limited search") - .len(), - 1 - ); - - let mut event_state = state.clone(); - event_state.cards[0].card.card_type = TodayCardType::Event; - event_state.cards[0].card.event_start = Some(100); - event_state.cards[0].card.event_end = Some(200); - event_state.cards[0].card.effective_at = 100; - assert_eq!( - ranked_cards(&event_state, &context, 150).expect("live event")[0] - .card - .lifecycle, - CardLifecycleState::Active - ); - assert_eq!( - ranked_cards(&event_state, &context, 200).expect("past event")[0] - .card - .lifecycle, - CardLifecycleState::Past - ); - - let root = admitted(&note); - assert!(matches!( - profile_summary(&root), - Err(TodayError::CorruptProjection) - )); - assert!(matches!( - reply_entry(&root), - Err(TodayError::CorruptProjection) - )); - assert!(comment_entry(&root).is_none()); - assert_eq!(tag_value(&[Vec::new()], &["x"]), None); - assert_eq!(tag_value(&[vec!["x".into()]], &["x"]), None); - assert_eq!(blossom_digest("not-a-url"), None); - assert_eq!(blossom_digest("https://blob.example/short"), None); - assert_eq!( - blossom_digest(&format!("https://blob.example/{}", "A".repeat(64))), - None - ); - - let tags = locality_tags(vec![ - Vec::new(), - vec!["x".into(), "ignored".into()], - vec!["g".into()], - vec!["location".into(), " ".into()], - vec!["g".into(), "u10hr".into()], - ]); - assert_eq!(tags.len(), 1); - assert_eq!( - locality_evidence(Some("u10"), &tags), - LocalityEvidence::Match - ); - assert_eq!( - locality_evidence(Some("u10hr7"), &tags), - LocalityEvidence::Match - ); - assert_eq!( - locality_evidence(Some("other"), &tags), - LocalityEvidence::Nonmatch - ); - assert_eq!( - locality_evidence(Some("selected"), &[]), - LocalityEvidence::Missing - ); - - let mut fields = BTreeMap::new(); - fields.insert("value".into(), serde_json::json!(1)); - assert_eq!(typed_profile_extra(&fields, "missing"), None); - assert_eq!(typed_profile_extra(&fields, "value"), None); - for value in [String::new(), "x".repeat(2_049), "bad\nvalue".into()] { - fields.insert("value".into(), serde_json::Value::String(value)); - assert_eq!(typed_profile_extra(&fields, "value"), None); - } - } - - #[test] - fn malformed_cursor_and_request_bounds_fail_closed() { - assert!(matches!( - TodayCursor::scope("bad"), - Err(CursorError::Malformed) - )); - assert!(!valid_public_key("short")); - assert!(!valid_public_key("A".repeat(64).as_str())); - assert!(valid_public_key("a".repeat(64).as_str())); - assert_eq!(locality_evidence(None, &[]), LocalityEvidence::Missing); - assert_eq!(blossom_digest("https://blob.example"), None); - assert_eq!( - blossom_digest(&format!( - "https://blob.example/{}/image.jpg", - "a".repeat(64) - )), - Some("a".repeat(64)) - ); - assert_eq!(TODAY_PAGE_LIMIT_MAX, 100); - assert_eq!(TODAY_SEARCH_LIMIT_MAX, 100); - } -} diff --git a/crates/mobile_core/src/runtime/sdk.rs b/crates/mobile_core/src/runtime/sdk.rs @@ -1,532 +0,0 @@ -use radroots_sdk::capability::{Availability, Maturity}; - -pub use radroots_sdk::trade::{ - RadrootsRhiEvidenceReportV1, RadrootsTradeEvidenceCoverageV1, RadrootsTradeEvidenceManifestV1, - RadrootsTradeEvidenceOutcomeV1, -}; - -use super::RadrootsRuntime; -#[cfg(feature = "mobile-social")] -use super::product_surface::{BlossomPreferences, RelayPreferences}; -use crate::RadrootsAppError; - -#[derive(Clone, Debug)] -pub struct SdkCapabilityRecord { - pub id: String, - pub compiled: bool, - pub configured: bool, - pub availability: String, - pub maturity: String, -} - -#[derive(Clone, Debug)] -pub struct SdkStorageStatusRecord { - pub backend: String, - pub open_mode: String, - pub shutdown: String, - pub integrity: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum SdkRelayAccessRecord { - ReadOnly, - ReadWrite, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SdkRelayStatusRecord { - pub relay_url: String, - pub access: SdkRelayAccessRecord, - pub read_state: String, - pub write_state: String, - pub read_last_attempt_unix_ms: Option<u64>, - pub write_last_attempt_unix_ms: Option<u64>, - pub read_next_attempt_unix_ms: Option<u64>, - pub write_next_attempt_unix_ms: Option<u64>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SdkRelayStatusReportRecord { - pub profile: String, - pub state: String, - pub read_availability: String, - pub write_availability: String, - pub relays: Vec<SdkRelayStatusRecord>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SdkBlossomConfigurationRecord { - pub host_kind: String, - pub endpoint_authority: String, - pub primary_origin: String, - pub fallback_origins: Vec<String>, - pub config_fingerprint: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SdkBlossomEvidenceRecord { - pub schema_version: u16, - pub origin: String, - pub config_fingerprint: String, - pub state: String, - pub last_successful_state: String, - pub transport_security: String, - pub observed_at_unix_ms: Option<u64>, - pub http_status: Option<u16>, - pub error_code: Option<String>, - pub server_error_code: Option<String>, - pub error_phase: Option<String>, - pub retryable: bool, - pub possible_orphan: bool, - pub attempts: u8, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SdkShutdownRecord { - pub state: String, - pub already_closed: bool, -} - -impl RadrootsRuntime { - pub fn sdk_capabilities(&self) -> Vec<SdkCapabilityRecord> { - self.client - .capabilities() - .iter() - .map(|status| SdkCapabilityRecord { - id: status.id().as_str().to_owned(), - compiled: status.is_compiled(), - configured: status.is_configured(), - availability: availability_label(status.availability()).to_owned(), - maturity: maturity_label(status.maturity()).to_owned(), - }) - .collect() - } - - pub async fn sdk_storage_status(&self) -> Result<SdkStorageStatusRecord, RadrootsAppError> { - let status = self - .client - .storage_status() - .await - .map_err(RadrootsAppError::from_sdk)?; - Ok(SdkStorageStatusRecord { - backend: status.backend().as_str().to_owned(), - open_mode: status.open_mode().as_str().to_owned(), - shutdown: status.shutdown().as_str().to_owned(), - integrity: status.integrity().health().as_str().to_owned(), - }) - } - - /// Installs a validated public relay profile without probing it. - #[cfg(feature = "mobile-social")] - pub fn configure_public_relays( - &self, - writable_relays: Vec<String>, - ) -> Result<(), RadrootsAppError> { - self.configure_relay_endpoints( - radroots_sdk::transport::RelayProfileKind::Public, - radroots_sdk::transport::RelayUrlPolicy::Public, - writable_relays, - ) - } - - /// Installs an exact-loopback simulator profile without probing it. - #[cfg(feature = "mobile-social")] - pub fn configure_simulator_relays( - &self, - loopback_relays: Vec<String>, - ) -> Result<(), RadrootsAppError> { - self.configure_relay_endpoints( - radroots_sdk::transport::RelayProfileKind::Simulator, - radroots_sdk::transport::RelayUrlPolicy::Local, - loopback_relays, - ) - } - - /// Installs an explicit physical-device TLS relay profile without probing it. - #[cfg(feature = "mobile-social")] - pub fn configure_device_relays( - &self, - writable_relays: Vec<String>, - ) -> Result<(), RadrootsAppError> { - self.configure_relay_endpoints( - radroots_sdk::transport::RelayProfileKind::Device, - radroots_sdk::transport::RelayUrlPolicy::PrivateNetwork, - writable_relays, - ) - } - - #[cfg(feature = "mobile-social")] - fn configure_relay_endpoints( - &self, - kind: radroots_sdk::transport::RelayProfileKind, - policy: radroots_sdk::transport::RelayUrlPolicy, - relays: Vec<String>, - ) -> Result<(), RadrootsAppError> { - let endpoints = relays - .into_iter() - .map(|relay| { - radroots_sdk::transport::RelayEndpoint::new( - relay, - policy, - radroots_sdk::transport::RelayAccess::ReadWrite, - ) - }) - .collect::<Result<Vec<_>, _>>() - .map_err(|error| RadrootsAppError::runtime(error.to_string()))?; - let profile = radroots_sdk::transport::RelayProfile::explicit(kind, endpoints) - .map_err(|error| RadrootsAppError::runtime(error.to_string()))?; - self.configure_relay_profile(profile) - } - - #[cfg(feature = "mobile-social")] - fn configure_relay_profile( - &self, - profile: radroots_sdk::transport::RelayProfile, - ) -> Result<(), RadrootsAppError> { - self.client - .configure_nostr(profile) - .map_err(RadrootsAppError::from_sdk) - } - - /// Installs the exact validated relay preferences persisted by the mobile product. - #[cfg(feature = "mobile-social")] - pub fn configure_relay_preferences( - &self, - preferences: &RelayPreferences, - ) -> Result<(), RadrootsAppError> { - self.configure_relay_profile( - preferences - .sdk_profile() - .map_err(|error| RadrootsAppError::runtime(error.code()))?, - ) - } - - /// Installs one canonical inert Blossom configuration without probing it. - #[cfg(feature = "mobile-social")] - pub fn configure_blossom( - &self, - host_kind: radroots_sdk::transport::BlossomHostKind, - endpoint_authority: radroots_sdk::transport::BlossomEndpointAuthority, - primary_origin: String, - fallback_origins: Vec<String>, - ) -> Result<(), RadrootsAppError> { - self.configure_blossom_profile( - radroots_sdk::transport::BlossomProfile::new( - host_kind, - endpoint_authority, - primary_origin, - fallback_origins, - ) - .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?, - ) - } - - #[cfg(feature = "mobile-social")] - fn configure_blossom_profile( - &self, - profile: radroots_sdk::transport::BlossomProfile, - ) -> Result<(), RadrootsAppError> { - self.client - .configure_blossom(radroots_sdk::transport::BlossomConfig::from_profile( - profile, - )) - .map_err(RadrootsAppError::from_sdk) - } - - /// Installs the exact validated Blossom preferences persisted by the mobile product. - #[cfg(feature = "mobile-social")] - pub fn configure_blossom_preferences( - &self, - preferences: &BlossomPreferences, - ) -> Result<(), RadrootsAppError> { - self.configure_blossom_profile( - preferences - .sdk_profile() - .map_err(|error| RadrootsAppError::runtime(error.code()))?, - ) - } - - /// Returns the configured adapter slot for Rust-owned media binding. - #[cfg(feature = "mobile-social")] - pub fn sdk_blossom_slot( - &self, - ) -> Result<Option<radroots_sdk::transport::BlossomSlot>, RadrootsAppError> { - self.client - .blossom() - .map(|slot| slot.cloned()) - .map_err(RadrootsAppError::from_sdk) - } - - /// Returns the complete inert Blossom configuration, when configured. - #[cfg(feature = "mobile-social")] - pub fn sdk_blossom_configuration( - &self, - ) -> Result<Option<SdkBlossomConfigurationRecord>, RadrootsAppError> { - let configuration = self - .client - .blossom() - .map_err(RadrootsAppError::from_sdk)? - .and_then(radroots_sdk::transport::BlossomSlot::configuration); - Ok( - configuration.map(|(profile, fingerprint)| SdkBlossomConfigurationRecord { - host_kind: blossom_host_kind_label(profile.host_kind()).to_owned(), - endpoint_authority: blossom_authority_label(profile.authority()).to_owned(), - primary_origin: profile.primary().origin().to_owned(), - fallback_origins: profile - .fallbacks() - .iter() - .map(|endpoint| endpoint.origin().to_owned()) - .collect(), - config_fingerprint: fingerprint.to_hex(), - }), - ) - } - - /// Returns the latest passive Blossom evidence without network I/O. - #[cfg(feature = "mobile-social")] - pub fn sdk_blossom_evidence( - &self, - ) -> Result<Option<SdkBlossomEvidenceRecord>, RadrootsAppError> { - let evidence = self - .client - .blossom() - .map_err(RadrootsAppError::from_sdk)? - .and_then(radroots_sdk::transport::BlossomSlot::evidence); - Ok(evidence.map(sdk_blossom_evidence_record)) - } - - /// Explicitly probes the primary Blossom origin without mutation or authorization. - #[cfg(feature = "mobile-social")] - pub async fn probe_blossom(&self) -> Result<SdkBlossomEvidenceRecord, RadrootsAppError> { - let blossom = self - .client - .blossom() - .map_err(RadrootsAppError::from_sdk)? - .ok_or_else(|| RadrootsAppError::runtime("blossom_endpoint_not_configured"))?; - blossom - .probe(radroots_sdk::transport::BlossomCancellation::default()) - .await - .map(sdk_blossom_evidence_record) - .map_err(|error| RadrootsAppError::runtime(error.code())) - } - - /// Returns passive relay evidence without DNS, socket, or probe work. - #[cfg(feature = "mobile-social")] - pub fn sdk_relay_status(&self) -> Result<Option<SdkRelayStatusReportRecord>, RadrootsAppError> { - let report = self - .client - .nostr_status() - .map_err(RadrootsAppError::from_sdk)?; - Ok(report.map(|report| SdkRelayStatusReportRecord { - profile: relay_profile_label(report.profile_kind()).to_owned(), - state: relay_aggregate_label(report.state()).to_owned(), - read_availability: transport_availability_label(report.read_availability()).to_owned(), - write_availability: transport_availability_label(report.write_availability()) - .to_owned(), - relays: report - .relays() - .iter() - .map(|relay| SdkRelayStatusRecord { - relay_url: relay.endpoint().url().to_string(), - access: if relay.endpoint().access().can_write() { - SdkRelayAccessRecord::ReadWrite - } else { - SdkRelayAccessRecord::ReadOnly - }, - read_state: relay_evidence_label(relay.read().state()).to_owned(), - write_state: relay_evidence_label(relay.write().state()).to_owned(), - read_last_attempt_unix_ms: relay.read().last_attempt_unix_ms(), - write_last_attempt_unix_ms: relay.write().last_attempt_unix_ms(), - read_next_attempt_unix_ms: relay.read().next_attempt_unix_ms(), - write_next_attempt_unix_ms: relay.write().next_attempt_unix_ms(), - }) - .collect(), - })) - } -} - -#[cfg(feature = "mobile-social")] -fn sdk_blossom_evidence_record( - value: radroots_sdk::transport::BlossomEndpointEvidence, -) -> SdkBlossomEvidenceRecord { - SdkBlossomEvidenceRecord { - schema_version: value.schema_version(), - origin: value.origin().to_owned(), - config_fingerprint: value.config_fingerprint().to_hex(), - state: blossom_evidence_label(value.state()).to_owned(), - last_successful_state: blossom_evidence_label(value.last_successful_state()).to_owned(), - transport_security: blossom_transport_security_label(value.transport_security()).to_owned(), - observed_at_unix_ms: value.observed_at_unix_ms(), - http_status: value.http_status(), - error_code: value.error_code().map(str::to_owned), - server_error_code: value.server_error_code().map(str::to_owned), - error_phase: value - .error_phase() - .map(blossom_phase_label) - .map(str::to_owned), - retryable: value.retryable(), - possible_orphan: value.possible_orphan(), - attempts: value.attempts(), - } -} - -#[cfg(feature = "mobile-social")] -const fn blossom_evidence_label( - value: radroots_sdk::transport::BlossomEvidenceState, -) -> &'static str { - match value { - radroots_sdk::transport::BlossomEvidenceState::ConfiguredUnobserved => { - "configured_unobserved" - } - radroots_sdk::transport::BlossomEvidenceState::DnsPolicyValidated => "dns_policy_validated", - radroots_sdk::transport::BlossomEvidenceState::TlsHttpObserved => "tls_http_observed", - radroots_sdk::transport::BlossomEvidenceState::UploadVerified => "upload_verified", - radroots_sdk::transport::BlossomEvidenceState::RetrievalVerified => "retrieval_verified", - radroots_sdk::transport::BlossomEvidenceState::RetryableFailure => "retryable_failure", - radroots_sdk::transport::BlossomEvidenceState::TerminalFailure => "terminal_failure", - _ => "unknown", - } -} - -#[cfg(feature = "mobile-social")] -const fn blossom_transport_security_label( - value: radroots_sdk::transport::BlossomTransportSecurity, -) -> &'static str { - match value { - radroots_sdk::transport::BlossomTransportSecurity::PublicWebPki => "public_webpki", - radroots_sdk::transport::BlossomTransportSecurity::DevelopmentTls => "development_tls", - radroots_sdk::transport::BlossomTransportSecurity::DevelopmentCleartext => { - "development_cleartext" - } - _ => "unknown", - } -} - -#[cfg(feature = "mobile-social")] -const fn blossom_phase_label(value: radroots_sdk::transport::BlossomPhase) -> &'static str { - match value { - radroots_sdk::transport::BlossomPhase::Configuration => "configuration", - radroots_sdk::transport::BlossomPhase::Probe => "probe", - radroots_sdk::transport::BlossomPhase::Authorization => "authorization", - radroots_sdk::transport::BlossomPhase::Upload => "upload", - radroots_sdk::transport::BlossomPhase::Descriptor => "descriptor", - radroots_sdk::transport::BlossomPhase::Retrieval => "retrieval", - radroots_sdk::transport::BlossomPhase::Verification => "verification", - _ => "unknown", - } -} - -#[cfg(feature = "mobile-social")] -const fn blossom_host_kind_label(value: radroots_sdk::transport::BlossomHostKind) -> &'static str { - match value { - radroots_sdk::transport::BlossomHostKind::Native => "native", - radroots_sdk::transport::BlossomHostKind::Simulator => "simulator", - radroots_sdk::transport::BlossomHostKind::PhysicalDevice => "physical_device", - _ => "unknown", - } -} - -#[cfg(feature = "mobile-social")] -const fn blossom_authority_label( - value: radroots_sdk::transport::BlossomEndpointAuthority, -) -> &'static str { - match value { - radroots_sdk::transport::BlossomEndpointAuthority::PublicWebPki => "public_webpki", - radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment => { - "loopback_development" - } - radroots_sdk::transport::BlossomEndpointAuthority::PrivateNetworkDevelopment => { - "private_network_development" - } - _ => "unknown", - } -} - -#[cfg(feature = "mobile-social")] -const fn relay_evidence_label(value: radroots_sdk::transport::RelayEvidenceState) -> &'static str { - match value { - radroots_sdk::transport::RelayEvidenceState::Unsupported => "unsupported", - radroots_sdk::transport::RelayEvidenceState::Unobserved => "unobserved", - radroots_sdk::transport::RelayEvidenceState::Connecting => "connecting", - radroots_sdk::transport::RelayEvidenceState::Available => "available", - radroots_sdk::transport::RelayEvidenceState::Unavailable => "unavailable", - _ => "unknown", - } -} - -#[cfg(feature = "mobile-social")] -const fn relay_profile_label(value: radroots_sdk::transport::RelayProfileKind) -> &'static str { - match value { - radroots_sdk::transport::RelayProfileKind::Public => "public", - radroots_sdk::transport::RelayProfileKind::Simulator => "simulator_local", - radroots_sdk::transport::RelayProfileKind::Device => "device_development", - _ => "unknown", - } -} - -#[cfg(feature = "mobile-social")] -const fn relay_aggregate_label( - value: radroots_sdk::transport::RelayAggregateState, -) -> &'static str { - match value { - radroots_sdk::transport::RelayAggregateState::Configured => "configured", - radroots_sdk::transport::RelayAggregateState::Connecting => "connecting", - radroots_sdk::transport::RelayAggregateState::ReadOnly => "read_only", - radroots_sdk::transport::RelayAggregateState::Writable => "writable", - radroots_sdk::transport::RelayAggregateState::Degraded => "degraded", - radroots_sdk::transport::RelayAggregateState::Offline => "offline", - radroots_sdk::transport::RelayAggregateState::Failed => "failed", - _ => "unknown", - } -} - -#[cfg(feature = "mobile-social")] -const fn transport_availability_label( - value: radroots_transport::capability::Availability, -) -> &'static str { - match value { - radroots_transport::capability::Availability::Available => "available", - radroots_transport::capability::Availability::Degraded => "degraded", - radroots_transport::capability::Availability::Unavailable => "unavailable", - } -} - -const fn availability_label(value: Availability) -> &'static str { - match value { - Availability::Available => "available", - Availability::Degraded => "degraded", - Availability::Unavailable => "unavailable", - Availability::Unsupported => "unsupported", - } -} - -const fn maturity_label(value: Maturity) -> &'static str { - match value { - Maturity::Stable => "stable", - Maturity::Preview => "preview", - Maturity::Experimental => "experimental", - } -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::RadrootsRuntime; - - #[tokio::test] - async fn explicit_test_runtime_is_memory_backed() { - let runtime = RadrootsRuntime::test_memory().expect("runtime"); - let capabilities = runtime.sdk_capabilities(); - assert!(capabilities.iter().any(|capability| { - capability.id == "storage.canonical" - && capability.configured - && capability.availability == "available" - && capability.maturity == "stable" - })); - let status = runtime.sdk_storage_status().await.expect("storage status"); - assert_eq!(status.backend, "memory"); - assert_eq!(status.integrity, "healthy"); - runtime.shutdown().await.expect("shutdown"); - assert!(runtime.sdk_storage_status().await.is_err()); - } -} diff --git a/crates/mobile_core/src/runtime/store.rs b/crates/mobile_core/src/runtime/store.rs @@ -1,257 +0,0 @@ -//! Validated host contract for one authenticated mobile user's durable store. - -use std::{ - path::{Component, Path, PathBuf}, - time::Duration, -}; - -use radroots_identity::PublicKey; -use radroots_storage::event::SourceGeneration; - -use crate::RadrootsAppError; - -const PRODUCT_DIRECTORY: &str = "radroots"; -const USER_DIRECTORY: &str = "users"; -const GENERATION_HEX_LENGTH: usize = 64; - -/// Host-observed Apple protected-data state at runtime construction time. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ProtectedDataAvailability { - Available, - Unavailable, -} - -/// Validated composition for one authenticated user's SQLite owner directory. -/// -/// The Apple host owns directory creation and data-protection attributes. Rust -/// derives the exact identity-scoped suffix and refuses alternate, relative, -/// or symlinked directory layouts before SQLite is opened. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MobileUserStoreConfig { - application_support_directory: PathBuf, - owner_directory: PathBuf, - public_key: PublicKey, - source_generation: SourceGeneration, - source_generation_created_at_unix_ms: u64, - protected_data: ProtectedDataAvailability, -} - -impl MobileUserStoreConfig { - /// Validates encoded host values without touching SQLite. - pub fn from_encoded( - application_support_directory: impl Into<PathBuf>, - public_key_hex: &str, - source_generation_hex: &str, - source_generation_created_at_unix_ms: u64, - protected_data: ProtectedDataAvailability, - ) -> Result<Self, RadrootsAppError> { - let public_key = PublicKey::from_hex(public_key_hex) - .map_err(|_| RadrootsAppError::store_invalid_configuration())?; - let source_generation = parse_source_generation(source_generation_hex)?; - Self::new( - application_support_directory, - public_key, - source_generation, - source_generation_created_at_unix_ms, - protected_data, - ) - } - - /// Creates a validated store configuration from canonical typed values. - pub fn new( - application_support_directory: impl Into<PathBuf>, - public_key: PublicKey, - source_generation: SourceGeneration, - source_generation_created_at_unix_ms: u64, - protected_data: ProtectedDataAvailability, - ) -> Result<Self, RadrootsAppError> { - let application_support_directory = application_support_directory.into(); - validate_absolute_normal_directory(&application_support_directory)?; - if source_generation_created_at_unix_ms == 0 - || i64::try_from(source_generation_created_at_unix_ms).is_err() - { - return Err(RadrootsAppError::store_invalid_configuration()); - } - let owner_directory = application_support_directory - .join(PRODUCT_DIRECTORY) - .join(USER_DIRECTORY) - .join(public_key.to_hex()); - Ok(Self { - application_support_directory, - owner_directory, - public_key, - source_generation, - source_generation_created_at_unix_ms, - protected_data, - }) - } - - /// Returns the host-owned Application Support root. - pub fn application_support_directory(&self) -> &Path { - self.application_support_directory.as_path() - } - - /// Returns the exact existing directory that must own both SQLite files. - pub fn owner_directory(&self) -> &Path { - self.owner_directory.as_path() - } - - /// Returns the authenticated identity that scopes this store. - pub const fn public_key(&self) -> PublicKey { - self.public_key - } - - pub(crate) const fn protected_data(&self) -> ProtectedDataAvailability { - self.protected_data - } - - pub(crate) fn validate_host_filesystem(&self) -> Result<(), RadrootsAppError> { - let directories = [ - self.application_support_directory.clone(), - self.application_support_directory - .join(PRODUCT_DIRECTORY) - .to_path_buf(), - self.application_support_directory - .join(PRODUCT_DIRECTORY) - .join(USER_DIRECTORY) - .to_path_buf(), - self.owner_directory.clone(), - ]; - for directory in directories { - let metadata = std::fs::symlink_metadata(&directory) - .map_err(|_| RadrootsAppError::store_path_unavailable())?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(RadrootsAppError::store_invalid_configuration()); - } - } - Ok(()) - } - - pub(crate) fn sqlite_options( - &self, - ) -> Result<radroots_sdk::storage::SqliteOptions, RadrootsAppError> { - let paths = radroots_sdk::storage::SqlitePaths::from_directory(&self.owner_directory) - .map_err(|_| RadrootsAppError::store_invalid_configuration())?; - radroots_sdk::storage::SqliteOptions::new( - paths, - radroots_sdk::storage::SqliteOpenMode::Create, - ) - .with_busy_timeout(Duration::from_secs(5)) - .and_then(|options| { - options.with_source_generation( - self.source_generation, - self.source_generation_created_at_unix_ms, - ) - }) - .map_err(|_| RadrootsAppError::store_invalid_configuration()) - } -} - -fn parse_source_generation(value: &str) -> Result<SourceGeneration, RadrootsAppError> { - if value.len() != GENERATION_HEX_LENGTH { - return Err(RadrootsAppError::store_invalid_configuration()); - } - let bytes = hex::decode(value).map_err(|_| RadrootsAppError::store_invalid_configuration())?; - let bytes: [u8; 32] = bytes - .try_into() - .map_err(|_| RadrootsAppError::store_invalid_configuration())?; - SourceGeneration::new(bytes).map_err(|_| RadrootsAppError::store_invalid_configuration()) -} - -fn validate_absolute_normal_directory(path: &Path) -> Result<(), RadrootsAppError> { - if !path.is_absolute() - || path - .components() - .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) - { - return Err(RadrootsAppError::store_invalid_configuration()); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - const GENERATION: &str = "0101010101010101010101010101010101010101010101010101010101010101"; - - #[test] - fn encoded_scope_derives_the_exact_user_directory() { - let root = tempfile::tempdir().expect("tempdir"); - let config = MobileUserStoreConfig::from_encoded( - root.path(), - PUBLIC_KEY, - GENERATION, - 1_800_000_000_000, - ProtectedDataAvailability::Available, - ) - .expect("config"); - assert_eq!( - config.owner_directory(), - root.path().join("radroots").join("users").join(PUBLIC_KEY) - ); - assert_eq!(config.public_key().to_hex(), PUBLIC_KEY); - } - - #[test] - fn encoded_scope_rejects_invalid_identity_generation_time_and_path() { - let root = tempfile::tempdir().expect("tempdir"); - for result in [ - MobileUserStoreConfig::from_encoded( - "relative", - PUBLIC_KEY, - GENERATION, - 1, - ProtectedDataAvailability::Available, - ), - MobileUserStoreConfig::from_encoded( - root.path(), - "bad", - GENERATION, - 1, - ProtectedDataAvailability::Available, - ), - MobileUserStoreConfig::from_encoded( - root.path(), - PUBLIC_KEY, - "00", - 1, - ProtectedDataAvailability::Available, - ), - MobileUserStoreConfig::from_encoded( - root.path(), - PUBLIC_KEY, - GENERATION, - 0, - ProtectedDataAvailability::Available, - ), - ] { - assert!(matches!(result, Err(RadrootsAppError::Store { .. }))); - } - } - - #[cfg(unix)] - #[test] - fn host_filesystem_rejects_a_symlinked_user_scope() { - use std::os::unix::fs::symlink; - - let root = tempfile::tempdir().expect("tempdir"); - let config = MobileUserStoreConfig::from_encoded( - root.path(), - PUBLIC_KEY, - GENERATION, - 1, - ProtectedDataAvailability::Available, - ) - .expect("config"); - std::fs::create_dir_all(root.path().join(PRODUCT_DIRECTORY).join(USER_DIRECTORY)) - .expect("parents"); - let target = tempfile::tempdir().expect("target"); - symlink(target.path(), config.owner_directory()).expect("symlink"); - assert!(matches!( - config.validate_host_filesystem(), - Err(RadrootsAppError::Store { .. }) - )); - } -} diff --git a/crates/mobile_core/tests/durable_runtime.rs b/crates/mobile_core/tests/durable_runtime.rs @@ -1,111 +0,0 @@ -use radroots_mobile_core::{ - RadrootsAppError, - runtime::{ - builder::RuntimeBuilder, - store::{MobileUserStoreConfig, ProtectedDataAvailability}, - }, -}; - -mod support; - -fn other_generation_store(root: &std::path::Path) -> MobileUserStoreConfig { - MobileUserStoreConfig::from_encoded( - root, - support::PUBLIC_KEY, - "0505050505050505050505050505050505050505050505050505050505050505", - 1_800_000_000_001, - ProtectedDataAvailability::Available, - ) - .expect("alternate store config") -} - -#[tokio::test] -async fn cold_create_shutdown_and_reopen_preserve_the_sqlite_store() { - let root = tempfile::tempdir().expect("tempdir"); - let store = support::store(root.path()); - let runtime = RuntimeBuilder::new(store.clone()) - .build() - .await - .expect("cold create"); - let status = runtime.sdk_storage_status().await.expect("status"); - assert_eq!( - runtime.authenticated_store_public_key_hex().as_deref(), - Some(support::PUBLIC_KEY) - ); - assert_eq!(status.backend, "sqlite"); - assert_eq!(status.open_mode, "create"); - assert_eq!(status.integrity, "unknown"); - assert!(store.owner_directory().join("runtime.sqlite").is_file()); - assert!(store.owner_directory().join("private.sqlite").is_file()); - runtime.shutdown().await.expect("shutdown"); - - let reopened = RuntimeBuilder::new(store).build().await.expect("reopen"); - assert_eq!( - reopened.sdk_storage_status().await.expect("status").backend, - "sqlite" - ); - reopened.shutdown().await.expect("shutdown"); -} - -#[tokio::test] -async fn one_authenticated_user_store_has_one_writable_runtime() { - let root = tempfile::tempdir().expect("tempdir"); - let store = support::store(root.path()); - let first = RuntimeBuilder::new(store.clone()) - .build() - .await - .expect("first runtime"); - let second = RuntimeBuilder::new(store.clone()).build().await; - let Err(RadrootsAppError::Sdk { report }) = second else { - panic!("second writable runtime must fail with a typed SDK error"); - }; - assert_eq!(report.code, "database_busy"); - assert!(report.retryable); - - first.shutdown().await.expect("first shutdown"); - let recovered = RuntimeBuilder::new(store) - .build() - .await - .expect("writer lock recovery"); - recovered.shutdown().await.expect("recovered shutdown"); -} - -#[tokio::test] -async fn source_generation_mismatch_is_integrity_classified() { - let root = tempfile::tempdir().expect("tempdir"); - let store = support::store(root.path()); - let runtime = RuntimeBuilder::new(store).build().await.expect("runtime"); - runtime.shutdown().await.expect("shutdown"); - - let result = RuntimeBuilder::new(other_generation_store(root.path())) - .build() - .await; - let Err(RadrootsAppError::Sdk { report }) = result else { - panic!("generation mismatch must fail with a typed SDK error"); - }; - assert_eq!(report.code, "storage_integrity_failed"); - assert!(!report.retryable); -} - -#[tokio::test] -async fn unrecognized_sqlite_bytes_are_corruption_classified() { - let root = tempfile::tempdir().expect("tempdir"); - let store = support::store(root.path()); - let runtime = RuntimeBuilder::new(store.clone()) - .build() - .await - .expect("runtime"); - runtime.shutdown().await.expect("shutdown"); - std::fs::write( - store.owner_directory().join("runtime.sqlite"), - b"not a sqlite database", - ) - .expect("replace runtime database with corrupt fixture"); - - let result = RuntimeBuilder::new(store).build().await; - let Err(RadrootsAppError::Sdk { report }) = result else { - panic!("corrupt store must fail with a typed SDK error"); - }; - assert_eq!(report.code, "storage_integrity_failed"); - assert!(!report.retryable); -} diff --git a/crates/mobile_core/tests/package_boundary.rs b/crates/mobile_core/tests/package_boundary.rs @@ -1,101 +0,0 @@ -const MANIFEST: &str = include_str!("../Cargo.toml"); -const LIB: &str = include_str!("../src/lib.rs"); -const ERROR: &str = include_str!("../src/error.rs"); -const RUNTIME: &str = include_str!("../src/runtime/mod.rs"); -const APP_INFO: &str = include_str!("../src/runtime/app_info.rs"); -const INFO: &str = include_str!("../src/runtime/info.rs"); -const PRODUCT_SURFACE: &str = include_str!("../src/runtime/product_surface.rs"); -const PRODUCT_AUTHORING: &str = include_str!("../src/runtime/product_surface/authoring.rs"); -const PRODUCT_CONTEXT: &str = include_str!("../src/runtime/product_surface/context.rs"); -const PRODUCT_CURSOR: &str = include_str!("../src/runtime/product_surface/cursor.rs"); -const PRODUCT_IDENTITY: &str = include_str!("../src/runtime/product_surface/identity.rs"); -const PRODUCT_MODEL: &str = include_str!("../src/runtime/product_surface/model.rs"); -const PRODUCT_OUTBOX: &str = include_str!("../src/runtime/product_surface/outbox.rs"); -const PRODUCT_PROJECTION: &str = include_str!("../src/runtime/product_surface/projection.rs"); -const PRODUCT_RANKING: &str = include_str!("../src/runtime/product_surface/ranking.rs"); -const SDK: &str = include_str!("../src/runtime/sdk.rs"); -const BUILDER: &str = include_str!("../src/runtime/builder.rs"); -const STORE: &str = include_str!("../src/runtime/store.rs"); - -#[test] -fn core_owns_no_uniffi_or_process_global_logging_policy() { - for (name, source) in [ - ("Cargo.toml", MANIFEST), - ("src/lib.rs", LIB), - ("src/error.rs", ERROR), - ("src/runtime/mod.rs", RUNTIME), - ("src/runtime/app_info.rs", APP_INFO), - ("src/runtime/info.rs", INFO), - ("src/runtime/product_surface.rs", PRODUCT_SURFACE), - ( - "src/runtime/product_surface/authoring.rs", - PRODUCT_AUTHORING, - ), - ("src/runtime/product_surface/context.rs", PRODUCT_CONTEXT), - ("src/runtime/product_surface/cursor.rs", PRODUCT_CURSOR), - ("src/runtime/product_surface/identity.rs", PRODUCT_IDENTITY), - ("src/runtime/product_surface/model.rs", PRODUCT_MODEL), - ("src/runtime/product_surface/outbox.rs", PRODUCT_OUTBOX), - ( - "src/runtime/product_surface/projection.rs", - PRODUCT_PROJECTION, - ), - ("src/runtime/product_surface/ranking.rs", PRODUCT_RANKING), - ("src/runtime/sdk.rs", SDK), - ] { - assert!( - !source.to_ascii_lowercase().contains("uniffi"), - "{name} contains UniFFI boundary policy" - ); - assert!( - !source.contains("tracing_subscriber") && !source.contains("set_global_default"), - "{name} contains process-global logging policy" - ); - } -} - -#[test] -fn mobile_runtime_has_no_secret_taking_or_local_signer_slot_surface() { - for source in [ - MANIFEST, - RUNTIME, - BUILDER, - PRODUCT_AUTHORING, - PRODUCT_OUTBOX, - ] { - assert!(!source.contains("signing::Slot")); - assert!(!source.contains("secret_key: String")); - assert!(!source.contains("Provider::slot")); - } - assert!(!MANIFEST.contains("radroots_sdk/local-signing")); -} - -#[test] -fn production_runtime_requires_validated_sqlite_and_memory_is_test_only() { - assert!(MANIFEST.contains("radroots_sdk = { workspace = true, features = [\"sqlite\"] }")); - assert_eq!(BUILDER.matches("ClientBuilder::sqlite").count(), 1); - assert!(!BUILDER.contains("memory_default") && !STORE.contains("memory_default")); - assert!(RUNTIME.contains("#[cfg(test)]\n pub(crate) fn test_memory()")); - assert!(!RUNTIME.contains("pub fn new()")); -} - -#[test] -fn mobile_core_reuses_the_final_sdk_evidence_vocabulary() { - for required in [ - "RadrootsRhiEvidenceReportV1", - "RadrootsTradeEvidenceCoverageV1", - "RadrootsTradeEvidenceManifestV1", - "RadrootsTradeEvidenceOutcomeV1", - ] { - assert!( - SDK.contains(required), - "missing SDK evidence type `{required}`" - ); - } - for forbidden in ["SecretKey", "sign_event", "publish_event", "tokio::spawn"] { - assert!( - !SDK.contains(forbidden), - "mobile evidence projection gained forbidden authority `{forbidden}`" - ); - } -} diff --git a/crates/mobile_core/tests/sdk_runtime.rs b/crates/mobile_core/tests/sdk_runtime.rs @@ -1,55 +0,0 @@ -use std::sync::Arc; - -use radroots_mobile_core::{RadrootsAppError, RadrootsRuntime}; - -mod support; - -#[tokio::test] -async fn runtime_is_send_sync_and_shares_one_sdk_lifecycle() { - fn require_send_sync<T: Send + Sync>() {} - require_send_sync::<RadrootsRuntime>(); - - let (_root, runtime) = support::runtime().await; - let runtime = Arc::new(runtime); - let worker = { - let runtime = Arc::clone(&runtime); - std::thread::spawn(move || runtime.sdk_capabilities()) - }; - let capabilities = worker.join().expect("worker"); - assert!( - capabilities - .iter() - .any(|capability| capability.id == "storage.canonical") - ); - let first = runtime.shutdown().await.expect("first shutdown"); - let second = runtime.shutdown().await.expect("second shutdown"); - assert!(!first.already_closed); - assert!(second.already_closed); - assert!(runtime.info().sdk_closed); -} - -#[tokio::test] -async fn operations_fail_safely_after_explicit_close() { - let (_root, runtime) = support::runtime().await; - assert_eq!( - runtime.sdk_storage_status().await.expect("status").backend, - "sqlite" - ); - runtime.shutdown().await.expect("shutdown"); - assert!(matches!( - runtime.sdk_storage_status().await, - Err(RadrootsAppError::Sdk { .. }) - )); -} - -#[tokio::test] -async fn dropping_unpolled_shutdown_has_no_effect_and_retry_closes() { - let (_root, runtime) = support::runtime().await; - drop(runtime.shutdown()); - assert!(!runtime.info().sdk_closed); - assert!(!runtime.info().app.shutting_down); - - runtime.shutdown().await.expect("retry shutdown"); - assert!(runtime.info().sdk_closed); - assert!(runtime.info().app.shutting_down); -} diff --git a/crates/mobile_core/tests/support/mod.rs b/crates/mobile_core/tests/support/mod.rs @@ -1,33 +0,0 @@ -use radroots_mobile_core::{ - RadrootsRuntime, - runtime::{ - builder::RuntimeBuilder, - store::{MobileUserStoreConfig, ProtectedDataAvailability}, - }, -}; - -pub const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; -pub const GENERATION: &str = "0303030303030303030303030303030303030303030303030303030303030303"; - -pub fn store(root: &std::path::Path) -> MobileUserStoreConfig { - let store = MobileUserStoreConfig::from_encoded( - root, - PUBLIC_KEY, - GENERATION, - 1_800_000_000_000, - ProtectedDataAvailability::Available, - ) - .expect("store config"); - std::fs::create_dir_all(store.owner_directory()).expect("owner directory"); - store -} - -#[allow(dead_code)] -pub async fn runtime() -> (tempfile::TempDir, RadrootsRuntime) { - let root = tempfile::tempdir().expect("tempdir"); - let runtime = RuntimeBuilder::new(store(root.path())) - .build() - .await - .expect("runtime"); - (root, runtime) -} diff --git a/crates/mobile_ffi/Cargo.toml b/crates/mobile_ffi/Cargo.toml @@ -1,54 +0,0 @@ -[package] -name = "radroots_mobile_ffi" -description = "Native FFI bindings for Radroots mobile apps" -version = "0.1.0-alpha" -edition.workspace = true -authors = ["Radroots Authors"] -rust-version.workspace = true -license = "GPL-3.0-or-later" -repository.workspace = true -homepage.workspace = true -readme.workspace = true -publish = false -include = ["src/**", "uniffi.toml", "Cargo.toml"] - -[lib] -crate-type = ["rlib", "staticlib", "cdylib"] - -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } - -[dependencies] -async-trait = { workspace = true } -hex = { workspace = true } -radroots_blossom = { workspace = true, features = ["std"] } -radroots_event = { workspace = true, features = ["serde", "std"] } -radroots_mobile_core = { workspace = true, features = ["mobile-social"] } -radroots_sdk = { workspace = true, features = ["blossom"] } -radroots_signing = { workspace = true, features = ["serde", "std"] } -radroots_storage = { workspace = true } -serde_json = { workspace = true, features = ["std"] } -tracing = { workspace = true } -tracing-appender = { workspace = true } -tracing-subscriber = { workspace = true } -thiserror = { workspace = true } -uniffi = { workspace = true, features = ["tokio"] } - -[target.'cfg(unix)'.dependencies] -libc = { workspace = true } -rustix = { workspace = true } - -[dev-dependencies] -nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } -nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" } -nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" } -secp256k1 = { workspace = true } -tempfile = { workspace = true } -tokio = { workspace = true, features = [ - "io-util", - "macros", - "net", - "rt-multi-thread", - "sync", - "time", -] } diff --git a/crates/mobile_ffi/src/dto.rs b/crates/mobile_ffi/src/dto.rs @@ -1,2999 +0,0 @@ -//! Focused, versioned value types owned by the native boundary. - -#[cfg(unix)] -use std::os::fd::{FromRawFd, OwnedFd, RawFd}; -#[cfg(unix)] -use std::os::unix::fs::FileExt; - -use radroots_blossom::{BlobDescriptor, MediaType, Sha256}; -use radroots_event::{ - calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate}, - food::availability::{ - FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityImage, - FoodAvailabilityStatus, FoodContent, FoodCurrency, FoodIdentifier, FoodImageDimensions, - FoodPrice, FoodPublishedAt, FoodQuantity, FoodText, FoodUnit, - }, - media::AuthoredImage, - post::{AuthoredPostImage, PostImageDimensions}, -}; -use radroots_mobile_core::runtime::{ - app_info::AppInfoPlatform, - info::{AppInfo, RuntimeBuildInfo, RuntimeInfo}, - product_surface::{ - AddCommandType, CardLifecycleState, CreateAsk, CreateEvent, CreateFoodAvailability, - CreatePhotoUpdate, CreateUpdate, LocalNetwork, LocalNetworkRelayPolicy, MeSnapshot, - MediaReference, Phase1AddCommand, Phase1CancellationPolicy, Phase1DraftEventTiming, - Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus, - Phase1InboundMediaState, Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState, - Phase1QueuePolicy, Phase1RelaySatisfaction, Phase1UploadIntent, ProfileSummary, - SearchResult, SearchResultType, SupportingProfile, ThreadEntry, TodayCard, TodayCardType, - TodayPage, TodayProjectionUpdate, TodayRefreshReceipt, TodayRelaySyncState, - TodaySyncReceipt, - }, - sdk::{ - SdkBlossomConfigurationRecord, SdkBlossomEvidenceRecord, SdkCapabilityRecord, - SdkRelayAccessRecord, SdkRelayStatusRecord, SdkRelayStatusReportRecord, SdkShutdownRecord, - SdkStorageStatusRecord, - }, -}; - -use crate::RadrootsAppError; - -pub const MOBILE_FFI_SCHEMA_VERSION: u16 = 1; -const MEDIA_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024; -const MEDIA_REFERENCE_MAX_BYTES: usize = 256; - -/// Final four-state trade-evidence coverage vocabulary. -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiTradeEvidenceCoverage { - Missing, - Partial, - ScopeSatisfied, - Unsupported, -} - -/// Final three-state trade-evidence outcome vocabulary. -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiTradeEvidenceOutcome { - Valid, - Invalid, - Indeterminate, -} - -/// Secret-free projection of one canonical evidence manifest. -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiTradeEvidenceManifestRecord { - pub schema_version: u16, - pub contract_id: String, - pub contract_version: u16, - pub trade_id: String, - pub trade_generation: String, - pub observed_at_unix_s: String, - pub coverage: FfiTradeEvidenceCoverage, - pub evidence_policy_digest: String, - pub manifest_digest: String, - pub canonical_bytes_hex: String, -} - -/// Secret-free projection of one canonical RHI evidence report. -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiRhiEvidenceReportRecord { - pub schema_version: u16, - pub contract_id: String, - pub contract_version: u16, - pub issuer_pubkey: String, - pub trade_id: String, - pub claim_mutation_id: String, - pub outcome: FfiTradeEvidenceOutcome, - pub reason_codes: Vec<String>, - pub projection_digest: String, - pub evidence_manifest_digest: String, - pub evidence_policy_digest: String, - pub observed_at_unix_s: String, - pub trade_generation: String, - pub statement_digest: String, - pub supersedes_report_id: Option<String>, - pub supersedes_event_id: Option<String>, - pub canonical_content: String, -} - -/// Unsigned typed event plan ready for host-owned signing. -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiTypedEvidenceEventPlanRecord { - pub schema_version: u16, - pub contract_id: String, - pub kind: u32, - pub author_pubkey: String, - pub created_at_unix_s: String, - pub expected_event_id: String, - pub tags: Vec<Vec<String>>, - pub content: String, -} - -/// Signed NIP-01 event input for verified attestation admission. -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiSignedEvidenceEventRecord { - pub id: String, - pub author_pubkey: String, - pub created_at_unix_s: u64, - pub kind: u32, - pub tags: Vec<Vec<String>>, - pub content: String, - pub signature: String, -} - -/// Secret-free supersession projection from one verified attestation. -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiRhiEvidenceAttestationSupersessionRecord { - pub report_id: String, - pub event_id: String, -} - -/// Verified final RHI evidence attestation. -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiRhiEvidenceAttestationRecord { - pub schema_version: u16, - pub issuer_pubkey: String, - pub trade_id: String, - pub claim_mutation_id: String, - pub outcome: FfiTradeEvidenceOutcome, - pub observed_at_unix_s: String, - pub trade_generation: String, - pub statement_digest: String, - pub supersession: Option<FfiRhiEvidenceAttestationSupersessionRecord>, - pub canonical_content: String, -} - -#[uniffi::export] -pub fn parse_trade_evidence_manifest( - canonical_bytes: Vec<u8>, -) -> Result<FfiTradeEvidenceManifestRecord, RadrootsAppError> { - let manifest = radroots_sdk::trade::parse_evidence_manifest(&canonical_bytes) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_manifest"))?; - Ok(FfiTradeEvidenceManifestRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - contract_id: manifest.contract_id().to_owned(), - contract_version: manifest.contract_version(), - trade_id: manifest.trade_id().to_string(), - trade_generation: manifest.trade_generation().get().to_string(), - observed_at_unix_s: manifest.observed_at_unix_s().to_string(), - coverage: manifest.coverage().into(), - evidence_policy_digest: manifest.evidence_policy_digest().to_hex(), - manifest_digest: manifest.digest().to_hex(), - canonical_bytes_hex: hex::encode(manifest.canonical_bytes()), - }) -} - -#[uniffi::export] -pub fn parse_rhi_evidence_report( - canonical_content: String, -) -> Result<FfiRhiEvidenceReportRecord, RadrootsAppError> { - let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes()) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_report"))?; - let supersession = report.supersession(); - Ok(FfiRhiEvidenceReportRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - contract_id: report.contract_id().to_owned(), - contract_version: report.contract_version(), - issuer_pubkey: report.issuer_public_key().to_hex(), - trade_id: report.trade_id().to_string(), - claim_mutation_id: report.claim_mutation_id().to_string(), - outcome: report.outcome().into(), - reason_codes: report - .reason_codes() - .iter() - .map(|code| code.as_str().to_owned()) - .collect(), - projection_digest: report.projection_digest().to_hex(), - evidence_manifest_digest: report.evidence_manifest_digest().to_hex(), - evidence_policy_digest: report.evidence_policy_digest().to_hex(), - observed_at_unix_s: report.observed_at_unix_s().to_string(), - trade_generation: report.trade_generation().get().to_string(), - statement_digest: report.statement_digest().to_hex(), - supersedes_report_id: supersession.map(|value| value.report_id().to_hex()), - supersedes_event_id: supersession.map(|value| value.event_id().to_hex()), - canonical_content: report.canonical_content().to_owned(), - }) -} - -#[uniffi::export] -pub fn prepare_rhi_evidence_attestation( - canonical_content: String, - created_at_unix_s: u64, -) -> Result<FfiTypedEvidenceEventPlanRecord, RadrootsAppError> { - let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes()) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_report"))?; - let plan = radroots_sdk::trade::prepare_rhi_evidence_attestation(&report, created_at_unix_s) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_attestation_plan"))?; - Ok(FfiTypedEvidenceEventPlanRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - contract_id: plan.body().contract().contract_id().as_str().to_owned(), - kind: plan.body().kind(), - author_pubkey: plan.author().to_hex(), - created_at_unix_s: plan.created_at().to_string(), - expected_event_id: plan.expected_event_id().to_hex(), - tags: plan.body().tags().to_vec(), - content: plan.body().content().to_owned(), - }) -} - -#[uniffi::export] -pub fn validate_rhi_evidence_attestation( - event: FfiSignedEvidenceEventRecord, -) -> Result<FfiRhiEvidenceAttestationRecord, RadrootsAppError> { - let event = radroots_event::envelope::EventEnvelope::new( - radroots_event::envelope::EventEnvelopeParts { - id: event.id, - author: event.author_pubkey, - created_at: event.created_at_unix_s, - kind: event.kind, - tags: event.tags, - content: event.content, - sig: event.signature, - }, - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_signed_event"))?; - let attestation = radroots_sdk::trade::validate_rhi_evidence_attestation(event).map_err( - |error| match error { - radroots_sdk::trade::EvidenceAttestationValidationError::Signature => { - RadrootsAppError::invalid_argument("invalid_event_signature") - } - radroots_sdk::trade::EvidenceAttestationValidationError::Contract => { - RadrootsAppError::invalid_argument("invalid_evidence_attestation") - } - }, - )?; - Ok(FfiRhiEvidenceAttestationRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - issuer_pubkey: attestation.issuer().to_hex(), - trade_id: attestation.trade_id().to_string(), - claim_mutation_id: attestation.claim_mutation_id().to_string(), - outcome: match attestation.outcome() { - radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Valid => { - FfiTradeEvidenceOutcome::Valid - } - radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Invalid => { - FfiTradeEvidenceOutcome::Invalid - } - radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Indeterminate => { - FfiTradeEvidenceOutcome::Indeterminate - } - }, - observed_at_unix_s: attestation.observed_at_unix_s().to_string(), - trade_generation: attestation.trade_generation().get().to_string(), - statement_digest: hex::encode(attestation.statement_digest()), - supersession: attestation.supersession().map(|value| { - FfiRhiEvidenceAttestationSupersessionRecord { - report_id: hex::encode(value.report_id()), - event_id: value.event_id().to_hex(), - } - }), - canonical_content: attestation.canonical_content().to_owned(), - }) -} - -impl From<radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1> for FfiTradeEvidenceCoverage { - fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1) -> Self { - match value { - radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Missing => Self::Missing, - radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Partial => Self::Partial, - radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::ScopeSatisfied => { - Self::ScopeSatisfied - } - radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Unsupported => Self::Unsupported, - } - } -} - -impl From<radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1> for FfiTradeEvidenceOutcome { - fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1) -> Self { - match value { - radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Valid => Self::Valid, - radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Invalid => Self::Invalid, - radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Indeterminate => { - Self::Indeterminate - } - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiBuildInfoRecord { - pub schema_version: u16, - pub crate_name: String, - pub crate_version: String, - pub rustc: Option<String>, - pub profile: Option<String>, - pub lib_revision: Option<String>, - pub consumer_revision: Option<String>, - pub build_time_unix: Option<u64>, -} - -// These exhaustive field-for-field adapters are verified by the generated API -// snapshot and Swift compilation. Excluding the mechanical projection glue -// keeps the coverage gate focused on validation and behavior. -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<RuntimeBuildInfo> for FfiBuildInfoRecord { - fn from(value: RuntimeBuildInfo) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - crate_name: value.crate_name, - crate_version: value.crate_version, - rustc: value.rustc, - profile: value.profile, - lib_revision: value.lib_revision, - consumer_revision: value.consumer_revision, - build_time_unix: value.build_time_unix, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiAppPlatformRecord { - pub schema_version: u16, - pub platform: Option<String>, - pub bundle_id: Option<String>, - pub version: Option<String>, - pub build_number: Option<String>, - pub build_sha: Option<String>, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<AppInfoPlatform> for FfiAppPlatformRecord { - fn from(value: AppInfoPlatform) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - platform: value.platform, - bundle_id: value.bundle_id, - version: value.version, - build_number: value.build_number, - build_sha: value.build_sha, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiAppInfoRecord { - pub schema_version: u16, - pub build: FfiBuildInfoRecord, - pub started_unix_ms: i64, - pub uptime_millis: i64, - pub shutting_down: bool, - pub platform: Option<FfiAppPlatformRecord>, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<AppInfo> for FfiAppInfoRecord { - fn from(value: AppInfo) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - build: value.build.into(), - started_unix_ms: value.started_unix_ms, - uptime_millis: value.uptime_millis, - shutting_down: value.shutting_down, - platform: value.platform.map(Into::into), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiRuntimeInfoRecord { - pub schema_version: u16, - pub app: FfiAppInfoRecord, - pub sdk: FfiBuildInfoRecord, - pub sdk_closed: bool, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<RuntimeInfo> for FfiRuntimeInfoRecord { - fn from(value: RuntimeInfo) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - app: value.app.into(), - sdk: value.sdk.into(), - sdk_closed: value.sdk_closed, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiIdentityStatusRecord { - pub schema_version: u16, - pub public_key: String, - pub host_signer_configured: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiTodayCardType { - Update, - PhotoUpdate, - Ask, - Event, - FoodAvailability, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<TodayCardType> for FfiTodayCardType { - fn from(value: TodayCardType) -> Self { - match value { - TodayCardType::Update => Self::Update, - TodayCardType::PhotoUpdate => Self::PhotoUpdate, - TodayCardType::Ask => Self::Ask, - TodayCardType::Event => Self::Event, - TodayCardType::FoodAvailability => Self::FoodAvailability, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiAddCommandType { - CreateUpdate, - CreatePhotoUpdate, - CreateAsk, - CreateEvent, - CreateFoodAvailability, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<AddCommandType> for FfiAddCommandType { - fn from(value: AddCommandType) -> Self { - match value { - AddCommandType::CreateUpdate => Self::CreateUpdate, - AddCommandType::CreatePhotoUpdate => Self::CreatePhotoUpdate, - AddCommandType::CreateAsk => Self::CreateAsk, - AddCommandType::CreateEvent => Self::CreateEvent, - AddCommandType::CreateFoodAvailability => Self::CreateFoodAvailability, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiCardAddParityRecord { - pub schema_version: u16, - pub card_type: FfiTodayCardType, - pub command_type: FfiAddCommandType, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiLocalNetworkRecord { - pub schema_version: u16, - pub id: String, - pub label: String, - pub relay_urls: Vec<String>, - pub locality: Option<String>, - pub followed_authors: Vec<String>, - pub generation: u64, -} - -impl TryFrom<FfiLocalNetworkRecord> for LocalNetwork { - type Error = RadrootsAppError; - - fn try_from(value: FfiLocalNetworkRecord) -> Result<Self, Self::Error> { - value.try_into_with_relay_policy(LocalNetworkRelayPolicy::Public) - } -} - -impl FfiLocalNetworkRecord { - pub(crate) fn try_into_with_relay_policy( - self, - relay_policy: LocalNetworkRelayPolicy, - ) -> Result<LocalNetwork, RadrootsAppError> { - require_schema(self.schema_version)?; - LocalNetwork::new_for_relay_policy( - self.id, - self.label, - self.relay_urls, - self.locality, - self.followed_authors, - self.generation, - relay_policy, - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_local_network")) - } -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<LocalNetwork> for FfiLocalNetworkRecord { - fn from(value: LocalNetwork) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - id: value.id, - label: value.label, - relay_urls: value.relay_urls, - locality: value.locality, - followed_authors: value.followed_authors, - generation: value.generation, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiMediaVerificationState { - Pending, - Verified, - Failed, - Unavailable, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<&Phase1InboundMediaState> for FfiMediaVerificationState { - fn from(value: &Phase1InboundMediaState) -> Self { - match value { - Phase1InboundMediaState::Pending(_) => Self::Pending, - Phase1InboundMediaState::Verified(_) => Self::Verified, - Phase1InboundMediaState::Failed(_) => Self::Failed, - Phase1InboundMediaState::Unavailable => Self::Unavailable, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiMediaReferenceRecord { - pub schema_version: u16, - pub reference_fingerprint: String, - pub url: String, - pub sha256: Option<String>, - pub media_type: Option<String>, - pub width: Option<u32>, - pub height: Option<u32>, - pub byte_size: Option<u64>, - pub alt: Option<String>, - pub verification: FfiMediaVerificationState, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<MediaReference> for FfiMediaReferenceRecord { - fn from(value: MediaReference) -> Self { - let structural = value.structural(); - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - reference_fingerprint: hex::encode(structural.fingerprint()), - url: structural.source_url().to_owned(), - sha256: structural.expected_sha256().map(str::to_owned), - media_type: structural.expected_media_type().map(str::to_owned), - width: structural.expected_width(), - height: structural.expected_height(), - byte_size: structural.expected_byte_size(), - alt: structural.alt().map(str::to_owned), - verification: value.retrieval().into(), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiProfileRecord { - pub schema_version: u16, - pub author_public_key: String, - pub name: Option<String>, - pub display_name: Option<String>, - pub about: Option<String>, - pub picture: Option<FfiMediaReferenceRecord>, - pub banner: Option<FfiMediaReferenceRecord>, - pub nip05: Option<String>, - pub website: Option<String>, - pub lightning_address: Option<String>, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<ProfileSummary> for FfiProfileRecord { - fn from(value: ProfileSummary) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - author_public_key: value.author_pubkey, - name: value.name, - display_name: value.display_name, - about: value.about, - picture: value.picture.map(Into::into), - banner: value.banner.map(Into::into), - nip05: value.nip05, - website: value.website, - lightning_address: value.lightning_address, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiThreadProfile { - Profile, - Reply, - Comment, - Deletion, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<SupportingProfile> for FfiThreadProfile { - fn from(value: SupportingProfile) -> Self { - match value { - SupportingProfile::Profile => Self::Profile, - SupportingProfile::Reply => Self::Reply, - SupportingProfile::Comment => Self::Comment, - SupportingProfile::Deletion => Self::Deletion, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiThreadEntryRecord { - pub schema_version: u16, - pub event_id: String, - pub author_public_key: String, - pub content: String, - pub authored_at_unix_s: u64, - pub profile: FfiThreadProfile, - pub root: String, - pub parent_event_id: String, - pub author_profile: Option<FfiProfileRecord>, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<ThreadEntry> for FfiThreadEntryRecord { - fn from(value: ThreadEntry) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - event_id: value.event_id, - author_public_key: value.author_pubkey, - content: value.content, - authored_at_unix_s: value.authored_at, - profile: value.reference.profile.into(), - root: value.reference.root, - parent_event_id: value.reference.parent_event_id, - author_profile: value.author_profile.map(Into::into), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiCardLifecycleState { - Active, - Sold, - Past, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<CardLifecycleState> for FfiCardLifecycleState { - fn from(value: CardLifecycleState) -> Self { - match value { - CardLifecycleState::Active => Self::Active, - CardLifecycleState::Sold => Self::Sold, - CardLifecycleState::Past => Self::Past, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiTodayCardRecord { - pub schema_version: u16, - pub card_id: String, - pub card_type: FfiTodayCardType, - pub source_event_id: String, - pub source_address: Option<String>, - pub author_public_key: String, - pub contract_id: String, - pub title: Option<String>, - pub content: String, - pub authored_at_unix_s: u64, - pub effective_at_unix_s: u64, - pub event_start_unix_s: Option<u64>, - pub event_end_unix_s: Option<u64>, - pub location: Option<String>, - pub price_amount: Option<String>, - pub price_currency: Option<String>, - pub price_unit: Option<String>, - pub quantity: Option<String>, - pub food_summary: Option<String>, - pub food_published_at_unix_s: Option<u64>, - pub food_status: Option<String>, - pub context_rank: u8, - pub inclusion_reason: String, - pub media: Vec<FfiMediaReferenceRecord>, - pub lifecycle: FfiCardLifecycleState, - pub rank_digest: Option<String>, - pub author_profile: Option<FfiProfileRecord>, - pub thread: Vec<FfiThreadEntryRecord>, - pub local_operation_id: Option<String>, - pub local_operation_state: Option<String>, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<TodayCard> for FfiTodayCardRecord { - fn from(value: TodayCard) -> Self { - let card = value.card; - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - card_id: card.card_id.to_hex(), - card_type: card.card_type.into(), - source_event_id: card.source_event_id, - source_address: card.source_address, - author_public_key: card.author_pubkey, - contract_id: card.contract_id, - title: card.title, - content: card.content, - authored_at_unix_s: card.authored_at, - effective_at_unix_s: card.effective_at, - event_start_unix_s: card.event_start, - event_end_unix_s: card.event_end, - location: card.location, - price_amount: card.price_amount, - price_currency: card.price_currency, - price_unit: card.price_unit, - quantity: card.quantity, - food_summary: card.food_summary, - food_published_at_unix_s: card.food_published_at, - food_status: card.food_status, - context_rank: card.context_rank.value(), - inclusion_reason: card.inclusion_reason, - media: card.media.into_iter().map(Into::into).collect(), - lifecycle: card.lifecycle.into(), - rank_digest: card.rank.map(|rank| rank.digest_hex()), - author_profile: value.author_profile.map(Into::into), - thread: value.thread.into_iter().map(Into::into).collect(), - local_operation_id: value - .local_overlay - .as_ref() - .map(|overlay| overlay.operation_id.clone()), - local_operation_state: value.local_overlay.map(|overlay| overlay.state), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiTodayPageRecord { - pub schema_version: u16, - pub as_of_unix_s: u64, - pub items: Vec<FfiTodayCardRecord>, - pub next_cursor: Option<String>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiTodayProjectionUpdate { - Incremental, - Rebuild, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<FfiTodayProjectionUpdate> for TodayProjectionUpdate { - fn from(value: FfiTodayProjectionUpdate) -> Self { - match value { - FfiTodayProjectionUpdate::Incremental => Self::Incremental, - FfiTodayProjectionUpdate::Rebuild => Self::Rebuild, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiTodayRefreshRecord { - pub schema_version: u16, - pub update: FfiTodayProjectionUpdate, - pub source_events: u64, - pub visible_cards: u64, - pub profiles: u64, - pub thread_entries: u64, - pub content_generation: u64, - pub changed: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiTodayRelaySyncState { - Complete, - Partial, - Offline, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiTodaySyncRecord { - pub schema_version: u16, - pub relay_state: FfiTodayRelaySyncState, - pub pages_fetched: u16, - pub events_observed: u64, - pub events_admitted: u64, - pub events_rejected: u64, - pub projection: FfiTodayRefreshRecord, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<TodaySyncReceipt> for FfiTodaySyncRecord { - fn from(value: TodaySyncReceipt) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - relay_state: match value.relay_state { - TodayRelaySyncState::Complete => FfiTodayRelaySyncState::Complete, - TodayRelaySyncState::Partial => FfiTodayRelaySyncState::Partial, - TodayRelaySyncState::Offline => FfiTodayRelaySyncState::Offline, - }, - pages_fetched: value.pages_fetched, - events_observed: value.events_observed, - events_admitted: value.events_admitted, - events_rejected: value.events_rejected, - projection: value.projection.into(), - } - } -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<TodayRefreshReceipt> for FfiTodayRefreshRecord { - fn from(value: TodayRefreshReceipt) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - update: match value.update { - TodayProjectionUpdate::Incremental => FfiTodayProjectionUpdate::Incremental, - TodayProjectionUpdate::Rebuild => FfiTodayProjectionUpdate::Rebuild, - }, - source_events: value.source_events, - visible_cards: value.visible_cards, - profiles: value.profiles, - thread_entries: value.thread_entries, - content_generation: value.content_generation, - changed: value.changed, - } - } -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<TodayPage> for FfiTodayPageRecord { - fn from(value: TodayPage) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - as_of_unix_s: value.as_of, - items: value.items.into_iter().map(Into::into).collect(), - next_cursor: value.next_cursor, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiSearchResultType { - Card, - Profile, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiSearchResultRecord { - pub schema_version: u16, - pub result_type: FfiSearchResultType, - pub stable_id: String, - pub card: Option<FfiTodayCardRecord>, - pub profile: Option<FfiProfileRecord>, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<SearchResult> for FfiSearchResultRecord { - fn from(value: SearchResult) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - result_type: match value.result_type { - SearchResultType::Card => FfiSearchResultType::Card, - SearchResultType::Profile => FfiSearchResultType::Profile, - }, - stable_id: value.stable_id, - card: value.card.map(Into::into), - profile: value.profile.map(Into::into), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiMeRecord { - pub schema_version: u16, - pub public_key: String, - pub profile: Option<FfiProfileRecord>, - pub cards: Vec<FfiTodayCardRecord>, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<MeSnapshot> for FfiMeRecord { - fn from(value: MeSnapshot) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - public_key: value.public_key, - profile: value.profile.map(Into::into), - cards: value.cards.into_iter().map(Into::into).collect(), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiAddFieldKind { - Text, - MultilineText, - Date, - DateTime, - Decimal, - Choice, - Location, - Media, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiAddFieldRecord { - pub schema_version: u16, - pub id: String, - pub label: String, - pub kind: FfiAddFieldKind, - pub required: bool, - pub choices: Vec<String>, - pub max_bytes: Option<u64>, - pub max_items: Option<u16>, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiAddSchemaRecord { - pub schema_version: u16, - pub command_type: FfiAddCommandType, - pub label: String, - pub fields: Vec<FfiAddFieldRecord>, -} - -pub fn add_schemas() -> Vec<FfiAddSchemaRecord> { - use FfiAddCommandType as Command; - use FfiAddFieldKind as Kind; - - let field = - |id: &str, label: &str, kind, required, choices: &[&str], max_bytes| FfiAddFieldRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - id: id.to_owned(), - label: label.to_owned(), - kind, - required, - choices: choices.iter().map(|value| (*value).to_owned()).collect(), - max_bytes, - max_items: None, - }; - let media_field = |label: &str, required: bool, max_items| FfiAddFieldRecord { - max_items: Some(max_items), - ..field( - "media", - label, - Kind::Media, - required, - &[], - Some(MEDIA_FILE_MAX_BYTES), - ) - }; - vec![ - FfiAddSchemaRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type: Command::CreateUpdate, - label: "Update".to_owned(), - fields: vec![field( - "content", - "Update", - Kind::MultilineText, - true, - &[], - Some(65_535), - )], - }, - FfiAddSchemaRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type: Command::CreatePhotoUpdate, - label: "Photo update".to_owned(), - fields: vec![ - field( - "content", - "Update", - Kind::MultilineText, - true, - &[], - Some(65_535), - ), - media_field("Photos", true, 20), - ], - }, - FfiAddSchemaRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type: Command::CreateAsk, - label: "Ask".to_owned(), - fields: vec![ - field( - "content", - "Question", - Kind::MultilineText, - true, - &[], - Some(65_535), - ), - media_field("Photos", false, 20), - ], - }, - FfiAddSchemaRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type: Command::CreateEvent, - label: "Event".to_owned(), - fields: vec![ - field("identifier", "Identifier", Kind::Text, true, &[], Some(256)), - field("title", "Title", Kind::Text, true, &[], Some(256)), - field( - "content", - "Description", - Kind::MultilineText, - false, - &[], - Some(65_535), - ), - field("event_start", "Starts", Kind::DateTime, true, &[], None), - field("event_end", "Ends", Kind::DateTime, false, &[], None), - field( - "location", - "Location", - Kind::Location, - false, - &[], - Some(256), - ), - media_field("Photo", false, 1), - ], - }, - FfiAddSchemaRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type: Command::CreateFoodAvailability, - label: "Food availability".to_owned(), - fields: vec![ - field("identifier", "Identifier", Kind::Text, true, &[], Some(256)), - field("title", "Food", Kind::Text, true, &[], Some(256)), - field("summary", "Summary", Kind::Text, true, &[], Some(256)), - field( - "content", - "Details", - Kind::MultilineText, - true, - &[], - Some(65_535), - ), - field( - "location", - "Pickup location", - Kind::Location, - true, - &[], - Some(256), - ), - field("price_amount", "Price", Kind::Decimal, true, &[], Some(64)), - field("currency", "Currency", Kind::Choice, true, &[], Some(3)), - field( - "unit", - "Unit", - Kind::Choice, - true, - &[ - "g", "kg", "lb", "oz", "each", "dozen", "bunch", "punnet", "bag", "basket", - ], - None, - ), - field( - "quantity", - "Available quantity", - Kind::Decimal, - false, - &[], - Some(64), - ), - media_field("Photos", false, 20), - ], - }, - ] -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiEventTimingKind { - AllDay, - Timed, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiPreparedMediaInput { - pub schema_version: u16, - pub opaque_reference: String, - pub file_descriptor: u64, - pub sha256: String, - pub media_type: String, - pub byte_size: u64, - pub width: u32, - pub height: u32, - pub alt: String, - pub prepared_at_unix_s: u64, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiAddDraftInput { - pub schema_version: u16, - pub command_type: FfiAddCommandType, - pub content: String, - pub identifier: Option<String>, - pub title: Option<String>, - pub summary: Option<String>, - pub location: Option<String>, - pub event_timing: Option<FfiEventTimingKind>, - pub event_start_date: Option<String>, - pub event_end_date: Option<String>, - pub event_start_unix_s: Option<u64>, - pub event_end_unix_s: Option<u64>, - pub event_timezone: Option<String>, - pub price_amount: Option<String>, - pub currency: Option<String>, - pub unit: Option<String>, - pub quantity: Option<String>, - pub food_published_at_unix_s: Option<u64>, - pub food_status: Option<String>, - pub media: Vec<FfiPreparedMediaInput>, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiRetractionDraftInput { - pub schema_version: u16, - pub command_type: FfiAddCommandType, - pub target_card_id: String, - pub target_event_id: String, - pub target_kind: u32, - pub target_address: Option<String>, - pub reason: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiBlossomUploadInput { - pub schema_version: u16, - pub draft_id: String, - pub expected_revision: u64, - pub media: FfiPreparedMediaInput, - pub authorization_content: String, - pub authorization_created_at_unix_s: u64, - pub authorization_lifetime_seconds: u64, - pub operation_id: String, - pub artifact_id: String, - pub signing_deadline_unix_ms: u64, - pub signing_cancellation: FfiCancellationPolicy, - pub verified_at_unix_ms: u64, - pub updated_at_unix_ms: u64, -} - -/// Minimal host input for a Rust-planned exact-byte upload attempt. -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiBlossomUploadIntent { - pub schema_version: u16, - pub draft_id: String, - pub expected_revision: u64, - pub media: FfiPreparedMediaInput, -} - -/// Secret-bearing native job. Hosts may use the authorization header for the -/// immediate OS request but must not persist it in application metadata. -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiNativeUploadJobRecord { - pub schema_version: u16, - pub operation_id: String, - pub draft: FfiDraftStatusRecord, - pub remote_url: String, - pub authorization_header: String, - pub expected_sha256: String, - pub media_type: String, - pub byte_size: u64, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiNativeUploadCompletionInput { - pub schema_version: u16, - pub draft_id: String, - pub expected_revision: u64, - pub media: FfiPreparedMediaInput, - pub status_code: u16, - pub response_media_type: Option<String>, - pub response_content_encoding: Option<String>, - pub response_body: Vec<u8>, -} - -impl FfiAddDraftInput { - pub(crate) fn command_and_media( - self, - authored_at_unix_s: u64, - blossom: Option<&radroots_sdk::transport::BlossomSlot>, - ) -> Result<(Phase1AddCommand, Vec<Phase1MediaPrerequisite>), RadrootsAppError> { - self.command_media_and_form(authored_at_unix_s, blossom) - .map(|(command, media, _)| (command, media)) - } - - pub(crate) fn command_media_and_form( - self, - authored_at_unix_s: u64, - blossom: Option<&radroots_sdk::transport::BlossomSlot>, - ) -> Result< - ( - Phase1AddCommand, - Vec<Phase1MediaPrerequisite>, - Phase1DraftFormSnapshot, - ), - RadrootsAppError, - > { - require_schema(self.schema_version)?; - if authored_at_unix_s == 0 || self.media.len() > 20 { - return Err(RadrootsAppError::invalid_argument("invalid_add_draft")); - } - let prepared = self - .media - .iter() - .cloned() - .map(PreparedMedia::try_from) - .map(|media| { - media.and_then(|media| { - let blossom = blossom.ok_or_else(|| { - RadrootsAppError::invalid_argument("blossom_not_configured") - })?; - media.bind(blossom) - }) - }) - .collect::<Result<Vec<BoundPreparedMedia>, _>>()?; - let prerequisites = prepared - .iter() - .map(|value| { - Phase1MediaPrerequisite::new( - value.media.opaque_reference.clone(), - &value.descriptor, - ) - }) - .collect::<Result<Vec<_>, _>>() - .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference"))?; - let post_images = prepared - .iter() - .map(BoundPreparedMedia::post_image) - .collect::<Result<Vec<_>, _>>()?; - let form = self.form_snapshot(&prepared); - let command = match self.command_type { - FfiAddCommandType::CreateUpdate => { - reject_media(&prepared)?; - Phase1AddCommand::CreateUpdate( - CreateUpdate::new(self.content) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_update"))?, - ) - } - FfiAddCommandType::CreatePhotoUpdate => Phase1AddCommand::CreatePhotoUpdate( - CreatePhotoUpdate::new( - content_with_media_references(self.content, &prepared)?, - post_images, - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_photo_update"))?, - ), - FfiAddCommandType::CreateAsk => Phase1AddCommand::CreateAsk( - CreateAsk::new( - content_with_media_references(self.content, &prepared)?, - post_images, - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_ask"))?, - ), - FfiAddCommandType::CreateEvent => { - Phase1AddCommand::CreateEvent(event_command(&self, prepared.first())?) - } - FfiAddCommandType::CreateFoodAvailability => Phase1AddCommand::CreateFoodAvailability( - food_command(self, authored_at_unix_s, &prepared)?, - ), - }; - Ok((command, prerequisites, form)) - } - - fn form_snapshot(&self, prepared: &[BoundPreparedMedia]) -> Phase1DraftFormSnapshot { - Phase1DraftFormSnapshot { - command_type: match self.command_type { - FfiAddCommandType::CreateUpdate => AddCommandType::CreateUpdate, - FfiAddCommandType::CreatePhotoUpdate => AddCommandType::CreatePhotoUpdate, - FfiAddCommandType::CreateAsk => AddCommandType::CreateAsk, - FfiAddCommandType::CreateEvent => AddCommandType::CreateEvent, - FfiAddCommandType::CreateFoodAvailability => AddCommandType::CreateFoodAvailability, - }, - content: self.content.clone(), - identifier: self.identifier.clone(), - title: self.title.clone(), - summary: self.summary.clone(), - location: self.location.clone(), - event_timing: self.event_timing.map(|value| match value { - FfiEventTimingKind::AllDay => Phase1DraftEventTiming::AllDay, - FfiEventTimingKind::Timed => Phase1DraftEventTiming::Timed, - }), - event_start_date: self.event_start_date.clone(), - event_end_date: self.event_end_date.clone(), - event_start_unix_s: self.event_start_unix_s, - event_end_unix_s: self.event_end_unix_s, - event_timezone: self.event_timezone.clone(), - price_amount: self.price_amount.clone(), - currency: self.currency.clone(), - unit: self.unit.clone(), - quantity: self.quantity.clone(), - food_published_at_unix_s: self.food_published_at_unix_s, - food_status: self.food_status.clone(), - media: prepared - .iter() - .map(|value| Phase1DraftMediaSnapshot { - opaque_reference: value.media.opaque_reference.clone(), - url: value.descriptor.url().as_str().to_owned(), - sha256: value.media.sha256.to_hex(), - media_type: value.media.media_type.as_str().to_owned(), - byte_size: value.media.byte_size, - width: value.media.width, - height: value.media.height, - alt: value.media.alt.clone(), - prepared_at_unix_s: value.media.prepared_at_unix_s, - }) - .collect(), - } - } -} - -pub(crate) struct PreparedMedia { - opaque_reference: String, - sha256: Sha256, - byte_size: u64, - prepared_at_unix_s: u64, - bytes: std::sync::Arc<[u8]>, - media_type: MediaType, - width: u32, - height: u32, - alt: String, -} - -struct BoundPreparedMedia { - media: PreparedMedia, - descriptor: radroots_blossom::ByteVerifiedDescriptor, -} - -impl TryFrom<FfiPreparedMediaInput> for PreparedMedia { - type Error = RadrootsAppError; - - fn try_from(value: FfiPreparedMediaInput) -> Result<Self, Self::Error> { - require_schema(value.schema_version)?; - if !opaque_media_reference_is_valid(&value.opaque_reference) - || value.byte_size == 0 - || value.byte_size > MEDIA_FILE_MAX_BYTES - || value.width == 0 - || value.height == 0 - || value.prepared_at_unix_s == 0 - || value.alt.trim().is_empty() - || value.alt.len() > 1_024 - { - return Err(RadrootsAppError::invalid_argument( - "invalid_media_reference", - )); - } - let byte_size = usize::try_from(value.byte_size) - .map_err(|_| RadrootsAppError::invalid_argument("media_size_mismatch"))?; - let bytes = read_media_file_descriptor(value.file_descriptor, value.byte_size, byte_size)?; - let media_type = MediaType::parse(&value.media_type) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_type"))?; - let sha256 = Sha256::from_hex(&value.sha256) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_digest"))?; - if Sha256::digest(&bytes) != sha256 { - return Err(RadrootsAppError::invalid_argument( - "media_verification_failed", - )); - } - let dimensions = - radroots_sdk::transport::BlossomImageDimensions::new(value.width, value.height) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?; - let verified_at_unix_ms = value - .prepared_at_unix_s - .checked_mul(1_000) - .ok_or_else(|| RadrootsAppError::invalid_argument("invalid_media_reference"))?; - radroots_sdk::transport::BlossomUploadRequest::new( - bytes.clone().into(), - media_type.clone(), - dimensions, - verified_at_unix_ms, - ) - .map_err(|_| RadrootsAppError::invalid_argument("media_verification_failed"))?; - Ok(Self { - opaque_reference: value.opaque_reference, - sha256, - byte_size: value.byte_size, - prepared_at_unix_s: value.prepared_at_unix_s, - bytes: bytes.into(), - media_type, - width: value.width, - height: value.height, - alt: value.alt, - }) - } -} - -#[cfg(unix)] -fn read_media_file_descriptor( - file_descriptor: u64, - expected_size: u64, - byte_size: usize, -) -> Result<Vec<u8>, RadrootsAppError> { - let raw_file_descriptor = RawFd::try_from(file_descriptor) - .map_err(|_| RadrootsAppError::invalid_argument("media_handle_unavailable"))?; - // SAFETY: `fcntl(F_DUPFD_CLOEXEC)` accepts any in-range integer descriptor - // and reports EBADF for an unavailable one. No borrowed or owned Rust - // descriptor is constructed until the kernel has duplicated it. - let duplicated = unsafe { libc::fcntl(raw_file_descriptor, libc::F_DUPFD_CLOEXEC, 0) }; - if duplicated < 0 { - return Err(RadrootsAppError::invalid_argument( - "media_handle_unavailable", - )); - } - // SAFETY: a nonnegative F_DUPFD_CLOEXEC result is a new descriptor owned by - // this call. The host's original descriptor remains independently owned. - let owned = unsafe { OwnedFd::from_raw_fd(duplicated) }; - let file = std::fs::File::from(owned); - let metadata = file - .metadata() - .map_err(|_| RadrootsAppError::invalid_argument("media_handle_unavailable"))?; - if !metadata.is_file() || metadata.len() != expected_size { - return Err(RadrootsAppError::invalid_argument("media_size_mismatch")); - } - let mut bytes = vec![0; byte_size]; - file.read_exact_at(&mut bytes, 0) - .map_err(|_| RadrootsAppError::invalid_argument("media_read_failed"))?; - Ok(bytes) -} - -#[cfg(not(unix))] -fn read_media_file_descriptor( - _file_descriptor: u64, - _expected_size: u64, - _byte_size: usize, -) -> Result<Vec<u8>, RadrootsAppError> { - Err(RadrootsAppError::failure( - "media_handle_unsupported", - "capability", - false, - &[], - "Protected media handles are unsupported on this platform.", - )) -} - -impl PreparedMedia { - pub(crate) fn into_authored_image( - self, - blossom: &radroots_sdk::transport::BlossomSlot, - ) -> Result<AuthoredImage, RadrootsAppError> { - self.bind(blossom)?.authored_image() - } - - pub(crate) fn into_upload_intent( - self, - draft_id: [u8; 16], - expected_revision: u64, - ) -> Result<Phase1UploadIntent, RadrootsAppError> { - Phase1UploadIntent::new( - draft_id, - expected_revision, - self.bytes, - self.media_type, - self.width, - self.height, - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_upload")) - } - - pub(crate) fn upload_request( - &self, - verified_at_unix_ms: u64, - ) -> Result<radroots_sdk::transport::BlossomUploadRequest, RadrootsAppError> { - let dimensions = - radroots_sdk::transport::BlossomImageDimensions::new(self.width, self.height) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?; - radroots_sdk::transport::BlossomUploadRequest::new( - std::sync::Arc::clone(&self.bytes), - self.media_type.clone(), - dimensions, - verified_at_unix_ms, - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_upload")) - } - - fn bind( - self, - blossom: &radroots_sdk::transport::BlossomSlot, - ) -> Result<BoundPreparedMedia, RadrootsAppError> { - let verified_at_unix_ms = self - .prepared_at_unix_s - .checked_mul(1_000) - .ok_or_else(|| RadrootsAppError::invalid_argument("invalid_media_reference"))?; - let transaction = blossom - .prepare_upload(self.upload_request(verified_at_unix_ms)?) - .map_err(|error| RadrootsAppError::invalid_argument(error.code()))?; - let descriptor = BlobDescriptor::new( - transaction.expected_url().clone(), - self.sha256, - self.byte_size, - self.media_type.clone(), - self.prepared_at_unix_s, - ) - .and_then(BlobDescriptor::approve_reference) - .and_then(|descriptor| descriptor.verify_bytes(&self.bytes, &self.media_type)) - .map_err(|_| RadrootsAppError::invalid_argument("media_verification_failed"))?; - Ok(BoundPreparedMedia { - media: self, - descriptor, - }) - } -} - -impl BoundPreparedMedia { - fn authored_image(&self) -> Result<AuthoredImage, RadrootsAppError> { - AuthoredImage::try_from_verified_descriptor(self.descriptor.clone()) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_media")) - } - - fn post_image(&self) -> Result<AuthoredPostImage, RadrootsAppError> { - AuthoredPostImage::new( - self.authored_image()?, - PostImageDimensions::new(self.media.width, self.media.height) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?, - self.media.alt.clone(), - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_image")) - } -} - -fn event_command( - input: &FfiAddDraftInput, - image: Option<&BoundPreparedMedia>, -) -> Result<CreateEvent, RadrootsAppError> { - if input.media.len() > 1 { - return Err(RadrootsAppError::invalid_argument("event_image_limit")); - } - let identifier = required(input.identifier.as_deref(), "event_identifier_required")?; - let title = required(input.title.as_deref(), "event_title_required")?; - let timing = input - .event_timing - .ok_or_else(|| RadrootsAppError::invalid_argument("event_timing_required"))?; - match timing { - FfiEventTimingKind::AllDay => { - let start = CalendarDate::parse(required( - input.event_start_date.as_deref(), - "event_start_date_required", - )?) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_start_date"))?; - let mut event = AuthoredCalendarDateEvent::new(identifier, title, start) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_event"))?; - if let Some(end) = input.event_end_date.as_deref() { - event = event - .with_end(CalendarDate::parse(end).map_err(|_| { - RadrootsAppError::invalid_argument("invalid_event_end_date") - })?) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_range"))?; - } - if !input.content.is_empty() { - event = event - .with_description(input.content.clone()) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_description"))?; - } - if let Some(location) = input.location.clone() { - event = event - .with_locations(vec![location]) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_location"))?; - } - if let Some(image) = image { - event = event - .with_image(image.authored_image()?) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_image"))?; - } - Ok(CreateEvent::date(event)) - } - FfiEventTimingKind::Timed => { - let start = input - .event_start_unix_s - .filter(|value| *value != 0) - .ok_or_else(|| RadrootsAppError::invalid_argument("event_start_required"))?; - let mut event = AuthoredCalendarTimeEvent::new(identifier, title, start) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_event"))?; - if let Some(end) = input.event_end_unix_s { - event = event - .with_end(end) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_range"))?; - } - if let Some(timezone) = input.event_timezone.as_deref() { - event = event - .with_start_tzid(timezone) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_timezone"))?; - } - if !input.content.is_empty() { - event = event - .with_description(input.content.clone()) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_description"))?; - } - if let Some(location) = input.location.clone() { - event = event - .with_locations(vec![location]) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_location"))?; - } - if let Some(image) = image { - event = event - .with_image(image.authored_image()?) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_image"))?; - } - Ok(CreateEvent::time(event)) - } - } -} - -fn food_command( - input: FfiAddDraftInput, - authored_at_unix_s: u64, - media: &[BoundPreparedMedia], -) -> Result<CreateFoodAvailability, RadrootsAppError> { - let unit = FoodUnit::parse(required(input.unit.as_deref(), "food_unit_required")?) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_unit"))?; - let images = media - .iter() - .map(|image| { - Ok(FoodAvailabilityImage::new( - image.authored_image()?, - FoodImageDimensions::new(image.media.width, image.media.height) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?, - )) - }) - .collect::<Result<Vec<_>, RadrootsAppError>>()?; - let status = input.food_status.as_deref().unwrap_or("active"); - let details = FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts { - content: FoodContent::new(input.content) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_content"))?, - identifier: FoodIdentifier::parse(required( - input.identifier.as_deref(), - "food_identifier_required", - )?) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_identifier"))?, - title: FoodText::new(required(input.title, "food_title_required")?) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_title"))?, - summary: FoodText::new(required(input.summary, "food_summary_required")?) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_summary"))?, - published_at: FoodPublishedAt::new( - input.food_published_at_unix_s.unwrap_or(authored_at_unix_s), - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_published_at"))?, - location: FoodText::new(required(input.location, "food_location_required")?) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_location"))?, - price: FoodPrice::new( - required(input.price_amount, "food_price_required")?, - FoodCurrency::parse(required(input.currency, "food_currency_required")?) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_currency"))?, - unit, - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_price"))?, - quantity: input - .quantity - .map(|quantity| FoodQuantity::new(quantity, unit)) - .transpose() - .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_quantity"))?, - status: FoodAvailabilityStatus::parse(status) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_status"))?, - images, - }) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_availability"))?; - Ok(CreateFoodAvailability::new(details)) -} - -fn reject_media(media: &[BoundPreparedMedia]) -> Result<(), RadrootsAppError> { - if media.is_empty() { - Ok(()) - } else { - Err(RadrootsAppError::invalid_argument("media_not_allowed")) - } -} - -fn content_with_media_references( - mut content: String, - media: &[BoundPreparedMedia], -) -> Result<String, RadrootsAppError> { - if content.trim().is_empty() { - return Err(RadrootsAppError::invalid_argument("content_required")); - } - for item in media { - let url = item.descriptor.url().as_str(); - match content.match_indices(url).count() { - 0 => { - if !content.ends_with('\n') { - content.push('\n'); - } - content.push_str(url); - } - 1 => {} - _ => { - return Err(RadrootsAppError::invalid_argument( - "duplicate_media_reference", - )); - } - } - } - Ok(content) -} - -fn required<T>(value: Option<T>, code: &'static str) -> Result<T, RadrootsAppError> { - value.ok_or_else(|| RadrootsAppError::invalid_argument(code)) -} - -fn opaque_media_reference_is_valid(value: &str) -> bool { - value.len() > "media:".len() - && value.len() <= MEDIA_REFERENCE_MAX_BYTES - && value.starts_with("media:") - && value["media:".len()..].bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') - }) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiMediaStage { - Pending, - Preparing, - Uploading, - Verified, - Failed, - Orphaned, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<Phase1MediaStage> for FfiMediaStage { - fn from(value: Phase1MediaStage) -> Self { - match value { - Phase1MediaStage::Pending => Self::Pending, - Phase1MediaStage::Preparing => Self::Preparing, - Phase1MediaStage::Uploading => Self::Uploading, - Phase1MediaStage::Verified => Self::Verified, - Phase1MediaStage::Failed => Self::Failed, - Phase1MediaStage::Orphaned => Self::Orphaned, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiDraftMediaRecord { - pub schema_version: u16, - pub url: String, - pub stage: FfiMediaStage, - pub upload_attempts: u8, - pub verified_at_unix_ms: Option<u64>, - pub possible_orphan: bool, - pub orphan_reason_code: Option<String>, - pub orphan_recorded_at_unix_ms: Option<u64>, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<&Phase1MediaPrerequisite> for FfiDraftMediaRecord { - fn from(value: &Phase1MediaPrerequisite) -> Self { - let orphan = value.orphan(); - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - url: value.url().to_owned(), - stage: value.stage().into(), - upload_attempts: value.upload_attempts(), - verified_at_unix_ms: value.verified_at_unix_ms(), - possible_orphan: orphan.is_some(), - orphan_reason_code: orphan.map(|value| value.reason_code().to_owned()), - orphan_recorded_at_unix_ms: orphan.map(|value| value.recorded_at_unix_ms()), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiOutboxState { - Draft, - MediaPreparing, - MediaUploading, - ReadyToSign, - Signing, - Signed, - Queued, - Delivering, - PartiallyDelivered, - Retryable, - Terminal, - Cancelled, - Complete, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiDraftKind { - Add, - Retraction, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<Phase1DraftKind> for FfiDraftKind { - fn from(value: Phase1DraftKind) -> Self { - match value { - Phase1DraftKind::Add => Self::Add, - Phase1DraftKind::Retraction => Self::Retraction, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiDraftFormMediaRecord { - pub schema_version: u16, - pub opaque_reference: String, - pub url: String, - pub sha256: String, - pub media_type: String, - pub byte_size: u64, - pub width: u32, - pub height: u32, - pub alt: String, - pub prepared_at_unix_s: u64, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<&Phase1DraftMediaSnapshot> for FfiDraftFormMediaRecord { - fn from(value: &Phase1DraftMediaSnapshot) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - opaque_reference: value.opaque_reference.clone(), - url: value.url.clone(), - sha256: value.sha256.clone(), - media_type: value.media_type.clone(), - byte_size: value.byte_size, - width: value.width, - height: value.height, - alt: value.alt.clone(), - prepared_at_unix_s: value.prepared_at_unix_s, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiDraftFormRecord { - pub schema_version: u16, - pub command_type: FfiAddCommandType, - pub content: String, - pub identifier: Option<String>, - pub title: Option<String>, - pub summary: Option<String>, - pub location: Option<String>, - pub event_timing: Option<FfiEventTimingKind>, - pub event_start_date: Option<String>, - pub event_end_date: Option<String>, - pub event_start_unix_s: Option<u64>, - pub event_end_unix_s: Option<u64>, - pub event_timezone: Option<String>, - pub price_amount: Option<String>, - pub currency: Option<String>, - pub unit: Option<String>, - pub quantity: Option<String>, - pub food_published_at_unix_s: Option<u64>, - pub food_status: Option<String>, - pub media: Vec<FfiDraftFormMediaRecord>, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<&Phase1DraftFormSnapshot> for FfiDraftFormRecord { - fn from(value: &Phase1DraftFormSnapshot) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type: value.command_type.into(), - content: value.content.clone(), - identifier: value.identifier.clone(), - title: value.title.clone(), - summary: value.summary.clone(), - location: value.location.clone(), - event_timing: value.event_timing.map(|value| match value { - Phase1DraftEventTiming::AllDay => FfiEventTimingKind::AllDay, - Phase1DraftEventTiming::Timed => FfiEventTimingKind::Timed, - }), - event_start_date: value.event_start_date.clone(), - event_end_date: value.event_end_date.clone(), - event_start_unix_s: value.event_start_unix_s, - event_end_unix_s: value.event_end_unix_s, - event_timezone: value.event_timezone.clone(), - price_amount: value.price_amount.clone(), - currency: value.currency.clone(), - unit: value.unit.clone(), - quantity: value.quantity.clone(), - food_published_at_unix_s: value.food_published_at_unix_s, - food_status: value.food_status.clone(), - media: value.media.iter().map(Into::into).collect(), - } - } -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<Phase1OutboxState> for FfiOutboxState { - fn from(value: Phase1OutboxState) -> Self { - match value { - Phase1OutboxState::Draft => Self::Draft, - Phase1OutboxState::MediaPreparing => Self::MediaPreparing, - Phase1OutboxState::MediaUploading => Self::MediaUploading, - Phase1OutboxState::ReadyToSign => Self::ReadyToSign, - Phase1OutboxState::Signing => Self::Signing, - Phase1OutboxState::Signed => Self::Signed, - Phase1OutboxState::Queued => Self::Queued, - Phase1OutboxState::Delivering => Self::Delivering, - Phase1OutboxState::PartiallyDelivered => Self::PartiallyDelivered, - Phase1OutboxState::Retryable => Self::Retryable, - Phase1OutboxState::Terminal => Self::Terminal, - Phase1OutboxState::Cancelled => Self::Cancelled, - Phase1OutboxState::Complete => Self::Complete, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiDraftStatusRecord { - pub schema_version: u16, - pub draft_id: String, - pub revision: u64, - pub author_public_key: String, - pub kind: FfiDraftKind, - pub command_type: FfiAddCommandType, - pub form: Option<FfiDraftFormRecord>, - pub state: FfiOutboxState, - pub card_id: String, - pub operation_id: Option<String>, - pub created_at_unix_ms: u64, - pub updated_at_unix_ms: u64, - pub media: Vec<FfiDraftMediaRecord>, - pub settlement: Option<FfiOperationSettlementRecord>, - pub is_revision: bool, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<Phase1DraftStatus> for FfiDraftStatusRecord { - fn from(value: Phase1DraftStatus) -> Self { - let draft = value.draft(); - let settlement = value.push().map(|push| push.settlement()); - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - draft_id: hex::encode(draft.draft_id().as_bytes()), - revision: draft.revision().get(), - author_public_key: hex::encode(draft.author()), - kind: value.kind().into(), - command_type: value.command_type().into(), - form: value.form().map(Into::into), - state: value.state().into(), - card_id: value.card_id().to_hex(), - operation_id: draft.operation_id().map(|id| hex::encode(id.as_bytes())), - created_at_unix_ms: draft.created_at_unix_ms(), - updated_at_unix_ms: draft.updated_at_unix_ms(), - media: value.media().iter().map(Into::into).collect(), - settlement: settlement.map(Into::into), - is_revision: value.revision_policy().is_some(), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiOperationSettlementRecord { - pub schema_version: u16, - pub artifacts: u16, - pub signed: u16, - pub admitted: u16, - pub pending: u16, - pub retryable: u16, - pub indeterminate: u16, - pub failed_terminal: u16, - pub cancelled: u16, - pub delivery_plans: u16, - pub delivery_satisfied: u16, - pub delivery_pending: u16, - pub delivery_retryable: u16, - pub delivery_exhausted: u16, - pub delivery_failed_terminal: u16, - pub delivery_cancelled: u16, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<radroots_storage::authored::OperationSettlement> for FfiOperationSettlementRecord { - fn from(value: radroots_storage::authored::OperationSettlement) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - artifacts: value.artifacts(), - signed: value.signed(), - admitted: value.admitted(), - pending: value.pending(), - retryable: value.retryable(), - indeterminate: value.indeterminate(), - failed_terminal: value.failed_terminal(), - cancelled: value.cancelled(), - delivery_plans: value.delivery_plans(), - delivery_satisfied: value.delivery_satisfied(), - delivery_pending: value.delivery_pending(), - delivery_retryable: value.delivery_retryable(), - delivery_exhausted: value.delivery_exhausted(), - delivery_failed_terminal: value.delivery_failed_terminal(), - delivery_cancelled: value.delivery_cancelled(), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiRelaySatisfaction { - AnyAccepted, - AllAccepted, - AnyDelivered, - AllDelivered, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiCancellationPolicy { - PreservePublishedRequest, - LocalCooperative, -} - -impl FfiCancellationPolicy { - pub(crate) const fn core(self) -> Phase1CancellationPolicy { - match self { - Self::PreservePublishedRequest => Phase1CancellationPolicy::PreservePublishedRequest, - Self::LocalCooperative => Phase1CancellationPolicy::LocalCooperative, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiQueuePolicyRecord { - pub schema_version: u16, - pub relay_urls: Vec<String>, - pub satisfaction: FfiRelaySatisfaction, - pub delivery_deadline_unix_ms: u64, - pub cancellation: FfiCancellationPolicy, -} - -impl TryFrom<FfiQueuePolicyRecord> for Phase1QueuePolicy { - type Error = RadrootsAppError; - - fn try_from(value: FfiQueuePolicyRecord) -> Result<Self, Self::Error> { - require_schema(value.schema_version)?; - Phase1QueuePolicy::new( - value.relay_urls, - match value.satisfaction { - FfiRelaySatisfaction::AnyAccepted => Phase1RelaySatisfaction::AnyAccepted, - FfiRelaySatisfaction::AllAccepted => Phase1RelaySatisfaction::AllAccepted, - FfiRelaySatisfaction::AnyDelivered => Phase1RelaySatisfaction::AnyDelivered, - FfiRelaySatisfaction::AllDelivered => Phase1RelaySatisfaction::AllDelivered, - }, - value.delivery_deadline_unix_ms, - match value.cancellation { - FfiCancellationPolicy::PreservePublishedRequest => { - Phase1CancellationPolicy::PreservePublishedRequest - } - FfiCancellationPolicy::LocalCooperative => { - Phase1CancellationPolicy::LocalCooperative - } - }, - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_queue_policy")) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiCapabilityRecord { - pub schema_version: u16, - pub id: String, - pub compiled: bool, - pub configured: bool, - pub availability: String, - pub maturity: String, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<SdkCapabilityRecord> for FfiCapabilityRecord { - fn from(value: SdkCapabilityRecord) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - id: value.id, - compiled: value.compiled, - configured: value.configured, - availability: value.availability, - maturity: value.maturity, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiStorageStatusRecord { - pub schema_version: u16, - pub backend: String, - pub open_mode: String, - pub shutdown: String, - pub integrity: String, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<SdkStorageStatusRecord> for FfiStorageStatusRecord { - fn from(value: SdkStorageStatusRecord) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - backend: value.backend, - open_mode: value.open_mode, - shutdown: value.shutdown, - integrity: value.integrity, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiRelayAccessRecord { - ReadOnly, - ReadWrite, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiRelayStatusRecord { - pub schema_version: u16, - pub relay_url: String, - pub access: FfiRelayAccessRecord, - pub read_state: String, - pub write_state: String, - pub read_last_attempt_unix_ms: Option<u64>, - pub write_last_attempt_unix_ms: Option<u64>, - pub read_next_attempt_unix_ms: Option<u64>, - pub write_next_attempt_unix_ms: Option<u64>, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<SdkRelayStatusRecord> for FfiRelayStatusRecord { - fn from(value: SdkRelayStatusRecord) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - relay_url: value.relay_url, - access: match value.access { - SdkRelayAccessRecord::ReadOnly => FfiRelayAccessRecord::ReadOnly, - SdkRelayAccessRecord::ReadWrite => FfiRelayAccessRecord::ReadWrite, - }, - read_state: value.read_state, - write_state: value.write_state, - read_last_attempt_unix_ms: value.read_last_attempt_unix_ms, - write_last_attempt_unix_ms: value.write_last_attempt_unix_ms, - read_next_attempt_unix_ms: value.read_next_attempt_unix_ms, - write_next_attempt_unix_ms: value.write_next_attempt_unix_ms, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiRelayStatusReportRecord { - pub schema_version: u16, - pub profile: String, - pub state: String, - pub read_availability: String, - pub write_availability: String, - pub relays: Vec<FfiRelayStatusRecord>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiBlossomHostKind { - Native, - Simulator, - PhysicalDevice, -} - -impl From<FfiBlossomHostKind> for radroots_sdk::transport::BlossomHostKind { - fn from(value: FfiBlossomHostKind) -> Self { - match value { - FfiBlossomHostKind::Native => Self::Native, - FfiBlossomHostKind::Simulator => Self::Simulator, - FfiBlossomHostKind::PhysicalDevice => Self::PhysicalDevice, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiBlossomEndpointAuthority { - PublicWebPki, - LoopbackDevelopment, - PrivateNetworkDevelopment, -} - -impl From<FfiBlossomEndpointAuthority> for radroots_sdk::transport::BlossomEndpointAuthority { - fn from(value: FfiBlossomEndpointAuthority) -> Self { - match value { - FfiBlossomEndpointAuthority::PublicWebPki => Self::PublicWebPki, - FfiBlossomEndpointAuthority::LoopbackDevelopment => Self::LoopbackDevelopment, - FfiBlossomEndpointAuthority::PrivateNetworkDevelopment => { - Self::PrivateNetworkDevelopment - } - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiBlossomConfigurationRecord { - pub schema_version: u16, - pub host_kind: String, - pub endpoint_authority: String, - pub primary_origin: String, - pub fallback_origins: Vec<String>, - pub config_fingerprint: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiBlossomEvidenceRecord { - pub schema_version: u16, - pub origin: String, - pub config_fingerprint: String, - pub state: String, - pub last_successful_state: String, - pub transport_security: String, - pub observed_at_unix_ms: Option<u64>, - pub http_status: Option<u16>, - pub error_code: Option<String>, - pub server_error_code: Option<String>, - pub error_phase: Option<String>, - pub retryable: bool, - pub possible_orphan: bool, - pub attempts: u8, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<SdkBlossomConfigurationRecord> for FfiBlossomConfigurationRecord { - fn from(value: SdkBlossomConfigurationRecord) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - host_kind: value.host_kind, - endpoint_authority: value.endpoint_authority, - primary_origin: value.primary_origin, - fallback_origins: value.fallback_origins, - config_fingerprint: value.config_fingerprint, - } - } -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<SdkBlossomEvidenceRecord> for FfiBlossomEvidenceRecord { - fn from(value: SdkBlossomEvidenceRecord) -> Self { - Self { - schema_version: value.schema_version, - origin: value.origin, - config_fingerprint: value.config_fingerprint, - state: value.state, - last_successful_state: value.last_successful_state, - transport_security: value.transport_security, - observed_at_unix_ms: value.observed_at_unix_ms, - http_status: value.http_status, - error_code: value.error_code, - server_error_code: value.server_error_code, - error_phase: value.error_phase, - retryable: value.retryable, - possible_orphan: value.possible_orphan, - attempts: value.attempts, - } - } -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<SdkRelayStatusReportRecord> for FfiRelayStatusReportRecord { - fn from(value: SdkRelayStatusReportRecord) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - profile: value.profile, - state: value.state, - read_availability: value.read_availability, - write_availability: value.write_availability, - relays: value.relays.into_iter().map(Into::into).collect(), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiShutdownRecord { - pub schema_version: u16, - pub state: String, - pub already_closed: bool, -} - -#[cfg_attr(coverage_nightly, coverage(off))] -impl From<SdkShutdownRecord> for FfiShutdownRecord { - fn from(value: SdkShutdownRecord) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - state: value.state, - already_closed: value.already_closed, - } - } -} - -pub(crate) fn decode_id(value: &str, code: &'static str) -> Result<[u8; 16], RadrootsAppError> { - if value.len() != 32 { - return Err(RadrootsAppError::invalid_argument(code)); - } - let bytes = hex::decode(value).map_err(|_| RadrootsAppError::invalid_argument(code))?; - bytes - .try_into() - .map_err(|_| RadrootsAppError::invalid_argument(code)) -} - -fn require_schema(schema_version: u16) -> Result<(), RadrootsAppError> { - if schema_version == MOBILE_FFI_SCHEMA_VERSION { - Ok(()) - } else { - Err(RadrootsAppError::invalid_argument( - "unsupported_schema_version", - )) - } -} - -#[cfg(test)] -mod tests { - use core::num::NonZeroU64; - use std::io::Write; - use std::os::fd::AsRawFd; - - use radroots_event::id::TradeId; - - use super::*; - - fn rhi_attestation_fixture() -> serde_json::Value { - let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../contracts/conformance/vectors/event/authored_operations.v1.json" - )) - .expect("authored corpus"); - fixture["vectors"] - .as_array() - .expect("operations") - .iter() - .find(|entry| entry["id"] == "typed_rhi_evidence_attestation_017") - .expect("RHI operation") - .get("expected") - .expect("expected") - .clone() - } - - #[test] - fn evidence_report_plan_and_verified_event_use_final_mobile_vocabulary() { - let expected = rhi_attestation_fixture(); - let content = expected["content"].as_str().expect("content").to_owned(); - let report = parse_rhi_evidence_report(content.clone()).expect("report"); - assert_eq!(report.outcome, FfiTradeEvidenceOutcome::Indeterminate); - assert_eq!(report.trade_generation, "7"); - assert_eq!(report.observed_at_unix_s, "1800000000"); - - let plan = prepare_rhi_evidence_attestation(content, 1_784_347_200).expect("plan"); - assert_eq!(plan.kind, 3_441); - assert_eq!(plan.created_at_unix_s, "1784347200"); - assert_eq!( - plan.expected_event_id, - expected["event_id"].as_str().expect("event id") - ); - - let raw: serde_json::Value = - serde_json::from_str(expected["raw_json"].as_str().expect("raw event")) - .expect("raw event JSON"); - let signed = FfiSignedEvidenceEventRecord { - id: raw["id"].as_str().expect("id").to_owned(), - author_pubkey: raw["pubkey"].as_str().expect("pubkey").to_owned(), - created_at_unix_s: raw["created_at"].as_u64().expect("created_at"), - kind: u32::try_from(raw["kind"].as_u64().expect("kind")).expect("u32 kind"), - tags: serde_json::from_value(raw["tags"].clone()).expect("tags"), - content: raw["content"].as_str().expect("content").to_owned(), - signature: raw["sig"].as_str().expect("signature").to_owned(), - }; - let attestation = validate_rhi_evidence_attestation(signed).expect("attestation"); - assert_eq!(attestation.outcome, FfiTradeEvidenceOutcome::Indeterminate); - assert_eq!(attestation.trade_generation, "7"); - } - - #[test] - fn evidence_manifest_and_supersession_project_the_complete_vocabulary() { - use radroots_sdk::trade::{ - RadrootsTradeEvidenceManifestSourceResultV1, RadrootsTradeEvidenceManifestV1, - RadrootsTradeEvidencePolicyDigestV1, RadrootsTradeEvidenceScopePrerequisitesV1, - RadrootsTradeEvidenceSourceCompletionV1, RadrootsTradeEvidenceSourceIdV1, - RadrootsTradeEvidenceSourceRequirementV1, RadrootsTradeEvidenceSourceResultDigestV1, - RadrootsTradeEvidenceSourceResultV1, - }; - - assert_eq!( - FfiTradeEvidenceCoverage::from( - radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Missing - ), - FfiTradeEvidenceCoverage::Missing - ); - assert_eq!( - FfiTradeEvidenceCoverage::from( - radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Partial - ), - FfiTradeEvidenceCoverage::Partial - ); - assert_eq!( - FfiTradeEvidenceCoverage::from( - radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::ScopeSatisfied - ), - FfiTradeEvidenceCoverage::ScopeSatisfied - ); - assert_eq!( - FfiTradeEvidenceCoverage::from( - radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Unsupported - ), - FfiTradeEvidenceCoverage::Unsupported - ); - assert_eq!( - FfiTradeEvidenceOutcome::from( - radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Valid - ), - FfiTradeEvidenceOutcome::Valid - ); - assert_eq!( - FfiTradeEvidenceOutcome::from( - radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Invalid - ), - FfiTradeEvidenceOutcome::Invalid - ); - assert_eq!( - FfiTradeEvidenceOutcome::from( - radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Indeterminate - ), - FfiTradeEvidenceOutcome::Indeterminate - ); - - let source = RadrootsTradeEvidenceManifestSourceResultV1::new( - RadrootsTradeEvidenceSourceIdV1::parse("typed_source").expect("source id"), - RadrootsTradeEvidenceSourceResultV1::new( - RadrootsTradeEvidenceSourceRequirementV1::Required, - RadrootsTradeEvidenceSourceCompletionV1::Complete, - 0, - ) - .expect("source result"), - RadrootsTradeEvidenceSourceResultDigestV1::from_bytes([0x33; 32]), - ); - let manifest = RadrootsTradeEvidenceManifestV1::new( - TradeId::from_bytes([0x11; 16]), - NonZeroU64::new(1).expect("nonzero generation"), - RadrootsTradeEvidencePolicyDigestV1::from_bytes([0x22; 32]), - 1_800_000_000, - RadrootsTradeEvidenceScopePrerequisitesV1::Satisfied, - [source], - [], - ) - .expect("manifest"); - let projected = parse_trade_evidence_manifest(manifest.canonical_bytes().to_vec()) - .expect("manifest projection"); - assert_eq!(projected.coverage, FfiTradeEvidenceCoverage::ScopeSatisfied); - assert_eq!( - projected.canonical_bytes_hex, - hex::encode(manifest.canonical_bytes()) - ); - - let decisions: serde_json::Value = serde_json::from_str(include_str!( - "../../../contracts/conformance/vectors/rhi/evidence_attestation_decision.v1.json" - )) - .expect("RHI decision corpus"); - let superseding = decisions["vectors"] - .as_array() - .expect("RHI decision vectors") - .iter() - .find(|entry| entry["id"] == "rhi_evidence_attestation_superseding_002") - .expect("superseding vector"); - let report = parse_rhi_evidence_report( - superseding["expected"]["canonical_event_content_utf8"] - .as_str() - .expect("canonical event content") - .to_owned(), - ) - .expect("superseding report"); - assert_eq!(report.outcome, FfiTradeEvidenceOutcome::Valid); - assert_eq!( - report.supersedes_report_id.as_deref(), - Some("7777777777777777777777777777777777777777777777777777777777777777") - ); - assert_eq!( - report.supersedes_event_id.as_deref(), - Some("8888888888888888888888888888888888888888888888888888888888888888") - ); - } - - #[test] - fn transport_policy_enums_map_every_closed_variant() { - assert_eq!( - FfiCancellationPolicy::PreservePublishedRequest.core(), - Phase1CancellationPolicy::PreservePublishedRequest - ); - assert_eq!( - FfiCancellationPolicy::LocalCooperative.core(), - Phase1CancellationPolicy::LocalCooperative - ); - assert_eq!( - radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::Native), - radroots_sdk::transport::BlossomHostKind::Native - ); - assert_eq!( - radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::Simulator), - radroots_sdk::transport::BlossomHostKind::Simulator - ); - assert_eq!( - radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::PhysicalDevice), - radroots_sdk::transport::BlossomHostKind::PhysicalDevice - ); - } - - #[test] - fn evidence_errors_do_not_render_untrusted_content() { - let secret = "mobile-private-evidence"; - let error = parse_rhi_evidence_report(secret.to_owned()).expect_err("malformed report"); - assert!(!error.to_string().contains(secret)); - assert!(!format!("{error:?}").contains(secret)); - } - - fn png(width: u32, height: u32) -> Vec<u8> { - let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); - bytes.extend_from_slice(&width.to_be_bytes()); - bytes.extend_from_slice(&height.to_be_bytes()); - bytes - } - - fn blossom_slot() -> radroots_sdk::transport::BlossomSlot { - let profile = radroots_sdk::transport::BlossomProfile::new( - radroots_sdk::transport::BlossomHostKind::Simulator, - radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment, - "http://127.0.0.1:3000", - std::iter::empty::<&str>(), - ) - .unwrap(); - let slot = radroots_sdk::transport::BlossomSlot::new(); - slot.configure(radroots_sdk::transport::BlossomConfig::from_profile( - profile, - )) - .unwrap(); - slot - } - - fn photo_input(file_descriptor: u64, bytes: &[u8], digest: String) -> FfiAddDraftInput { - FfiAddDraftInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type: FfiAddCommandType::CreatePhotoUpdate, - content: "Fresh carrots from this morning.".to_owned(), - identifier: None, - title: None, - summary: None, - location: None, - event_timing: None, - event_start_date: None, - event_end_date: None, - event_start_unix_s: None, - event_end_unix_s: None, - event_timezone: None, - price_amount: None, - currency: None, - unit: None, - quantity: None, - food_published_at_unix_s: None, - food_status: None, - media: vec![FfiPreparedMediaInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - opaque_reference: "media:carrots-01".to_owned(), - file_descriptor, - sha256: digest, - media_type: "image/png".to_owned(), - byte_size: bytes.len() as u64, - width: 2, - height: 2, - alt: "A basket of carrots".to_owned(), - prepared_at_unix_s: 1_800_000_000, - }], - } - } - - fn text_input(command_type: FfiAddCommandType) -> FfiAddDraftInput { - FfiAddDraftInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type, - content: "Fresh from the farm".to_owned(), - identifier: None, - title: None, - summary: None, - location: None, - event_timing: None, - event_start_date: None, - event_end_date: None, - event_start_unix_s: None, - event_end_unix_s: None, - event_timezone: None, - price_amount: None, - currency: None, - unit: None, - quantity: None, - food_published_at_unix_s: None, - food_status: None, - media: Vec::new(), - } - } - - #[test] - fn exact_five_add_inputs_build_their_typed_core_commands() { - let (update, media) = text_input(FfiAddCommandType::CreateUpdate) - .command_and_media(1_800_000_000, None) - .expect("update"); - assert!(matches!(update, Phase1AddCommand::CreateUpdate(_))); - assert!(media.is_empty()); - - let (ask, media) = text_input(FfiAddCommandType::CreateAsk) - .command_and_media(1_800_000_000, None) - .expect("ask"); - assert!(matches!(ask, Phase1AddCommand::CreateAsk(_))); - assert!(media.is_empty()); - - let mut all_day = text_input(FfiAddCommandType::CreateEvent); - all_day.identifier = Some("market-day".to_owned()); - all_day.title = Some("Farmers market".to_owned()); - all_day.location = Some("Town square".to_owned()); - all_day.event_timing = Some(FfiEventTimingKind::AllDay); - all_day.event_start_date = Some("2026-08-08".to_owned()); - all_day.event_end_date = Some("2026-08-09".to_owned()); - let (event, media) = all_day - .command_and_media(1_800_000_000, None) - .expect("all-day event"); - assert!(matches!(event, Phase1AddCommand::CreateEvent(_))); - assert!(media.is_empty()); - - let mut timed = text_input(FfiAddCommandType::CreateEvent); - timed.identifier = Some("harvest-tour".to_owned()); - timed.title = Some("Harvest tour".to_owned()); - timed.event_timing = Some(FfiEventTimingKind::Timed); - timed.event_start_unix_s = Some(1_800_000_000); - timed.event_end_unix_s = Some(1_800_003_600); - timed.event_timezone = Some("America/Vancouver".to_owned()); - let (event, media) = timed - .command_and_media(1_800_000_000, None) - .expect("timed event"); - assert!(matches!(event, Phase1AddCommand::CreateEvent(_))); - assert!(media.is_empty()); - - let bytes = png(2, 2); - let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(&bytes).expect("write media"); - file.flush().expect("flush media"); - let digest = Sha256::digest(&bytes).to_hex(); - let mut food = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); - food.command_type = FfiAddCommandType::CreateFoodAvailability; - food.identifier = Some("carrots-2026-08".to_owned()); - food.title = Some("Carrots".to_owned()); - food.summary = Some("Fresh bunches".to_owned()); - food.location = Some("Victoria".to_owned()); - food.price_amount = Some("4.5".to_owned()); - food.currency = Some("CAD".to_owned()); - food.unit = Some("bunch".to_owned()); - food.quantity = Some("12".to_owned()); - food.food_status = Some("active".to_owned()); - let blossom = blossom_slot(); - let (food, media) = food - .command_and_media(1_800_000_000, Some(&blossom)) - .expect("food availability"); - assert!(matches!(food, Phase1AddCommand::CreateFoodAvailability(_))); - assert_eq!(media.len(), 1); - } - - #[test] - fn add_validation_reports_schema_shape_media_and_required_field_failures() { - let mut wrong_schema = text_input(FfiAddCommandType::CreateUpdate); - wrong_schema.schema_version = MOBILE_FFI_SCHEMA_VERSION + 1; - assert_eq!( - wrong_schema - .command_and_media(1_800_000_000, None) - .expect_err("schema") - .report() - .code, - "unsupported_schema_version" - ); - assert_eq!( - text_input(FfiAddCommandType::CreateUpdate) - .command_and_media(0, None) - .expect_err("authored time") - .report() - .code, - "invalid_add_draft" - ); - assert_eq!( - text_input(FfiAddCommandType::CreateEvent) - .command_and_media(1_800_000_000, None) - .expect_err("event identity") - .report() - .code, - "event_identifier_required" - ); - let mut food = text_input(FfiAddCommandType::CreateFoodAvailability); - food.unit = Some("crate".to_owned()); - assert_eq!( - food.command_and_media(1_800_000_000, None) - .expect_err("food unit") - .report() - .code, - "invalid_food_unit" - ); - } - - #[test] - fn prepared_media_accepts_only_the_exact_bounded_file_descriptor_bytes() { - let bytes = png(2, 2); - let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(&bytes).expect("write media"); - file.flush().expect("flush media"); - let digest = Sha256::digest(&bytes).to_hex(); - let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest.clone()); - let blossom = blossom_slot(); - - let (command, media) = input - .command_and_media(1_800_000_000, Some(&blossom)) - .expect("verified media input"); - assert!(matches!(command, Phase1AddCommand::CreatePhotoUpdate(_))); - assert_eq!(media.len(), 1); - assert_eq!( - media[0].url(), - format!("http://127.0.0.1:3000/{digest}.png") - ); - assert_eq!( - file.as_file().metadata().expect("caller-owned media").len(), - bytes.len() as u64 - ); - } - - #[test] - fn prepared_media_rejects_file_descriptors_outside_the_platform_range() { - let bytes = png(2, 2); - let blossom = blossom_slot(); - let input = photo_input(u64::MAX, &bytes, Sha256::digest(&bytes).to_hex()); - - assert_eq!( - input - .command_and_media(1_800_000_000, Some(&blossom)) - .expect_err("out-of-range descriptor") - .report() - .code, - "media_handle_unavailable" - ); - } - - #[cfg(unix)] - #[test] - fn prepared_media_rejects_unavailable_in_range_file_descriptors() { - let bytes = png(2, 2); - let blossom = blossom_slot(); - let input = photo_input(i32::MAX as u64, &bytes, Sha256::digest(&bytes).to_hex()); - - assert_eq!( - input - .command_and_media(1_800_000_000, Some(&blossom)) - .expect_err("unavailable in-range descriptor") - .report() - .code, - "media_handle_unavailable" - ); - } - - #[test] - fn prepared_media_rejects_digest_tamper_and_path_like_references() { - let bytes = png(2, 2); - let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(&bytes).expect("write media"); - file.flush().expect("flush media"); - - let tampered = photo_input( - file.as_file().as_raw_fd() as u64, - &bytes, - Sha256::digest(b"other").to_hex(), - ); - let blossom = blossom_slot(); - assert_eq!( - tampered - .command_and_media(1_800_000_000, Some(&blossom)) - .expect_err("digest mismatch") - .report() - .code, - "media_verification_failed" - ); - - let mut path_like = photo_input( - file.as_file().as_raw_fd() as u64, - &bytes, - Sha256::digest(&bytes).to_hex(), - ); - path_like.media[0].opaque_reference = "file:/private/media.jpg".to_owned(); - assert_eq!( - path_like - .command_and_media(1_800_000_000, Some(&blossom)) - .expect_err("path-like reference") - .report() - .code, - "invalid_media_reference" - ); - } - - #[test] - fn prepared_media_constructs_a_bounded_verified_upload_request() { - let bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x02"; - let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(bytes).expect("write media"); - file.flush().expect("flush media"); - let digest = Sha256::digest(bytes).to_hex(); - let mut input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest.clone()); - input.media[0].media_type = "image/png".to_owned(); - - let prepared = PreparedMedia::try_from(input.media.remove(0)).expect("prepared media"); - let request = prepared - .upload_request(1_800_000_000_000) - .expect("bounded upload request"); - assert_eq!(request.sha256().to_hex(), digest); - assert_eq!(request.byte_size(), bytes.len() as u64); - assert_eq!(request.media_type().as_str(), "image/png"); - assert_eq!(request.dimensions().width(), 2); - assert_eq!(request.dimensions().height(), 2); - } - - #[test] - fn media_validation_executes_every_bounded_shape_guard() { - let bytes = png(2, 2); - let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(&bytes).expect("write media"); - file.flush().expect("flush media"); - let digest = Sha256::digest(&bytes).to_hex(); - let valid = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest).media[0].clone(); - - let mut invalid_values = Vec::new(); - let mut value = valid.clone(); - value.byte_size = 0; - invalid_values.push(value); - let mut value = valid.clone(); - value.byte_size = MEDIA_FILE_MAX_BYTES + 1; - invalid_values.push(value); - let mut value = valid.clone(); - value.width = 0; - invalid_values.push(value); - let mut value = valid.clone(); - value.height = 0; - invalid_values.push(value); - let mut value = valid.clone(); - value.prepared_at_unix_s = 0; - invalid_values.push(value); - let mut value = valid.clone(); - value.alt = " ".to_owned(); - invalid_values.push(value); - let mut value = valid.clone(); - value.alt = "a".repeat(1_025); - invalid_values.push(value); - - for value in invalid_values { - assert_eq!( - PreparedMedia::try_from(value) - .err() - .expect("invalid bounded media") - .report() - .code, - "invalid_media_reference" - ); - } - - let mut wrong_size = valid.clone(); - wrong_size.byte_size += 1; - assert_eq!( - PreparedMedia::try_from(wrong_size) - .err() - .expect("descriptor size") - .report() - .code, - "media_size_mismatch" - ); - for reference in ["media:", "Media:item", "media:BAD", "media:item/path"] { - assert!(!opaque_media_reference_is_valid(reference)); - } - assert!(opaque_media_reference_is_valid("media:item_01-a")); - assert!(!opaque_media_reference_is_valid(&format!( - "media:{}", - "a".repeat(MEDIA_REFERENCE_MAX_BYTES) - ))); - } - - #[test] - fn event_and_post_optional_branches_remain_strict_and_complete() { - let mut minimal_date = text_input(FfiAddCommandType::CreateEvent); - minimal_date.content.clear(); - minimal_date.identifier = Some("minimal-date".to_owned()); - minimal_date.title = Some("Minimal date".to_owned()); - minimal_date.event_timing = Some(FfiEventTimingKind::AllDay); - minimal_date.event_start_date = Some("2026-08-08".to_owned()); - assert!(minimal_date.command_and_media(1_800_000_000, None).is_ok()); - - let mut minimal_time = text_input(FfiAddCommandType::CreateEvent); - minimal_time.content.clear(); - minimal_time.identifier = Some("minimal-time".to_owned()); - minimal_time.title = Some("Minimal time".to_owned()); - minimal_time.event_timing = Some(FfiEventTimingKind::Timed); - minimal_time.event_start_unix_s = Some(1_800_000_000); - assert!(minimal_time.command_and_media(1_800_000_000, None).is_ok()); - - let bytes = png(2, 2); - let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(&bytes).expect("write media"); - file.flush().expect("flush media"); - let digest = Sha256::digest(&bytes).to_hex(); - let mut event = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); - event.command_type = FfiAddCommandType::CreateEvent; - event.identifier = Some("event-image".to_owned()); - event.title = Some("Event image".to_owned()); - event.event_timing = Some(FfiEventTimingKind::Timed); - event.event_start_unix_s = Some(1_800_000_000); - let blossom = blossom_slot(); - assert!( - event - .clone() - .command_and_media(1_800_000_000, Some(&blossom)) - .is_ok() - ); - - let mut second = event.media[0].clone(); - second.opaque_reference = "media:event-image-two".to_owned(); - event.media.push(second); - assert_eq!( - event - .command_and_media(1_800_000_000, Some(&blossom)) - .expect_err("event media limit") - .report() - .code, - "event_image_limit" - ); - } - - #[test] - fn content_references_and_identifier_decoding_cover_all_outcomes() { - let bytes = png(2, 2); - let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(&bytes).expect("write media"); - file.flush().expect("flush media"); - let digest = Sha256::digest(&bytes).to_hex(); - let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); - let blossom = blossom_slot(); - let prepared = PreparedMedia::try_from(input.media[0].clone()) - .expect("prepared media") - .bind(&blossom) - .expect("bound media"); - let url = prepared.descriptor.url().as_str().to_owned(); - - assert_eq!( - content_with_media_references(" ".to_owned(), std::slice::from_ref(&prepared)) - .expect_err("blank content") - .report() - .code, - "content_required" - ); - assert_eq!( - content_with_media_references( - format!("caption\n{url}"), - std::slice::from_ref(&prepared) - ) - .expect("one existing reference") - .match_indices(&url) - .count(), - 1 - ); - assert!( - content_with_media_references("caption\n".to_owned(), std::slice::from_ref(&prepared)) - .expect("newline append") - .ends_with(&url) - ); - assert_eq!( - content_with_media_references( - format!("{url}\n{url}"), - std::slice::from_ref(&prepared), - ) - .expect_err("duplicate reference") - .report() - .code, - "duplicate_media_reference" - ); - - let mut update = text_input(FfiAddCommandType::CreateUpdate); - update.media.push(input.media[0].clone()); - assert_eq!( - update - .command_and_media(1_800_000_000, Some(&blossom)) - .expect_err("update media") - .report() - .code, - "media_not_allowed" - ); - let mut over_limit = text_input(FfiAddCommandType::CreateUpdate); - over_limit.media = vec![input.media[0].clone(); 21]; - assert_eq!( - over_limit - .command_and_media(1_800_000_000, None) - .expect_err("media count") - .report() - .code, - "invalid_add_draft" - ); - - assert_eq!(decode_id(&"01".repeat(16), "bad").expect("id"), [1; 16]); - assert_eq!( - decode_id("01", "bad").expect_err("short id").report().code, - "bad" - ); - assert_eq!( - decode_id(&"gg".repeat(16), "bad") - .expect_err("non-hex id") - .report() - .code, - "bad" - ); - } -} diff --git a/crates/mobile_ffi/src/error.rs b/crates/mobile_ffi/src/error.rs @@ -1,483 +0,0 @@ -use radroots_mobile_core::runtime::product_surface::{ - Phase1DraftError, ProfileMetadataError, SettingsError, TodayError, -}; -use thiserror::Error; - -use crate::MOBILE_FFI_SCHEMA_VERSION; - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct RadrootsErrorRecord { - pub schema_version: u16, - pub code: String, - pub category: String, - pub retryable: bool, - pub recovery_actions: Vec<String>, - pub operation_id: Option<String>, - pub capability_id: Option<String>, - pub safe_message: String, -} - -/// The only error envelope exported across the native language boundary. -#[derive(Debug, Error, uniffi::Error)] -pub enum RadrootsAppError { - #[error("radroots operation failed: {report:?}")] - Failure { report: RadrootsErrorRecord }, -} - -impl RadrootsAppError { - pub(crate) fn initialization(_message: impl Into<String>) -> Self { - Self::failure( - "initialization_failed", - "initialization", - true, - &["retry"], - "The Radroots runtime could not be initialized.", - ) - } - - pub(crate) fn invalid_argument(code: impl Into<String>) -> Self { - Self::Failure { - report: RadrootsErrorRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - code: code.into(), - category: "validation".to_owned(), - retryable: false, - recovery_actions: vec!["correct_input".to_owned()], - operation_id: None, - capability_id: None, - safe_message: "The request is invalid.".to_owned(), - }, - } - } - - pub(crate) fn failure( - code: &str, - category: &str, - retryable: bool, - recovery_actions: &[&str], - safe_message: &str, - ) -> Self { - Self::Failure { - report: RadrootsErrorRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - code: code.to_owned(), - category: category.to_owned(), - retryable, - recovery_actions: recovery_actions - .iter() - .map(|action| (*action).to_owned()) - .collect(), - operation_id: None, - capability_id: None, - safe_message: safe_message.to_owned(), - }, - } - } - - pub fn report(&self) -> &RadrootsErrorRecord { - match self { - Self::Failure { report } => report, - } - } - - pub(crate) fn with_operation_id(mut self, operation_id: String) -> Self { - match &mut self { - Self::Failure { report } => report.operation_id = Some(operation_id), - } - self - } -} - -impl From<radroots_mobile_core::RadrootsAppError> for RadrootsAppError { - fn from(error: radroots_mobile_core::RadrootsAppError) -> Self { - match error { - radroots_mobile_core::RadrootsAppError::Sdk { report } => Self::Failure { - report: RadrootsErrorRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - code: report.code, - category: report.class, - retryable: report.retryable, - recovery_actions: report.recovery_actions, - operation_id: report.operation_id, - capability_id: report.capability_id, - safe_message: report.message, - }, - }, - radroots_mobile_core::RadrootsAppError::Store { report } => Self::Failure { - report: RadrootsErrorRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - code: report.code, - category: report.class, - retryable: report.retryable, - recovery_actions: report.recovery_actions, - operation_id: None, - capability_id: None, - safe_message: report.message, - }, - }, - radroots_mobile_core::RadrootsAppError::Initialization(_) => { - Self::initialization("redacted") - } - radroots_mobile_core::RadrootsAppError::Runtime(_) => Self::failure( - "runtime_failed", - "runtime", - true, - &["retry"], - "The runtime operation failed.", - ), - radroots_mobile_core::RadrootsAppError::Unsupported(_) => Self::failure( - "unsupported", - "capability", - false, - &[], - "The requested capability is unsupported.", - ), - radroots_mobile_core::RadrootsAppError::Internal(_) => Self::failure( - "internal_failure", - "internal", - false, - &["restart"], - "An internal Radroots error occurred.", - ), - } - } -} - -impl From<TodayError> for RadrootsAppError { - fn from(error: TodayError) -> Self { - let (code, retryable, actions) = match error { - TodayError::InvalidRequest | TodayError::EventNotVisible => { - ("today_invalid_request", false, &["correct_input"][..]) - } - TodayError::ProjectionMissing | TodayError::SnapshotMissing => { - ("today_refresh_required", true, &["refresh"][..]) - } - TodayError::CursorPositionMissing | TodayError::Cursor(_) => { - ("today_cursor_invalid", true, &["restart_pagination"][..]) - } - TodayError::RuntimeUnavailable => ("today_runtime_unavailable", true, &["retry"][..]), - TodayError::InboundMedia(_) => ("today_media_invalid", false, &["retry_media"][..]), - TodayError::InboundRetrieval(error) => { - if error.retryable() { - ("today_media_retrieval_failed", true, &["retry_media"][..]) - } else { - ("today_media_retrieval_failed", false, &["review_media"][..]) - } - } - TodayError::CorruptProjection | TodayError::Serialization | TodayError::Storage(_) => { - ("today_state_failed", true, &["rebuild", "retry"][..]) - } - }; - Self::failure( - code, - "today", - retryable, - actions, - "The Today operation could not be completed.", - ) - } -} - -impl From<Phase1DraftError> for RadrootsAppError { - fn from(error: Phase1DraftError) -> Self { - let (code, retryable, actions) = match error { - Phase1DraftError::IdentityUnavailable => ( - "identity_unavailable", - true, - &["unlock_identity", "retry"][..], - ), - Phase1DraftError::InvalidDraft => ("draft_invalid", false, &["correct_input"][..]), - Phase1DraftError::InvalidMedia => { - ("draft_media_invalid", false, &["replace_media"][..]) - } - Phase1DraftError::InvalidQueuePolicy => { - ("draft_queue_policy_invalid", false, &["correct_input"][..]) - } - Phase1DraftError::RevisionConflict => { - ("draft_revision_conflict", true, &["refresh"][..]) - } - Phase1DraftError::NotFound => ("draft_not_found", false, &[][..]), - Phase1DraftError::Terminal => ("draft_terminal", false, &[][..]), - Phase1DraftError::MediaNotReady => { - ("draft_media_not_ready", true, &["retry_media"][..]) - } - Phase1DraftError::OperationUnavailable => { - ("authoring_unavailable", true, &["retry"][..]) - } - Phase1DraftError::Operation | Phase1DraftError::Storage | Phase1DraftError::Overlay => { - ("authoring_failed", true, &["retry", "inspect_outbox"][..]) - } - Phase1DraftError::Corrupt => ("draft_corrupt", false, &["recover_draft"][..]), - Phase1DraftError::ClockUnavailable => { - ("operation_clock_unavailable", true, &["retry"][..]) - } - Phase1DraftError::DeadlineOverflow => ("operation_deadline_overflow", false, &[][..]), - Phase1DraftError::NoWritableRelay => ( - "writable_relay_unavailable", - true, - &["configure_relay", "retry"][..], - ), - Phase1DraftError::InvalidRevision => { - ("revision_invalid", false, &["review_revision"][..]) - } - }; - Self::failure( - code, - "authoring", - retryable, - actions, - "The authored operation could not be completed.", - ) - } -} - -impl From<SettingsError> for RadrootsAppError { - fn from(error: SettingsError) -> Self { - let retryable = matches!( - error, - SettingsError::RevisionConflict - | SettingsError::RevisionExhausted - | SettingsError::Storage - ); - let actions = if matches!(error, SettingsError::RevisionConflict) { - &["refresh"] as &[&str] - } else if retryable { - &["retry"] as &[&str] - } else { - &["correct_input"] as &[&str] - }; - Self::failure( - error.code(), - "settings", - retryable, - actions, - "The settings operation could not be completed.", - ) - } -} - -impl From<ProfileMetadataError> for RadrootsAppError { - fn from(error: ProfileMetadataError) -> Self { - Self::failure( - error.code(), - "profile", - false, - &["correct_input"], - "The profile metadata is invalid.", - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn internal_core_messages_are_not_copied_to_the_ffi_record() { - let error = RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::internal( - "private/path/secret-value", - )); - assert_eq!(error.report().code, "internal_failure"); - assert!(!format!("{error:?}").contains("secret-value")); - assert!(!error.report().safe_message.contains("secret-value")); - } - - #[test] - fn every_core_error_class_maps_to_a_versioned_redacted_record() { - let sdk = radroots_mobile_core::RadrootsAppError::Sdk { - report: radroots_mobile_core::SdkErrorRecord { - schema_version: 1, - code: "relay_unavailable".to_owned(), - class: "transport".to_owned(), - retryable: true, - recovery_actions: vec!["retry".to_owned()], - operation_id: Some("operation".to_owned()), - capability_id: Some("relay".to_owned()), - message: "Safe relay failure".to_owned(), - }, - }; - let sdk = RadrootsAppError::from(sdk); - assert_eq!(sdk.report().code, "relay_unavailable"); - assert_eq!(sdk.report().operation_id.as_deref(), Some("operation")); - - let store = radroots_mobile_core::RadrootsAppError::Store { - report: radroots_mobile_core::StoreErrorRecord { - schema_version: 1, - code: "store_locked".to_owned(), - class: "storage".to_owned(), - retryable: true, - recovery_actions: vec!["unlock".to_owned()], - message: "Safe store failure".to_owned(), - }, - }; - let store = RadrootsAppError::from(store); - assert_eq!(store.report().code, "store_locked"); - assert!(store.report().operation_id.is_none()); - - for (core, code, category) in [ - ( - radroots_mobile_core::RadrootsAppError::initialization("secret"), - "initialization_failed", - "initialization", - ), - ( - radroots_mobile_core::RadrootsAppError::runtime("secret"), - "runtime_failed", - "runtime", - ), - ( - radroots_mobile_core::RadrootsAppError::unsupported("secret"), - "unsupported", - "capability", - ), - ( - radroots_mobile_core::RadrootsAppError::internal("secret"), - "internal_failure", - "internal", - ), - ] { - let ffi = RadrootsAppError::from(core); - assert_eq!(ffi.report().code, code); - assert_eq!(ffi.report().category, category); - assert!(!ffi.report().safe_message.contains("secret")); - } - } - - #[test] - fn today_and_draft_failures_have_stable_recovery_classes() { - for error in [ - TodayError::InvalidRequest, - TodayError::EventNotVisible, - TodayError::ProjectionMissing, - TodayError::SnapshotMissing, - TodayError::CursorPositionMissing, - TodayError::RuntimeUnavailable, - TodayError::CorruptProjection, - TodayError::Serialization, - ] { - let ffi = RadrootsAppError::from(error); - assert_eq!(ffi.report().category, "today"); - assert!(!ffi.report().safe_message.is_empty()); - } - - for error in [ - Phase1DraftError::IdentityUnavailable, - Phase1DraftError::InvalidDraft, - Phase1DraftError::InvalidMedia, - Phase1DraftError::InvalidQueuePolicy, - Phase1DraftError::RevisionConflict, - Phase1DraftError::NotFound, - Phase1DraftError::Terminal, - Phase1DraftError::MediaNotReady, - Phase1DraftError::OperationUnavailable, - Phase1DraftError::Operation, - Phase1DraftError::Storage, - Phase1DraftError::Overlay, - Phase1DraftError::Corrupt, - Phase1DraftError::ClockUnavailable, - Phase1DraftError::DeadlineOverflow, - Phase1DraftError::NoWritableRelay, - Phase1DraftError::InvalidRevision, - ] { - let ffi = RadrootsAppError::from(error); - assert_eq!(ffi.report().category, "authoring"); - assert!(!ffi.report().safe_message.is_empty()); - } - } - - #[test] - fn cursor_media_settings_and_profile_failures_cover_every_stable_class() { - use radroots_mobile_core::runtime::product_surface::{ - CursorError, IdentitySettingsError, Phase1InboundMediaError, - }; - - for cursor in [ - CursorError::InvalidContext, - CursorError::Malformed, - CursorError::Integrity, - CursorError::Version, - CursorError::ContextMismatch, - CursorError::SnapshotMismatch, - CursorError::Stale, - CursorError::InvalidPosition, - ] { - let error = RadrootsAppError::from(TodayError::Cursor(cursor)); - assert_eq!(error.report().code, "today_cursor_invalid"); - } - for media in [ - Phase1InboundMediaError::InvalidReference, - Phase1InboundMediaError::InvalidDigest, - Phase1InboundMediaError::MissingDigest, - Phase1InboundMediaError::InvalidMediaType, - Phase1InboundMediaError::InvalidDimensions, - Phase1InboundMediaError::InvalidByteSize, - Phase1InboundMediaError::InvalidAlt, - Phase1InboundMediaError::MetadataMismatch, - Phase1InboundMediaError::InvalidOperation, - Phase1InboundMediaError::OperationMismatch, - Phase1InboundMediaError::InvalidFailure, - Phase1InboundMediaError::InvalidConfiguration, - Phase1InboundMediaError::ConfigurationMismatch, - Phase1InboundMediaError::InvalidVerificationTime, - Phase1InboundMediaError::InvalidCachePolicy, - Phase1InboundMediaError::InvalidCacheObservation, - Phase1InboundMediaError::CacheQuotaExceeded, - Phase1InboundMediaError::ArtifactCollision, - Phase1InboundMediaError::CorruptReceipt, - Phase1InboundMediaError::CorruptState, - Phase1InboundMediaError::UnsupportedSchema, - Phase1InboundMediaError::CacheUnavailable, - Phase1InboundMediaError::CacheIo, - Phase1InboundMediaError::CorruptArtifact, - ] { - let error = RadrootsAppError::from(TodayError::InboundMedia(media)); - assert_eq!(error.report().code, "today_media_invalid"); - } - for settings in [ - SettingsError::UnknownRelayAccess, - SettingsError::InvalidRelayEndpoint, - SettingsError::InvalidRelayEndpointCount, - SettingsError::DuplicateRelayEndpoint, - SettingsError::InvalidBlossomEndpoint, - SettingsError::InvalidBlossomEndpointCount, - SettingsError::NetworkEnvironmentMismatch, - SettingsError::InvalidMediaCacheBytes, - SettingsError::InvalidMediaCacheArtifacts, - SettingsError::RevisionConflict, - SettingsError::RevisionExhausted, - SettingsError::UnsupportedSchema, - SettingsError::CorruptDocument, - SettingsError::Storage, - SettingsError::Identity(IdentitySettingsError::InvalidIdentityId), - ] { - let expected_retryable = matches!( - settings, - SettingsError::RevisionConflict - | SettingsError::RevisionExhausted - | SettingsError::Storage - ); - let error = RadrootsAppError::from(settings); - assert_eq!(error.report().retryable, expected_retryable); - } - for profile in [ - ProfileMetadataError::InvalidName, - ProfileMetadataError::InvalidDisplayName, - ProfileMetadataError::InvalidAbout, - ProfileMetadataError::InvalidNip05, - ] { - assert_eq!(RadrootsAppError::from(profile).report().category, "profile"); - } - assert_eq!( - RadrootsAppError::initialization("private").report().code, - "initialization_failed" - ); - assert_eq!( - RadrootsAppError::invalid_argument("bad") - .with_operation_id("operation".to_owned()) - .report() - .operation_id - .as_deref(), - Some("operation") - ); - } -} diff --git a/crates/mobile_ffi/src/lib.rs b/crates/mobile_ffi/src/lib.rs @@ -1,46 +0,0 @@ -// UniFFI serializes the complete versioned error record across the language -// boundary; keeping it by value preserves the generated wire contract. -#![allow(clippy::result_large_err)] -#![cfg_attr(coverage_nightly, feature(coverage_attribute))] - -uniffi::setup_scaffolding!("radroots_mobile_core"); - -mod dto; -pub mod logging; -mod operations; -mod runtime; -mod signer; -mod subscription; - -pub use dto::*; -pub use error::{RadrootsAppError, RadrootsErrorRecord}; -pub use operations::*; -pub use runtime::{ProtectedDataAvailability, RadrootsRuntime}; -pub use signer::{ - HostSigningOutcome, HostSigningPurpose, HostSigningRequest, HostSigningResult, - RadrootsHostSigner, SignerAvailabilityRecord, SignerStatusRecord, -}; -pub use subscription::{ - FfiRuntimeChangeKind, FfiRuntimeChangeRecord, FfiSubscriptionHandle, RadrootsRuntimeObserver, -}; - -mod error; - -#[allow( - clippy::if_same_then_else, - reason = "coverage probe intentionally exercises both paths with a stable value" -)] -pub fn coverage_branch_probe(input: bool) -> &'static str { - if input { "ffi" } else { "ffi" } -} - -#[cfg(test)] -mod tests { - use super::coverage_branch_probe; - - #[test] - fn coverage_branch_probe_hits_both_paths() { - assert_eq!(coverage_branch_probe(true), "ffi"); - assert_eq!(coverage_branch_probe(false), "ffi"); - } -} diff --git a/crates/mobile_ffi/src/logging.rs b/crates/mobile_ffi/src/logging.rs @@ -1,284 +0,0 @@ -mod writer; - -use std::fs; -use std::path::{Component, Path, PathBuf}; -use std::sync::Mutex; - -use tracing_appender::non_blocking::WorkerGuard; -use tracing_subscriber::prelude::*; - -use self::writer::{LogRotation, SizeRotatingWriter}; - -#[derive(Debug, Clone, PartialEq, Eq)] -struct LoggingOptions { - dir: Option<PathBuf>, - file_name: String, - stdout: bool, - rotation: LogRotation, -} - -impl Default for LoggingOptions { - fn default() -> Self { - Self { - dir: None, - file_name: "radroots.log".to_owned(), - stdout: true, - rotation: LogRotation::default(), - } - } -} - -struct ActiveLogging { - options: LoggingOptions, - _file_guard: Option<WorkerGuard>, -} - -static LOGGING: Mutex<Option<ActiveLogging>> = Mutex::new(None); - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn init_logging( - dir: Option<String>, - file_name: Option<String>, - is_stdout: Option<bool>, -) -> Result<(), crate::RadrootsAppError> { - let opts = logging_options(dir, file_name, is_stdout); - initialize(opts).map_err(crate::RadrootsAppError::initialization) -} - -fn logging_options( - dir: Option<String>, - file_name: Option<String>, - is_stdout: Option<bool>, -) -> LoggingOptions { - LoggingOptions { - dir: dir.map(PathBuf::from), - file_name: file_name.unwrap_or_else(|| "radroots.log".to_string()), - stdout: is_stdout.unwrap_or(true), - ..LoggingOptions::default() - } -} - -fn initialize(options: LoggingOptions) -> Result<(), String> { - validate_options(&options)?; - let mut active = LOGGING - .lock() - .map_err(|_| "logging initialization state is poisoned".to_owned())?; - if let Some(existing) = active.as_ref() { - return if existing.options == options { - Ok(()) - } else { - Err("logging is already initialized with a different configuration".to_owned()) - }; - } - - let (file_writer, file_guard) = build_file_writer(&options)?; - let file_layer = file_writer.as_ref().map(|writer| { - tracing_subscriber::fmt::layer() - .with_writer(writer.clone()) - .with_ansi(false) - .with_target(false) - }); - let stdout_layer = options.stdout.then(|| { - tracing_subscriber::fmt::layer() - .with_writer(std::io::stdout) - .with_target(false) - }); - tracing_subscriber::registry() - .with(file_layer) - .with(stdout_layer) - .try_init() - .map_err(|error| error.to_string())?; - *active = Some(ActiveLogging { - options, - _file_guard: file_guard, - }); - Ok(()) -} - -fn validate_options(options: &LoggingOptions) -> Result<(), String> { - if options.dir.is_none() && !options.stdout { - return Err("logging requires at least one configured output".to_owned()); - } - if options.file_name.is_empty() - || Path::new(&options.file_name).components().count() != 1 - || !matches!( - Path::new(&options.file_name).components().next(), - Some(Component::Normal(_)) - ) - { - return Err("log file_name must be a safe basename".to_owned()); - } - Ok(()) -} - -type FileWriter = tracing_appender::non_blocking::NonBlocking; - -fn build_file_writer( - options: &LoggingOptions, -) -> Result<(Option<FileWriter>, Option<WorkerGuard>), String> { - #[cfg(windows)] - if options.dir.is_some() { - return Err( - "secure file logging is unavailable until Windows ACL and reparse-point enforcement is active" - .to_owned(), - ); - } - let Some(dir) = options.dir.as_ref() else { - return Ok((None, None)); - }; - fs::create_dir_all(dir).map_err(|error| error.to_string())?; - let metadata = fs::symlink_metadata(dir).map_err(|error| error.to_string())?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err("log directory is not a safe directory".to_owned()); - } - let writer = SizeRotatingWriter::new(dir.join(&options.file_name), options.rotation) - .map_err(|error| error.to_string())?; - let (writer, guard) = tracing_appender::non_blocking::NonBlockingBuilder::default() - .buffered_lines_limit(8192) - .lossy(false) - .thread_name("radroots-app-log-writer") - .finish(writer); - Ok((Some(writer), Some(guard))) -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn init_logging_stdout() -> Result<(), crate::RadrootsAppError> { - initialize(LoggingOptions::default()).map_err(crate::RadrootsAppError::initialization) -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn log_info(msg: String) -> Result<(), crate::RadrootsAppError> { - tracing::info!("{msg}"); - Ok(()) -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn log_error(msg: String) -> Result<(), crate::RadrootsAppError> { - tracing::error!("{msg}"); - Ok(()) -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn log_debug(msg: String) -> Result<(), crate::RadrootsAppError> { - tracing::debug!("{msg}"); - Ok(()) -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::{ - LogRotation, LoggingOptions, build_file_writer, initialize, log_debug, log_error, log_info, - logging_options, validate_options, - }; - use std::path::PathBuf; - - #[test] - fn logging_options_adopt_bounded_library_defaults() { - let options = logging_options( - Some("logs".to_owned()), - Some("mobile.log".to_owned()), - Some(false), - ); - - assert_eq!(options.dir, Some(PathBuf::from("logs"))); - assert_eq!(options.file_name, "mobile.log"); - assert!(!options.stdout); - assert_eq!(options.rotation, LogRotation::default()); - } - - #[test] - fn logging_options_preserve_public_api_defaults() { - let options = logging_options(None, None, None); - - assert_eq!(options.file_name, "radroots.log"); - assert!(options.stdout); - assert_eq!(options, LoggingOptions::default()); - } - - #[test] - fn validation_rejects_missing_outputs_and_unsafe_names() { - let mut options = LoggingOptions { - stdout: false, - ..LoggingOptions::default() - }; - assert!(validate_options(&options).is_err()); - - options.stdout = true; - for name in ["", "../radroots.log", "nested/radroots.log", "."] { - options.file_name = name.to_owned(); - assert!(validate_options(&options).is_err(), "accepted {name:?}"); - } - - options.dir = Some(PathBuf::from("logs")); - options.file_name = "radroots.log".to_owned(); - options.stdout = false; - validate_options(&options).expect("file output is sufficient"); - } - - #[test] - fn file_writer_is_optional_and_rejects_non_directories() { - let options = LoggingOptions::default(); - let (writer, guard) = build_file_writer(&options).expect("stdout only"); - assert!(writer.is_none()); - assert!(guard.is_none()); - - let directory = tempfile::tempdir().expect("temporary directory"); - let file = directory.path().join("not-a-directory"); - std::fs::write(&file, b"not a directory").expect("fixture"); - let options = LoggingOptions { - dir: Some(file), - ..LoggingOptions::default() - }; - assert!(build_file_writer(&options).is_err()); - - #[cfg(unix)] - { - let target = directory.path().join("real-directory"); - std::fs::create_dir(&target).expect("real directory"); - let link = directory.path().join("linked-directory"); - std::os::unix::fs::symlink(&target, &link).expect("directory symlink"); - let options = LoggingOptions { - dir: Some(link), - ..LoggingOptions::default() - }; - assert!(build_file_writer(&options).is_err()); - } - } - - #[test] - fn logging_entry_points_accept_secret_safe_messages() { - log_info("info".to_owned()).expect("info"); - log_error("error".to_owned()).expect("error"); - log_debug("debug".to_owned()).expect("debug"); - } - - #[cfg(windows)] - #[test] - fn windows_file_logging_fails_before_filesystem_mutation() { - let directory = tempfile::tempdir().expect("temporary directory"); - let requested = directory.path().join("must-not-exist"); - let options = LoggingOptions { - dir: Some(requested.clone()), - stdout: false, - ..LoggingOptions::default() - }; - let error = build_file_writer(&options).expect_err("ACL capability must fail closed"); - assert!(error.contains("ACL")); - assert!(error.contains("reparse-point")); - assert!(!requested.exists()); - } - - #[test] - fn initialization_is_idempotent_but_rejects_reconfiguration() { - let directory = tempfile::tempdir().expect("temporary directory"); - let options = LoggingOptions { - dir: Some(directory.path().to_owned()), - stdout: false, - ..LoggingOptions::default() - }; - initialize(options.clone()).expect("first initialization"); - initialize(options).expect("idempotent initialization"); - assert!(initialize(LoggingOptions::default()).is_err()); - } -} diff --git a/crates/mobile_ffi/src/logging/writer.rs b/crates/mobile_ffi/src/logging/writer.rs @@ -1,436 +0,0 @@ -use std::ffi::{OsStr, OsString}; -#[cfg(any(test, not(unix)))] -use std::fs; -use std::fs::File; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; - -const DEFAULT_MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; -const DEFAULT_RETAINED_FILES: usize = 5; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct LogRotation { - max_file_bytes: u64, - retained_files: usize, -} - -impl Default for LogRotation { - fn default() -> Self { - Self { - max_file_bytes: DEFAULT_MAX_FILE_BYTES, - retained_files: DEFAULT_RETAINED_FILES, - } - } -} - -pub(super) struct SizeRotatingWriter { - #[cfg(not(unix))] - path: PathBuf, - #[cfg(unix)] - directory: File, - file_name: OsString, - file: Option<File>, - bytes_written: u64, - policy: LogRotation, -} - -impl SizeRotatingWriter { - pub(super) fn new(path: PathBuf, policy: LogRotation) -> io::Result<Self> { - let file_name = path - .file_name() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "log target has no name"))? - .to_owned(); - #[cfg(unix)] - let directory = open_secure_directory(path.parent().ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "log target has no parent") - })?)?; - #[cfg(unix)] - let file = open_append_at(&directory, &file_name)?; - #[cfg(not(unix))] - let file = open_append(&path)?; - let bytes_written = file.metadata()?.len(); - let mut writer = Self { - #[cfg(not(unix))] - path, - #[cfg(unix)] - directory, - file_name, - file: Some(file), - bytes_written, - policy, - }; - if writer.bytes_written >= writer.policy.max_file_bytes { - writer.rotate()?; - } - Ok(writer) - } - - fn rotate(&mut self) -> io::Result<()> { - if let Some(mut file) = self.file.take() { - file.flush()?; - file.sync_data()?; - } - if self.policy.retained_files == 1 { - self.remove_if_present(&self.file_name)?; - } else { - self.remove_if_present(&rotated_name( - &self.file_name, - self.policy.retained_files - 1, - ))?; - for index in (2..self.policy.retained_files).rev() { - self.rename_if_present( - &rotated_name(&self.file_name, index - 1), - &rotated_name(&self.file_name, index), - )?; - } - self.rename_if_present(&self.file_name, &rotated_name(&self.file_name, 1))?; - } - #[cfg(unix)] - let file = open_append_at(&self.directory, &self.file_name)?; - #[cfg(not(unix))] - let file = open_append(&self.path)?; - self.file = Some(file); - self.bytes_written = 0; - Ok(()) - } - - fn remove_if_present(&self, name: &OsStr) -> io::Result<()> { - #[cfg(unix)] - { - use rustix::fs::{AtFlags, unlinkat}; - match unlinkat(&self.directory, name, AtFlags::empty()) { - Ok(()) => Ok(()), - Err(error) if error == rustix::io::Errno::NOENT => Ok(()), - Err(error) => Err(io::Error::from_raw_os_error(error.raw_os_error())), - } - } - #[cfg(not(unix))] - remove_if_present(&self.path.with_file_name(name)) - } - - fn rename_if_present(&self, source: &OsStr, target: &OsStr) -> io::Result<()> { - #[cfg(unix)] - { - use rustix::fs::renameat; - match renameat(&self.directory, source, &self.directory, target) { - Ok(()) => Ok(()), - Err(error) if error == rustix::io::Errno::NOENT => Ok(()), - Err(error) => Err(io::Error::from_raw_os_error(error.raw_os_error())), - } - } - #[cfg(not(unix))] - rename_if_present( - &self.path.with_file_name(source), - &self.path.with_file_name(target), - ) - } - - fn file_mut(&mut self) -> io::Result<&mut File> { - self.file - .as_mut() - .ok_or_else(|| io::Error::other("log file is unavailable")) - } -} - -impl Write for SizeRotatingWriter { - fn write(&mut self, buffer: &[u8]) -> io::Result<usize> { - let incoming = u64::try_from(buffer.len()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "log event is too large"))?; - if incoming > self.policy.max_file_bytes { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "log event exceeds the configured file limit", - )); - } - if self.bytes_written > 0 - && self.bytes_written.saturating_add(incoming) > self.policy.max_file_bytes - { - self.rotate()?; - } - let written = self.file_mut()?.write(buffer)?; - self.bytes_written = self - .bytes_written - .saturating_add(u64::try_from(written).unwrap_or(u64::MAX)); - Ok(written) - } - - fn flush(&mut self) -> io::Result<()> { - self.file_mut()?.flush() - } -} - -#[cfg(test)] -fn reject_unsafe_target(path: &Path) -> io::Result<()> { - match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err( - io::Error::new(io::ErrorKind::InvalidInput, "log target is not a safe file"), - ), - Ok(_) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), - } -} - -#[cfg(unix)] -fn open_secure_directory(path: &Path) -> io::Result<File> { - use rustix::fs::{FileType, Mode, OFlags, fstat, open}; - use rustix::process::geteuid; - - let directory = open( - path, - OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, - Mode::empty(), - ) - .map_err(errno_to_io)?; - let status = fstat(&directory).map_err(errno_to_io)?; - if FileType::from_raw_mode(status.st_mode) != FileType::Directory - || status.st_uid != geteuid().as_raw() - || status.st_mode & 0o022 != 0 - { - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "log parent must be an owner-controlled non-writable directory", - )); - } - Ok(File::from(directory)) -} - -#[cfg(unix)] -fn open_append_at(directory: &File, name: &OsStr) -> io::Result<File> { - use rustix::fs::{FileType, Mode, OFlags, fchmod, fstat, openat}; - - let descriptor = openat( - directory, - name, - OFlags::WRONLY | OFlags::APPEND | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::CLOEXEC, - Mode::RUSR | Mode::WUSR, - ) - .map_err(errno_to_io)?; - let status = fstat(&descriptor).map_err(errno_to_io)?; - if FileType::from_raw_mode(status.st_mode) != FileType::RegularFile || status.st_nlink != 1 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "log target must be one regular file link", - )); - } - fchmod(&descriptor, Mode::RUSR | Mode::WUSR).map_err(errno_to_io)?; - Ok(File::from(descriptor)) -} - -#[cfg(unix)] -fn errno_to_io(error: rustix::io::Errno) -> io::Error { - io::Error::from_raw_os_error(error.raw_os_error()) -} - -#[cfg(not(unix))] -fn open_append(_path: &Path) -> io::Result<File> { - Err(io::Error::new( - io::ErrorKind::Unsupported, - "secure file logging is unavailable without a no-follow ACL implementation", - )) -} - -#[cfg(test)] -fn rotated_path(path: &Path, index: usize) -> PathBuf { - let mut value = path.as_os_str().to_owned(); - value.push(format!(".{index}")); - PathBuf::from(value) -} - -fn rotated_name(name: &OsStr, index: usize) -> OsString { - let mut value = name.to_owned(); - value.push(format!(".{index}")); - value -} - -#[cfg(any(test, not(unix)))] -fn remove_if_present(path: &Path) -> io::Result<()> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), - } -} - -#[cfg(any(test, not(unix)))] -fn rename_if_present(source: &Path, target: &Path) -> io::Result<()> { - match fs::rename(source, target) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), - } -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::{ - LogRotation, SizeRotatingWriter, reject_unsafe_target, remove_if_present, - rename_if_present, rotated_path, - }; - use std::ffi::OsStr; - use std::io::Write; - - #[test] - fn rotation_is_size_bounded_and_retention_is_finite() { - let directory = tempfile::tempdir().expect("temporary log directory"); - let path = directory.path().join("radroots.log"); - let mut writer = SizeRotatingWriter::new( - path.clone(), - LogRotation { - max_file_bytes: 5, - retained_files: 3, - }, - ) - .expect("writer"); - for value in [b"1111", b"2222", b"3333", b"4444"] { - writer.write_all(value).expect("log entry"); - } - writer.flush().expect("flush"); - assert_eq!(std::fs::read(&path).expect("current"), b"4444"); - assert_eq!( - std::fs::read(rotated_path(&path, 1)).expect("first retained"), - b"3333" - ); - assert_eq!( - std::fs::read(rotated_path(&path, 2)).expect("second retained"), - b"2222" - ); - assert!(!rotated_path(&path, 3).exists()); - } - - #[test] - fn single_file_rotation_removes_the_previous_file() { - let directory = tempfile::tempdir().expect("temporary log directory"); - let path = directory.path().join("radroots.log"); - std::fs::write(&path, b"full!").expect("fixture"); - let mut writer = SizeRotatingWriter::new( - path.clone(), - LogRotation { - max_file_bytes: 5, - retained_files: 1, - }, - ) - .expect("writer"); - writer.write_all(b"next").expect("write"); - writer.flush().expect("flush"); - assert_eq!(std::fs::read(path).expect("current"), b"next"); - } - - #[test] - fn oversized_events_and_unavailable_files_fail_closed() { - let directory = tempfile::tempdir().expect("temporary log directory"); - let path = directory.path().join("radroots.log"); - let mut writer = SizeRotatingWriter::new( - path, - LogRotation { - max_file_bytes: 3, - retained_files: 2, - }, - ) - .expect("writer"); - assert_eq!( - writer.write(b"four").expect_err("oversized").kind(), - std::io::ErrorKind::InvalidData - ); - writer.write_all(b"a").expect("first short write"); - writer.write_all(b"b").expect("second short write"); - writer.file = None; - assert_eq!( - writer.flush().expect_err("missing file").kind(), - std::io::ErrorKind::Other - ); - } - - #[test] - fn unsafe_targets_and_absent_rotation_files_are_handled() { - let directory = tempfile::tempdir().expect("temporary directory"); - assert!(reject_unsafe_target(directory.path()).is_err()); - let regular = directory.path().join("regular"); - std::fs::write(&regular, b"regular").expect("regular file"); - assert!(reject_unsafe_target(&regular).is_ok()); - let missing = directory.path().join("missing"); - assert!(reject_unsafe_target(&missing).is_ok()); - assert!(remove_if_present(&missing).is_ok()); - assert!(rename_if_present(&missing, &directory.path().join("target")).is_ok()); - assert!(remove_if_present(directory.path()).is_err()); - - let source = directory.path().join("source"); - std::fs::write(&source, b"source").expect("source"); - assert!(rename_if_present(&source, directory.path()).is_err()); - - #[cfg(unix)] - { - std::os::unix::fs::symlink(directory.path(), &missing).expect("symlink"); - assert!(reject_unsafe_target(&missing).is_err()); - } - } - - #[cfg(unix)] - #[test] - fn descriptor_relative_open_rejects_symlinks_and_multiple_links() { - let directory = tempfile::tempdir().expect("temporary directory"); - let outside = tempfile::NamedTempFile::new().expect("outside file"); - let symlink = directory.path().join("radroots.log"); - std::os::unix::fs::symlink(outside.path(), &symlink).expect("symlink"); - assert!(SizeRotatingWriter::new(symlink.clone(), LogRotation::default()).is_err()); - - std::fs::remove_file(&symlink).expect("remove symlink"); - std::fs::hard_link(outside.path(), &symlink).expect("hard link"); - assert!(SizeRotatingWriter::new(symlink, LogRotation::default()).is_err()); - - let link_holder = tempfile::tempdir().expect("link holder"); - let parent_link = link_holder.path().join("parent-link"); - std::os::unix::fs::symlink(directory.path(), &parent_link).expect("parent symlink"); - assert!( - SizeRotatingWriter::new(parent_link.join("other.log"), LogRotation::default()).is_err() - ); - - use std::os::unix::fs::PermissionsExt; - let insecure = tempfile::tempdir().expect("insecure parent"); - std::fs::set_permissions(insecure.path(), std::fs::Permissions::from_mode(0o777)) - .expect("insecure permissions"); - assert!( - SizeRotatingWriter::new(insecure.path().join("radroots.log"), LogRotation::default()) - .is_err() - ); - } - - #[cfg(unix)] - #[test] - fn descriptor_relative_rotation_propagates_non_missing_errors() { - let directory = tempfile::tempdir().expect("temporary directory"); - let mut writer = SizeRotatingWriter::new( - directory.path().join("radroots.log"), - LogRotation::default(), - ) - .expect("writer"); - writer.flush().expect("flush"); - - std::fs::create_dir(directory.path().join("blocked")).expect("blocked directory"); - assert!(writer.remove_if_present(OsStr::new("blocked")).is_err()); - std::fs::write(directory.path().join("source"), b"source").expect("source"); - assert!( - writer - .rename_if_present(OsStr::new("source"), OsStr::new("blocked")) - .is_err() - ); - } - - #[test] - fn rotation_without_an_open_file_recreates_the_target() { - let directory = tempfile::tempdir().expect("temporary directory"); - let path = directory.path().join("radroots.log"); - let mut writer = SizeRotatingWriter::new( - path, - LogRotation { - max_file_bytes: 3, - retained_files: 2, - }, - ) - .expect("writer"); - writer.file = None; - writer.rotate().expect("rotation"); - writer.write_all(b"ok").expect("write after rotation"); - } -} diff --git a/crates/mobile_ffi/src/operations.rs b/crates/mobile_ffi/src/operations.rs @@ -1,1008 +0,0 @@ -//! Focused secret-safe operation records for settings, profile, revision, and inbound media. - -use radroots_mobile_core::runtime::product_surface::{ - AddCommandType, BlossomEndpointAuthorityPreference, BlossomPreferences, IdentityCommand, - IdentityLockState, IdentityRecord, IdentityState, LocalStoragePolicy, MediaNetworkPolicy, - MobileNetworkEnvironment, MobileSettings, Phase1LocalMediaArtifact, Phase1MediaCacheStatus, - Phase1ProfileStatus, Phase1RevisionPhase, Phase1RevisionPolicy, Phase1RevisionStatus, - Phase1RevisionTarget, ProfileMetadataCommand, RelayAccessPreference, RelayEndpointPreference, - RelayPreferences, SettingsTransition, phase1_new_operation_id, -}; - -use crate::dto::PreparedMedia; -use crate::{ - FfiAddDraftInput, FfiDraftStatusRecord, FfiOperationSettlementRecord, FfiOutboxState, - FfiPreparedMediaInput, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError, -}; - -impl From<crate::FfiAddCommandType> for AddCommandType { - fn from(value: crate::FfiAddCommandType) -> Self { - match value { - crate::FfiAddCommandType::CreateUpdate => Self::CreateUpdate, - crate::FfiAddCommandType::CreatePhotoUpdate => Self::CreatePhotoUpdate, - crate::FfiAddCommandType::CreateAsk => Self::CreateAsk, - crate::FfiAddCommandType::CreateEvent => Self::CreateEvent, - crate::FfiAddCommandType::CreateFoodAvailability => Self::CreateFoodAvailability, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiIdentityLockState { - Locked, - Unlocked, -} - -impl From<IdentityLockState> for FfiIdentityLockState { - fn from(value: IdentityLockState) -> Self { - match value { - IdentityLockState::Locked => Self::Locked, - IdentityLockState::Unlocked => Self::Unlocked, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiSettingsIdentityRecord { - pub schema_version: u16, - pub id: String, - pub public_key: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiIdentityStateRecord { - pub schema_version: u16, - pub identities: Vec<FfiSettingsIdentityRecord>, - pub active_identity_id: Option<String>, - pub lock_state: FfiIdentityLockState, - pub pending_import_operation_id: Option<String>, -} - -impl From<&IdentityState> for FfiIdentityStateRecord { - fn from(value: &IdentityState) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - identities: value - .identities() - .iter() - .map(|identity| FfiSettingsIdentityRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - id: identity.id().to_owned(), - public_key: identity.public_key_hex().to_owned(), - }) - .collect(), - active_identity_id: value.active_identity_id().map(str::to_owned), - lock_state: value.lock_state().into(), - pending_import_operation_id: value.pending_import_operation_id().map(str::to_owned), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiIdentityCommandKind { - BeginImport, - CompleteImport, - CancelImport, - Select, - Lock, - Unlock, - Recover, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiIdentityCommandRecord { - pub schema_version: u16, - pub kind: FfiIdentityCommandKind, - pub operation_id: Option<String>, - pub identity_id: Option<String>, - pub public_key: Option<String>, -} - -impl TryFrom<FfiIdentityCommandRecord> for IdentityCommand { - type Error = RadrootsAppError; - - fn try_from(value: FfiIdentityCommandRecord) -> Result<Self, Self::Error> { - require_schema(value.schema_version)?; - let no_operation = value.operation_id.is_none(); - let no_identity = value.identity_id.is_none() && value.public_key.is_none(); - match value.kind { - FfiIdentityCommandKind::BeginImport if no_identity => Ok(Self::BeginImport { - operation_id: required(value.operation_id, "identity_operation_id_required")?, - }), - FfiIdentityCommandKind::CompleteImport => { - let operation_id = required(value.operation_id, "identity_operation_id_required")?; - let identity_id = required(value.identity_id, "identity_id_required")?; - let public_key = required(value.public_key, "identity_public_key_required")?; - Ok(Self::CompleteImport { - operation_id, - identity: IdentityRecord::new(identity_id, &public_key) - .map_err(|error| RadrootsAppError::invalid_argument(error.code()))?, - }) - } - FfiIdentityCommandKind::CancelImport if no_identity => Ok(Self::CancelImport { - operation_id: required(value.operation_id, "identity_operation_id_required")?, - }), - FfiIdentityCommandKind::Select if no_operation && value.public_key.is_none() => { - Ok(Self::Select { - identity_id: required(value.identity_id, "identity_id_required")?, - }) - } - FfiIdentityCommandKind::Lock if no_operation && no_identity => Ok(Self::Lock), - FfiIdentityCommandKind::Unlock if no_operation && no_identity => Ok(Self::Unlock), - FfiIdentityCommandKind::Recover if no_operation && no_identity => Ok(Self::Recover), - _ => Err(RadrootsAppError::invalid_argument( - "invalid_identity_command", - )), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiMobileNetworkEnvironment { - Public, - Simulator, - PhysicalDevice, -} - -impl From<FfiMobileNetworkEnvironment> for MobileNetworkEnvironment { - fn from(value: FfiMobileNetworkEnvironment) -> Self { - match value { - FfiMobileNetworkEnvironment::Public => Self::Public, - FfiMobileNetworkEnvironment::Simulator => Self::Simulator, - FfiMobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice, - } - } -} - -impl From<MobileNetworkEnvironment> for FfiMobileNetworkEnvironment { - fn from(value: MobileNetworkEnvironment) -> Self { - match value { - MobileNetworkEnvironment::Public => Self::Public, - MobileNetworkEnvironment::Simulator => Self::Simulator, - MobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiRelayAccessPreference { - ReadOnly, - ReadWrite, -} - -impl From<FfiRelayAccessPreference> for RelayAccessPreference { - fn from(value: FfiRelayAccessPreference) -> Self { - match value { - FfiRelayAccessPreference::ReadOnly => Self::ReadOnly, - FfiRelayAccessPreference::ReadWrite => Self::ReadWrite, - } - } -} - -impl From<RelayAccessPreference> for FfiRelayAccessPreference { - fn from(value: RelayAccessPreference) -> Self { - match value { - RelayAccessPreference::ReadOnly => Self::ReadOnly, - RelayAccessPreference::ReadWrite => Self::ReadWrite, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiRelayPreferenceRecord { - pub schema_version: u16, - pub url: String, - pub access: FfiRelayAccessPreference, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiRelayPreferencesRecord { - pub schema_version: u16, - pub environment: FfiMobileNetworkEnvironment, - pub endpoints: Vec<FfiRelayPreferenceRecord>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiBlossomAuthorityPreference { - PublicWebPki, - LoopbackDevelopment, - PrivateNetworkDevelopment, -} - -impl From<FfiBlossomAuthorityPreference> for BlossomEndpointAuthorityPreference { - fn from(value: FfiBlossomAuthorityPreference) -> Self { - match value { - FfiBlossomAuthorityPreference::PublicWebPki => Self::PublicWebPki, - FfiBlossomAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment, - FfiBlossomAuthorityPreference::PrivateNetworkDevelopment => { - Self::PrivateNetworkDevelopment - } - } - } -} - -impl From<BlossomEndpointAuthorityPreference> for FfiBlossomAuthorityPreference { - fn from(value: BlossomEndpointAuthorityPreference) -> Self { - match value { - BlossomEndpointAuthorityPreference::PublicWebPki => Self::PublicWebPki, - BlossomEndpointAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment, - BlossomEndpointAuthorityPreference::PrivateNetworkDevelopment => { - Self::PrivateNetworkDevelopment - } - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiBlossomPreferencesRecord { - pub schema_version: u16, - pub environment: FfiMobileNetworkEnvironment, - pub authority: FfiBlossomAuthorityPreference, - pub primary_origin: String, - pub fallback_origins: Vec<String>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiMediaNetworkPolicyRecord { - pub schema_version: u16, - pub allow_cellular_downloads: bool, - pub allow_cellular_uploads: bool, - pub allow_background_transfers: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiLocalStoragePolicyRecord { - pub schema_version: u16, - pub media_cache_bytes: u64, - pub media_cache_artifacts: u32, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiMobileSettingsRecord { - pub schema_version: u16, - pub revision: u64, - pub identity: FfiIdentityStateRecord, - pub relays: FfiRelayPreferencesRecord, - pub blossom: FfiBlossomPreferencesRecord, - pub media_network: FfiMediaNetworkPolicyRecord, - pub local_storage: FfiLocalStoragePolicyRecord, -} - -impl From<&MobileSettings> for FfiMobileSettingsRecord { - fn from(value: &MobileSettings) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - revision: value.revision(), - identity: value.identity().into(), - relays: FfiRelayPreferencesRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - environment: value.relays().environment().into(), - endpoints: value - .relays() - .endpoints() - .iter() - .map(|endpoint| FfiRelayPreferenceRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - url: endpoint.url().to_owned(), - access: endpoint.access().into(), - }) - .collect(), - }, - blossom: FfiBlossomPreferencesRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - environment: value.blossom().environment().into(), - authority: value.blossom().authority().into(), - primary_origin: value.blossom().primary_origin().to_owned(), - fallback_origins: value.blossom().fallback_origins().to_vec(), - }, - media_network: FfiMediaNetworkPolicyRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - allow_cellular_downloads: value.media_network().allow_cellular_downloads(), - allow_cellular_uploads: value.media_network().allow_cellular_uploads(), - allow_background_transfers: value.media_network().allow_background_transfers(), - }, - local_storage: FfiLocalStoragePolicyRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - media_cache_bytes: value.local_storage().media_cache_bytes(), - media_cache_artifacts: value.local_storage().media_cache_artifacts(), - }, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiReplaceSettingsRecord { - pub schema_version: u16, - pub expected_revision: u64, - pub relays: FfiRelayPreferencesRecord, - pub blossom: FfiBlossomPreferencesRecord, - pub media_network: FfiMediaNetworkPolicyRecord, - pub local_storage: FfiLocalStoragePolicyRecord, -} - -impl FfiReplaceSettingsRecord { - pub(crate) fn apply(self, current: MobileSettings) -> Result<MobileSettings, RadrootsAppError> { - require_schema(self.schema_version)?; - if current.revision() != self.expected_revision { - return Err(RadrootsAppError::invalid_argument( - "settings_revision_conflict", - )); - } - require_schema(self.relays.schema_version)?; - let relay_environment: MobileNetworkEnvironment = self.relays.environment.into(); - let relay_endpoints = self - .relays - .endpoints - .into_iter() - .map(|endpoint| { - require_schema(endpoint.schema_version)?; - RelayEndpointPreference::new( - relay_environment, - endpoint.url, - endpoint.access.into(), - ) - .map_err(Into::into) - }) - .collect::<Result<Vec<_>, RadrootsAppError>>()?; - let relays = RelayPreferences::new(relay_environment, relay_endpoints)?; - - require_schema(self.blossom.schema_version)?; - let blossom = BlossomPreferences::new( - self.blossom.environment.into(), - self.blossom.authority.into(), - self.blossom.primary_origin, - self.blossom.fallback_origins, - )?; - require_schema(self.media_network.schema_version)?; - let media_network = MediaNetworkPolicy::new( - self.media_network.allow_cellular_downloads, - self.media_network.allow_cellular_uploads, - self.media_network.allow_background_transfers, - ); - require_schema(self.local_storage.schema_version)?; - let local_storage = LocalStoragePolicy::new( - self.local_storage.media_cache_bytes, - self.local_storage.media_cache_artifacts, - )?; - Ok(current - .with_relays(relays) - .with_blossom(blossom) - .with_media_network(media_network) - .with_local_storage(local_storage)) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiSettingsTransitionRecord { - pub schema_version: u16, - pub settings: FfiMobileSettingsRecord, - pub runtime_restart_required: bool, - pub outbox_requeue_required: bool, - pub media_cache_invalidation_required: bool, -} - -impl From<SettingsTransition> for FfiSettingsTransitionRecord { - fn from(value: SettingsTransition) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - settings: (&value.settings).into(), - runtime_restart_required: value.runtime_restart_required, - outbox_requeue_required: value.outbox_requeue_required, - media_cache_invalidation_required: value.media_cache_invalidation_required, - } - } -} - -#[derive(Clone, Debug, uniffi::Record)] -pub struct FfiProfileMetadataInputRecord { - pub schema_version: u16, - pub name: String, - pub display_name: Option<String>, - pub about: Option<String>, - pub picture: Option<FfiPreparedMediaInput>, - pub banner: Option<FfiPreparedMediaInput>, - pub nip05: Option<String>, - pub bot: Option<bool>, -} - -impl FfiProfileMetadataInputRecord { - pub(crate) fn command( - self, - blossom: Option<&radroots_sdk::transport::BlossomSlot>, - ) -> Result<ProfileMetadataCommand, RadrootsAppError> { - require_schema(self.schema_version)?; - let picture = self - .picture - .map(PreparedMedia::try_from) - .transpose()? - .map(|media| { - let blossom = blossom.ok_or_else(|| { - RadrootsAppError::failure( - "blossom_unconfigured", - "profile", - true, - &["configure_blossom"], - "Profile media configuration is unavailable.", - ) - })?; - media.into_authored_image(blossom) - }) - .transpose()?; - let banner = self - .banner - .map(PreparedMedia::try_from) - .transpose()? - .map(|media| { - let blossom = blossom.ok_or_else(|| { - RadrootsAppError::failure( - "blossom_unconfigured", - "profile", - true, - &["configure_blossom"], - "Profile media configuration is unavailable.", - ) - })?; - media.into_authored_image(blossom) - }) - .transpose()?; - ProfileMetadataCommand::new( - self.name, - self.display_name, - self.about, - picture, - banner, - self.nip05, - self.bot, - ) - .map_err(Into::into) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiProfileStatusRecord { - pub schema_version: u16, - pub operation_id: String, - pub revision: u64, - pub author_public_key: String, - pub state: FfiOutboxState, - pub delivery_id: Option<String>, - pub created_at_unix_ms: u64, - pub updated_at_unix_ms: u64, - pub settlement: Option<FfiOperationSettlementRecord>, -} - -impl From<Phase1ProfileStatus> for FfiProfileStatusRecord { - fn from(value: Phase1ProfileStatus) -> Self { - let draft = value.draft(); - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - operation_id: hex::encode(draft.draft_id().as_bytes()), - revision: draft.revision().get(), - author_public_key: hex::encode(draft.author()), - state: value.state().into(), - delivery_id: draft.operation_id().map(|id| hex::encode(id.as_bytes())), - created_at_unix_ms: draft.created_at_unix_ms(), - updated_at_unix_ms: draft.updated_at_unix_ms(), - settlement: value.push().map(|push| push.settlement().into()), - } - } -} - -#[derive(Clone, Debug, uniffi::Record)] -pub struct FfiRevisionInputRecord { - pub schema_version: u16, - pub card_id: String, - pub source_event_id: String, - pub source_address: Option<String>, - pub author_public_key: String, - pub replacement: FfiAddDraftInput, -} - -impl FfiRevisionInputRecord { - pub(crate) fn target(&self) -> Result<Phase1RevisionTarget, RadrootsAppError> { - require_schema(self.schema_version)?; - Phase1RevisionTarget::from_source( - self.replacement.command_type.into(), - radroots_mobile_core::runtime::product_surface::CardId::parse(&self.card_id) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_card_id"))?, - self.source_event_id.clone(), - self.source_address.clone(), - self.author_public_key.clone(), - ) - .map_err(Into::into) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiRevisionPolicy { - ReplaceThenRetract, - AddressableReplacement, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiRevisionPhase { - ReplacementPending, - ReplacementFailed, - RetractionPending, - Complete, - PartialEffect, - Cancelled, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiRevisionStatusRecord { - pub schema_version: u16, - pub operation_id: String, - pub replacement: FfiDraftStatusRecord, - pub retraction: Option<FfiDraftStatusRecord>, - pub policy: FfiRevisionPolicy, - pub phase: FfiRevisionPhase, -} - -impl From<Phase1RevisionStatus> for FfiRevisionStatusRecord { - fn from(value: Phase1RevisionStatus) -> Self { - let operation_id = hex::encode(value.replacement().draft().draft_id().as_bytes()); - let retraction = value.retraction().cloned().map(Into::into); - let replacement = value.replacement().clone().into(); - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - operation_id, - replacement, - retraction, - policy: match value.policy() { - Phase1RevisionPolicy::ReplaceThenRetract => FfiRevisionPolicy::ReplaceThenRetract, - Phase1RevisionPolicy::AddressableReplacement => { - FfiRevisionPolicy::AddressableReplacement - } - }, - phase: match value.phase() { - Phase1RevisionPhase::ReplacementPending => FfiRevisionPhase::ReplacementPending, - Phase1RevisionPhase::ReplacementFailed => FfiRevisionPhase::ReplacementFailed, - Phase1RevisionPhase::RetractionPending => FfiRevisionPhase::RetractionPending, - Phase1RevisionPhase::Complete => FfiRevisionPhase::Complete, - Phase1RevisionPhase::PartialEffect => FfiRevisionPhase::PartialEffect, - Phase1RevisionPhase::Cancelled => FfiRevisionPhase::Cancelled, - }, - } - } -} - -#[derive(uniffi::Object)] -pub struct FfiMediaOperation { - operation_id: [u8; 16], - cancellation: radroots_sdk::transport::BlossomCancellation, - claimed: std::sync::atomic::AtomicBool, -} - -#[uniffi::export] -impl FfiMediaOperation { - #[uniffi::constructor] - pub fn new() -> Result<Self, RadrootsAppError> { - Ok(Self { - operation_id: phase1_new_operation_id().map_err(RadrootsAppError::from)?, - cancellation: radroots_sdk::transport::BlossomCancellation::default(), - claimed: std::sync::atomic::AtomicBool::new(false), - }) - } - - pub fn operation_id(&self) -> String { - hex::encode(self.operation_id) - } - - pub fn cancel(&self) { - self.cancellation.cancel(); - } - - pub fn is_cancelled(&self) -> bool { - self.cancellation.is_cancelled() - } -} - -impl FfiMediaOperation { - pub(crate) fn claim(&self) -> Result<(), RadrootsAppError> { - self.claimed - .compare_exchange( - false, - true, - std::sync::atomic::Ordering::AcqRel, - std::sync::atomic::Ordering::Acquire, - ) - .map(|_| ()) - .map_err(|_| RadrootsAppError::invalid_argument("media_operation_already_used")) - } - - pub(crate) const fn id(&self) -> [u8; 16] { - self.operation_id - } - - pub(crate) fn cancellation(&self) -> radroots_sdk::transport::BlossomCancellation { - self.cancellation.clone() - } -} - -#[derive(Clone, Eq, PartialEq, uniffi::Record)] -pub struct FfiVerifiedMediaArtifactRecord { - pub schema_version: u16, - pub operation_id: Option<String>, - pub artifact_id: String, - pub bytes: Vec<u8>, - pub byte_size: u64, - pub media_type: String, - pub width: u32, - pub height: u32, -} - -impl std::fmt::Debug for FfiVerifiedMediaArtifactRecord { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("FfiVerifiedMediaArtifactRecord") - .field("schema_version", &self.schema_version) - .field("operation_id", &self.operation_id) - .field("artifact_id", &self.artifact_id) - .field("bytes", &"<redacted>") - .field("byte_size", &self.byte_size) - .field("media_type", &self.media_type) - .field("width", &self.width) - .field("height", &self.height) - .finish() - } -} - -impl FfiVerifiedMediaArtifactRecord { - pub(crate) fn from_artifact( - value: Phase1LocalMediaArtifact, - operation_id: Option<String>, - ) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - operation_id, - artifact_id: value.artifact_id().to_hex(), - bytes: value.bytes().to_vec(), - byte_size: value.byte_size(), - media_type: value.media_type().to_owned(), - width: value.width(), - height: value.height(), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiMediaCacheStatusRecord { - pub schema_version: u16, - pub artifact_count: u32, - pub total_bytes: u64, - pub configuration_fingerprint: Option<String>, -} - -impl From<Phase1MediaCacheStatus> for FfiMediaCacheStatusRecord { - fn from(value: Phase1MediaCacheStatus) -> Self { - Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - artifact_count: value.artifacts, - total_bytes: value.bytes, - configuration_fingerprint: value.configuration.map(|value| value.to_hex()), - } - } -} - -pub(crate) fn require_schema(schema_version: u16) -> Result<(), RadrootsAppError> { - if schema_version == MOBILE_FFI_SCHEMA_VERSION { - Ok(()) - } else { - Err(RadrootsAppError::invalid_argument( - "unsupported_schema_version", - )) - } -} - -fn required(value: Option<String>, code: &'static str) -> Result<String, RadrootsAppError> { - value.ok_or_else(|| RadrootsAppError::invalid_argument(code)) -} - -pub(crate) fn decode_artifact_id( - value: &str, -) -> Result<radroots_mobile_core::runtime::product_surface::Phase1MediaArtifactId, RadrootsAppError> -{ - radroots_mobile_core::runtime::product_surface::Phase1MediaArtifactId::parse(value) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_artifact_id")) -} - -pub(crate) fn decode_configuration( - value: &str, -) -> Result< - radroots_mobile_core::runtime::product_surface::Phase1MediaConfigurationFingerprint, - RadrootsAppError, -> { - radroots_mobile_core::runtime::product_surface::Phase1MediaConfigurationFingerprint::parse( - value, - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_configuration")) -} - -pub(crate) fn decode_reference_fingerprint(value: &str) -> Result<[u8; 32], RadrootsAppError> { - let bytes = hex::decode(value) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference_fingerprint"))?; - bytes - .try_into() - .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference_fingerprint")) -} - -#[cfg(test)] -mod tests { - use super::*; - use radroots_mobile_core::runtime::product_surface::Phase1MediaConfigurationFingerprint; - - const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - - #[test] - fn media_operation_can_be_claimed_exactly_once() { - let operation = FfiMediaOperation::new().unwrap(); - operation.claim().unwrap(); - let error = operation.claim().unwrap_err(); - assert_eq!(error.report().code, "media_operation_already_used"); - assert_eq!(operation.operation_id().len(), 32); - } - - #[test] - fn verified_media_artifact_debug_never_exposes_renderable_bytes() { - let artifact = FfiVerifiedMediaArtifactRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - operation_id: None, - artifact_id: "11".repeat(32), - bytes: b"private farm image".to_vec(), - byte_size: 18, - media_type: "image/png".to_owned(), - width: 1, - height: 1, - }; - let debug = format!("{artifact:?}"); - assert!(debug.contains("<redacted>")); - assert!(!debug.contains("private farm image")); - } - - #[test] - fn identity_commands_reject_fields_outside_the_selected_variant() { - let error = IdentityCommand::try_from(FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::Lock, - operation_id: Some("unexpected".to_owned()), - identity_id: None, - public_key: None, - }) - .unwrap_err(); - assert_eq!(error.report().code, "invalid_identity_command"); - } - - #[test] - fn boundary_enums_and_identity_commands_cover_the_closed_vocabularies() { - for (ffi, core) in [ - ( - crate::FfiAddCommandType::CreateUpdate, - AddCommandType::CreateUpdate, - ), - ( - crate::FfiAddCommandType::CreatePhotoUpdate, - AddCommandType::CreatePhotoUpdate, - ), - ( - crate::FfiAddCommandType::CreateAsk, - AddCommandType::CreateAsk, - ), - ( - crate::FfiAddCommandType::CreateEvent, - AddCommandType::CreateEvent, - ), - ( - crate::FfiAddCommandType::CreateFoodAvailability, - AddCommandType::CreateFoodAvailability, - ), - ] { - assert_eq!(AddCommandType::from(ffi), core); - } - for environment in [ - FfiMobileNetworkEnvironment::Public, - FfiMobileNetworkEnvironment::Simulator, - FfiMobileNetworkEnvironment::PhysicalDevice, - ] { - assert_eq!( - FfiMobileNetworkEnvironment::from(MobileNetworkEnvironment::from(environment)), - environment - ); - } - for access in [ - FfiRelayAccessPreference::ReadOnly, - FfiRelayAccessPreference::ReadWrite, - ] { - assert_eq!( - FfiRelayAccessPreference::from(RelayAccessPreference::from(access)), - access - ); - } - for authority in [ - FfiBlossomAuthorityPreference::PublicWebPki, - FfiBlossomAuthorityPreference::LoopbackDevelopment, - FfiBlossomAuthorityPreference::PrivateNetworkDevelopment, - ] { - assert_eq!( - FfiBlossomAuthorityPreference::from(BlossomEndpointAuthorityPreference::from( - authority - )), - authority - ); - } - - let commands = [ - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::BeginImport, - operation_id: Some("operation".to_owned()), - identity_id: None, - public_key: None, - }, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::CompleteImport, - operation_id: Some("operation".to_owned()), - identity_id: Some("primary".to_owned()), - public_key: Some(PUBLIC_KEY.to_owned()), - }, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::CancelImport, - operation_id: Some("operation".to_owned()), - identity_id: None, - public_key: None, - }, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::Select, - operation_id: None, - identity_id: Some("primary".to_owned()), - public_key: None, - }, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::Lock, - operation_id: None, - identity_id: None, - public_key: None, - }, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::Unlock, - operation_id: None, - identity_id: None, - public_key: None, - }, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::Recover, - operation_id: None, - identity_id: None, - public_key: None, - }, - ]; - for command in commands { - assert!(IdentityCommand::try_from(command).is_ok()); - } - for command in [ - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::BeginImport, - operation_id: Some("operation".to_owned()), - identity_id: Some("unexpected".to_owned()), - public_key: None, - }, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::CancelImport, - operation_id: Some("operation".to_owned()), - identity_id: None, - public_key: Some(PUBLIC_KEY.to_owned()), - }, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::Select, - operation_id: Some("unexpected".to_owned()), - identity_id: Some("primary".to_owned()), - public_key: None, - }, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::Select, - operation_id: None, - identity_id: Some("primary".to_owned()), - public_key: Some(PUBLIC_KEY.to_owned()), - }, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::Lock, - operation_id: Some("unexpected".to_owned()), - identity_id: None, - public_key: None, - }, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::Unlock, - operation_id: None, - identity_id: Some("unexpected".to_owned()), - public_key: None, - }, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::Recover, - operation_id: None, - identity_id: None, - public_key: Some(PUBLIC_KEY.to_owned()), - }, - ] { - assert!(IdentityCommand::try_from(command).is_err()); - } - assert!( - IdentityCommand::try_from(FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION + 1, - kind: FfiIdentityCommandKind::Lock, - operation_id: None, - identity_id: None, - public_key: None, - }) - .is_err() - ); - - let identity = IdentityRecord::new("primary", PUBLIC_KEY).unwrap(); - for lock_state in [IdentityLockState::Locked, IdentityLockState::Unlocked] { - let state = IdentityState::new( - vec![identity.clone()], - Some("primary".to_owned()), - lock_state, - None, - ) - .unwrap(); - let record = FfiIdentityStateRecord::from(&state); - assert_eq!(record.identities.len(), 1); - assert_eq!(record.lock_state, lock_state.into()); - } - } - - #[test] - fn media_decoders_status_and_private_operation_accessors_are_exhaustive() { - assert!(require_schema(MOBILE_FFI_SCHEMA_VERSION).is_ok()); - assert!(require_schema(MOBILE_FFI_SCHEMA_VERSION + 1).is_err()); - for invalid in ["", "not-hex", "00"] { - assert!(decode_artifact_id(invalid).is_err()); - assert!(decode_configuration(invalid).is_err()); - assert!(decode_reference_fingerprint(invalid).is_err()); - } - let digest = "11".repeat(32); - assert!(decode_artifact_id(&digest).is_ok()); - assert!(decode_configuration(&digest).is_ok()); - assert_eq!(decode_reference_fingerprint(&digest).unwrap(), [0x11; 32]); - - let configuration = Phase1MediaConfigurationFingerprint::new([7; 32]).unwrap(); - let status = FfiMediaCacheStatusRecord::from(Phase1MediaCacheStatus { - artifacts: 2, - bytes: 9, - configuration: Some(configuration), - }); - assert_eq!(status.artifact_count, 2); - assert_eq!(status.total_bytes, 9); - assert_eq!(status.configuration_fingerprint, Some("07".repeat(32))); - assert!( - FfiMediaCacheStatusRecord::from(Phase1MediaCacheStatus { - artifacts: 0, - bytes: 0, - configuration: None, - }) - .configuration_fingerprint - .is_none() - ); - - let operation = FfiMediaOperation::new().unwrap(); - assert_eq!(operation.id(), operation.operation_id); - assert!(!operation.cancellation().is_cancelled()); - assert!(!operation.is_cancelled()); - operation.cancel(); - assert!(operation.is_cancelled()); - assert!(operation.cancellation().is_cancelled()); - } -} diff --git a/crates/mobile_ffi/src/runtime.rs b/crates/mobile_ffi/src/runtime.rs @@ -1,1190 +0,0 @@ -use std::sync::Arc; - -use radroots_mobile_core::runtime::product_surface::{ - LocalNetworkRelayPolicy, Phase1AddIntent, Phase1ExistingDraft, Phase1MediaCachePolicy, - Phase1QueueIntent, Phase1ReviseIntent, ReplaceMobileSettings, TodayPageRequest, - phase1_new_addressable_identifier, phase1_operation_now_unix_ms, -}; - -use crate::dto::PreparedMedia; -use crate::operations::{ - FfiIdentityCommandRecord, FfiMediaCacheStatusRecord, FfiMediaOperation, - FfiMobileSettingsRecord, FfiProfileMetadataInputRecord, FfiProfileStatusRecord, - FfiReplaceSettingsRecord, FfiRevisionInputRecord, FfiRevisionStatusRecord, - FfiSettingsTransitionRecord, FfiVerifiedMediaArtifactRecord, decode_artifact_id, - decode_configuration, decode_reference_fingerprint, -}; -use crate::signer::HostSignerAdapter; -use crate::subscription::SubscriptionHub; -use crate::{ - FfiAddDraftInput, FfiAddSchemaRecord, FfiBlossomConfigurationRecord, - FfiBlossomEndpointAuthority, FfiBlossomEvidenceRecord, FfiBlossomHostKind, - FfiBlossomUploadInput, FfiBlossomUploadIntent, FfiCapabilityRecord, FfiCardAddParityRecord, - FfiDraftStatusRecord, FfiIdentityStatusRecord, FfiLocalNetworkRecord, FfiMeRecord, - FfiQueuePolicyRecord, FfiRelayStatusReportRecord, FfiRetractionDraftInput, - FfiRuntimeChangeKind, FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord, - FfiStorageStatusRecord, FfiSubscriptionHandle, FfiTodayPageRecord, FfiTodayProjectionUpdate, - FfiTodayRefreshRecord, FfiTodaySyncRecord, RadrootsAppError, RadrootsHostSigner, - RadrootsRuntimeObserver, add_schemas, decode_id, -}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum ProtectedDataAvailability { - Available, - Unavailable, -} - -impl From<ProtectedDataAvailability> - for radroots_mobile_core::runtime::store::ProtectedDataAvailability -{ - fn from(value: ProtectedDataAvailability) -> Self { - match value { - ProtectedDataAvailability::Available => Self::Available, - ProtectedDataAvailability::Unavailable => Self::Unavailable, - } - } -} - -/// Native boundary object delegating all product behavior to the ordinary Rust core. -#[derive(uniffi::Object)] -pub struct RadrootsRuntime { - inner: radroots_mobile_core::RadrootsRuntime, - has_host_signer: bool, - subscriptions: Arc<SubscriptionHub>, -} - -#[cfg_attr(not(coverage_nightly), uniffi::export(async_runtime = "tokio"))] -impl RadrootsRuntime { - #[cfg_attr(not(coverage_nightly), uniffi::constructor)] - pub async fn new( - application_support_directory: String, - public_key_hex: String, - source_generation_hex: String, - source_generation_created_at_unix_ms: u64, - protected_data: ProtectedDataAvailability, - ) -> Result<Self, RadrootsAppError> { - build_runtime( - application_support_directory, - public_key_hex, - source_generation_hex, - source_generation_created_at_unix_ms, - protected_data, - None, - ) - .await - } - - #[cfg_attr(not(coverage_nightly), uniffi::constructor)] - pub async fn with_host_signer( - application_support_directory: String, - public_key_hex: String, - source_generation_hex: String, - source_generation_created_at_unix_ms: u64, - protected_data: ProtectedDataAvailability, - host_signer: Box<dyn RadrootsHostSigner>, - ) -> Result<Self, RadrootsAppError> { - build_runtime( - application_support_directory, - public_key_hex, - source_generation_hex, - source_generation_created_at_unix_ms, - protected_data, - Some(host_signer), - ) - .await - } - - pub async fn shutdown(&self) -> Result<FfiShutdownRecord, RadrootsAppError> { - let result = self - .inner - .shutdown() - .await - .map(Into::into) - .map_err(Into::into); - if result.is_ok() { - self.subscriptions - .notify(FfiRuntimeChangeKind::Lifecycle, None); - self.subscriptions.close(); - } - result - } - - pub fn uptime_millis(&self) -> i64 { - self.inner.uptime_millis() - } - - pub fn info(&self) -> FfiRuntimeInfoRecord { - self.inner.info().into() - } - - pub fn info_json(&self) -> String { - self.inner.info_json() - } - - pub fn set_app_info_platform( - &self, - platform: Option<String>, - bundle_id: Option<String>, - version: Option<String>, - build_number: Option<String>, - build_sha: Option<String>, - ) { - self.inner - .set_app_info_platform(platform, bundle_id, version, build_number, build_sha); - } - - pub fn identity_status(&self) -> Result<FfiIdentityStatusRecord, RadrootsAppError> { - let public_key = self - .inner - .authenticated_store_public_key_hex() - .ok_or_else(|| { - RadrootsAppError::failure( - "identity_unavailable", - "identity", - true, - &["unlock_identity"], - "The active identity is unavailable.", - ) - })?; - Ok(FfiIdentityStatusRecord { - schema_version: crate::MOBILE_FFI_SCHEMA_VERSION, - public_key, - host_signer_configured: self.has_host_signer, - }) - } - - pub fn sdk_capabilities(&self) -> Vec<FfiCapabilityRecord> { - self.inner - .sdk_capabilities() - .into_iter() - .map(Into::into) - .collect() - } - - pub async fn sdk_storage_status(&self) -> Result<FfiStorageStatusRecord, RadrootsAppError> { - self.inner - .sdk_storage_status() - .await - .map(Into::into) - .map_err(Into::into) - } - - pub fn sdk_relay_status(&self) -> Result<Option<FfiRelayStatusReportRecord>, RadrootsAppError> { - self.inner - .sdk_relay_status() - .map(|value| value.map(Into::into)) - .map_err(Into::into) - } - - pub fn sdk_blossom_configuration( - &self, - ) -> Result<Option<FfiBlossomConfigurationRecord>, RadrootsAppError> { - self.inner - .sdk_blossom_configuration() - .map(|value| value.map(Into::into)) - .map_err(Into::into) - } - - pub fn sdk_blossom_evidence( - &self, - ) -> Result<Option<FfiBlossomEvidenceRecord>, RadrootsAppError> { - self.inner - .sdk_blossom_evidence() - .map(|value| value.map(Into::into)) - .map_err(Into::into) - } - - pub async fn probe_blossom(&self) -> Result<FfiBlossomEvidenceRecord, RadrootsAppError> { - let evidence = self - .inner - .probe_blossom() - .await - .map(Into::into) - .map_err(RadrootsAppError::from)?; - self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); - Ok(evidence) - } - - pub fn subscribe_changes( - &self, - observer: Box<dyn RadrootsRuntimeObserver>, - ) -> Result<Arc<FfiSubscriptionHandle>, RadrootsAppError> { - self.subscriptions.subscribe(observer) - } - - pub fn configure_public_relays( - &self, - writable_relays: Vec<String>, - ) -> Result<(), RadrootsAppError> { - self.inner - .configure_public_relays(writable_relays) - .map_err(RadrootsAppError::from)?; - self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None); - Ok(()) - } - - pub fn configure_simulator_relays( - &self, - loopback_relays: Vec<String>, - ) -> Result<(), RadrootsAppError> { - self.inner - .configure_simulator_relays(loopback_relays) - .map_err(RadrootsAppError::from)?; - self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None); - Ok(()) - } - - pub fn configure_device_relays( - &self, - writable_relays: Vec<String>, - ) -> Result<(), RadrootsAppError> { - self.inner - .configure_device_relays(writable_relays) - .map_err(RadrootsAppError::from)?; - self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None); - Ok(()) - } - - pub fn configure_blossom( - &self, - host_kind: FfiBlossomHostKind, - endpoint_authority: FfiBlossomEndpointAuthority, - primary_origin: String, - fallback_origins: Vec<String>, - ) -> Result<(), RadrootsAppError> { - self.inner - .configure_blossom( - host_kind.into(), - endpoint_authority.into(), - primary_origin, - fallback_origins, - ) - .map_err(RadrootsAppError::from)?; - self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); - Ok(()) - } - - pub fn phase1_card_add_parity(&self) -> Vec<FfiCardAddParityRecord> { - self.inner - .phase1_card_add_parity() - .into_iter() - .map(|value| FfiCardAddParityRecord { - schema_version: crate::MOBILE_FFI_SCHEMA_VERSION, - card_type: value.card_type.into(), - command_type: value.add_command_type.into(), - }) - .collect() - } - - pub fn phase1_add_schemas(&self) -> Vec<FfiAddSchemaRecord> { - add_schemas() - } - - pub fn phase1_local_network( - &self, - context: FfiLocalNetworkRecord, - ) -> Result<FfiLocalNetworkRecord, RadrootsAppError> { - self.local_network(context).map(Into::into) - } - - pub async fn phase1_today_page( - &self, - context: FfiLocalNetworkRecord, - limit: u16, - as_of_unix_s: Option<u64>, - cursor: Option<String>, - ) -> Result<FfiTodayPageRecord, RadrootsAppError> { - if as_of_unix_s.is_some() == cursor.is_some() { - return Err(RadrootsAppError::invalid_argument( - "invalid_today_page_request", - )); - } - let context = self.local_network(context)?; - let request = match cursor { - Some(cursor) => TodayPageRequest::after(limit, cursor), - None => TodayPageRequest::first( - limit, - as_of_unix_s - .ok_or_else(|| RadrootsAppError::invalid_argument("today_as_of_required"))?, - ), - }; - self.inner - .phase1_today_page(&context, request) - .await - .map(Into::into) - .map_err(Into::into) - } - - pub async fn phase1_refresh_today( - &self, - context: FfiLocalNetworkRecord, - now_unix_s: u64, - update: FfiTodayProjectionUpdate, - ) -> Result<FfiTodayRefreshRecord, RadrootsAppError> { - let context = self.local_network(context)?; - let receipt = self - .inner - .phase1_refresh_today(&context, now_unix_s, update.into()) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions.notify(FfiRuntimeChangeKind::Today, None); - Ok(receipt.into()) - } - - pub async fn phase1_sync_today( - &self, - context: FfiLocalNetworkRecord, - now_unix_s: u64, - update: FfiTodayProjectionUpdate, - ) -> Result<FfiTodaySyncRecord, RadrootsAppError> { - let context = self.local_network(context)?; - let receipt = self - .inner - .phase1_sync_today(&context, now_unix_s, update.into()) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions.notify(FfiRuntimeChangeKind::Today, None); - Ok(receipt.into()) - } - - pub async fn phase1_search( - &self, - context: FfiLocalNetworkRecord, - query: String, - limit: u16, - as_of_unix_s: u64, - ) -> Result<Vec<FfiSearchResultRecord>, RadrootsAppError> { - let context = self.local_network(context)?; - self.inner - .phase1_search(&context, &query, limit, as_of_unix_s) - .await - .map(|results| results.into_iter().map(Into::into).collect()) - .map_err(Into::into) - } - - pub async fn phase1_me( - &self, - context: FfiLocalNetworkRecord, - as_of_unix_s: u64, - ) -> Result<FfiMeRecord, RadrootsAppError> { - let context = self.local_network(context)?; - let public_key = self - .inner - .authenticated_store_public_key_hex() - .ok_or_else(|| { - RadrootsAppError::failure( - "identity_unavailable", - "identity", - true, - &["unlock_identity"], - "The active identity is unavailable.", - ) - })?; - self.inner - .phase1_me(&context, &public_key, as_of_unix_s) - .await - .map(Into::into) - .map_err(Into::into) - } - - pub fn phase1_validate_add_draft( - &self, - input: FfiAddDraftInput, - authored_at_unix_s: u64, - ) -> Result<(), RadrootsAppError> { - let blossom = self - .inner - .sdk_blossom_slot() - .map_err(RadrootsAppError::from)?; - input - .command_and_media(authored_at_unix_s, blossom.as_ref()) - .map(|_| ()) - } - - /// Saves one new or existing Add form while Rust owns all identity and - /// timestamp policy. Addressable identifiers are generated when omitted. - pub async fn phase1_save_add_intent( - &self, - mut input: FfiAddDraftInput, - existing_draft_id: Option<String>, - expected_revision: Option<u64>, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - if input.identifier.is_none() - && matches!( - input.command_type, - crate::FfiAddCommandType::CreateEvent - | crate::FfiAddCommandType::CreateFoodAvailability - ) - { - input.identifier = Some(phase1_new_addressable_identifier()); - } - let authored_at_unix_s = - phase1_operation_now_unix_ms().map_err(RadrootsAppError::from)? / 1_000; - let blossom = self - .inner - .sdk_blossom_slot() - .map_err(RadrootsAppError::from)?; - let (command, media, form) = - input.command_media_and_form(authored_at_unix_s, blossom.as_ref())?; - let existing = match (existing_draft_id, expected_revision) { - (Some(draft_id), Some(revision)) => Some( - Phase1ExistingDraft::new(decode_id(&draft_id, "invalid_draft_id")?, revision) - .map_err(RadrootsAppError::from)?, - ), - (None, None) => None, - _ => { - return Err(RadrootsAppError::invalid_argument("invalid_existing_draft")); - } - }; - let status = self - .inner - .phase1_save_add_intent( - Phase1AddIntent::new(command, media, form, existing) - .map_err(RadrootsAppError::from)?, - ) - .await - .map_err(RadrootsAppError::from)?; - let draft_id = hex::encode(status.draft().draft_id().as_bytes()); - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); - Ok(status.into()) - } - - #[allow(clippy::too_many_arguments)] - pub async fn phase1_save_draft( - &self, - draft_id: String, - input: FfiAddDraftInput, - authored_at_unix_s: u64, - expected_revision: Option<u64>, - persisted_at_unix_ms: u64, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - let blossom = self - .inner - .sdk_blossom_slot() - .map_err(RadrootsAppError::from)?; - let (command, media, form) = - input.command_media_and_form(authored_at_unix_s, blossom.as_ref())?; - let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; - let status = self - .inner - .phase1_save_draft_with_form( - decoded_id, - command, - authored_at_unix_s, - media, - form, - expected_revision, - persisted_at_unix_ms, - ) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); - Ok(status.into()) - } - - pub async fn phase1_save_retraction_draft( - &self, - draft_id: String, - input: FfiRetractionDraftInput, - authored_at_unix_s: u64, - persisted_at_unix_ms: u64, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION { - return Err(RadrootsAppError::invalid_argument( - "unsupported_schema_version", - )); - } - let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; - let command_type = match input.command_type { - crate::FfiAddCommandType::CreateUpdate => { - radroots_mobile_core::runtime::product_surface::AddCommandType::CreateUpdate - } - crate::FfiAddCommandType::CreatePhotoUpdate => { - radroots_mobile_core::runtime::product_surface::AddCommandType::CreatePhotoUpdate - } - crate::FfiAddCommandType::CreateAsk => { - radroots_mobile_core::runtime::product_surface::AddCommandType::CreateAsk - } - crate::FfiAddCommandType::CreateEvent => { - radroots_mobile_core::runtime::product_surface::AddCommandType::CreateEvent - } - crate::FfiAddCommandType::CreateFoodAvailability => { - radroots_mobile_core::runtime::product_surface::AddCommandType::CreateFoodAvailability - } - }; - let card_id = - radroots_mobile_core::runtime::product_surface::CardId::parse(&input.target_card_id) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_card_id"))?; - let status = self - .inner - .phase1_save_retraction_draft( - decoded_id, - command_type, - card_id, - &input.target_event_id, - input.target_kind, - input.target_address.as_deref(), - &input.reason, - authored_at_unix_s, - persisted_at_unix_ms, - ) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); - Ok(status.into()) - } - - pub async fn phase1_draft_status( - &self, - draft_id: String, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - self.inner - .phase1_draft_status(decode_id(&draft_id, "invalid_draft_id")?) - .await - .map(Into::into) - .map_err(Into::into) - } - - pub async fn phase1_draft_heads( - &self, - limit: u16, - ) -> Result<Vec<FfiDraftStatusRecord>, RadrootsAppError> { - self.inner - .phase1_draft_heads(limit) - .await - .map(|values| values.into_iter().map(Into::into).collect()) - .map_err(Into::into) - } - - pub async fn phase1_queue_draft( - &self, - draft_id: String, - expected_revision: u64, - policy: FfiQueuePolicyRecord, - queued_at_unix_ms: u64, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; - let status = self - .inner - .phase1_queue_draft( - decoded_id, - expected_revision, - policy.try_into()?, - queued_at_unix_ms, - ) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); - Ok(status.into()) - } - - /// Queues with the Rust-owned active relay and settlement policy. - pub async fn phase1_queue_add_intent( - &self, - draft_id: String, - expected_revision: u64, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; - let intent = Phase1QueueIntent::new(decoded_id, expected_revision) - .map_err(RadrootsAppError::from)?; - let status = self - .inner - .phase1_queue_add_intent(intent) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); - Ok(status.into()) - } - - pub async fn phase1_recover_draft_queue( - &self, - draft_id: String, - recovered_at_unix_ms: u64, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; - let status = self - .inner - .phase1_recover_draft_queue(decoded_id, recovered_at_unix_ms) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); - Ok(status.into()) - } - - pub async fn phase1_recover_add_intent( - &self, - draft_id: String, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; - let status = self - .inner - .phase1_recover_add_intent(decoded_id) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); - Ok(status.into()) - } - - pub async fn phase1_sign_queued_draft( - &self, - draft_id: String, - expected_revision: u64, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; - let status = self - .inner - .phase1_sign_queued_draft(decoded_id, expected_revision) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); - Ok(status.into()) - } - - pub async fn phase1_advance_draft( - &self, - draft_id: String, - expected_revision: u64, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; - let status = self - .inner - .phase1_advance_draft(decoded_id, expected_revision) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); - Ok(status.into()) - } - - pub async fn phase1_upload_draft_media( - &self, - input: FfiBlossomUploadInput, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION { - return Err(RadrootsAppError::invalid_argument( - "unsupported_schema_version", - )); - } - let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?; - let operation_id = decode_id(&input.operation_id, "invalid_operation_id")?; - let artifact_id = decode_id(&input.artifact_id, "invalid_artifact_id")?; - let media = PreparedMedia::try_from(input.media)?; - let request = media.upload_request(input.verified_at_unix_ms)?; - let content = radroots_blossom::authorization::AuthorizationContent::parse( - &input.authorization_content, - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_authorization"))?; - let status = self - .inner - .phase1_upload_draft_media( - draft_id, - input.expected_revision, - request, - content, - input.authorization_created_at_unix_s, - input.authorization_lifetime_seconds, - operation_id, - artifact_id, - input.signing_deadline_unix_ms, - input.signing_cancellation.core(), - radroots_sdk::transport::BlossomCancellation::default(), - input.updated_at_unix_ms, - ) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone())); - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id)); - Ok(status.into()) - } - - /// Runs a Rust-planned BUD-11/BUD-02/BUD-01 upload attempt. The host - /// supplies only the selected bounded file handle and draft revision. - pub async fn phase1_upload_add_media_intent( - &self, - input: FfiBlossomUploadIntent, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION { - return Err(RadrootsAppError::invalid_argument( - "unsupported_schema_version", - )); - } - let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?; - let intent = PreparedMedia::try_from(input.media)? - .into_upload_intent(draft_id, input.expected_revision)?; - let status = self - .inner - .phase1_upload_add_media_intent(intent) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone())); - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id)); - Ok(status.into()) - } - - /// Persists the upload transition before returning an immutable native - /// background-transfer job. - pub async fn phase1_prepare_add_media_background( - &self, - input: crate::FfiBlossomUploadIntent, - ) -> Result<crate::FfiNativeUploadJobRecord, RadrootsAppError> { - if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION { - return Err(RadrootsAppError::invalid_argument( - "unsupported_schema_version", - )); - } - let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?; - let intent = PreparedMedia::try_from(input.media)? - .into_upload_intent(draft_id, input.expected_revision)?; - let (status, job) = self - .inner - .phase1_prepare_native_upload(intent) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone())); - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id)); - Ok(crate::FfiNativeUploadJobRecord { - schema_version: crate::MOBILE_FFI_SCHEMA_VERSION, - operation_id: hex::encode(job.operation_id()), - draft: status.into(), - remote_url: job.remote_url().to_owned(), - authorization_header: job.authorization_header().to_owned(), - expected_sha256: job.expected_sha256().to_owned(), - media_type: job.media_type().to_owned(), - byte_size: job.byte_size(), - }) - } - - /// Accepts only bounded native HTTP evidence; Rust performs descriptor and - /// exact-byte retrieval verification before advancing durable state. - pub async fn phase1_complete_add_media_background( - &self, - input: crate::FfiNativeUploadCompletionInput, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION - || input.response_body.len() > 16_384 - { - return Err(RadrootsAppError::invalid_argument( - "invalid_native_upload_completion", - )); - } - let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?; - let intent = PreparedMedia::try_from(input.media)? - .into_upload_intent(draft_id, input.expected_revision)?; - let status = self - .inner - .phase1_complete_native_upload( - intent, - input.status_code, - input.response_media_type.as_deref(), - input.response_content_encoding.as_deref(), - input.response_body.as_slice(), - ) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone())); - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id)); - Ok(status.into()) - } - - pub async fn phase1_cancel_draft( - &self, - draft_id: String, - expected_revision: u64, - cancelled_at_unix_ms: u64, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; - let status = self - .inner - .phase1_cancel_draft(decoded_id, expected_revision, cancelled_at_unix_ms) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); - Ok(status.into()) - } - - pub async fn phase1_cancel_add_intent( - &self, - draft_id: String, - expected_revision: u64, - ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; - let status = self - .inner - .phase1_cancel_add_intent(decoded_id, expected_revision) - .await - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); - Ok(status.into()) - } - - pub async fn phase1_settings(&self) -> Result<FfiMobileSettingsRecord, RadrootsAppError> { - self.inner - .phase1_settings() - .await - .map(|settings| (&settings).into()) - .map_err(Into::into) - } - - pub async fn phase1_apply_settings_to_runtime( - &self, - ) -> Result<FfiMobileSettingsRecord, RadrootsAppError> { - let settings = self.inner.phase1_settings().await?; - self.inner - .configure_relay_preferences(settings.relays()) - .map_err(RadrootsAppError::from)?; - self.inner - .configure_blossom_preferences(settings.blossom()) - .map_err(RadrootsAppError::from)?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Settings, None); - Ok((&settings).into()) - } - - pub async fn phase1_replace_settings( - &self, - input: FfiReplaceSettingsRecord, - ) -> Result<FfiSettingsTransitionRecord, RadrootsAppError> { - let current = self.inner.phase1_settings().await?; - let expected_revision = input.expected_revision; - let next = input.apply(current)?; - let transition = self - .inner - .phase1_replace_settings(ReplaceMobileSettings::new(expected_revision, next)?) - .await?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Settings, None); - Ok(transition.into()) - } - - pub async fn phase1_apply_identity_command( - &self, - expected_revision: u64, - command: FfiIdentityCommandRecord, - ) -> Result<FfiSettingsTransitionRecord, RadrootsAppError> { - let transition = self - .inner - .phase1_apply_identity_command(expected_revision, command.try_into()?) - .await?; - let identity_id = transition - .settings - .identity() - .active_identity_id() - .map(str::to_owned); - self.subscriptions - .notify(FfiRuntimeChangeKind::Identity, identity_id); - Ok(transition.into()) - } - - pub async fn phase1_save_profile_metadata( - &self, - input: FfiProfileMetadataInputRecord, - ) -> Result<FfiProfileStatusRecord, RadrootsAppError> { - let blossom = self - .inner - .sdk_blossom_slot() - .map_err(RadrootsAppError::from)?; - let status = self - .inner - .phase1_save_profile_metadata(input.command(blossom.as_ref())?) - .await?; - let operation_id = hex::encode(status.draft().draft_id().as_bytes()); - self.subscriptions - .notify(FfiRuntimeChangeKind::Profile, Some(operation_id)); - Ok(status.into()) - } - - pub async fn phase1_profile_status( - &self, - operation_id: String, - ) -> Result<FfiProfileStatusRecord, RadrootsAppError> { - self.inner - .phase1_profile_status(decode_id(&operation_id, "invalid_operation_id")?) - .await - .map(Into::into) - .map_err(Into::into) - } - - pub async fn phase1_advance_profile( - &self, - operation_id: String, - ) -> Result<FfiProfileStatusRecord, RadrootsAppError> { - let status = self - .inner - .phase1_advance_profile(decode_id(&operation_id, "invalid_operation_id")?) - .await?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Profile, Some(operation_id)); - Ok(status.into()) - } - - pub async fn phase1_cancel_profile( - &self, - operation_id: String, - expected_revision: u64, - ) -> Result<FfiProfileStatusRecord, RadrootsAppError> { - let status = self - .inner - .phase1_cancel_profile( - decode_id(&operation_id, "invalid_operation_id")?, - expected_revision, - ) - .await?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Profile, Some(operation_id)); - Ok(status.into()) - } - - pub async fn phase1_save_revision_intent( - &self, - mut input: FfiRevisionInputRecord, - ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> { - let target = input.target()?; - if input.replacement.identifier.is_none() - && matches!( - input.replacement.command_type, - crate::FfiAddCommandType::CreateEvent - | crate::FfiAddCommandType::CreateFoodAvailability - ) - { - input.replacement.identifier = Some(phase1_new_addressable_identifier()); - } - let authored_at_unix_s = phase1_operation_now_unix_ms()? / 1_000; - let blossom = self - .inner - .sdk_blossom_slot() - .map_err(RadrootsAppError::from)?; - let (command, media, form) = input - .replacement - .command_media_and_form(authored_at_unix_s, blossom.as_ref())?; - let status = self - .inner - .phase1_save_revision_intent(Phase1ReviseIntent::new(target, command, media, form)?) - .await?; - let operation_id = hex::encode(status.replacement().draft().draft_id().as_bytes()); - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id)); - Ok(status.into()) - } - - pub async fn phase1_revision_status( - &self, - operation_id: String, - ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> { - self.inner - .phase1_revision_status(decode_id(&operation_id, "invalid_operation_id")?) - .await - .map(Into::into) - .map_err(Into::into) - } - - pub async fn phase1_advance_revision( - &self, - operation_id: String, - ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> { - let status = self - .inner - .phase1_advance_revision(decode_id(&operation_id, "invalid_operation_id")?) - .await?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id)); - Ok(status.into()) - } - - pub async fn phase1_cancel_revision( - &self, - operation_id: String, - ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> { - let status = self - .inner - .phase1_cancel_revision(decode_id(&operation_id, "invalid_operation_id")?) - .await?; - self.subscriptions - .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id)); - Ok(status.into()) - } - - pub async fn phase1_retrieve_media( - &self, - context: FfiLocalNetworkRecord, - reference_fingerprint: String, - operation: Arc<FfiMediaOperation>, - ) -> Result<FfiVerifiedMediaArtifactRecord, RadrootsAppError> { - let context = self.local_network(context)?; - operation.claim()?; - let operation_id = operation.operation_id(); - let settings = self.inner.phase1_settings().await?; - let policy = Phase1MediaCachePolicy::new( - settings.local_storage().media_cache_bytes(), - settings.local_storage().media_cache_artifacts(), - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_cache_policy"))?; - let artifact = self - .inner - .phase1_retrieve_media( - &context, - decode_reference_fingerprint(&reference_fingerprint)?, - operation.id(), - policy, - operation.cancellation(), - ) - .await - .map_err(|error| { - RadrootsAppError::from(error).with_operation_id(operation_id.clone()) - })?; - self.subscriptions.notify( - FfiRuntimeChangeKind::Media, - Some(artifact.artifact_id().to_hex()), - ); - Ok(FfiVerifiedMediaArtifactRecord::from_artifact( - artifact, - Some(operation_id), - )) - } - - pub async fn phase1_verified_media_artifact( - &self, - context: FfiLocalNetworkRecord, - artifact_id: String, - ) -> Result<Option<FfiVerifiedMediaArtifactRecord>, RadrootsAppError> { - let context = self.local_network(context)?; - self.inner - .phase1_verified_media_artifact( - &context, - decode_artifact_id(&artifact_id)?, - phase1_operation_now_unix_ms()?, - ) - .await - .map(|value| { - value.map(|artifact| FfiVerifiedMediaArtifactRecord::from_artifact(artifact, None)) - }) - .map_err(Into::into) - } - - pub async fn phase1_media_cache_status( - &self, - context: FfiLocalNetworkRecord, - ) -> Result<FfiMediaCacheStatusRecord, RadrootsAppError> { - let context = self.local_network(context)?; - self.inner - .phase1_media_cache_status(&context) - .await - .map(Into::into) - .map_err(Into::into) - } - - pub async fn phase1_invalidate_media_artifact( - &self, - context: FfiLocalNetworkRecord, - artifact_id: String, - ) -> Result<bool, RadrootsAppError> { - let context = self.local_network(context)?; - let changed = self - .inner - .phase1_invalidate_media_artifact(&context, decode_artifact_id(&artifact_id)?) - .await?; - if changed { - self.subscriptions - .notify(FfiRuntimeChangeKind::Media, Some(artifact_id)); - } - Ok(changed) - } - - pub async fn phase1_invalidate_media_configuration( - &self, - context: FfiLocalNetworkRecord, - configuration_fingerprint: String, - ) -> Result<Vec<String>, RadrootsAppError> { - let context = self.local_network(context)?; - let removed = self - .inner - .phase1_invalidate_media_configuration( - &context, - decode_configuration(&configuration_fingerprint)?, - ) - .await?; - self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); - Ok(removed.into_iter().map(|value| value.to_hex()).collect()) - } -} - -impl RadrootsRuntime { - fn local_network( - &self, - context: FfiLocalNetworkRecord, - ) -> Result<radroots_mobile_core::runtime::product_surface::LocalNetwork, RadrootsAppError> - { - let profile = self.inner.sdk_relay_status()?.ok_or_else(|| { - RadrootsAppError::failure( - "relay_profile_unavailable", - "relay", - true, - &["configure_relay"], - "The relay profile is unavailable.", - ) - })?; - let relay_policy = match profile.profile.as_str() { - "public" => LocalNetworkRelayPolicy::Public, - "simulator_local" => LocalNetworkRelayPolicy::Simulator, - "device_development" => LocalNetworkRelayPolicy::Device, - _ => { - return Err(RadrootsAppError::failure( - "relay_profile_unsupported", - "relay", - false, - &["configure_relay"], - "The relay profile is unsupported.", - )); - } - }; - context.try_into_with_relay_policy(relay_policy) - } -} - -async fn build_runtime( - application_support_directory: String, - public_key_hex: String, - source_generation_hex: String, - source_generation_created_at_unix_ms: u64, - protected_data: ProtectedDataAvailability, - host_signer: Option<Box<dyn RadrootsHostSigner>>, -) -> Result<RadrootsRuntime, RadrootsAppError> { - let store = radroots_mobile_core::runtime::store::MobileUserStoreConfig::from_encoded( - application_support_directory, - public_key_hex.as_str(), - source_generation_hex.as_str(), - source_generation_created_at_unix_ms, - protected_data.into(), - )?; - let has_host_signer = host_signer.is_some(); - let mut builder = radroots_mobile_core::runtime::builder::RuntimeBuilder::new(store); - if let Some(host_signer) = host_signer { - builder = builder.signer(Arc::new(HostSignerAdapter::new(host_signer))); - } - builder - .build() - .await - .map(|inner| RadrootsRuntime { - inner, - has_host_signer, - subscriptions: SubscriptionHub::new(), - }) - .map_err(Into::into) -} diff --git a/crates/mobile_ffi/src/signer.rs b/crates/mobile_ffi/src/signer.rs @@ -1,404 +0,0 @@ -//! Opaque, secret-free host signing bridge. - -use std::sync::Arc; - -use radroots_event::{SignedEvent, wire::v1::Nip01EventWire}; -use radroots_signing::{ - Error, SignReceipt, SignRequest, Signer, SignerStatus, - capability::{CancellationSupport, SignerCapability, SignerKind}, - error::Kind, - recovery::ReplayCapability, - signer::BoxFuture, - status::SignerAvailability, -}; - -use crate::MOBILE_FFI_SCHEMA_VERSION; - -const SIGNED_EVENT_MAX_BYTES: usize = 1_048_576; - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum SignerAvailabilityRecord { - Ready, - Busy, - Locked, - Unavailable, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct SignerStatusRecord { - pub schema_version: u16, - pub availability: SignerAvailabilityRecord, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum HostSigningPurpose { - NostrEvent, - BlossomUpload, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct HostSigningRequest { - pub schema_version: u16, - pub operation_kind: String, - pub operation_id: String, - pub artifact_id: String, - pub signer_request_id: String, - pub public_key: String, - pub purpose: HostSigningPurpose, - pub deadline_unix_ms: u64, - pub event_id_digest: Vec<u8>, - pub expected_event_id: String, - pub created_at_unix_s: u64, - pub kind: u32, - pub tags: Vec<Vec<String>>, - pub content: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum HostSigningOutcome { - Signed, - Locked, - Cancelled, - Rejected, - TimedOut, - Unavailable, - Invalidated, - Failed, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct HostSigningResult { - pub schema_version: u16, - pub outcome: HostSigningOutcome, - pub operation_id: String, - pub signer_request_id: String, - pub public_key: String, - pub purpose: HostSigningPurpose, - pub signature_hex: Option<String>, - pub completed_at_unix_ms: u64, -} - -#[uniffi::export(callback_interface)] -#[async_trait::async_trait] -pub trait RadrootsHostSigner: Send + Sync { - async fn signer_status(&self) -> SignerStatusRecord; - async fn sign(&self, request: HostSigningRequest) -> HostSigningResult; -} - -pub(crate) struct HostSignerAdapter { - host: Arc<dyn RadrootsHostSigner>, -} - -impl HostSignerAdapter { - pub(crate) fn new(host: Box<dyn RadrootsHostSigner>) -> Self { - Self { - host: Arc::from(host), - } - } -} - -impl Signer for HostSignerAdapter { - fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> { - Box::pin(async move { - let status = self.host.signer_status().await; - if status.schema_version != MOBILE_FFI_SCHEMA_VERSION { - return Err(Error::new(Kind::SignerOutputInvalid)); - } - let availability = match status.availability { - SignerAvailabilityRecord::Ready => SignerAvailability::Ready, - SignerAvailabilityRecord::Busy => SignerAvailability::Busy, - SignerAvailabilityRecord::Locked => SignerAvailability::AwaitingAuthentication, - SignerAvailabilityRecord::Unavailable => SignerAvailability::Unavailable, - }; - Ok(SignerStatus::new( - availability, - vec![SignerCapability::new( - SignerKind::HostMediated, - ReplayCapability::ExactReplayByRequestId, - CancellationSupport::BeforeAndAfterPublication, - false, - true, - )], - None, - )) - }) - } - - fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { - Box::pin(async move { - request.ensure_active(now_unix_ms())?; - let ffi_request = HostSigningRequest::from_request(&request)?; - let result = self.host.sign(ffi_request.clone()).await; - request.ensure_active(now_unix_ms())?; - request.ensure_active(result.completed_at_unix_ms)?; - validate_result_binding(&ffi_request, &result)?; - match result.outcome { - HostSigningOutcome::Signed => signed_receipt(&request, result), - HostSigningOutcome::Locked | HostSigningOutcome::Unavailable => { - Err(Error::new(Kind::SignerUnavailable)) - } - HostSigningOutcome::Cancelled => Err(Error::new(Kind::SignerCancelled)), - HostSigningOutcome::Rejected => Err(Error::new(Kind::SignerRejected)), - HostSigningOutcome::TimedOut => Err(Error::new(Kind::SignerTimeout)), - HostSigningOutcome::Invalidated => Err(Error::new(Kind::SignerOutputInvalid)), - HostSigningOutcome::Failed => Err(Error::new(Kind::InternalError)), - } - }) - } -} - -impl HostSigningRequest { - fn from_request(request: &SignRequest) -> Result<Self, Error> { - let purpose = match request.purpose() { - radroots_signing::SigningPurpose::AuthoredEvent => HostSigningPurpose::NostrEvent, - radroots_signing::SigningPurpose::BlossomUploadAuthorization => { - HostSigningPurpose::BlossomUpload - } - _ => return Err(Error::new(Kind::SignerOutputInvalid)), - }; - Ok(Self { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - operation_kind: request.operation_kind().as_str().to_owned(), - operation_id: uuid_string(request.intent_id().operation_id().as_bytes()), - artifact_id: hex::encode(request.intent_id().artifact_id().as_bytes()), - signer_request_id: request.signer_request_id().to_hex(), - public_key: request.expected_author().to_hex(), - purpose, - deadline_unix_ms: request.policy().deadline_unix_ms(), - event_id_digest: request.expected_event_id().as_bytes().to_vec(), - expected_event_id: request.expected_event_id().to_hex(), - created_at_unix_s: request.created_at(), - kind: request.kind(), - tags: request.tags().to_vec(), - content: request.content().to_owned(), - }) - } -} - -fn validate_result_binding( - request: &HostSigningRequest, - result: &HostSigningResult, -) -> Result<(), Error> { - if result.schema_version != MOBILE_FFI_SCHEMA_VERSION - || result.operation_id != request.operation_id - || result.signer_request_id != request.signer_request_id - || result.public_key != request.public_key - || result.purpose != request.purpose - || result.completed_at_unix_ms == 0 - || (result.outcome == HostSigningOutcome::Signed) != result.signature_hex.is_some() - { - return Err(Error::new(Kind::SignerOutputInvalid)); - } - Ok(()) -} - -fn signed_receipt(request: &SignRequest, result: HostSigningResult) -> Result<SignReceipt, Error> { - let signature = result - .signature_hex - .filter(|value| { - value.len() == 128 - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - }) - .ok_or_else(|| Error::new(Kind::SignerOutputInvalid))?; - let wire = Nip01EventWire { - id: request.expected_event_id().to_hex(), - pubkey: request.expected_author().to_hex(), - created_at: request.created_at(), - kind: request.kind(), - tags: request.tags().to_vec(), - content: request.content().to_owned(), - sig: signature, - extra: Default::default(), - }; - let raw_json = serde_json::to_string(&wire).map_err(|_| Error::new(Kind::InternalError))?; - if raw_json.len() > SIGNED_EVENT_MAX_BYTES { - return Err(Error::new(Kind::SignerOutputInvalid)); - } - let signed = SignedEvent::from_wire_verified_id(wire, raw_json) - .map_err(|_| Error::new(Kind::SignerOutputInvalid))?; - SignReceipt::from_signed_event(request, signed, result.completed_at_unix_ms) -} - -fn uuid_string(bytes: &[u8; 16]) -> String { - let hex = hex::encode(bytes); - format!( - "{}-{}-{}-{}-{}", - &hex[0..8], - &hex[8..12], - &hex[12..16], - &hex[16..20], - &hex[20..32] - ) -} - -fn now_unix_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| { - duration.as_millis().try_into().unwrap_or(u64::MAX) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - struct StatusHost { - schema_version: u16, - availability: SignerAvailabilityRecord, - } - - #[async_trait::async_trait] - impl RadrootsHostSigner for StatusHost { - async fn signer_status(&self) -> SignerStatusRecord { - SignerStatusRecord { - schema_version: self.schema_version, - availability: self.availability, - } - } - - async fn sign(&self, request: HostSigningRequest) -> HostSigningResult { - HostSigningResult { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - outcome: HostSigningOutcome::Failed, - operation_id: request.operation_id, - signer_request_id: request.signer_request_id, - public_key: request.public_key, - purpose: request.purpose, - signature_hex: None, - completed_at_unix_ms: 1, - } - } - } - - #[test] - fn opaque_operation_identity_has_canonical_uuid_shape() { - assert_eq!( - uuid_string(&[0xabu8; 16]), - "abababab-abab-abab-abab-abababababab" - ); - } - - #[test] - fn result_binding_rejects_signature_on_failure() { - let request = HostSigningRequest { - schema_version: 1, - operation_kind: "sync.push".to_owned(), - operation_id: "11111111-1111-1111-1111-111111111111".to_owned(), - artifact_id: "22".repeat(16), - signer_request_id: "33".repeat(32), - public_key: "44".repeat(32), - purpose: HostSigningPurpose::NostrEvent, - deadline_unix_ms: 10, - event_id_digest: vec![5; 32], - expected_event_id: "55".repeat(32), - created_at_unix_s: 1, - kind: 1, - tags: Vec::new(), - content: "test".to_owned(), - }; - let result = HostSigningResult { - schema_version: 1, - outcome: HostSigningOutcome::Failed, - operation_id: request.operation_id.clone(), - signer_request_id: request.signer_request_id.clone(), - public_key: request.public_key.clone(), - purpose: request.purpose, - signature_hex: Some("66".repeat(64)), - completed_at_unix_ms: 2, - }; - assert_eq!( - validate_result_binding(&request, &result) - .expect_err("failure cannot carry signature") - .kind(), - Kind::SignerOutputInvalid - ); - - let valid = HostSigningResult { - signature_hex: None, - ..result.clone() - }; - assert!(validate_result_binding(&request, &valid).is_ok()); - let invalid_results = [ - HostSigningResult { - schema_version: 2, - ..valid.clone() - }, - HostSigningResult { - operation_id: "different".to_owned(), - ..valid.clone() - }, - HostSigningResult { - signer_request_id: "different".to_owned(), - ..valid.clone() - }, - HostSigningResult { - public_key: "different".to_owned(), - ..valid.clone() - }, - HostSigningResult { - purpose: HostSigningPurpose::BlossomUpload, - ..valid.clone() - }, - HostSigningResult { - completed_at_unix_ms: 0, - ..valid.clone() - }, - HostSigningResult { - outcome: HostSigningOutcome::Signed, - signature_hex: None, - ..valid - }, - ]; - for invalid in invalid_results { - assert_eq!( - validate_result_binding(&request, &invalid) - .expect_err("binding mismatch") - .kind(), - Kind::SignerOutputInvalid - ); - } - } - - #[tokio::test] - async fn host_status_maps_every_availability_and_rejects_schema_drift() { - for (ffi, expected) in [ - (SignerAvailabilityRecord::Ready, SignerAvailability::Ready), - (SignerAvailabilityRecord::Busy, SignerAvailability::Busy), - ( - SignerAvailabilityRecord::Locked, - SignerAvailability::AwaitingAuthentication, - ), - ( - SignerAvailabilityRecord::Unavailable, - SignerAvailability::Unavailable, - ), - ] { - let adapter = HostSignerAdapter::new(Box::new(StatusHost { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - availability: ffi, - })); - assert_eq!( - Signer::status(&adapter) - .await - .expect("mapped host status") - .availability(), - expected - ); - } - - let adapter = HostSignerAdapter::new(Box::new(StatusHost { - schema_version: MOBILE_FFI_SCHEMA_VERSION + 1, - availability: SignerAvailabilityRecord::Ready, - })); - assert_eq!( - Signer::status(&adapter) - .await - .expect_err("schema drift") - .kind(), - Kind::SignerOutputInvalid - ); - } -} diff --git a/crates/mobile_ffi/src/subscription.rs b/crates/mobile_ffi/src/subscription.rs @@ -1,321 +0,0 @@ -//! Bounded, independent host subscriptions for focused runtime invalidation signals. - -use std::collections::BTreeMap; -use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::mpsc::{SyncSender, TrySendError, sync_channel}; -use std::sync::{Arc, Mutex, Weak}; - -use crate::{MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError}; - -const MAX_SUBSCRIPTIONS: usize = 32; -const CHANGE_BUFFER_CAPACITY: usize = 16; - -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum FfiRuntimeChangeKind { - Initial, - Identity, - Settings, - Profile, - Today, - Drafts, - Relay, - Media, - Lifecycle, -} - -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] -pub struct FfiRuntimeChangeRecord { - pub schema_version: u16, - pub generation: u64, - pub kind: FfiRuntimeChangeKind, - pub entity_id: Option<String>, -} - -#[uniffi::export(callback_interface)] -pub trait RadrootsRuntimeObserver: Send + Sync { - fn on_change(&self, change: FfiRuntimeChangeRecord); -} - -pub(crate) struct SubscriptionHub { - next_id: AtomicU64, - generation: AtomicU64, - closed: AtomicBool, - subscriptions: Mutex<BTreeMap<u64, SyncSender<FfiRuntimeChangeRecord>>>, -} - -impl SubscriptionHub { - pub(crate) fn new() -> Arc<Self> { - Arc::new(Self { - next_id: AtomicU64::new(1), - generation: AtomicU64::new(1), - closed: AtomicBool::new(false), - subscriptions: Mutex::new(BTreeMap::new()), - }) - } - - pub(crate) fn subscribe( - self: &Arc<Self>, - observer: Box<dyn RadrootsRuntimeObserver>, - ) -> Result<Arc<FfiSubscriptionHandle>, RadrootsAppError> { - if self.closed.load(Ordering::Acquire) { - return Err(subscription_error("runtime_closed", false)); - } - let id = self.next_id.fetch_add(1, Ordering::AcqRel); - let (sender, receiver) = sync_channel(CHANGE_BUFFER_CAPACITY); - let observer: Arc<dyn RadrootsRuntimeObserver> = Arc::from(observer); - let hub = Arc::downgrade(self); - std::thread::Builder::new() - .name(format!("radroots-ffi-observer-{id}")) - .spawn(move || { - while let Ok(change) = receiver.recv() { - if catch_unwind(AssertUnwindSafe(|| observer.on_change(change))).is_err() { - break; - } - } - if let Some(hub) = hub.upgrade() { - hub.remove(id); - } - }) - .map_err(|_| subscription_error("subscription_worker_unavailable", true))?; - - { - let mut subscriptions = self - .subscriptions - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if self.closed.load(Ordering::Acquire) || subscriptions.len() >= MAX_SUBSCRIPTIONS { - return Err(subscription_error("subscription_limit_reached", true)); - } - subscriptions.insert(id, sender.clone()); - } - - let initial = FfiRuntimeChangeRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - generation: self.generation.load(Ordering::Acquire), - kind: FfiRuntimeChangeKind::Initial, - entity_id: None, - }; - let _ = sender.try_send(initial); - Ok(Arc::new(FfiSubscriptionHandle { - hub: Arc::downgrade(self), - id: Mutex::new(Some(id)), - })) - } - - pub(crate) fn notify(&self, kind: FfiRuntimeChangeKind, entity_id: Option<String>) { - if self.closed.load(Ordering::Acquire) { - return; - } - let change = FfiRuntimeChangeRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - generation: self.generation.fetch_add(1, Ordering::AcqRel) + 1, - kind, - entity_id, - }; - let senders = self - .subscriptions - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .iter() - .map(|(id, sender)| (*id, sender.clone())) - .collect::<Vec<_>>(); - let mut disconnected = Vec::new(); - for (id, sender) in senders { - match sender.try_send(change.clone()) { - Ok(()) | Err(TrySendError::Full(_)) => {} - Err(TrySendError::Disconnected(_)) => disconnected.push(id), - } - } - if !disconnected.is_empty() { - let mut subscriptions = self - .subscriptions - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - for id in disconnected { - subscriptions.remove(&id); - } - } - } - - pub(crate) fn close(&self) { - if !self.closed.swap(true, Ordering::AcqRel) { - self.generation.fetch_add(1, Ordering::AcqRel); - self.subscriptions - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clear(); - } - } - - fn remove(&self, id: u64) { - self.subscriptions - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(&id); - } -} - -#[derive(uniffi::Object)] -pub struct FfiSubscriptionHandle { - hub: Weak<SubscriptionHub>, - id: Mutex<Option<u64>>, -} - -#[uniffi::export] -impl FfiSubscriptionHandle { - pub fn unsubscribe(&self) { - let id = self - .id - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - if let (Some(hub), Some(id)) = (self.hub.upgrade(), id) { - hub.remove(id); - } - } - - pub fn is_active(&self) -> bool { - let id = *self - .id - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let (Some(hub), Some(id)) = (self.hub.upgrade(), id) else { - return false; - }; - !hub.closed.load(Ordering::Acquire) - && hub - .subscriptions - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .contains_key(&id) - } -} - -impl Drop for FfiSubscriptionHandle { - fn drop(&mut self) { - let id = self - .id - .get_mut() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - if let (Some(hub), Some(id)) = (self.hub.upgrade(), id) { - hub.remove(id); - } - } -} - -fn subscription_error(code: &str, retryable: bool) -> RadrootsAppError { - RadrootsAppError::failure( - code, - "subscription", - retryable, - if retryable { &["retry"] } else { &[] }, - "The runtime change subscription is unavailable.", - ) -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Condvar}; - use std::time::{Duration, Instant}; - - use super::*; - - struct NoopObserver; - - impl RadrootsRuntimeObserver for NoopObserver { - fn on_change(&self, _change: FfiRuntimeChangeRecord) {} - } - - struct PanicObserver; - - impl RadrootsRuntimeObserver for PanicObserver { - fn on_change(&self, _change: FfiRuntimeChangeRecord) { - panic!("observer panic is isolated"); - } - } - - struct BlockingObserver(Arc<(Mutex<bool>, Condvar)>); - - impl RadrootsRuntimeObserver for BlockingObserver { - fn on_change(&self, change: FfiRuntimeChangeRecord) { - if change.kind == FfiRuntimeChangeKind::Initial { - let (released, wake) = &*self.0; - let guard = released - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = wake - .wait_while(guard, |released| !*released) - .unwrap_or_else(std::sync::PoisonError::into_inner); - } - } - } - - #[test] - fn closed_limit_and_detached_handle_paths_are_typed_and_idempotent() { - let closed = SubscriptionHub::new(); - closed.close(); - closed.close(); - closed.notify(FfiRuntimeChangeKind::Today, None); - let error = closed - .subscribe(Box::new(NoopObserver)) - .err() - .expect("closed hub"); - assert_eq!(error.report().code, "runtime_closed"); - assert!(!error.report().retryable); - - let hub = SubscriptionHub::new(); - let handles = (0..MAX_SUBSCRIPTIONS) - .map(|_| hub.subscribe(Box::new(NoopObserver)).expect("subscription")) - .collect::<Vec<_>>(); - let error = hub - .subscribe(Box::new(NoopObserver)) - .err() - .expect("bounded subscription limit"); - assert_eq!(error.report().code, "subscription_limit_reached"); - assert!(error.report().retryable); - drop(handles); - - let detached_hub = SubscriptionHub::new(); - let detached = detached_hub - .subscribe(Box::new(NoopObserver)) - .expect("detached subscription"); - drop(detached_hub); - assert!(!detached.is_active()); - detached.unsubscribe(); - detached.unsubscribe(); - } - - #[test] - fn callback_panics_and_full_buffers_never_escape_or_block_publishers() { - let hub = SubscriptionHub::new(); - let panicking = hub - .subscribe(Box::new(PanicObserver)) - .expect("panicking subscription"); - let deadline = Instant::now() + Duration::from_secs(1); - while panicking.is_active() && Instant::now() < deadline { - std::thread::yield_now(); - } - assert!(!panicking.is_active()); - - let release = Arc::new((Mutex::new(false), Condvar::new())); - let blocked = hub - .subscribe(Box::new(BlockingObserver(Arc::clone(&release)))) - .expect("blocked subscription"); - for generation in 0..=CHANGE_BUFFER_CAPACITY { - hub.notify( - FfiRuntimeChangeKind::Drafts, - Some(format!("draft-{generation}")), - ); - } - assert!(blocked.is_active()); - let (released, wake) = &*release; - *released - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = true; - wake.notify_all(); - hub.close(); - assert!(!blocked.is_active()); - } -} diff --git a/crates/mobile_ffi/tests/local_mvp_real_io.rs b/crates/mobile_ffi/tests/local_mvp_real_io.rs @@ -1,964 +0,0 @@ -use std::io::Write; -use std::os::fd::AsRawFd; -use std::sync::Arc; -use std::time::Duration; - -use nostr::{EventBuilder, Keys, Kind, Metadata, Tag, Timestamp}; -use nostr_relay_builder::MockRelay; -use nostr_sdk::Client; -use radroots_blossom::Sha256; -use radroots_mobile_ffi::{ - FfiAddCommandType, FfiAddDraftInput, FfiBlossomEndpointAuthority, FfiBlossomHostKind, - FfiBlossomUploadInput, FfiCancellationPolicy, FfiEventTimingKind, FfiLocalNetworkRecord, - FfiMediaOperation, FfiMediaStage, FfiOutboxState, FfiPreparedMediaInput, FfiQueuePolicyRecord, - FfiRelaySatisfaction, FfiRetractionDraftInput, FfiTodayCardType, FfiTodayProjectionUpdate, - FfiTodayRelaySyncState, HostSigningOutcome, HostSigningRequest, HostSigningResult, - MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsHostSigner, RadrootsRuntime, - SignerAvailabilityRecord, SignerStatusRecord, -}; -use secp256k1::{Keypair, Message, Secp256k1, SecretKey}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; - -#[allow(dead_code)] -mod support; - -const AUTHORED_AT: u64 = 1_786_000_000; -const AS_OF: u64 = 1_786_200_000; -const FIXTURE_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; -const REPLY_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000002"; - -struct FixtureHostSigner; - -#[async_trait::async_trait] -impl RadrootsHostSigner for FixtureHostSigner { - async fn signer_status(&self) -> SignerStatusRecord { - SignerStatusRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - availability: SignerAvailabilityRecord::Ready, - } - } - - async fn sign(&self, request: HostSigningRequest) -> HostSigningResult { - let secret = SecretKey::from_slice(&hex::decode(FIXTURE_SECRET).expect("fixture secret")) - .expect("valid fixture secret"); - let keypair = Keypair::from_secret_key(&Secp256k1::new(), &secret); - let digest: [u8; 32] = request - .event_id_digest - .clone() - .try_into() - .expect("32-byte event digest"); - assert_eq!(hex::encode(digest), request.expected_event_id); - assert_eq!( - keypair.x_only_public_key().0.to_string(), - request.public_key - ); - let signature = Secp256k1::new() - .sign_schnorr_no_aux_rand(&Message::from_digest(digest), &keypair) - .to_string(); - HostSigningResult { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - outcome: HostSigningOutcome::Signed, - operation_id: request.operation_id, - signer_request_id: request.signer_request_id, - public_key: request.public_key, - purpose: request.purpose, - signature_hex: Some(signature), - completed_at_unix_ms: unix_time_ms(), - } - } -} - -struct BlossomServer { - origin: String, - task: tokio::task::JoinHandle<()>, -} - -impl BlossomServer { - async fn spawn(bytes: Vec<u8>, corrupt_retrieval: bool) -> Self { - Self::spawn_with_retrievals(bytes, corrupt_retrieval, 1).await - } - - async fn spawn_with_retrievals( - bytes: Vec<u8>, - corrupt_retrieval: bool, - retrievals: usize, - ) -> Self { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind Blossom server"); - let origin = format!("http://{}", listener.local_addr().expect("Blossom address")); - let hash = Sha256::digest(bytes.as_slice()).to_string(); - let blob_url = format!("{origin}/{hash}.png"); - let descriptor = serde_json::to_vec(&serde_json::json!({ - "url": blob_url, - "sha256": hash, - "size": bytes.len(), - "type": "image/png", - "uploaded": AUTHORED_AT, - })) - .expect("descriptor JSON"); - let task = tokio::spawn(async move { - let (mut upload, _) = listener.accept().await.expect("accept Blossom upload"); - let request = read_http_request(&mut upload).await; - let request_text = String::from_utf8_lossy(&request); - assert!(request_text.starts_with("PUT /upload HTTP/1.1\r\n")); - assert!( - request_text - .to_ascii_lowercase() - .contains("authorization: nostr ") - ); - assert!( - request_text - .to_ascii_lowercase() - .contains(format!("x-sha-256: {hash}").as_str()) - ); - write_http_response(&mut upload, "application/json", &descriptor).await; - - for _ in 0..retrievals { - let (mut retrieval, _) = listener.accept().await.expect("accept Blossom retrieval"); - let request = read_http_request(&mut retrieval).await; - assert!( - String::from_utf8_lossy(&request) - .starts_with(format!("GET /{hash}.png HTTP/1.1\r\n").as_str()) - ); - let mut response_bytes = bytes.clone(); - if corrupt_retrieval { - response_bytes[0] ^= 1; - } - write_http_response(&mut retrieval, "image/png", &response_bytes).await; - } - }); - Self { origin, task } - } - - async fn finish(self) { - self.task.await.expect("Blossom server task"); - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn public_runtime_completes_the_local_mvp_against_real_protocol_services() { - let relay = MockRelay::run().await.expect("local NIP-01 relay"); - let relay_url = relay.url().await.to_string(); - let image_bytes = png(2, 3); - let blossom = BlossomServer::spawn_with_retrievals(image_bytes.clone(), false, 2).await; - let mut image_file = tempfile::tempfile().expect("media file"); - image_file.write_all(&image_bytes).expect("write media"); - let media = prepared_media(&blossom.origin, &image_bytes, &image_file, "Harvest photo"); - - let publisher_root = tempfile::tempdir().expect("publisher root"); - support::prepare(publisher_root.path()); - let publisher = runtime_with_signer(publisher_root.path()).await; - configure_simulator(&publisher, &relay_url, &blossom.origin); - let context = local_network(&relay_url); - - let update_id = draft_id(1); - let update = publisher - .phase1_save_draft( - update_id.clone(), - add_input( - FfiAddCommandType::CreateUpdate, - "Equal-time harvest update", - None, - ), - AUTHORED_AT, - None, - 1_800_000_000_000, - ) - .await - .expect("save offline update"); - let queued_update = queue( - &publisher, - &update_id, - update.revision, - &relay_url, - false, - 1_800_000_000_500, - ) - .await; - assert_eq!(queued_update.state, FfiOutboxState::Queued); - publisher - .shutdown() - .await - .expect("shutdown before delivery"); - - let publisher = runtime_with_signer(publisher_root.path()).await; - configure_simulator(&publisher, &relay_url, &blossom.origin); - let recovered = publisher - .phase1_recover_draft_queue(update_id.clone(), 1_800_000_001_000) - .await - .expect("recover queued update after restart"); - assert_eq!(recovered.state, FfiOutboxState::Queued); - advance_complete(&publisher, &update_id, recovered.revision).await; - - let flows = [ - ( - 2, - add_input( - FfiAddCommandType::CreatePhotoUpdate, - "Equal-time photo harvest", - Some(media.clone()), - ), - ), - ( - 3, - add_input( - FfiAddCommandType::CreateAsk, - "Equal-time ask: who has basil?", - None, - ), - ), - (4, event_input("Equal-time Saturday market")), - (5, food_input("Equal-time carrots", "today-carrots")), - ]; - for (index, input) in flows { - let id = draft_id(index); - let saved = publisher - .phase1_save_draft( - id.clone(), - input, - AUTHORED_AT, - None, - 1_800_000_000_000 + u64::from(index), - ) - .await - .expect("save authored flow"); - let ready = if index == 2 { - let uploaded = publisher - .phase1_upload_draft_media(FfiBlossomUploadInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - draft_id: id.clone(), - expected_revision: saved.revision, - media: media.clone(), - authorization_content: "Upload the exact local harvest image".to_owned(), - authorization_created_at_unix_s: AUTHORED_AT, - authorization_lifetime_seconds: 300, - operation_id: "21".repeat(16), - artifact_id: "22".repeat(16), - signing_deadline_unix_ms: u64::MAX, - signing_cancellation: FfiCancellationPolicy::LocalCooperative, - verified_at_unix_ms: 1_800_000_000_100, - updated_at_unix_ms: 1_800_000_000_200, - }) - .await - .expect("upload and re-fetch exact media"); - assert_eq!(uploaded.media[0].stage, FfiMediaStage::Verified); - assert_eq!(uploaded.state, FfiOutboxState::MediaPreparing); - let evidence = publisher - .sdk_blossom_evidence() - .expect("Blossom evidence") - .expect("configured evidence"); - assert_eq!(evidence.state, "retrieval_verified"); - assert_eq!(evidence.last_successful_state, "retrieval_verified"); - assert!(evidence.error_code.is_none()); - uploaded - } else { - saved - }; - let queued = queue( - &publisher, - &id, - ready.revision, - &relay_url, - false, - 1_800_000_000_500 + u64::from(index), - ) - .await; - advance_complete(&publisher, &id, queued.revision).await; - } - let reader_root = tempfile::tempdir().expect("fresh reader root"); - support::prepare(reader_root.path()); - let reader = RadrootsRuntime::new( - reader_root.path().to_string_lossy().into_owned(), - support::PUBLIC_KEY.to_owned(), - support::GENERATION.to_owned(), - 1_800_000_000_000, - ProtectedDataAvailability::Available, - ) - .await - .expect("fresh reader runtime"); - reader - .configure_simulator_relays(vec![relay_url.clone()]) - .expect("reader relay profile"); - let first_sync = reader - .phase1_sync_today(context.clone(), AS_OF, FfiTodayProjectionUpdate::Rebuild) - .await - .expect("fresh relay re-read"); - assert_eq!(first_sync.relay_state, FfiTodayRelaySyncState::Complete); - assert_eq!(first_sync.events_admitted, 5); - - let cards = collect_pages(&reader, &context, 2, AS_OF).await; - assert_eq!(cards.len(), 5, "all equal-time cards survive frozen paging"); - let mut card_types = cards.iter().map(|card| card.card_type).collect::<Vec<_>>(); - card_types.sort_by_key(|card_type| match card_type { - FfiTodayCardType::Update => 0, - FfiTodayCardType::PhotoUpdate => 1, - FfiTodayCardType::Ask => 2, - FfiTodayCardType::Event => 3, - FfiTodayCardType::FoodAvailability => 4, - }); - assert_eq!( - card_types, - vec![ - FfiTodayCardType::Update, - FfiTodayCardType::PhotoUpdate, - FfiTodayCardType::Ask, - FfiTodayCardType::Event, - FfiTodayCardType::FoodAvailability, - ] - ); - let photo = cards - .iter() - .find(|card| card.card_type == FfiTodayCardType::PhotoUpdate) - .expect("photo card"); - assert_eq!(photo.media.len(), 1); - assert_eq!( - photo.media[0].sha256.as_deref(), - Some(media.sha256.as_str()) - ); - reader - .configure_blossom( - FfiBlossomHostKind::Simulator, - FfiBlossomEndpointAuthority::LoopbackDevelopment, - blossom.origin.clone(), - vec![], - ) - .expect("reader Blossom profile"); - let artifact = reader - .phase1_retrieve_media( - context.clone(), - photo.media[0].reference_fingerprint.clone(), - Arc::new(FfiMediaOperation::new().expect("media operation")), - ) - .await - .expect("retrieve projected media"); - assert_eq!(artifact.bytes, image_bytes); - assert_eq!( - artifact.byte_size, - u64::try_from(image_bytes.len()).unwrap() - ); - assert!( - reader - .phase1_verified_media_artifact(context.clone(), artifact.artifact_id.clone()) - .await - .expect("verify cached media") - .is_some() - ); - let media_cache = reader - .phase1_media_cache_status(context.clone()) - .await - .expect("media cache status"); - assert_eq!(media_cache.artifact_count, 1); - assert_eq!(media_cache.total_bytes, artifact.byte_size); - assert!(media_cache.configuration_fingerprint.is_some()); - assert!( - reader - .phase1_invalidate_media_artifact(context.clone(), artifact.artifact_id.clone()) - .await - .expect("invalidate cached media") - ); - assert!( - reader - .phase1_verified_media_artifact(context.clone(), artifact.artifact_id) - .await - .expect("verify invalidated media") - .is_none() - ); - blossom.finish().await; - let update = cards - .iter() - .find(|card| card.card_type == FfiTodayCardType::Update) - .expect("update card"); - let food = cards - .iter() - .find(|card| card.card_type == FfiTodayCardType::FoodAvailability) - .expect("food card"); - assert_eq!(food.food_summary.as_deref(), Some("Freshly harvested")); - assert_eq!(food.food_published_at_unix_s, Some(AUTHORED_AT)); - assert_eq!(food.food_status.as_deref(), Some("active")); - let update_event_id = update.source_event_id.clone(); - let update_card_id = update.card_id.clone(); - let food_event_id = food.source_event_id.clone(); - let food_card_id = food.card_id.clone(); - - publish_supporting_events(&relay_url, &update_event_id, &food_event_id).await; - reader - .phase1_sync_today( - context.clone(), - AS_OF, - FfiTodayProjectionUpdate::Incremental, - ) - .await - .expect("sync profile and thread events"); - let enriched = reader - .phase1_today_page(context.clone(), 20, Some(AS_OF), None) - .await - .expect("enriched Today"); - assert_eq!( - enriched - .items - .iter() - .find(|card| card.card_id == update_card_id) - .expect("reply root") - .thread - .len(), - 1 - ); - assert_eq!( - enriched - .items - .iter() - .find(|card| card.card_id == food_card_id) - .expect("comment root") - .thread - .len(), - 1 - ); - - let offline_port = unused_loopback_port().await; - let replacement_id = draft_id(6); - let mut replacement_input = food_input("Corrected carrots from Moss Farm", "today-carrots"); - replacement_input.food_published_at_unix_s = Some(AUTHORED_AT); - let replacement = publisher - .phase1_save_draft( - replacement_id.clone(), - replacement_input, - AUTHORED_AT + 10, - None, - 1_800_000_010_000, - ) - .await - .expect("save replacement"); - let replacement_queued = publisher - .phase1_queue_draft( - replacement_id.clone(), - replacement.revision, - FfiQueuePolicyRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - relay_urls: vec![relay_url.clone(), format!("ws://127.0.0.1:{offline_port}")], - satisfaction: FfiRelaySatisfaction::AllAccepted, - delivery_deadline_unix_ms: u64::MAX, - cancellation: FfiCancellationPolicy::LocalCooperative, - }, - 1_800_000_010_100, - ) - .await - .expect("queue replacement"); - let partial = publisher - .phase1_advance_draft(replacement_id, replacement_queued.revision) - .await - .expect("attempt partial replacement delivery"); - assert_eq!( - partial.state, - FfiOutboxState::PartiallyDelivered, - "partial delivery status: {partial:?}" - ); - let settlement = partial.settlement.expect("partial settlement"); - assert_eq!(settlement.delivery_satisfied, 0); - assert_eq!(settlement.delivery_exhausted, 1); - - reader - .phase1_sync_today( - context.clone(), - AS_OF, - FfiTodayProjectionUpdate::Incremental, - ) - .await - .expect("sync replacement"); - let replaced = reader - .phase1_today_page(context.clone(), 20, Some(AS_OF), None) - .await - .expect("replacement projection"); - let current_food = replaced - .items - .iter() - .find(|card| card.card_id == food_card_id) - .expect("current food head"); - assert_eq!(current_food.content, "Corrected carrots from Moss Farm"); - assert_ne!(current_food.source_event_id, food_event_id); - - let retraction_id = draft_id(7); - let retraction = publisher - .phase1_save_retraction_draft( - retraction_id.clone(), - FfiRetractionDraftInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type: FfiAddCommandType::CreateUpdate, - target_card_id: update_card_id.clone(), - target_event_id: update_event_id, - target_kind: 1, - target_address: None, - reason: "Superseded local update".to_owned(), - }, - AUTHORED_AT + 20, - 1_800_000_020_000, - ) - .await - .expect("save deletion request"); - let retraction_queued = queue( - &publisher, - &retraction_id, - retraction.revision, - &relay_url, - false, - 1_800_000_020_100, - ) - .await; - advance_complete(&publisher, &retraction_id, retraction_queued.revision).await; - reader - .phase1_sync_today( - context.clone(), - AS_OF, - FfiTodayProjectionUpdate::Incremental, - ) - .await - .expect("sync deletion"); - let after_deletion = reader - .phase1_today_page(context.clone(), 20, Some(AS_OF), None) - .await - .expect("projection after deletion"); - assert_eq!(after_deletion.items.len(), 4); - assert!( - after_deletion - .items - .iter() - .all(|card| card.card_id != update_card_id) - ); - - let search = reader - .phase1_search(context.clone(), "moss farm".to_owned(), 20, AS_OF) - .await - .expect("search current projection"); - assert!(search.iter().any(|result| result.profile.is_some())); - assert!(search.iter().any(|result| { - result - .card - .as_ref() - .is_some_and(|card| card.content == "Corrected carrots from Moss Farm") - })); - let me = reader - .phase1_me(context.clone(), AS_OF) - .await - .expect("Me projection"); - assert_eq!( - me.profile - .as_ref() - .and_then(|profile| profile.display_name.as_deref()), - Some("Moss Farm") - ); - assert_eq!(me.cards.len(), 4); - - prove_corrupted_media_fails(&publisher, &image_bytes, &image_file).await; - - reader.shutdown().await.expect("reader shutdown"); - publisher.shutdown().await.expect("publisher shutdown"); - relay.shutdown(); -} - -async fn runtime_with_signer(root: &std::path::Path) -> RadrootsRuntime { - RadrootsRuntime::with_host_signer( - root.to_string_lossy().into_owned(), - support::PUBLIC_KEY.to_owned(), - support::GENERATION.to_owned(), - 1_800_000_000_000, - ProtectedDataAvailability::Available, - Box::new(FixtureHostSigner), - ) - .await - .expect("runtime with fixture host signer") -} - -fn configure_simulator(runtime: &RadrootsRuntime, relay_url: &str, blossom_origin: &str) { - runtime - .configure_simulator_relays(vec![relay_url.to_owned()]) - .expect("simulator relay profile"); - runtime - .configure_blossom( - FfiBlossomHostKind::Simulator, - FfiBlossomEndpointAuthority::LoopbackDevelopment, - blossom_origin.to_owned(), - vec![], - ) - .expect("simulator Blossom profile"); -} - -fn local_network(relay_url: &str) -> FfiLocalNetworkRecord { - FfiLocalNetworkRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - id: "local-mvp".to_owned(), - label: "Local MVP".to_owned(), - relay_urls: vec![relay_url.to_owned()], - locality: None, - followed_authors: Vec::new(), - generation: 1, - } -} - -fn add_input( - command_type: FfiAddCommandType, - content: &str, - media: Option<FfiPreparedMediaInput>, -) -> FfiAddDraftInput { - FfiAddDraftInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type, - content: content.to_owned(), - identifier: None, - title: None, - summary: None, - location: None, - event_timing: None, - event_start_date: None, - event_end_date: None, - event_start_unix_s: None, - event_end_unix_s: None, - event_timezone: None, - price_amount: None, - currency: None, - unit: None, - quantity: None, - food_published_at_unix_s: None, - food_status: None, - media: media.into_iter().collect(), - } -} - -fn event_input(content: &str) -> FfiAddDraftInput { - FfiAddDraftInput { - identifier: Some("saturday-market".to_owned()), - title: Some("Saturday Market".to_owned()), - location: Some("Victoria".to_owned()), - event_timing: Some(FfiEventTimingKind::AllDay), - event_start_date: Some("2026-08-09".to_owned()), - ..add_input(FfiAddCommandType::CreateEvent, content, None) - } -} - -fn food_input(content: &str, identifier: &str) -> FfiAddDraftInput { - FfiAddDraftInput { - identifier: Some(identifier.to_owned()), - title: Some("Carrots".to_owned()), - summary: Some("Freshly harvested".to_owned()), - location: Some("Victoria".to_owned()), - price_amount: Some("4.5".to_owned()), - currency: Some("CAD".to_owned()), - unit: Some("kg".to_owned()), - quantity: Some("12".to_owned()), - food_status: Some("active".to_owned()), - ..add_input(FfiAddCommandType::CreateFoodAvailability, content, None) - } -} - -fn prepared_media( - _origin: &str, - bytes: &[u8], - file: &std::fs::File, - alt: &str, -) -> FfiPreparedMediaInput { - let hash = Sha256::digest(bytes).to_string(); - FfiPreparedMediaInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - opaque_reference: format!("media:{hash}"), - file_descriptor: u64::try_from(file.as_raw_fd()).expect("nonnegative media descriptor"), - sha256: hash, - media_type: "image/png".to_owned(), - byte_size: u64::try_from(bytes.len()).expect("media size"), - width: 2, - height: 3, - alt: alt.to_owned(), - prepared_at_unix_s: AUTHORED_AT, - } -} - -async fn queue( - runtime: &RadrootsRuntime, - draft_id: &str, - revision: u64, - relay_url: &str, - all: bool, - queued_at_unix_ms: u64, -) -> radroots_mobile_ffi::FfiDraftStatusRecord { - runtime - .phase1_queue_draft( - draft_id.to_owned(), - revision, - FfiQueuePolicyRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - relay_urls: vec![relay_url.to_owned()], - satisfaction: if all { - FfiRelaySatisfaction::AllAccepted - } else { - FfiRelaySatisfaction::AnyAccepted - }, - delivery_deadline_unix_ms: u64::MAX, - cancellation: FfiCancellationPolicy::LocalCooperative, - }, - queued_at_unix_ms, - ) - .await - .expect("queue draft") -} - -async fn advance_complete(runtime: &RadrootsRuntime, draft_id: &str, revision: u64) { - let status = match runtime - .phase1_advance_draft(draft_id.to_owned(), revision) - .await - { - Ok(status) => status, - Err(error) => { - let durable = runtime - .phase1_draft_status(draft_id.to_owned()) - .await - .expect("durable failure status"); - panic!("advance failed: {error:?}; durable status: {durable:?}"); - } - }; - assert_eq!(status.state, FfiOutboxState::Complete); - let settlement = status.settlement.expect("complete settlement"); - assert_eq!(settlement.signed, 1); - assert_eq!(settlement.admitted, 1); - assert_eq!(settlement.delivery_satisfied, 1); -} - -async fn collect_pages( - runtime: &RadrootsRuntime, - context: &FfiLocalNetworkRecord, - limit: u16, - as_of: u64, -) -> Vec<radroots_mobile_ffi::FfiTodayCardRecord> { - let first = runtime - .phase1_today_page(context.clone(), limit, Some(as_of), None) - .await - .expect("first Today page"); - let mut items = first.items; - let mut cursor = first.next_cursor; - while let Some(next) = cursor { - let page = runtime - .phase1_today_page(context.clone(), limit, None, Some(next)) - .await - .expect("continued Today page"); - items.extend(page.items); - cursor = page.next_cursor; - } - let mut unique = items.iter().map(|card| &card.card_id).collect::<Vec<_>>(); - unique.sort_unstable(); - unique.dedup(); - assert_eq!(unique.len(), items.len(), "frozen pages have no duplicates"); - items -} - -async fn publish_supporting_events(relay_url: &str, update_id: &str, food_id: &str) { - let profile_keys = Keys::parse(FIXTURE_SECRET).expect("profile keys"); - let profile_client = Client::new(profile_keys); - profile_client - .add_relay(relay_url) - .await - .expect("profile relay"); - profile_client.connect().await; - profile_client - .wait_for_connection(Duration::from_secs(2)) - .await; - profile_client - .send_event_builder( - EventBuilder::metadata( - &Metadata::new() - .name("moss") - .display_name("Moss Farm") - .about("Local harvests"), - ) - .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 1)), - ) - .await - .expect("publish profile"); - profile_client.shutdown().await; - - let reply_keys = Keys::parse(REPLY_SECRET).expect("reply keys"); - let reply_author = reply_keys.public_key().to_string(); - let reply_client = Client::new(reply_keys); - reply_client - .add_relay(relay_url) - .await - .expect("reply relay"); - reply_client.connect().await; - reply_client - .wait_for_connection(Duration::from_secs(2)) - .await; - reply_client - .send_event_builder( - EventBuilder::text_note("The farm stand is open") - .tags([ - Tag::parse(["e", update_id, relay_url, "root"]).expect("reply root tag"), - Tag::parse(["p", support::PUBLIC_KEY]).expect("reply author tag"), - ]) - .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 2)), - ) - .await - .expect("publish reply"); - reply_client - .send_event_builder( - EventBuilder::new(Kind::Custom(1_111), "Are these available Saturday?") - .tags([ - Tag::parse(["E", food_id, relay_url, support::PUBLIC_KEY]) - .expect("comment root event tag"), - Tag::parse(["K", "30402"]).expect("comment root kind tag"), - Tag::parse(["P", support::PUBLIC_KEY, relay_url]) - .expect("comment root author tag"), - Tag::parse(["e", food_id, relay_url, support::PUBLIC_KEY]) - .expect("comment parent event tag"), - Tag::parse(["k", "30402"]).expect("comment parent kind tag"), - Tag::parse(["p", support::PUBLIC_KEY, relay_url]) - .expect("comment parent author tag"), - Tag::parse(["p", reply_author.as_str()]).expect("self author context tag"), - ]) - .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 3)), - ) - .await - .expect("publish comment"); - reply_client.shutdown().await; -} - -async fn prove_corrupted_media_fails( - runtime: &RadrootsRuntime, - bytes: &[u8], - image_file: &std::fs::File, -) { - let corrupt = BlossomServer::spawn(bytes.to_vec(), true).await; - runtime - .configure_blossom( - FfiBlossomHostKind::Simulator, - FfiBlossomEndpointAuthority::LoopbackDevelopment, - corrupt.origin.clone(), - vec![], - ) - .expect("corrupt test Blossom profile"); - let media = prepared_media( - &corrupt.origin, - bytes, - image_file, - "Corrupt retrieval proof", - ); - let id = draft_id(8); - let saved = runtime - .phase1_save_draft( - id.clone(), - add_input( - FfiAddCommandType::CreatePhotoUpdate, - "This image must fail closed", - Some(media.clone()), - ), - AUTHORED_AT + 30, - None, - 1_800_000_030_000, - ) - .await - .expect("save corrupt-media draft"); - let error = runtime - .phase1_upload_draft_media(FfiBlossomUploadInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - draft_id: id.clone(), - expected_revision: saved.revision, - media, - authorization_content: "Verify corrupt retrieval rejection".to_owned(), - authorization_created_at_unix_s: AUTHORED_AT, - authorization_lifetime_seconds: 300, - operation_id: "31".repeat(16), - artifact_id: "32".repeat(16), - signing_deadline_unix_ms: u64::MAX, - signing_cancellation: FfiCancellationPolicy::LocalCooperative, - verified_at_unix_ms: 1_800_000_030_100, - updated_at_unix_ms: 1_800_000_030_200, - }) - .await - .expect_err("corrupt media retrieval must fail"); - assert_eq!(error.report().code, "authoring_failed"); - let failed = runtime - .phase1_draft_status(id) - .await - .expect("durable corrupt-media status"); - assert_eq!(failed.media[0].stage, FfiMediaStage::Failed); - assert_eq!(failed.state, FfiOutboxState::MediaPreparing); - assert!(failed.media[0].possible_orphan); - let evidence = runtime - .sdk_blossom_evidence() - .expect("Blossom evidence") - .expect("configured evidence"); - assert_eq!(evidence.state, "terminal_failure"); - assert_eq!(evidence.last_successful_state, "upload_verified"); - assert_eq!( - evidence.error_code.as_deref(), - Some("blossom_response_hash_mismatch") - ); - assert_eq!(evidence.error_phase.as_deref(), Some("verification")); - assert!(evidence.possible_orphan); - corrupt.finish().await; -} - -async fn unused_loopback_port() -> u16 { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("reserve unused port"); - listener.local_addr().expect("unused address").port() -} - -async fn read_http_request(stream: &mut tokio::net::TcpStream) -> Vec<u8> { - let mut request = Vec::new(); - let header_end = loop { - let mut chunk = [0_u8; 1_024]; - let read = stream.read(&mut chunk).await.expect("read HTTP request"); - assert_ne!(read, 0, "HTTP request ended before headers"); - request.extend_from_slice(&chunk[..read]); - if let Some(index) = request.windows(4).position(|value| value == b"\r\n\r\n") { - break index + 4; - } - assert!(request.len() < 64 * 1_024, "HTTP headers are bounded"); - }; - let content_length = String::from_utf8_lossy(&request[..header_end]) - .lines() - .find_map(|line| { - line.to_ascii_lowercase() - .strip_prefix("content-length: ") - .and_then(|value| value.trim().parse::<usize>().ok()) - }) - .unwrap_or_default(); - while request.len() - header_end < content_length { - let mut chunk = [0_u8; 1_024]; - let read = stream.read(&mut chunk).await.expect("read HTTP body"); - assert_ne!(read, 0, "HTTP request ended before body"); - request.extend_from_slice(&chunk[..read]); - } - request -} - -async fn write_http_response(stream: &mut tokio::net::TcpStream, content_type: &str, body: &[u8]) { - let head = format!( - "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); - stream - .write_all(head.as_bytes()) - .await - .expect("write response head"); - stream.write_all(body).await.expect("write response body"); - stream.shutdown().await.expect("close HTTP response"); -} - -fn draft_id(index: u8) -> String { - format!("{index:02x}").repeat(16) -} - -fn png(width: u32, height: u32) -> Vec<u8> { - let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); - bytes.extend_from_slice(&width.to_be_bytes()); - bytes.extend_from_slice(&height.to_be_bytes()); - bytes -} - -fn unix_time_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock after epoch") - .as_millis() - .try_into() - .expect("current time fits u64") -} diff --git a/crates/mobile_ffi/tests/logging_error.rs b/crates/mobile_ffi/tests/logging_error.rs @@ -1,13 +0,0 @@ -use radroots_mobile_ffi::RadrootsAppError; -use radroots_mobile_ffi::logging; - -#[test] -fn init_logging_stdout_maps_global_subscriber_error() { - let _ = tracing_subscriber::fmt().try_init(); - let err = logging::init_logging_stdout(); - assert!(matches!( - err, - Err(RadrootsAppError::Failure { report }) - if report.code == "initialization_failed" - )); -} diff --git a/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_ffi/tests/runtime_delegation.rs @@ -1,850 +0,0 @@ -use std::sync::Arc; - -use radroots_mobile_ffi::{ - FfiAddCommandType, FfiAddDraftInput, FfiBlossomAuthorityPreference, - FfiBlossomEndpointAuthority, FfiBlossomHostKind, FfiBlossomPreferencesRecord, - FfiBlossomUploadIntent, FfiCancellationPolicy, FfiDraftKind, FfiEventTimingKind, - FfiIdentityCommandKind, FfiIdentityCommandRecord, FfiIdentityLockState, FfiLocalNetworkRecord, - FfiLocalStoragePolicyRecord, FfiMediaNetworkPolicyRecord, FfiMediaOperation, - FfiMobileNetworkEnvironment, FfiNativeUploadCompletionInput, FfiOutboxState, - FfiPreparedMediaInput, FfiProfileMetadataInputRecord, FfiQueuePolicyRecord, - FfiRelayAccessPreference, FfiRelayAccessRecord, FfiRelayPreferenceRecord, - FfiRelayPreferencesRecord, FfiRelaySatisfaction, FfiReplaceSettingsRecord, - FfiRetractionDraftInput, FfiRevisionInputRecord, FfiRevisionPhase, FfiTodayCardType, - FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError, -}; - -mod support; - -#[tokio::test] -async fn native_boundary_delegates_the_complete_core_surface() { - let (_root, runtime) = support::runtime().await; - assert!(runtime.uptime_millis() >= 0); - assert!(runtime.info_json().contains("sdk")); - runtime.set_app_info_platform( - Some("ios".to_owned()), - Some("org.radroots.app".to_owned()), - Some("0.1.0-alpha".to_owned()), - Some("1".to_owned()), - Some("0123456789abcdef0123456789abcdef01234567".to_owned()), - ); - assert_eq!( - runtime.info().app.platform.expect("platform").platform, - Some("ios".to_owned()) - ); - assert!(!runtime.sdk_capabilities().is_empty()); - assert_eq!( - runtime.sdk_storage_status().await.expect("storage").backend, - "sqlite" - ); - - let public = runtime - .sdk_relay_status() - .expect("relay status") - .expect("default public profile"); - assert_eq!(public.profile, "public"); - assert_eq!(public.state, "configured"); - assert_eq!(public.read_availability, "unavailable"); - assert_eq!(public.write_availability, "unavailable"); - assert_eq!(public.relays.len(), 1); - assert_eq!(public.relays[0].access, FfiRelayAccessRecord::ReadWrite); - assert_eq!(public.relays[0].read_state, "unobserved"); - assert_eq!(public.relays[0].write_state, "unobserved"); - - runtime - .configure_public_relays(vec!["wss://write.example".to_owned()]) - .expect("public relays"); - let public = runtime - .sdk_relay_status() - .expect("relay status") - .expect("public profile"); - assert_eq!(public.relays.len(), 1); - assert_eq!(public.relays[0].relay_url, "wss://write.example"); - assert_eq!(public.relays[0].access, FfiRelayAccessRecord::ReadWrite); - assert!( - runtime - .configure_public_relays(vec!["ws://127.0.0.1:7447".to_owned()]) - .is_err() - ); - - let settings = runtime.phase1_settings().await.expect("settings"); - runtime - .phase1_replace_settings(FfiReplaceSettingsRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - expected_revision: settings.revision, - relays: FfiRelayPreferencesRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - environment: FfiMobileNetworkEnvironment::Public, - endpoints: vec![ - FfiRelayPreferenceRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - url: "wss://read.example".to_owned(), - access: FfiRelayAccessPreference::ReadOnly, - }, - FfiRelayPreferenceRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - url: "wss://write.example".to_owned(), - access: FfiRelayAccessPreference::ReadWrite, - }, - ], - }, - blossom: FfiBlossomPreferencesRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - environment: FfiMobileNetworkEnvironment::Public, - authority: FfiBlossomAuthorityPreference::PublicWebPki, - primary_origin: "https://media.example".to_owned(), - fallback_origins: vec![], - }, - media_network: FfiMediaNetworkPolicyRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - allow_cellular_downloads: true, - allow_cellular_uploads: true, - allow_background_transfers: true, - }, - local_storage: FfiLocalStoragePolicyRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - media_cache_bytes: 256 * 1024 * 1024, - media_cache_artifacts: 1024, - }, - }) - .await - .expect("replace settings"); - runtime - .phase1_apply_settings_to_runtime() - .await - .expect("apply settings"); - let applied = runtime - .sdk_relay_status() - .expect("relay status") - .expect("applied settings profile"); - assert_eq!(applied.relays.len(), 2); - assert_eq!(applied.relays[0].access, FfiRelayAccessRecord::ReadOnly); - assert_eq!(applied.relays[1].access, FfiRelayAccessRecord::ReadWrite); - - runtime - .configure_simulator_relays(vec!["ws://127.0.0.1:7447".to_owned()]) - .expect("simulator relays"); - let simulator = runtime - .sdk_relay_status() - .expect("relay status") - .expect("simulator profile"); - assert_eq!(simulator.profile, "simulator_local"); - assert_eq!(simulator.relays.len(), 1); - assert_eq!(simulator.relays[0].access, FfiRelayAccessRecord::ReadWrite); - assert!( - runtime - .phase1_local_network(FfiLocalNetworkRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - id: "simulator".to_owned(), - label: "Simulator".to_owned(), - relay_urls: vec!["ws://127.0.0.1:7447".to_owned()], - locality: None, - followed_authors: vec![], - generation: 1, - }) - .is_ok() - ); - assert!( - runtime - .configure_simulator_relays(vec!["wss://relay.example".to_owned()]) - .is_err() - ); - - runtime - .configure_device_relays(vec!["wss://10.0.0.5:7447".to_owned()]) - .expect("device relays"); - let device = runtime - .sdk_relay_status() - .expect("relay status") - .expect("device profile"); - assert_eq!(device.profile, "device_development"); - assert_eq!(device.relays.len(), 1); - assert_eq!(device.relays[0].relay_url, "wss://10.0.0.5:7447"); - assert_eq!(device.relays[0].access, FfiRelayAccessRecord::ReadWrite); - assert!( - runtime - .configure_device_relays(vec!["wss://127.0.0.1:7447".to_owned()]) - .is_err() - ); - - runtime - .configure_blossom( - FfiBlossomHostKind::PhysicalDevice, - FfiBlossomEndpointAuthority::PublicWebPki, - "https://media.example".to_owned(), - vec!["https://fallback.example".to_owned()], - ) - .expect("public Blossom"); - let blossom = runtime - .sdk_blossom_configuration() - .expect("Blossom configuration") - .expect("configured Blossom"); - assert_eq!(blossom.host_kind, "physical_device"); - assert_eq!(blossom.endpoint_authority, "public_webpki"); - assert_eq!(blossom.primary_origin, "https://media.example"); - assert_eq!(blossom.fallback_origins, ["https://fallback.example"]); - assert_eq!(blossom.config_fingerprint.len(), 64); - let evidence = runtime - .sdk_blossom_evidence() - .expect("Blossom evidence") - .expect("configured evidence"); - assert_eq!(evidence.schema_version, 2); - assert_eq!(evidence.origin, "https://media.example"); - assert_eq!(evidence.config_fingerprint, blossom.config_fingerprint); - assert_eq!(evidence.state, "configured_unobserved"); - assert_eq!(evidence.transport_security, "public_webpki"); - assert!(evidence.observed_at_unix_ms.is_none()); - assert!(evidence.error_code.is_none()); - assert!(evidence.server_error_code.is_none()); - runtime - .configure_blossom( - FfiBlossomHostKind::Simulator, - FfiBlossomEndpointAuthority::LoopbackDevelopment, - "http://127.0.0.1:3100".to_owned(), - vec![], - ) - .expect("simulator Blossom"); - runtime - .configure_blossom( - FfiBlossomHostKind::Simulator, - FfiBlossomEndpointAuthority::LoopbackDevelopment, - "http://127.0.0.1:1".to_owned(), - vec![], - ) - .expect("unavailable simulator Blossom"); - assert!(runtime.probe_blossom().await.is_err()); - runtime - .configure_blossom( - FfiBlossomHostKind::PhysicalDevice, - FfiBlossomEndpointAuthority::PrivateNetworkDevelopment, - "https://10.0.0.5:3100".to_owned(), - vec![], - ) - .expect("device Blossom"); - - assert_eq!( - runtime - .phase1_card_add_parity() - .into_iter() - .map(|item| item.card_type) - .collect::<Vec<_>>(), - vec![ - FfiTodayCardType::Update, - FfiTodayCardType::PhotoUpdate, - FfiTodayCardType::Ask, - FfiTodayCardType::Event, - FfiTodayCardType::FoodAvailability, - ] - ); - assert_eq!( - runtime - .phase1_add_schemas() - .into_iter() - .map(|schema| schema.command_type) - .collect::<Vec<_>>(), - vec![ - FfiAddCommandType::CreateUpdate, - FfiAddCommandType::CreatePhotoUpdate, - FfiAddCommandType::CreateAsk, - FfiAddCommandType::CreateEvent, - FfiAddCommandType::CreateFoodAvailability, - ] - ); - let parity = runtime.phase1_card_add_parity(); - let schemas = runtime.phase1_add_schemas(); - assert_eq!(parity.len(), 5); - for (index, item) in parity.iter().enumerate() { - assert_eq!(item.command_type, schemas[index].command_type); - } - assert_eq!( - schemas[1] - .fields - .iter() - .find(|field| field.id == "media") - .and_then(|field| field.max_items), - Some(20) - ); - assert_eq!( - schemas[3] - .fields - .iter() - .find(|field| field.id == "media") - .and_then(|field| field.max_items), - Some(1) - ); - let local_network = runtime - .phase1_local_network(FfiLocalNetworkRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - id: "nearby".to_owned(), - label: "Near me".to_owned(), - relay_urls: vec!["ws://192.168.1.7:7447".to_owned()], - locality: Some("u10h".to_owned()), - followed_authors: vec!["a".repeat(64)], - generation: 1, - }) - .expect("valid local network"); - assert_eq!(local_network.id, "nearby"); - assert!( - runtime - .phase1_local_network(FfiLocalNetworkRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - id: "public-host".to_owned(), - label: "Public host".to_owned(), - relay_urls: vec!["wss://relay.example".to_owned()], - locality: None, - followed_authors: vec![], - generation: 1, - }) - .is_err() - ); - let refresh = runtime - .phase1_refresh_today( - local_network.clone(), - 1_800_000_001, - FfiTodayProjectionUpdate::Incremental, - ) - .await - .expect("empty Today refresh"); - assert_eq!(refresh.update, FfiTodayProjectionUpdate::Incremental); - assert_eq!(refresh.source_events, 0); - assert_eq!(refresh.visible_cards, 0); - assert!(refresh.content_generation > 0); - let today = runtime - .phase1_today_page(local_network.clone(), 20, Some(1_800_000_001), None) - .await - .expect("empty Today page"); - assert!(today.items.is_empty()); - assert!(today.next_cursor.is_none()); - assert!( - runtime - .phase1_today_page(local_network.clone(), 20, None, None) - .await - .is_err() - ); - assert!( - runtime - .phase1_today_page( - local_network.clone(), - 20, - Some(1_800_000_001), - Some("opaque".to_owned()), - ) - .await - .is_err() - ); - assert!( - runtime - .phase1_search( - local_network.clone(), - "carrots".to_owned(), - 20, - 1_800_000_001, - ) - .await - .expect("empty search") - .is_empty() - ); - let me = runtime - .phase1_me(local_network.clone(), 1_800_000_001) - .await - .expect("Me snapshot"); - assert_eq!(me.public_key, support::PUBLIC_KEY); - - let add = FfiAddDraftInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type: FfiAddCommandType::CreateUpdate, - content: "Farm stand opens at noon".to_owned(), - identifier: None, - title: None, - summary: None, - location: None, - event_timing: None, - event_start_date: None, - event_end_date: None, - event_start_unix_s: None, - event_end_unix_s: None, - event_timezone: None, - price_amount: None, - currency: None, - unit: None, - quantity: None, - food_published_at_unix_s: None, - food_status: None, - media: Vec::new(), - }; - runtime - .phase1_validate_add_draft(add.clone(), 1_800_000_001) - .expect("valid draft"); - let saved = runtime - .phase1_save_add_intent(add, None, None) - .await - .expect("saved draft"); - let draft_id = saved.draft_id.clone(); - assert_eq!(draft_id.len(), 32); - assert_eq!(saved.state, FfiOutboxState::Draft); - assert_eq!(saved.kind, FfiDraftKind::Add); - assert_eq!( - saved.form.as_ref().map(|form| form.content.as_str()), - Some("Farm stand opens at noon") - ); - assert_eq!( - runtime - .phase1_draft_status(draft_id.clone()) - .await - .expect("draft status") - .revision, - saved.revision - ); - assert_eq!( - runtime - .phase1_draft_heads(10) - .await - .expect("draft heads") - .len(), - 1 - ); - let queued = runtime - .phase1_queue_add_intent(draft_id.clone(), saved.revision) - .await - .expect("queued draft"); - assert_eq!(queued.state, FfiOutboxState::Queued); - let recovered = runtime - .phase1_recover_add_intent(draft_id.clone()) - .await - .expect("recovered queue"); - assert_eq!(recovered.revision, queued.revision); - let cancelled = runtime - .phase1_cancel_add_intent(draft_id.clone(), recovered.revision) - .await - .expect("cancelled draft"); - assert_eq!(cancelled.state, FfiOutboxState::Cancelled); - assert!( - runtime - .phase1_save_add_intent( - FfiAddDraftInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type: FfiAddCommandType::CreateEvent, - content: "Market opens Saturday".to_owned(), - identifier: None, - title: Some("Saturday market".to_owned()), - summary: Some("Weekly market".to_owned()), - location: Some("Town square".to_owned()), - event_timing: Some(FfiEventTimingKind::AllDay), - event_start_date: Some("2027-01-02".to_owned()), - event_end_date: None, - event_start_unix_s: None, - event_end_unix_s: None, - event_timezone: None, - price_amount: None, - currency: None, - unit: None, - quantity: None, - food_published_at_unix_s: None, - food_status: None, - media: Vec::new(), - }, - None, - Some(1), - ) - .await - .is_err() - ); - assert!( - runtime - .phase1_advance_draft(draft_id.clone(), cancelled.revision) - .await - .is_err() - ); - - let retraction_id = "0a".repeat(16); - let retraction_input = FfiRetractionDraftInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type: FfiAddCommandType::CreateUpdate, - target_card_id: "c".repeat(64), - target_event_id: "a".repeat(64), - target_kind: 1, - target_address: None, - reason: "Replaced with a corrected copy".to_owned(), - }; - let mut unsupported_retraction = retraction_input.clone(); - unsupported_retraction.schema_version += 1; - assert_eq!( - runtime - .phase1_save_retraction_draft( - retraction_id.clone(), - unsupported_retraction, - 1_800_000_005, - 1_800_000_005_000, - ) - .await - .expect_err("unsupported retraction schema") - .report() - .code, - "unsupported_schema_version" - ); - let retraction = runtime - .phase1_save_retraction_draft( - retraction_id.clone(), - retraction_input, - 1_800_000_005, - 1_800_000_005_000, - ) - .await - .expect("saved retraction"); - assert_eq!(retraction.kind, FfiDraftKind::Retraction); - assert_eq!(retraction.card_id, "c".repeat(64)); - assert!(retraction.form.is_none()); - let queued_retraction = runtime - .phase1_queue_draft( - retraction_id.clone(), - retraction.revision, - FfiQueuePolicyRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - relay_urls: vec!["wss://write.example".to_owned()], - satisfaction: FfiRelaySatisfaction::AllAccepted, - delivery_deadline_unix_ms: 1_800_100_000_000, - cancellation: FfiCancellationPolicy::LocalCooperative, - }, - 1_800_000_006_000, - ) - .await - .expect("queued retraction"); - let cancelled_retraction = runtime - .phase1_cancel_draft(retraction_id, queued_retraction.revision, 1_800_000_007_000) - .await - .expect("cancelled retraction"); - assert_eq!(cancelled_retraction.state, FfiOutboxState::Cancelled); - - let upload = FfiBlossomUploadIntent { - schema_version: MOBILE_FFI_SCHEMA_VERSION + 1, - draft_id, - expected_revision: cancelled.revision, - media: FfiPreparedMediaInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - opaque_reference: "media:unused".to_owned(), - file_descriptor: 0, - sha256: "00".repeat(32), - media_type: "image/png".to_owned(), - byte_size: 1, - width: 1, - height: 1, - alt: "unused".to_owned(), - prepared_at_unix_s: 1_800_000_000, - }, - }; - let upload_error = runtime - .phase1_upload_add_media_intent(upload.clone()) - .await - .expect_err("unsupported upload schema"); - assert_eq!(upload_error.report().code, "unsupported_schema_version"); - assert_eq!( - runtime - .phase1_prepare_add_media_background(upload.clone()) - .await - .expect_err("unsupported background upload schema") - .report() - .code, - "unsupported_schema_version" - ); - assert_eq!( - runtime - .phase1_complete_add_media_background(FfiNativeUploadCompletionInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION + 1, - draft_id: upload.draft_id.clone(), - expected_revision: upload.expected_revision, - media: upload.media.clone(), - status_code: 200, - response_media_type: Some("application/json".to_owned()), - response_content_encoding: None, - response_body: Vec::new(), - }) - .await - .expect_err("unsupported completion schema") - .report() - .code, - "invalid_native_upload_completion" - ); - assert_eq!( - runtime - .phase1_complete_add_media_background(FfiNativeUploadCompletionInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - draft_id: upload.draft_id.clone(), - expected_revision: upload.expected_revision, - media: upload.media.clone(), - status_code: 200, - response_media_type: Some("application/json".to_owned()), - response_content_encoding: None, - response_body: vec![0; 16_385], - }) - .await - .expect_err("oversized completion body") - .report() - .code, - "invalid_native_upload_completion" - ); - let mut invalid_id_upload = upload; - invalid_id_upload.schema_version = MOBILE_FFI_SCHEMA_VERSION; - invalid_id_upload.draft_id = "not-a-draft-id".to_owned(); - assert_eq!( - runtime - .phase1_upload_add_media_intent(invalid_id_upload.clone()) - .await - .expect_err("invalid draft id") - .report() - .code, - "invalid_draft_id" - ); - assert_eq!( - runtime - .phase1_prepare_add_media_background(invalid_id_upload.clone()) - .await - .expect_err("invalid background draft id") - .report() - .code, - "invalid_draft_id" - ); - assert_eq!( - runtime - .phase1_complete_add_media_background(FfiNativeUploadCompletionInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - draft_id: invalid_id_upload.draft_id, - expected_revision: invalid_id_upload.expected_revision, - media: invalid_id_upload.media, - status_code: 200, - response_media_type: Some("application/json".to_owned()), - response_content_encoding: None, - response_body: Vec::new(), - }) - .await - .expect_err("invalid completion draft id") - .report() - .code, - "invalid_draft_id" - ); - assert!( - runtime - .phase1_local_network(FfiLocalNetworkRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - id: "nearby".to_owned(), - label: "Near me".to_owned(), - relay_urls: Vec::new(), - locality: None, - followed_authors: Vec::new(), - generation: 1, - }) - .is_err() - ); - - let initial_settings = runtime.phase1_settings().await.expect("settings"); - let begun = runtime - .phase1_apply_identity_command( - initial_settings.revision, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::BeginImport, - operation_id: Some("import-ffi-1".to_owned()), - identity_id: None, - public_key: None, - }, - ) - .await - .expect("begin identity import"); - let completed = runtime - .phase1_apply_identity_command( - begun.settings.revision, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::CompleteImport, - operation_id: Some("import-ffi-1".to_owned()), - identity_id: Some("primary".to_owned()), - public_key: Some(support::PUBLIC_KEY.to_owned()), - }, - ) - .await - .expect("complete identity import"); - let unlocked = runtime - .phase1_apply_identity_command( - completed.settings.revision, - FfiIdentityCommandRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - kind: FfiIdentityCommandKind::Unlock, - operation_id: None, - identity_id: None, - public_key: None, - }, - ) - .await - .expect("record process-local unlock"); - assert_eq!( - unlocked.settings.identity.lock_state, - FfiIdentityLockState::Unlocked - ); - assert_eq!(unlocked.settings.revision, completed.settings.revision); - - let source_event_id = "ab".repeat(32); - let source = radroots_mobile_core::runtime::product_surface::CardSourceIdentity::Event( - radroots_event::EventId::parse(&source_event_id).expect("source event id"), - ); - let card_id = radroots_mobile_core::runtime::product_surface::CardId::derive( - radroots_mobile_core::runtime::product_surface::TodayCardType::Update, - &source, - ) - .to_hex(); - let revision = runtime - .phase1_save_revision_intent(FfiRevisionInputRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - card_id, - source_event_id, - source_address: None, - author_public_key: support::PUBLIC_KEY.to_owned(), - replacement: FfiAddDraftInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type: FfiAddCommandType::CreateUpdate, - content: "Corrected farm stand hours".to_owned(), - identifier: None, - title: None, - summary: None, - location: None, - event_timing: None, - event_start_date: None, - event_end_date: None, - event_start_unix_s: None, - event_end_unix_s: None, - event_timezone: None, - price_amount: None, - currency: None, - unit: None, - quantity: None, - food_published_at_unix_s: None, - food_status: None, - media: Vec::new(), - }, - }) - .await - .expect("save lossless revision intent"); - assert_eq!(revision.phase, FfiRevisionPhase::ReplacementPending); - assert_eq!(revision.operation_id, revision.replacement.draft_id); - assert!(revision.replacement.is_revision); - assert_eq!( - runtime - .phase1_revision_status(revision.operation_id.clone()) - .await - .expect("revision status") - .phase, - FfiRevisionPhase::ReplacementPending - ); - assert!( - runtime - .phase1_advance_revision(revision.operation_id.clone()) - .await - .is_err() - ); - let cancelled_revision = runtime - .phase1_cancel_revision(revision.operation_id.clone()) - .await - .expect("cancel revision intent"); - assert_eq!(cancelled_revision.operation_id, revision.operation_id); - assert_eq!(cancelled_revision.phase, FfiRevisionPhase::Cancelled); - - let profile = runtime - .phase1_save_profile_metadata(FfiProfileMetadataInputRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - name: "grower".to_owned(), - display_name: Some("Local Grower".to_owned()), - about: Some("Seasonal produce".to_owned()), - picture: None, - banner: None, - nip05: Some("grower@farm.example".to_owned()), - bot: Some(false), - }) - .await - .expect("save profile intent"); - assert_eq!(profile.state, FfiOutboxState::Draft); - assert_eq!(profile.operation_id.len(), 32); - assert_eq!( - runtime - .phase1_profile_status(profile.operation_id.clone()) - .await - .expect("profile status") - .revision, - profile.revision - ); - let cancelled_profile = runtime - .phase1_cancel_profile(profile.operation_id.clone(), profile.revision) - .await - .expect("cancel profile intent"); - assert_eq!(cancelled_profile.operation_id, profile.operation_id); - assert_eq!(cancelled_profile.state, FfiOutboxState::Cancelled); - assert_eq!( - runtime - .phase1_advance_profile(profile.operation_id.clone()) - .await - .expect("advance cancelled profile") - .state, - FfiOutboxState::Cancelled - ); - - let cache = runtime - .phase1_media_cache_status(local_network.clone()) - .await - .expect("empty media cache status"); - assert_eq!(cache.artifact_count, 0); - assert_eq!(cache.total_bytes, 0); - assert!(cache.configuration_fingerprint.is_none()); - assert!( - runtime - .phase1_verified_media_artifact(local_network.clone(), "11".repeat(32)) - .await - .expect("empty verified media lookup") - .is_none() - ); - assert!( - !runtime - .phase1_invalidate_media_artifact(local_network.clone(), "11".repeat(32)) - .await - .expect("missing media invalidation") - ); - assert!( - runtime - .phase1_invalidate_media_configuration(local_network.clone(), "22".repeat(32)) - .await - .expect("empty configuration invalidation") - .is_empty() - ); - let media_operation = Arc::new(FfiMediaOperation::new().expect("media operation")); - let media_error = runtime - .phase1_retrieve_media( - local_network.clone(), - "invalid-reference".to_owned(), - Arc::clone(&media_operation), - ) - .await - .expect_err("invalid media reference"); - assert_eq!( - media_error.report().code, - "invalid_media_reference_fingerprint" - ); - assert_eq!( - runtime - .phase1_retrieve_media(local_network, "00".repeat(32), Arc::clone(&media_operation),) - .await - .expect_err("single-use media operation") - .report() - .code, - "media_operation_already_used" - ); - - runtime.shutdown().await.expect("shutdown"); - assert!(matches!( - runtime.sdk_storage_status().await, - Err(RadrootsAppError::Failure { .. }) - )); - assert!(matches!( - runtime.sdk_relay_status(), - Err(RadrootsAppError::Failure { .. }) - )); - assert!(matches!( - runtime.configure_public_relays(Vec::new()), - Err(RadrootsAppError::Failure { .. }) - )); -} diff --git a/crates/mobile_ffi/tests/runtime_lifecycle.rs b/crates/mobile_ffi/tests/runtime_lifecycle.rs @@ -1,44 +0,0 @@ -use std::{sync::Arc, time::Duration}; - -use radroots_mobile_ffi::RadrootsAppError; - -mod support; - -#[tokio::test] -async fn host_release_ordering_retains_close_and_finishes_within_deadline() { - let (_root, runtime) = support::runtime().await; - let host = Arc::new(runtime); - let closing_owner = Arc::clone(&host); - let close = tokio::spawn(async move { closing_owner.shutdown().await }); - drop(host); - - let result = tokio::time::timeout(Duration::from_secs(1), close) - .await - .expect("mobile shutdown exceeded its host deadline") - .expect("shutdown task panicked") - .expect("shutdown failed"); - assert_eq!(result.state, "closed"); - assert!(!result.already_closed); -} - -#[tokio::test] -async fn concurrent_host_references_converge_and_repeated_close_is_idempotent() { - let (_root, runtime) = support::runtime().await; - let runtime = Arc::new(runtime); - let first = Arc::clone(&runtime); - let second = Arc::clone(&runtime); - let (first, second) = tokio::join!(first.shutdown(), second.shutdown()); - - for outcome in [&first, &second] { - assert!( - outcome.is_ok() - || matches!( - outcome, - Err(RadrootsAppError::Failure { report }) - if report.code == "client_close_in_progress" - ) - ); - } - let repeated = runtime.shutdown().await.expect("repeated close"); - assert!(repeated.already_closed); -} diff --git a/crates/mobile_ffi/tests/subscription_contract.rs b/crates/mobile_ffi/tests/subscription_contract.rs @@ -1,67 +0,0 @@ -use std::sync::mpsc::{Receiver, Sender, channel}; -use std::time::Duration; - -use radroots_mobile_ffi::{FfiRuntimeChangeKind, FfiRuntimeChangeRecord, RadrootsRuntimeObserver}; - -mod support; - -struct Observer(Sender<FfiRuntimeChangeRecord>); - -impl RadrootsRuntimeObserver for Observer { - fn on_change(&self, change: FfiRuntimeChangeRecord) { - let _ = self.0.send(change); - } -} - -fn observer() -> ( - Box<dyn RadrootsRuntimeObserver>, - Receiver<FfiRuntimeChangeRecord>, -) { - let (sender, receiver) = channel(); - (Box::new(Observer(sender)), receiver) -} - -fn receive(receiver: &Receiver<FfiRuntimeChangeRecord>) -> FfiRuntimeChangeRecord { - receiver - .recv_timeout(Duration::from_secs(1)) - .expect("bounded observer delivery") -} - -#[tokio::test] -async fn subscriptions_are_independent_bounded_handles_and_stop_individually() { - let (_root, runtime) = support::runtime().await; - let (first_observer, first_receiver) = observer(); - let (second_observer, second_receiver) = observer(); - let first = runtime - .subscribe_changes(first_observer) - .expect("first subscription"); - let second = runtime - .subscribe_changes(second_observer) - .expect("second subscription"); - - assert_eq!(receive(&first_receiver).kind, FfiRuntimeChangeKind::Initial); - assert_eq!( - receive(&second_receiver).kind, - FfiRuntimeChangeKind::Initial - ); - first.unsubscribe(); - assert!(!first.is_active()); - assert!(second.is_active()); - - runtime - .configure_public_relays(vec!["wss://write.example".to_owned()]) - .expect("relay configuration"); - assert_eq!(receive(&second_receiver).kind, FfiRuntimeChangeKind::Relay); - assert!( - first_receiver - .recv_timeout(Duration::from_millis(50)) - .is_err() - ); - - runtime.shutdown().await.expect("shutdown"); - assert_eq!( - receive(&second_receiver).kind, - FfiRuntimeChangeKind::Lifecycle - ); - assert!(!second.is_active()); -} diff --git a/crates/mobile_ffi/tests/support/mod.rs b/crates/mobile_ffi/tests/support/mod.rs @@ -1,24 +0,0 @@ -use radroots_mobile_ffi::{ProtectedDataAvailability, RadrootsRuntime}; - -pub const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; -pub const GENERATION: &str = "0404040404040404040404040404040404040404040404040404040404040404"; - -pub fn prepare(root: &std::path::Path) { - std::fs::create_dir_all(root.join("radroots").join("users").join(PUBLIC_KEY)) - .expect("owner directory"); -} - -pub async fn runtime() -> (tempfile::TempDir, RadrootsRuntime) { - let root = tempfile::tempdir().expect("tempdir"); - prepare(root.path()); - let runtime = RadrootsRuntime::new( - root.path().to_string_lossy().into_owned(), - PUBLIC_KEY.to_owned(), - GENERATION.to_owned(), - 1_800_000_000_000, - ProtectedDataAvailability::Available, - ) - .await - .expect("runtime"); - (root, runtime) -} diff --git a/crates/mobile_ffi/tests/uniffi_contract.rs b/crates/mobile_ffi/tests/uniffi_contract.rs @@ -1,258 +0,0 @@ -use radroots_mobile_ffi::{ - FfiAddCommandType, FfiAddDraftInput, FfiCancellationPolicy, FfiMediaOperation, - FfiQueuePolicyRecord, FfiRelaySatisfaction, FfiTradeEvidenceCoverage, FfiTradeEvidenceOutcome, - HostSigningOutcome, HostSigningRequest, HostSigningResult, MOBILE_FFI_SCHEMA_VERSION, - ProtectedDataAvailability, RadrootsAppError, RadrootsHostSigner, RadrootsRuntime, - SignerAvailabilityRecord, SignerStatusRecord, -}; -use secp256k1::{Keypair, Message, Secp256k1, SecretKey}; -use std::sync::{Arc, Mutex}; - -mod support; - -struct TestHostSigner(Arc<Mutex<HostSigningOutcome>>); - -#[async_trait::async_trait] -impl RadrootsHostSigner for TestHostSigner { - async fn signer_status(&self) -> SignerStatusRecord { - SignerStatusRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - availability: SignerAvailabilityRecord::Ready, - } - } - - async fn sign(&self, request: HostSigningRequest) -> HostSigningResult { - let outcome = *self - .0 - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let signature_hex = (outcome == HostSigningOutcome::Signed).then(|| { - let mut secret_bytes = [0; 32]; - secret_bytes[31] = 1; - let secret = SecretKey::from_slice(&secret_bytes).expect("fixture secret key"); - let keypair = Keypair::from_secret_key(&Secp256k1::new(), &secret); - let digest: [u8; 32] = request - .event_id_digest - .clone() - .try_into() - .expect("32-byte event ID digest"); - assert_eq!(hex::encode(digest), request.expected_event_id); - assert_eq!( - keypair.x_only_public_key().0.to_string(), - request.public_key - ); - let message = Message::from_digest(digest); - let signature = Secp256k1::new().sign_schnorr_no_aux_rand(&message, &keypair); - Secp256k1::new() - .verify_schnorr(&signature, &message, &keypair.x_only_public_key().0) - .expect("fixture host signature verifies"); - signature.to_string() - }); - let completed_at_unix_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock after epoch") - .as_millis() - .try_into() - .expect("current time fits u64"); - assert!(completed_at_unix_ms < request.deadline_unix_ms); - HostSigningResult { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - outcome, - operation_id: request.operation_id, - signer_request_id: request.signer_request_id, - public_key: request.public_key, - purpose: request.purpose, - signature_hex, - completed_at_unix_ms, - } - } -} - -#[test] -fn swift_module_names_preserve_the_host_contract() { - let config = include_str!("../uniffi.toml"); - assert_eq!( - config, - "[bindings.swift]\nmodule_name = \"RadrootsKitBindings\"\nffi_module_name = \"RadrootsFFI\"\n" - ); -} - -#[test] -fn final_evidence_vocabularies_are_exact_at_the_mobile_boundary() { - assert_eq!( - [ - FfiTradeEvidenceCoverage::Missing, - FfiTradeEvidenceCoverage::Partial, - FfiTradeEvidenceCoverage::ScopeSatisfied, - FfiTradeEvidenceCoverage::Unsupported, - ] - .len(), - 4 - ); - assert_eq!( - [ - FfiTradeEvidenceOutcome::Valid, - FfiTradeEvidenceOutcome::Invalid, - FfiTradeEvidenceOutcome::Indeterminate, - ] - .len(), - 3 - ); -} - -#[test] -fn media_cancellation_handle_owns_one_stable_opaque_operation_identity() { - let operation = FfiMediaOperation::new().expect("media operation"); - let operation_id = operation.operation_id(); - assert_eq!(operation_id.len(), 32); - assert!(!operation.is_cancelled()); - operation.cancel(); - assert!(operation.is_cancelled()); - assert_eq!(operation.operation_id(), operation_id); -} - -#[tokio::test] -async fn protected_data_failure_is_typed_and_opens_no_store() { - let root = tempfile::tempdir().expect("tempdir"); - support::prepare(root.path()); - let result = RadrootsRuntime::new( - root.path().to_string_lossy().into_owned(), - support::PUBLIC_KEY.to_owned(), - support::GENERATION.to_owned(), - 1_800_000_000_000, - ProtectedDataAvailability::Unavailable, - ) - .await; - let Err(RadrootsAppError::Failure { report }) = result else { - panic!("protected data failure must remain typed across UniFFI"); - }; - assert_eq!(report.code, "protected_data_unavailable"); - assert!(report.retryable); - assert!( - !root - .path() - .join("radroots/users") - .join(support::PUBLIC_KEY) - .join("runtime.sqlite") - .exists() - ); -} - -#[tokio::test] -async fn final_mobile_abi_uses_async_sdk_dtos_and_versioned_errors() { - let (_root, runtime) = support::runtime().await; - let storage = runtime.sdk_storage_status().await.expect("storage status"); - assert_eq!(storage.backend, "sqlite"); - - runtime.shutdown().await.expect("shutdown"); - let error = runtime - .sdk_storage_status() - .await - .expect_err("closed client must reject operations"); - let RadrootsAppError::Failure { report } = error; - assert_eq!(report.schema_version, 1); - assert_eq!(report.code, "client_closed"); - assert_eq!(report.category, "runtime"); - assert!(!report.retryable); - assert_eq!(report.safe_message, "SDK client is closed"); -} - -#[tokio::test] -async fn host_signer_constructor_exposes_only_an_opaque_configured_boundary() { - let root = tempfile::tempdir().expect("tempdir"); - support::prepare(root.path()); - let outcome = Arc::new(Mutex::new(HostSigningOutcome::Rejected)); - let runtime = RadrootsRuntime::with_host_signer( - root.path().to_string_lossy().into_owned(), - support::PUBLIC_KEY.to_owned(), - support::GENERATION.to_owned(), - 1_800_000_000_000, - ProtectedDataAvailability::Available, - Box::new(TestHostSigner(Arc::clone(&outcome))), - ) - .await - .expect("runtime with host signer"); - - let identity = runtime.identity_status().expect("identity status"); - assert_eq!(identity.public_key, support::PUBLIC_KEY); - assert!(identity.host_signer_configured); - - for (index, host_outcome) in [ - HostSigningOutcome::Signed, - HostSigningOutcome::Locked, - HostSigningOutcome::Cancelled, - HostSigningOutcome::Rejected, - HostSigningOutcome::TimedOut, - HostSigningOutcome::Unavailable, - HostSigningOutcome::Invalidated, - HostSigningOutcome::Failed, - ] - .into_iter() - .enumerate() - { - *outcome - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = host_outcome; - let draft_id = format!("{:02x}", index + 17).repeat(16); - let saved = runtime - .phase1_save_draft( - draft_id.clone(), - FfiAddDraftInput { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - command_type: FfiAddCommandType::CreateUpdate, - content: format!("Signer boundary test {index}"), - identifier: None, - title: None, - summary: None, - location: None, - event_timing: None, - event_start_date: None, - event_end_date: None, - event_start_unix_s: None, - event_end_unix_s: None, - event_timezone: None, - price_amount: None, - currency: None, - unit: None, - quantity: None, - food_published_at_unix_s: None, - food_status: None, - media: Vec::new(), - }, - 1_800_000_000 + index as u64, - None, - 1_800_000_000_000 + index as u64, - ) - .await - .expect("saved draft"); - let queued = runtime - .phase1_queue_draft( - draft_id.clone(), - saved.revision, - FfiQueuePolicyRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - relay_urls: vec!["wss://relay.example".to_owned()], - satisfaction: FfiRelaySatisfaction::AnyAccepted, - delivery_deadline_unix_ms: u64::MAX, - cancellation: FfiCancellationPolicy::PreservePublishedRequest, - }, - 1_800_000_001_000 + index as u64, - ) - .await - .expect("queued draft"); - let signing_result = runtime - .phase1_sign_queued_draft(draft_id, queued.revision) - .await; - if host_outcome == HostSigningOutcome::Signed { - assert_eq!( - signing_result.expect("valid host signature").state, - radroots_mobile_ffi::FfiOutboxState::Signed - ); - } else { - let signing_error = signing_result.expect_err("host failure remains typed"); - assert_eq!(signing_error.report().category, "authoring"); - assert!(!signing_error.report().safe_message.contains("signature")); - } - } - runtime.shutdown().await.expect("shutdown"); -} diff --git a/crates/mobile_ffi/uniffi.toml b/crates/mobile_ffi/uniffi.toml @@ -1,3 +0,0 @@ -[bindings.swift] -module_name = "RadrootsKitBindings" -ffi_module_name = "RadrootsFFI" diff --git a/crates/mobile_wasm/Cargo.toml b/crates/mobile_wasm/Cargo.toml @@ -1,21 +0,0 @@ -[package] -name = "radroots_mobile_wasm" -version = "0.1.0-alpha" -edition.workspace = true -authors = ["Radroots Authors"] -rust-version.workspace = true -license = "GPL-3.0-or-later" -description = "WebAssembly bindings for Radroots web apps" -repository.workspace = true -homepage.workspace = true -readme.workspace = true -publish = false -include = ["src/**", "Cargo.toml"] - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -radroots_mobile_core = { workspace = true, default-features = false } -serde_json = { workspace = true } -wasm-bindgen = { workspace = true } diff --git a/crates/mobile_wasm/src/lib.rs b/crates/mobile_wasm/src/lib.rs @@ -1,39 +0,0 @@ -#![forbid(unsafe_code)] - -use wasm_bindgen::prelude::wasm_bindgen; - -#[wasm_bindgen] -pub fn radroots_mobile_build_info_json() -> String { - serde_json::to_string(&radroots_mobile_core::runtime::info::app_build_info()) - .expect("static build information must serialize") -} - -#[allow( - clippy::if_same_then_else, - reason = "coverage probe intentionally exercises both paths with a stable value" -)] -pub fn coverage_branch_probe(input: bool) -> &'static str { - if input { - "radroots_mobile_wasm" - } else { - "radroots_mobile_wasm" - } -} - -#[cfg(test)] -mod tests { - use super::{coverage_branch_probe, radroots_mobile_build_info_json}; - - #[test] - fn radroots_mobile_build_info_json_contains_runtime_keys() { - let json = radroots_mobile_build_info_json(); - assert!(json.contains("\"crate_name\"")); - assert!(json.contains("radroots_mobile_core")); - } - - #[test] - fn coverage_branch_probe_hits_both_paths() { - assert_eq!(coverage_branch_probe(true), "radroots_mobile_wasm"); - assert_eq!(coverage_branch_probe(false), "radroots_mobile_wasm"); - } -} diff --git a/tools/xtask/src/build_control.rs b/tools/xtask/src/build_control.rs @@ -95,8 +95,8 @@ impl ConsumerRoot { .map_err(|error| format!("consumer marker is not UTF-8: {error}"))? .trim() .to_owned(); - if !matches!(product.as_str(), "sdk" | "mobile" | "myc" | "rhi") { - return Err("consumer marker must contain sdk, mobile, myc, or rhi".to_owned()); + if !matches!(product.as_str(), "sdk" | "myc" | "rhi") { + return Err("consumer marker must contain sdk, myc, or rhi".to_owned()); } let source_lock_path = canonical.join(SOURCE_LOCK_NAME); let source_lock = parse_source_lock(&source_lock_path)?; @@ -538,7 +538,6 @@ pub fn artifact( } let external_names = match product { "sdk" => vec!["radroots", "radroots_sdk"], - "mobile" => vec!["RadrootsFFI", "RadrootsKitBindings"], _ => return Err("unsupported artifact product".to_owned()), }; let manifest = ArtifactManifest { @@ -607,10 +606,6 @@ fn validate_artifact_route(product: &str, target: &str, language: &str) -> Resul | ("ffi", "swift") | ("ffi", "kotlin") ), - "mobile" => matches!( - (target, language), - ("ios", "swift") | ("android", "kotlin") | ("wasm", "javascript") - ), _ => false, }; if valid { @@ -1163,6 +1158,37 @@ mod tests { } #[test] + fn retired_application_consumers_and_artifact_routes_fail_closed() { + for product in ["mobile", "tera"] { + let fixture = Fixture::new(product); + assert!(ConsumerRoot::open(&fixture.consumer).is_err()); + for (target, language) in [ + ("ios", "swift"), + ("android", "kotlin"), + ("wasm", "javascript"), + ] { + assert!(validate_artifact_route(product, target, language).is_err()); + assert!( + artifact( + product, + target, + language, + Mode::Write, + &fixture.consumer, + &fixture.source, + Path::new("generated/retired.json"), + 1, + "fixture", + &[], + ) + .is_err() + ); + } + assert!(!fixture.consumer.join("generated").exists()); + } + } + + #[test] fn source_lock_supports_a_contained_nested_lockfile() { let mut fixture = Fixture::new("sdk"); let core = fixture.consumer.join("core"); @@ -1189,7 +1215,7 @@ mod tests { #[test] fn consumer_manifest_discovery_skips_swiftpm_build_output_only() { - let fixture = Fixture::new("mobile"); + let fixture = Fixture::new("sdk"); let swiftpm_checkout = fixture.consumer.join(".build/checkouts/dependency"); fs::create_dir_all(&swiftpm_checkout).expect("SwiftPM checkout directory"); fs::write(swiftpm_checkout.join("Cargo.toml"), "not valid TOML") diff --git a/tools/xtask/src/catalog.rs b/tools/xtask/src/catalog.rs @@ -23,6 +23,39 @@ const CATALOG_SCHEMA: &str = "radroots.workspace.catalog.v2"; const RELEASE_ID: &str = "radroots.crates.release.v2"; const CONSOLIDATION_ID: &str = "radroots.rust.consolidation.v1"; const VERSION: &str = "0.1.0-alpha"; +const APPLICATION_RETIREMENT: &str = + "contracts/architecture/decisions/tera_application_ownership.v1.json"; +const RETIRED_APPLICATION_PACKAGES: [&str; 4] = [ + "radroots_mobile_bindgen", + "radroots_mobile_core", + "radroots_mobile_ffi", + "radroots_mobile_wasm", +]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ApplicationRetirement { + schema: String, + source_repository: String, + source_catalog_revision: String, + source_catalog_sha256: String, + transfer_donor_commit: String, + transfer_donor_tree: String, + target_repository: String, + target_revision: String, + transfer_evidence_sha256: String, + package: Vec<RetiredApplication>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RetiredApplication { + former_catalog_entry: CatalogPackage, + target_name: String, + target_path: String, + projected_commit: String, + projected_tree: String, +} #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -145,6 +178,7 @@ struct CargoPackage { #[derive(Debug, Deserialize)] struct CargoDependency { name: String, + source: Option<String>, req: String, path: Option<String>, kind: Option<String>, @@ -184,6 +218,7 @@ fn active_packages_matching( ) -> Result<Vec<String>, String> { let catalog = parse_file::<Catalog>(workspace_root, CATALOG_RELATIVE)?; validate_catalog(&catalog)?; + validate_application_retirement(&catalog, workspace_root)?; let mut packages = catalog .package .iter() @@ -232,6 +267,7 @@ fn load_and_validate( let consolidation = parse_file::<ConsolidationV1>(workspace_root, CONSOLIDATION_RELATIVE)?; let coverage = parse_file::<CoveragePolicy>(workspace_root, COVERAGE_RELATIVE)?; validate_catalog(&catalog)?; + validate_application_retirement(&catalog, workspace_root)?; validate_coverage_authority(&catalog, &coverage)?; validate_release(&release, &catalog, workspace_root)?; validate_consolidation(&consolidation)?; @@ -402,7 +438,6 @@ fn validate_catalog(catalog: &Catalog) -> Result<(), String> { let required_groups = BTreeSet::from([ "boundaries", "coverage_required", - "mobile", "portable", "preview", "public_native", @@ -756,6 +791,13 @@ fn validate_workspace_manifest(catalog: &Catalog, workspace_root: &Path) -> Resu .and_then(|workspace| workspace.get("dependencies")) .and_then(toml::Value::as_table) .ok_or_else(|| "Cargo.toml lacks [workspace.dependencies]".to_owned())?; + for (name, value) in dependencies { + let name = value + .get("package") + .and_then(toml::Value::as_str) + .unwrap_or(name); + validate_foundation_dependency(name, value.get("git").and_then(toml::Value::as_str))?; + } for package in catalog .package .iter() @@ -781,6 +823,86 @@ fn validate_workspace_manifest(catalog: &Catalog, workspace_root: &Path) -> Resu Ok(()) } +fn validate_foundation_dependency(name: &str, source: Option<&str>) -> Result<(), String> { + if expected_retired_packages().contains(name) + || name == "tera" + || name.starts_with("tera_") + || source.is_some_and(|source| source.contains("radrootslabs/tera")) + { + return Err(format!( + "shared foundation cannot depend on retired or application package {name}" + )); + } + Ok(()) +} + +fn validate_application_retirement(catalog: &Catalog, root: &Path) -> Result<(), String> { + let bytes = read_regular_file(root, APPLICATION_RETIREMENT)?; + let retirement: ApplicationRetirement = serde_json::from_slice(&bytes) + .map_err(|error| format!("parse application ownership decision: {error}"))?; + if retirement.schema != "radroots.tera.application-ownership.v1" + || retirement.source_repository != "https://github.com/radrootslabs/lib" + || retirement.target_repository != "https://github.com/radrootslabs/tera" + || retirement.package.len() != RETIRED_APPLICATION_PACKAGES.len() + { + return Err("application ownership decision identity drifted".to_owned()); + } + validate_oid( + &retirement.source_catalog_revision, + "former catalog revision", + )?; + validate_oid( + &retirement.transfer_donor_commit, + "application donor commit", + )?; + validate_oid(&retirement.transfer_donor_tree, "application donor tree")?; + validate_oid(&retirement.target_revision, "application target revision")?; + validate_sha256(&retirement.source_catalog_sha256, "former catalog digest")?; + validate_sha256( + &retirement.transfer_evidence_sha256, + "transfer evidence digest", + )?; + let mut names = BTreeSet::new(); + for transferred in &retirement.package { + let former = &transferred.former_catalog_entry; + if !RETIRED_APPLICATION_PACKAGES.contains(&former.name.as_str()) + || !names.insert(former.name.as_str()) + || !catalog.retired_packages.contains(&former.name) + || catalog + .package + .iter() + .any(|package| package.name == former.name) + || former.state != "active" + || former.publish + || former.license != "GPL-3.0-or-later" + || former.path != format!("crates/{}", former.name.trim_start_matches("radroots_")) + || transferred.target_name != former.name.replacen("radroots_mobile_", "tera_", 1) + || transferred.target_path != format!("core/crates/{}", transferred.target_name) + { + return Err( + "application package retirement or retained attribution drifted".to_owned(), + ); + } + validate_package_provenance(former)?; + validate_oid( + &transferred.projected_commit, + "application projected commit", + )?; + validate_oid(&transferred.projected_tree, "application projected tree")?; + match fs::symlink_metadata(root.join(&former.path)) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("inspect retired application path: {error}")), + Ok(_) => { + return Err(format!( + "retired application path {} remains present", + former.path + )); + } + } + } + Ok(()) +} + fn validate_metadata( catalog: &Catalog, metadata: &CargoMetadata, @@ -848,6 +970,7 @@ fn validate_metadata( return Err(format!("catalog and Cargo metadata drifted for {name}")); } for dependency in &cargo.dependencies { + validate_foundation_dependency(&dependency.name, dependency.source.as_deref())?; let Some(target) = catalog_by_name.get(dependency.name.as_str()) else { continue; }; @@ -1257,6 +1380,10 @@ fn expected_retired_packages() -> BTreeSet<&'static str> { "radroots_app_core", "radroots_app_ffi", "radroots_app_wasm", + "radroots_mobile_bindgen", + "radroots_mobile_core", + "radroots_mobile_ffi", + "radroots_mobile_wasm", "radroots_sdk_xtask", ]) } @@ -1544,6 +1671,82 @@ mod tests { } #[test] + fn retired_application_packages_cannot_return_as_dependencies_or_sources() { + for name in + RETIRED_APPLICATION_PACKAGES + .into_iter() + .chain(["tera", "tera_core", "tera_ffi"]) + { + assert!(validate_foundation_dependency(name, None).is_err()); + } + for source in [ + "git+https://github.com/radrootslabs/tera?rev=immutable", + "https://github.com/radrootslabs/tera.git", + "ssh://git@github.com/radrootslabs/tera.git", + ] { + assert!(validate_foundation_dependency("renamed_application", Some(source)).is_err()); + } + validate_foundation_dependency("radroots_sdk", None).expect("shared SDK remains owned"); + validate_foundation_dependency("radroots_storage_sqlite", None) + .expect("shared storage remains owned"); + let catalog = checked_in_catalog(); + validate_application_retirement(&catalog, &crate::workspace_root()) + .expect("retirement is coherent"); + let fixture = tempfile::TempDir::new().expect("retirement fixture"); + let decision = fixture.path().join(APPLICATION_RETIREMENT); + fs::create_dir_all(decision.parent().expect("decision parent")) + .expect("decision directory"); + fs::copy( + crate::workspace_root().join(APPLICATION_RETIREMENT), + &decision, + ) + .expect("decision"); + validate_application_retirement(&catalog, fixture.path()).expect("no application source"); + fs::create_dir_all(fixture.path().join("crates/mobile_core")).expect("reintroduced source"); + assert!(validate_application_retirement(&catalog, fixture.path()).is_err()); + } + + #[test] + fn retirement_rejects_missing_duplicate_or_modified_package_evidence() { + let fixture = tempfile::TempDir::new().expect("retirement fixture"); + let path = fixture.path().join(APPLICATION_RETIREMENT); + fs::create_dir_all(path.parent().expect("parent")).expect("decision directory"); + let original: serde_json::Value = serde_json::from_slice( + &fs::read(crate::workspace_root().join(APPLICATION_RETIREMENT)).expect("decision"), + ) + .expect("decision JSON"); + let catalog = checked_in_catalog(); + for case in 0..5 { + let mut changed = original.clone(); + match case { + 0 => { + changed["package"].as_array_mut().expect("packages").pop(); + } + 1 => { + changed["package"][1] = changed["package"][0].clone(); + } + 2 => { + changed["package"][0]["former_catalog_entry"]["license"] = "MIT".into(); + } + 3 => { + changed["package"][0]["target_path"] = "../outside".into(); + } + _ => { + changed["unknown"] = true.into(); + } + } + fs::write(&path, serde_json::to_vec(&changed).expect("encode")) + .expect("altered decision"); + assert!(validate_application_retirement(&catalog, fixture.path()).is_err()); + } + let mut reactivated = checked_in_catalog(); + reactivated + .retired_packages + .retain(|name| name != "radroots_mobile_core"); + assert!(validate_catalog(&reactivated).is_err()); + } + + #[test] fn catalog_rejects_version_visibility_license_and_retirement_drift() { let mut catalog = checked_in_catalog(); catalog @@ -1567,8 +1770,8 @@ mod tests { catalog .package .iter_mut() - .find(|package| package.name == "radroots_mobile_core") - .expect("mobile") + .find(|package| package.name == "radroots_sdk_ffi") + .expect("sdk ffi") .version = "0.1.0-alpha.1".to_owned(); assert!(validate_catalog(&catalog).is_err()); diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -339,14 +339,12 @@ enum ServiceReleaseArtifactMode { #[derive(Clone, Copy, Debug, ValueEnum)] enum ArtifactProduct { Sdk, - Mobile, } impl ArtifactProduct { fn as_str(self) -> &'static str { match self { Self::Sdk => "sdk", - Self::Mobile => "mobile", } } } @@ -356,8 +354,6 @@ enum ArtifactTarget { Typescript, Wasm, Ffi, - Ios, - Android, Linux, Macos, Windows, @@ -369,8 +365,6 @@ impl ArtifactTarget { Self::Typescript => "typescript", Self::Wasm => "wasm", Self::Ffi => "ffi", - Self::Ios => "ios", - Self::Android => "android", Self::Linux => "linux", Self::Macos => "macos", Self::Windows => "windows", @@ -489,7 +483,7 @@ fn usage() { " cargo xtask source archive-create --source-root <absolute-directory> --revision <full-sha> --output <absolute-bundle>" ); eprintln!( - " cargo xtask artifact --product <sdk|mobile> --target <target> --language <language> --mode <check|write> --consumer-root <absolute-directory> --source-root <absolute-directory> --output <relative-path> --source-date-epoch <seconds> --builder-id <id>" + " cargo xtask artifact --product sdk --target <target> --language <language> --mode <check|write> --consumer-root <absolute-directory> --source-root <absolute-directory> --output <relative-path> --source-date-epoch <seconds> --builder-id <id>" ); } @@ -581,10 +575,12 @@ fn release_preflight_at(root: &Path) -> Result<(), String> { LaneId::ReleaseContracts => advisory_snapshot::validate_decision(root) .and_then(|_| contract::validate_release_preflight(root)), }; - if result.is_ok() { - LaneState::Pass - } else { - LaneState::Failed + match result { + Ok(()) => LaneState::Pass, + Err(error) => { + eprintln!("release preflight {} failed: {error}", lane.as_str()); + LaneState::Failed + } } }) .map(|_| ()) @@ -1107,34 +1103,20 @@ mod tests { #[test] fn artifact_cli_values_preserve_every_governed_identifier() { - assert_eq!( - [ - ArtifactProduct::Sdk.as_str(), - ArtifactProduct::Mobile.as_str(), - ], - ["sdk", "mobile"] - ); + assert!(ArtifactProduct::from_str("mobile", false).is_err()); + assert!(ArtifactTarget::from_str("ios", false).is_err()); + assert!(ArtifactTarget::from_str("android", false).is_err()); + assert_eq!([ArtifactProduct::Sdk.as_str()], ["sdk"]); assert_eq!( [ ArtifactTarget::Typescript.as_str(), ArtifactTarget::Wasm.as_str(), ArtifactTarget::Ffi.as_str(), - ArtifactTarget::Ios.as_str(), - ArtifactTarget::Android.as_str(), ArtifactTarget::Linux.as_str(), ArtifactTarget::Macos.as_str(), ArtifactTarget::Windows.as_str(), ], - [ - "typescript", - "wasm", - "ffi", - "ios", - "android", - "linux", - "macos", - "windows", - ] + ["typescript", "wasm", "ffi", "linux", "macos", "windows",] ); assert_eq!( [ diff --git a/tools/xtask/src/sdk_generation/bindings.rs b/tools/xtask/src/sdk_generation/bindings.rs @@ -390,11 +390,11 @@ mod tests { let expected = BTreeMap::from([ ( "generated/swift/radroots_sdk.swift", - "0f81256c17243e485d9bca8f2dd4771c0ed2c2c1dc4fb03eb352389aab8599b0", + "38ff1a652ff49b45c48601d2b1c0064103c1e815df9e80604f3369af2e53f4ac", ), ( "generated/swift/radroots_sdkFFI.h", - "511d3a22f969ef21d1a6d7357241756c2fda5d96adbb8bb17f2da5e8c65bcc54", + "2efe02ecda4a68a495bacc61acf8ff41dc84bc01d9132a00b38353a57c51466a", ), ( "generated/swift/radroots_sdkFFI.modulemap", @@ -402,12 +402,16 @@ mod tests { ), ( "generated/kotlin/uniffi/radroots_sdk/radroots_sdk.kt", - "63a91b7c7790ee786d1c94cfad30cbe6e3ac5c737108ec61c74696fc068ef7da", + "cc57260fade24a3b6d8366f06f17fb2d3132a0293cb61dd2a12d30d95f4a4e66", ), ]); for (relative, expected_sha256) in expected { let bytes = fs::read(consumer.path().join(relative)).expect("generated binding"); - assert_eq!(format!("{:x}", Sha256::digest(bytes)), expected_sha256); + assert_eq!( + format!("{:x}", Sha256::digest(bytes)), + expected_sha256, + "generated SDK binding {relative}" + ); } } }