lib

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

commit c0698f3d2671050896f25289f00246adefe0dbfc
parent a72c713ead7eea7463d98e952e0c630bb7cfc540
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 15:36:08 +0000

workspace: narrow the supported package set

Remove inactive application package sources and workspace edges.

Reserve removed package identities and refresh catalog projections.

Align coverage, publication, supply-chain, and artifact contracts with the supported workspace.

Diffstat:
MAGENTS.md | 4++--
MCargo.lock | 992+++----------------------------------------------------------------------------
MCargo.toml | 16----------------
Mcontracts/coverage-profiles.toml | 9---------
Mcontracts/coverage.toml | 13-------------
Mcontracts/crates/catalog.v2.toml | 267+++----------------------------------------------------------------------------
Mcontracts/crates/generated/package_groups.v1.toml | 16+++++-----------
Mcontracts/crates/generated/platform_inventory.v1.toml | 16++--------------
Mcontracts/crates/generated/release_inventory.v2.toml | 4++--
Mcontracts/releases/publish_policy.toml | 8--------
Dcrates/studio_application/Cargo.toml | 25-------------------------
Dcrates/studio_application/src/accounts.rs | 1822-------------------------------------------------------------------------------
Dcrates/studio_application/src/actor.rs | 787-------------------------------------------------------------------------------
Dcrates/studio_application/src/app_core.rs | 358-------------------------------------------------------------------------------
Dcrates/studio_application/src/change_stream.rs | 239-------------------------------------------------------------------------------
Dcrates/studio_application/src/config.rs | 107-------------------------------------------------------------------------------
Dcrates/studio_application/src/custody.rs | 288-------------------------------------------------------------------------------
Dcrates/studio_application/src/lib.rs | 58----------------------------------------------------------
Dcrates/studio_application/src/ports.rs | 887-------------------------------------------------------------------------------
Dcrates/studio_application/src/profile_refresh.rs | 659-------------------------------------------------------------------------------
Dcrates/studio_application/src/recovery.rs | 788-------------------------------------------------------------------------------
Dcrates/studio_application/src/secrets.rs | 308-------------------------------------------------------------------------------
Dcrates/studio_application/src/session.rs | 268-------------------------------------------------------------------------------
Dcrates/studio_application/src/snapshot.rs | 479-------------------------------------------------------------------------------
Dcrates/studio_application/src/state_machine.rs | 616-------------------------------------------------------------------------------
Dcrates/studio_application/src/test_support.rs | 58----------------------------------------------------------
Dcrates/studio_application/tests/redaction.rs | 48------------------------------------------------
Dcrates/studio_domain/Cargo.toml | 22----------------------
Dcrates/studio_domain/src/account.rs | 430-------------------------------------------------------------------------------
Dcrates/studio_domain/src/error.rs | 113-------------------------------------------------------------------------------
Dcrates/studio_domain/src/key.rs | 451-------------------------------------------------------------------------------
Dcrates/studio_domain/src/lib.rs | 21---------------------
Dcrates/studio_domain/src/profile.rs | 298-------------------------------------------------------------------------------
Dcrates/studio_domain/src/relay.rs | 198-------------------------------------------------------------------------------
Dcrates/studio_domain/src/time.rs | 36------------------------------------
Dcrates/studio_ffi/Cargo.toml | 45---------------------------------------------
Dcrates/studio_ffi/build.rs | 95-------------------------------------------------------------------------------
Dcrates/studio_ffi/src/commands.rs | 1122-------------------------------------------------------------------------------
Dcrates/studio_ffi/src/contract.rs | 9---------
Dcrates/studio_ffi/src/dto.rs | 729-------------------------------------------------------------------------------
Dcrates/studio_ffi/src/lib.rs | 46----------------------------------------------
Dcrates/studio_ffi/src/observer.rs | 512-------------------------------------------------------------------------------
Dcrates/studio_ffi/uniffi.toml | 3---
Dcrates/studio_nostr/Cargo.toml | 34----------------------------------
Dcrates/studio_nostr/src/client.rs | 353-------------------------------------------------------------------------------
Dcrates/studio_nostr/src/keys.rs | 125-------------------------------------------------------------------------------
Dcrates/studio_nostr/src/lib.rs | 9---------
Dcrates/studio_nostr/src/profile.rs | 182-------------------------------------------------------------------------------
Dcrates/studio_preferences/Cargo.toml | 18------------------
Dcrates/studio_preferences/src/lib.rs | 328-------------------------------------------------------------------------------
Dcrates/studio_runtime/Cargo.toml | 34----------------------------------
Dcrates/studio_runtime/src/blocking.rs | 114-------------------------------------------------------------------------------
Dcrates/studio_runtime/src/installation.rs | 58----------------------------------------------------------
Dcrates/studio_runtime/src/lib.rs | 12------------
Dcrates/studio_runtime/src/persistence.rs | 746-------------------------------------------------------------------------------
Dcrates/studio_runtime/src/runtime_actor.rs | 2276-------------------------------------------------------------------------------
Dcrates/studio_runtime/tests/local_relay_e2e.rs | 100-------------------------------------------------------------------------------
Dcrates/studio_runtime/tests/restart_isolation.rs | 95-------------------------------------------------------------------------------
Dcrates/studio_storage/Cargo.toml | 35-----------------------------------
Dcrates/studio_storage/migrations/V10__installation_identity.sql | 7-------
Dcrates/studio_storage/migrations/V1__initialize.sql | 6------
Dcrates/studio_storage/migrations/V2__accounts.sql | 28----------------------------
Dcrates/studio_storage/migrations/V3__profile_cache.sql | 12------------
Dcrates/studio_storage/migrations/V4__account_namespace.sql | 6------
Dcrates/studio_storage/migrations/V5__operation_journal.sql | 8--------
Dcrates/studio_storage/migrations/V6__normalized_runtime_schema.sql | 100-------------------------------------------------------------------------------
Dcrates/studio_storage/migrations/V7__migrate_v5_runtime_data.sql | 68--------------------------------------------------------------------
Dcrates/studio_storage/migrations/V8__normalized_account_preferences.sql | 10----------
Dcrates/studio_storage/migrations/V9__durable_operation_receipts.sql | 11-----------
Dcrates/studio_storage/src/account_namespace.rs | 189-------------------------------------------------------------------------------
Dcrates/studio_storage/src/accounts.rs | 516-------------------------------------------------------------------------------
Dcrates/studio_storage/src/compatibility.rs | 474-------------------------------------------------------------------------------
Dcrates/studio_storage/src/db.rs | 907-------------------------------------------------------------------------------
Dcrates/studio_storage/src/installation.rs | 71-----------------------------------------------------------------------
Dcrates/studio_storage/src/journal.rs | 774-------------------------------------------------------------------------------
Dcrates/studio_storage/src/lib.rs | 20--------------------
Dcrates/studio_storage/src/os_keyring.rs | 138-------------------------------------------------------------------------------
Dcrates/studio_storage/src/profiles.rs | 254-------------------------------------------------------------------------------
Dcrates/studio_storage/src/recovery.rs | 692-------------------------------------------------------------------------------
Dcrates/studio_storage/src/repair.rs | 403-------------------------------------------------------------------------------
Dcrates/studio_storage/tests/redaction.rs | 52----------------------------------------------------
Dcrates/studio_uniffi_bindgen/Cargo.toml | 18------------------
Dcrates/studio_uniffi_bindgen/src/main.rs | 21---------------------
Msupply-chain/config.toml | 9---------
Mtools/xtask/src/build_control.rs | 11+++--------
Mtools/xtask/src/catalog.rs | 9++++++++-
Mtools/xtask/src/coverage.rs | 66+++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mtools/xtask/src/main.rs | 7++-----
88 files changed, 113 insertions(+), 22563 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -33,8 +33,8 @@ 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`, `studio`, `myc`, - and `rhi`. Only the first three are generated-artifact product identities; +- Source-lock consumer identities include `sdk`, `mobile`, `myc`, and `rhi`. + Only the first two are generated-artifact product identities; accepting a service consumer marker must not expose an artifact route. - Current source and tests are implementation evidence. They do not silently override `radroots.crates.release.v1`. diff --git a/Cargo.lock b/Cargo.lock @@ -153,17 +153,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "apple-native-keyring-store" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" -dependencies = [ - "keyring-core", - "log", - "security-framework 3.7.0", -] - -[[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -175,20 +164,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" dependencies = [ - "askama_derive 0.13.1", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - -[[package]] -name = "askama" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" -dependencies = [ - "askama_macros", + "askama_derive", "itoa", "percent-encoding", "serde", @@ -201,26 +177,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" dependencies = [ - "askama_parser 0.13.0", - "basic-toml", - "memchr", - "proc-macro2", - "quote", - "rustc-hash 2.1.3", - "serde", - "serde_derive", - "syn 2.0.117", -] - -[[package]] -name = "askama_derive" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" -dependencies = [ - "askama_parser 0.16.0", + "askama_parser", "basic-toml", - "glob", "memchr", "proc-macro2", "quote", @@ -231,15 +189,6 @@ dependencies = [ ] [[package]] -name = "askama_macros" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" -dependencies = [ - "askama_derive 0.16.0", -] - -[[package]] name = "askama_parser" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -248,20 +197,7 @@ dependencies = [ "memchr", "serde", "serde_derive", - "winnow 0.7.15", -] - -[[package]] -name = "askama_parser" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" -dependencies = [ - "rustc-hash 2.1.3", - "serde", - "serde_derive", - "unicode-ident", - "winnow 1.0.4", + "winnow", ] [[package]] @@ -304,30 +240,6 @@ dependencies = [ ] [[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] name = "async-compat" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -341,102 +253,6 @@ dependencies = [ ] [[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 1.1.4", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix 1.1.4", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix 1.1.4", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] name = "async-trait" version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -665,19 +481,6 @@ dependencies = [ ] [[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] name = "borrow-or-share" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -726,37 +529,13 @@ dependencies = [ ] [[package]] -name = "cargo-platform" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] name = "cargo_metadata" version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" dependencies = [ "camino", - "cargo-platform 0.1.9", - "semver", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "cargo_metadata" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" -dependencies = [ - "camino", - "cargo-platform 0.3.3", + "cargo-platform", "semver", "serde", "serde_json", @@ -1254,27 +1033,6 @@ dependencies = [ ] [[package]] -name = "directories" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] name = "displaydoc" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1440,33 +1198,6 @@ dependencies = [ ] [[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1494,16 +1225,6 @@ dependencies = [ ] [[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] name = "fallible-iterator" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1674,15 +1395,6 @@ dependencies = [ ] [[package]] -name = "fs-err" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" -dependencies = [ - "autocfg", -] - -[[package]] name = "fs2" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1752,19 +1464,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] name = "futures-macro" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1982,12 +1681,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2468,27 +2161,6 @@ dependencies = [ ] [[package]] -name = "keyring" -version = "4.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72585bb6cc9bc370d1d545b7e23fcce71dfd4461c5e15275e3cf51bdfd9a980a" -dependencies = [ - "apple-native-keyring-store", - "keyring-core", - "windows-native-keyring-store", - "zbus-secret-service-keyring-store", -] - -[[package]] -name = "keyring-core" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" -dependencies = [ - "log", -] - -[[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2670,15 +2342,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] name = "micromap" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3113,22 +2776,6 @@ dependencies = [ ] [[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] name = "outref" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3207,17 +2854,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - -[[package]] name = "pkcs8" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3250,20 +2886,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] name = "poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3321,15 +2943,6 @@ dependencies = [ ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", -] - -[[package]] name = "proc-macro2" version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3578,7 +3191,7 @@ dependencies = [ name = "radroots_mobile_bindgen" version = "0.1.0-alpha" dependencies = [ - "uniffi 0.29.5", + "uniffi", ] [[package]] @@ -3633,7 +3246,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "uniffi 0.29.5", + "uniffi", ] [[package]] @@ -3854,7 +3467,7 @@ dependencies = [ "radroots_sdk", "thiserror 1.0.69", "tokio", - "uniffi 0.29.5", + "uniffi", ] [[package]] @@ -3875,7 +3488,7 @@ dependencies = [ "chacha20poly1305", "futures-executor", "hex", - "keyring 3.6.3", + "keyring", "serde", "serde_json", "sha2", @@ -4055,110 +3668,6 @@ dependencies = [ ] [[package]] -name = "radroots_studio_application" -version = "0.1.0-alpha" -dependencies = [ - "radroots_studio_domain", - "secrecy", - "tokio", -] - -[[package]] -name = "radroots_studio_domain" -version = "0.1.0-alpha" -dependencies = [ - "bech32", - "radroots_identity", - "secrecy", - "url", - "zeroize", -] - -[[package]] -name = "radroots_studio_ffi" -version = "0.1.0-alpha" -dependencies = [ - "directories", - "nostr 0.44.1", - "nostr-relay-builder", - "nostr-sdk 0.44.0", - "quote", - "radroots_studio_application", - "radroots_studio_domain", - "radroots_studio_nostr", - "radroots_studio_runtime", - "radroots_studio_storage", - "sha2", - "syn 2.0.117", - "tempfile", - "tokio", - "uniffi 0.32.0", -] - -[[package]] -name = "radroots_studio_nostr" -version = "0.1.0-alpha" -dependencies = [ - "nostr 0.44.1", - "nostr-relay-builder", - "nostr-sdk 0.44.0", - "radroots_identity", - "radroots_studio_application", - "radroots_studio_domain", - "radroots_transport", - "radroots_transport_nostr", - "tokio", -] - -[[package]] -name = "radroots_studio_preferences" -version = "0.1.0-alpha" -dependencies = [ - "url", -] - -[[package]] -name = "radroots_studio_runtime" -version = "0.1.0-alpha" -dependencies = [ - "nostr 0.44.1", - "nostr-relay-builder", - "nostr-sdk 0.44.0", - "radroots_studio_application", - "radroots_studio_domain", - "radroots_studio_nostr", - "radroots_studio_storage", - "tempfile", - "tokio", - "uuid", -] - -[[package]] -name = "radroots_studio_storage" -version = "0.1.0-alpha" -dependencies = [ - "fs2", - "getrandom 0.2.17", - "hmac", - "keyring 4.1.6", - "radroots_studio_application", - "radroots_studio_domain", - "refinery", - "rusqlite", - "rustix 1.1.4", - "sha2", - "tempfile", - "zeroize", -] - -[[package]] -name = "radroots_studio_uniffi_bindgen" -version = "0.1.0-alpha" -dependencies = [ - "uniffi 0.32.0", -] - -[[package]] name = "radroots_sync" version = "0.1.0-alpha" dependencies = [ @@ -4376,17 +3885,6 @@ dependencies = [ ] [[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] name = "ref-cast" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4424,47 +3922,6 @@ dependencies = [ ] [[package]] -name = "refinery" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2a344cdb48871e27addeafbbaffab8828cc12cec2b9041119e9bea0c0f551a" -dependencies = [ - "refinery-core", - "refinery-macros", -] - -[[package]] -name = "refinery-core" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24eeafd893124f29183dd6afa9137a27e7bef59250223b8b660005279c60aea4" -dependencies = [ - "async-trait", - "cfg-if", - "log", - "regex", - "rusqlite", - "siphasher 1.0.3", - "thiserror 2.0.18", - "time", - "url", - "walkdir", -] - -[[package]] -name = "refinery-macros" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90cea6d11a9a4e8a85a884b6305461004101b28ca65dd35ec028e009a898e16" -dependencies = [ - "proc-macro2", - "quote", - "refinery-core", - "regex", - "syn 2.0.117", -] - -[[package]] name = "regex" version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4573,7 +4030,6 @@ dependencies = [ "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", - "hashlink", "libsqlite3-sys", "smallvec", "sqlite-wasm-rs", @@ -4803,34 +4259,6 @@ dependencies = [ ] [[package]] -name = "secrecy" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" -dependencies = [ - "zeroize", -] - -[[package]] -name = "secret-service" -version = "5.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" -dependencies = [ - "aes", - "cbc", - "futures-util", - "generic-array", - "getrandom 0.2.17", - "hkdf", - "num", - "once_cell", - "serde", - "sha2", - "zbus", -] - -[[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4931,17 +4359,6 @@ dependencies = [ ] [[package]] -name = "serde_repr" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] name = "serde_spanned" version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4951,15 +4368,6 @@ dependencies = [ ] [[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] name = "serde_urlencoded" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5040,16 +4448,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] name = "signature" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5071,12 +4469,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - -[[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5586,24 +4978,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - -[[package]] -name = "toml" -version = "1.1.4+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 1.0.4", + "serde_spanned", + "toml_datetime", + "toml_edit", ] [[package]] @@ -5616,15 +4993,6 @@ dependencies = [ ] [[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] name = "toml_edit" version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5632,31 +5000,10 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_spanned", + "toml_datetime", "toml_write", - "winnow 0.7.15", -] - -[[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.4", -] - -[[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -dependencies = [ - "winnow 1.0.4", + "winnow", ] [[package]] @@ -5666,12 +5013,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] -name = "toml_writer" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" - -[[package]] name = "tower" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5825,17 +5166,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] name = "unicode-bidi" version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5876,28 +5206,12 @@ checksum = "3291800a6b06569f7d3e15bdb6dc235e0f0c8bd3eb07177f430057feb076415f" dependencies = [ "anyhow", "camino", - "cargo_metadata 0.19.2", - "clap", - "uniffi_bindgen 0.29.5", - "uniffi_core 0.29.5", - "uniffi_macros 0.29.5", - "uniffi_pipeline 0.29.5", -] - -[[package]] -name = "uniffi" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8" -dependencies = [ - "anyhow", - "camino", - "cargo_metadata 0.23.1", + "cargo_metadata", "clap", - "uniffi_bindgen 0.32.0", - "uniffi_core 0.32.0", - "uniffi_macros 0.32.0", - "uniffi_pipeline 0.32.0", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", ] [[package]] @@ -5907,10 +5221,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a04b99fa7796eaaa7b87976a0dbdd1178dc1ee702ea00aca2642003aef9b669e" dependencies = [ "anyhow", - "askama 0.13.1", + "askama", "camino", - "cargo_metadata 0.19.2", - "fs-err 2.11.0", + "cargo_metadata", + "fs-err", "glob", "goblin", "heck", @@ -5920,36 +5234,10 @@ dependencies = [ "tempfile", "textwrap", "toml 0.5.11", - "uniffi_internal_macros 0.29.5", - "uniffi_meta 0.29.5", - "uniffi_pipeline 0.29.5", - "uniffi_udl 0.29.5", -] - -[[package]] -name = "uniffi_bindgen" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac" -dependencies = [ - "anyhow", - "askama 0.16.0", - "camino", - "cargo_metadata 0.23.1", - "fs-err 3.3.1", - "glob", - "goblin", - "heck", - "indexmap", - "once_cell", - "serde", - "tempfile", - "textwrap", - "toml 1.1.4+spec-1.1.0", - "uniffi_internal_macros 0.32.0", - "uniffi_meta 0.32.0", - "uniffi_pipeline 0.32.0", - "uniffi_udl 0.32.0", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", ] [[package]] @@ -5966,18 +5254,6 @@ dependencies = [ ] [[package]] -name = "uniffi_core" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e32e261c5b0dfaba6488f536e71957dddd6b1a498ac7eb791bee56b60a086be" -dependencies = [ - "anyhow", - "bytes", - "once_cell", - "static_assertions", -] - -[[package]] name = "uniffi_internal_macros" version = "0.29.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5991,50 +5267,20 @@ dependencies = [ ] [[package]] -name = "uniffi_internal_macros" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af" -dependencies = [ - "anyhow", - "indexmap", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] name = "uniffi_macros" version = "0.29.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5596f178c4f7aafa1a501c4e0b96236a96bc2ef92bdb453d83e609dad0040152" dependencies = [ "camino", - "fs-err 2.11.0", + "fs-err", "once_cell", "proc-macro2", "quote", "serde", "syn 2.0.117", "toml 0.5.11", - "uniffi_meta 0.29.5", -] - -[[package]] -name = "uniffi_macros" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330be6770532e86320df31f54c70bb0be67588594e8e77fa56e9083a3fed5d0d" -dependencies = [ - "camino", - "fs-err 3.3.1", - "once_cell", - "proc-macro2", - "quote", - "serde", - "syn 2.0.117", - "toml 1.1.4+spec-1.1.0", - "uniffi_meta 0.32.0", + "uniffi_meta", ] [[package]] @@ -6044,21 +5290,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "beadc1f460eb2e209263c49c4f5b19e9a02e00a3b2b393f78ad10d766346ecff" dependencies = [ "anyhow", - "siphasher 0.3.11", - "uniffi_internal_macros 0.29.5", - "uniffi_pipeline 0.29.5", -] - -[[package]] -name = "uniffi_meta" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7" -dependencies = [ - "anyhow", - "siphasher 1.0.3", - "uniffi_internal_macros 0.32.0", - "uniffi_pipeline 0.32.0", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", ] [[package]] @@ -6071,20 +5305,7 @@ dependencies = [ "heck", "indexmap", "tempfile", - "uniffi_internal_macros 0.29.5", -] - -[[package]] -name = "uniffi_pipeline" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "tempfile", - "uniffi_internal_macros 0.32.0", + "uniffi_internal_macros", ] [[package]] @@ -6095,19 +5316,7 @@ checksum = "4319cf905911d70d5b97ce0f46f101619a22e9a189c8c46d797a9955e9233716" dependencies = [ "anyhow", "textwrap", - "uniffi_meta 0.29.5", - "weedle2", -] - -[[package]] -name = "uniffi_udl" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0" -dependencies = [ - "anyhow", - "textwrap", - "uniffi_meta 0.32.0", + "uniffi_meta", "weedle2", ] @@ -6203,7 +5412,6 @@ checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.2", "js-sys", - "serde_core", "wasm-bindgen", ] @@ -6561,19 +5769,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-native-keyring-store" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" -dependencies = [ - "byteorder", - "keyring-core", - "regex", - "windows-sys 0.61.2", - "zeroize", -] - -[[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6766,15 +5961,6 @@ dependencies = [ ] [[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] - -[[package]] name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6999,78 +6185,6 @@ dependencies = [ ] [[package]] -name = "zbus" -version = "5.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix 1.1.4", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 1.0.4", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus-secret-service-keyring-store" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" -dependencies = [ - "keyring-core", - "secret-service", - "zbus", -] - -[[package]] -name = "zbus_macros" -version = "5.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" -dependencies = [ - "serde", - "winnow 1.0.4", - "zvariant", -] - -[[package]] name = "zerocopy" version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -7229,43 +6343,3 @@ dependencies = [ "cc", "pkg-config", ] - -[[package]] -name = "zvariant" -version = "5.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow 1.0.4", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.117", - "winnow 1.0.4", -] diff --git a/Cargo.toml b/Cargo.toml @@ -42,14 +42,6 @@ members = [ "crates/mobile_core", "crates/mobile_ffi", "crates/mobile_wasm", - "crates/studio_application", - "crates/studio_domain", - "crates/studio_ffi", - "crates/studio_nostr", - "crates/studio_preferences", - "crates/studio_runtime", - "crates/studio_storage", - "crates/studio_uniffi_bindgen", "crates/core_bindings", "crates/event_bindings", "crates/event_codec_wasm", @@ -169,14 +161,6 @@ radroots_mobile_bindgen = { path = "crates/mobile_bindgen", version = "=0.1.0-al 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_studio_application = { path = "crates/studio_application", version = "=0.1.0-alpha" } -radroots_studio_domain = { path = "crates/studio_domain", version = "=0.1.0-alpha" } -radroots_studio_ffi = { path = "crates/studio_ffi", version = "=0.1.0-alpha" } -radroots_studio_nostr = { path = "crates/studio_nostr", version = "=0.1.0-alpha" } -radroots_studio_preferences = { path = "crates/studio_preferences", version = "=0.1.0-alpha" } -radroots_studio_runtime = { path = "crates/studio_runtime", version = "=0.1.0-alpha" } -radroots_studio_storage = { path = "crates/studio_storage", version = "=0.1.0-alpha" } -radroots_studio_uniffi_bindgen = { path = "crates/studio_uniffi_bindgen", 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" } diff --git a/contracts/coverage-profiles.toml b/contracts/coverage-profiles.toml @@ -27,12 +27,3 @@ test_threads = 1 no_default_features = false features = ["full"] test_threads = 1 - -[profiles.crates."radroots_studio_application"] -test_packages = ["radroots_studio_runtime", "radroots_studio_ffi"] - -[profiles.crates."radroots_studio_runtime"] -test_packages = ["radroots_studio_ffi"] - -[profiles.crates."radroots_studio_storage"] -test_packages = ["radroots_studio_runtime", "radroots_studio_ffi"] diff --git a/contracts/coverage.toml b/contracts/coverage.toml @@ -50,11 +50,6 @@ 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_studio_uniffi_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 @@ -88,14 +83,6 @@ crates = [ "radroots_signing", "radroots_storage", "radroots_storage_sqlite", - "radroots_studio_application", - "radroots_studio_domain", - "radroots_studio_ffi", - "radroots_studio_nostr", - "radroots_studio_preferences", - "radroots_studio_runtime", - "radroots_studio_storage", - "radroots_studio_uniffi_bindgen", "radroots_sync", "radroots_transport_nostr", "radroots_transport_reticulum", diff --git a/contracts/crates/catalog.v2.toml b/contracts/crates/catalog.v2.toml @@ -7,7 +7,7 @@ rust_version = "1.97.1" edition = "2024" resolver = "3" public_package_count = 19 -package_count = 63 +package_count = 55 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" @@ -24,6 +24,14 @@ retired_packages = [ "radroots-studio-storage", "radroots-studio-ffi", "radroots-studio-uniffi-bindgen", + "radroots_studio_application", + "radroots_studio_domain", + "radroots_studio_ffi", + "radroots_studio_nostr", + "radroots_studio_preferences", + "radroots_studio_runtime", + "radroots_studio_storage", + "radroots_studio_uniffi_bindgen", "radroots_sdk_xtask", ] @@ -1479,260 +1487,3 @@ source_path = "crates/bindgen" source_tree_sha256 = "f0a9d7ec9794257bc56063117208e6e83a34efe1b852e8af6d6a56a1693d27fa" compatibility = ["generated", "swift", "kotlin", "package_private"] replaces = ["radroots_app_bindgen"] - -[[package]] -name = "radroots_studio_domain" -path = "crates/studio_domain" -state = "active" -tier = "application_domain" -visibility = "private_runtime" -publish = false -version = "0.1.0-alpha" -license = "GPL-3.0-only" -platforms = ["native"] -groups = ["coverage_required", "studio"] -owners = ["studio"] -permitted_dependency_tiers = [ - "foundation", - "domain", - "spi", - "adapter", - "orchestration", - "sdk", - "facade", - "application_domain", -] -provenance_kind = "imported" -source_repository = "https://github.com/radrootslabs/studio_app" -source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" -source_path = "core/crates/domain" -source_tree_sha256 = "25f8abe2f8f4e9dfeb6450116be67eb3faad53f2d3879a4c142796238f74b0a9" -compatibility = ["product", "data", "package_private"] -replaces = ["radroots-studio-domain"] - -[[package]] -name = "radroots_studio_preferences" -path = "crates/studio_preferences" -state = "active" -tier = "application_domain" -visibility = "private_runtime" -publish = false -version = "0.1.0-alpha" -license = "MPL-2.0" -platforms = ["native"] -groups = ["coverage_required", "studio"] -owners = ["studio"] -permitted_dependency_tiers = [ - "foundation", - "domain", - "spi", - "adapter", - "orchestration", - "sdk", - "facade", - "application_domain", -] -provenance_kind = "imported" -source_repository = "https://github.com/radrootslabs/_radroots" -source_revision = "6074a4745be361f21bb47d4778c74a14b2d57954" -source_path = "studio_app/studio_app_core/crates/core" -source_tree_sha256 = "0237265710a676ce1db0fb3091e1e5d9a5831339e00076ea2a33b96d6343834d" -compatibility = ["product", "preferences", "package_private"] -replaces = ["studio_app_core"] - -[[package]] -name = "radroots_studio_application" -path = "crates/studio_application" -state = "active" -tier = "application" -visibility = "private_runtime" -publish = false -version = "0.1.0-alpha" -license = "GPL-3.0-only" -platforms = ["native"] -groups = ["coverage_required", "studio"] -owners = ["studio"] -permitted_dependency_tiers = [ - "foundation", - "domain", - "spi", - "adapter", - "orchestration", - "sdk", - "facade", - "application_domain", - "application", -] -provenance_kind = "imported" -source_repository = "https://github.com/radrootslabs/studio_app" -source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" -source_path = "core/crates/application" -source_tree_sha256 = "8b0b5d4d74dde0af3c5fb3dac979b0cf57cccf073d597f7bd35b1e73a711fe0b" -compatibility = ["product", "behavior", "package_private"] -replaces = ["radroots-studio-application"] - -[[package]] -name = "radroots_studio_nostr" -path = "crates/studio_nostr" -state = "active" -tier = "application_adapter" -visibility = "private_adapter" -publish = false -version = "0.1.0-alpha" -license = "GPL-3.0-only" -platforms = ["native"] -groups = ["coverage_required", "studio"] -owners = ["studio"] -permitted_dependency_tiers = [ - "foundation", - "domain", - "spi", - "adapter", - "orchestration", - "sdk", - "facade", - "application_domain", - "application", - "application_adapter", -] -provenance_kind = "imported" -source_repository = "https://github.com/radrootslabs/studio_app" -source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" -source_path = "core/crates/nostr" -source_tree_sha256 = "2ce5ce0227ab190102a94ffdf80d95b37ed35f5ef62286c4e3e19cd42a777115" -compatibility = ["network", "security", "package_private"] -replaces = ["radroots-studio-nostr"] - -[[package]] -name = "radroots_studio_storage" -path = "crates/studio_storage" -state = "active" -tier = "application_adapter" -visibility = "private_adapter" -publish = false -version = "0.1.0-alpha" -license = "GPL-3.0-only" -platforms = ["native"] -groups = ["coverage_required", "studio"] -owners = ["studio"] -permitted_dependency_tiers = [ - "foundation", - "domain", - "spi", - "adapter", - "orchestration", - "sdk", - "facade", - "application_domain", - "application", - "application_adapter", -] -provenance_kind = "imported" -source_repository = "https://github.com/radrootslabs/studio_app" -source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" -source_path = "core/crates/storage" -source_tree_sha256 = "f3addecbd7f6dbccda4a438597b4e433443a34b48a6c4ef9efa90e49e8f05c4b" -compatibility = ["data", "keyring", "package_private"] -replaces = ["radroots-studio-storage"] - -[[package]] -name = "radroots_studio_runtime" -path = "crates/studio_runtime" -state = "active" -tier = "runtime_composition" -visibility = "private_runtime" -publish = false -version = "0.1.0-alpha" -license = "GPL-3.0-only" -platforms = ["native"] -groups = ["coverage_required", "studio"] -owners = ["studio"] -permitted_dependency_tiers = [ - "foundation", - "domain", - "spi", - "adapter", - "orchestration", - "sdk", - "facade", - "application_domain", - "application", - "application_adapter", - "runtime_composition", -] -provenance_kind = "imported" -source_repository = "https://github.com/radrootslabs/studio_app" -source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" -source_path = "core/crates/storage" -source_tree_sha256 = "f3addecbd7f6dbccda4a438597b4e433443a34b48a6c4ef9efa90e49e8f05c4b" -compatibility = ["product", "lifecycle", "package_private"] -replaces = [] - -[[package]] -name = "radroots_studio_ffi" -path = "crates/studio_ffi" -state = "active" -tier = "boundary" -visibility = "private_boundary" -publish = false -version = "0.1.0-alpha" -license = "GPL-3.0-only" -platforms = ["linux", "macos", "windows"] -groups = ["coverage_required", "studio", "boundaries"] -owners = ["studio"] -permitted_dependency_tiers = [ - "foundation", - "domain", - "spi", - "adapter", - "orchestration", - "sdk", - "facade", - "application_domain", - "application", - "application_adapter", - "runtime_composition", - "boundary", -] -provenance_kind = "imported" -source_repository = "https://github.com/radrootslabs/studio_app" -source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" -source_path = "core/crates/ffi" -source_tree_sha256 = "0bb1d02eb963a606a288d9c35fb418de7bfd914dc5e2c4a38112af64c643151c" -compatibility = ["ffi", "kotlin", "product", "lifecycle", "package_private"] -replaces = ["radroots-studio-ffi"] - -[[package]] -name = "radroots_studio_uniffi_bindgen" -path = "crates/studio_uniffi_bindgen" -state = "active" -tier = "codegen" -visibility = "private_codegen" -publish = false -version = "0.1.0-alpha" -license = "GPL-3.0-only" -platforms = ["native"] -groups = ["coverage_required", "studio", "boundaries"] -owners = ["studio"] -permitted_dependency_tiers = [ - "foundation", - "domain", - "spi", - "adapter", - "orchestration", - "sdk", - "facade", - "application_domain", - "application", - "application_adapter", - "runtime_composition", - "boundary", - "codegen", -] -provenance_kind = "imported" -source_repository = "https://github.com/radrootslabs/studio_app" -source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" -source_path = "core/tools/uniffi-bindgen" -source_tree_sha256 = "0eedd47c17fc7b2b84d953f2c4613aecf96575df466eb106450c7d7eee25ca9b" -compatibility = ["generated", "kotlin", "package_private"] -replaces = ["radroots-studio-uniffi-bindgen"] diff --git a/contracts/crates/generated/package_groups.v1.toml b/contracts/crates/generated/package_groups.v1.toml @@ -1,16 +1,16 @@ schema = "radroots.workspace.package-groups.v1" -catalog_sha256 = "58d9d5ed0a98eeaec9198928d1a3ec8d722ad2eb08fbb5ab6ef4bb1eba898d0f" +catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4" [[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_studio_ffi", "radroots_studio_uniffi_bindgen", "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_studio_ffi", "radroots_studio_uniffi_bindgen", "radroots_trade_bindings"] +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"] 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_studio_application", "radroots_studio_domain", "radroots_studio_ffi", "radroots_studio_nostr", "radroots_studio_preferences", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen", "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_studio_application", "radroots_studio_domain", "radroots_studio_ffi", "radroots_studio_nostr", "radroots_studio_preferences", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen", "radroots_sync", "radroots_test_fixtures", "radroots_trade", "radroots_trade_bindings", "radroots_transport", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"] +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]] @@ -44,12 +44,6 @@ active_packages = ["radroots", "radroots_core_bindings", "radroots_event_binding reserved_packages = [] [[group]] -id = "studio" -packages = ["radroots_studio_application", "radroots_studio_domain", "radroots_studio_ffi", "radroots_studio_nostr", "radroots_studio_preferences", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen"] -active_packages = ["radroots_studio_application", "radroots_studio_domain", "radroots_studio_ffi", "radroots_studio_nostr", "radroots_studio_preferences", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen"] -reserved_packages = [] - -[[group]] id = "tools" packages = ["xtask"] active_packages = ["xtask"] diff --git a/contracts/crates/generated/platform_inventory.v1.toml b/contracts/crates/generated/platform_inventory.v1.toml @@ -1,5 +1,5 @@ schema = "radroots.workspace.platform-inventory.v1" -catalog_sha256 = "58d9d5ed0a98eeaec9198928d1a3ec8d722ad2eb08fbb5ab6ef4bb1eba898d0f" +catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4" [[platform]] id = "android" @@ -14,21 +14,9 @@ id = "apple" packages = ["radroots_mobile_ffi"] [[platform]] -id = "linux" -packages = ["radroots_studio_ffi"] - -[[platform]] -id = "macos" -packages = ["radroots_studio_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_studio_application", "radroots_studio_domain", "radroots_studio_nostr", "radroots_studio_preferences", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen", "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_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"] [[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"] - -[[platform]] -id = "windows" -packages = ["radroots_studio_ffi"] 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 = "58d9d5ed0a98eeaec9198928d1a3ec8d722ad2eb08fbb5ab6ef4bb1eba898d0f" +catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4" 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_studio_application", "radroots_studio_domain", "radroots_studio_ffi", "radroots_studio_nostr", "radroots_studio_preferences", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen", "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_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"] reserved_packages = [] diff --git a/contracts/releases/publish_policy.toml b/contracts/releases/publish_policy.toml @@ -60,12 +60,6 @@ private = [ "radroots_service_host", "radroots_service_sqlite", "radroots_sql_core", - "radroots_studio_application", - "radroots_studio_domain", - "radroots_studio_nostr", - "radroots_studio_preferences", - "radroots_studio_runtime", - "radroots_studio_storage", ] build_codegen = [ "radroots_core_bindings", @@ -79,8 +73,6 @@ build_codegen = [ "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", - "radroots_studio_ffi", - "radroots_studio_uniffi_bindgen", "radroots_trade_bindings", "xtask", ] diff --git a/crates/studio_application/Cargo.toml b/crates/studio_application/Cargo.toml @@ -1,25 +0,0 @@ -[package] -name = "radroots_studio_application" -description = "Private application policy and ports for Radroots Studio" -version = "0.1.0-alpha" -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license = "GPL-3.0-only" -repository.workspace = true -homepage.workspace = true -publish = false -include = ["src/**", "tests/**", "Cargo.toml"] - -[dependencies] -radroots_studio_domain.workspace = true -secrecy = "=0.10.3" -tokio = { version = "=1.47.1", features = [ - "macros", - "rt-multi-thread", - "sync", - "time", -] } - -[lints] -workspace = true diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_application/src/accounts.rs @@ -1,1822 +0,0 @@ -use std::sync::{Mutex, MutexGuard}; - -use crate::{ - AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, - Clock, DurableOperationKind, DurableOperationPhase, DurableOperationRepository, - DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic, - OperationId, OperationJournal, OperationPriorState, PendingAccountOperation, - RemovalConfirmationToken, SecretStore, StagedGeneratedKey, StateTransition, -}; -use radroots_studio_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, -}; - -pub struct GenerateAccountReceipt { - account: AccountSummary, - generated_nsec: Nsec, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ImportAccountReceipt { - account: AccountSummary, -} - -impl ImportAccountReceipt { - #[must_use] - pub const fn account(&self) -> &AccountSummary { - &self.account - } -} - -impl GenerateAccountReceipt { - #[must_use] - pub const fn account(&self) -> &AccountSummary { - &self.account - } - - #[must_use] - pub const fn generated_nsec(&self) -> &Nsec { - &self.generated_nsec - } -} - -impl AppCore { - /// Commits a staged generated key only after its recovery acknowledgement. - /// - /// # Errors - /// - /// Returns a safe conflict, keyring, persistence, or recovery error. - #[allow(clippy::too_many_arguments)] - pub fn commit_staged_generated_key( - &self, - request_id: &DurableRequestId, - staged: StagedGeneratedKey, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { - let expected_revision = staged.expected_revision(); - self.require_revision(expected_revision)?; - let (account, secret) = staged.into_commit_parts(); - self.persist_account_durable( - request_id, - DurableOperationKind::Create, - expected_revision, - &account, - secret, - None, - accounts, - app_state, - secrets, - operations, - clock, - )?; - Ok(ImportAccountReceipt { account }) - } - - /// Generates and commits one account under a durable caller request. - /// - /// # Errors - /// - /// Returns a safe conflict, keyring, persistence, or state error. Staged recovery transport - /// replaces this transitional generated-secret receipt in the custody phase. - #[allow(clippy::too_many_arguments)] - pub fn generate_account_durable( - &self, - request_id: &DurableRequestId, - expected_revision: u64, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<GenerateAccountReceipt, SafeError> { - self.require_revision(expected_revision)?; - let generated = self.key_material().generate()?; - let (public_key, npub, secret, nsec) = generated.into_parts(); - let account = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned())?, - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(clock.now()), - None, - )?; - self.persist_account_durable( - request_id, - DurableOperationKind::Create, - expected_revision, - &account, - secret, - None, - accounts, - app_state, - secrets, - operations, - clock, - )?; - Ok(GenerateAccountReceipt { - account, - generated_nsec: nsec, - }) - } - - /// Imports or explicitly repairs one local account under a durable caller request. - /// - /// # Errors - /// - /// Returns a safe conflict, validation, keyring, persistence, or state error. - #[allow(clippy::too_many_arguments)] - pub fn import_secret_key_durable( - &self, - request_id: &DurableRequestId, - expected_revision: u64, - input: SecretKeyInput, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { - if let Some(existing) = operations.load_durable_operation(request_id)? { - return if existing - .terminal() - .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) - { - accounts - .find_account(existing.account())? - .map(|account| ImportAccountReceipt { account }) - .ok_or_else(recovery_required) - } else { - Err(recovery_required()) - }; - } - self.require_revision(expected_revision)?; - let imported = self.key_material().import(input)?; - let (public_key, npub, secret) = imported.into_parts(); - let previous = accounts.find_account(public_key)?; - if let Some(existing) = &previous - && (existing.signer().availability() != BindingAvailability::CredentialMissing - || secrets.contains(public_key)?) - { - return Err(account_exists()); - } - if previous.is_none() && secrets.contains(public_key)? { - return Err(account_exists()); - } - let account = if let Some(existing) = &previous { - existing.with_binding_availability(BindingAvailability::Available) - } else { - AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned())?, - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(clock.now()), - None, - )? - }; - let kind = if previous.is_some() { - DurableOperationKind::Repair - } else { - DurableOperationKind::Import - }; - self.persist_account_durable( - request_id, - kind, - expected_revision, - &account, - secret, - previous.as_ref(), - accounts, - app_state, - secrets, - operations, - clock, - )?; - Ok(ImportAccountReceipt { account }) - } - - fn require_revision(&self, expected_revision: u64) -> Result<(), SafeError> { - if self.snapshot().revision().value() != expected_revision { - return Err(operation_conflict()); - } - Ok(()) - } - - #[allow(clippy::too_many_arguments)] - fn persist_account_durable( - &self, - request_id: &DurableRequestId, - kind: DurableOperationKind, - expected_revision: u64, - account: &AccountSummary, - secret: SecretKeyInput, - previous: Option<&AccountSummary>, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<(), SafeError> { - let prior = OperationPriorState::new( - app_state.load_selected_account()?, - previous.map(|account| account.signer().availability()), - ); - match operations.begin_durable_operation( - request_id, - kind, - account.public_key(), - Some(expected_revision), - prior, - clock.now(), - )? { - DurableOperationStart::Started(_) => {} - DurableOperationStart::Existing(operation) => { - return if operation - .terminal() - .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) - { - Ok(()) - } else { - Err(recovery_required()) - }; - } - } - secrets.put(account.public_key(), secret)?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialWritten, - clock.now(), - None, - )?; - previous.map_or_else( - || accounts.insert_account(account), - |_| accounts.update_account(account), - )?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::CredentialWritten, - DurableOperationPhase::MetadataCommitted, - clock.now(), - None, - )?; - app_state.save_selected_account(Some(account.public_key()))?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::MetadataCommitted, - DurableOperationPhase::SelectionCommitted, - clock.now(), - None, - )?; - let snapshot = self.apply_transition(StateTransition::ReplaceRegistry { - accounts: accounts.list_accounts()?, - selected: Some(account.public_key()), - })?; - operations.finalize_durable_operation( - request_id, - DurableOperationPhase::SelectionCommitted, - DurableTerminalOutcome::Completed, - Some(snapshot.revision().value()), - clock.now(), - )?; - Ok(()) - } - - /// Issues a single-use confirmation bound to the target and current revision. - /// - /// # Errors - /// - /// Returns a safe account or application-state error. - pub fn request_account_removal( - &self, - public_key: PublicKey, - clock: &(impl Clock + ?Sized), - ) -> Result<RemovalConfirmationToken, SafeError> { - self.issue_removal_token(public_key, clock.now()) - } - - pub fn cancel_account_removal(&self, token: RemovalConfirmationToken) -> bool { - self.cancel_removal_token(token) - } - - /// Permanently removes a confirmed account and selects a deterministic fallback. - /// - /// # Errors - /// - /// Returns a safe confirmation, credential, persistence, recovery, or state error. - pub fn confirm_account_removal( - &self, - token: RemovalConfirmationToken, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - journal: &(impl OperationJournal + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<crate::AppSnapshot, SafeError> { - let public_key = self.consume_removal_token(token, clock.now())?; - let registry = accounts.list_accounts()?; - let index = registry - .iter() - .position(|account| account.public_key() == public_key) - .ok_or_else(account_not_found)?; - let selected = if self.snapshot().selected_account() == Some(public_key) { - registry - .get(index + 1) - .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) - .map(AccountSummary::public_key) - } else { - self.snapshot().selected_account() - }; - let operation = - journal.begin_operation(AccountOperationKind::Remove, public_key, clock.now())?; - let was_active = self - .snapshot() - .active_account() - .is_some_and(|active| active.account().public_key() == public_key); - if was_active { - self.sign_out()?; - } - let account = &registry[index]; - match secrets.delete(public_key) { - Ok(()) => {} - Err(error) - if error.code() == SafeErrorCode::CredentialMissing - && account.signer().availability() - == BindingAvailability::CredentialMissing => {} - Err(error) => return Err(error), - } - journal.update_operation( - operation, - AccountOperationPhase::CredentialDeleted, - clock.now(), - None, - )?; - accounts.remove_account(public_key)?; - app_state.save_selected_account(selected)?; - journal.update_operation( - operation, - AccountOperationPhase::MetadataDeleted, - clock.now(), - None, - )?; - journal.finalize_operation(operation)?; - self.apply_transition(StateTransition::ReplaceRegistryPreservingSession { - accounts: accounts.list_accounts()?, - selected, - }) - } - - /// Confirms and executes an expiring removal plan as a durable request. - /// - /// # Errors - /// - /// Returns a safe expiry, conflict, credential, persistence, or recovery error. - #[allow(clippy::too_many_arguments)] - pub fn confirm_account_removal_durable( - &self, - request_id: &DurableRequestId, - token: RemovalConfirmationToken, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<crate::AppSnapshot, SafeError> { - let expected_revision = token.revision().value(); - let public_key = self.consume_removal_token(token, clock.now())?; - self.require_revision(expected_revision)?; - let registry = accounts.list_accounts()?; - let index = registry - .iter() - .position(|account| account.public_key() == public_key) - .ok_or_else(account_not_found)?; - let selected = if self.snapshot().selected_account() == Some(public_key) { - registry - .get(index + 1) - .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) - .map(AccountSummary::public_key) - } else { - self.snapshot().selected_account() - }; - let account = &registry[index]; - match operations.begin_durable_operation( - request_id, - DurableOperationKind::Remove, - public_key, - Some(expected_revision), - OperationPriorState::new(selected, Some(account.signer().availability())), - clock.now(), - )? { - DurableOperationStart::Started(_) => {} - DurableOperationStart::Existing(operation) => { - return if operation - .terminal() - .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) - { - Ok(self.snapshot()) - } else { - Err(recovery_required()) - }; - } - } - if self - .snapshot() - .active_account() - .is_some_and(|active| active.account().public_key() == public_key) - { - self.sign_out()?; - } - match secrets.delete(public_key) { - Ok(()) => {} - Err(error) - if error.code() == SafeErrorCode::CredentialMissing - && account.signer().availability() - == BindingAvailability::CredentialMissing => {} - Err(error) => return Err(error), - } - operations.advance_durable_operation( - request_id, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialDeleted, - clock.now(), - None, - )?; - accounts.remove_account(public_key)?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::CredentialDeleted, - DurableOperationPhase::MetadataDeleted, - clock.now(), - None, - )?; - app_state.save_selected_account(selected)?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::MetadataDeleted, - DurableOperationPhase::SelectionCommitted, - clock.now(), - None, - )?; - let snapshot = - self.apply_transition(StateTransition::ReplaceRegistryPreservingSession { - accounts: accounts.list_accounts()?, - selected, - })?; - operations.finalize_durable_operation( - request_id, - DurableOperationPhase::SelectionCommitted, - DurableTerminalOutcome::Completed, - Some(snapshot.revision().value()), - clock.now(), - )?; - Ok(snapshot) - } - - /// Persists and publishes a saved account selection without activating it. - /// - /// # Errors - /// - /// Returns a safe account, persistence, or application-state error. - pub fn select_account( - &self, - public_key: PublicKey, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - ) -> Result<crate::AppSnapshot, SafeError> { - if accounts.find_account(public_key)?.is_none() { - return Err(account_not_found()); - } - app_state.save_selected_account(Some(public_key))?; - self.apply_transition(StateTransition::Select(public_key)) - } - - /// Generates, stores, and selects one local Nostr account without activating it. - /// - /// # Errors - /// - /// Returns a safe key, credential, persistence, or application-state error. - pub fn generate_account( - &self, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - journal: &(impl OperationJournal + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<GenerateAccountReceipt, SafeError> { - let generated = self.key_material().generate()?; - let (public_key, npub, secret, nsec) = generated.into_parts(); - let account = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned())?, - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(clock.now()), - None, - )?; - Self::persist_account_transaction( - AccountOperationKind::Add, - &account, - secret, - None, - accounts, - app_state, - secrets, - journal, - clock, - )?; - let registry = accounts.list_accounts()?; - self.apply_transition(StateTransition::ReplaceRegistry { - accounts: registry, - selected: Some(public_key), - })?; - Ok(GenerateAccountReceipt { - account, - generated_nsec: nsec, - }) - } - - /// Imports, stores, and selects one local Nostr account without activating it. - /// - /// # Errors - /// - /// Returns a safe key, credential, persistence, or application-state error. - pub fn import_secret_key( - &self, - input: SecretKeyInput, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - journal: &(impl OperationJournal + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { - let imported = self.key_material().import(input)?; - let (public_key, npub, secret) = imported.into_parts(); - if let Some(existing) = accounts.find_account(public_key)? { - if existing.signer().availability() != BindingAvailability::CredentialMissing - || secrets.contains(public_key)? - { - return Err(account_exists()); - } - let repaired = existing.with_binding_availability(BindingAvailability::Available); - Self::persist_account_transaction( - AccountOperationKind::Import, - &repaired, - secret, - Some(&existing), - accounts, - app_state, - secrets, - journal, - clock, - )?; - self.apply_transition(StateTransition::ReplaceRegistry { - accounts: accounts.list_accounts()?, - selected: Some(public_key), - })?; - return Ok(ImportAccountReceipt { account: repaired }); - } - if secrets.contains(public_key)? { - return Err(account_exists()); - } - let account = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned())?, - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(clock.now()), - None, - )?; - Self::persist_account_transaction( - AccountOperationKind::Import, - &account, - secret, - None, - accounts, - app_state, - secrets, - journal, - clock, - )?; - self.apply_transition(StateTransition::ReplaceRegistry { - accounts: accounts.list_accounts()?, - selected: Some(public_key), - })?; - Ok(ImportAccountReceipt { account }) - } - - #[allow(clippy::too_many_arguments)] - fn persist_account_transaction( - kind: AccountOperationKind, - account: &AccountSummary, - secret: SecretKeyInput, - previous: Option<&AccountSummary>, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - journal: &(impl OperationJournal + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<(), SafeError> { - let public_key = account.public_key(); - let previous_selection = app_state.load_selected_account()?; - let operation = journal.begin_operation(kind, public_key, clock.now())?; - if let Err(error) = secrets.put(public_key, secret) { - let _ = journal.finalize_operation(operation); - return Err(error); - } - if let Err(error) = journal.update_operation( - operation, - AccountOperationPhase::CredentialWritten, - clock.now(), - None, - ) { - return compensate_account_write( - operation, - public_key, - error, - None, - previous_selection, - accounts, - app_state, - secrets, - journal, - clock, - ); - } - let metadata_result = previous.map_or_else( - || accounts.insert_account(account), - |_| accounts.update_account(account), - ); - if let Err(error) = metadata_result { - return compensate_account_write( - operation, - public_key, - error, - previous, - previous_selection, - accounts, - app_state, - secrets, - journal, - clock, - ); - } - if let Err(error) = app_state.save_selected_account(Some(public_key)) { - return compensate_account_write( - operation, - public_key, - error, - previous, - previous_selection, - accounts, - app_state, - secrets, - journal, - clock, - ); - } - journal.update_operation( - operation, - AccountOperationPhase::MetadataCommitted, - clock.now(), - None, - )?; - journal.finalize_operation(operation) - } -} - -#[allow(clippy::too_many_arguments)] -fn compensate_account_write( - operation: OperationId, - public_key: PublicKey, - original_error: SafeError, - previous: Option<&AccountSummary>, - previous_selection: Option<PublicKey>, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - journal: &(impl OperationJournal + ?Sized), - clock: &(impl Clock + ?Sized), -) -> Result<(), SafeError> { - let metadata_rollback = if let Some(previous) = previous { - accounts.update_account(previous) - } else { - accounts.remove_account(public_key) - }; - let selection_rollback = app_state.save_selected_account(previous_selection); - let credential_rollback = secrets.delete(public_key); - if metadata_rollback.is_err() || selection_rollback.is_err() || credential_rollback.is_err() { - let _ = journal.update_operation( - operation, - AccountOperationPhase::CompensationPending, - clock.now(), - Some(OperationDiagnostic::CompensationFailed), - ); - return Err(recovery_required()); - } - let _ = journal.finalize_operation(operation); - Err(original_error) -} - -#[derive(Default)] -pub struct InMemoryOperationJournal { - state: Mutex<InMemoryJournalState>, -} - -#[derive(Default)] -struct InMemoryJournalState { - next_id: u64, - pending: Vec<PendingAccountOperation>, -} - -impl OperationJournal for InMemoryOperationJournal { - fn begin_operation( - &self, - kind: AccountOperationKind, - subject: PublicKey, - updated_at: radroots_studio_domain::UnixTimestamp, - ) -> Result<OperationId, SafeError> { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - state.next_id = state.next_id.checked_add(1).ok_or_else(recovery_required)?; - let id = OperationId::from_raw(state.next_id); - state.pending.push(PendingAccountOperation::new( - id, - kind, - subject, - AccountOperationPhase::IntentRecorded, - updated_at, - None, - )); - Ok(id) - } - - fn update_operation( - &self, - id: OperationId, - phase: AccountOperationPhase, - updated_at: radroots_studio_domain::UnixTimestamp, - diagnostic: Option<OperationDiagnostic>, - ) -> Result<(), SafeError> { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let operation = state - .pending - .iter_mut() - .find(|operation| operation.id() == id) - .ok_or_else(recovery_required)?; - *operation = PendingAccountOperation::new( - id, - operation.kind(), - operation.subject(), - phase, - updated_at, - diagnostic, - ); - Ok(()) - } - - fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { - Ok(self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .pending - .clone()) - } - - fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> { - self.state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .pending - .retain(|operation| operation.id() != id); - Ok(()) - } -} - -#[derive(Default)] -pub struct InMemoryAccountRepository { - state: Mutex<InMemoryAccountState>, -} - -#[derive(Default)] -struct InMemoryAccountState { - accounts: Vec<AccountSummary>, - selected: Option<PublicKey>, -} - -impl InMemoryAccountRepository { - fn state(&self) -> MutexGuard<'_, InMemoryAccountState> { - self.state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } -} - -impl AccountRepository for InMemoryAccountRepository { - fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { - Ok(self.state().accounts.clone()) - } - - fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { - Ok(self - .state() - .accounts - .iter() - .find(|account| account.public_key() == public_key) - .cloned()) - } - - fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - let mut state = self.state(); - if state - .accounts - .iter() - .any(|saved| saved.public_key() == account.public_key()) - { - return Err(account_exists()); - } - state.accounts.push(account.clone()); - state - .accounts - .sort_by_key(|saved| (saved.created_at().timestamp(), saved.public_key())); - Ok(()) - } - - fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - let mut state = self.state(); - let saved = state - .accounts - .iter_mut() - .find(|saved| saved.public_key() == account.public_key()) - .ok_or_else(account_not_found)?; - *saved = account.clone(); - Ok(()) - } - - fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { - let mut state = self.state(); - state - .accounts - .retain(|account| account.public_key() != public_key); - if state.selected == Some(public_key) { - state.selected = None; - } - Ok(()) - } -} - -impl AppStateRepository for InMemoryAccountRepository { - fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { - Ok(self.state().selected) - } - - fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { - let mut state = self.state(); - if public_key.is_some_and(|key| { - !state - .accounts - .iter() - .any(|account| account.public_key() == key) - }) { - return Err(account_not_found()); - } - state.selected = public_key; - Ok(()) - } -} - -const fn account_exists() -> SafeError { - SafeError::new( - SafeErrorCode::AccountAlreadyExists, - SafeMessage::new("The Nostr account is already saved."), - ) -} - -const fn account_not_found() -> SafeError { - SafeError::new( - SafeErrorCode::AccountNotFound, - SafeMessage::new("The account was not found."), - ) -} - -const fn recovery_required() -> SafeError { - SafeError::new( - SafeErrorCode::PendingOperationRecoveryRequired, - SafeMessage::new("Account recovery is required before this operation can continue."), - ) -} - -const fn operation_conflict() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The account operation conflicts with the current application state."), - ) -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicBool, Ordering}; - - use radroots_studio_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, - }; - - use super::InMemoryAccountRepository; - use crate::{ - AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, Clock, - DurableOperationKind, DurableOperationPhase, FailureSecretStore, InMemoryOperationJournal, - InMemorySecretStore, OperationJournal, ProfileRefreshStatus, ProfileRepository, - RelayConfiguration, SecretStore, SecretStoreOperation, SessionState, StateTransition, - recovery::tests::{TestDurableRepository, operation as durable_operation}, - }; - - struct FixedClock; - - impl Clock for FixedClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(10).expect("time") - } - } - - struct LateClock; - - impl Clock for LateClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(311).expect("time") - } - } - - struct EmptyProfiles; - - impl ProfileRepository for EmptyProfiles { - fn load_profile( - &self, - _public_key: PublicKey, - ) -> Result<Option<crate::CachedProfile>, SafeError> { - Ok(None) - } - - fn save_profile(&self, _profile: &crate::CachedProfile) -> Result<(), SafeError> { - Ok(()) - } - - fn record_refresh_status( - &self, - _public_key: PublicKey, - _refreshed_at: UnixTimestamp, - _status: ProfileRefreshStatus, - ) -> Result<(), SafeError> { - Ok(()) - } - - fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { - Ok(()) - } - } - - #[derive(Default)] - struct FailingUpdateJournal(InMemoryOperationJournal); - - impl OperationJournal for FailingUpdateJournal { - fn begin_operation( - &self, - kind: crate::AccountOperationKind, - subject: PublicKey, - updated_at: UnixTimestamp, - ) -> Result<crate::OperationId, SafeError> { - self.0.begin_operation(kind, subject, updated_at) - } - - fn update_operation( - &self, - _id: crate::OperationId, - _phase: AccountOperationPhase, - _updated_at: UnixTimestamp, - _diagnostic: Option<crate::OperationDiagnostic>, - ) -> Result<(), SafeError> { - Err(SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The test journal is unavailable."), - )) - } - - fn list_pending_operations( - &self, - ) -> Result<Vec<crate::PendingAccountOperation>, SafeError> { - self.0.list_pending_operations() - } - - fn finalize_operation(&self, id: crate::OperationId) -> Result<(), SafeError> { - self.0.finalize_operation(id) - } - } - - #[derive(Default)] - struct FailingInsertRepository { - inner: InMemoryAccountRepository, - } - - #[derive(Default)] - struct FailingSelectionRepository { - inner: InMemoryAccountRepository, - fail_next_selection: AtomicBool, - } - - impl AccountRepository for FailingSelectionRepository { - fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { - self.inner.list_accounts() - } - - fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { - self.inner.find_account(public_key) - } - - fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - self.inner.insert_account(account) - } - - fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - self.inner.update_account(account) - } - - fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { - self.inner.remove_account(public_key) - } - } - - impl AppStateRepository for FailingSelectionRepository { - fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { - self.inner.load_selected_account() - } - - fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { - if self.fail_next_selection.swap(false, Ordering::SeqCst) { - return Err(SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The test selection repository is unavailable."), - )); - } - self.inner.save_selected_account(public_key) - } - } - - impl AccountRepository for FailingInsertRepository { - fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { - self.inner.list_accounts() - } - - fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { - self.inner.find_account(public_key) - } - - fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> { - Err(SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The test account repository is unavailable."), - )) - } - - fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - self.inner.update_account(account) - } - - fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { - self.inner.remove_account(public_key) - } - } - - impl AppStateRepository for FailingInsertRepository { - fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { - self.inner.load_selected_account() - } - - fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { - self.inner.save_selected_account(public_key) - } - } - - #[test] - fn generate_account_stores_selects_and_returns_one_time_nsec_without_activation() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - - let receipt = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("generate"); - let public_key = receipt.account().public_key(); - assert_eq!(public_key.to_hex().len(), 64); - assert!(secrets.contains(public_key).expect("credential")); - assert_eq!( - accounts.load_selected_account().expect("selection"), - Some(public_key) - ); - assert_eq!(core.snapshot().selected_account(), Some(public_key)); - assert_eq!(core.snapshot().session(), SessionState::SignedOut); - assert!(core.snapshot().active_account().is_none()); - assert_eq!(receipt.generated_nsec().with_exposed_secret(str::len), 63); - assert!(!format!("{:?}", core.snapshot()).contains("nsec1")); - } - - #[test] - fn import_secret_key_accepts_nsec_and_hex_without_exposing_or_activating() { - for input in [ - "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - ] { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let receipt = core - .import_secret_key( - SecretKeyInput::parse(input.to_owned()).expect("input"), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("import"); - let public_key = receipt.account().public_key(); - assert!(secrets.contains(public_key).expect("credential")); - assert_eq!(core.snapshot().selected_account(), Some(public_key)); - assert_eq!(core.snapshot().session(), SessionState::SignedOut); - assert!(!format!("{:?}", core.snapshot()).contains(input)); - } - } - - #[test] - fn import_secret_key_rejects_invalid_nsec_checksum_before_persistence() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let input = SecretKeyInput::parse( - "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(), - ) - .expect("domain shape"); - let error = core - .import_secret_key(input, &accounts, &accounts, &secrets, &journal, &FixedClock) - .expect_err("invalid import"); - assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); - assert!(core.snapshot().accounts().is_empty()); - } - - #[test] - fn duplicate_import_preserves_existing_credential_and_snapshot() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let import = || { - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), - ) - .expect("input") - }; - core.import_secret_key( - import(), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("first import"); - let before = core.snapshot(); - let error = core - .import_secret_key( - import(), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect_err("duplicate"); - assert_eq!(error.code(), SafeErrorCode::AccountAlreadyExists); - assert_eq!(core.snapshot(), before); - assert_eq!(core.snapshot().accounts().len(), 1); - } - - #[test] - fn duplicate_import_repairs_only_explicit_missing_credential_account() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let input = || { - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), - ) - .expect("input") - }; - let imported = core.key_material().import(input()).expect("derive"); - let (public_key, npub, _) = imported.into_parts(); - let missing = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned()).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), - None, - AccountCreatedAt::new(FixedClock.now()), - None, - ) - .expect("missing account"); - accounts.insert_account(&missing).expect("missing metadata"); - accounts - .save_selected_account(Some(public_key)) - .expect("selection"); - core.apply_transition(StateTransition::ReplaceRegistry { - accounts: vec![missing], - selected: Some(public_key), - }) - .expect("registry"); - - let receipt = core - .import_secret_key( - input(), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("repair"); - assert_eq!( - receipt.account().signer().availability(), - BindingAvailability::Available - ); - assert!(secrets.contains(public_key).expect("credential")); - assert_eq!(core.snapshot().accounts().len(), 1); - } - - #[test] - fn account_transaction_publishes_nothing_when_credential_write_fails() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = FailureSecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - secrets.fail_next(SecretStoreOperation::Put); - - let error = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .err() - .expect("credential failure"); - assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); - assert!(core.snapshot().accounts().is_empty()); - assert!( - journal - .list_pending_operations() - .expect("journal") - .is_empty() - ); - } - - #[test] - fn account_transaction_removes_written_credential_when_metadata_fails() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = FailingInsertRepository::default(); - let secrets = FailureSecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - - let error = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .err() - .expect("metadata failure"); - assert_eq!(error.code(), SafeErrorCode::StorageUnavailable); - let calls = secrets.calls(); - assert_eq!(calls[0].operation(), SecretStoreOperation::Put); - assert_eq!(calls[1].operation(), SecretStoreOperation::Delete); - assert_eq!(calls[0].public_key(), calls[1].public_key()); - assert!(core.snapshot().accounts().is_empty()); - assert!( - journal - .list_pending_operations() - .expect("journal") - .is_empty() - ); - } - - #[test] - fn account_transaction_rolls_back_metadata_and_credential_when_selection_fails() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = FailingSelectionRepository::default(); - let secrets = FailureSecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - accounts.fail_next_selection.store(true, Ordering::SeqCst); - - let error = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .err() - .expect("selection failure"); - - assert_eq!(error.code(), SafeErrorCode::StorageUnavailable); - assert!(accounts.list_accounts().expect("accounts").is_empty()); - assert_eq!(accounts.load_selected_account().expect("selection"), None); - let calls = secrets.calls(); - assert_eq!(calls[0].operation(), SecretStoreOperation::Put); - assert_eq!(calls[1].operation(), SecretStoreOperation::Delete); - assert_eq!(calls[0].public_key(), calls[1].public_key()); - assert!(core.snapshot().accounts().is_empty()); - assert!( - journal - .list_pending_operations() - .expect("journal") - .is_empty() - ); - } - - #[test] - fn account_transaction_retains_non_secret_journal_when_compensation_fails() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = FailingInsertRepository::default(); - let secrets = FailureSecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - secrets.fail_next(SecretStoreOperation::Delete); - - let error = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .err() - .expect("recovery required"); - assert_eq!( - error.code(), - SafeErrorCode::PendingOperationRecoveryRequired - ); - let pending = journal.list_pending_operations().expect("journal"); - assert_eq!(pending.len(), 1); - assert_eq!( - pending[0].phase(), - AccountOperationPhase::CompensationPending - ); - assert!(!format!("{pending:?}").contains("nsec1")); - assert!(core.snapshot().accounts().is_empty()); - } - - #[test] - fn select_account_persists_existing_choice_without_activating() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let first = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("first") - .account() - .public_key(); - core.generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("second"); - - let selected = core - .select_account(first, &accounts, &accounts) - .expect("select first"); - assert_eq!(selected.selected_account(), Some(first)); - assert_eq!(selected.session(), SessionState::SignedOut); - assert!(selected.active_account().is_none()); - assert_eq!( - accounts.load_selected_account().expect("saved"), - Some(first) - ); - let missing = core - .select_account( - PublicKey::from_hex( - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", - ) - .expect("unknown public key"), - &accounts, - &accounts, - ) - .expect_err("missing account"); - assert_eq!(missing.code(), SafeErrorCode::AccountNotFound); - assert_eq!(core.snapshot(), selected); - } - - #[test] - fn remove_account_requires_fresh_single_use_confirmation_and_selects_next_fallback() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let first = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("first") - .account() - .public_key(); - let second = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("second") - .account() - .public_key(); - core.select_account(first, &accounts, &accounts) - .expect("select first"); - let stale = core - .request_account_removal(first, &FixedClock) - .expect("stale token"); - core.select_account(second, &accounts, &accounts) - .expect("change revision"); - let stale_error = core - .confirm_account_removal(stale, &accounts, &accounts, &secrets, &journal, &FixedClock) - .expect_err("stale token"); - assert_eq!(stale_error.code(), SafeErrorCode::InvalidApplicationState); - assert_eq!(core.snapshot().accounts().len(), 2); - - core.select_account(first, &accounts, &accounts) - .expect("reselect first"); - let token = core - .request_account_removal(first, &FixedClock) - .expect("token"); - let removed = core - .confirm_account_removal(token, &accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("remove"); - assert_eq!(removed.accounts().len(), 1); - assert_eq!(removed.selected_account(), Some(second)); - assert!(!secrets.contains(first).expect("credential removed")); - assert_eq!(removed.session(), SessionState::SignedOut); - } - - #[test] - fn removal_preflight_reports_impact_expires_and_can_be_cancelled() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let account = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("account") - .account() - .public_key(); - let expired = core - .request_account_removal(account, &FixedClock) - .expect("plan"); - assert!(expired.impact().deletes_local_credential()); - assert!(!expired.impact().signs_out()); - assert!( - core.confirm_account_removal( - expired, &accounts, &accounts, &secrets, &journal, &LateClock, - ) - .is_err() - ); - let cancelled = core - .request_account_removal(account, &FixedClock) - .expect("replacement plan"); - assert!(core.cancel_account_removal(cancelled)); - assert_eq!(core.snapshot().accounts().len(), 1); - } - - #[test] - fn import_rejects_orphan_credentials_and_durable_nonterminal_replays() { - const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let material = core - .key_material() - .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret")) - .expect("key material"); - let (public_key, _npub, secret) = material.into_parts(); - secrets.put(public_key, secret).expect("orphan credential"); - - assert_eq!( - core.import_secret_key( - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect_err("orphan credential must fail") - .code(), - SafeErrorCode::AccountAlreadyExists - ); - - let pending = durable_operation( - DurableOperationKind::Import, - DurableOperationPhase::IntentRecorded, - public_key, - None, - ); - let request_id = pending.request_id().clone(); - let operations = TestDurableRepository::new(pending); - assert_eq!( - core.import_secret_key_durable( - &request_id, - core.snapshot().revision().value(), - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - &accounts, - &accounts, - &secrets, - &operations, - &FixedClock, - ) - .expect_err("unfinished replay must require recovery") - .code(), - SafeErrorCode::PendingOperationRecoveryRequired - ); - } - - #[test] - fn durable_import_covers_new_and_missing_credential_repair_paths() { - const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - for repair in [false, true] { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let material = core - .key_material() - .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret")) - .expect("key material"); - let (public_key, npub, secret) = material.into_parts(); - drop(secret); - if repair { - let account = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned()) - .expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), - None, - AccountCreatedAt::new(FixedClock.now()), - None, - ) - .expect("account"); - accounts.insert_account(&account).expect("insert account"); - accounts - .save_selected_account(Some(public_key)) - .expect("selection"); - core.apply_transition(StateTransition::BootstrapRegistry { - accounts: vec![account], - selected: Some(public_key), - }) - .expect("registry"); - } else { - core.bootstrap().expect("bootstrap"); - } - let kind = if repair { - DurableOperationKind::Repair - } else { - DurableOperationKind::Import - }; - let pending = durable_operation( - kind, - DurableOperationPhase::IntentRecorded, - public_key, - repair.then_some(BindingAvailability::CredentialMissing), - ); - let request_id = pending.request_id().clone(); - let operations = TestDurableRepository::fresh(pending); - let receipt = core - .import_secret_key_durable( - &request_id, - core.snapshot().revision().value(), - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - &accounts, - &accounts, - &secrets, - &operations, - &FixedClock, - ) - .expect("durable import"); - assert_eq!(receipt.account().public_key(), public_key); - assert_eq!( - operations.operation().phase(), - DurableOperationPhase::Finalized - ); - } - } - - #[test] - fn removal_of_unselected_account_preserves_the_current_selection() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let first = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("first") - .account() - .public_key(); - let second = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("second") - .account() - .public_key(); - let token = core - .request_account_removal(first, &FixedClock) - .expect("removal token"); - let snapshot = core - .confirm_account_removal(token, &accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("remove unselected account"); - assert_eq!(snapshot.selected_account(), Some(second)); - - let missing = crate::test_support::valid_test_public_key(99).expect("missing key"); - assert!(accounts.insert_account(&snapshot.accounts()[0]).is_err()); - assert!(accounts.save_selected_account(Some(missing)).is_err()); - - let third = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("third") - .account() - .public_key(); - let token = core - .request_account_removal(second, &FixedClock) - .expect("durable removal token"); - let pending = durable_operation( - DurableOperationKind::Remove, - DurableOperationPhase::IntentRecorded, - second, - Some(BindingAvailability::Available), - ); - let request_id = pending.request_id().clone(); - let operations = TestDurableRepository::fresh(pending); - let snapshot = core - .confirm_account_removal_durable( - &request_id, - token, - &accounts, - &accounts, - &secrets, - &operations, - &FixedClock, - ) - .expect("durable unselected removal"); - assert_eq!(snapshot.selected_account(), Some(third)); - } - - #[test] - fn duplicate_missing_binding_with_orphan_credential_fails_closed() { - const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - let material = core - .key_material() - .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret")) - .expect("key material"); - let (public_key, npub, secret) = material.into_parts(); - let account = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned()).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), - None, - AccountCreatedAt::new(FixedClock.now()), - None, - ) - .expect("account"); - accounts.insert_account(&account).expect("insert account"); - accounts - .save_selected_account(Some(public_key)) - .expect("selection"); - secrets.put(public_key, secret).expect("credential"); - core.apply_transition(StateTransition::BootstrapRegistry { - accounts: vec![account], - selected: Some(public_key), - }) - .expect("registry"); - - assert_eq!( - core.import_secret_key( - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect_err("orphan credential must fail") - .code(), - SafeErrorCode::AccountAlreadyExists - ); - let pending = durable_operation( - DurableOperationKind::Repair, - DurableOperationPhase::IntentRecorded, - public_key, - Some(BindingAvailability::CredentialMissing), - ); - let request_id = pending.request_id().clone(); - let operations = TestDurableRepository::fresh(pending); - assert_eq!( - core.import_secret_key_durable( - &request_id, - core.snapshot().revision().value(), - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - &accounts, - &accounts, - &secrets, - &operations, - &FixedClock, - ) - .expect_err("orphan durable credential must fail") - .code(), - SafeErrorCode::AccountAlreadyExists - ); - } - - #[test] - fn removing_an_active_account_signs_out_for_legacy_and_durable_requests() { - for durable in [false, true] { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let public_key = core - .import_secret_key( - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" - .to_owned(), - ) - .expect("secret"), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("account") - .account() - .public_key(); - core.activate_account( - public_key, - &accounts, - &accounts, - &EmptyProfiles, - &secrets, - &FixedClock, - ) - .expect("activate account"); - let token = core - .request_account_removal(public_key, &FixedClock) - .expect("removal token"); - let snapshot = if durable { - let pending = durable_operation( - DurableOperationKind::Remove, - DurableOperationPhase::IntentRecorded, - public_key, - Some(BindingAvailability::Available), - ); - let request_id = pending.request_id().clone(); - let operations = TestDurableRepository::fresh(pending); - core.confirm_account_removal_durable( - &request_id, - token, - &accounts, - &accounts, - &secrets, - &operations, - &FixedClock, - ) - .expect("durable removal") - } else { - core.confirm_account_removal( - token, - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("removal") - }; - assert_eq!(snapshot.session(), SessionState::SignedOut); - } - } - - #[test] - fn account_transaction_compensates_a_journal_phase_failure() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = FailingUpdateJournal::default(); - core.bootstrap().expect("bootstrap"); - - assert_eq!( - core.generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .err() - .expect("journal failure must be returned") - .code(), - SafeErrorCode::StorageUnavailable - ); - assert!(accounts.list_accounts().unwrap().is_empty()); - } -} diff --git a/crates/studio_application/src/actor.rs b/crates/studio_application/src/actor.rs @@ -1,787 +0,0 @@ -use std::num::{NonZeroU64, NonZeroUsize}; -use std::time::Instant; - -use radroots_studio_domain::{ - AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, - SafeMessage, -}; -use tokio::sync::{mpsc, oneshot}; - -use crate::SnapshotRevision; - -#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct SessionGeneration(u64); - -impl SessionGeneration { - #[must_use] - pub const fn initial() -> Self { - Self(0) - } - - #[must_use] - pub const fn from_value(value: u64) -> Self { - Self(value) - } - - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } - - #[must_use] - pub const fn next(self) -> Option<Self> { - match self.0.checked_add(1) { - Some(value) => Some(Self(value)), - None => None, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ForegroundSessionBinding { - identity: AccountIdentity, - signer: LocalSignerBinding, - generation: SessionGeneration, -} - -impl ForegroundSessionBinding { - /// Binds one foreground session to a ready local signer and generation. - /// - /// # Errors - /// - /// Returns a safe state error when account and binding differ or when the - /// signer is unavailable. - pub fn new( - identity: AccountIdentity, - signer: LocalSignerBinding, - generation: SessionGeneration, - ) -> Result<Self, SafeError> { - if identity.public_key() != signer.account() - || signer.availability() != BindingAvailability::Available - { - return Err(invalid_foreground_session()); - } - Ok(Self { - identity, - signer, - generation, - }) - } - - #[must_use] - pub const fn identity(&self) -> &AccountIdentity { - &self.identity - } - - #[must_use] - pub const fn signer(&self) -> LocalSignerBinding { - self.signer - } - - #[must_use] - pub const fn generation(&self) -> SessionGeneration { - self.generation - } -} - -const fn invalid_foreground_session() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The foreground session binding is invalid."), - ) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct TaskCorrelation { - request_id: RequestId, - account: PublicKey, - binding: LocalSignerBinding, - expected_revision: SnapshotRevision, - session_generation: SessionGeneration, -} - -impl TaskCorrelation { - #[must_use] - pub const fn new( - request_id: RequestId, - account: PublicKey, - binding: LocalSignerBinding, - expected_revision: SnapshotRevision, - session_generation: SessionGeneration, - ) -> Self { - Self { - request_id, - account, - binding, - expected_revision, - session_generation, - } - } - - #[must_use] - pub const fn request_id(self) -> RequestId { - self.request_id - } - - #[must_use] - pub const fn account(self) -> PublicKey { - self.account - } - - #[must_use] - pub const fn binding(self) -> LocalSignerBinding { - self.binding - } - - #[must_use] - pub const fn expected_revision(self) -> SnapshotRevision { - self.expected_revision - } - - #[must_use] - pub const fn session_generation(self) -> SessionGeneration { - self.session_generation - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum RuntimeLifecycle { - Opening, - CompatibilityChecking, - AcquiringOwnership, - Migrating, - Recovering, - Ready, - Degraded(SafeError), - Blocked(SafeError), - ShuttingDown, - Closed, - Fatal(SafeError), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum RuntimeCommandClass { - Observe, - MutateLocalState, - UseCredential, - UseRelay, - RetryOpening, - Shutdown, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct LifecycleGate { - lifecycle: RuntimeLifecycle, -} - -impl Default for LifecycleGate { - fn default() -> Self { - Self::opening() - } -} - -impl LifecycleGate { - #[must_use] - pub const fn opening() -> Self { - Self { - lifecycle: RuntimeLifecycle::Opening, - } - } - - #[must_use] - pub const fn lifecycle(self) -> RuntimeLifecycle { - self.lifecycle - } - - #[must_use] - pub const fn allows(self, command: RuntimeCommandClass) -> bool { - match self.lifecycle { - RuntimeLifecycle::Opening - | RuntimeLifecycle::CompatibilityChecking - | RuntimeLifecycle::AcquiringOwnership - | RuntimeLifecycle::Migrating - | RuntimeLifecycle::Recovering => { - matches!( - command, - RuntimeCommandClass::Observe | RuntimeCommandClass::Shutdown - ) - } - RuntimeLifecycle::Ready => !matches!(command, RuntimeCommandClass::RetryOpening), - RuntimeLifecycle::Degraded(_) => !matches!( - command, - RuntimeCommandClass::UseRelay | RuntimeCommandClass::RetryOpening - ), - RuntimeLifecycle::Blocked(_) => matches!( - command, - RuntimeCommandClass::Observe - | RuntimeCommandClass::RetryOpening - | RuntimeCommandClass::Shutdown - ), - RuntimeLifecycle::ShuttingDown => matches!(command, RuntimeCommandClass::Observe), - RuntimeLifecycle::Closed | RuntimeLifecycle::Fatal(_) => false, - } - } - - /// Advances the required open sequence to compatibility checking. - /// - /// # Errors - /// - /// Returns a safe lifecycle error when the stage is out of order. - pub fn begin_compatibility_check(&mut self) -> Result<(), SafeError> { - self.advance( - RuntimeLifecycle::Opening, - RuntimeLifecycle::CompatibilityChecking, - ) - } - - /// Records compatibility acceptance and begins ownership acquisition. - /// - /// # Errors - /// - /// Returns a safe lifecycle error when the stage is out of order. - pub fn compatibility_accepted(&mut self) -> Result<(), SafeError> { - self.advance( - RuntimeLifecycle::CompatibilityChecking, - RuntimeLifecycle::AcquiringOwnership, - ) - } - - /// Records exclusive ownership and begins migration. - /// - /// # Errors - /// - /// Returns a safe lifecycle error when the stage is out of order. - pub fn ownership_acquired(&mut self) -> Result<(), SafeError> { - self.advance( - RuntimeLifecycle::AcquiringOwnership, - RuntimeLifecycle::Migrating, - ) - } - - /// Records migration completion and begins recovery. - /// - /// # Errors - /// - /// Returns a safe lifecycle error when the stage is out of order. - pub fn migration_complete(&mut self) -> Result<(), SafeError> { - self.advance(RuntimeLifecycle::Migrating, RuntimeLifecycle::Recovering) - } - - /// Records recovery completion and admits normal commands. - /// - /// # Errors - /// - /// Returns a safe lifecycle error when the stage is out of order. - pub fn recovery_complete(&mut self) -> Result<(), SafeError> { - self.advance(RuntimeLifecycle::Recovering, RuntimeLifecycle::Ready) - } - - pub fn block(&mut self, error: SafeError) { - self.lifecycle = RuntimeLifecycle::Blocked(error); - } - - pub fn fail(&mut self, error: SafeError) { - self.lifecycle = RuntimeLifecycle::Fatal(error); - } - - /// Moves a ready runtime into a nonfatal degraded state. - /// - /// # Errors - /// - /// Returns a safe lifecycle error when the runtime is not ready. - pub fn degrade(&mut self, error: SafeError) -> Result<(), SafeError> { - self.advance(RuntimeLifecycle::Ready, RuntimeLifecycle::Degraded(error)) - } - - /// Restores local and relay command availability after degradation. - /// - /// # Errors - /// - /// Returns a safe lifecycle error when the runtime is not degraded. - pub fn restore_ready(&mut self) -> Result<(), SafeError> { - if !matches!(self.lifecycle, RuntimeLifecycle::Degraded(_)) { - return Err(invalid_lifecycle_transition()); - } - self.lifecycle = RuntimeLifecycle::Ready; - Ok(()) - } - - /// Begins actor-owned shutdown. - /// - /// # Errors - /// - /// Returns a safe lifecycle error after shutdown or close has begun. - pub fn begin_shutdown(&mut self) -> Result<(), SafeError> { - if matches!( - self.lifecycle, - RuntimeLifecycle::ShuttingDown | RuntimeLifecycle::Closed - ) { - return Err(invalid_lifecycle_transition()); - } - self.lifecycle = RuntimeLifecycle::ShuttingDown; - Ok(()) - } - - /// Completes actor-owned shutdown. - /// - /// # Errors - /// - /// Returns a safe lifecycle error unless shutdown already began. - pub fn finish_shutdown(&mut self) -> Result<(), SafeError> { - self.advance(RuntimeLifecycle::ShuttingDown, RuntimeLifecycle::Closed) - } - - fn advance( - &mut self, - expected: RuntimeLifecycle, - next: RuntimeLifecycle, - ) -> Result<(), SafeError> { - if self.lifecycle != expected { - return Err(invalid_lifecycle_transition()); - } - self.lifecycle = next; - Ok(()) - } -} - -const fn invalid_lifecycle_transition() -> SafeError { - SafeError::new( - radroots_studio_domain::SafeErrorCode::InvalidApplicationState, - radroots_studio_domain::SafeMessage::new("The runtime lifecycle transition is invalid."), - ) -} - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct RequestId(NonZeroU64); - -impl RequestId { - #[must_use] - pub const fn new(value: u64) -> Option<Self> { - match NonZeroU64::new(value) { - Some(value) => Some(Self(value)), - None => None, - } - } - - #[must_use] - pub const fn get(self) -> u64 { - self.0.get() - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct CommandContext { - request_id: RequestId, - expected_revision: Option<SnapshotRevision>, - deadline: Instant, -} - -impl CommandContext { - #[must_use] - pub const fn new( - request_id: RequestId, - expected_revision: Option<SnapshotRevision>, - deadline: Instant, - ) -> Self { - Self { - request_id, - expected_revision, - deadline, - } - } - - #[must_use] - pub const fn request_id(self) -> RequestId { - self.request_id - } - - #[must_use] - pub const fn expected_revision(self) -> Option<SnapshotRevision> { - self.expected_revision - } - - #[must_use] - pub const fn deadline(self) -> Instant { - self.deadline - } - - #[must_use] - pub fn is_expired(self, now: Instant) -> bool { - now >= self.deadline - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CommandRejection { - MailboxSaturated, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum CommandResult<T> { - Completed(T), - Rejected(CommandRejection), - Conflicted { current_revision: SnapshotRevision }, - TimedOut, - Closed, - Failed(SafeError), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CommandReceipt<T> { - request_id: RequestId, - result: CommandResult<T>, -} - -impl<T> CommandReceipt<T> { - #[must_use] - pub const fn new(request_id: RequestId, result: CommandResult<T>) -> Self { - Self { request_id, result } - } - - #[must_use] - pub const fn request_id(&self) -> RequestId { - self.request_id - } - - #[must_use] - pub const fn result(&self) -> &CommandResult<T> { - &self.result - } - - #[must_use] - pub fn into_result(self) -> CommandResult<T> { - self.result - } -} - -pub struct CommandTicket<T> { - request_id: RequestId, - receiver: oneshot::Receiver<CommandReceipt<T>>, -} - -impl<T> CommandTicket<T> { - #[must_use] - pub const fn request_id(&self) -> RequestId { - self.request_id - } - - pub async fn receipt(self) -> CommandReceipt<T> { - self.receiver - .await - .unwrap_or_else(|_| CommandReceipt::new(self.request_id, CommandResult::Closed)) - } -} - -pub enum CommandSubmission<T> { - Accepted(CommandTicket<T>), - Rejected(CommandReceipt<T>), -} - -impl<T> CommandSubmission<T> { - #[must_use] - pub const fn request_id(&self) -> RequestId { - match self { - Self::Accepted(ticket) => ticket.request_id(), - Self::Rejected(receipt) => receipt.request_id(), - } - } -} - -pub struct CommandEnvelope<C, R> { - context: CommandContext, - command: C, - reply: oneshot::Sender<CommandReceipt<R>>, -} - -impl<C, R> CommandEnvelope<C, R> { - #[must_use] - pub const fn context(&self) -> CommandContext { - self.context - } - - #[must_use] - pub const fn command(&self) -> &C { - &self.command - } - - #[must_use] - pub fn into_parts(self) -> (CommandContext, C, oneshot::Sender<CommandReceipt<R>>) { - (self.context, self.command, self.reply) - } -} - -pub struct ActorMailbox<C, R> { - sender: mpsc::Sender<CommandEnvelope<C, R>>, -} - -impl<C, R> Clone for ActorMailbox<C, R> { - fn clone(&self) -> Self { - Self { - sender: self.sender.clone(), - } - } -} - -impl<C, R> ActorMailbox<C, R> { - #[must_use] - pub fn bounded(capacity: NonZeroUsize) -> (Self, mpsc::Receiver<CommandEnvelope<C, R>>) { - let (sender, receiver) = mpsc::channel(capacity.get()); - (Self { sender }, receiver) - } - - #[must_use] - pub fn available_capacity(&self) -> usize { - self.sender.capacity() - } - - #[must_use] - pub fn submit(&self, context: CommandContext, command: C) -> CommandSubmission<R> { - let request_id = context.request_id(); - if context.is_expired(Instant::now()) { - return CommandSubmission::Rejected(CommandReceipt::new( - request_id, - CommandResult::TimedOut, - )); - } - let (reply, receiver) = oneshot::channel(); - let envelope = CommandEnvelope { - context, - command, - reply, - }; - match self.sender.try_send(envelope) { - Ok(()) => CommandSubmission::Accepted(CommandTicket { - request_id, - receiver, - }), - Err(mpsc::error::TrySendError::Full(_)) => { - CommandSubmission::Rejected(CommandReceipt::new( - request_id, - CommandResult::Rejected(CommandRejection::MailboxSaturated), - )) - } - Err(mpsc::error::TrySendError::Closed(_)) => { - CommandSubmission::Rejected(CommandReceipt::new(request_id, CommandResult::Closed)) - } - } - } -} - -#[cfg(test)] -mod tests { - use std::num::NonZeroUsize; - use std::time::{Duration, Instant}; - - use radroots_studio_domain::{AccountIdentity, BindingAvailability, LocalSignerBinding}; - - use crate::{ - ActorMailbox, CommandContext, CommandReceipt, CommandRejection, CommandResult, - CommandSubmission, ForegroundSessionBinding, LifecycleGate, RequestId, RuntimeCommandClass, - RuntimeLifecycle, SessionGeneration, - }; - - fn context(id: u64) -> CommandContext { - CommandContext::new( - RequestId::new(id).expect("nonzero request"), - None, - Instant::now() + Duration::from_secs(1), - ) - } - - #[test] - fn foreground_session_requires_matching_available_binding_and_generation() { - let public_key = crate::test_support::valid_test_public_key(3).expect("valid public key"); - let identity = AccountIdentity::derive(public_key).expect("identity"); - let generation = SessionGeneration::from_value(4); - let session = ForegroundSessionBinding::new( - identity.clone(), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - generation, - ) - .expect("session"); - assert_eq!(session.identity(), &identity); - assert_eq!(session.signer().account(), public_key); - assert_eq!(session.generation(), generation); - - let correlation = super::TaskCorrelation::new( - RequestId::new(8).expect("request"), - public_key, - session.signer(), - crate::SnapshotRevision::from_value(9), - generation, - ); - assert_eq!(correlation.request_id().get(), 8); - assert_eq!(correlation.account(), public_key); - assert_eq!(correlation.binding(), session.signer()); - assert_eq!(correlation.expected_revision().value(), 9); - assert_eq!(correlation.session_generation(), generation); - - assert!( - ForegroundSessionBinding::new( - identity.clone(), - LocalSignerBinding::new( - radroots_studio_domain::PublicKey::from_hex( - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", - ) - .expect("different valid public key"), - BindingAvailability::Available, - ), - generation, - ) - .is_err() - ); - assert!( - ForegroundSessionBinding::new( - identity, - LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), - generation, - ) - .is_err() - ); - } - - #[tokio::test] - async fn bounded_mailbox_accepts_one_and_rejects_saturation() { - let (mailbox, mut receiver) = - ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity")); - let CommandSubmission::Accepted(ticket) = mailbox.submit(context(1), 7) else { - panic!("first command must be accepted"); - }; - let CommandSubmission::Rejected(rejected) = mailbox.submit(context(2), 8) else { - panic!("second command must be rejected"); - }; - assert_eq!( - rejected.into_result(), - CommandResult::Rejected(CommandRejection::MailboxSaturated) - ); - - let envelope = receiver.recv().await.expect("command"); - assert_eq!(envelope.context().request_id().get(), 1); - assert_eq!(*envelope.command(), 7); - let (context, command, reply) = envelope.into_parts(); - reply - .send(CommandReceipt::new( - context.request_id(), - CommandResult::Completed(command + 1), - )) - .expect("ticket remains open"); - assert_eq!( - ticket.receipt().await.into_result(), - CommandResult::Completed(8) - ); - } - - #[test] - fn expired_and_closed_mailboxes_reject_without_enqueuing() { - let (mailbox, receiver) = - ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity")); - let expired = - CommandContext::new(RequestId::new(1).expect("request"), None, Instant::now()); - let CommandSubmission::Rejected(receipt) = mailbox.submit(expired, 1) else { - panic!("expired command must be rejected"); - }; - assert_eq!(receipt.into_result(), CommandResult::TimedOut); - - drop(receiver); - let CommandSubmission::Rejected(receipt) = mailbox.submit(context(2), 2) else { - panic!("closed mailbox must be rejected"); - }; - assert_eq!(receipt.into_result(), CommandResult::Closed); - } - - #[tokio::test] - async fn dropped_actor_reply_becomes_closed_receipt() { - let (mailbox, mut receiver) = - ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity")); - let CommandSubmission::Accepted(ticket) = mailbox.submit(context(1), 1) else { - panic!("command must be accepted"); - }; - drop(receiver.recv().await.expect("command")); - - assert_eq!(ticket.receipt().await.into_result(), CommandResult::Closed); - } - - #[test] - fn opening_sequence_gates_mutation_until_recovery_completes() { - let mut lifecycle = LifecycleGate::opening(); - for expected in [ - RuntimeLifecycle::Opening, - RuntimeLifecycle::CompatibilityChecking, - RuntimeLifecycle::AcquiringOwnership, - RuntimeLifecycle::Migrating, - RuntimeLifecycle::Recovering, - ] { - assert_eq!(lifecycle.lifecycle(), expected); - assert!(lifecycle.allows(RuntimeCommandClass::Observe)); - assert!(lifecycle.allows(RuntimeCommandClass::Shutdown)); - assert!(!lifecycle.allows(RuntimeCommandClass::MutateLocalState)); - match expected { - RuntimeLifecycle::Opening => { - lifecycle - .begin_compatibility_check() - .expect("compatibility"); - } - RuntimeLifecycle::CompatibilityChecking => { - lifecycle - .compatibility_accepted() - .expect("compatibility accepted"); - } - RuntimeLifecycle::AcquiringOwnership => { - lifecycle.ownership_acquired().expect("ownership"); - } - RuntimeLifecycle::Migrating => { - lifecycle.migration_complete().expect("migration"); - } - RuntimeLifecycle::Recovering => { - lifecycle.recovery_complete().expect("recovery"); - } - _ => unreachable!("opening states only"), - } - } - assert_eq!(lifecycle.lifecycle(), RuntimeLifecycle::Ready); - assert!(lifecycle.allows(RuntimeCommandClass::MutateLocalState)); - assert!(lifecycle.allows(RuntimeCommandClass::UseCredential)); - assert!(lifecycle.allows(RuntimeCommandClass::UseRelay)); - } - - #[test] - fn blocked_degraded_fatal_and_closed_states_fail_safe() { - let problem = radroots_studio_domain::SafeError::new( - radroots_studio_domain::SafeErrorCode::StorageUnavailable, - radroots_studio_domain::SafeMessage::new("The runtime is unavailable."), - ); - let mut blocked = LifecycleGate::opening(); - blocked.block(problem); - assert!(blocked.allows(RuntimeCommandClass::RetryOpening)); - assert!(!blocked.allows(RuntimeCommandClass::MutateLocalState)); - - let mut degraded = LifecycleGate::opening(); - degraded.begin_compatibility_check().expect("compatibility"); - degraded.compatibility_accepted().expect("accepted"); - degraded.ownership_acquired().expect("ownership"); - degraded.migration_complete().expect("migration"); - degraded.recovery_complete().expect("recovery"); - degraded.degrade(problem).expect("degraded"); - assert!(degraded.allows(RuntimeCommandClass::MutateLocalState)); - assert!(!degraded.allows(RuntimeCommandClass::UseRelay)); - degraded.restore_ready().expect("restored"); - assert!(LifecycleGate::opening().restore_ready().is_err()); - - let mut fatal = LifecycleGate::opening(); - fatal.fail(problem); - assert!(!fatal.allows(RuntimeCommandClass::Observe)); - fatal.begin_shutdown().expect("fatal can close"); - fatal.finish_shutdown().expect("closed"); - assert_eq!(fatal.lifecycle(), RuntimeLifecycle::Closed); - assert!(!fatal.allows(RuntimeCommandClass::Shutdown)); - } - - #[test] - fn opening_stages_reject_out_of_order_and_repeated_transitions() { - let mut lifecycle = LifecycleGate::opening(); - assert!(lifecycle.migration_complete().is_err()); - lifecycle.begin_compatibility_check().expect("first stage"); - assert!(lifecycle.begin_compatibility_check().is_err()); - assert!(lifecycle.recovery_complete().is_err()); - } -} diff --git a/crates/studio_application/src/app_core.rs b/crates/studio_application/src/app_core.rs @@ -1,358 +0,0 @@ -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex, MutexGuard}; - -use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp}; - -use crate::{ - AccountRepository, AppSnapshot, AppStateRepository, KeyMaterialProvider, RelayConfiguration, - SnapshotRevision, StateMachine, StateTransition, -}; - -pub struct RemovalConfirmationToken { - id: u64, - public_key: PublicKey, - revision: SnapshotRevision, - expires_at: UnixTimestamp, - impact: RemovalImpact, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct RemovalImpact { - deletes_local_credential: bool, - signs_out: bool, -} - -impl RemovalImpact { - #[must_use] - pub const fn deletes_local_credential(self) -> bool { - self.deletes_local_credential - } - #[must_use] - pub const fn signs_out(self) -> bool { - self.signs_out - } -} - -impl RemovalConfirmationToken { - #[must_use] - pub const fn public_key(&self) -> PublicKey { - self.public_key - } - #[must_use] - pub const fn revision(&self) -> SnapshotRevision { - self.revision - } - #[must_use] - pub const fn expires_at(&self) -> UnixTimestamp { - self.expires_at - } - #[must_use] - pub const fn impact(&self) -> RemovalImpact { - self.impact - } -} - -#[derive(Clone, Copy)] -struct RemovalTokenState { - public_key: PublicKey, - revision: SnapshotRevision, - expires_at: UnixTimestamp, - impact: RemovalImpact, -} - -struct CoreState { - state_machine: StateMachine, - removal_tokens: BTreeMap<u64, RemovalTokenState>, - next_removal_token: u64, -} - -pub struct AppCore { - relay_configuration: RelayConfiguration, - key_material: Arc<dyn KeyMaterialProvider>, - state: Mutex<CoreState>, -} - -impl AppCore { - #[must_use] - pub fn new( - relay_configuration: RelayConfiguration, - key_material: Arc<dyn KeyMaterialProvider>, - ) -> Self { - Self { - relay_configuration, - key_material, - state: Mutex::new(CoreState { - state_machine: StateMachine::booting(), - removal_tokens: BTreeMap::new(), - next_removal_token: 1, - }), - } - } - - #[cfg(test)] - #[must_use] - pub fn in_memory(relay_configuration: RelayConfiguration) -> Self { - Self::new( - relay_configuration, - Arc::new(crate::test_support::TestKeyMaterialProvider::default()), - ) - } - - pub(crate) fn key_material(&self) -> &dyn KeyMaterialProvider { - self.key_material.as_ref() - } - - /// Moves the in-memory core from booting to an empty ready snapshot. - /// - /// # Errors - /// - /// Returns a safe application-state error if the ready snapshot invariant - /// cannot be constructed. - pub fn bootstrap(&self) -> Result<AppSnapshot, SafeError> { - self.apply_transition(StateTransition::Bootstrap) - } - - /// Loads the durable public registry and selection into a signed-out snapshot. - /// - /// # Errors - /// - /// Returns the safe persistence error after publishing a fatal snapshot when - /// durable state cannot be read or violates application invariants. - pub fn bootstrap_from( - &self, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - ) -> Result<AppSnapshot, SafeError> { - let loaded = accounts.list_accounts().and_then(|accounts| { - app_state - .load_selected_account() - .map(|selected| (accounts, selected)) - }); - match loaded { - Ok((accounts, selected)) => { - self.apply_transition(StateTransition::BootstrapRegistry { accounts, selected }) - } - Err(error) => { - self.apply_transition(StateTransition::Fatal(error))?; - Err(error) - } - } - } - - #[must_use] - pub fn snapshot(&self) -> AppSnapshot { - self.lock_state().state_machine.snapshot().clone() - } - - pub(crate) fn apply_transition( - &self, - transition: StateTransition, - ) -> Result<AppSnapshot, SafeError> { - self.lock_state() - .state_machine - .apply(transition, &self.relay_configuration) - } - - pub(crate) fn issue_removal_token( - &self, - public_key: PublicKey, - now: UnixTimestamp, - ) -> Result<RemovalConfirmationToken, SafeError> { - let mut state = self.lock_state(); - let Some(account) = state - .state_machine - .snapshot() - .accounts() - .iter() - .find(|account| account.public_key() == public_key) - else { - return Err(account_not_found()); - }; - let deletes_local_credential = account.signer().availability() - != radroots_studio_domain::BindingAvailability::CredentialMissing; - let id = state.next_removal_token; - state.next_removal_token = id.checked_add(1).ok_or_else(invalid_application_state)?; - let revision = state.state_machine.snapshot().revision(); - let expires_at = UnixTimestamp::from_seconds( - now.as_seconds() - .checked_add(300) - .ok_or_else(invalid_application_state)?, - ) - .ok_or_else(invalid_application_state)?; - let impact = RemovalImpact { - deletes_local_credential, - signs_out: state - .state_machine - .snapshot() - .active_account() - .is_some_and(|active| active.account().public_key() == public_key), - }; - state.removal_tokens.insert( - id, - RemovalTokenState { - public_key, - revision, - expires_at, - impact, - }, - ); - Ok(RemovalConfirmationToken { - id, - public_key, - revision, - expires_at, - impact, - }) - } - - #[allow(clippy::needless_pass_by_value)] - pub(crate) fn consume_removal_token( - &self, - token: RemovalConfirmationToken, - now: UnixTimestamp, - ) -> Result<PublicKey, SafeError> { - let RemovalConfirmationToken { - id, - public_key, - revision, - expires_at, - impact, - } = token; - let mut state = self.lock_state(); - let stored = state.removal_tokens.remove(&id); - if stored.is_none_or(|stored| { - stored.public_key != public_key - || stored.revision != revision - || stored.expires_at != expires_at - || stored.impact != impact - }) || state.state_machine.snapshot().revision() != revision - || now.as_seconds() > expires_at.as_seconds() - { - return Err(invalid_application_state()); - } - Ok(public_key) - } - - #[allow(clippy::needless_pass_by_value)] - pub(crate) fn cancel_removal_token(&self, token: RemovalConfirmationToken) -> bool { - self.lock_state().removal_tokens.remove(&token.id).is_some() - } - - fn lock_state(&self) -> MutexGuard<'_, CoreState> { - self.state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } -} - -const fn invalid_application_state() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The account removal confirmation is no longer valid."), - ) -} - -const fn account_not_found() -> SafeError { - SafeError::new( - SafeErrorCode::AccountNotFound, - SafeMessage::new("The account was not found."), - ) -} - -#[cfg(test)] -mod tests { - use radroots_studio_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - UnixTimestamp, - }; - - use crate::{AppCore, AppLifecycle, RelayConfiguration, StateTransition}; - - #[test] - fn bootstrap_is_idempotent_and_advances_only_once() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let ready = core.bootstrap().expect("bootstrap"); - let repeated = core.bootstrap().expect("idempotent bootstrap"); - - assert_eq!(ready.lifecycle(), AppLifecycle::Ready); - assert_eq!(ready.revision().value(), 1); - assert_eq!(repeated, ready); - } - - #[test] - fn core_instances_never_share_state() { - let first = AppCore::in_memory(RelayConfiguration::default()); - let second = AppCore::in_memory(RelayConfiguration::default()); - - first.bootstrap().expect("first bootstrap"); - - assert_eq!(first.snapshot().revision().value(), 1); - assert_eq!(second.snapshot().revision().value(), 0); - } - - #[test] - fn removal_impact_matches_missing_local_binding() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let public_key = crate::test_support::valid_test_public_key(9).expect("valid public key"); - let account = AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), - None, - ) - .expect("account"); - core.apply_transition(StateTransition::BootstrapRegistry { - accounts: vec![account], - selected: Some(public_key), - }) - .expect("registry"); - - let removal = core - .issue_removal_token(public_key, UnixTimestamp::from_seconds(2).expect("time")) - .expect("removal"); - - assert!(!removal.impact().deletes_local_credential()); - assert!(!removal.impact().signs_out()); - } - - #[test] - fn removal_confirmation_rejects_every_tampered_authority_field() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let public_key = crate::test_support::valid_test_public_key(7).expect("public key"); - let other_key = crate::test_support::valid_test_public_key(8).expect("other key"); - let account = AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), - None, - ) - .expect("account"); - core.apply_transition(StateTransition::BootstrapRegistry { - accounts: vec![account], - selected: Some(public_key), - }) - .expect("registry"); - - let now = UnixTimestamp::from_seconds(2).expect("now"); - let mut wrong_key = core.issue_removal_token(public_key, now).expect("token"); - wrong_key.public_key = other_key; - assert!(core.consume_removal_token(wrong_key, now).is_err()); - - let mut wrong_revision = core.issue_removal_token(public_key, now).expect("token"); - wrong_revision.revision = crate::SnapshotRevision::initial(); - assert!(core.consume_removal_token(wrong_revision, now).is_err()); - - let mut wrong_expiry = core.issue_removal_token(public_key, now).expect("token"); - wrong_expiry.expires_at = UnixTimestamp::from_seconds(999).expect("expiry"); - assert!(core.consume_removal_token(wrong_expiry, now).is_err()); - - let mut wrong_impact = core.issue_removal_token(public_key, now).expect("token"); - wrong_impact.impact = super::RemovalImpact { - deletes_local_credential: false, - signs_out: false, - }; - assert!(core.consume_removal_token(wrong_impact, now).is_err()); - } -} diff --git a/crates/studio_application/src/change_stream.rs b/crates/studio_application/src/change_stream.rs @@ -1,239 +0,0 @@ -use std::collections::BTreeMap; -use std::num::{NonZeroU64, NonZeroUsize}; - -use tokio::sync::mpsc; - -use crate::{AppSnapshot, SnapshotRevision}; - -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub struct ChangeSubscriptionId(NonZeroU64); - -impl ChangeSubscriptionId { - #[must_use] - pub const fn value(self) -> u64 { - self.0.get() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SnapshotChange { - snapshot: AppSnapshot, - previous_revision: Option<SnapshotRevision>, -} - -impl SnapshotChange { - #[must_use] - pub const fn revision(&self) -> SnapshotRevision { - self.snapshot.revision() - } - - #[must_use] - pub const fn snapshot(&self) -> &AppSnapshot { - &self.snapshot - } - - #[must_use] - pub fn into_snapshot(self) -> AppSnapshot { - self.snapshot - } - - #[must_use] - pub const fn previous_revision(&self) -> Option<SnapshotRevision> { - self.previous_revision - } - - #[must_use] - pub fn recovers_gap_after(&self, observed: SnapshotRevision) -> bool { - self.previous_revision - .is_some_and(|previous| previous != observed) - } -} - -pub struct SnapshotChangeReceiver { - receiver: mpsc::Receiver<SnapshotChange>, -} - -impl SnapshotChangeReceiver { - pub async fn receive(&mut self) -> Option<SnapshotChange> { - self.receiver.recv().await - } -} - -pub struct OrderedSnapshotChanges { - latest: AppSnapshot, - next_subscription: u64, - subscribers: BTreeMap<ChangeSubscriptionId, mpsc::Sender<SnapshotChange>>, - closed: bool, -} - -impl OrderedSnapshotChanges { - #[must_use] - pub fn new(initial_snapshot: AppSnapshot) -> Self { - Self { - latest: initial_snapshot, - next_subscription: 1, - subscribers: BTreeMap::new(), - closed: false, - } - } - - #[must_use] - pub const fn last_revision(&self) -> SnapshotRevision { - self.latest.revision() - } - - /// Registers a bounded consumer for future changes. - /// - /// # Errors - /// - /// Returns `None` if the subscription identifier space is exhausted. - pub fn subscribe( - &mut self, - capacity: NonZeroUsize, - ) -> Option<(ChangeSubscriptionId, SnapshotChangeReceiver)> { - if self.closed { - return None; - } - let id = ChangeSubscriptionId(NonZeroU64::new(self.next_subscription)?); - self.next_subscription = self.next_subscription.checked_add(1)?; - let (sender, receiver) = mpsc::channel(capacity.get()); - sender - .try_send(SnapshotChange { - snapshot: self.latest.clone(), - previous_revision: None, - }) - .ok()?; - self.subscribers.insert(id, sender); - Some((id, SnapshotChangeReceiver { receiver })) - } - - #[must_use] - pub fn unsubscribe(&mut self, id: ChangeSubscriptionId) -> bool { - self.subscribers.remove(&id).is_some() - } - - pub fn publish(&mut self, snapshot: AppSnapshot) { - if self.closed || snapshot.revision() <= self.latest.revision() { - return; - } - let change = SnapshotChange { - previous_revision: Some(self.latest.revision()), - snapshot, - }; - self.latest = change.snapshot.clone(); - self.subscribers - .retain(|_, sender| match sender.try_send(change.clone()) { - Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => true, - Err(mpsc::error::TrySendError::Closed(_)) => false, - }); - } - - pub fn close(&mut self) { - self.closed = true; - self.subscribers.clear(); - } -} - -#[cfg(test)] -mod tests { - use std::num::NonZeroUsize; - - use crate::{ - AppSnapshot, OrderedSnapshotChanges, RelayConfiguration, SessionState, SnapshotRevision, - }; - - #[tokio::test] - async fn change_stream_publishes_monotonic_revisions_to_multiple_consumers() { - let mut changes = OrderedSnapshotChanges::new(snapshot(0)); - let (_, mut first) = changes - .subscribe(NonZeroUsize::new(4).expect("capacity")) - .expect("first subscription"); - let (_, mut second) = changes - .subscribe(NonZeroUsize::new(4).expect("capacity")) - .expect("second subscription"); - - assert_eq!( - first.receive().await.expect("initial").revision(), - revision(0) - ); - assert_eq!( - second.receive().await.expect("initial").revision(), - revision(0) - ); - changes.publish(snapshot(1)); - changes.publish(snapshot(1)); - changes.publish(snapshot(2)); - - for receiver in [&mut first, &mut second] { - assert_eq!( - receiver.receive().await.expect("revision 1").revision(), - revision(1) - ); - assert_eq!( - receiver.receive().await.expect("revision 2").revision(), - revision(2) - ); - } - assert_eq!(changes.last_revision(), revision(2)); - } - - #[tokio::test] - async fn slow_consumers_expose_a_revision_gap_without_blocking_publication() { - let mut changes = OrderedSnapshotChanges::new(snapshot(0)); - let (_, mut receiver) = changes - .subscribe(NonZeroUsize::new(1).expect("capacity")) - .expect("subscription"); - - assert_eq!( - receiver.receive().await.expect("initial").revision(), - revision(0) - ); - changes.publish(snapshot(1)); - changes.publish(snapshot(2)); - let first = receiver.receive().await.expect("first"); - assert_eq!(first.revision(), revision(1)); - changes.publish(snapshot(3)); - let recovered = receiver.receive().await.expect("gap recovery"); - assert_eq!(recovered.revision(), revision(3)); - assert!(recovered.recovers_gap_after(first.revision())); - } - - #[tokio::test] - async fn close_terminates_consumers_and_rejects_later_subscriptions() { - let mut changes = OrderedSnapshotChanges::new(snapshot(0)); - let (_, mut receiver) = changes - .subscribe(NonZeroUsize::new(1).expect("capacity")) - .expect("subscription"); - receiver.receive().await.expect("initial"); - - changes.close(); - changes.publish(snapshot(1)); - assert!(receiver.receive().await.is_none()); - assert!( - changes - .subscribe(NonZeroUsize::new(1).expect("capacity")) - .is_none() - ); - } - - fn revision(value: u64) -> SnapshotRevision { - SnapshotRevision::from_value(value) - } - - fn snapshot(value: u64) -> AppSnapshot { - if value == 0 { - AppSnapshot::booting() - } else { - AppSnapshot::ready( - revision(value), - RelayConfiguration::default(), - Vec::new(), - None, - SessionState::SignedOut, - None, - None, - ) - .expect("snapshot") - } - } -} diff --git a/crates/studio_application/src/config.rs b/crates/studio_application/src/config.rs @@ -1,107 +0,0 @@ -use radroots_studio_domain::{ - RelayDestinationPolicy, SafeError, SafeErrorCode, SafeMessage, normalize_relay_urls, -}; - -use crate::RelayConfiguration; - -pub const RELAY_ENVIRONMENT_VARIABLE: &str = "RADROOTS_NOSTR_RELAYS"; -const DEVELOPMENT_RELAY: &str = "ws://localhost:8080"; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum RelayRuntimeMode { - Development, - Packaged, -} - -/// Reads the process relay configuration once through the Rust-owned boundary. -/// -/// # Errors -/// -/// Returns a safe configuration error for missing Unicode or invalid relay data. -pub fn relay_configuration_from_environment( - mode: RelayRuntimeMode, -) -> Result<RelayConfiguration, SafeError> { - let value = match std::env::var(RELAY_ENVIRONMENT_VARIABLE) { - Ok(value) => Some(value), - Err(std::env::VarError::NotPresent) => None, - Err(std::env::VarError::NotUnicode(_)) => return Err(invalid_configuration()), - }; - relay_configuration_from_value(value.as_deref(), mode) -} - -/// Parses an injected comma-separated relay list without mutating process state. -/// -/// # Errors -/// -/// Returns a safe configuration error when an entry is invalid or packaged mode -/// has no configured relay. -pub fn relay_configuration_from_value( - value: Option<&str>, - mode: RelayRuntimeMode, -) -> Result<RelayConfiguration, SafeError> { - let configured = value.unwrap_or_default().trim(); - let (source, policy) = if configured.is_empty() { - match mode { - RelayRuntimeMode::Development => (DEVELOPMENT_RELAY, RelayDestinationPolicy::Local), - RelayRuntimeMode::Packaged => return Err(invalid_configuration()), - } - } else { - (configured, RelayDestinationPolicy::Public) - }; - let normalized = normalize_relay_urls(source.split(',').map(str::trim), policy)?; - if normalized.is_empty() { - return Err(invalid_configuration()); - } - RelayConfiguration::new(normalized) -} - -const fn invalid_configuration() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidRelayConfiguration, - SafeMessage::new("The Nostr relay configuration is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use radroots_studio_domain::SafeErrorCode; - - use super::{RelayRuntimeMode, relay_configuration_from_value}; - - #[test] - fn relay_config_uses_localhost_fallback_only_for_development() { - for value in [None, Some(""), Some(" ")] { - let development = relay_configuration_from_value(value, RelayRuntimeMode::Development) - .expect("development fallback"); - assert_eq!(development.relays()[0].as_str(), "ws://localhost:8080/"); - let packaged = relay_configuration_from_value(value, RelayRuntimeMode::Packaged) - .expect_err("packaged configuration required"); - assert_eq!(packaged.code(), SafeErrorCode::InvalidRelayConfiguration); - } - } - - #[test] - fn relay_config_trims_deduplicates_and_preserves_order() { - let configuration = relay_configuration_from_value( - Some(" wss://relay.one ,wss://relay.two,wss://relay.one/ "), - RelayRuntimeMode::Packaged, - ) - .expect("configuration"); - let relays = configuration - .relays() - .iter() - .map(radroots_studio_domain::RelayUrl::as_str) - .collect::<Vec<_>>(); - assert_eq!(relays, ["wss://relay.one/", "wss://relay.two/"]); - } - - #[test] - fn relay_config_rejects_any_invalid_comma_separated_entry() { - let error = relay_configuration_from_value( - Some("wss://relay.one,https://not-a-relay.test"), - RelayRuntimeMode::Packaged, - ) - .expect_err("invalid entry"); - assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); - } -} diff --git a/crates/studio_application/src/custody.rs b/crates/studio_application/src/custody.rs @@ -1,288 +0,0 @@ -use std::num::NonZeroU64; -use std::sync::Mutex; -use std::time::Duration; - -use crate::KeyMaterialProvider; -use radroots_studio_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - Nsec, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, -}; -pub const GENERATED_KEY_STAGE_TTL: Duration = Duration::from_mins(5); - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct RecoveryStageId(NonZeroU64); - -impl RecoveryStageId { - #[must_use] - pub const fn new(value: NonZeroU64) -> Self { - Self(value) - } - - #[must_use] - pub const fn value(self) -> u64 { - self.0.get() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct GeneratedKeyStageView { - account: AccountSummary, - expires_at: UnixTimestamp, -} - -impl GeneratedKeyStageView { - #[must_use] - pub const fn account(&self) -> &AccountSummary { - &self.account - } - - #[must_use] - pub const fn expires_at(&self) -> UnixTimestamp { - self.expires_at - } -} - -pub struct StagedGeneratedKey { - id: RecoveryStageId, - account: AccountSummary, - secret: SecretKeyInput, - expected_revision: u64, - expires_at: UnixTimestamp, -} - -impl StagedGeneratedKey { - #[must_use] - pub fn view(&self) -> GeneratedKeyStageView { - GeneratedKeyStageView { - account: self.account.clone(), - expires_at: self.expires_at, - } - } - - #[must_use] - pub const fn expected_revision(&self) -> u64 { - self.expected_revision - } - - #[must_use] - pub const fn id(&self) -> RecoveryStageId { - self.id - } - - #[must_use] - pub const fn account(&self) -> &AccountSummary { - &self.account - } - - #[must_use] - pub fn into_commit_parts(self) -> (AccountSummary, SecretKeyInput) { - (self.account, self.secret) - } -} - -pub struct GeneratedKeyRecoveryHandle { - id: RecoveryStageId, - view: GeneratedKeyStageView, - recovery_nsec: Mutex<Option<Nsec>>, -} - -impl GeneratedKeyRecoveryHandle { - fn new(id: RecoveryStageId, view: GeneratedKeyStageView, recovery_nsec: Nsec) -> Self { - Self { - id, - view, - recovery_nsec: Mutex::new(Some(recovery_nsec)), - } - } - - #[must_use] - pub const fn id(&self) -> RecoveryStageId { - self.id - } - - #[must_use] - pub const fn view(&self) -> &GeneratedKeyStageView { - &self.view - } - - /// Returns the generated recovery value exactly once. - /// - /// # Errors - /// - /// Returns a safe unavailable error after the value was already consumed. - pub fn take_recovery_nsec(&self) -> Result<Nsec, SafeError> { - self.recovery_nsec - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take() - .ok_or_else(recovery_not_available) - } -} - -#[derive(Default)] -pub struct GeneratedKeyStage { - pending: Option<StagedGeneratedKey>, -} - -impl GeneratedKeyStage { - /// Replaces an expired stage or creates the only active generated-key stage. - /// - /// # Errors - /// - /// Returns a safe conflict while an unexpired recovery stage is active. - pub fn begin( - &mut self, - key_material: &dyn KeyMaterialProvider, - id: RecoveryStageId, - expected_revision: u64, - now: UnixTimestamp, - ) -> Result<GeneratedKeyRecoveryHandle, SafeError> { - self.expire(now); - if self.pending.is_some() { - return Err(recovery_in_progress()); - } - let generated = key_material.generate()?; - let (public_key, npub, secret, recovery_nsec) = generated.into_parts(); - let account = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned())?, - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(now), - None, - )?; - let ttl = - i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).map_err(|_| invalid_stage_expiry())?; - let expires_at = now - .as_seconds() - .checked_add(ttl) - .and_then(UnixTimestamp::from_seconds) - .ok_or_else(invalid_stage_expiry)?; - let pending = StagedGeneratedKey { - id, - account, - secret, - expected_revision, - expires_at, - }; - let view = pending.view(); - self.pending = Some(pending); - Ok(GeneratedKeyRecoveryHandle::new(id, view, recovery_nsec)) - } - - pub fn cancel(&mut self) -> bool { - self.pending.take().is_some() - } - - pub fn expire(&mut self, now: UnixTimestamp) -> bool { - if self - .pending - .as_ref() - .is_some_and(|pending| now >= pending.expires_at) - { - self.pending = None; - true - } else { - false - } - } - - #[must_use] - pub const fn pending(&self) -> Option<&StagedGeneratedKey> { - self.pending.as_ref() - } - - /// Consumes the active, unexpired stage for its commit boundary. - /// - /// # Errors - /// - /// Returns a safe unavailable error when no live stage remains. - pub fn take( - &mut self, - id: RecoveryStageId, - now: UnixTimestamp, - ) -> Result<StagedGeneratedKey, SafeError> { - self.expire(now); - if self.pending.as_ref().map(StagedGeneratedKey::id) != Some(id) { - return Err(recovery_not_available()); - } - self.pending.take().ok_or_else(recovery_not_available) - } -} - -const fn recovery_in_progress() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("A generated-key recovery step is already in progress."), - ) -} - -const fn recovery_not_available() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The generated-key recovery step is no longer available."), - ) -} - -const fn invalid_stage_expiry() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The generated-key recovery expiry is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use std::num::NonZeroU64; - - use radroots_studio_domain::UnixTimestamp; - - use super::{GENERATED_KEY_STAGE_TTL, GeneratedKeyStage, RecoveryStageId}; - use crate::test_support::TestKeyMaterialProvider; - - fn time(seconds: i64) -> UnixTimestamp { - UnixTimestamp::from_seconds(seconds).expect("time") - } - - fn id(value: u64) -> RecoveryStageId { - RecoveryStageId::new(NonZeroU64::new(value).expect("id")) - } - - #[test] - fn stage_is_exclusive_cancelable_and_never_publishes_secret_debug() { - let mut stage = GeneratedKeyStage::default(); - let key_material = TestKeyMaterialProvider::default(); - let handle = stage - .begin(&key_material, id(1), 4, time(10)) - .expect("begin"); - let view = handle.view(); - assert_eq!(view.expires_at().as_seconds(), 310); - assert_eq!(stage.pending().expect("pending").expected_revision(), 4); - assert!(stage.begin(&key_material, id(2), 4, time(11)).is_err()); - let nsec = handle.take_recovery_nsec().expect("one-use recovery"); - assert_eq!(nsec.with_exposed_secret(str::len), 63); - assert!(handle.take_recovery_nsec().is_err()); - assert!(stage.cancel()); - assert!(!stage.cancel()); - assert!(format!("{view:?}").contains(view.account().npub().as_str())); - assert!(!format!("{view:?}").contains("nsec1")); - } - - #[test] - fn stage_expires_and_is_destroyed_on_owner_drop() { - let mut stage = GeneratedKeyStage::default(); - let key_material = TestKeyMaterialProvider::default(); - stage - .begin(&key_material, id(1), 0, time(20)) - .expect("begin"); - let expiry = 20 + i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).expect("ttl"); - assert!(stage.expire(time(expiry))); - assert!(stage.pending().is_none()); - assert!(stage.take(id(1), time(expiry)).is_err()); - - let mut shutdown_stage = GeneratedKeyStage::default(); - shutdown_stage - .begin(&key_material, id(2), 0, time(30)) - .expect("begin"); - drop(shutdown_stage); - } -} diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs @@ -1,58 +0,0 @@ -#![doc = "Radroots Studio application runtime."] - -pub mod accounts; -pub mod actor; -pub mod app_core; -mod change_stream; -pub mod config; -pub mod custody; -pub mod ports; -mod profile_refresh; -pub mod recovery; -pub mod secrets; -pub mod session; -pub mod snapshot; -pub mod state_machine; - -#[cfg(test)] -mod test_support; - -pub use accounts::{ - GenerateAccountReceipt, ImportAccountReceipt, InMemoryAccountRepository, - InMemoryOperationJournal, -}; -pub use actor::{ - ActorMailbox, CommandContext, CommandEnvelope, CommandReceipt, CommandRejection, CommandResult, - CommandSubmission, CommandTicket, ForegroundSessionBinding, LifecycleGate, RequestId, - RuntimeCommandClass, RuntimeLifecycle, SessionGeneration, TaskCorrelation, -}; -pub use app_core::{AppCore, RemovalConfirmationToken, RemovalImpact}; -pub use change_stream::{ - ChangeSubscriptionId, OrderedSnapshotChanges, SnapshotChange, SnapshotChangeReceiver, -}; -pub use config::{ - RelayRuntimeMode, relay_configuration_from_environment, relay_configuration_from_value, -}; -pub use custody::{ - GENERATED_KEY_STAGE_TTL, GeneratedKeyRecoveryHandle, GeneratedKeyStage, GeneratedKeyStageView, - RecoveryStageId, StagedGeneratedKey, -}; -pub use ports::{ - AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey, - AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock, - DurableAccountOperation, DurableOperationKind, DurableOperationPhase, DurableOperationReceipt, - DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome, - GeneratedKeyMaterial, ImportedKeyMaterial, KeyMaterialProvider, NostrClient, - OperationDiagnostic, OperationId, OperationJournal, OperationPriorState, - PendingAccountOperation, ProfileFetchResult, ProfileRefreshStatus, ProfileRepository, - RelayFetchCompleteness, -}; -pub use profile_refresh::ProfileRefreshPlan; -pub use secrets::{ - FailureSecretStore, InMemorySecretStore, SecretStore, SecretStoreCall, SecretStoreOperation, -}; -pub use snapshot::{ - ActiveAccountSnapshot, AppLifecycle, AppSnapshot, MAX_CONFIGURED_RELAYS, ProfileLoadState, - RelayConfiguration, RelayConnectionState, SessionState, SnapshotRevision, -}; -pub use state_machine::{StateMachine, StateTransition}; diff --git a/crates/studio_application/src/ports.rs b/crates/studio_application/src/ports.rs @@ -1,887 +0,0 @@ -use std::future::Future; -use std::pin::Pin; -use std::time::Instant; - -use radroots_studio_domain::{ - AccountSummary, BindingAvailability, Kind0ProfileCandidate, Npub, Nsec, PublicKey, RelayUrl, - SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, -}; - -const MAX_DURABLE_REQUEST_ID_BYTES: usize = 128; - -#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct DurableRequestId(String); - -impl DurableRequestId { - /// Validates an opaque caller-generated idempotency key. - /// - /// # Errors - /// - /// Returns a safe validation error when the value is empty, oversized, or contains anything - /// other than visible ASCII characters. - pub fn parse(value: impl Into<String>) -> Result<Self, SafeError> { - let value = value.into(); - if value.is_empty() - || value.len() > MAX_DURABLE_REQUEST_ID_BYTES - || !value.bytes().all(|byte| byte.is_ascii_graphic()) - { - return Err(invalid_request_id()); - } - Ok(Self(value)) - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum DurableOperationKind { - Create, - Import, - Repair, - Remove, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum DurableOperationPhase { - IntentRecorded, - CredentialWritten, - MetadataCommitted, - SelectionCommitted, - CompensationPending, - CredentialDeleted, - MetadataDeleted, - Finalized, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum DurableTerminalOutcome { - Completed, - Cancelled, - Failed, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct OperationPriorState { - selected_account: Option<PublicKey>, - binding_availability: Option<BindingAvailability>, -} - -impl OperationPriorState { - #[must_use] - pub const fn new( - selected_account: Option<PublicKey>, - binding_availability: Option<BindingAvailability>, - ) -> Self { - Self { - selected_account, - binding_availability, - } - } - - #[must_use] - pub const fn selected_account(self) -> Option<PublicKey> { - self.selected_account - } - - #[must_use] - pub const fn binding_availability(self) -> Option<BindingAvailability> { - self.binding_availability - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DurableOperationReceipt { - request_id: DurableRequestId, - account: PublicKey, - outcome: DurableTerminalOutcome, - resulting_revision: Option<u64>, -} - -impl DurableOperationReceipt { - #[must_use] - pub const fn new( - request_id: DurableRequestId, - account: PublicKey, - outcome: DurableTerminalOutcome, - resulting_revision: Option<u64>, - ) -> Self { - Self { - request_id, - account, - outcome, - resulting_revision, - } - } - - #[must_use] - pub const fn request_id(&self) -> &DurableRequestId { - &self.request_id - } - - #[must_use] - pub const fn account(&self) -> PublicKey { - self.account - } - - #[must_use] - pub const fn outcome(&self) -> DurableTerminalOutcome { - self.outcome - } - - #[must_use] - pub const fn resulting_revision(&self) -> Option<u64> { - self.resulting_revision - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DurableAccountOperation { - request_id: DurableRequestId, - kind: DurableOperationKind, - account: PublicKey, - expected_revision: Option<u64>, - phase: DurableOperationPhase, - prior: OperationPriorState, - updated_at: UnixTimestamp, - diagnostic: Option<OperationDiagnostic>, - terminal: Option<DurableOperationReceipt>, -} - -impl DurableAccountOperation { - #[allow(clippy::too_many_arguments)] - #[must_use] - pub const fn new( - request_id: DurableRequestId, - kind: DurableOperationKind, - account: PublicKey, - expected_revision: Option<u64>, - phase: DurableOperationPhase, - prior: OperationPriorState, - updated_at: UnixTimestamp, - diagnostic: Option<OperationDiagnostic>, - terminal: Option<DurableOperationReceipt>, - ) -> Self { - Self { - request_id, - kind, - account, - expected_revision, - phase, - prior, - updated_at, - diagnostic, - terminal, - } - } - - #[must_use] - pub const fn request_id(&self) -> &DurableRequestId { - &self.request_id - } - #[must_use] - pub const fn kind(&self) -> DurableOperationKind { - self.kind - } - #[must_use] - pub const fn account(&self) -> PublicKey { - self.account - } - #[must_use] - pub const fn expected_revision(&self) -> Option<u64> { - self.expected_revision - } - #[must_use] - pub const fn phase(&self) -> DurableOperationPhase { - self.phase - } - #[must_use] - pub const fn prior(&self) -> OperationPriorState { - self.prior - } - #[must_use] - pub const fn updated_at(&self) -> UnixTimestamp { - self.updated_at - } - #[must_use] - pub const fn diagnostic(&self) -> Option<OperationDiagnostic> { - self.diagnostic - } - #[must_use] - pub const fn terminal(&self) -> Option<&DurableOperationReceipt> { - self.terminal.as_ref() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum DurableOperationStart { - Started(DurableAccountOperation), - Existing(DurableAccountOperation), -} - -const fn invalid_request_id() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The request identifier is invalid."), - ) -} - -pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ProfileRefreshStatus { - Success, - Offline, - InvalidData, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum RelayFetchCompleteness { - Complete, - Partial, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProfileFetchResult { - candidate: Option<Kind0ProfileCandidate>, - completeness: RelayFetchCompleteness, -} - -impl ProfileFetchResult { - #[must_use] - pub const fn complete(candidate: Option<Kind0ProfileCandidate>) -> Self { - Self { - candidate, - completeness: RelayFetchCompleteness::Complete, - } - } - - #[must_use] - pub const fn partial(candidate: Option<Kind0ProfileCandidate>) -> Self { - Self { - candidate, - completeness: RelayFetchCompleteness::Partial, - } - } - - #[must_use] - pub fn into_parts(self) -> (Option<Kind0ProfileCandidate>, RelayFetchCompleteness) { - (self.candidate, self.completeness) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CachedProfile { - candidate: Kind0ProfileCandidate, - refreshed_at: UnixTimestamp, - refresh_status: ProfileRefreshStatus, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum AccountPreferenceKey { - NamespaceProbe, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum AccountOperationKind { - Add, - Import, - Remove, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum AccountOperationPhase { - IntentRecorded, - CredentialWritten, - MetadataCommitted, - CompensationPending, - CredentialDeleted, - MetadataDeleted, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum OperationDiagnostic { - StorageUnavailable, - KeyringUnavailable, - CredentialMissing, - CompensationFailed, - Conflict, - Expired, -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub struct OperationId(u64); - -impl OperationId { - #[must_use] - pub const fn from_raw(value: u64) -> Self { - Self(value) - } - - #[must_use] - pub const fn as_raw(self) -> u64 { - self.0 - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PendingAccountOperation { - id: OperationId, - kind: AccountOperationKind, - subject: PublicKey, - phase: AccountOperationPhase, - updated_at: UnixTimestamp, - diagnostic: Option<OperationDiagnostic>, -} - -impl PendingAccountOperation { - #[must_use] - pub const fn new( - id: OperationId, - kind: AccountOperationKind, - subject: PublicKey, - phase: AccountOperationPhase, - updated_at: UnixTimestamp, - diagnostic: Option<OperationDiagnostic>, - ) -> Self { - Self { - id, - kind, - subject, - phase, - updated_at, - diagnostic, - } - } - - #[must_use] - pub const fn id(&self) -> OperationId { - self.id - } - #[must_use] - pub const fn kind(&self) -> AccountOperationKind { - self.kind - } - #[must_use] - pub const fn subject(&self) -> PublicKey { - self.subject - } - #[must_use] - pub const fn phase(&self) -> AccountOperationPhase { - self.phase - } - #[must_use] - pub const fn updated_at(&self) -> UnixTimestamp { - self.updated_at - } - #[must_use] - pub const fn diagnostic(&self) -> Option<OperationDiagnostic> { - self.diagnostic - } -} - -impl CachedProfile { - #[must_use] - pub const fn new( - candidate: Kind0ProfileCandidate, - refreshed_at: UnixTimestamp, - refresh_status: ProfileRefreshStatus, - ) -> Self { - Self { - candidate, - refreshed_at, - refresh_status, - } - } - - #[must_use] - pub const fn candidate(&self) -> &Kind0ProfileCandidate { - &self.candidate - } - - #[must_use] - pub const fn refreshed_at(&self) -> UnixTimestamp { - self.refreshed_at - } - - #[must_use] - pub const fn refresh_status(&self) -> ProfileRefreshStatus { - self.refresh_status - } -} - -pub trait AccountRepository: Send + Sync { - /// Lists saved public account records in deterministic order. - /// - /// # Errors - /// - /// Returns a safe storage error when records cannot be read. - fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError>; - /// Finds one saved public account record. - /// - /// # Errors - /// - /// Returns a safe storage error when the lookup cannot complete. - fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError>; - /// Inserts one public account record. - /// - /// # Errors - /// - /// Returns a safe storage error when the durable write fails. - fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError>; - /// Updates one existing public account record. - /// - /// # Errors - /// - /// Returns a safe storage or account-not-found error when the durable - /// update cannot complete. - fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError>; - /// Removes one public account record. - /// - /// # Errors - /// - /// Returns a safe storage error when the durable delete fails. - fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError>; -} - -pub trait ProfileRepository: Send + Sync { - /// Loads cached public profile metadata. - /// - /// # Errors - /// - /// Returns a safe storage error when the cache cannot be read. - fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError>; - /// Saves a verified kind-0 profile candidate. - /// - /// # Errors - /// - /// Returns a safe storage error when the cache cannot be committed. - fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError>; - /// Records the result of a profile refresh without replacing cached metadata. - /// - /// # Errors - /// - /// Returns a safe storage error when the cache cannot be committed. - fn record_refresh_status( - &self, - public_key: PublicKey, - refreshed_at: UnixTimestamp, - status: ProfileRefreshStatus, - ) -> Result<(), SafeError>; - /// Removes cached profile metadata for an account. - /// - /// # Errors - /// - /// Returns a safe storage error when the cache cannot be deleted. - fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError>; -} - -pub trait AccountNamespaceRepository: Send + Sync { - /// Reads one internal non-secret account-scoped value. - /// - /// # Errors - /// - /// Returns a safe storage error when the value cannot be read. - fn get_value( - &self, - owner: PublicKey, - key: AccountPreferenceKey, - ) -> Result<Option<String>, SafeError>; - /// Writes one internal non-secret account-scoped value. - /// - /// # Errors - /// - /// Returns a safe storage error when the value cannot be committed. - fn set_value( - &self, - owner: PublicKey, - key: AccountPreferenceKey, - value: &str, - ) -> Result<(), SafeError>; - /// Removes all internal values owned by an account. - /// - /// # Errors - /// - /// Returns a safe storage error when cleanup cannot be committed. - fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError>; -} - -pub trait AppStateRepository: Send + Sync { - /// Loads the persisted selected account. - /// - /// # Errors - /// - /// Returns a safe storage error when application state cannot be read. - fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError>; - /// Persists the selected account or the empty selection. - /// - /// # Errors - /// - /// Returns a safe storage error when application state cannot be committed. - fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError>; -} - -pub trait OperationJournal: Send + Sync { - /// Records one cross-resource account operation intent. - /// - /// # Errors - /// - /// Returns a safe storage error when the entry cannot be committed. - fn begin_operation( - &self, - kind: AccountOperationKind, - subject: PublicKey, - updated_at: UnixTimestamp, - ) -> Result<OperationId, SafeError>; - /// Advances an operation to a durable recovery phase. - /// - /// # Errors - /// - /// Returns a safe storage error when the entry cannot be updated. - fn update_operation( - &self, - id: OperationId, - phase: AccountOperationPhase, - updated_at: UnixTimestamp, - diagnostic: Option<OperationDiagnostic>, - ) -> Result<(), SafeError>; - /// Loads all unfinished operations in deterministic order. - /// - /// # Errors - /// - /// Returns a safe storage error when entries cannot be read. - fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError>; - /// Deletes one fully reconciled operation entry. - /// - /// # Errors - /// - /// Returns a safe storage error when finalization cannot be committed. - fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError>; -} - -pub trait DurableOperationRepository: Send + Sync { - /// Records one idempotent durable operation or returns the existing matching request. - /// - /// # Errors - /// - /// Returns a safe conflict or storage error when the request cannot be recorded. - #[allow(clippy::too_many_arguments)] - fn begin_durable_operation( - &self, - request_id: &DurableRequestId, - kind: DurableOperationKind, - account: PublicKey, - expected_revision: Option<u64>, - prior: OperationPriorState, - updated_at: UnixTimestamp, - ) -> Result<DurableOperationStart, SafeError>; - /// Loads one durable operation by its idempotency key. - /// - /// # Errors - /// - /// Returns a safe storage error when the lookup cannot complete. - fn load_durable_operation( - &self, - request_id: &DurableRequestId, - ) -> Result<Option<DurableAccountOperation>, SafeError>; - /// Advances one operation only from the caller's expected phase. - /// - /// # Errors - /// - /// Returns a safe conflict or storage error when the transition cannot commit. - fn advance_durable_operation( - &self, - request_id: &DurableRequestId, - expected_phase: DurableOperationPhase, - next_phase: DurableOperationPhase, - updated_at: UnixTimestamp, - diagnostic: Option<OperationDiagnostic>, - ) -> Result<DurableAccountOperation, SafeError>; - /// Finalizes one operation and durably retains its recoverable receipt. - /// - /// # Errors - /// - /// Returns a safe conflict or storage error when finalization cannot commit. - fn finalize_durable_operation( - &self, - request_id: &DurableRequestId, - expected_phase: DurableOperationPhase, - outcome: DurableTerminalOutcome, - resulting_revision: Option<u64>, - updated_at: UnixTimestamp, - ) -> Result<DurableOperationReceipt, SafeError>; - /// Lists unfinished operations in deterministic request order. - /// - /// # Errors - /// - /// Returns a safe storage error when operations cannot be read. - fn list_unfinished_durable_operations(&self) - -> Result<Vec<DurableAccountOperation>, SafeError>; -} - -pub trait NostrClient: Send + Sync { - fn fetch_profile<'a>( - &'a self, - public_key: PublicKey, - relays: &'a [RelayUrl], - deadline: Instant, - ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>>; -} - -pub struct GeneratedKeyMaterial { - public_key: PublicKey, - npub: Npub, - secret: SecretKeyInput, - nsec: Nsec, -} - -impl GeneratedKeyMaterial { - #[must_use] - pub const fn new( - public_key: PublicKey, - npub: Npub, - secret: SecretKeyInput, - nsec: Nsec, - ) -> Self { - Self { - public_key, - npub, - secret, - nsec, - } - } - - #[must_use] - pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput, Nsec) { - (self.public_key, self.npub, self.secret, self.nsec) - } -} - -pub struct ImportedKeyMaterial { - public_key: PublicKey, - npub: Npub, - secret: SecretKeyInput, -} - -impl ImportedKeyMaterial { - #[must_use] - pub const fn new(public_key: PublicKey, npub: Npub, secret: SecretKeyInput) -> Self { - Self { - public_key, - npub, - secret, - } - } - - #[must_use] - pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput) { - (self.public_key, self.npub, self.secret) - } -} - -pub trait KeyMaterialProvider: Send + Sync { - /// Generates one keypair from host-provided cryptographic entropy. - /// - /// # Errors - /// - /// Returns a redacted key or entropy error. - fn generate(&self) -> Result<GeneratedKeyMaterial, SafeError>; - - /// Canonicalizes imported secret material and derives its public identity. - /// - /// # Errors - /// - /// Returns a redacted validation error. - fn import(&self, input: SecretKeyInput) -> Result<ImportedKeyMaterial, SafeError>; -} - -pub trait Clock: Send + Sync { - fn now(&self) -> UnixTimestamp; -} - -#[cfg(test)] -mod tests { - use std::time::Instant; - - use std::sync::Mutex; - - use radroots_studio_domain::{AccountSummary, PublicKey, RelayUrl, SafeError, UnixTimestamp}; - - use super::{ - AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, - AccountPreferenceKey, AccountRepository, AppStateRepository, BoxFuture, CachedProfile, - Clock, DurableOperationReceipt, DurableRequestId, DurableTerminalOutcome, NostrClient, - OperationDiagnostic, OperationId, OperationJournal, PendingAccountOperation, - ProfileFetchResult, ProfileRefreshStatus, ProfileRepository, - }; - - #[test] - fn durable_request_ids_and_terminal_receipts_are_bounded_and_public() { - let request = DurableRequestId::parse("create:desktop:0001").expect("request id"); - let receipt = DurableOperationReceipt::new( - request.clone(), - PublicKey::from_bytes([7; 32]).expect("valid public key"), - DurableTerminalOutcome::Completed, - Some(42), - ); - assert_eq!(receipt.request_id(), &request); - assert_eq!(receipt.resulting_revision(), Some(42)); - for invalid in ["", "contains space", &"x".repeat(129)] { - assert!(DurableRequestId::parse(invalid).is_err()); - } - } - - #[derive(Default)] - struct FakePorts { - selected: Mutex<Option<PublicKey>>, - } - - impl AccountRepository for FakePorts { - fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { - Ok(Vec::new()) - } - - fn find_account( - &self, - _public_key: PublicKey, - ) -> Result<Option<AccountSummary>, SafeError> { - Ok(None) - } - - fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> { - Ok(()) - } - - fn update_account(&self, _account: &AccountSummary) -> Result<(), SafeError> { - Ok(()) - } - - fn remove_account(&self, _public_key: PublicKey) -> Result<(), SafeError> { - Ok(()) - } - } - - impl ProfileRepository for FakePorts { - fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { - Ok(None) - } - - fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> { - Ok(()) - } - - fn record_refresh_status( - &self, - _public_key: PublicKey, - _refreshed_at: UnixTimestamp, - _status: ProfileRefreshStatus, - ) -> Result<(), SafeError> { - Ok(()) - } - - fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { - Ok(()) - } - } - - impl AccountNamespaceRepository for FakePorts { - fn get_value( - &self, - _owner: PublicKey, - _key: AccountPreferenceKey, - ) -> Result<Option<String>, SafeError> { - Ok(None) - } - - fn set_value( - &self, - _owner: PublicKey, - _key: AccountPreferenceKey, - _value: &str, - ) -> Result<(), SafeError> { - Ok(()) - } - - fn clear_owner(&self, _owner: PublicKey) -> Result<(), SafeError> { - Ok(()) - } - } - - impl AppStateRepository for FakePorts { - fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { - Ok(*self.selected.lock().expect("selected lock")) - } - - fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { - *self.selected.lock().expect("selected lock") = public_key; - Ok(()) - } - } - - impl OperationJournal for FakePorts { - fn begin_operation( - &self, - _kind: AccountOperationKind, - _subject: PublicKey, - _updated_at: UnixTimestamp, - ) -> Result<OperationId, SafeError> { - Ok(OperationId::from_raw(1)) - } - - fn update_operation( - &self, - _id: OperationId, - _phase: AccountOperationPhase, - _updated_at: UnixTimestamp, - _diagnostic: Option<OperationDiagnostic>, - ) -> Result<(), SafeError> { - Ok(()) - } - - fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { - Ok(Vec::new()) - } - - fn finalize_operation(&self, _id: OperationId) -> Result<(), SafeError> { - Ok(()) - } - } - - impl NostrClient for FakePorts { - fn fetch_profile<'a>( - &'a self, - _public_key: PublicKey, - _relays: &'a [RelayUrl], - _deadline: Instant, - ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { - Box::pin(async { Ok(ProfileFetchResult::complete(None)) }) - } - } - - impl Clock for FakePorts { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(1).expect("valid fake time") - } - } - - fn assert_send_sync<T: Send + Sync>() {} - - #[test] - fn ports_accept_send_sync_test_fakes() { - assert_send_sync::<FakePorts>(); - - let ports = FakePorts::default(); - ports - .save_selected_account(Some( - PublicKey::from_bytes([7_u8; 32]).expect("valid public key"), - )) - .expect("save selection"); - assert_eq!( - ports.load_selected_account().expect("load selection"), - Some(PublicKey::from_bytes([7_u8; 32]).expect("valid public key")) - ); - assert_eq!(ports.now().as_seconds(), 1); - } -} diff --git a/crates/studio_application/src/profile_refresh.rs b/crates/studio_application/src/profile_refresh.rs @@ -1,659 +0,0 @@ -use radroots_studio_domain::{PublicKey, RelayUrl, SafeError, SafeErrorCode}; -use std::time::Instant; - -use crate::{ - ActiveAccountSnapshot, AppCore, AppSnapshot, CachedProfile, Clock, NostrClient, - ProfileFetchResult, ProfileLoadState, ProfileRefreshStatus, ProfileRepository, - RelayConnectionState, RelayFetchCompleteness, SnapshotRevision, StateTransition, -}; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProfileRefreshPlan { - public_key: PublicKey, - active_account: ActiveAccountSnapshot, - relays: Vec<RelayUrl>, - expected_revision: SnapshotRevision, -} - -impl ProfileRefreshPlan { - #[must_use] - pub const fn public_key(&self) -> PublicKey { - self.public_key - } - - #[must_use] - pub const fn active_account(&self) -> &ActiveAccountSnapshot { - &self.active_account - } - - #[must_use] - pub fn relays(&self) -> &[RelayUrl] { - &self.relays - } - - #[must_use] - pub const fn expected_revision(&self) -> SnapshotRevision { - self.expected_revision - } -} - -impl AppCore { - /// Manually refreshes the active account's Nostr kind-0 profile. - /// - /// Cached public metadata remains visible while the asynchronous request is - /// running. Calling this command while signed out is an idempotent no-op. - /// - /// # Errors - /// - /// Returns a safe storage or application-state error. Relay and invalid-data - /// failures are represented as nonfatal snapshot state. - pub async fn refresh_active_profile( - &self, - profiles: &(impl ProfileRepository + ?Sized), - client: &(impl NostrClient + ?Sized), - clock: &(impl Clock + ?Sized), - deadline: Instant, - ) -> Result<AppSnapshot, SafeError> { - self.refresh_profile_for_active_account(profiles, client, clock, deadline) - .await - } - - /// Refreshes the current active account while retaining any cached profile. - /// - /// Stale results are discarded when the account is replaced or signed out. - /// - /// # Errors - /// - /// Returns a safe storage or application-state error. Relay and invalid-data - /// failures are represented as nonfatal snapshot state. - async fn refresh_profile_for_active_account( - &self, - profiles: &(impl ProfileRepository + ?Sized), - client: &(impl NostrClient + ?Sized), - clock: &(impl Clock + ?Sized), - deadline: Instant, - ) -> Result<AppSnapshot, SafeError> { - let Some(plan) = self.begin_profile_refresh()? else { - return Ok(self.snapshot()); - }; - let result = client - .fetch_profile(plan.public_key(), plan.relays(), deadline) - .await; - self.complete_profile_refresh(&plan, result, profiles, clock) - } - - /// Begins a refresh on the actor and returns the immutable network plan. - /// - /// # Errors - /// - /// Returns a safe state error when the loading transition is invalid. - pub fn begin_profile_refresh(&self) -> Result<Option<ProfileRefreshPlan>, SafeError> { - let Some(active) = self.snapshot().active_account().cloned() else { - return Ok(None); - }; - let public_key = active.account().public_key(); - let loading = self.apply_transition(StateTransition::UpdateActiveAccount { - expected: public_key, - active_account: Box::new(ActiveAccountSnapshot::new( - active.account().clone(), - RelayConnectionState::Connecting, - ProfileLoadState::Loading, - active.profile().cloned(), - )), - problem: None, - })?; - Ok(Some(ProfileRefreshPlan { - public_key, - active_account: active, - relays: loading.relay_configuration().relays().to_vec(), - expected_revision: loading.revision(), - })) - } - - /// Applies a correlated refresh result on the actor. - /// - /// # Errors - /// - /// Returns a safe storage or application-state error. Stale results are - /// discarded without persistence or publication. - pub fn complete_profile_refresh( - &self, - plan: &ProfileRefreshPlan, - result: Result<ProfileFetchResult, SafeError>, - profiles: &(impl ProfileRepository + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<AppSnapshot, SafeError> { - if !is_current_active(self, plan.public_key()) { - return Ok(self.snapshot()); - } - - let current_active = self - .snapshot() - .active_account() - .cloned() - .ok_or_else(invalid_profile_completion)?; - - match result { - Ok(fetched) => { - let (candidate, completeness) = fetched.into_parts(); - self.complete_successful_profile_fetch( - plan, - current_active, - candidate, - completeness, - profiles, - clock, - ) - } - Err(error) => { - let status = refresh_status(error); - profiles.record_refresh_status(plan.public_key(), clock.now(), status)?; - self.apply_transition(StateTransition::UpdateActiveAccount { - expected: plan.public_key(), - active_account: Box::new(ActiveAccountSnapshot::new( - current_active.account().clone(), - RelayConnectionState::Degraded, - ProfileLoadState::Error(error), - current_active.profile().cloned(), - )), - problem: Some(error), - }) - } - } - } - - fn complete_successful_profile_fetch( - &self, - plan: &ProfileRefreshPlan, - current_active: ActiveAccountSnapshot, - candidate: Option<radroots_studio_domain::Kind0ProfileCandidate>, - completeness: RelayFetchCompleteness, - profiles: &(impl ProfileRepository + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<AppSnapshot, SafeError> { - let relay_state = match completeness { - RelayFetchCompleteness::Complete => RelayConnectionState::Connected, - RelayFetchCompleteness::Partial => RelayConnectionState::Degraded, - }; - let problem = match completeness { - RelayFetchCompleteness::Complete => None, - RelayFetchCompleteness::Partial => Some(partial_relay_result()), - }; - match candidate { - Some(candidate) => { - let cached = CachedProfile::new( - candidate.clone(), - clock.now(), - ProfileRefreshStatus::Success, - ); - profiles.save_profile(&cached)?; - let winning_profile = profiles.load_profile(plan.public_key())?.map_or_else( - || candidate.metadata().clone(), - |profile| profile.candidate().metadata().clone(), - ); - self.apply_transition(StateTransition::UpdateActiveAccount { - expected: plan.public_key(), - active_account: Box::new(ActiveAccountSnapshot::new( - current_active.account().clone(), - relay_state, - ProfileLoadState::Fresh, - Some(winning_profile), - )), - problem, - }) - } - None => self.apply_transition(StateTransition::UpdateActiveAccount { - expected: plan.public_key(), - active_account: Box::new(ActiveAccountSnapshot::new( - current_active.account().clone(), - relay_state, - if current_active.profile().is_some() { - ProfileLoadState::Cached - } else { - ProfileLoadState::Empty - }, - current_active.profile().cloned(), - )), - problem, - }), - } - } -} - -const fn partial_relay_result() -> SafeError { - SafeError::new( - SafeErrorCode::RelayConnectionFailed, - radroots_studio_domain::SafeMessage::new("One or more Nostr relays did not complete."), - ) -} - -const fn invalid_profile_completion() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - radroots_studio_domain::SafeMessage::new("The active profile refresh is no longer valid."), - ) -} - -fn is_current_active(core: &AppCore, public_key: PublicKey) -> bool { - core.snapshot() - .active_account() - .is_some_and(|active| active.account().public_key() == public_key) -} - -const fn refresh_status(error: SafeError) -> ProfileRefreshStatus { - match error.code() { - SafeErrorCode::InvalidProfileMetadata | SafeErrorCode::ProfileRefreshFailed => { - ProfileRefreshStatus::InvalidData - } - _ => ProfileRefreshStatus::Offline, - } -} - -#[cfg(test)] -mod tests { - use std::sync::Mutex; - use std::time::{Duration, Instant}; - - use radroots_studio_domain::{ - EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, RelayDestinationPolicy, - RelayUrl, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, - select_latest_kind0, - }; - - use crate::{ - ActiveAccountSnapshot, AppCore, BoxFuture, CachedProfile, Clock, InMemoryAccountRepository, - InMemoryOperationJournal, InMemorySecretStore, NostrClient, ProfileFetchResult, - ProfileLoadState, ProfileRefreshStatus, ProfileRepository, RelayConfiguration, - RelayConnectionState, - }; - - #[derive(Default)] - struct MemoryProfiles(Mutex<Option<CachedProfile>>); - - impl ProfileRepository for MemoryProfiles { - fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { - Ok(self.0.lock().expect("profiles").clone()) - } - fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> { - let mut cached = self.0.lock().expect("profiles"); - let selected = cached.as_ref().map_or_else( - || profile.clone(), - |current| { - let winner = select_latest_kind0([ - current.candidate().clone(), - profile.candidate().clone(), - ]) - .expect("two candidates"); - if &winner == current.candidate() { - current.clone() - } else { - profile.clone() - } - }, - ); - *cached = Some(selected); - Ok(()) - } - fn record_refresh_status( - &self, - _public_key: PublicKey, - refreshed_at: UnixTimestamp, - status: ProfileRefreshStatus, - ) -> Result<(), SafeError> { - if let Some(profile) = self.0.lock().expect("profiles").as_mut() { - *profile = CachedProfile::new(profile.candidate().clone(), refreshed_at, status); - } - Ok(()) - } - fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { - *self.0.lock().expect("profiles") = None; - Ok(()) - } - } - - struct FixedClock; - impl Clock for FixedClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(50).expect("time") - } - } - - struct FixedClient(Result<Option<Kind0ProfileCandidate>, SafeError>); - impl NostrClient for FixedClient { - fn fetch_profile<'a>( - &'a self, - _public_key: PublicKey, - _relays: &'a [RelayUrl], - _deadline: std::time::Instant, - ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { - let result = self.0.clone(); - Box::pin(async move { result.map(ProfileFetchResult::complete) }) - } - } - - struct BlockingClient { - started: tokio::sync::Semaphore, - release: tokio::sync::Semaphore, - result: Result<Option<Kind0ProfileCandidate>, SafeError>, - } - - impl BlockingClient { - fn new(result: Result<Option<Kind0ProfileCandidate>, SafeError>) -> Self { - Self { - started: tokio::sync::Semaphore::new(0), - release: tokio::sync::Semaphore::new(0), - result, - } - } - } - - impl NostrClient for BlockingClient { - fn fetch_profile<'a>( - &'a self, - _public_key: PublicKey, - _relays: &'a [RelayUrl], - _deadline: std::time::Instant, - ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { - Box::pin(async move { - self.started.add_permits(1); - let permit = self.release.acquire().await.expect("release open"); - permit.forget(); - self.result.clone().map(ProfileFetchResult::complete) - }) - } - } - - fn profile(public_key: PublicKey, name: &str, timestamp: i64) -> Kind0ProfileCandidate { - Kind0ProfileCandidate::new( - EventId::from_bytes([u8::try_from(timestamp).expect("small timestamp"); 32]), - public_key, - UnixTimestamp::from_seconds(timestamp).expect("time"), - ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("profile"), - ) - } - - fn active_core(profiles: &MemoryProfiles, cached_name: Option<&str>) -> (AppCore, PublicKey) { - let relays = RelayConfiguration::new(vec![ - RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local).expect("relay"), - ]) - .expect("relay configuration"); - let core = AppCore::in_memory(relays); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let public_key = core - .import_secret_key( - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), - ) - .expect("secret"), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("import") - .account() - .public_key(); - if let Some(name) = cached_name { - profiles - .save_profile(&CachedProfile::new( - profile(public_key, name, 10), - UnixTimestamp::from_seconds(11).expect("time"), - ProfileRefreshStatus::Success, - )) - .expect("cache"); - } - core.activate_account( - public_key, - &accounts, - &accounts, - profiles, - &secrets, - &FixedClock, - ) - .expect("activate"); - (core, public_key) - } - - #[tokio::test] - async fn refresh_transitions_from_cache_through_loading_to_fresh_profile() { - let profiles = MemoryProfiles::default(); - let (core, public_key) = active_core(&profiles, Some("Cached")); - assert_eq!( - core.snapshot() - .active_account() - .map(crate::ActiveAccountSnapshot::profile_state), - Some(ProfileLoadState::Cached) - ); - let plan = core - .begin_profile_refresh() - .expect("begin refresh") - .expect("active refresh"); - let loading = core.snapshot(); - assert_eq!( - loading - .active_account() - .map(crate::ActiveAccountSnapshot::profile_state), - Some(ProfileLoadState::Loading) - ); - assert_eq!( - loading - .active_account() - .map(crate::ActiveAccountSnapshot::relay_state), - Some(RelayConnectionState::Connecting) - ); - let client = FixedClient(Ok(Some(profile(public_key, "Fresh", 20)))); - let result = client - .fetch_profile(plan.public_key(), plan.relays(), deadline()) - .await; - core.complete_profile_refresh(&plan, result, &profiles, &FixedClock) - .expect("complete refresh"); - assert_eq!( - core.snapshot() - .active_account() - .and_then(|active| active.profile()) - .and_then(ProfileMetadata::name), - Some("Fresh") - ); - } - - #[tokio::test] - async fn refresh_failure_preserves_cached_profile_as_nonfatal_state() { - let profiles = MemoryProfiles::default(); - let (core, public_key) = active_core(&profiles, Some("Cached")); - let cached = profile(public_key, "Cached", 10); - let error = SafeError::new( - SafeErrorCode::RelayConnectionFailed, - SafeMessage::new("The relay is offline."), - ); - - let snapshot = core - .refresh_profile_for_active_account( - &profiles, - &FixedClient(Err(error)), - &FixedClock, - deadline(), - ) - .await - .expect("nonfatal refresh"); - - assert_eq!(snapshot.recoverable_problem(), Some(error)); - assert_eq!( - snapshot - .active_account() - .map(crate::ActiveAccountSnapshot::relay_state), - Some(RelayConnectionState::Degraded) - ); - assert_eq!( - profiles - .load_profile(public_key) - .expect("load") - .expect("cache") - .candidate(), - &cached - ); - } - - #[tokio::test] - async fn refresh_discards_stale_completion_after_sign_out() { - let profiles = MemoryProfiles::default(); - let (core, public_key) = active_core(&profiles, Some("Cached")); - let client = BlockingClient::new(Ok(Some(profile(public_key, "Stale", 20)))); - - let refresh = - core.refresh_profile_for_active_account(&profiles, &client, &FixedClock, deadline()); - let sign_out = async { - let permit = client.started.acquire().await.expect("refresh starts"); - permit.forget(); - core.sign_out().expect("sign out"); - client.release.add_permits(1); - }; - let (result, ()) = tokio::join!(refresh, sign_out); - - assert!( - result - .expect("stale result is harmless") - .active_account() - .is_none() - ); - assert_eq!( - profiles - .load_profile(public_key) - .expect("load") - .expect("cached") - .candidate() - .metadata() - .name(), - Some("Cached") - ); - } - - #[tokio::test] - async fn manual_refresh_is_repeatable_and_signed_out_safe() { - let profiles = MemoryProfiles::default(); - let (core, public_key) = active_core(&profiles, None); - let first = core - .refresh_active_profile( - &profiles, - &FixedClient(Ok(Some(profile(public_key, "First", 10)))), - &FixedClock, - deadline(), - ) - .await - .expect("first refresh"); - let second = core - .refresh_active_profile( - &profiles, - &FixedClient(Ok(Some(profile(public_key, "Second", 20)))), - &FixedClock, - deadline(), - ) - .await - .expect("second refresh"); - - assert!(second.revision() > first.revision()); - assert_eq!( - second - .active_account() - .and_then(|active| active.profile()) - .and_then(ProfileMetadata::name), - Some("Second") - ); - let signed_out = core.sign_out().expect("sign out"); - let no_op = core - .refresh_active_profile(&profiles, &FixedClient(Ok(None)), &FixedClock, deadline()) - .await - .expect("signed-out no-op"); - assert_eq!(no_op, signed_out); - } - - fn deadline() -> Instant { - Instant::now() + Duration::from_secs(5) - } - - #[test] - fn partial_relay_success_retains_verified_data_and_marks_degraded_connectivity() { - let profiles = MemoryProfiles::default(); - let (core, public_key) = active_core(&profiles, None); - let plan = core - .begin_profile_refresh() - .expect("begin") - .expect("active"); - let snapshot = core - .complete_profile_refresh( - &plan, - Ok(ProfileFetchResult::partial(Some(profile( - public_key, "Partial", 30, - )))), - &profiles, - &FixedClock, - ) - .expect("partial completion"); - let active = snapshot.active_account().expect("active account"); - assert_eq!(active.relay_state(), RelayConnectionState::Degraded); - assert_eq!(active.profile_state(), ProfileLoadState::Fresh); - assert_eq!( - active.profile().and_then(ProfileMetadata::name), - Some("Partial") - ); - assert_eq!( - snapshot.recoverable_problem().map(SafeError::code), - Some(SafeErrorCode::RelayConnectionFailed) - ); - } - - #[test] - fn overlapping_refreshes_keep_the_newest_event_regardless_of_completion_order() { - let profiles = MemoryProfiles::default(); - let (core, public_key) = active_core(&profiles, Some("Cached")); - let first = core - .begin_profile_refresh() - .expect("first") - .expect("active"); - let second = core - .begin_profile_refresh() - .expect("second") - .expect("active"); - - core.complete_profile_refresh( - &second, - Ok(ProfileFetchResult::complete(Some(profile( - public_key, "Newest", 30, - )))), - &profiles, - &FixedClock, - ) - .expect("newest completes first"); - let final_snapshot = core - .complete_profile_refresh( - &first, - Ok(ProfileFetchResult::complete(Some(profile( - public_key, "Older", 20, - )))), - &profiles, - &FixedClock, - ) - .expect("older completes last"); - - assert_eq!( - final_snapshot - .active_account() - .and_then(ActiveAccountSnapshot::profile) - .and_then(ProfileMetadata::name), - Some("Newest") - ); - assert_eq!( - profiles - .load_profile(public_key) - .expect("cache") - .expect("profile") - .candidate() - .metadata() - .name(), - Some("Newest") - ); - } -} diff --git a/crates/studio_application/src/recovery.rs b/crates/studio_application/src/recovery.rs @@ -1,788 +0,0 @@ -use radroots_studio_domain::{PublicKey, SafeError}; - -use crate::{ - AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, - Clock, DurableAccountOperation, DurableOperationKind, DurableOperationPhase, - DurableOperationRepository, DurableTerminalOutcome, OperationJournal, SecretStore, -}; - -impl AppCore { - /// Reconciles durable request operations before public state is restored. - /// - /// # Errors - /// - /// Returns a safe credential, persistence, or recovery error while retaining the operation - /// at its last durable phase for a later retry. - pub fn recover_durable_operations( - &self, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<(), SafeError> { - for operation in operations.list_unfinished_durable_operations()? { - match operation.kind() { - DurableOperationKind::Create - | DurableOperationKind::Import - | DurableOperationKind::Repair => recover_durable_addition( - &operation, accounts, app_state, secrets, operations, clock, - )?, - DurableOperationKind::Remove => recover_durable_removal( - &operation, accounts, app_state, secrets, operations, clock, - )?, - } - } - Ok(()) - } - - /// Reconciles non-secret cross-resource journal entries before bootstrap. - /// - /// An empty journal does not access the credential store. - /// - /// # Errors - /// - /// Returns a safe credential, persistence, or recovery error while retaining - /// the unfinished journal entry for a later retry. - pub fn recover_pending_operations( - &self, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - journal: &(impl OperationJournal + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<(), SafeError> { - for operation in journal.list_pending_operations()? { - match operation.kind() { - AccountOperationKind::Remove => { - recover_removal(&operation, accounts, app_state, secrets, journal, clock)?; - } - AccountOperationKind::Add | AccountOperationKind::Import => { - recover_addition(&operation, accounts, secrets, journal, clock)?; - } - } - } - Ok(()) - } -} - -fn recover_durable_removal( - operation: &DurableAccountOperation, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), -) -> Result<(), SafeError> { - let request = operation.request_id(); - let account = operation.account(); - let mut phase = operation.phase(); - if phase == DurableOperationPhase::IntentRecorded { - if secrets.contains(account)? { - secrets.delete(account)?; - } - operations.advance_durable_operation( - request, - phase, - DurableOperationPhase::CredentialDeleted, - clock.now(), - None, - )?; - phase = DurableOperationPhase::CredentialDeleted; - } - if phase == DurableOperationPhase::CredentialDeleted { - if accounts.find_account(account)?.is_some() { - accounts.remove_account(account)?; - } - operations.advance_durable_operation( - request, - phase, - DurableOperationPhase::MetadataDeleted, - clock.now(), - None, - )?; - phase = DurableOperationPhase::MetadataDeleted; - } - if phase == DurableOperationPhase::MetadataDeleted { - app_state.save_selected_account(operation.prior().selected_account())?; - operations.advance_durable_operation( - request, - phase, - DurableOperationPhase::SelectionCommitted, - clock.now(), - None, - )?; - phase = DurableOperationPhase::SelectionCommitted; - } - if phase == DurableOperationPhase::SelectionCommitted { - operations.finalize_durable_operation( - request, - phase, - DurableTerminalOutcome::Completed, - None, - clock.now(), - )?; - } - Ok(()) -} - -fn recover_durable_addition( - operation: &DurableAccountOperation, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), -) -> Result<(), SafeError> { - let request = operation.request_id(); - let account = operation.account(); - match operation.phase() { - DurableOperationPhase::IntentRecorded => { - if secrets.contains(account)? { - secrets.delete(account)?; - } - operations.finalize_durable_operation( - request, - DurableOperationPhase::IntentRecorded, - DurableTerminalOutcome::Failed, - None, - clock.now(), - )?; - } - DurableOperationPhase::CredentialWritten => { - let metadata = accounts.find_account(account)?; - let committed = metadata.as_ref().is_some_and(|saved| { - saved.signer().availability() - == radroots_studio_domain::BindingAvailability::Available - }); - if committed { - operations.advance_durable_operation( - request, - DurableOperationPhase::CredentialWritten, - DurableOperationPhase::MetadataCommitted, - clock.now(), - None, - )?; - finish_durable_selection(operation, app_state, operations, clock)?; - } else { - operations.advance_durable_operation( - request, - DurableOperationPhase::CredentialWritten, - DurableOperationPhase::CompensationPending, - clock.now(), - None, - )?; - compensate_durable_addition( - operation, accounts, app_state, secrets, operations, clock, - )?; - } - } - DurableOperationPhase::MetadataCommitted => { - finish_durable_selection(operation, app_state, operations, clock)?; - } - DurableOperationPhase::SelectionCommitted => { - operations.finalize_durable_operation( - request, - DurableOperationPhase::SelectionCommitted, - DurableTerminalOutcome::Completed, - None, - clock.now(), - )?; - } - DurableOperationPhase::CompensationPending => { - compensate_durable_addition( - operation, accounts, app_state, secrets, operations, clock, - )?; - } - DurableOperationPhase::CredentialDeleted | DurableOperationPhase::MetadataDeleted => { - operations.finalize_durable_operation( - request, - operation.phase(), - DurableTerminalOutcome::Failed, - None, - clock.now(), - )?; - } - DurableOperationPhase::Finalized => {} - } - Ok(()) -} - -fn finish_durable_selection( - operation: &DurableAccountOperation, - app_state: &(impl AppStateRepository + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), -) -> Result<(), SafeError> { - app_state.save_selected_account(Some(operation.account()))?; - operations.advance_durable_operation( - operation.request_id(), - DurableOperationPhase::MetadataCommitted, - DurableOperationPhase::SelectionCommitted, - clock.now(), - None, - )?; - operations.finalize_durable_operation( - operation.request_id(), - DurableOperationPhase::SelectionCommitted, - DurableTerminalOutcome::Completed, - None, - clock.now(), - )?; - Ok(()) -} - -fn compensate_durable_addition( - operation: &DurableAccountOperation, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), -) -> Result<(), SafeError> { - if secrets.contains(operation.account())? { - secrets.delete(operation.account())?; - } - if let Some(availability) = operation.prior().binding_availability() { - if let Some(previous) = accounts.find_account(operation.account())? { - accounts.update_account(&previous.with_binding_availability(availability))?; - } - } else if accounts.find_account(operation.account())?.is_some() { - accounts.remove_account(operation.account())?; - } - app_state.save_selected_account(operation.prior().selected_account())?; - operations.advance_durable_operation( - operation.request_id(), - DurableOperationPhase::CompensationPending, - DurableOperationPhase::CredentialDeleted, - clock.now(), - None, - )?; - operations.finalize_durable_operation( - operation.request_id(), - DurableOperationPhase::CredentialDeleted, - DurableTerminalOutcome::Failed, - None, - clock.now(), - )?; - Ok(()) -} - -fn recover_removal( - operation: &crate::PendingAccountOperation, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - journal: &(impl OperationJournal + ?Sized), - clock: &(impl Clock + ?Sized), -) -> Result<(), SafeError> { - let public_key = operation.subject(); - if operation.phase() == AccountOperationPhase::IntentRecorded { - match secrets.delete(public_key) { - Ok(()) => {} - Err(error) - if error.code() == radroots_studio_domain::SafeErrorCode::CredentialMissing => {} - Err(error) => return Err(error), - } - journal.update_operation( - operation.id(), - AccountOperationPhase::CredentialDeleted, - clock.now(), - None, - )?; - } - if matches!( - operation.phase(), - AccountOperationPhase::IntentRecorded | AccountOperationPhase::CredentialDeleted - ) { - let registry = accounts.list_accounts()?; - let selected = removal_fallback(&registry, app_state.load_selected_account()?, public_key); - accounts.remove_account(public_key)?; - app_state.save_selected_account(selected)?; - journal.update_operation( - operation.id(), - AccountOperationPhase::MetadataDeleted, - clock.now(), - None, - )?; - } - journal.finalize_operation(operation.id()) -} - -fn recover_addition( - operation: &crate::PendingAccountOperation, - accounts: &(impl AccountRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - journal: &(impl OperationJournal + ?Sized), - clock: &(impl Clock + ?Sized), -) -> Result<(), SafeError> { - let has_metadata = accounts.find_account(operation.subject())?.is_some(); - match operation.phase() { - AccountOperationPhase::CredentialWritten | AccountOperationPhase::CompensationPending - if !has_metadata => - { - match secrets.delete(operation.subject()) { - Ok(()) => {} - Err(error) - if error.code() == radroots_studio_domain::SafeErrorCode::CredentialMissing => { - } - Err(error) => return Err(error), - } - journal.update_operation( - operation.id(), - AccountOperationPhase::MetadataDeleted, - clock.now(), - None, - )?; - } - _ => {} - } - journal.finalize_operation(operation.id()) -} - -fn removal_fallback( - registry: &[radroots_studio_domain::AccountSummary], - selected: Option<PublicKey>, - removed: PublicKey, -) -> Option<PublicKey> { - if selected != Some(removed) { - return selected; - } - let index = registry - .iter() - .position(|account| account.public_key() == removed)?; - registry - .get(index + 1) - .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) - .map(radroots_studio_domain::AccountSummary::public_key) -} - -#[cfg(test)] -pub(crate) mod tests { - use std::sync::{Mutex, MutexGuard}; - - use radroots_studio_domain::{ - BindingAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, - UnixTimestamp, - }; - - use super::*; - use crate::{ - DurableOperationReceipt, DurableOperationStart, DurableRequestId, FailureSecretStore, - InMemoryAccountRepository, InMemoryOperationJournal, InMemorySecretStore, - RelayConfiguration, SecretStore, SecretStoreOperation, - }; - - struct FixedClock; - - impl Clock for FixedClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(10).expect("time") - } - } - - pub(crate) struct TestDurableRepository { - operation: Mutex<DurableAccountOperation>, - return_existing: bool, - } - - impl TestDurableRepository { - pub(crate) fn new(operation: DurableAccountOperation) -> Self { - Self { - operation: Mutex::new(operation), - return_existing: true, - } - } - - pub(crate) fn fresh(operation: DurableAccountOperation) -> Self { - Self { - operation: Mutex::new(operation), - return_existing: false, - } - } - - pub(crate) fn operation(&self) -> MutexGuard<'_, DurableAccountOperation> { - self.operation - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } - - fn replace( - current: &DurableAccountOperation, - phase: DurableOperationPhase, - diagnostic: Option<crate::OperationDiagnostic>, - terminal: Option<DurableOperationReceipt>, - ) -> DurableAccountOperation { - DurableAccountOperation::new( - current.request_id().clone(), - current.kind(), - current.account(), - current.expected_revision(), - phase, - current.prior(), - current.updated_at(), - diagnostic, - terminal, - ) - } - } - - impl DurableOperationRepository for TestDurableRepository { - fn begin_durable_operation( - &self, - _request_id: &DurableRequestId, - _kind: DurableOperationKind, - _account: PublicKey, - _expected_revision: Option<u64>, - _prior: crate::OperationPriorState, - _updated_at: UnixTimestamp, - ) -> Result<DurableOperationStart, SafeError> { - let operation = self.operation().clone(); - Ok(if self.return_existing { - DurableOperationStart::Existing(operation) - } else { - DurableOperationStart::Started(operation) - }) - } - - fn load_durable_operation( - &self, - request_id: &DurableRequestId, - ) -> Result<Option<DurableAccountOperation>, SafeError> { - if !self.return_existing { - return Ok(None); - } - let operation = self.operation(); - Ok((operation.request_id() == request_id).then(|| operation.clone())) - } - - fn advance_durable_operation( - &self, - request_id: &DurableRequestId, - expected_phase: DurableOperationPhase, - next_phase: DurableOperationPhase, - _updated_at: UnixTimestamp, - diagnostic: Option<crate::OperationDiagnostic>, - ) -> Result<DurableAccountOperation, SafeError> { - let mut operation = self.operation(); - if operation.request_id() != request_id || operation.phase() != expected_phase { - return Err(conflict()); - } - *operation = Self::replace(&operation, next_phase, diagnostic, None); - Ok(operation.clone()) - } - - fn finalize_durable_operation( - &self, - request_id: &DurableRequestId, - expected_phase: DurableOperationPhase, - outcome: DurableTerminalOutcome, - resulting_revision: Option<u64>, - _updated_at: UnixTimestamp, - ) -> Result<DurableOperationReceipt, SafeError> { - let mut operation = self.operation(); - if operation.request_id() != request_id || operation.phase() != expected_phase { - return Err(conflict()); - } - let receipt = DurableOperationReceipt::new( - request_id.clone(), - operation.account(), - outcome, - resulting_revision, - ); - *operation = Self::replace( - &operation, - DurableOperationPhase::Finalized, - operation.diagnostic(), - Some(receipt.clone()), - ); - Ok(receipt) - } - - fn list_unfinished_durable_operations( - &self, - ) -> Result<Vec<DurableAccountOperation>, SafeError> { - Ok(vec![self.operation().clone()]) - } - } - - fn conflict() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The test durable operation conflicted."), - ) - } - - fn seeded() -> ( - AppCore, - InMemoryAccountRepository, - InMemorySecretStore, - InMemoryOperationJournal, - PublicKey, - ) { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let receipt = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("seed account"); - ( - core, - accounts, - secrets, - journal, - receipt.account().public_key(), - ) - } - - pub(crate) fn operation( - kind: DurableOperationKind, - phase: DurableOperationPhase, - account: PublicKey, - prior_availability: Option<BindingAvailability>, - ) -> DurableAccountOperation { - DurableAccountOperation::new( - DurableRequestId::parse(format!("{kind:?}-{phase:?}")).expect("durable request ID"), - kind, - account, - Some(1), - phase, - crate::OperationPriorState::new(None, prior_availability), - FixedClock.now(), - None, - None, - ) - } - - fn run_durable( - core: &AppCore, - accounts: &InMemoryAccountRepository, - secrets: &InMemorySecretStore, - operation: DurableAccountOperation, - ) -> DurableAccountOperation { - let repository = TestDurableRepository::new(operation); - core.recover_durable_operations(accounts, accounts, secrets, &repository, &FixedClock) - .expect("durable recovery"); - repository.operation().clone() - } - - #[test] - fn durable_recovery_exercises_every_removal_phase_and_presence_branch() { - for phase in [ - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialDeleted, - DurableOperationPhase::MetadataDeleted, - DurableOperationPhase::SelectionCommitted, - DurableOperationPhase::Finalized, - ] { - let (core, accounts, secrets, _journal, public_key) = seeded(); - if phase != DurableOperationPhase::IntentRecorded { - secrets.delete(public_key).expect("delete credential"); - } - if matches!( - phase, - DurableOperationPhase::MetadataDeleted - | DurableOperationPhase::SelectionCommitted - | DurableOperationPhase::Finalized - ) { - accounts.remove_account(public_key).expect("remove account"); - } - let recovered = run_durable( - &core, - &accounts, - &secrets, - operation(DurableOperationKind::Remove, phase, public_key, None), - ); - assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); - } - - let (core, accounts, secrets, _journal, public_key) = seeded(); - secrets.delete(public_key).expect("delete credential"); - accounts.remove_account(public_key).expect("remove account"); - let recovered = run_durable( - &core, - &accounts, - &secrets, - operation( - DurableOperationKind::Remove, - DurableOperationPhase::IntentRecorded, - public_key, - None, - ), - ); - assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); - } - - #[test] - fn durable_recovery_exercises_every_addition_phase_and_compensation_shape() { - for phase in [ - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialWritten, - DurableOperationPhase::MetadataCommitted, - DurableOperationPhase::SelectionCommitted, - DurableOperationPhase::CredentialDeleted, - DurableOperationPhase::MetadataDeleted, - DurableOperationPhase::Finalized, - ] { - let (core, accounts, secrets, _journal, public_key) = seeded(); - if phase == DurableOperationPhase::IntentRecorded { - accounts - .remove_account(public_key) - .expect("remove metadata"); - } - let recovered = run_durable( - &core, - &accounts, - &secrets, - operation(DurableOperationKind::Create, phase, public_key, None), - ); - assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); - } - - for (prior, retain_metadata, retain_secret) in [ - (Some(BindingAvailability::CredentialMissing), true, true), - (Some(BindingAvailability::CredentialMissing), false, true), - (None, true, true), - (None, false, false), - ] { - let (core, accounts, secrets, _journal, public_key) = seeded(); - if !retain_metadata { - accounts - .remove_account(public_key) - .expect("remove metadata"); - } - if !retain_secret { - secrets.delete(public_key).expect("delete credential"); - } - let recovered = run_durable( - &core, - &accounts, - &secrets, - operation( - DurableOperationKind::Repair, - DurableOperationPhase::CompensationPending, - public_key, - prior, - ), - ); - assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); - } - - let (core, accounts, secrets, _journal, public_key) = seeded(); - accounts - .remove_account(public_key) - .expect("remove metadata"); - let recovered = run_durable( - &core, - &accounts, - &secrets, - operation( - DurableOperationKind::Import, - DurableOperationPhase::CredentialWritten, - public_key, - None, - ), - ); - assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); - - let (core, accounts, secrets, _journal, public_key) = seeded(); - accounts - .remove_account(public_key) - .expect("remove metadata"); - secrets.delete(public_key).expect("delete credential"); - let recovered = run_durable( - &core, - &accounts, - &secrets, - operation( - DurableOperationKind::Create, - DurableOperationPhase::IntentRecorded, - public_key, - None, - ), - ); - assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); - } - - #[test] - fn pending_recovery_exercises_removal_and_addition_presence_branches() { - for credential_present in [true, false] { - let (core, accounts, secrets, journal, public_key) = seeded(); - if !credential_present { - secrets.delete(public_key).expect("delete credential"); - accounts - .save_selected_account(None) - .expect("clear selection"); - } - journal - .begin_operation(AccountOperationKind::Remove, public_key, FixedClock.now()) - .expect("removal intent"); - core.recover_pending_operations(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("removal recovery"); - assert!(journal.list_pending_operations().unwrap().is_empty()); - } - - for (kind, metadata_present, credential_present) in [ - (AccountOperationKind::Add, false, true), - (AccountOperationKind::Import, false, false), - (AccountOperationKind::Add, true, true), - ] { - let (core, accounts, secrets, journal, public_key) = seeded(); - if !metadata_present { - accounts - .remove_account(public_key) - .expect("remove metadata"); - } - if !credential_present { - secrets.delete(public_key).expect("delete credential"); - } - let id = journal - .begin_operation(kind, public_key, FixedClock.now()) - .expect("addition intent"); - journal - .update_operation( - id, - AccountOperationPhase::CredentialWritten, - FixedClock.now(), - None, - ) - .expect("credential phase"); - core.recover_pending_operations(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("addition recovery"); - assert!(journal.list_pending_operations().unwrap().is_empty()); - } - - let (core, accounts, _secrets, journal, public_key) = seeded(); - accounts - .remove_account(public_key) - .expect("remove metadata"); - let secrets = FailureSecretStore::default(); - secrets - .put( - public_key, - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), - ) - .expect("secret"), - ) - .expect("store credential"); - secrets.fail_next(SecretStoreOperation::Delete); - let id = journal - .begin_operation(AccountOperationKind::Add, public_key, FixedClock.now()) - .expect("addition intent"); - journal - .update_operation( - id, - AccountOperationPhase::CredentialWritten, - FixedClock.now(), - None, - ) - .expect("credential phase"); - assert!( - core.recover_pending_operations(&accounts, &accounts, &secrets, &journal, &FixedClock,) - .is_err() - ); - } -} diff --git a/crates/studio_application/src/secrets.rs b/crates/studio_application/src/secrets.rs @@ -1,308 +0,0 @@ -use std::collections::BTreeMap; -use std::sync::{Mutex, MutexGuard}; - -use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput}; -use secrecy::{ExposeSecret, SecretString}; - -pub trait SecretStore: Send + Sync { - /// Stores a credential under its canonical public key without overwriting. - /// - /// # Errors - /// - /// Returns a safe duplicate or keyring error without exposing the credential. - fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError>; - /// Loads a credential into a non-cloneable redacted boundary value. - /// - /// # Errors - /// - /// Returns a safe missing-credential or keyring error. - fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError>; - /// Reports whether a credential exists without exposing it. - /// - /// # Errors - /// - /// Returns a safe keyring error when availability cannot be determined. - fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError>; - /// Deletes a credential without affecting public account metadata. - /// - /// # Errors - /// - /// Returns a safe missing-credential or keyring error. - fn delete(&self, public_key: PublicKey) -> Result<(), SafeError>; -} - -#[derive(Default)] -pub struct InMemorySecretStore { - credentials: Mutex<BTreeMap<PublicKey, SecretString>>, -} - -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub enum SecretStoreOperation { - Put, - Load, - Contains, - Delete, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct SecretStoreCall { - operation: SecretStoreOperation, - public_key: PublicKey, -} - -impl SecretStoreCall { - #[must_use] - pub const fn operation(self) -> SecretStoreOperation { - self.operation - } - - #[must_use] - pub const fn public_key(self) -> PublicKey { - self.public_key - } -} - -#[derive(Default)] -pub struct FailureSecretStore { - inner: InMemorySecretStore, - remaining_failures: Mutex<BTreeMap<SecretStoreOperation, usize>>, - calls: Mutex<Vec<SecretStoreCall>>, -} - -impl FailureSecretStore { - pub fn fail_next(&self, operation: SecretStoreOperation) { - *self - .remaining_failures - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .entry(operation) - .or_default() += 1; - } - - #[must_use] - pub fn calls(&self) -> Vec<SecretStoreCall> { - self.calls - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() - } - - fn record_and_should_fail( - &self, - operation: SecretStoreOperation, - public_key: PublicKey, - ) -> bool { - self.calls - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push(SecretStoreCall { - operation, - public_key, - }); - let mut failures = self - .remaining_failures - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let remaining = failures.entry(operation).or_default(); - let should_fail = *remaining > 0; - *remaining = remaining.saturating_sub(1); - should_fail - } -} - -impl SecretStore for FailureSecretStore { - fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> { - if self.record_and_should_fail(SecretStoreOperation::Put, public_key) { - return Err(keyring_unavailable()); - } - self.inner.put(public_key, secret) - } - - fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> { - if self.record_and_should_fail(SecretStoreOperation::Load, public_key) { - return Err(keyring_unavailable()); - } - self.inner.load(public_key) - } - - fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> { - if self.record_and_should_fail(SecretStoreOperation::Contains, public_key) { - return Err(keyring_unavailable()); - } - self.inner.contains(public_key) - } - - fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> { - if self.record_and_should_fail(SecretStoreOperation::Delete, public_key) { - return Err(keyring_unavailable()); - } - self.inner.delete(public_key) - } -} - -impl InMemorySecretStore { - fn credentials(&self) -> MutexGuard<'_, BTreeMap<PublicKey, SecretString>> { - self.credentials - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } -} - -impl SecretStore for InMemorySecretStore { - fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> { - let mut credentials = self.credentials(); - if credentials.contains_key(&public_key) { - return Err(credential_exists()); - } - let value = secret.with_exposed_secret(ToOwned::to_owned); - credentials.insert(public_key, SecretString::from(value)); - Ok(()) - } - - fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> { - let credentials = self.credentials(); - let secret = credentials - .get(&public_key) - .ok_or_else(credential_missing)?; - SecretKeyInput::parse(secret.expose_secret().to_owned()).map_err(|_| credential_missing()) - } - - fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> { - Ok(self.credentials().contains_key(&public_key)) - } - - fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> { - self.credentials() - .remove(&public_key) - .map(|_| ()) - .ok_or_else(credential_missing) - } -} - -const fn credential_exists() -> SafeError { - SafeError::new( - SafeErrorCode::AccountAlreadyExists, - SafeMessage::new("The Nostr account credential already exists."), - ) -} - -const fn credential_missing() -> SafeError { - SafeError::new( - SafeErrorCode::CredentialMissing, - SafeMessage::new("The Nostr account credential is missing."), - ) -} - -const fn keyring_unavailable() -> SafeError { - SafeError::new( - SafeErrorCode::KeyringUnavailable, - SafeMessage::new("The operating system credential store is unavailable."), - ) -} - -#[cfg(test)] -mod tests { - use radroots_studio_domain::{PublicKey, SafeErrorCode, SecretKeyInput}; - - use super::{FailureSecretStore, InMemorySecretStore, SecretStore, SecretStoreOperation}; - - const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - - #[test] - fn secret_store_puts_loads_checks_and_deletes_redacted_credentials() { - let store = InMemorySecretStore::default(); - let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); - assert!(!store.contains(public_key).expect("contains")); - store - .put( - public_key, - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - ) - .expect("put"); - assert!(store.contains(public_key).expect("contains")); - let loaded = store.load(public_key).expect("load"); - assert_eq!(loaded.with_exposed_secret(str::len), 64); - store.delete(public_key).expect("delete"); - assert!(!store.contains(public_key).expect("contains")); - } - - #[test] - fn secret_store_rejects_duplicates_and_reports_missing_credentials() { - let store = InMemorySecretStore::default(); - let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); - let Err(missing) = store.load(public_key) else { - panic!("missing credential was returned"); - }; - assert_eq!(missing.code(), SafeErrorCode::CredentialMissing); - store - .put( - public_key, - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - ) - .expect("put"); - let duplicate = store - .put( - public_key, - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - ) - .expect_err("duplicate"); - assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists); - store.delete(public_key).expect("delete"); - let missing = store.delete(public_key).expect_err("missing delete"); - assert_eq!(missing.code(), SafeErrorCode::CredentialMissing); - } - - #[test] - fn failure_secret_store_injects_each_boundary_without_mutating_state() { - let store = FailureSecretStore::default(); - let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); - store.fail_next(SecretStoreOperation::Put); - let error = store - .put( - public_key, - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - ) - .expect_err("put failure"); - assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); - assert!(!store.contains(public_key).expect("not written")); - - store - .put( - public_key, - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - ) - .expect("put"); - for operation in [ - SecretStoreOperation::Load, - SecretStoreOperation::Contains, - SecretStoreOperation::Delete, - ] { - store.fail_next(operation); - let error = match operation { - SecretStoreOperation::Load => store.load(public_key).map(|_| ()), - SecretStoreOperation::Contains => store.contains(public_key).map(|_| ()), - SecretStoreOperation::Delete => store.delete(public_key), - SecretStoreOperation::Put => unreachable!("put tested separately"), - } - .expect_err("injected failure"); - assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); - } - assert!(store.contains(public_key).expect("credential retained")); - } - - #[test] - fn failure_secret_store_call_log_contains_only_public_identity() { - let store = FailureSecretStore::default(); - let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); - store - .put( - public_key, - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - ) - .expect("put"); - let calls = store.calls(); - assert_eq!(calls[0].operation(), SecretStoreOperation::Put); - assert_eq!(calls[0].public_key(), public_key); - assert!(!format!("{calls:?}").contains(SECRET)); - } -} diff --git a/crates/studio_application/src/session.rs b/crates/studio_application/src/session.rs @@ -1,268 +0,0 @@ -use crate::{ - AccountRepository, ActiveAccountSnapshot, AppCore, AppSnapshot, AppStateRepository, Clock, - ProfileLoadState, ProfileRepository, RelayConnectionState, SecretStore, StateTransition, -}; -use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage}; - -impl AppCore { - /// Drops the active session while retaining accounts, selection, and credentials. - /// - /// # Errors - /// - /// Returns a safe application-state error if the transition cannot be applied. - pub fn sign_out(&self) -> Result<AppSnapshot, SafeError> { - if matches!(self.snapshot().session(), crate::SessionState::SignedOut) { - return Ok(self.snapshot()); - } - self.apply_transition(StateTransition::SignOut) - } - - /// Validates and prepares a saved local account before replacing the active session. - /// - /// # Errors - /// - /// Returns a safe account, credential, profile-cache, persistence, or state - /// error while preserving any previously active session. - pub fn activate_account( - &self, - public_key: PublicKey, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - profiles: &(impl ProfileRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<AppSnapshot, SafeError> { - let account = accounts - .find_account(public_key)? - .ok_or_else(account_not_found)?; - self.apply_transition(StateTransition::BeginActivation(public_key))?; - let prepared = (|| { - let credential = secrets.load(public_key)?; - let imported = self.key_material().import(credential)?; - let (derived_public_key, _npub, canonical_secret) = imported.into_parts(); - drop(canonical_secret); - if derived_public_key != public_key { - return Err(invalid_credential()); - } - let cached = profiles.load_profile(public_key)?; - let active = ActiveAccountSnapshot::new( - account.with_last_used_at(clock.now()), - RelayConnectionState::Disconnected, - if cached.is_some() { - ProfileLoadState::Cached - } else { - ProfileLoadState::Empty - }, - cached.map(|profile| profile.candidate().metadata().clone()), - ); - accounts.update_account(active.account())?; - app_state.save_selected_account(Some(public_key))?; - Ok(active) - })(); - match prepared { - Ok(active) => { - self.apply_transition(StateTransition::ActivationSucceeded(Box::new(active))) - } - Err(error) => { - self.apply_transition(StateTransition::ActivationFailed(error))?; - Err(error) - } - } - } -} - -const fn account_not_found() -> SafeError { - SafeError::new( - SafeErrorCode::AccountNotFound, - SafeMessage::new("The account was not found."), - ) -} - -const fn invalid_credential() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidSecretKey, - SafeMessage::new("The Nostr account credential is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; - - use crate::{ - AppCore, CachedProfile, Clock, InMemoryAccountRepository, InMemoryOperationJournal, - InMemorySecretStore, ProfileRefreshStatus, ProfileRepository, RelayConfiguration, - SecretStore, SessionState, - }; - - #[derive(Default)] - struct EmptyProfiles; - - impl ProfileRepository for EmptyProfiles { - fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { - Ok(None) - } - - fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> { - Ok(()) - } - - fn record_refresh_status( - &self, - _public_key: PublicKey, - _refreshed_at: UnixTimestamp, - _status: ProfileRefreshStatus, - ) -> Result<(), SafeError> { - Ok(()) - } - - fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { - Ok(()) - } - } - - struct FixedClock; - - impl Clock for FixedClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(30).expect("time") - } - } - - fn input(value: &str) -> SecretKeyInput { - SecretKeyInput::parse(value.to_owned()).expect("input") - } - - #[test] - fn activate_account_switches_only_after_candidate_is_ready() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - let profiles = EmptyProfiles; - core.bootstrap().expect("bootstrap"); - let first = core - .import_secret_key( - input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("first") - .account() - .public_key(); - let second = core - .import_secret_key( - input("1111111111111111111111111111111111111111111111111111111111111111"), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("second") - .account() - .public_key(); - core.activate_account( - first, - &accounts, - &accounts, - &profiles, - &secrets, - &FixedClock, - ) - .expect("activate first"); - assert_eq!(core.snapshot().session(), SessionState::Active); - assert_eq!( - core.snapshot() - .active_account() - .map(|active| active.account().public_key()), - Some(first) - ); - - secrets.delete(second).expect("remove second credential"); - let error = core - .activate_account( - second, - &accounts, - &accounts, - &profiles, - &secrets, - &FixedClock, - ) - .expect_err("missing credential"); - assert_eq!( - error.code(), - radroots_studio_domain::SafeErrorCode::CredentialMissing - ); - secrets - .put( - second, - input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"), - ) - .expect("mismatched credential"); - let invalid = core - .activate_account( - second, - &accounts, - &accounts, - &profiles, - &secrets, - &FixedClock, - ) - .expect_err("mismatched credential"); - assert_eq!( - invalid.code(), - radroots_studio_domain::SafeErrorCode::InvalidSecretKey - ); - assert_eq!(core.snapshot().session(), SessionState::Active); - assert_eq!( - core.snapshot() - .active_account() - .map(|active| active.account().public_key()), - Some(first) - ); - } - - #[test] - fn sign_out_retains_saved_account_selection_and_credential() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - let profiles = EmptyProfiles; - core.bootstrap().expect("bootstrap"); - let public_key = core - .import_secret_key( - input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("import") - .account() - .public_key(); - core.activate_account( - public_key, - &accounts, - &accounts, - &profiles, - &secrets, - &FixedClock, - ) - .expect("activate"); - - let signed_out = core.sign_out().expect("sign out"); - let repeated = core.sign_out().expect("idempotent sign out"); - assert_eq!(signed_out, repeated); - assert_eq!(signed_out.session(), SessionState::SignedOut); - assert!(signed_out.active_account().is_none()); - assert_eq!(signed_out.accounts().len(), 1); - assert_eq!(signed_out.selected_account(), Some(public_key)); - assert!(secrets.contains(public_key).expect("credential retained")); - } -} diff --git a/crates/studio_application/src/snapshot.rs b/crates/studio_application/src/snapshot.rs @@ -1,479 +0,0 @@ -use std::collections::HashSet; - -use radroots_studio_domain::{ - AccountSummary, ProfileMetadata, PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage, -}; - -pub const MAX_CONFIGURED_RELAYS: usize = 16; - -#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] -pub struct SnapshotRevision(u64); - -impl SnapshotRevision { - #[must_use] - pub const fn initial() -> Self { - Self(0) - } - - #[must_use] - pub const fn from_value(value: u64) -> Self { - Self(value) - } - - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } - - #[must_use] - pub const fn next(self) -> Option<Self> { - match self.0.checked_add(1) { - Some(value) => Some(Self(value)), - None => None, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum AppLifecycle { - Booting, - Ready, - Fatal(SafeError), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SessionState { - SignedOut, - Activating(PublicKey), - Active, - SigningOut, - Failed(SafeError), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum RelayConnectionState { - Disconnected, - Connecting, - Connected, - Degraded, - Error(SafeError), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ProfileLoadState { - Empty, - Loading, - Cached, - Fresh, - Error(SafeError), -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct RelayConfiguration(Vec<RelayUrl>); - -impl RelayConfiguration { - /// Creates a bounded, explicitly classified relay configuration. - /// - /// # Errors - /// - /// Returns a safe configuration error before runtime or network work when - /// the relay count exceeds the Studio policy. - pub fn new(relays: Vec<RelayUrl>) -> Result<Self, SafeError> { - if relays.len() > MAX_CONFIGURED_RELAYS { - return Err(relay_limit_exceeded()); - } - Ok(Self(relays)) - } - - #[must_use] - pub fn relays(&self) -> &[RelayUrl] { - &self.0 - } -} - -const fn relay_limit_exceeded() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidRelayConfiguration, - SafeMessage::new("The Nostr relay configuration exceeds its limit."), - ) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ActiveAccountSnapshot { - account: AccountSummary, - relay_state: RelayConnectionState, - profile_state: ProfileLoadState, - profile: Option<ProfileMetadata>, -} - -impl ActiveAccountSnapshot { - #[must_use] - pub const fn new( - account: AccountSummary, - relay_state: RelayConnectionState, - profile_state: ProfileLoadState, - profile: Option<ProfileMetadata>, - ) -> Self { - Self { - account, - relay_state, - profile_state, - profile, - } - } - - #[must_use] - pub const fn account(&self) -> &AccountSummary { - &self.account - } - - #[must_use] - pub const fn relay_state(&self) -> RelayConnectionState { - self.relay_state - } - - #[must_use] - pub const fn profile_state(&self) -> ProfileLoadState { - self.profile_state - } - - #[must_use] - pub const fn profile(&self) -> Option<&ProfileMetadata> { - self.profile.as_ref() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AppSnapshot { - revision: SnapshotRevision, - lifecycle: AppLifecycle, - relay_configuration: RelayConfiguration, - accounts: Vec<AccountSummary>, - selected_account: Option<PublicKey>, - session: SessionState, - active_account: Option<ActiveAccountSnapshot>, - recoverable_problem: Option<SafeError>, -} - -impl AppSnapshot { - #[must_use] - pub fn booting() -> Self { - Self { - revision: SnapshotRevision::initial(), - lifecycle: AppLifecycle::Booting, - relay_configuration: RelayConfiguration::default(), - accounts: Vec::new(), - selected_account: None, - session: SessionState::SignedOut, - active_account: None, - recoverable_problem: None, - } - } - - #[must_use] - pub fn fatal( - revision: SnapshotRevision, - relay_configuration: RelayConfiguration, - error: SafeError, - ) -> Self { - Self { - revision, - lifecycle: AppLifecycle::Fatal(error), - relay_configuration, - accounts: Vec::new(), - selected_account: None, - session: SessionState::SignedOut, - active_account: None, - recoverable_problem: None, - } - } - - /// Constructs a ready immutable snapshot after validating state invariants. - /// - /// # Errors - /// - /// Returns a safe invalid-state error for duplicate accounts, invalid - /// selection, or inconsistent active-session state. - pub fn ready( - revision: SnapshotRevision, - relay_configuration: RelayConfiguration, - accounts: Vec<AccountSummary>, - selected_account: Option<PublicKey>, - session: SessionState, - active_account: Option<ActiveAccountSnapshot>, - recoverable_problem: Option<SafeError>, - ) -> Result<Self, SafeError> { - validate_snapshot( - &accounts, - selected_account, - session, - active_account.as_ref(), - )?; - Ok(Self { - revision, - lifecycle: AppLifecycle::Ready, - relay_configuration, - accounts, - selected_account, - session, - active_account, - recoverable_problem, - }) - } - - #[must_use] - pub const fn revision(&self) -> SnapshotRevision { - self.revision - } - - #[must_use] - pub const fn lifecycle(&self) -> AppLifecycle { - self.lifecycle - } - - #[must_use] - pub const fn relay_configuration(&self) -> &RelayConfiguration { - &self.relay_configuration - } - - #[must_use] - pub fn accounts(&self) -> &[AccountSummary] { - &self.accounts - } - - #[must_use] - pub const fn selected_account(&self) -> Option<PublicKey> { - self.selected_account - } - - #[must_use] - pub const fn session(&self) -> SessionState { - self.session - } - - #[must_use] - pub const fn active_account(&self) -> Option<&ActiveAccountSnapshot> { - self.active_account.as_ref() - } - - #[must_use] - pub const fn recoverable_problem(&self) -> Option<SafeError> { - self.recoverable_problem - } -} - -fn validate_snapshot( - accounts: &[AccountSummary], - selected_account: Option<PublicKey>, - session: SessionState, - active_account: Option<&ActiveAccountSnapshot>, -) -> Result<(), SafeError> { - let unique_accounts = accounts - .iter() - .map(AccountSummary::public_key) - .collect::<HashSet<_>>(); - if unique_accounts.len() != accounts.len() - || (accounts.is_empty() != selected_account.is_none()) - || selected_account.is_some_and(|key| !unique_accounts.contains(&key)) - || active_account - .is_some_and(|active| !unique_accounts.contains(&active.account().public_key())) - || (matches!(session, SessionState::Active) && active_account.is_none()) - || (matches!(session, SessionState::SignedOut) && active_account.is_some()) - { - return Err(invalid_snapshot()); - } - Ok(()) -} - -const fn invalid_snapshot() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The application state is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use radroots_studio_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - RelayDestinationPolicy, RelayUrl, SafeErrorCode, UnixTimestamp, - }; - - use super::{ - ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration, - RelayConnectionState, SessionState, SnapshotRevision, - }; - - fn account(key_byte: u8) -> AccountSummary { - let public_key = - crate::test_support::valid_test_public_key(key_byte).expect("valid public key"); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")), - None, - ) - .expect("account") - } - - #[test] - fn snapshot_boots_empty_and_secret_free() { - let snapshot = AppSnapshot::booting(); - let debug = format!("{snapshot:?}"); - - assert_eq!(snapshot.revision(), SnapshotRevision::initial()); - assert_eq!(snapshot.lifecycle(), AppLifecycle::Booting); - assert_eq!(snapshot.session(), SessionState::SignedOut); - assert!(snapshot.accounts().is_empty()); - assert!(snapshot.selected_account().is_none()); - assert!(snapshot.active_account().is_none()); - assert!(snapshot.relay_configuration().relays().is_empty()); - assert!(snapshot.recoverable_problem().is_none()); - assert!(!debug.contains("nsec1")); - assert!(!debug.contains(&"11".repeat(32))); - } - - #[test] - fn relay_configuration_rejects_excess_targets_before_runtime_work() { - let relays = (0..=super::MAX_CONFIGURED_RELAYS) - .map(|index| { - RelayUrl::parse( - format!("wss://relay-{index}.example").as_str(), - RelayDestinationPolicy::Public, - ) - .expect("relay") - }) - .collect(); - let error = RelayConfiguration::new(relays).expect_err("relay limit"); - assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); - } - - #[test] - fn revision_helper_is_monotonic_and_checked() { - assert_eq!( - SnapshotRevision::initial() - .next() - .map(SnapshotRevision::value), - Some(1) - ); - assert_eq!(SnapshotRevision::from_value(u64::MAX).next(), None); - } - - #[test] - fn ready_snapshot_requires_valid_selection_and_active_session() { - let first = account(1); - let second = account(2); - let active = ActiveAccountSnapshot::new( - second.clone(), - RelayConnectionState::Disconnected, - ProfileLoadState::Empty, - None, - ); - let valid = AppSnapshot::ready( - SnapshotRevision::from_value(1), - RelayConfiguration::default(), - vec![first.clone(), second.clone()], - Some(first.public_key()), - SessionState::Active, - Some(active), - None, - ) - .expect("valid ready snapshot"); - - assert_eq!(valid.lifecycle(), AppLifecycle::Ready); - assert_eq!(valid.selected_account(), Some(first.public_key())); - assert_eq!( - valid - .active_account() - .map(|value| value.account().public_key()), - Some(second.public_key()) - ); - - assert!( - AppSnapshot::ready( - SnapshotRevision::initial(), - RelayConfiguration::default(), - vec![first.clone(), first], - Some(second.public_key()), - SessionState::SignedOut, - None, - None, - ) - .is_err() - ); - assert!( - AppSnapshot::ready( - SnapshotRevision::initial(), - RelayConfiguration::default(), - vec![second.clone()], - Some(second.public_key()), - SessionState::Active, - None, - None, - ) - .is_err() - ); - - let missing = account(3); - for result in [ - AppSnapshot::ready( - SnapshotRevision::initial(), - RelayConfiguration::default(), - Vec::new(), - Some(missing.public_key()), - SessionState::SignedOut, - None, - None, - ), - AppSnapshot::ready( - SnapshotRevision::initial(), - RelayConfiguration::default(), - vec![second.clone()], - None, - SessionState::SignedOut, - None, - None, - ), - AppSnapshot::ready( - SnapshotRevision::initial(), - RelayConfiguration::default(), - vec![second.clone()], - Some(missing.public_key()), - SessionState::SignedOut, - None, - None, - ), - AppSnapshot::ready( - SnapshotRevision::initial(), - RelayConfiguration::default(), - vec![second.clone()], - Some(second.public_key()), - SessionState::SignedOut, - Some(ActiveAccountSnapshot::new( - missing, - RelayConnectionState::Disconnected, - ProfileLoadState::Empty, - None, - )), - None, - ), - AppSnapshot::ready( - SnapshotRevision::initial(), - RelayConfiguration::default(), - vec![second.clone()], - Some(second.public_key()), - SessionState::SignedOut, - Some(ActiveAccountSnapshot::new( - second, - RelayConnectionState::Disconnected, - ProfileLoadState::Empty, - None, - )), - None, - ), - ] { - assert!(result.is_err()); - } - } -} diff --git a/crates/studio_application/src/state_machine.rs b/crates/studio_application/src/state_machine.rs @@ -1,616 +0,0 @@ -use radroots_studio_domain::{AccountSummary, PublicKey, SafeError, SafeErrorCode, SafeMessage}; - -use crate::{ActiveAccountSnapshot, AppLifecycle, AppSnapshot, RelayConfiguration, SessionState}; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum StateTransition { - Bootstrap, - BootstrapRegistry { - accounts: Vec<AccountSummary>, - selected: Option<PublicKey>, - }, - Fatal(SafeError), - ReplaceRegistry { - accounts: Vec<AccountSummary>, - selected: Option<PublicKey>, - }, - ReplaceRegistryPreservingSession { - accounts: Vec<AccountSummary>, - selected: Option<PublicKey>, - }, - Select(PublicKey), - BeginActivation(PublicKey), - ActivationSucceeded(Box<ActiveAccountSnapshot>), - ActivationFailed(SafeError), - UpdateActiveAccount { - expected: PublicKey, - active_account: Box<ActiveAccountSnapshot>, - problem: Option<SafeError>, - }, - SignOut, - SetProblem(Option<SafeError>), -} - -#[derive(Clone)] -struct PreviousSession { - session: SessionState, - active_account: Option<ActiveAccountSnapshot>, -} - -pub struct StateMachine { - snapshot: AppSnapshot, - pending_activation: Option<(PublicKey, PreviousSession)>, -} - -impl StateMachine { - #[must_use] - pub fn booting() -> Self { - Self { - snapshot: AppSnapshot::booting(), - pending_activation: None, - } - } - - #[must_use] - pub const fn snapshot(&self) -> &AppSnapshot { - &self.snapshot - } - - /// Applies one deterministic state transition and returns the new snapshot. - /// - /// # Errors - /// - /// Returns a safe application error when the transition violates account, - /// revision, activation, or snapshot invariants. - pub fn apply( - &mut self, - transition: StateTransition, - relay_configuration: &RelayConfiguration, - ) -> Result<AppSnapshot, SafeError> { - let next_revision = self - .snapshot - .revision() - .next() - .ok_or_else(invalid_application_state)?; - - let next = match transition { - StateTransition::Bootstrap => self.bootstrap(next_revision, relay_configuration)?, - StateTransition::BootstrapRegistry { accounts, selected } => { - self.bootstrap_registry(next_revision, relay_configuration, accounts, selected)? - } - StateTransition::Fatal(error) => { - AppSnapshot::fatal(next_revision, relay_configuration.clone(), error) - } - StateTransition::ReplaceRegistry { accounts, selected } => { - self.replace_registry(next_revision, accounts, selected)? - } - StateTransition::ReplaceRegistryPreservingSession { accounts, selected } => { - self.replace_registry_preserving_session(next_revision, accounts, selected)? - } - StateTransition::Select(public_key) => self.select(next_revision, public_key)?, - StateTransition::BeginActivation(public_key) => { - self.begin_activation(next_revision, public_key)? - } - StateTransition::ActivationSucceeded(active_account) => { - self.activation_succeeded(next_revision, *active_account)? - } - StateTransition::ActivationFailed(problem) => { - self.activation_failed(next_revision, problem)? - } - StateTransition::UpdateActiveAccount { - expected, - active_account, - problem, - } => self.update_active_account(next_revision, expected, *active_account, problem)?, - StateTransition::SignOut => self.sign_out(next_revision)?, - StateTransition::SetProblem(problem) => self.copy_ready( - next_revision, - self.snapshot.selected_account(), - self.snapshot.session(), - self.snapshot.active_account().cloned(), - problem, - )?, - }; - self.snapshot = next.clone(); - Ok(next) - } - - fn bootstrap( - &self, - revision: crate::SnapshotRevision, - relay_configuration: &RelayConfiguration, - ) -> Result<AppSnapshot, SafeError> { - if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) { - return Ok(self.snapshot.clone()); - } - AppSnapshot::ready( - revision, - relay_configuration.clone(), - Vec::new(), - None, - SessionState::SignedOut, - None, - None, - ) - } - - fn bootstrap_registry( - &self, - revision: crate::SnapshotRevision, - relay_configuration: &RelayConfiguration, - accounts: Vec<AccountSummary>, - selected: Option<PublicKey>, - ) -> Result<AppSnapshot, SafeError> { - if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) { - return Ok(self.snapshot.clone()); - } - AppSnapshot::ready( - revision, - relay_configuration.clone(), - accounts, - selected, - SessionState::SignedOut, - None, - None, - ) - } - - fn replace_registry( - &mut self, - revision: crate::SnapshotRevision, - accounts: Vec<AccountSummary>, - selected: Option<PublicKey>, - ) -> Result<AppSnapshot, SafeError> { - self.pending_activation = None; - AppSnapshot::ready( - revision, - self.snapshot.relay_configuration().clone(), - accounts, - selected, - SessionState::SignedOut, - None, - None, - ) - } - - fn replace_registry_preserving_session( - &mut self, - revision: crate::SnapshotRevision, - accounts: Vec<AccountSummary>, - selected: Option<PublicKey>, - ) -> Result<AppSnapshot, SafeError> { - self.pending_activation = None; - AppSnapshot::ready( - revision, - self.snapshot.relay_configuration().clone(), - accounts, - selected, - self.snapshot.session(), - self.snapshot.active_account().cloned(), - None, - ) - } - - fn select( - &self, - revision: crate::SnapshotRevision, - public_key: PublicKey, - ) -> Result<AppSnapshot, SafeError> { - self.require_account(public_key)?; - self.copy_ready( - revision, - Some(public_key), - self.snapshot.session(), - self.snapshot.active_account().cloned(), - None, - ) - } - - fn begin_activation( - &mut self, - revision: crate::SnapshotRevision, - public_key: PublicKey, - ) -> Result<AppSnapshot, SafeError> { - self.require_account(public_key)?; - if self.pending_activation.is_some() { - return Err(invalid_application_state()); - } - self.pending_activation = Some(( - public_key, - PreviousSession { - session: self.snapshot.session(), - active_account: self.snapshot.active_account().cloned(), - }, - )); - self.copy_ready( - revision, - self.snapshot.selected_account(), - SessionState::Activating(public_key), - self.snapshot.active_account().cloned(), - None, - ) - } - - fn activation_succeeded( - &mut self, - revision: crate::SnapshotRevision, - active_account: ActiveAccountSnapshot, - ) -> Result<AppSnapshot, SafeError> { - let Some((target, _previous)) = self.pending_activation.as_ref() else { - return Err(invalid_application_state()); - }; - if active_account.account().public_key() != *target { - return Err(invalid_application_state()); - } - let target = *target; - self.pending_activation = None; - self.copy_ready( - revision, - Some(target), - SessionState::Active, - Some(active_account), - None, - ) - } - - fn activation_failed( - &mut self, - revision: crate::SnapshotRevision, - problem: SafeError, - ) -> Result<AppSnapshot, SafeError> { - let Some((_target, previous)) = self.pending_activation.take() else { - return Err(invalid_application_state()); - }; - self.copy_ready( - revision, - self.snapshot.selected_account(), - previous.session, - previous.active_account, - Some(problem), - ) - } - - fn sign_out(&mut self, revision: crate::SnapshotRevision) -> Result<AppSnapshot, SafeError> { - self.pending_activation = None; - self.copy_ready( - revision, - self.snapshot.selected_account(), - SessionState::SignedOut, - None, - None, - ) - } - - fn update_active_account( - &self, - revision: crate::SnapshotRevision, - expected: PublicKey, - active_account: ActiveAccountSnapshot, - problem: Option<SafeError>, - ) -> Result<AppSnapshot, SafeError> { - if !matches!(self.snapshot.session(), SessionState::Active) - || self - .snapshot - .active_account() - .map(|active| active.account().public_key()) - != Some(expected) - || active_account.account().public_key() != expected - { - return Err(invalid_application_state()); - } - self.copy_ready( - revision, - self.snapshot.selected_account(), - SessionState::Active, - Some(active_account), - problem, - ) - } - - fn require_account(&self, public_key: PublicKey) -> Result<(), SafeError> { - if self - .snapshot - .accounts() - .iter() - .any(|account| account.public_key() == public_key) - { - Ok(()) - } else { - Err(account_not_found()) - } - } - - fn copy_ready( - &self, - revision: crate::SnapshotRevision, - selected_account: Option<PublicKey>, - session: SessionState, - active_account: Option<ActiveAccountSnapshot>, - recoverable_problem: Option<SafeError>, - ) -> Result<AppSnapshot, SafeError> { - AppSnapshot::ready( - revision, - self.snapshot.relay_configuration().clone(), - self.snapshot.accounts().to_vec(), - selected_account, - session, - active_account, - recoverable_problem, - ) - } -} - -const fn invalid_application_state() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The application state is invalid."), - ) -} - -const fn account_not_found() -> SafeError { - SafeError::new( - SafeErrorCode::AccountNotFound, - SafeMessage::new("The account was not found."), - ) -} - -#[cfg(test)] -mod tests { - use radroots_studio_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, - }; - - use crate::{ - ActiveAccountSnapshot, ProfileLoadState, RelayConfiguration, RelayConnectionState, - SessionState, StateMachine, StateTransition, - }; - - fn account(key_byte: u8) -> AccountSummary { - let public_key = - crate::test_support::valid_test_public_key(key_byte).expect("valid public key"); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")), - None, - ) - .expect("account") - } - - fn active(account: AccountSummary) -> ActiveAccountSnapshot { - ActiveAccountSnapshot::new( - account, - RelayConnectionState::Disconnected, - ProfileLoadState::Empty, - None, - ) - } - - #[test] - fn state_machine_command_trace_preserves_working_session_on_failed_replacement() { - let first = account(1); - let second = account(2); - let mut machine = StateMachine::booting(); - let relays = RelayConfiguration::default(); - let problem = SafeError::new( - SafeErrorCode::CredentialMissing, - SafeMessage::new("The account credential is missing."), - ); - - machine - .apply(StateTransition::Bootstrap, &relays) - .expect("bootstrap"); - machine - .apply( - StateTransition::ReplaceRegistry { - accounts: vec![first.clone(), second.clone()], - selected: Some(first.public_key()), - }, - &relays, - ) - .expect("load registry"); - machine - .apply( - StateTransition::BeginActivation(first.public_key()), - &relays, - ) - .expect("begin first activation"); - machine - .apply( - StateTransition::ActivationSucceeded(Box::new(active(first.clone()))), - &relays, - ) - .expect("activate first"); - machine - .apply(StateTransition::Select(second.public_key()), &relays) - .expect("select second"); - let pending = machine - .apply( - StateTransition::BeginActivation(second.public_key()), - &relays, - ) - .expect("begin replacement"); - let restored = machine - .apply(StateTransition::ActivationFailed(problem), &relays) - .expect("fail replacement"); - - assert_eq!( - pending.session(), - SessionState::Activating(second.public_key()) - ); - assert_eq!( - pending - .active_account() - .map(|value| value.account().public_key()), - Some(first.public_key()) - ); - assert_eq!(restored.session(), SessionState::Active); - assert_eq!(restored.selected_account(), Some(second.public_key())); - assert_eq!( - restored - .active_account() - .map(|value| value.account().public_key()), - Some(first.public_key()) - ); - assert_eq!(restored.recoverable_problem(), Some(problem)); - assert_eq!(restored.revision().value(), 7); - } - - #[test] - fn state_machine_rejects_missing_targets_and_signs_out_without_deleting() { - let account = account(1); - let mut machine = StateMachine::booting(); - let relays = RelayConfiguration::default(); - machine - .apply(StateTransition::Bootstrap, &relays) - .expect("bootstrap"); - machine - .apply( - StateTransition::ReplaceRegistry { - accounts: vec![account.clone()], - selected: Some(account.public_key()), - }, - &relays, - ) - .expect("load registry"); - - let error = machine - .apply( - StateTransition::Select( - crate::test_support::valid_test_public_key(9).expect("valid public key"), - ), - &relays, - ) - .expect_err("missing account"); - assert_eq!(error.code(), SafeErrorCode::AccountNotFound); - - machine - .apply( - StateTransition::BeginActivation(account.public_key()), - &relays, - ) - .expect("begin activation"); - machine - .apply( - StateTransition::ActivationSucceeded(Box::new(active(account.clone()))), - &relays, - ) - .expect("activate"); - let signed_out = machine - .apply(StateTransition::SignOut, &relays) - .expect("sign out"); - - assert_eq!(signed_out.accounts(), &[account]); - assert_eq!(signed_out.session(), SessionState::SignedOut); - assert!(signed_out.active_account().is_none()); - } - - #[test] - fn activation_state_policy_rejects_every_stale_or_mismatched_transition() { - let first = account(1); - let second = account(2); - let relays = RelayConfiguration::default(); - let problem = SafeError::new( - SafeErrorCode::CredentialMissing, - SafeMessage::new("The account credential is missing."), - ); - let mut machine = StateMachine::booting(); - machine - .apply( - StateTransition::BootstrapRegistry { - accounts: vec![first.clone(), second.clone()], - selected: Some(first.public_key()), - }, - &relays, - ) - .expect("registry"); - let unchanged = machine - .apply( - StateTransition::BootstrapRegistry { - accounts: Vec::new(), - selected: None, - }, - &relays, - ) - .expect("repeated bootstrap is idempotent"); - assert_eq!(unchanged.accounts().len(), 2); - - assert!( - machine - .apply( - StateTransition::ActivationSucceeded(Box::new(active(first.clone()))), - &relays, - ) - .is_err() - ); - assert!( - machine - .apply(StateTransition::ActivationFailed(problem), &relays) - .is_err() - ); - machine - .apply( - StateTransition::BeginActivation(first.public_key()), - &relays, - ) - .expect("begin activation"); - assert!( - machine - .apply( - StateTransition::BeginActivation(second.public_key()), - &relays, - ) - .is_err() - ); - assert!( - machine - .apply( - StateTransition::ActivationSucceeded(Box::new(active(second.clone()))), - &relays, - ) - .is_err() - ); - machine - .apply( - StateTransition::ActivationSucceeded(Box::new(active(first.clone()))), - &relays, - ) - .expect("activate first"); - - for (expected, candidate) in [ - (second.public_key(), first.clone()), - (first.public_key(), second.clone()), - ] { - assert!( - machine - .apply( - StateTransition::UpdateActiveAccount { - expected, - active_account: Box::new(active(candidate)), - problem: None, - }, - &relays, - ) - .is_err() - ); - } - - machine - .apply(StateTransition::SignOut, &relays) - .expect("sign out"); - assert!( - machine - .apply( - StateTransition::UpdateActiveAccount { - expected: first.public_key(), - active_account: Box::new(active(first)), - problem: None, - }, - &relays, - ) - .is_err() - ); - } -} diff --git a/crates/studio_application/src/test_support.rs b/crates/studio_application/src/test_support.rs @@ -1,58 +0,0 @@ -use std::sync::atomic::{AtomicU8, Ordering}; - -use radroots_studio_domain::{ - Npub, Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, -}; - -use crate::{GeneratedKeyMaterial, ImportedKeyMaterial, KeyMaterialProvider}; - -#[derive(Default)] -pub(crate) struct TestKeyMaterialProvider { - next: AtomicU8, -} - -impl KeyMaterialProvider for TestKeyMaterialProvider { - fn generate(&self) -> Result<GeneratedKeyMaterial, SafeError> { - let public_key = (0..=u8::MAX) - .find_map(|_| { - let candidate = self.next.fetch_add(1, Ordering::Relaxed).wrapping_add(9); - PublicKey::from_bytes([candidate; 32]).ok() - }) - .ok_or_else(invalid_secret_key)?; - let secret_byte = public_key.as_bytes()[0]; - Ok(GeneratedKeyMaterial::new( - public_key, - Npub::derive(public_key)?, - SecretKeyInput::parse(format!("{secret_byte:02x}").repeat(32))?, - Nsec::from_encoded( - "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5".to_owned(), - )?, - )) - } - - fn import(&self, input: SecretKeyInput) -> Result<ImportedKeyMaterial, SafeError> { - let discriminator = input.with_exposed_secret(|value| value.as_bytes()[0]); - if input.with_exposed_secret(|value| value.starts_with("nsec1qq")) { - return Err(invalid_secret_key()); - } - let public_key = valid_test_public_key(discriminator)?; - Ok(ImportedKeyMaterial::new( - public_key, - Npub::derive(public_key)?, - input, - )) - } -} - -pub(crate) fn valid_test_public_key(discriminator: u8) -> Result<PublicKey, SafeError> { - (0..=u8::MAX) - .find_map(|offset| PublicKey::from_bytes([discriminator.wrapping_add(offset); 32]).ok()) - .ok_or_else(invalid_secret_key) -} - -const fn invalid_secret_key() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidSecretKey, - SafeMessage::new("The Nostr secret key is invalid."), - ) -} diff --git a/crates/studio_application/tests/redaction.rs b/crates/studio_application/tests/redaction.rs @@ -1,48 +0,0 @@ -use radroots_studio_application::{ - AppSnapshot, RelayConfiguration, SessionState, SnapshotRevision, -}; -use radroots_studio_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, -}; - -const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; -const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; -fn assert_redacted(text: &str) { - assert!(!text.contains(SECRET_HEX)); - assert!(!text.contains(SECRET_NSEC)); - assert!(!text.contains("nsec1")); -} - -#[test] -fn redaction_guards_public_snapshot_and_safe_error_debug() { - let account = AccountSummary::new( - AccountIdentity::derive(PublicKey::from_bytes([7; 32]).expect("valid public key")) - .expect("identity"), - LocalSignerBinding::new( - PublicKey::from_bytes([7; 32]).expect("valid public key"), - BindingAvailability::Available, - ), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), - None, - ) - .expect("account"); - let snapshot = AppSnapshot::ready( - SnapshotRevision::from_value(1), - RelayConfiguration::default(), - vec![account.clone()], - Some(account.public_key()), - SessionState::SignedOut, - None, - None, - ) - .expect("snapshot"); - let error = SafeError::new( - SafeErrorCode::KeyringUnavailable, - SafeMessage::new("The operating system credential store is unavailable."), - ); - - assert_redacted(&format!("{snapshot:?}")); - assert_redacted(&format!("{error:?} {error}")); -} diff --git a/crates/studio_domain/Cargo.toml b/crates/studio_domain/Cargo.toml @@ -1,22 +0,0 @@ -[package] -name = "radroots_studio_domain" -description = "Private domain model for Radroots Studio" -version = "0.1.0-alpha" -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license = "GPL-3.0-only" -repository.workspace = true -homepage.workspace = true -publish = false -include = ["src/**", "Cargo.toml"] - -[dependencies] -bech32 = "=0.11.1" -radroots_identity.workspace = true -secrecy = "=0.10.3" -url = "=2.5.8" -zeroize = "=1.9.0" - -[lints] -workspace = true diff --git a/crates/studio_domain/src/account.rs b/crates/studio_domain/src/account.rs @@ -1,430 +0,0 @@ -//! Public account metadata and lifecycle values. - -use crate::time::UnixTimestamp; -use crate::{Npub, PublicKey, SafeError, SafeErrorCode, SafeMessage}; - -const MAX_ACCOUNT_LABEL_CHARS: usize = 80; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AccountIdentity { - public_key: PublicKey, - npub: Npub, -} - -impl AccountIdentity { - /// Constructs one canonical Nostr account identity and derives its npub. - /// - /// # Errors - /// - /// Returns a safe public-key error if canonical NIP-19 encoding fails. - pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> { - Ok(Self { - public_key, - npub: Npub::derive(public_key)?, - }) - } - - /// Reconstitutes persisted identity only when its public forms agree. - /// - /// # Errors - /// - /// Returns a safe public-key error for a mismatched or malformed npub. - pub fn verify(public_key: PublicKey, npub: String) -> Result<Self, SafeError> { - Ok(Self { - public_key, - npub: Npub::verify(public_key, npub)?, - }) - } - - #[must_use] - pub const fn public_key(&self) -> PublicKey { - self.public_key - } - - #[must_use] - pub const fn npub(&self) -> &Npub { - &self.npub - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct LocalSignerBinding { - account: PublicKey, - availability: BindingAvailability, -} - -impl LocalSignerBinding { - #[must_use] - pub const fn new(account: PublicKey, availability: BindingAvailability) -> Self { - Self { - account, - availability, - } - } - - #[must_use] - pub const fn account(self) -> PublicKey { - self.account - } - - #[must_use] - pub const fn availability(self) -> BindingAvailability { - self.availability - } - - #[must_use] - pub const fn repair_action(self) -> Option<BindingRepairAction> { - match self.availability { - BindingAvailability::Available => None, - BindingAvailability::CredentialMissing => Some(BindingRepairAction::ImportCredential), - BindingAvailability::StoreUnavailable => { - Some(BindingRepairAction::RetryCredentialStore) - } - } - } - - /// Records a missing credential after a successful store lookup. - /// - /// # Errors - /// - /// Returns a safe state error unless the binding was previously available. - pub fn mark_credential_missing(&mut self) -> Result<(), SafeError> { - self.transition( - BindingAvailability::Available, - BindingAvailability::CredentialMissing, - ) - } - - pub fn mark_store_unavailable(&mut self) { - self.availability = BindingAvailability::StoreUnavailable; - } - - /// Completes an explicit credential repair. - /// - /// # Errors - /// - /// Returns a safe state error unless a credential was missing. - pub fn repair_credential(&mut self) -> Result<(), SafeError> { - self.transition( - BindingAvailability::CredentialMissing, - BindingAvailability::Available, - ) - } - - /// Resolves a recovered store lookup to its observed credential state. - /// - /// # Errors - /// - /// Returns a safe state error unless the credential store was unavailable. - pub fn resolve_store_recovery(&mut self, credential_present: bool) -> Result<(), SafeError> { - if self.availability != BindingAvailability::StoreUnavailable { - return Err(invalid_account_metadata()); - } - self.availability = if credential_present { - BindingAvailability::Available - } else { - BindingAvailability::CredentialMissing - }; - Ok(()) - } - - fn transition( - &mut self, - expected: BindingAvailability, - next: BindingAvailability, - ) -> Result<(), SafeError> { - if self.availability != expected { - return Err(invalid_account_metadata()); - } - self.availability = next; - Ok(()) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum BindingAvailability { - Available, - CredentialMissing, - StoreUnavailable, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum BindingRepairAction { - ImportCredential, - RetryCredentialStore, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AccountLabel(String); - -impl AccountLabel { - /// Trims and validates an optional human-assigned account label value. - /// - /// # Errors - /// - /// Returns a safe metadata error when the resulting label is empty, too - /// long, or contains a control character. - pub fn parse(value: &str) -> Result<Self, SafeError> { - let normalized = value.trim(); - if normalized.is_empty() - || normalized.chars().count() > MAX_ACCOUNT_LABEL_CHARS - || normalized.chars().any(char::is_control) - { - return Err(invalid_account_metadata()); - } - Ok(Self(normalized.to_owned())) - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub struct AccountCreatedAt(UnixTimestamp); - -impl AccountCreatedAt { - #[must_use] - pub const fn new(timestamp: UnixTimestamp) -> Self { - Self(timestamp) - } - - #[must_use] - pub const fn timestamp(self) -> UnixTimestamp { - self.0 - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AccountSummary { - identity: AccountIdentity, - signer: LocalSignerBinding, - label: Option<AccountLabel>, - created_at: AccountCreatedAt, - last_used_at: Option<UnixTimestamp>, -} - -impl AccountSummary { - /// Creates an account summary whose identity and signer binding refer to the same account. - /// - /// # Errors - /// - /// Returns an invalid-account-metadata error when the signer binding belongs to a different - /// public key. - pub fn new( - identity: AccountIdentity, - signer: LocalSignerBinding, - label: Option<AccountLabel>, - created_at: AccountCreatedAt, - last_used_at: Option<UnixTimestamp>, - ) -> Result<Self, SafeError> { - if identity.public_key() != signer.account() { - return Err(invalid_account_metadata()); - } - Ok(Self { - identity, - signer, - label, - created_at, - last_used_at, - }) - } - - #[must_use] - pub const fn public_key(&self) -> PublicKey { - self.identity.public_key() - } - - #[must_use] - pub fn npub(&self) -> &Npub { - self.identity.npub() - } - - #[must_use] - pub const fn signer(&self) -> LocalSignerBinding { - self.signer - } - - #[must_use] - pub fn label(&self) -> Option<&AccountLabel> { - self.label.as_ref() - } - - #[must_use] - pub const fn created_at(&self) -> AccountCreatedAt { - self.created_at - } - - #[must_use] - pub const fn last_used_at(&self) -> Option<UnixTimestamp> { - self.last_used_at - } - - #[must_use] - pub fn with_binding_availability(&self, availability: BindingAvailability) -> Self { - Self { - identity: self.identity.clone(), - signer: LocalSignerBinding::new(self.public_key(), availability), - label: self.label.clone(), - created_at: self.created_at, - last_used_at: self.last_used_at, - } - } - - #[must_use] - pub fn with_last_used_at(&self, last_used_at: UnixTimestamp) -> Self { - Self { - identity: self.identity.clone(), - signer: self.signer, - label: self.label.clone(), - created_at: self.created_at, - last_used_at: Some(last_used_at), - } - } - - #[must_use] - pub fn display_label(&self) -> String { - self.label - .as_ref() - .map_or_else(|| self.npub().short(), |label| label.as_str().to_owned()) - } -} - -const fn invalid_account_metadata() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidAccountMetadata, - SafeMessage::new("The account metadata is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use crate::PublicKey; - use crate::time::UnixTimestamp; - - use super::{ - AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, - BindingRepairAction, LocalSignerBinding, - }; - - const DERIVED_NPUB: &str = "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"; - const MISMATCHED_NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; - - fn public_key() -> PublicKey { - PublicKey::from_bytes([7_u8; 32]).expect("valid public key") - } - - fn account(label: Option<AccountLabel>) -> AccountSummary { - let public_key = public_key(); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - label, - AccountCreatedAt::new(UnixTimestamp::from_seconds(10).expect("valid time")), - None, - ) - .expect("account") - } - - #[test] - fn account_label_is_trimmed_bounded_and_control_free() { - let label = AccountLabel::parse(" Farm account ").expect("valid label"); - assert_eq!(label.as_str(), "Farm account"); - - for invalid in ["", " ", "line\nbreak", &"x".repeat(81)] { - assert!(AccountLabel::parse(invalid).is_err()); - } - } - - #[test] - fn account_display_prefers_label_then_shortened_npub() { - let labelled = account(Some(AccountLabel::parse("Farm").expect("valid label"))); - let unlabelled = account(None); - - assert_eq!(labelled.display_label(), "Farm"); - assert_eq!(unlabelled.display_label(), "npub1qurswpc…rsnvjvl7"); - } - - #[test] - fn local_account_summary_contains_public_metadata_only() { - let account = account(None); - let debug = format!("{account:?}"); - - assert_eq!( - account.signer().availability(), - BindingAvailability::Available - ); - assert!(account.label().is_none()); - assert!(account.last_used_at().is_none()); - assert_eq!(account.created_at().timestamp().as_seconds(), 10); - assert_eq!(account.public_key(), public_key()); - assert_eq!(account.npub().as_str(), DERIVED_NPUB); - assert!(!debug.contains("nsec1")); - assert!(!debug.contains(&"11".repeat(32))); - } - - #[test] - fn account_identity_derives_npub_and_rejects_mismatched_persisted_forms() { - let public_key = public_key(); - let identity = AccountIdentity::derive(public_key).expect("identity"); - assert_eq!(identity.public_key(), public_key); - assert_eq!(identity.npub().as_str(), DERIVED_NPUB); - assert_eq!( - AccountIdentity::verify(public_key, DERIVED_NPUB.to_owned()).expect("verified"), - identity - ); - assert!(AccountIdentity::verify(public_key, MISMATCHED_NPUB.to_owned()).is_err()); - assert!( - AccountIdentity::verify( - PublicKey::from_hex( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - ) - .expect("second public key"), - MISMATCHED_NPUB.to_owned() - ) - .is_err() - ); - } - - #[test] - fn local_signer_binding_carries_only_canonical_account_identity() { - let public_key = public_key(); - let identity = AccountIdentity::derive(public_key).expect("identity"); - let binding = LocalSignerBinding::new(public_key, BindingAvailability::Available); - - assert_eq!(binding.account(), identity.public_key()); - assert!(!format!("{binding:?}").contains("nsec1")); - } - - #[test] - fn local_binding_repair_transitions_are_typed_and_fail_closed() { - let public_key = public_key(); - let mut binding = LocalSignerBinding::new(public_key, BindingAvailability::Available); - assert_eq!(binding.repair_action(), None); - assert!(binding.repair_credential().is_err()); - - binding - .mark_credential_missing() - .expect("missing credential"); - assert_eq!( - binding.repair_action(), - Some(BindingRepairAction::ImportCredential) - ); - binding.repair_credential().expect("repair"); - - binding.mark_store_unavailable(); - assert_eq!( - binding.repair_action(), - Some(BindingRepairAction::RetryCredentialStore) - ); - binding - .resolve_store_recovery(false) - .expect("store recovery"); - assert_eq!( - binding.availability(), - BindingAvailability::CredentialMissing - ); - assert!(binding.resolve_store_recovery(true).is_err()); - } -} diff --git a/crates/studio_domain/src/error.rs b/crates/studio_domain/src/error.rs @@ -1,113 +0,0 @@ -use std::error::Error; -use std::fmt::{self, Debug, Display, Formatter}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SafeErrorCode { - InvalidPublicKey, - InvalidSecretKey, - InvalidAccountMetadata, - InvalidProfileMetadata, - InvalidApplicationState, - AccountAlreadyExists, - AccountNotFound, - KeyringUnavailable, - CredentialMissing, - StorageUnavailable, - StorageCorrupt, - StorageQuarantined, - StorageBackupInvalid, - UnsupportedSchemaVersion, - RepairUnauthorized, - PendingOperationRecoveryRequired, - InvalidRelayConfiguration, - RelayConnectionFailed, - ProfileRefreshFailed, - ObserverRegistrationFailed, - NativeLibraryLoadFailed, -} - -#[derive(Clone, Copy, Eq, PartialEq)] -pub struct SafeMessage(&'static str); - -impl SafeMessage { - #[must_use] - pub const fn new(message: &'static str) -> Self { - Self(message) - } - - #[must_use] - pub const fn as_str(self) -> &'static str { - self.0 - } -} - -impl Debug for SafeMessage { - fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - formatter.debug_tuple("SafeMessage").field(&self.0).finish() - } -} - -impl Display for SafeMessage { - fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - formatter.write_str(self.0) - } -} - -#[derive(Clone, Copy, Eq, PartialEq)] -pub struct SafeError { - code: SafeErrorCode, - message: SafeMessage, -} - -impl SafeError { - #[must_use] - pub const fn new(code: SafeErrorCode, message: SafeMessage) -> Self { - Self { code, message } - } - - #[must_use] - pub const fn code(self) -> SafeErrorCode { - self.code - } - - #[must_use] - pub const fn message(self) -> SafeMessage { - self.message - } -} - -impl Debug for SafeError { - fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("SafeError") - .field("code", &self.code) - .field("message", &self.message) - .finish() - } -} - -impl Display for SafeError { - fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - Display::fmt(&self.message, formatter) - } -} - -impl Error for SafeError {} - -#[cfg(test)] -mod tests { - use super::{SafeError, SafeErrorCode, SafeMessage}; - - #[test] - fn safe_error_formats_only_a_static_public_message() { - let error = SafeError::new( - SafeErrorCode::InvalidSecretKey, - SafeMessage::new("The secret key is invalid."), - ); - - assert_eq!(error.to_string(), "The secret key is invalid."); - assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); - assert_eq!(error.message().as_str(), "The secret key is invalid."); - assert!(!format!("{error:?}").contains("nsec1unsafe-test-value")); - } -} diff --git a/crates/studio_domain/src/key.rs b/crates/studio_domain/src/key.rs @@ -1,451 +0,0 @@ -//! Validated Nostr public and secret-key boundary values. - -use std::fmt::{self, Display, Formatter}; -use std::str::FromStr; - -use secrecy::{ExposeSecret, SecretString}; -use zeroize::Zeroizing; - -use crate::{SafeError, SafeErrorCode, SafeMessage}; - -pub const PUBLIC_KEY_BYTE_LENGTH: usize = 32; -pub const PUBLIC_KEY_HEX_LENGTH: usize = PUBLIC_KEY_BYTE_LENGTH * 2; -pub const MAX_SECRET_KEY_INPUT_BYTES: usize = 128; -const NIP19_KEY_LENGTH: usize = 63; -const BECH32_DATA_CHARSET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; - -#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct Npub(String); - -impl Npub { - /// Constructs a human-facing npub after structural validation. - /// - /// Cryptographic conversion and checksum validation are performed by the - /// selected Nostr adapter before this domain value is created in runtime - /// flows. - /// - /// # Errors - /// - /// Returns a safe invalid-public-key error for a malformed npub shape. - pub fn from_encoded(value: String) -> Result<Self, SafeError> { - if !is_nip19_key_shape(&value, "npub1") { - return Err(invalid_public_key()); - } - Ok(Self(value)) - } - - /// Derives the canonical NIP-19 display identity from a public key. - /// - /// # Errors - /// - /// Returns a safe public-key error if canonical encoding fails. - pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> { - let hrp = bech32::Hrp::parse("npub").map_err(|_| invalid_public_key())?; - bech32::encode::<bech32::Bech32>(hrp, public_key.as_bytes()) - .map_err(|_| invalid_public_key()) - .and_then(Self::from_encoded) - } - - /// Validates that encoded display identity belongs to the canonical key. - /// - /// # Errors - /// - /// Returns a safe public-key error when the values do not match. - pub fn verify(public_key: PublicKey, encoded: String) -> Result<Self, SafeError> { - let candidate = Self::from_encoded(encoded)?; - if candidate != Self::derive(public_key)? { - return Err(invalid_public_key()); - } - Ok(candidate) - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - #[must_use] - pub fn short(&self) -> String { - format!("{}…{}", &self.0[..12], &self.0[self.0.len() - 8..]) - } -} - -impl Display for Npub { - fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.0) - } -} - -pub struct Nsec(SecretString); - -impl Nsec { - /// Constructs a secret nsec display value after structural validation. - /// - /// # Errors - /// - /// Returns a safe invalid-secret-key error for a malformed nsec shape. - pub fn from_encoded(value: String) -> Result<Self, SafeError> { - if !is_nip19_key_shape(&value, "nsec1") { - return Err(invalid_secret_key()); - } - Ok(Self(SecretString::from(value))) - } - - pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T { - operation(self.0.expose_secret()) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SecretKeyInputKind { - Nsec, - Hex, -} - -pub struct SecretKeyInput { - value: SecretString, - kind: SecretKeyInputKind, -} - -impl SecretKeyInput { - /// Moves bounded transport bytes into the zeroizing secret boundary. - /// - /// The source byte allocation is cleared on every return path. - /// - /// # Errors - /// - /// Returns a safe invalid-secret-key error for oversized, non-UTF-8, or - /// structurally invalid input. - pub fn parse_bytes(value: Vec<u8>) -> Result<Self, SafeError> { - let value = Zeroizing::new(value); - if value.len() > MAX_SECRET_KEY_INPUT_BYTES { - return Err(invalid_secret_key()); - } - let encoded = std::str::from_utf8(&value).map_err(|_| invalid_secret_key())?; - Self::parse(encoded.to_owned()) - } - - /// Moves one secret input string into a zeroizing boundary. - /// - /// Nsec inputs receive complete NIP-19 validation in the Nostr adapter. - /// Hex input is structurally validated here to prevent ambiguous fallback. - /// - /// # Errors - /// - /// Returns a safe invalid-secret-key error when the input is neither an - /// nsec-looking value nor exactly 64 lowercase hexadecimal characters. - pub fn parse(value: String) -> Result<Self, SafeError> { - let mut value = Zeroizing::new(value); - let kind = if value.len() == PUBLIC_KEY_HEX_LENGTH - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - SecretKeyInputKind::Hex - } else if is_nip19_key_shape(&value, "nsec1") { - SecretKeyInputKind::Nsec - } else { - return Err(invalid_secret_key()); - }; - - Ok(Self { - value: SecretString::from(std::mem::take(&mut *value)), - kind, - }) - } - - #[must_use] - pub const fn kind(&self) -> SecretKeyInputKind { - self.kind - } - - pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T { - operation(self.value.expose_secret()) - } -} - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct PublicKey(radroots_identity::PublicKey); - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum PersistedPublicKeyClassification { - Canonical(PublicKey), - NonCanonicalEncoding, - InvalidCurvePoint, - MalformedEncoding, -} - -impl PublicKey { - /// Validates canonical x-only secp256k1 public-key bytes. - /// - /// # Errors - /// - /// Returns a safe invalid-public-key error when the bytes are not a valid - /// x-only secp256k1 point. - pub fn from_bytes(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Result<Self, SafeError> { - radroots_identity::PublicKey::from_bytes(bytes) - .map(Self) - .map_err(|_| invalid_public_key()) - } - - /// Parses a canonical lowercase hexadecimal Nostr public key. - /// - /// # Errors - /// - /// Returns a safe invalid-public-key error when the value is not exactly - /// 64 lowercase hexadecimal characters. - pub fn from_hex(value: &str) -> Result<Self, SafeError> { - if value.len() != PUBLIC_KEY_HEX_LENGTH - || !value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(invalid_public_key()); - } - - radroots_identity::PublicKey::from_hex(value) - .map(Self) - .map_err(|_| invalid_public_key()) - } - - #[must_use] - pub const fn as_bytes(&self) -> &[u8; PUBLIC_KEY_BYTE_LENGTH] { - self.0.as_bytes() - } - - #[must_use] - pub const fn canonical(self) -> radroots_identity::PublicKey { - self.0 - } - - #[must_use] - pub const fn from_canonical(public_key: radroots_identity::PublicKey) -> Self { - Self(public_key) - } - - #[must_use] - pub fn to_hex(self) -> String { - self.0.to_hex() - } - - #[must_use] - pub fn short_hex(self) -> String { - let hex = self.to_hex(); - format!("{}…{}", &hex[..8], &hex[PUBLIC_KEY_HEX_LENGTH - 8..]) - } -} - -impl Display for PublicKey { - fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.to_hex()) - } -} - -impl From<radroots_identity::PublicKey> for PublicKey { - fn from(value: radroots_identity::PublicKey) -> Self { - Self::from_canonical(value) - } -} - -impl From<PublicKey> for radroots_identity::PublicKey { - fn from(value: PublicKey) -> Self { - value.canonical() - } -} - -impl FromStr for PublicKey { - type Err = SafeError; - - fn from_str(value: &str) -> Result<Self, Self::Err> { - Self::from_hex(value) - } -} - -const fn invalid_public_key() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidPublicKey, - SafeMessage::new("The Nostr public key is invalid."), - ) -} - -const fn invalid_secret_key() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidSecretKey, - SafeMessage::new("The Nostr secret key is invalid."), - ) -} - -#[must_use] -pub fn classify_persisted_public_key(value: &str) -> PersistedPublicKeyClassification { - if value.len() != PUBLIC_KEY_HEX_LENGTH || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return PersistedPublicKeyClassification::MalformedEncoding; - } - if !value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return PersistedPublicKeyClassification::NonCanonicalEncoding; - } - match PublicKey::from_hex(value) { - Ok(public_key) => PersistedPublicKeyClassification::Canonical(public_key), - Err(_) => PersistedPublicKeyClassification::InvalidCurvePoint, - } -} - -fn is_nip19_key_shape(value: &str, prefix: &str) -> bool { - value.len() == NIP19_KEY_LENGTH - && value.starts_with(prefix) - && value[prefix.len()..] - .bytes() - .all(|byte| BECH32_DATA_CHARSET.contains(&byte)) -} - -#[cfg(test)] -mod tests { - use std::str::FromStr; - - use super::{ - MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PUBLIC_KEY_BYTE_LENGTH, - PersistedPublicKeyClassification, PublicKey, SecretKeyInput, SecretKeyInputKind, - classify_persisted_public_key, - }; - use crate::SafeErrorCode; - - const HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; - const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; - - #[test] - fn public_key_round_trips_canonical_hex_and_bytes() { - let key = PublicKey::from_str(HEX).expect("valid public key"); - - assert_eq!(key.to_hex(), HEX); - assert_eq!(key.to_string(), HEX); - assert_eq!(key.short_hex(), "7e7e9c42…2107f6d7"); - assert_eq!( - PublicKey::from_bytes(*key.as_bytes()).expect("valid bytes"), - key - ); - assert_eq!(key.as_bytes().len(), PUBLIC_KEY_BYTE_LENGTH); - } - - #[test] - fn public_key_rejects_noncanonical_or_malformed_hex() { - for value in [ - "", - "00", - "7E7E9C42A91BFEF19FA7EA99D52D8AFDB67D893A8FEFBA1F5CB9793F2107F6D7", - "ze7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - " 7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - "00e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - ] { - let error = PublicKey::from_hex(value).expect_err("invalid public key"); - assert_eq!(error.code(), SafeErrorCode::InvalidPublicKey); - } - } - - #[test] - fn public_keys_are_ordered_by_canonical_bytes() { - let low = - PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df") - .expect("low key"); - let high = - PublicKey::from_hex("e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af") - .expect("high key"); - - assert!(low < high); - } - - #[test] - fn persisted_public_key_classification_is_explicit_and_fail_closed() { - assert!(matches!( - classify_persisted_public_key(HEX), - PersistedPublicKeyClassification::Canonical(_) - )); - assert_eq!( - classify_persisted_public_key(HEX.to_ascii_uppercase().as_str()), - PersistedPublicKeyClassification::NonCanonicalEncoding - ); - assert_eq!( - classify_persisted_public_key(&"00".repeat(PUBLIC_KEY_BYTE_LENGTH)), - PersistedPublicKeyClassification::InvalidCurvePoint - ); - assert_eq!( - classify_persisted_public_key("not-a-public-key"), - PersistedPublicKeyClassification::MalformedEncoding - ); - } - - #[test] - fn secret_input_is_redacted_and_exposed_only_to_a_scoped_operation() { - let secret = "11".repeat(PUBLIC_KEY_BYTE_LENGTH); - let input = SecretKeyInput::parse(secret.clone()).expect("valid secret hex"); - - assert_eq!(input.kind(), SecretKeyInputKind::Hex); - assert_eq!(input.with_exposed_secret(str::len), secret.len()); - assert_eq!(input.with_exposed_secret(str::len), 64); - } - - #[test] - fn secret_input_accepts_nsec_shape_without_exposing_it() { - let secret = NSEC.to_owned(); - let input = SecretKeyInput::parse(secret.clone()).expect("nsec-shaped input"); - - assert_eq!(input.kind(), SecretKeyInputKind::Nsec); - assert_eq!(input.with_exposed_secret(str::len), secret.len()); - } - - #[test] - fn secret_input_rejects_invalid_hex_and_arbitrary_text() { - for value in [ - "", - "very-sensitive-input", - &"GG".repeat(PUBLIC_KEY_BYTE_LENGTH), - ] { - let Err(error) = SecretKeyInput::parse(value.to_owned()) else { - panic!("invalid secret accepted"); - }; - assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); - if !value.is_empty() { - assert!(!format!("{error:?}").contains(value)); - } - } - } - - #[test] - fn secret_byte_transport_is_bounded_and_validated() { - let parsed = SecretKeyInput::parse_bytes(HEX.as_bytes().to_vec()).expect("bytes"); - assert_eq!(parsed.with_exposed_secret(str::len), 64); - assert!(SecretKeyInput::parse_bytes(vec![0xff]).is_err()); - assert!(SecretKeyInput::parse_bytes(vec![b'a'; MAX_SECRET_KEY_INPUT_BYTES + 1]).is_err()); - } - - #[test] - fn npub_is_public_display_data_but_not_canonical_identity() { - let npub = Npub::from_encoded(NPUB.to_owned()).expect("valid npub shape"); - - assert_eq!(npub.as_str(), NPUB); - assert_eq!(npub.to_string(), NPUB); - } - - #[test] - fn nsec_is_redacted_and_exposed_only_to_a_scoped_operation() { - let nsec = Nsec::from_encoded(NSEC.to_owned()).expect("valid nsec shape"); - - assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len()); - assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len()); - } - - #[test] - fn nip19_display_types_reject_wrong_prefix_length_and_charset() { - for invalid in [ - "", - "npub1short", - "nsec1short", - "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjp!g", - ] { - assert!(Npub::from_encoded(invalid.to_owned()).is_err()); - assert!(Nsec::from_encoded(invalid.to_owned()).is_err()); - } - } -} diff --git a/crates/studio_domain/src/lib.rs b/crates/studio_domain/src/lib.rs @@ -1,21 +0,0 @@ -#![doc = "Radroots Studio Nostr account domain types."] - -pub mod account; -pub mod error; -pub mod key; -pub mod profile; -pub mod relay; -pub mod time; - -pub use account::{ - AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, - BindingRepairAction, LocalSignerBinding, -}; -pub use error::{SafeError, SafeErrorCode, SafeMessage}; -pub use key::{ - MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PersistedPublicKeyClassification, PublicKey, - SecretKeyInput, SecretKeyInputKind, classify_persisted_public_key, -}; -pub use profile::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0}; -pub use relay::{RelayDestinationPolicy, RelayUrl, normalize_relay_urls}; -pub use time::UnixTimestamp; diff --git a/crates/studio_domain/src/profile.rs b/crates/studio_domain/src/profile.rs @@ -1,298 +0,0 @@ -//! Public Nostr profile metadata values. - -use crate::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp}; - -const EVENT_ID_BYTES: usize = 32; -const EVENT_ID_HEX: usize = EVENT_ID_BYTES * 2; -const MAX_NAME_CHARS: usize = 128; -const MAX_NIP05_CHARS: usize = 320; -const MAX_ABOUT_CHARS: usize = 4_096; -const MAX_PICTURE_CHARS: usize = 2_048; - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct EventId([u8; EVENT_ID_BYTES]); - -impl EventId { - /// Parses a canonical lowercase hexadecimal Nostr event ID. - /// - /// # Errors - /// - /// Returns a safe profile error for malformed input. - pub fn from_hex(value: &str) -> Result<Self, SafeError> { - if value.len() != EVENT_ID_HEX - || !value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(invalid_profile_metadata()); - } - - let mut bytes = [0_u8; EVENT_ID_BYTES]; - for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { - let high = decode_hex(pair[0]).ok_or_else(invalid_profile_metadata)?; - let low = decode_hex(pair[1]).ok_or_else(invalid_profile_metadata)?; - bytes[index] = (high << 4) | low; - } - Ok(Self(bytes)) - } - - #[must_use] - pub const fn from_bytes(bytes: [u8; EVENT_ID_BYTES]) -> Self { - Self(bytes) - } - - #[must_use] - pub const fn as_bytes(self) -> [u8; EVENT_ID_BYTES] { - self.0 - } - - #[must_use] - pub fn to_hex(self) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::with_capacity(EVENT_ID_HEX); - for byte in self.0 { - output.push(char::from(HEX[usize::from(byte >> 4)])); - output.push(char::from(HEX[usize::from(byte & 0x0f)])); - } - output - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct ProfileMetadata { - name: Option<String>, - display_name: Option<String>, - nip05: Option<String>, - about: Option<String>, - picture: Option<String>, -} - -impl ProfileMetadata { - /// Normalizes and bounds public kind-0 profile fields. - /// - /// # Errors - /// - /// Returns a safe profile error when a field exceeds its limit or contains - /// a forbidden control character. - pub fn new( - name: Option<String>, - display_name: Option<String>, - nip05: Option<String>, - about: Option<String>, - picture: Option<String>, - ) -> Result<Self, SafeError> { - Ok(Self { - name: normalize_field(name, MAX_NAME_CHARS, false)?, - display_name: normalize_field(display_name, MAX_NAME_CHARS, false)?, - nip05: normalize_field(nip05, MAX_NIP05_CHARS, false)?, - about: normalize_field(about, MAX_ABOUT_CHARS, true)?, - picture: normalize_field(picture, MAX_PICTURE_CHARS, false)?, - }) - } - - #[must_use] - pub fn name(&self) -> Option<&str> { - self.name.as_deref() - } - - #[must_use] - pub fn display_name(&self) -> Option<&str> { - self.display_name.as_deref() - } - - #[must_use] - pub fn nip05(&self) -> Option<&str> { - self.nip05.as_deref() - } - - #[must_use] - pub fn about(&self) -> Option<&str> { - self.about.as_deref() - } - - #[must_use] - pub fn picture(&self) -> Option<&str> { - self.picture.as_deref() - } - - #[must_use] - pub fn preferred_name(&self) -> Option<&str> { - self.display_name().or_else(|| self.name()) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Kind0ProfileCandidate { - event_id: EventId, - author: PublicKey, - created_at: UnixTimestamp, - metadata: ProfileMetadata, -} - -impl Kind0ProfileCandidate { - #[must_use] - pub const fn new( - event_id: EventId, - author: PublicKey, - created_at: UnixTimestamp, - metadata: ProfileMetadata, - ) -> Self { - Self { - event_id, - author, - created_at, - metadata, - } - } - - #[must_use] - pub const fn event_id(&self) -> EventId { - self.event_id - } - - #[must_use] - pub const fn author(&self) -> PublicKey { - self.author - } - - #[must_use] - pub const fn created_at(&self) -> UnixTimestamp { - self.created_at - } - - #[must_use] - pub const fn metadata(&self) -> &ProfileMetadata { - &self.metadata - } -} - -#[must_use] -pub fn select_latest_kind0( - candidates: impl IntoIterator<Item = Kind0ProfileCandidate>, -) -> Option<Kind0ProfileCandidate> { - candidates.into_iter().reduce(|selected, candidate| { - if candidate.created_at > selected.created_at - || (candidate.created_at == selected.created_at - && candidate.event_id < selected.event_id) - { - candidate - } else { - selected - } - }) -} - -fn normalize_field( - value: Option<String>, - max_chars: usize, - allow_layout_controls: bool, -) -> Result<Option<String>, SafeError> { - let Some(value) = value else { - return Ok(None); - }; - let normalized = value.trim(); - if normalized.is_empty() { - return Ok(None); - } - if normalized.chars().count() > max_chars - || normalized.chars().any(|character| { - character.is_control() - && !(allow_layout_controls && matches!(character, '\n' | '\r' | '\t')) - }) - { - return Err(invalid_profile_metadata()); - } - Ok(Some(normalized.to_owned())) -} - -const fn invalid_profile_metadata() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidProfileMetadata, - SafeMessage::new("The Nostr profile metadata is invalid."), - ) -} - -const fn decode_hex(byte: u8) -> Option<u8> { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use crate::{PublicKey, UnixTimestamp}; - - use super::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0}; - - fn profile(name: &str) -> ProfileMetadata { - ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("valid profile") - } - - fn candidate(id_byte: u8, created_at: i64, name: &str) -> Kind0ProfileCandidate { - Kind0ProfileCandidate::new( - EventId::from_bytes([id_byte; 32]), - PublicKey::from_bytes([7_u8; 32]).expect("valid public key"), - UnixTimestamp::from_seconds(created_at).expect("valid timestamp"), - profile(name), - ) - } - - #[test] - fn profile_fields_are_trimmed_bounded_and_public() { - let metadata = ProfileMetadata::new( - Some(" farmer ".to_owned()), - Some(" Farm Account ".to_owned()), - Some("farmer@example.test".to_owned()), - Some("First line\nSecond line".to_owned()), - Some("https://images.example.test/profile.png".to_owned()), - ) - .expect("valid profile"); - - assert_eq!(metadata.name(), Some("farmer")); - assert_eq!(metadata.display_name(), Some("Farm Account")); - assert_eq!(metadata.preferred_name(), Some("Farm Account")); - assert_eq!(metadata.nip05(), Some("farmer@example.test")); - assert_eq!(metadata.about(), Some("First line\nSecond line")); - assert_eq!( - metadata.picture(), - Some("https://images.example.test/profile.png") - ); - } - - #[test] - fn profile_fields_reject_forbidden_controls_and_oversize_values() { - assert!( - ProfileMetadata::new(Some("bad\0name".to_owned()), None, None, None, None).is_err() - ); - assert!(ProfileMetadata::new(Some("x".repeat(129)), None, None, None, None).is_err()); - } - - #[test] - fn latest_kind0_uses_timestamp_then_lowest_event_id() { - let older = candidate(0, 10, "older"); - let equal_high_id = candidate(9, 20, "high-id"); - let equal_low_id = candidate(1, 20, "low-id"); - - let selected = - select_latest_kind0([older, equal_high_id, equal_low_id]).expect("selected profile"); - - assert_eq!(selected.metadata().name(), Some("low-id")); - assert_eq!(selected.event_id().as_bytes(), [1_u8; 32]); - assert_eq!( - selected.author(), - PublicKey::from_bytes([7_u8; 32]).expect("valid public key") - ); - assert_eq!(selected.created_at().as_seconds(), 20); - } - - #[test] - fn event_id_rejects_noncanonical_hex_and_round_trips() { - let hex = "12".repeat(32); - let event_id = EventId::from_hex(&hex).expect("valid event id"); - - assert_eq!(event_id.to_hex(), hex); - assert!(EventId::from_hex(&"GG".repeat(32)).is_err()); - } -} diff --git a/crates/studio_domain/src/relay.rs b/crates/studio_domain/src/relay.rs @@ -1,198 +0,0 @@ -//! Validated Nostr relay values. - -use std::collections::HashSet; -use std::fmt::{self, Display, Formatter}; - -use url::{Host, Url}; - -use crate::{SafeError, SafeErrorCode, SafeMessage}; - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub enum RelayDestinationPolicy { - Public, - Local, - PrivateNetwork, -} - -#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct RelayUrl { - value: String, - policy: RelayDestinationPolicy, -} - -impl RelayUrl { - /// Parses and normalizes an allowed WebSocket relay URL. - /// - /// # Errors - /// - /// Returns a safe configuration error for empty or malformed input, - /// forbidden schemes, credentials, fragments, or non-loopback `ws://`. - pub fn parse(value: &str, policy: RelayDestinationPolicy) -> Result<Self, SafeError> { - let trimmed = value.trim(); - if trimmed.is_empty() || trimmed.chars().any(char::is_control) { - return Err(invalid_relay()); - } - - let parsed = Url::parse(trimmed).map_err(|_| invalid_relay())?; - if !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.fragment().is_some() - { - return Err(invalid_relay()); - } - - match (policy, parsed.scheme()) { - (RelayDestinationPolicy::Public | RelayDestinationPolicy::PrivateNetwork, "wss") => {} - (RelayDestinationPolicy::Local, "ws" | "wss") if is_loopback(&parsed) => {} - _ => return Err(invalid_relay()), - } - - if parsed.host().is_none() { - return Err(invalid_relay()); - } - - Ok(Self { - value: parsed.to_string(), - policy, - }) - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.value - } - - #[must_use] - pub const fn policy(&self) -> RelayDestinationPolicy { - self.policy - } -} - -impl Display for RelayUrl { - fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.value) - } -} - -/// Parses relay values and removes duplicates without changing first-seen order. -/// -/// # Errors -/// -/// Returns the first safe relay validation error. -pub fn normalize_relay_urls<I, S>( - values: I, - policy: RelayDestinationPolicy, -) -> Result<Vec<RelayUrl>, SafeError> -where - I: IntoIterator<Item = S>, - S: AsRef<str>, -{ - let mut seen = HashSet::new(); - let mut relays = Vec::new(); - for value in values { - let relay = RelayUrl::parse(value.as_ref(), policy)?; - if seen.insert(relay.clone()) { - relays.push(relay); - } - } - Ok(relays) -} - -fn is_loopback(url: &Url) -> bool { - match url.host() { - Some(Host::Domain(domain)) => domain == "localhost", - Some(Host::Ipv4(address)) => address.octets()[0] == 127, - Some(Host::Ipv6(address)) => address.is_loopback(), - None => false, - } -} - -const fn invalid_relay() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidRelayConfiguration, - SafeMessage::new("The Nostr relay URL is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use super::{RelayDestinationPolicy, RelayUrl, normalize_relay_urls}; - use crate::SafeErrorCode; - - #[test] - fn relay_accepts_secure_remote_and_loopback_development_urls() { - for (input, policy, expected) in [ - ( - " wss://Relay.Example/path ", - RelayDestinationPolicy::Public, - "wss://relay.example/path", - ), - ( - "ws://localhost:8080", - RelayDestinationPolicy::Local, - "ws://localhost:8080/", - ), - ( - "ws://127.42.1.9:8080", - RelayDestinationPolicy::Local, - "ws://127.42.1.9:8080/", - ), - ( - "ws://[::1]:8080", - RelayDestinationPolicy::Local, - "ws://[::1]:8080/", - ), - ] { - let relay = RelayUrl::parse(input, policy).expect("allowed relay"); - assert_eq!(relay.as_str(), expected); - assert_eq!(relay.to_string(), expected); - } - } - - #[test] - fn relay_rejects_non_websocket_credentials_fragments_and_remote_plaintext() { - for input in [ - "", - "https://relay.example", - "http://localhost:8080", - "wss://user:password@relay.example", - "wss://relay.example/#fragment", - "ws://relay.example", - "ws://192.168.1.2:8080", - "ws://localhost.evil.example:8080", - "wss://relay.example/\nunsafe", - ] { - let error = RelayUrl::parse(input, RelayDestinationPolicy::Public) - .expect_err("forbidden relay"); - assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); - } - } - - #[test] - fn relay_deduplication_preserves_normalized_first_seen_order() { - let relays = normalize_relay_urls( - [ - "wss://relay.example", - " wss://second.example/path ", - "wss://RELAY.example/", - "wss://second.example/path", - ], - RelayDestinationPolicy::Public, - ) - .expect("valid relays"); - - assert_eq!( - relays.iter().map(RelayUrl::as_str).collect::<Vec<_>>(), - vec!["wss://relay.example/", "wss://second.example/path"] - ); - } - - #[test] - fn relay_destination_policy_is_explicit_and_fail_closed() { - assert!(RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Public).is_err()); - assert!(RelayUrl::parse("wss://relay.example", RelayDestinationPolicy::Local).is_err()); - let private = RelayUrl::parse("wss://10.0.0.4", RelayDestinationPolicy::PrivateNetwork) - .expect("explicit private network"); - assert_eq!(private.policy(), RelayDestinationPolicy::PrivateNetwork); - } -} diff --git a/crates/studio_domain/src/time.rs b/crates/studio_domain/src/time.rs @@ -1,36 +0,0 @@ -//! Time values shared by account and profile records. - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct UnixTimestamp(i64); - -impl UnixTimestamp { - pub const UNIX_EPOCH: Self = Self(0); - - #[must_use] - pub const fn from_seconds(seconds: i64) -> Option<Self> { - if seconds < 0 { - None - } else { - Some(Self(seconds)) - } - } - - #[must_use] - pub const fn as_seconds(self) -> i64 { - self.0 - } -} - -#[cfg(test)] -mod tests { - use super::UnixTimestamp; - - #[test] - fn timestamp_rejects_negative_seconds() { - assert_eq!(UnixTimestamp::from_seconds(-1), None); - assert_eq!( - UnixTimestamp::from_seconds(0).map(UnixTimestamp::as_seconds), - Some(0) - ); - } -} diff --git a/crates/studio_ffi/Cargo.toml b/crates/studio_ffi/Cargo.toml @@ -1,45 +0,0 @@ -[package] -name = "radroots_studio_ffi" -description = "Private native FFI boundary for Radroots Studio" -version = "0.1.0-alpha" -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license = "GPL-3.0-only" -repository.workspace = true -homepage.workspace = true -publish = false -build = "build.rs" -include = ["build.rs", "src/**", "uniffi.toml", "Cargo.toml"] - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -directories = "=6.0.0" -radroots_studio_application.workspace = true -radroots_studio_domain.workspace = true -radroots_studio_nostr.workspace = true -radroots_studio_runtime.workspace = true -radroots_studio_storage.workspace = true -tokio = { version = "=1.47.1", features = [ - "macros", - "rt-multi-thread", - "sync", - "time", -] } -uniffi = "=0.32.0" - -[build-dependencies] -quote.workspace = true -sha2.workspace = true -syn.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" } -tempfile = "=3.23.0" - -[lints] -workspace = true diff --git a/crates/studio_ffi/build.rs b/crates/studio_ffi/build.rs @@ -1,95 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use quote::ToTokens; -use sha2::{Digest, Sha256}; -use syn::{ImplItem, Item, Visibility}; - -const CONTRACT_SOURCES: &[&str] = &[ - "src/commands.rs", - "src/contract.rs", - "src/dto.rs", - "src/lib.rs", - "src/observer.rs", -]; - -fn main() { - for source in CONTRACT_SOURCES { - println!("cargo:rerun-if-changed={source}"); - } - println!("cargo:rerun-if-changed=../studio_storage/migrations"); - - let mut metadata = Vec::new(); - for source in CONTRACT_SOURCES { - collect_public_metadata(Path::new(source), &mut metadata); - } - let mut migrations = fs::read_dir("../studio_storage/migrations") - .expect("read Studio migration catalog") - .map(|entry| entry.expect("read migration entry").path()) - .filter(|path| path.extension().is_some_and(|extension| extension == "sql")) - .collect::<Vec<_>>(); - migrations.sort(); - for migration in migrations { - metadata.push(format!( - "migration:{}:{}", - migration - .file_name() - .expect("migration filename") - .to_string_lossy(), - hex_digest(&fs::read(&migration).expect("read migration")) - )); - } - metadata.sort(); - metadata.dedup(); - let normalized = metadata.join("\n"); - println!( - "cargo:rustc-env=RADROOTS_STUDIO_FFI_CONTRACT_DIGEST={}", - hex_digest(normalized.as_bytes()) - ); - fs::write( - PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")) - .join("ffi_contract_metadata.txt"), - normalized, - ) - .expect("write normalized FFI metadata"); -} - -fn collect_public_metadata(path: &Path, output: &mut Vec<String>) { - let source = fs::read_to_string(path).expect("read FFI source"); - let file = syn::parse_file(&source).expect("parse FFI source"); - for item in file.items { - match item { - Item::Const(item) if is_public(&item.vis) => push_tokens("const", item, output), - Item::Enum(item) if is_public(&item.vis) => push_tokens("enum", item, output), - Item::Fn(item) if is_public(&item.vis) => push_tokens("fn", item.sig, output), - Item::Struct(item) if is_public(&item.vis) => push_tokens("struct", item, output), - Item::Trait(item) if is_public(&item.vis) => push_tokens("trait", item, output), - Item::Impl(item) => { - let owner = item.self_ty.to_token_stream().to_string(); - for member in item.items { - if let ImplItem::Fn(function) = member - && is_public(&function.vis) - { - output.push(format!("method:{owner}:{}", function.sig.to_token_stream())); - } - } - } - _ => {} - } - } -} - -fn push_tokens(kind: &str, value: impl ToTokens, output: &mut Vec<String>) { - output.push(format!("{kind}:{}", value.to_token_stream())); -} - -const fn is_public(visibility: &Visibility) -> bool { - matches!(visibility, Visibility::Public(_)) -} - -fn hex_digest(bytes: &[u8]) -> String { - Sha256::digest(bytes) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs @@ -1,1122 +0,0 @@ -use std::collections::BTreeMap; -use std::fmt::{self, Display, Formatter}; -use std::num::NonZeroUsize; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use directories::ProjectDirs; -use radroots_studio_application::{ - Clock, DurableRequestId, GeneratedKeyRecoveryHandle, RelayConfiguration, RelayRuntimeMode, - RemovalConfirmationToken, relay_configuration_from_environment, -}; -use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; -use radroots_studio_nostr::SdkNostrClient; -use radroots_studio_runtime::{ - RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, -}; -use radroots_studio_storage::OsKeyringSecretStore; - -use crate::{ - AccountDto, AppSnapshotDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, - contract::{ - FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, MINIMUM_SCHEMA_VERSION, - PRODUCT_VERSION, - }, - dto::error_policy, -}; - -const DATABASE_QUALIFIER: &str = "org"; -const DATABASE_ORGANIZATION: &str = "radroots"; -const DATABASE_APPLICATION: &str = "studio"; -const DATABASE_FILENAME: &str = "studio.sqlite3"; -const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = "RADROOTS_STUDIO_DEVELOPMENT_DATA_DIR"; -pub(crate) const ACTOR_MAILBOX_CAPACITY: usize = 64; -const MAX_COMMAND_DEADLINE_MILLIS: u64 = 30_000; - -#[derive(Clone, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct RequestContextDto { - pub request_id: String, - pub expected_revision: u64, - pub deadline_millis: u64, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct AccountCommandReceiptDto { - pub request_id: String, - pub committed_revision: u64, - pub snapshot: AppSnapshotDto, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct CompatibilityDescriptor { - pub product_version: String, - pub cargo_package_version: String, - pub contract_major: u16, - pub contract_minor: u16, - pub contract_hash: String, - pub minimum_schema_version: u32, - pub current_schema_version: u32, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct CompatibilityExpectation { - pub contract_major: u16, - pub minimum_contract_minor: u16, - pub contract_hash: String, - pub minimum_schema_version: u32, - pub maximum_schema_version: u32, -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn compatibility_descriptor() -> CompatibilityDescriptor { - CompatibilityDescriptor { - product_version: PRODUCT_VERSION.to_owned(), - cargo_package_version: env!("CARGO_PKG_VERSION").to_owned(), - contract_major: FFI_CONTRACT_MAJOR, - contract_minor: FFI_CONTRACT_MINOR, - contract_hash: FFI_CONTRACT_HASH.to_owned(), - minimum_schema_version: MINIMUM_SCHEMA_VERSION, - current_schema_version: radroots_studio_storage::CURRENT_SCHEMA_VERSION, - } -} - -#[derive(Debug)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Error))] -pub enum StudioError { - Failure { - code: WireErrorCode, - category: WireErrorCategory, - retryable: bool, - recovery_action: WireRecoveryAction, - correlation_id: Option<String>, - safe_message: String, - }, -} - -impl Display for StudioError { - fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - match self { - Self::Failure { safe_message, .. } => formatter.write_str(safe_message), - } - } -} - -impl std::error::Error for StudioError {} - -impl From<SafeError> for StudioError { - fn from(error: SafeError) -> Self { - let (category, retryable, recovery_action) = error_policy(error.code()); - Self::Failure { - code: error.code().into(), - category, - retryable, - recovery_action, - correlation_id: None, - safe_message: error.message().as_str().to_owned(), - } - } -} - -impl StudioError { - fn correlated(error: SafeError, correlation_id: &str) -> Self { - let (category, retryable, recovery_action) = error_policy(error.code()); - Self::Failure { - code: error.code().into(), - category, - retryable, - recovery_action, - correlation_id: Some(correlation_id.to_owned()), - safe_message: error.message().as_str().to_owned(), - } - } -} - -#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))] -pub struct GeneratedRecoveryRequest { - handle: GeneratedKeyRecoveryHandle, - resolved: AtomicBool, -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -impl GeneratedRecoveryRequest { - pub fn account(&self) -> AccountDto { - self.handle.view().account().into() - } - - pub fn expires_at_seconds(&self) -> i64 { - self.handle.view().expires_at().as_seconds() - } - - /// Returns the recovery secret exactly once. - /// - /// # Errors - /// - /// Returns a safe unavailable error after the first read. - pub fn take_recovery_nsec(&self) -> Result<String, StudioError> { - self.handle - .take_recovery_nsec() - .map(|nsec| nsec.with_exposed_secret(str::to_owned)) - .map_err(StudioError::from) - } -} - -#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))] -pub struct RemovalRequest { - public_key_hex: String, - deletes_local_credential: bool, - signs_out: bool, - expires_at_seconds: i64, - token: Mutex<Option<RemovalConfirmationToken>>, -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -impl RemovalRequest { - pub fn public_key_hex(&self) -> String { - self.public_key_hex.clone() - } - - pub fn deletes_local_credential(&self) -> bool { - self.deletes_local_credential - } - - pub fn signs_out(&self) -> bool { - self.signs_out - } - - pub fn expires_at_seconds(&self) -> i64 { - self.expires_at_seconds - } -} - -pub(crate) struct RuntimeCore { - pub(crate) actor: RuntimeActorHandle, - pub(crate) observers: Mutex< - BTreeMap< - radroots_studio_application::ChangeSubscriptionId, - Option<tokio::task::JoinHandle<()>>, - >, - >, - pub(crate) closed: AtomicBool, - pub(crate) startup_relay_problem: Option<SafeError>, -} - -impl RuntimeCore { - pub(crate) fn snapshot_dto(&self) -> AppSnapshotDto { - AppSnapshotDto::from_runtime(&self.actor.snapshot(), self.effective_lifecycle()) - } - - pub(crate) fn dto_for( - &self, - snapshot: &radroots_studio_application::AppSnapshot, - ) -> AppSnapshotDto { - AppSnapshotDto::from_runtime(snapshot, self.effective_lifecycle()) - } - - pub(crate) fn effective_lifecycle(&self) -> radroots_studio_application::RuntimeLifecycle { - let lifecycle = self.actor.lifecycle(); - match (lifecycle, self.startup_relay_problem) { - (radroots_studio_application::RuntimeLifecycle::Ready, Some(problem)) => { - radroots_studio_application::RuntimeLifecycle::Degraded(problem) - } - _ => lifecycle, - } - } -} - -#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))] -pub struct StudioAppCore { - pub(crate) inner: Arc<RuntimeCore>, -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -impl StudioAppCore { - /// Verifies the static contract before touching the application data path. - /// - /// # Errors - /// - /// Returns a safe compatibility error without opening or migrating storage. - #[cfg_attr(not(coverage_nightly), uniffi::constructor)] - #[allow(clippy::needless_pass_by_value)] - pub fn open_compatible( - expectation: CompatibilityExpectation, - development_mode: bool, - ) -> Result<Arc<Self>, StudioError> { - let path = application_database_path(development_mode)?; - Self::open_path_compatible(&path, &expectation, development_mode) - } - - /// Restores durable public application state. - /// - /// # Errors - /// - /// Returns a safe storage, recovery, or application-state error. - pub async fn bootstrap(&self) -> Result<AppSnapshotDto, StudioError> { - self.inner - .actor - .bootstrap() - .await - .map(|snapshot| self.inner.dto_for(&snapshot)) - .map_err(StudioError::from) - } - - #[must_use] - pub fn snapshot(&self) -> AppSnapshotDto { - self.inner.snapshot_dto() - } - - /// Begins the exclusive generated-account recovery flow without persistence. - /// - /// # Errors - /// - /// Returns a safe key-generation, conflict, timeout, or lifecycle error. - pub async fn begin_generated_account_v2( - &self, - ) -> Result<Arc<GeneratedRecoveryRequest>, StudioError> { - self.inner - .actor - .begin_generated_key_stage() - .await - .map(|handle| { - Arc::new(GeneratedRecoveryRequest { - handle, - resolved: AtomicBool::new(false), - }) - }) - .map_err(StudioError::from) - } - - /// Acknowledges recovery and commits the generated account once. - /// - /// # Errors - /// - /// Returns a terminal safe recovery, credential, persistence, timeout, or lifecycle error. - /// A failed commit must be recovered by importing the already-saved recovery key. - pub async fn acknowledge_generated_account_v2( - &self, - context: RequestContextDto, - request: Arc<GeneratedRecoveryRequest>, - ) -> Result<AppSnapshotDto, StudioError> { - if request.resolved.swap(true, Ordering::AcqRel) { - return Err(generated_recovery_expired()); - } - let request_id = DurableRequestId::parse(context.request_id.clone()) - .map_err(|error| StudioError::correlated(error, &context.request_id))?; - let timeout = command_timeout(context.deadline_millis, &context.request_id)?; - self.inner - .actor - .acknowledge_generated_key_stage( - request.handle.id(), - request_id, - radroots_studio_application::SnapshotRevision::from_value( - context.expected_revision, - ), - timeout, - ) - .await - .map(|snapshot| self.inner.dto_for(&snapshot)) - .map_err(generated_commit_failed) - } - - /// Cancels the exclusive generated-account recovery flow. - /// - /// # Errors - /// - /// Returns a safe timeout or lifecycle error. - pub async fn cancel_generated_account_v2( - &self, - request: Arc<GeneratedRecoveryRequest>, - ) -> Result<bool, StudioError> { - if request.resolved.swap(true, Ordering::AcqRel) { - return Ok(false); - } - self.inner - .actor - .cancel_generated_key_stage() - .await - .map_err(StudioError::from) - } - - /// Imports or repairs an account using a caller-owned idempotency key. - /// - /// # Errors - /// - /// Returns a correlated validation, conflict, timeout, credential, or storage error. - pub async fn import_account_v2( - &self, - context: RequestContextDto, - secret_key: Vec<u8>, - ) -> Result<AccountCommandReceiptDto, StudioError> { - let request_id = DurableRequestId::parse(context.request_id.clone()) - .map_err(|error| StudioError::correlated(error, &context.request_id))?; - let timeout = command_timeout(context.deadline_millis, &context.request_id)?; - let input = SecretKeyInput::parse_bytes(secret_key) - .map_err(|error| StudioError::correlated(error, &context.request_id))?; - self.inner - .actor - .import_secret_key( - request_id, - radroots_studio_application::SnapshotRevision::from_value( - context.expected_revision, - ), - input, - timeout, - ) - .await - .map(|_| { - let snapshot = self.inner.snapshot_dto(); - AccountCommandReceiptDto { - request_id: context.request_id.clone(), - committed_revision: snapshot.revision, - snapshot, - } - }) - .map_err(|error| StudioError::correlated(error, &context.request_id)) - } - - /// Selects one saved account without activating it. - /// - /// # Errors - /// - /// Returns a safe public-key, account, or storage error. - pub async fn select_account( - &self, - public_key_hex: String, - ) -> Result<AppSnapshotDto, StudioError> { - let public_key = parse_public_key(&public_key_hex)?; - self.inner - .actor - .select_account(public_key) - .await - .map(|snapshot| self.inner.dto_for(&snapshot)) - .map_err(StudioError::from) - } - - /// Activates one saved account after validating its credential. - /// - /// # Errors - /// - /// Returns a safe public-key, credential, account, or storage error. - pub async fn activate_account( - &self, - public_key_hex: String, - ) -> Result<AppSnapshotDto, StudioError> { - let public_key = parse_public_key(&public_key_hex)?; - self.inner - .actor - .activate_account(public_key) - .await - .map(|snapshot| self.inner.dto_for(&snapshot)) - .map_err(StudioError::from) - } - - /// Signs out while retaining accounts and credentials. - /// - /// # Errors - /// - /// Returns a safe application-state error. - pub async fn sign_out(&self) -> Result<AppSnapshotDto, StudioError> { - self.inner - .actor - .sign_out() - .await - .map(|snapshot| self.inner.dto_for(&snapshot)) - .map_err(StudioError::from) - } - - /// Refreshes the active Nostr profile from configured relays. - /// - /// # Errors - /// - /// Returns a safe storage or application-state error. - pub async fn refresh_active_profile(&self) -> Result<AppSnapshotDto, StudioError> { - self.inner - .actor - .refresh_active_profile() - .await - .map(|snapshot| self.inner.dto_for(&snapshot)) - .map_err(StudioError::from) - } - - /// Issues a revision-bound removal confirmation object. - /// - /// # Errors - /// - /// Returns a safe public-key or account error. - pub async fn request_account_removal( - &self, - public_key_hex: String, - ) -> Result<Arc<RemovalRequest>, StudioError> { - let public_key = parse_public_key(&public_key_hex)?; - self.inner - .actor - .request_account_removal(public_key) - .await - .map(|token| { - let impact = token.impact(); - Arc::new(RemovalRequest { - public_key_hex, - deletes_local_credential: impact.deletes_local_credential(), - signs_out: impact.signs_out(), - expires_at_seconds: token.expires_at().as_seconds(), - token: Mutex::new(Some(token)), - }) - }) - .map_err(StudioError::from) - } - - /// Permanently removes the account represented by a one-time request. - /// - /// # Errors - /// - /// Returns a safe confirmation, credential, recovery, or storage error. - pub async fn confirm_account_removal( - &self, - context: RequestContextDto, - request: Arc<RemovalRequest>, - ) -> Result<AppSnapshotDto, StudioError> { - let token = request - .token - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take() - .ok_or_else(confirmation_expired)?; - let request_id = DurableRequestId::parse(context.request_id.clone()) - .map_err(|error| StudioError::correlated(error, &context.request_id))?; - let timeout = command_timeout(context.deadline_millis, &context.request_id)?; - self.inner - .actor - .confirm_account_removal( - token, - request_id, - radroots_studio_application::SnapshotRevision::from_value( - context.expected_revision, - ), - timeout, - ) - .await - .map(|snapshot| self.inner.dto_for(&snapshot)) - .map_err(StudioError::from) - } -} - -fn verify_compatibility(expectation: &CompatibilityExpectation) -> Result<(), StudioError> { - let actual = compatibility_descriptor(); - if expectation.contract_major != actual.contract_major - || expectation.minimum_contract_minor > actual.contract_minor - || expectation.contract_hash != actual.contract_hash - || expectation.minimum_schema_version > actual.current_schema_version - || expectation.maximum_schema_version < actual.minimum_schema_version - { - return Err(compatibility_mismatch()); - } - Ok(()) -} - -impl StudioAppCore { - fn open_path_compatible( - path: &Path, - expectation: &CompatibilityExpectation, - development_mode: bool, - ) -> Result<Arc<Self>, StudioError> { - verify_compatibility(expectation)?; - std::fs::create_dir_all(path.parent().ok_or_else(path_unavailable)?) - .map_err(|_| path_unavailable())?; - Self::open_path(path, development_mode) - } - - // The concrete product opener binds operating-system paths, keyrings, and - // SQLite ownership. Platform installation lanes exercise this adapter; - // deterministic coverage owns the compatibility and runtime policies. - #[cfg_attr(coverage_nightly, coverage(off))] - fn open_path(path: &Path, development_mode: bool) -> Result<Arc<Self>, StudioError> { - let mode = if development_mode { - RelayRuntimeMode::Development - } else { - RelayRuntimeMode::Packaged - }; - let (relays, startup_relay_problem) = - local_first_relay_configuration(relay_configuration_from_environment(mode)); - let runtime = runtime()?; - let actor = runtime.block_on(RuntimeActorHandle::open( - path, - relays, - RuntimeDependencies::new( - Arc::new(OsKeyringSecretStore::default()), - Arc::new(SystemClock), - Arc::new(SdkNostrClient::new(Duration::from_secs(5))), - Arc::new(UuidInstallationIdentitySource), - ), - actor_mailbox_capacity()?, - runtime.handle(), - ))?; - Ok(Arc::new(Self { - inner: Arc::new(RuntimeCore { - actor, - observers: Mutex::new(BTreeMap::new()), - closed: AtomicBool::new(false), - startup_relay_problem, - }), - })) - } -} - -fn local_first_relay_configuration( - configured: Result<RelayConfiguration, SafeError>, -) -> (RelayConfiguration, Option<SafeError>) { - match configured { - Ok(relays) => (relays, None), - Err(problem) => (RelayConfiguration::default(), Some(problem)), - } -} - -#[derive(Clone, Copy)] -pub(crate) struct SystemClock; - -impl Clock for SystemClock { - fn now(&self) -> UnixTimestamp { - let seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |duration| { - i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) - }); - UnixTimestamp::from_seconds(seconds).unwrap_or(UnixTimestamp::UNIX_EPOCH) - } -} - -// ProjectDirs and the process environment are host integration boundaries. -#[cfg_attr(coverage_nightly, coverage(off))] -fn application_database_path(development_mode: bool) -> Result<PathBuf, StudioError> { - if development_mode && let Some(directory) = std::env::var_os(DEVELOPMENT_DATA_DIR_ENVIRONMENT) - { - return Ok(PathBuf::from(directory).join(DATABASE_FILENAME)); - } - ProjectDirs::from( - DATABASE_QUALIFIER, - DATABASE_ORGANIZATION, - DATABASE_APPLICATION, - ) - .map(|project| project.data_dir().join(DATABASE_FILENAME)) - .ok_or_else(path_unavailable) -} - -fn parse_public_key(value: &str) -> Result<PublicKey, StudioError> { - PublicKey::from_hex(value).map_err(StudioError::from) -} - -fn command_timeout(millis: u64, correlation_id: &str) -> Result<Duration, StudioError> { - if millis == 0 || millis > MAX_COMMAND_DEADLINE_MILLIS { - return Err(StudioError::Failure { - code: WireErrorCode::InvalidApplicationState, - category: WireErrorCategory::Input, - retryable: false, - recovery_action: WireRecoveryAction::None, - correlation_id: Some(correlation_id.to_owned()), - safe_message: "The command deadline is invalid.".to_owned(), - }); - } - Ok(Duration::from_millis(millis)) -} - -pub(crate) fn runtime() -> Result<&'static tokio::runtime::Runtime, StudioError> { - static RUNTIME: OnceLock<Result<tokio::runtime::Runtime, ()>> = OnceLock::new(); - RUNTIME - .get_or_init(|| { - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .thread_name("radroots-studio-core") - .build() - .map_err(|_| ()) - }) - .as_ref() - .map_err(|()| runtime_unavailable()) -} - -fn actor_mailbox_capacity() -> Result<NonZeroUsize, StudioError> { - NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).ok_or_else(runtime_unavailable) -} - -fn runtime_unavailable() -> StudioError { - StudioError::Failure { - code: WireErrorCode::InvalidApplicationState, - category: WireErrorCategory::Lifecycle, - retryable: true, - recovery_action: WireRecoveryAction::RestartApplication, - correlation_id: None, - safe_message: "The application runtime is unavailable.".to_owned(), - } -} - -fn path_unavailable() -> StudioError { - StudioError::Failure { - code: WireErrorCode::StorageUnavailable, - category: WireErrorCategory::Storage, - retryable: true, - recovery_action: WireRecoveryAction::RestartApplication, - correlation_id: None, - safe_message: "The application data directory is unavailable.".to_owned(), - } -} - -fn confirmation_expired() -> StudioError { - StudioError::Failure { - code: WireErrorCode::InvalidApplicationState, - category: WireErrorCategory::Lifecycle, - retryable: false, - recovery_action: WireRecoveryAction::None, - correlation_id: None, - safe_message: "The account removal confirmation is no longer valid.".to_owned(), - } -} - -fn generated_recovery_expired() -> StudioError { - StudioError::Failure { - code: WireErrorCode::InvalidApplicationState, - category: WireErrorCategory::Lifecycle, - retryable: false, - recovery_action: WireRecoveryAction::None, - correlation_id: None, - safe_message: "The generated-key recovery step is no longer valid.".to_owned(), - } -} - -fn generated_commit_failed(error: SafeError) -> StudioError { - let (category, _, _) = error_policy(error.code()); - StudioError::Failure { - code: error.code().into(), - category, - retryable: false, - recovery_action: WireRecoveryAction::None, - correlation_id: None, - safe_message: - "The generated account could not be saved. Import the recovery key you saved to try again." - .to_owned(), - } -} - -fn compatibility_mismatch() -> StudioError { - StudioError::Failure { - code: WireErrorCode::CompatibilityMismatch, - category: WireErrorCategory::Compatibility, - retryable: false, - recovery_action: WireRecoveryAction::UpdateApplication, - correlation_id: None, - safe_message: "The application and native runtime are incompatible.".to_owned(), - } -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use std::num::NonZeroUsize; - use std::sync::Arc; - - use radroots_studio_application::{InMemorySecretStore, RelayConfiguration}; - use radroots_studio_domain::SafeError; - use radroots_studio_nostr::SdkNostrClient; - use radroots_studio_runtime::{ - RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, - }; - - use radroots_studio_storage::{CREDENTIAL_SERVICE, CURRENT_SCHEMA_VERSION}; - - use super::{ - ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME, - DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, - FFI_CONTRACT_MINOR, RequestContextDto, RuntimeCore, StudioAppCore, StudioError, - SystemClock, WireErrorCategory, WireErrorCode, WireRecoveryAction, actor_mailbox_capacity, - compatibility_descriptor, confirmation_expired, generated_commit_failed, - local_first_relay_configuration, path_unavailable, runtime, runtime_unavailable, - verify_compatibility, - }; - - async fn in_memory_core() -> Arc<StudioAppCore> { - let actor = RuntimeActorHandle::in_memory( - RelayConfiguration::default(), - RuntimeDependencies::new( - Arc::new(InMemorySecretStore::default()), - Arc::new(SystemClock), - Arc::new(SdkNostrClient::new(std::time::Duration::from_millis(10))), - Arc::new(UuidInstallationIdentitySource), - ), - NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("capacity"), - runtime().expect("runtime").handle(), - ) - .await - .expect("in-memory actor"); - Arc::new(StudioAppCore { - inner: Arc::new(RuntimeCore { - actor, - observers: std::sync::Mutex::new(std::collections::BTreeMap::new()), - closed: std::sync::atomic::AtomicBool::new(false), - startup_relay_problem: None, - }), - }) - } - - #[tokio::test] - async fn exported_bootstrap_and_snapshot_are_revisioned() { - let core = in_memory_core().await; - let bootstrapped = core.bootstrap().await.expect("bootstrap"); - let current = core.snapshot(); - - assert_eq!(bootstrapped, current); - assert_eq!(current.revision, 1); - } - - #[tokio::test] - async fn request_context_import_replays_one_committed_receipt() { - let core = in_memory_core().await; - let initial = core.snapshot(); - let context = RequestContextDto { - request_id: "ffi-test-import-1".to_owned(), - expected_revision: initial.revision, - deadline_millis: 5_000, - }; - let secret = b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - let first = core - .import_account_v2(context.clone(), secret.to_vec()) - .await - .expect("first import"); - let replay = core - .import_account_v2(context, secret.to_vec()) - .await - .expect("replayed import"); - - assert_eq!(first, replay); - assert_eq!(first.snapshot.accounts.len(), 1); - assert_eq!(first.request_id, "ffi-test-import-1"); - } - - #[tokio::test] - async fn generated_recovery_handle_is_one_use_and_acknowledgement_gated() { - let core = in_memory_core().await; - let initial = core.snapshot(); - let recovery = core - .begin_generated_account_v2() - .await - .expect("begin recovery"); - - assert_eq!(core.snapshot(), initial); - let nsec = recovery.take_recovery_nsec().expect("one-use nsec"); - assert!(nsec.starts_with("nsec1")); - assert!(recovery.take_recovery_nsec().is_err()); - let context = RequestContextDto { - request_id: "ffi-test-generate-1".to_owned(), - expected_revision: initial.revision, - deadline_millis: 5_000, - }; - let committed = core - .acknowledge_generated_account_v2(context.clone(), Arc::clone(&recovery)) - .await - .expect("acknowledge"); - assert_eq!(committed.accounts.len(), 1); - let repeated = core - .acknowledge_generated_account_v2(context, recovery) - .await - .expect_err("repeated acknowledgement"); - assert!(matches!( - repeated, - StudioError::Failure { safe_message, .. } - if safe_message == "The generated-key recovery step is no longer valid." - )); - } - - #[tokio::test] - async fn account_lifecycle_and_one_use_removal_are_exercised_through_the_ffi_boundary() { - let core = in_memory_core().await; - let initial = core.bootstrap().await.expect("bootstrap"); - let imported = core - .import_account_v2( - RequestContextDto { - request_id: "ffi-lifecycle-import".to_owned(), - expected_revision: initial.revision, - deadline_millis: 5_000, - }, - b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_vec(), - ) - .await - .expect("import account"); - let public_key = imported.snapshot.accounts[0].public_key_hex.clone(); - - let selected = core - .select_account(public_key.clone()) - .await - .expect("select account"); - let active = core - .activate_account(public_key.clone()) - .await - .expect("activate account"); - assert!(active.revision > selected.revision); - let signed_out = core.sign_out().await.expect("sign out"); - assert!(signed_out.revision > active.revision); - let refreshed = core - .refresh_active_profile() - .await - .expect("signed-out refresh is a stable no-op"); - assert_eq!(refreshed.revision, signed_out.revision); - - let removal = core - .request_account_removal(public_key.clone()) - .await - .expect("request removal"); - assert_eq!(removal.public_key_hex(), public_key); - assert!(removal.deletes_local_credential()); - assert!(!removal.signs_out()); - assert!(removal.expires_at_seconds() > 0); - let removed = core - .confirm_account_removal( - RequestContextDto { - request_id: "ffi-lifecycle-remove".to_owned(), - expected_revision: signed_out.revision, - deadline_millis: 5_000, - }, - Arc::clone(&removal), - ) - .await - .expect("confirm removal"); - assert!(removed.accounts.is_empty()); - assert!( - core.confirm_account_removal( - RequestContextDto { - request_id: "ffi-lifecycle-remove-repeated".to_owned(), - expected_revision: removed.revision, - deadline_millis: 5_000, - }, - removal, - ) - .await - .is_err() - ); - assert!( - core.select_account("not-a-public-key".to_owned()) - .await - .is_err() - ); - } - - #[tokio::test] - async fn generated_recovery_cancellation_and_request_validation_fail_closed() { - let core = in_memory_core().await; - let recovery = core - .begin_generated_account_v2() - .await - .expect("begin generated account"); - assert_eq!(recovery.account().public_key_hex.len(), 64); - assert!(recovery.expires_at_seconds() > 0); - assert!( - core.cancel_generated_account_v2(Arc::clone(&recovery)) - .await - .expect("first cancellation") - ); - assert!( - !core - .cancel_generated_account_v2(recovery) - .await - .expect("second cancellation") - ); - - for context in [ - RequestContextDto { - request_id: String::new(), - expected_revision: 0, - deadline_millis: 5_000, - }, - RequestContextDto { - request_id: "ffi-zero-deadline".to_owned(), - expected_revision: 0, - deadline_millis: 0, - }, - RequestContextDto { - request_id: "ffi-long-deadline".to_owned(), - expected_revision: 0, - deadline_millis: 30_001, - }, - ] { - assert!(core.import_account_v2(context, vec![0; 32]).await.is_err()); - } - assert!( - core.import_account_v2( - RequestContextDto { - request_id: "ffi-invalid-secret".to_owned(), - expected_revision: 0, - deadline_millis: 5_000, - }, - vec![0; 31], - ) - .await - .is_err() - ); - } - - #[test] - fn boundary_failures_remain_typed_and_secret_safe() { - assert_eq!(actor_mailbox_capacity().expect("capacity").get(), 64); - for (error, code, category, retryable, recovery, message) in [ - ( - runtime_unavailable(), - WireErrorCode::InvalidApplicationState, - WireErrorCategory::Lifecycle, - true, - WireRecoveryAction::RestartApplication, - "The application runtime is unavailable.", - ), - ( - path_unavailable(), - WireErrorCode::StorageUnavailable, - WireErrorCategory::Storage, - true, - WireRecoveryAction::RestartApplication, - "The application data directory is unavailable.", - ), - ( - confirmation_expired(), - WireErrorCode::InvalidApplicationState, - WireErrorCategory::Lifecycle, - false, - WireRecoveryAction::None, - "The account removal confirmation is no longer valid.", - ), - ( - generated_commit_failed(SafeError::new( - radroots_studio_domain::SafeErrorCode::StorageUnavailable, - radroots_studio_domain::SafeMessage::new("internal detail"), - )), - WireErrorCode::StorageUnavailable, - WireErrorCategory::Storage, - false, - WireRecoveryAction::None, - "The generated account could not be saved. Import the recovery key you saved to try again.", - ), - ] { - assert_eq!(error.to_string(), message); - assert!(matches!( - error, - StudioError::Failure { - code: actual_code, - category: actual_category, - retryable: actual_retryable, - recovery_action: actual_recovery, - correlation_id: None, - safe_message, - } if actual_code == code - && actual_category == category - && actual_retryable == retryable - && actual_recovery == recovery - && safe_message == message - )); - } - } - - #[test] - fn compatibility_matrix_rejects_before_storage_mutation() { - let actual = compatibility_descriptor(); - let compatible = CompatibilityExpectation { - contract_major: FFI_CONTRACT_MAJOR, - minimum_contract_minor: FFI_CONTRACT_MINOR, - contract_hash: FFI_CONTRACT_HASH.to_owned(), - minimum_schema_version: 5, - maximum_schema_version: CURRENT_SCHEMA_VERSION, - }; - verify_compatibility(&compatible).expect("compatible"); - - for incompatible in [ - CompatibilityExpectation { - contract_major: FFI_CONTRACT_MAJOR + 1, - ..compatible.clone() - }, - CompatibilityExpectation { - minimum_contract_minor: FFI_CONTRACT_MINOR + 1, - ..compatible.clone() - }, - CompatibilityExpectation { - contract_hash: "wrong-contract".to_owned(), - ..compatible.clone() - }, - CompatibilityExpectation { - minimum_schema_version: actual.current_schema_version + 1, - ..compatible.clone() - }, - CompatibilityExpectation { - maximum_schema_version: actual.minimum_schema_version - 1, - ..compatible.clone() - }, - ] { - assert!(verify_compatibility(&incompatible).is_err()); - } - - let directory = tempfile::tempdir().expect("directory"); - let rejected = directory.path().join("rejected").join("studio.sqlite3"); - let incompatible = CompatibilityExpectation { - contract_major: FFI_CONTRACT_MAJOR + 1, - ..compatible - }; - assert!(StudioAppCore::open_path_compatible(&rejected, &incompatible, true).is_err()); - assert!(!rejected.parent().expect("parent").exists()); - } - - #[test] - fn v5_compatibility_fixture_preserves_external_coordinates() { - let fixture = - include_str!("../../../contracts/compatibility/studio/v5-baseline.properties"); - let property = |key: &str| { - fixture.lines().find_map(|line| { - line.split_once('=') - .filter(|(candidate, _)| *candidate == key) - .map(|(_, value)| value) - }) - }; - - assert_eq!(property("baseline.id"), Some("studio-runtime-v5")); - assert_eq!(property("schema.version"), Some("5")); - assert_eq!(CURRENT_SCHEMA_VERSION, 10); - assert_eq!(property("ffi.contract"), Some("legacy-unversioned-v1")); - assert_eq!(property("ffi.snapshot.schema"), Some("1")); - assert_eq!(property("ffi.runtime.version"), Some("0.1.0-alpha")); - assert_eq!(property("database.qualifier"), Some(DATABASE_QUALIFIER)); - assert_eq!( - property("database.organization"), - Some(DATABASE_ORGANIZATION) - ); - assert_eq!(property("database.application"), Some(DATABASE_APPLICATION)); - assert_eq!(property("database.filename"), Some(DATABASE_FILENAME)); - assert_eq!(property("keyring.service"), Some(CREDENTIAL_SERVICE)); - assert_eq!( - property("keyring.account"), - Some("canonical-lowercase-public-key-hex") - ); - } - - #[test] - fn superseded_v1_ffi_commands_are_absent() { - let commands = include_str!("commands.rs"); - let observer = include_str!("observer.rs"); - for forbidden in [ - format!("pub async fn {}_account(", "generate"), - format!("pub async fn {}_secret_key(", "import"), - format!("pub fn {}(development_mode", "open"), - format!("pub async fn {}(", "subscribe"), - format!("pub fn {}(&self)", "shutdown"), - ] { - assert!(!commands.contains(&forbidden)); - assert!(!observer.contains(&forbidden)); - } - } - - #[test] - fn invalid_relay_configuration_preserves_local_startup_as_degraded() { - let problem = SafeError::new( - radroots_studio_domain::SafeErrorCode::InvalidRelayConfiguration, - radroots_studio_domain::SafeMessage::new("The Nostr relay configuration is invalid."), - ); - let (relays, degraded) = local_first_relay_configuration(Err(problem)); - - assert!(relays.relays().is_empty()); - assert_eq!(degraded, Some(problem)); - } -} diff --git a/crates/studio_ffi/src/contract.rs b/crates/studio_ffi/src/contract.rs @@ -1,9 +0,0 @@ -pub const PRODUCT_VERSION: &str = "0.1.0-alpha"; -pub const FFI_CONTRACT_MAJOR: u16 = 3; -pub const FFI_CONTRACT_MINOR: u16 = 0; -pub const MINIMUM_SCHEMA_VERSION: u32 = 5; -pub const FFI_CONTRACT_HASH: &str = env!("RADROOTS_STUDIO_FFI_CONTRACT_DIGEST"); - -#[cfg(test)] -pub(crate) const NORMALIZED_CONTRACT_METADATA: &str = - include_str!(concat!(env!("OUT_DIR"), "/ffi_contract_metadata.txt")); diff --git a/crates/studio_ffi/src/dto.rs b/crates/studio_ffi/src/dto.rs @@ -1,729 +0,0 @@ -use radroots_studio_application::{ - ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConnectionState, - RuntimeLifecycle, SessionState, -}; -use radroots_studio_domain::{ - AccountSummary, BindingAvailability, ProfileMetadata, SafeError, SafeErrorCode, -}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] -pub enum WireErrorCode { - InvalidPublicKey, - InvalidSecretKey, - InvalidAccountMetadata, - InvalidProfileMetadata, - InvalidApplicationState, - AccountAlreadyExists, - AccountNotFound, - KeyringUnavailable, - CredentialMissing, - StorageUnavailable, - StorageCorrupt, - StorageQuarantined, - StorageBackupInvalid, - UnsupportedSchemaVersion, - RepairUnauthorized, - PendingOperationRecoveryRequired, - InvalidRelayConfiguration, - RelayConnectionFailed, - ProfileRefreshFailed, - ObserverRegistrationFailed, - NativeLibraryLoadFailed, - CompatibilityMismatch, - Internal, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] -pub enum WireErrorCategory { - Input, - Conflict, - Credential, - Storage, - Network, - Lifecycle, - Compatibility, - Internal, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] -pub enum WireRecoveryAction { - None, - Retry, - RepairCredential, - Authenticate, - RepairStorage, - RestoreBackup, - CheckConfiguration, - RestartApplication, - UpdateApplication, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct SafeErrorDto { - pub code: WireErrorCode, - pub category: WireErrorCategory, - pub retryable: bool, - pub recovery_action: WireRecoveryAction, - pub message: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] -pub enum AppLifecycleDto { - Opening, - CompatibilityChecking, - AcquiringOwnership, - Migrating, - Recovering, - Ready, - Degraded, - Blocked, - ShuttingDown, - Closed, - Fatal, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] -pub enum SessionStateDto { - SignedOut, - Activating, - Active, - SigningOut, - Failed, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] -pub enum RelayConnectionStateDto { - Disconnected, - Connecting, - Connected, - Degraded, - Error, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] -pub enum ProfileLoadStateDto { - Empty, - Loading, - Cached, - Fresh, - Error, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] -pub enum SignerKindDto { - LocalSecret, - WatchOnly, - RemoteNip46, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] -pub enum KeyAvailabilityDto { - Available, - CredentialMissing, - StoreUnavailable, - NotRequired, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct ProfileDto { - pub name: Option<String>, - pub display_name: Option<String>, - pub nip05: Option<String>, - pub about: Option<String>, - pub picture: Option<String>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct AccountDto { - pub public_key_hex: String, - pub npub: String, - pub display_label: String, - pub signer_kind: SignerKindDto, - pub key_availability: KeyAvailabilityDto, - pub created_at_seconds: i64, - pub last_used_at_seconds: Option<i64>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct ActiveAccountDto { - pub account: AccountDto, - pub relay_state: RelayConnectionStateDto, - pub profile_state: ProfileLoadStateDto, - pub profile: Option<ProfileDto>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct AppSnapshotDto { - pub revision: u64, - pub lifecycle: AppLifecycleDto, - pub lifecycle_error: Option<SafeErrorDto>, - pub configured_relays: Vec<String>, - pub accounts: Vec<AccountDto>, - pub selected_public_key_hex: Option<String>, - pub session: SessionStateDto, - pub session_subject_public_key_hex: Option<String>, - pub session_error: Option<SafeErrorDto>, - pub active_account: Option<ActiveAccountDto>, - pub recoverable_problem: Option<SafeErrorDto>, -} - -impl From<&AppSnapshot> for AppSnapshotDto { - fn from(snapshot: &AppSnapshot) -> Self { - let (lifecycle, lifecycle_error) = match snapshot.lifecycle() { - AppLifecycle::Booting => (AppLifecycleDto::Opening, None), - AppLifecycle::Ready => (AppLifecycleDto::Ready, None), - AppLifecycle::Fatal(error) => (AppLifecycleDto::Fatal, Some(error.into())), - }; - let (session, session_subject_public_key_hex, session_error) = match snapshot.session() { - SessionState::SignedOut => (SessionStateDto::SignedOut, None, None), - SessionState::Activating(public_key) => { - (SessionStateDto::Activating, Some(public_key.to_hex()), None) - } - SessionState::Active => (SessionStateDto::Active, None, None), - SessionState::SigningOut => (SessionStateDto::SigningOut, None, None), - SessionState::Failed(error) => (SessionStateDto::Failed, None, Some(error.into())), - }; - Self { - revision: snapshot.revision().value(), - lifecycle, - lifecycle_error, - configured_relays: snapshot - .relay_configuration() - .relays() - .iter() - .map(|relay| relay.as_str().to_owned()) - .collect(), - accounts: snapshot.accounts().iter().map(AccountDto::from).collect(), - selected_public_key_hex: snapshot - .selected_account() - .map(radroots_studio_domain::PublicKey::to_hex), - session, - session_subject_public_key_hex, - session_error, - active_account: snapshot.active_account().map(ActiveAccountDto::from), - recoverable_problem: snapshot.recoverable_problem().map(SafeErrorDto::from), - } - } -} - -impl AppSnapshotDto { - pub(crate) fn from_runtime(snapshot: &AppSnapshot, runtime: RuntimeLifecycle) -> Self { - let mut dto = Self::from(snapshot); - let (lifecycle, problem) = match runtime { - RuntimeLifecycle::Opening => (AppLifecycleDto::Opening, None), - RuntimeLifecycle::CompatibilityChecking => { - (AppLifecycleDto::CompatibilityChecking, None) - } - RuntimeLifecycle::AcquiringOwnership => (AppLifecycleDto::AcquiringOwnership, None), - RuntimeLifecycle::Migrating => (AppLifecycleDto::Migrating, None), - RuntimeLifecycle::Recovering => (AppLifecycleDto::Recovering, None), - RuntimeLifecycle::Ready => (AppLifecycleDto::Ready, None), - RuntimeLifecycle::Degraded(error) => { - (AppLifecycleDto::Degraded, Some(SafeErrorDto::from(error))) - } - RuntimeLifecycle::Blocked(error) => { - (AppLifecycleDto::Blocked, Some(SafeErrorDto::from(error))) - } - RuntimeLifecycle::ShuttingDown => (AppLifecycleDto::ShuttingDown, None), - RuntimeLifecycle::Closed => (AppLifecycleDto::Closed, None), - RuntimeLifecycle::Fatal(error) => { - (AppLifecycleDto::Fatal, Some(SafeErrorDto::from(error))) - } - }; - dto.lifecycle = lifecycle; - dto.lifecycle_error = problem; - dto - } -} - -impl From<&AccountSummary> for AccountDto { - fn from(account: &AccountSummary) -> Self { - Self { - public_key_hex: account.public_key().to_hex(), - npub: account.npub().as_str().to_owned(), - display_label: account.display_label(), - signer_kind: SignerKindDto::LocalSecret, - key_availability: account.signer().availability().into(), - created_at_seconds: account.created_at().timestamp().as_seconds(), - last_used_at_seconds: account - .last_used_at() - .map(radroots_studio_domain::UnixTimestamp::as_seconds), - } - } -} - -impl From<&ActiveAccountSnapshot> for ActiveAccountDto { - fn from(active: &ActiveAccountSnapshot) -> Self { - Self { - account: active.account().into(), - relay_state: active.relay_state().into(), - profile_state: active.profile_state().into(), - profile: active.profile().map(ProfileDto::from), - } - } -} - -impl From<&ProfileMetadata> for ProfileDto { - fn from(profile: &ProfileMetadata) -> Self { - Self { - name: profile.name().map(str::to_owned), - display_name: profile.display_name().map(str::to_owned), - nip05: profile.nip05().map(str::to_owned), - about: profile.about().map(str::to_owned), - picture: profile.picture().map(str::to_owned), - } - } -} - -impl From<SafeError> for SafeErrorDto { - fn from(error: SafeError) -> Self { - let (category, retryable, recovery_action) = error_policy(error.code()); - Self { - code: error.code().into(), - category, - retryable, - recovery_action, - message: error.message().as_str().to_owned(), - } - } -} - -impl From<SafeErrorCode> for WireErrorCode { - fn from(code: SafeErrorCode) -> Self { - match code { - SafeErrorCode::InvalidPublicKey => Self::InvalidPublicKey, - SafeErrorCode::InvalidSecretKey => Self::InvalidSecretKey, - SafeErrorCode::InvalidAccountMetadata => Self::InvalidAccountMetadata, - SafeErrorCode::InvalidProfileMetadata => Self::InvalidProfileMetadata, - SafeErrorCode::InvalidApplicationState => Self::InvalidApplicationState, - SafeErrorCode::AccountAlreadyExists => Self::AccountAlreadyExists, - SafeErrorCode::AccountNotFound => Self::AccountNotFound, - SafeErrorCode::KeyringUnavailable => Self::KeyringUnavailable, - SafeErrorCode::CredentialMissing => Self::CredentialMissing, - SafeErrorCode::StorageUnavailable => Self::StorageUnavailable, - SafeErrorCode::StorageCorrupt => Self::StorageCorrupt, - SafeErrorCode::StorageQuarantined => Self::StorageQuarantined, - SafeErrorCode::StorageBackupInvalid => Self::StorageBackupInvalid, - SafeErrorCode::UnsupportedSchemaVersion => Self::UnsupportedSchemaVersion, - SafeErrorCode::RepairUnauthorized => Self::RepairUnauthorized, - SafeErrorCode::PendingOperationRecoveryRequired => { - Self::PendingOperationRecoveryRequired - } - SafeErrorCode::InvalidRelayConfiguration => Self::InvalidRelayConfiguration, - SafeErrorCode::RelayConnectionFailed => Self::RelayConnectionFailed, - SafeErrorCode::ProfileRefreshFailed => Self::ProfileRefreshFailed, - SafeErrorCode::ObserverRegistrationFailed => Self::ObserverRegistrationFailed, - SafeErrorCode::NativeLibraryLoadFailed => Self::NativeLibraryLoadFailed, - } - } -} - -pub(crate) const fn error_policy( - code: SafeErrorCode, -) -> (WireErrorCategory, bool, WireRecoveryAction) { - match code { - SafeErrorCode::InvalidPublicKey - | SafeErrorCode::InvalidSecretKey - | SafeErrorCode::InvalidAccountMetadata - | SafeErrorCode::InvalidProfileMetadata => { - (WireErrorCategory::Input, false, WireRecoveryAction::None) - } - SafeErrorCode::AccountAlreadyExists | SafeErrorCode::AccountNotFound => { - (WireErrorCategory::Conflict, false, WireRecoveryAction::None) - } - SafeErrorCode::KeyringUnavailable => ( - WireErrorCategory::Credential, - true, - WireRecoveryAction::Retry, - ), - SafeErrorCode::CredentialMissing => ( - WireErrorCategory::Credential, - false, - WireRecoveryAction::RepairCredential, - ), - SafeErrorCode::StorageUnavailable => ( - WireErrorCategory::Storage, - true, - WireRecoveryAction::RestartApplication, - ), - SafeErrorCode::StorageCorrupt | SafeErrorCode::PendingOperationRecoveryRequired => ( - WireErrorCategory::Storage, - false, - WireRecoveryAction::RestartApplication, - ), - SafeErrorCode::StorageQuarantined => ( - WireErrorCategory::Storage, - false, - WireRecoveryAction::RepairStorage, - ), - SafeErrorCode::StorageBackupInvalid => ( - WireErrorCategory::Storage, - false, - WireRecoveryAction::RestoreBackup, - ), - SafeErrorCode::UnsupportedSchemaVersion => ( - WireErrorCategory::Compatibility, - false, - WireRecoveryAction::UpdateApplication, - ), - SafeErrorCode::RepairUnauthorized => ( - WireErrorCategory::Credential, - false, - WireRecoveryAction::Authenticate, - ), - SafeErrorCode::InvalidRelayConfiguration => ( - WireErrorCategory::Network, - false, - WireRecoveryAction::CheckConfiguration, - ), - SafeErrorCode::RelayConnectionFailed | SafeErrorCode::ProfileRefreshFailed => { - (WireErrorCategory::Network, true, WireRecoveryAction::Retry) - } - SafeErrorCode::InvalidApplicationState | SafeErrorCode::ObserverRegistrationFailed => ( - WireErrorCategory::Lifecycle, - true, - WireRecoveryAction::Retry, - ), - SafeErrorCode::NativeLibraryLoadFailed => ( - WireErrorCategory::Internal, - false, - WireRecoveryAction::RestartApplication, - ), - } -} - -impl From<BindingAvailability> for KeyAvailabilityDto { - fn from(value: BindingAvailability) -> Self { - match value { - BindingAvailability::Available => Self::Available, - BindingAvailability::CredentialMissing => Self::CredentialMissing, - BindingAvailability::StoreUnavailable => Self::StoreUnavailable, - } - } -} - -impl From<RelayConnectionState> for RelayConnectionStateDto { - fn from(value: RelayConnectionState) -> Self { - match value { - RelayConnectionState::Disconnected => Self::Disconnected, - RelayConnectionState::Connecting => Self::Connecting, - RelayConnectionState::Connected => Self::Connected, - RelayConnectionState::Degraded => Self::Degraded, - RelayConnectionState::Error(_) => Self::Error, - } - } -} - -impl From<ProfileLoadState> for ProfileLoadStateDto { - fn from(value: ProfileLoadState) -> Self { - match value { - ProfileLoadState::Empty => Self::Empty, - ProfileLoadState::Loading => Self::Loading, - ProfileLoadState::Cached => Self::Cached, - ProfileLoadState::Fresh => Self::Fresh, - ProfileLoadState::Error(_) => Self::Error, - } - } -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use std::sync::Arc; - - use radroots_studio_application::{ - AppCore, ProfileLoadState, RelayConfiguration, RelayConnectionState, RuntimeLifecycle, - }; - use radroots_studio_nostr::NostrKeyMaterialProvider; - - use radroots_studio_domain::{BindingAvailability, SafeError, SafeErrorCode, SafeMessage}; - - use super::{ - AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileLoadStateDto, - RelayConnectionStateDto, SafeErrorDto, WireErrorCategory, WireErrorCode, - WireRecoveryAction, error_policy, - }; - - fn safe_error(code: SafeErrorCode) -> SafeError { - SafeError::new(code, SafeMessage::new("Safe compatibility failure.")) - } - - #[test] - fn snapshot_dto_is_revisioned_public_and_secret_free() { - let core = AppCore::new( - RelayConfiguration::default(), - Arc::new(NostrKeyMaterialProvider), - ); - let snapshot = core.bootstrap().expect("bootstrap"); - let dto = AppSnapshotDto::from(&snapshot); - let debug = format!("{dto:?}"); - - assert_eq!(dto.revision, 1); - assert!(dto.accounts.is_empty()); - assert!(!debug.contains("nsec")); - assert!(!debug.contains("secret_key")); - assert!(!debug.contains("server_url")); - } - - #[test] - fn security_errors_have_explicit_stable_wire_mappings() { - for (code, expected_code, expected_recovery) in [ - ( - SafeErrorCode::StorageQuarantined, - WireErrorCode::StorageQuarantined, - WireRecoveryAction::RepairStorage, - ), - ( - SafeErrorCode::StorageBackupInvalid, - WireErrorCode::StorageBackupInvalid, - WireRecoveryAction::RestoreBackup, - ), - ( - SafeErrorCode::UnsupportedSchemaVersion, - WireErrorCode::UnsupportedSchemaVersion, - WireRecoveryAction::UpdateApplication, - ), - ( - SafeErrorCode::RepairUnauthorized, - WireErrorCode::RepairUnauthorized, - WireRecoveryAction::Authenticate, - ), - ] { - let dto = SafeErrorDto::from(radroots_studio_domain::SafeError::new( - code, - SafeMessage::new("Safe compatibility failure."), - )); - assert_eq!(dto.code, expected_code); - assert_eq!(dto.recovery_action, expected_recovery); - assert!(!dto.retryable); - } - } - - #[test] - fn every_safe_error_has_an_explicit_wire_code_and_policy() { - let cases = [ - ( - SafeErrorCode::InvalidPublicKey, - WireErrorCode::InvalidPublicKey, - ), - ( - SafeErrorCode::InvalidSecretKey, - WireErrorCode::InvalidSecretKey, - ), - ( - SafeErrorCode::InvalidAccountMetadata, - WireErrorCode::InvalidAccountMetadata, - ), - ( - SafeErrorCode::InvalidProfileMetadata, - WireErrorCode::InvalidProfileMetadata, - ), - ( - SafeErrorCode::InvalidApplicationState, - WireErrorCode::InvalidApplicationState, - ), - ( - SafeErrorCode::AccountAlreadyExists, - WireErrorCode::AccountAlreadyExists, - ), - ( - SafeErrorCode::AccountNotFound, - WireErrorCode::AccountNotFound, - ), - ( - SafeErrorCode::KeyringUnavailable, - WireErrorCode::KeyringUnavailable, - ), - ( - SafeErrorCode::CredentialMissing, - WireErrorCode::CredentialMissing, - ), - ( - SafeErrorCode::StorageUnavailable, - WireErrorCode::StorageUnavailable, - ), - (SafeErrorCode::StorageCorrupt, WireErrorCode::StorageCorrupt), - ( - SafeErrorCode::StorageQuarantined, - WireErrorCode::StorageQuarantined, - ), - ( - SafeErrorCode::StorageBackupInvalid, - WireErrorCode::StorageBackupInvalid, - ), - ( - SafeErrorCode::UnsupportedSchemaVersion, - WireErrorCode::UnsupportedSchemaVersion, - ), - ( - SafeErrorCode::RepairUnauthorized, - WireErrorCode::RepairUnauthorized, - ), - ( - SafeErrorCode::PendingOperationRecoveryRequired, - WireErrorCode::PendingOperationRecoveryRequired, - ), - ( - SafeErrorCode::InvalidRelayConfiguration, - WireErrorCode::InvalidRelayConfiguration, - ), - ( - SafeErrorCode::RelayConnectionFailed, - WireErrorCode::RelayConnectionFailed, - ), - ( - SafeErrorCode::ProfileRefreshFailed, - WireErrorCode::ProfileRefreshFailed, - ), - ( - SafeErrorCode::ObserverRegistrationFailed, - WireErrorCode::ObserverRegistrationFailed, - ), - ( - SafeErrorCode::NativeLibraryLoadFailed, - WireErrorCode::NativeLibraryLoadFailed, - ), - ]; - for (code, expected_wire_code) in cases { - let dto = SafeErrorDto::from(safe_error(code)); - assert_eq!(dto.code, expected_wire_code); - assert_eq!( - (dto.category, dto.retryable, dto.recovery_action), - error_policy(code) - ); - } - assert_eq!( - error_policy(SafeErrorCode::KeyringUnavailable), - ( - WireErrorCategory::Credential, - true, - WireRecoveryAction::Retry, - ) - ); - assert_eq!( - error_policy(SafeErrorCode::NativeLibraryLoadFailed), - ( - WireErrorCategory::Internal, - false, - WireRecoveryAction::RestartApplication, - ) - ); - } - - #[test] - fn runtime_and_connection_states_map_exhaustively_to_wire_states() { - let core = AppCore::new( - RelayConfiguration::default(), - Arc::new(NostrKeyMaterialProvider), - ); - let snapshot = core.bootstrap().expect("bootstrap"); - for (runtime, expected) in [ - (RuntimeLifecycle::Opening, AppLifecycleDto::Opening), - ( - RuntimeLifecycle::CompatibilityChecking, - AppLifecycleDto::CompatibilityChecking, - ), - ( - RuntimeLifecycle::AcquiringOwnership, - AppLifecycleDto::AcquiringOwnership, - ), - (RuntimeLifecycle::Migrating, AppLifecycleDto::Migrating), - (RuntimeLifecycle::Recovering, AppLifecycleDto::Recovering), - (RuntimeLifecycle::Ready, AppLifecycleDto::Ready), - ( - RuntimeLifecycle::Degraded(safe_error(SafeErrorCode::RelayConnectionFailed)), - AppLifecycleDto::Degraded, - ), - ( - RuntimeLifecycle::Blocked(safe_error(SafeErrorCode::StorageUnavailable)), - AppLifecycleDto::Blocked, - ), - ( - RuntimeLifecycle::ShuttingDown, - AppLifecycleDto::ShuttingDown, - ), - (RuntimeLifecycle::Closed, AppLifecycleDto::Closed), - ( - RuntimeLifecycle::Fatal(safe_error(SafeErrorCode::StorageCorrupt)), - AppLifecycleDto::Fatal, - ), - ] { - let dto = AppSnapshotDto::from_runtime(&snapshot, runtime); - assert_eq!(dto.lifecycle, expected); - assert_eq!( - dto.lifecycle_error.is_some(), - matches!( - expected, - AppLifecycleDto::Degraded | AppLifecycleDto::Blocked | AppLifecycleDto::Fatal - ) - ); - } - - for (source, expected) in [ - ( - BindingAvailability::Available, - KeyAvailabilityDto::Available, - ), - ( - BindingAvailability::CredentialMissing, - KeyAvailabilityDto::CredentialMissing, - ), - ( - BindingAvailability::StoreUnavailable, - KeyAvailabilityDto::StoreUnavailable, - ), - ] { - assert_eq!(KeyAvailabilityDto::from(source), expected); - } - for (source, expected) in [ - ( - RelayConnectionState::Disconnected, - RelayConnectionStateDto::Disconnected, - ), - ( - RelayConnectionState::Connecting, - RelayConnectionStateDto::Connecting, - ), - ( - RelayConnectionState::Connected, - RelayConnectionStateDto::Connected, - ), - ( - RelayConnectionState::Degraded, - RelayConnectionStateDto::Degraded, - ), - ( - RelayConnectionState::Error(safe_error(SafeErrorCode::RelayConnectionFailed)), - RelayConnectionStateDto::Error, - ), - ] { - assert_eq!(RelayConnectionStateDto::from(source), expected); - } - for (source, expected) in [ - (ProfileLoadState::Empty, ProfileLoadStateDto::Empty), - (ProfileLoadState::Loading, ProfileLoadStateDto::Loading), - (ProfileLoadState::Cached, ProfileLoadStateDto::Cached), - (ProfileLoadState::Fresh, ProfileLoadStateDto::Fresh), - ( - ProfileLoadState::Error(safe_error(SafeErrorCode::ProfileRefreshFailed)), - ProfileLoadStateDto::Error, - ), - ] { - assert_eq!(ProfileLoadStateDto::from(source), expected); - } - } -} diff --git a/crates/studio_ffi/src/lib.rs b/crates/studio_ffi/src/lib.rs @@ -1,46 +0,0 @@ -#![doc = "Radroots Studio `UniFFI` boundary."] -#![cfg_attr(coverage_nightly, feature(coverage_attribute))] - -mod commands; -mod contract; -mod dto; -mod observer; - -pub use commands::{ - AccountCommandReceiptDto, GeneratedRecoveryRequest, RemovalRequest, RequestContextDto, - StudioAppCore, StudioError, -}; -pub use contract::{ - FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, MINIMUM_SCHEMA_VERSION, - PRODUCT_VERSION, -}; -pub use dto::{ - AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto, - ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto, - WireErrorCategory, WireErrorCode, WireRecoveryAction, -}; -pub use observer::{ - ObserverSubscription, ShutdownReceiptDto, SnapshotChangeDto, StudioChangeObserver, -}; - -uniffi::setup_scaffolding!(); - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -#[must_use] -pub fn native_runtime_version() -> String { - PRODUCT_VERSION.to_owned() -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - #[test] - fn native_runtime_reports_the_product_version_independently() { - assert_eq!(super::native_runtime_version(), "0.1.0-alpha"); - assert_eq!(super::PRODUCT_VERSION, "0.1.0-alpha"); - assert_eq!(env!("CARGO_PKG_VERSION"), "0.1.0-alpha"); - assert_eq!(super::FFI_CONTRACT_MAJOR, 3); - assert_eq!(super::FFI_CONTRACT_HASH.len(), 64); - assert!(!super::contract::NORMALIZED_CONTRACT_METADATA.is_empty()); - } -} diff --git a/crates/studio_ffi/src/observer.rs b/crates/studio_ffi/src/observer.rs @@ -1,512 +0,0 @@ -use std::num::NonZeroUsize; -use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::sync::atomic::Ordering; -use std::sync::{Arc, Mutex, Weak}; - -use radroots_studio_application::ChangeSubscriptionId; - -use crate::commands::RuntimeCore; -use crate::{AppSnapshotDto, StudioAppCore, StudioError}; - -const OBSERVER_CHANGE_CAPACITY: NonZeroUsize = NonZeroUsize::MIN.saturating_add(63); -const MAX_OBSERVERS: usize = 32; - -#[derive(Clone, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct SnapshotChangeDto { - pub snapshot: AppSnapshotDto, - pub previous_revision: Option<u64>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct ShutdownReceiptDto { - pub final_revision: u64, - pub closed: bool, -} - -#[cfg_attr(not(coverage_nightly), uniffi::export(callback_interface))] -pub trait StudioChangeObserver: Send + Sync { - fn on_change(&self, change: SnapshotChangeDto); -} - -#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))] -pub struct ObserverSubscription { - core: Weak<RuntimeCore>, - id: Mutex<Option<ChangeSubscriptionId>>, -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -impl ObserverSubscription { - pub async fn unsubscribe(&self) { - let id = self - .id - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - let (Some(core), Some(id)) = (self.core.upgrade(), id) else { - return; - }; - let task = { - core.observers - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(&id) - }; - if let Some(Some(task)) = task { - task.abort(); - let _ = task.await; - } - let _ = core.actor.unsubscribe_changes(id).await; - } -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -impl StudioAppCore { - /// Subscribes to ordered revision changes including predecessor metadata. - /// - /// # Errors - /// - /// Returns a safe observer or lifecycle error. - pub async fn subscribe_changes_v2( - &self, - observer: Box<dyn StudioChangeObserver>, - ) -> Result<Arc<ObserverSubscription>, StudioError> { - if self.inner.closed.load(Ordering::Acquire) { - return Err(closed_error()); - } - let mut subscription = self - .inner - .actor - .subscribe_changes(OBSERVER_CHANGE_CAPACITY) - .await - .map_err(StudioError::from)?; - let id = subscription.id(); - let observer: Arc<dyn StudioChangeObserver> = Arc::from(observer); - let runtime_core = Arc::downgrade(&self.inner); - let admitted = { - let mut observers = self - .inner - .observers - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if self.inner.closed.load(Ordering::Acquire) || observers.len() >= MAX_OBSERVERS { - false - } else { - observers.insert(id, None); - true - } - }; - if !admitted { - self.inner - .actor - .unsubscribe_changes(id) - .await - .map_err(StudioError::from)?; - return Err(observer_registration_error()); - } - let task = crate::commands::runtime()?.spawn(async move { - while let Some(change) = subscription.receive().await { - let Some(runtime_core) = runtime_core.upgrade() else { - break; - }; - let delivery = SnapshotChangeDto { - snapshot: AppSnapshotDto::from_runtime( - change.snapshot(), - runtime_core.effective_lifecycle(), - ), - previous_revision: change - .previous_revision() - .map(radroots_studio_application::SnapshotRevision::value), - }; - if catch_unwind(AssertUnwindSafe(|| observer.on_change(delivery))).is_err() { - break; - } - } - if let Some(runtime_core) = runtime_core.upgrade() { - let _ = runtime_core.actor.unsubscribe_changes(id).await; - runtime_core - .observers - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(&id); - } - }); - let retained = { - let mut observers = self - .inner - .observers - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(slot) = observers.get_mut(&id) { - *slot = Some(task); - true - } else { - task.abort(); - false - } - }; - if !retained { - let _ = self.inner.actor.unsubscribe_changes(id).await; - return Err(closed_error()); - } - Ok(Arc::new(ObserverSubscription { - core: Arc::downgrade(&self.inner), - id: Mutex::new(Some(id)), - })) - } - - /// Stops observer delivery and waits for actor-owned shutdown. - /// - /// # Errors - /// - /// Returns a safe closed or timeout error when shutdown cannot complete. - pub async fn shutdown_v2(&self) -> Result<ShutdownReceiptDto, StudioError> { - if self.inner.closed.swap(true, Ordering::AcqRel) { - return Err(closed_error()); - } - let handles = std::mem::take( - &mut *self - .inner - .observers - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner), - ); - for (_, task) in handles { - if let Some(task) = task { - task.abort(); - let _ = task.await; - } - } - self.inner.actor.close().await.map_err(StudioError::from)?; - Ok(ShutdownReceiptDto { - final_revision: self.inner.actor.snapshot().revision().value(), - closed: true, - }) - } -} - -fn closed_error() -> StudioError { - StudioError::Failure { - code: crate::WireErrorCode::InvalidApplicationState, - category: crate::WireErrorCategory::Lifecycle, - retryable: false, - recovery_action: crate::WireRecoveryAction::None, - correlation_id: None, - safe_message: "The application runtime is closed.".to_owned(), - } -} - -fn observer_registration_error() -> StudioError { - StudioError::Failure { - code: crate::WireErrorCode::ObserverRegistrationFailed, - category: crate::WireErrorCategory::Lifecycle, - retryable: true, - recovery_action: crate::WireRecoveryAction::Retry, - correlation_id: None, - safe_message: "The change observer could not be registered.".to_owned(), - } -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use std::num::NonZeroUsize; - use std::sync::{Arc, Mutex}; - use std::time::Duration; - - use nostr::{EventBuilder, Keys, Metadata}; - use nostr_relay_builder::MockRelay; - use nostr_sdk::Client; - use radroots_studio_application::{InMemorySecretStore, RelayConfiguration}; - use radroots_studio_domain::{RelayDestinationPolicy, RelayUrl}; - use radroots_studio_nostr::SdkNostrClient; - use radroots_studio_runtime::{ - RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, - }; - - use crate::commands::{ACTOR_MAILBOX_CAPACITY, RuntimeCore, SystemClock, runtime}; - use crate::{ - AppSnapshotDto, ProfileLoadStateDto, SnapshotChangeDto, StudioAppCore, StudioChangeObserver, - }; - - const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - - #[derive(Default)] - struct RecordingObserver { - snapshots: Mutex<Vec<AppSnapshotDto>>, - core: Mutex<Option<Arc<StudioAppCore>>>, - } - - struct PanickingObserver; - - impl StudioChangeObserver for PanickingObserver { - fn on_change(&self, _change: SnapshotChangeDto) { - panic!("injected host callback failure"); - } - } - - impl StudioChangeObserver for RecordingObserver { - fn on_change(&self, change: SnapshotChangeDto) { - let snapshot = change.snapshot; - if let Some(core) = self.core.lock().expect("core").as_ref() { - assert_eq!(core.snapshot().revision, snapshot.revision); - } - self.snapshots.lock().expect("snapshots").push(snapshot); - } - } - - async fn core() -> Arc<StudioAppCore> { - core_with_relays(RelayConfiguration::default()).await - } - - async fn core_with_relays(relays: RelayConfiguration) -> Arc<StudioAppCore> { - let actor = RuntimeActorHandle::in_memory( - relays, - RuntimeDependencies::new( - Arc::new(InMemorySecretStore::default()), - Arc::new(SystemClock), - Arc::new(SdkNostrClient::new(std::time::Duration::from_millis(10))), - Arc::new(UuidInstallationIdentitySource), - ), - NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("capacity"), - runtime().expect("runtime").handle(), - ) - .await - .expect("actor"); - Arc::new(StudioAppCore { - inner: Arc::new(RuntimeCore { - actor, - observers: Mutex::new(std::collections::BTreeMap::new()), - closed: std::sync::atomic::AtomicBool::new(false), - startup_relay_problem: None, - }), - }) - } - - #[test] - fn callbacks_allow_reentry_and_stop_after_subscription_close() { - runtime().expect("runtime").block_on(async { - let core = core().await; - let observer = Arc::new(RecordingObserver::default()); - *observer.core.lock().expect("core") = Some(Arc::clone(&core)); - let subscription = core - .subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))) - .await - .expect("subscribe"); - wait_for_snapshot_count(&observer, 1).await; - core.inner - .actor - .bootstrap() - .await - .expect("idempotent bootstrap"); - assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1); - subscription.unsubscribe().await; - subscription.unsubscribe().await; - core.inner.actor.sign_out().await.expect("sign out"); - assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1); - }); - } - - #[test] - fn core_close_deregisters_all_observers_and_rejects_new_subscriptions() { - runtime().expect("runtime").block_on(async { - let core = core().await; - let observer = Arc::new(RecordingObserver::default()); - let subscription = core - .subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))) - .await - .expect("subscribe"); - let _active_subscription = core - .subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))) - .await - .expect("second subscription"); - let id = subscription - .id - .lock() - .expect("subscription id") - .expect("active subscription id"); - let handle = core - .inner - .observers - .lock() - .expect("observers") - .get_mut(&id) - .expect("registered observer") - .take() - .expect("observer task"); - handle.abort(); - - core.shutdown_v2().await.expect("shutdown"); - assert!(core.shutdown_v2().await.is_err()); - - assert!( - core.subscribe_changes_v2(Box::new(ArcObserver(observer))) - .await - .is_err() - ); - assert!(core.inner.observers.lock().expect("observers").is_empty()); - }); - } - - #[test] - fn subscription_unsubscribe_tolerates_a_dropped_runtime_core() { - runtime().expect("runtime").block_on(async { - let core = core().await; - let observer = Arc::new(RecordingObserver::default()); - let subscription = core - .subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))) - .await - .expect("subscribe"); - wait_for_snapshot_count(&observer, 1).await; - - drop(core); - subscription.unsubscribe().await; - }); - } - - #[test] - fn observer_registration_is_bounded_and_callback_panics_are_contained() { - runtime().expect("runtime").block_on(async { - let core = core().await; - let panic_subscription = core - .subscribe_changes_v2(Box::new(PanickingObserver)) - .await - .expect("panic observer registration"); - tokio::time::sleep(Duration::from_millis(10)).await; - assert!(core.inner.observers.lock().expect("observers").is_empty()); - panic_subscription.unsubscribe().await; - - let observer = Arc::new(RecordingObserver::default()); - let mut subscriptions = Vec::new(); - for _ in 0..super::MAX_OBSERVERS { - subscriptions.push( - core.subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))) - .await - .expect("bounded observer registration"), - ); - } - assert!( - core.subscribe_changes_v2(Box::new(ArcObserver(observer))) - .await - .is_err() - ); - for subscription in subscriptions { - subscription.unsubscribe().await; - } - assert!(core.inner.observers.lock().expect("observers").is_empty()); - }); - } - - #[tokio::test] - async fn ffi_callback_receives_async_profile_refresh_and_stops_after_unsubscribe() { - let local_relay = MockRelay::run().await.expect("local relay"); - let relay_url = local_relay.url().await; - let publisher = Client::new(Keys::parse(SECRET_HEX).expect("known key")); - publisher - .add_relay(relay_url.clone()) - .await - .expect("publisher relay"); - publisher.connect().await; - publisher.wait_for_connection(Duration::from_secs(2)).await; - publisher - .send_event_builder(EventBuilder::metadata( - &Metadata::new().display_name("FFI Profile"), - )) - .await - .expect("publish profile"); - - let core = core_with_relays( - RelayConfiguration::new(vec![ - RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local) - .expect("relay URL"), - ]) - .expect("relay configuration"), - ) - .await; - core.bootstrap().await.expect("bootstrap"); - let observer = Arc::new(RecordingObserver::default()); - *observer.core.lock().expect("core") = Some(Arc::clone(&core)); - let subscription = core - .subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))) - .await - .expect("subscribe"); - let imported = core - .import_account_v2( - crate::RequestContextDto { - request_id: "observer-import".to_owned(), - expected_revision: core.snapshot().revision, - deadline_millis: 5_000, - }, - SECRET_HEX.as_bytes().to_vec(), - ) - .await - .expect("import") - .snapshot; - let public_key = imported.selected_public_key_hex.expect("selection"); - core.activate_account(public_key).await.expect("activate"); - core.refresh_active_profile().await.expect("refresh"); - - wait_for_fresh_profile(&observer).await; - let snapshots = observer.snapshots.lock().expect("snapshots").clone(); - assert!(snapshots.iter().any(|snapshot| { - snapshot.active_account.as_ref().is_some_and(|active| { - active.profile_state == ProfileLoadStateDto::Fresh - && active - .profile - .as_ref() - .and_then(|profile| profile.display_name.as_deref()) - == Some("FFI Profile") - }) - })); - subscription.unsubscribe().await; - let count = observer.snapshots.lock().expect("snapshots").len(); - core.sign_out().await.expect("sign out"); - assert_eq!(observer.snapshots.lock().expect("snapshots").len(), count); - - core.shutdown_v2().await.expect("shutdown"); - publisher.shutdown().await; - local_relay.shutdown(); - } - - struct ArcObserver(Arc<RecordingObserver>); - - impl StudioChangeObserver for ArcObserver { - fn on_change(&self, change: SnapshotChangeDto) { - self.0.on_change(change); - } - } - - async fn wait_for_snapshot_count(observer: &RecordingObserver, minimum: usize) { - tokio::time::timeout(Duration::from_secs(1), async { - while observer.snapshots.lock().expect("snapshots").len() < minimum { - tokio::task::yield_now().await; - } - }) - .await - .expect("snapshot delivery"); - } - - async fn wait_for_fresh_profile(observer: &RecordingObserver) { - tokio::time::timeout(Duration::from_secs(1), async { - loop { - let fresh = observer - .snapshots - .lock() - .expect("snapshots") - .iter() - .any(|snapshot| { - snapshot.active_account.as_ref().is_some_and(|active| { - active.profile_state == ProfileLoadStateDto::Fresh - }) - }); - if fresh { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("fresh profile delivery"); - } -} diff --git a/crates/studio_ffi/uniffi.toml b/crates/studio_ffi/uniffi.toml @@ -1,3 +0,0 @@ -[crates.radroots_studio_ffi.bindings.kotlin] -package_name = "org.radroots.studio.ffi" -cdylib_name = "radroots_studio_ffi" diff --git a/crates/studio_nostr/Cargo.toml b/crates/studio_nostr/Cargo.toml @@ -1,34 +0,0 @@ -[package] -name = "radroots_studio_nostr" -description = "Private Nostr adapter for Radroots Studio" -version = "0.1.0-alpha" -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license = "GPL-3.0-only" -repository.workspace = true -homepage.workspace = true -publish = false -include = ["src/**", "Cargo.toml"] - -[dependencies] -nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } -nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" } -radroots_studio_application.workspace = true -radroots_studio_domain.workspace = true -radroots_identity.workspace = true -radroots_transport.workspace = true -radroots_transport_nostr.workspace = true -tokio = { version = "=1.47.1", features = ["sync", "time"] } - -[dev-dependencies] -nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" } -tokio = { version = "=1.47.1", features = [ - "macros", - "rt-multi-thread", - "sync", - "time", -] } - -[lints] -workspace = true diff --git a/crates/studio_nostr/src/client.rs b/crates/studio_nostr/src/client.rs @@ -1,353 +0,0 @@ -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use radroots_studio_domain::{ - PublicKey, RelayDestinationPolicy, RelayUrl, SafeError, SafeErrorCode, SafeMessage, - select_latest_kind0, -}; -use radroots_transport::{ - EventSource, FetchRequest, Target, TargetSet, - outcome::FetchTargetState, - source::{FetchBounds, FetchSelector}, -}; -use radroots_transport_nostr::{Config, NostrTransport, RelayProfile}; - -use radroots_studio_application::{ - BoxFuture, MAX_CONFIGURED_RELAYS, NostrClient, ProfileFetchResult, -}; - -pub struct SdkNostrClient { - timeout: Duration, -} - -const MAX_PROFILE_EVENTS_PER_RELAY: usize = 64; - -impl SdkNostrClient { - #[must_use] - pub const fn new(timeout: Duration) -> Self { - Self { timeout } - } -} - -impl NostrClient for SdkNostrClient { - fn fetch_profile<'a>( - &'a self, - public_key: PublicKey, - relays: &'a [RelayUrl], - deadline: Instant, - ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { - Box::pin(async move { - if relays.is_empty() { - return Err(invalid_relay_configuration()); - } - if relays.len() > MAX_CONFIGURED_RELAYS { - return Err(invalid_relay_configuration()); - } - - let author = public_key.canonical(); - let deadline = deadline.min(Instant::now() + self.timeout); - let mut candidates = Vec::new(); - let mut successful_relays = 0usize; - for policy in [ - RelayDestinationPolicy::Public, - RelayDestinationPolicy::Local, - RelayDestinationPolicy::PrivateNetwork, - ] { - let policy_relays = relays - .iter() - .filter(|relay| relay.policy() == policy) - .collect::<Vec<_>>(); - if policy_relays.is_empty() { - continue; - } - let profile = - relay_profile(policy, policy_relays.iter().map(|relay| relay.as_str())) - .map_err(|_| invalid_relay_configuration())?; - let config = Config::from_profile(profile); - let timeout_ms = timeout_millis(deadline.saturating_duration_since(Instant::now())); - let config = config - .with_timeouts(timeout_ms, timeout_ms, timeout_ms) - .map_err(|_| invalid_relay_configuration())?; - let targets = policy_relays - .iter() - .map(|relay| Target::nostr_relay(relay.as_str())) - .collect::<Result<Vec<_>, _>>() - .map_err(|_| invalid_relay_configuration())?; - let request = FetchRequest::new( - format!("studio-profile-{policy:?}"), - TargetSet::new(targets).map_err(|_| invalid_relay_configuration())?, - FetchBounds::new( - MAX_PROFILE_EVENTS_PER_RELAY as u16, - unix_deadline(deadline.saturating_duration_since(Instant::now()))?, - ) - .map_err(|_| invalid_relay_configuration())?, - ) - .map_err(|_| invalid_relay_configuration())? - .with_selector( - FetchSelector::all() - .with_kinds(vec![0]) - .and_then(|selector| selector.with_authors(vec![author])) - .map_err(|_| invalid_relay_configuration())?, - ); - let page = tokio::time::timeout_at( - deadline.into(), - NostrTransport::new(config).fetch(request), - ) - .await - .map_err(|_| relay_connection_failed())? - .map_err(|_| relay_connection_failed())?; - successful_relays += page - .target_outcomes() - .iter() - .filter(|outcome| { - matches!( - outcome.state(), - FetchTargetState::Complete | FetchTargetState::Partial - ) - }) - .count(); - for observed in page.events() { - candidates.push(crate::parse_verified_kind0( - observed.event().raw_json(), - public_key, - )?); - } - } - if successful_relays == 0 { - return Err(relay_connection_failed()); - } - let candidate = select_latest_kind0(candidates); - if successful_relays == relays.len() { - Ok(ProfileFetchResult::complete(candidate)) - } else { - Ok(ProfileFetchResult::partial(candidate)) - } - }) - } -} - -fn unix_deadline(timeout: Duration) -> Result<u64, SafeError> { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| relay_connection_failed())?; - let deadline = now - .checked_add(timeout) - .ok_or_else(relay_connection_failed)?; - u64::try_from(deadline.as_millis()) - .ok() - .filter(|deadline| *deadline > 0) - .ok_or_else(relay_connection_failed) -} - -fn timeout_millis(timeout: Duration) -> u64 { - u64::try_from(timeout.as_millis()) - .unwrap_or(u64::MAX) - .clamp(1, 120_000) -} - -fn relay_profile<I, S>( - policy: RelayDestinationPolicy, - relays: I, -) -> Result<RelayProfile, radroots_transport_nostr::Error> -where - I: IntoIterator<Item = S>, - S: AsRef<str>, -{ - match policy { - RelayDestinationPolicy::Public => RelayProfile::public(relays), - RelayDestinationPolicy::Local => RelayProfile::simulator(relays), - RelayDestinationPolicy::PrivateNetwork => RelayProfile::device(relays), - } -} - -const fn invalid_relay_configuration() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidRelayConfiguration, - SafeMessage::new("No Nostr relay is configured."), - ) -} - -const fn relay_connection_failed() -> SafeError { - SafeError::new( - SafeErrorCode::RelayConnectionFailed, - SafeMessage::new("The Nostr relays could not be reached."), - ) -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use nostr::{EventBuilder, Keys, Metadata}; - use nostr_relay_builder::MockRelay; - use nostr_sdk::Client; - use radroots_studio_domain::{PublicKey, RelayDestinationPolicy, RelayUrl, SafeErrorCode}; - - use radroots_studio_application::NostrClient; - - use crate::SdkNostrClient; - - #[tokio::test] - async fn sdk_client_fetches_verified_profile_from_ephemeral_local_relay() { - let relay = MockRelay::run().await.expect("local relay"); - let relay_url = relay.url().await; - let keys = Keys::generate(); - let publisher = Client::new(keys.clone()); - publisher - .add_relay(relay_url.clone()) - .await - .expect("add relay"); - publisher.connect().await; - publisher.wait_for_connection(Duration::from_secs(2)).await; - publisher - .send_event_builder(EventBuilder::metadata( - &Metadata::new().name("Farmer").display_name("Farm Account"), - )) - .await - .expect("publish metadata"); - - let adapter = SdkNostrClient::new(Duration::from_secs(2)); - let domain_relay = RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local) - .expect("domain relay URL"); - let public_key = - PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"); - let fetched = adapter - .fetch_profile( - public_key, - &[domain_relay], - std::time::Instant::now() + Duration::from_secs(2), - ) - .await - .expect("fetch profile"); - let (profile, completeness) = fetched.into_parts(); - let profile = profile.expect("published profile"); - - assert_eq!(profile.author(), public_key); - assert_eq!(profile.metadata().preferred_name(), Some("Farm Account")); - assert_eq!( - completeness, - radroots_studio_application::RelayFetchCompleteness::Complete - ); - publisher.shutdown().await; - relay.shutdown(); - } - - #[tokio::test] - async fn sdk_client_rejects_empty_configuration_without_network_access() { - let error = SdkNostrClient::new(Duration::from_millis(10)) - .fetch_profile( - PublicKey::from_bytes([7; 32]).expect("valid public key"), - &[], - std::time::Instant::now() + Duration::from_millis(10), - ) - .await - .expect_err("empty relay list"); - - assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); - - let relay = RelayUrl::parse("wss://relay.example.test", RelayDestinationPolicy::Public) - .expect("relay URL"); - let too_many = vec![relay; radroots_studio_application::MAX_CONFIGURED_RELAYS + 1]; - let error = SdkNostrClient::new(Duration::from_millis(10)) - .fetch_profile( - PublicKey::from_bytes([7; 32]).expect("valid public key"), - &too_many, - std::time::Instant::now() + Duration::from_millis(10), - ) - .await - .expect_err("oversized relay list"); - assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); - } - - #[tokio::test] - async fn sdk_client_fails_when_no_configured_relay_completes() { - let relay = RelayUrl::parse("ws://127.0.0.1:1", RelayDestinationPolicy::Local) - .expect("unavailable relay"); - let error = SdkNostrClient::new(Duration::from_millis(25)) - .fetch_profile( - PublicKey::from_bytes([7; 32]).expect("valid public key"), - &[relay], - std::time::Instant::now() + Duration::from_millis(50), - ) - .await - .expect_err("all relays unavailable"); - assert_eq!(error.code(), SafeErrorCode::RelayConnectionFailed); - } - - #[tokio::test] - async fn sdk_client_reports_partial_when_one_configured_relay_is_unavailable() { - let relay = MockRelay::run().await.expect("local relay"); - let relay_url = relay.url().await; - let keys = Keys::generate(); - let publisher = Client::new(keys.clone()); - publisher - .add_relay(relay_url.clone()) - .await - .expect("add relay"); - publisher.connect().await; - publisher - .send_event_builder(EventBuilder::metadata(&Metadata::new().name("Partial"))) - .await - .expect("publish metadata"); - - let configured = [ - RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local).expect("live relay"), - RelayUrl::parse("ws://127.0.0.1:1", RelayDestinationPolicy::Local) - .expect("unavailable relay"), - ]; - let fetched = SdkNostrClient::new(Duration::from_millis(250)) - .fetch_profile( - PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"), - &configured, - std::time::Instant::now() + Duration::from_secs(1), - ) - .await - .expect("partial fetch"); - let (candidate, completeness) = fetched.into_parts(); - assert!(candidate.is_some()); - assert_eq!( - completeness, - radroots_studio_application::RelayFetchCompleteness::Partial - ); - publisher.shutdown().await; - relay.shutdown(); - } - - #[test] - fn studio_policy_maps_exactly_to_the_canonical_transport_profile() { - let public = super::relay_profile(RelayDestinationPolicy::Public, ["wss://public.example"]) - .expect("public profile"); - let local = super::relay_profile(RelayDestinationPolicy::Local, ["ws://127.0.0.1:8080"]) - .expect("local profile"); - let device = super::relay_profile( - RelayDestinationPolicy::PrivateNetwork, - ["wss://10.0.0.5:7447"], - ) - .expect("device profile"); - assert_eq!( - public.kind(), - radroots_transport_nostr::RelayProfileKind::Public - ); - assert_eq!( - local.kind(), - radroots_transport_nostr::RelayProfileKind::Simulator - ); - assert_eq!( - device.kind(), - radroots_transport_nostr::RelayProfileKind::Device - ); - assert_eq!(super::timeout_millis(Duration::ZERO), 1); - assert_eq!( - super::timeout_millis(Duration::from_secs(1_000_000)), - 120_000 - ); - assert!(super::unix_deadline(Duration::from_secs(1)).is_ok()); - assert_eq!( - super::invalid_relay_configuration().code(), - SafeErrorCode::InvalidRelayConfiguration - ); - assert_eq!( - super::relay_connection_failed().code(), - SafeErrorCode::RelayConnectionFailed - ); - } -} diff --git a/crates/studio_nostr/src/keys.rs b/crates/studio_nostr/src/keys.rs @@ -1,125 +0,0 @@ -use nostr::{Keys, ToBech32}; -use radroots_studio_application::{GeneratedKeyMaterial, ImportedKeyMaterial, KeyMaterialProvider}; -use radroots_studio_domain::{ - Npub, Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, -}; - -#[derive(Clone, Copy, Debug, Default)] -pub struct NostrKeyMaterialProvider; - -/// Generates one cryptographically random local Nostr keypair. -/// -/// # Errors -/// -/// Returns a safe key error if an upstream encoding cannot be represented by -/// the stricter Radroots domain boundary. -impl KeyMaterialProvider for NostrKeyMaterialProvider { - fn generate(&self) -> Result<GeneratedKeyMaterial, SafeError> { - let keys = Keys::generate(); - let (public_key, npub, secret, nsec) = encode_keys(&keys)?; - Ok(GeneratedKeyMaterial::new(public_key, npub, secret, nsec)) - } - - fn import(&self, input: SecretKeyInput) -> Result<ImportedKeyMaterial, SafeError> { - let keys = input - .with_exposed_secret(Keys::parse) - .map_err(|_| invalid_secret_key())?; - drop(input); - let public_key = PublicKey::from_bytes(keys.public_key().to_bytes())?; - let npub = keys - .public_key() - .to_bech32() - .map_err(|_| invalid_public_key()) - .and_then(Npub::from_encoded)?; - let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?; - Ok(ImportedKeyMaterial::new(public_key, npub, secret)) - } -} - -fn encode_keys(keys: &Keys) -> Result<(PublicKey, Npub, SecretKeyInput, Nsec), SafeError> { - let public_key = PublicKey::from_bytes(keys.public_key().to_bytes())?; - let npub = keys - .public_key() - .to_bech32() - .map_err(|_| invalid_public_key()) - .and_then(Npub::from_encoded)?; - let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?; - let nsec = keys - .secret_key() - .to_bech32() - .map_err(|_| invalid_secret_key()) - .and_then(Nsec::from_encoded)?; - Ok((public_key, npub, secret, nsec)) -} - -const fn invalid_secret_key() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidSecretKey, - SafeMessage::new("The Nostr secret key is invalid."), - ) -} - -const fn invalid_public_key() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidPublicKey, - SafeMessage::new("The Nostr public key is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use radroots_studio_domain::{SafeErrorCode, SecretKeyInput}; - - use radroots_studio_application::KeyMaterialProvider; - - use super::{NostrKeyMaterialProvider, invalid_public_key, invalid_secret_key}; - - const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; - const NSEC_PUBLIC_HEX: &str = - "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"; - const HEX_PUBLIC_HEX: &str = "0cfda0afa91cc2fbbd6050c285802fe95c7a1755e0f68323999e13760501dc40"; - - #[test] - fn keys_generate_valid_redacted_material() { - let generated = NostrKeyMaterialProvider.generate().expect("generated"); - let (public_key, npub, secret, nsec) = generated.into_parts(); - assert_eq!(public_key.to_hex().len(), 64); - assert!(npub.as_str().starts_with("npub1")); - assert_eq!(secret.with_exposed_secret(str::len), 64); - assert_eq!(nsec.with_exposed_secret(str::len), 63); - assert_eq!(secret.with_exposed_secret(str::len), 64); - assert_eq!(nsec.with_exposed_secret(str::len), 63); - } - - #[test] - fn keys_import_known_nsec_and_hex_vectors() { - let from_nsec = NostrKeyMaterialProvider - .import(SecretKeyInput::parse(NSEC.to_owned()).expect("nsec")) - .expect("import nsec"); - let from_hex = NostrKeyMaterialProvider - .import(SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("hex")) - .expect("import hex"); - let (nsec_public, nsec_npub, _) = from_nsec.into_parts(); - let (hex_public, hex_npub, _) = from_hex.into_parts(); - assert_eq!(nsec_public.to_hex(), NSEC_PUBLIC_HEX); - assert_eq!(hex_public.to_hex(), HEX_PUBLIC_HEX); - assert!(nsec_npub.as_str().starts_with("npub1")); - assert!(hex_npub.as_str().starts_with("npub1")); - } - - #[test] - fn keys_reject_structurally_plausible_nsec_with_invalid_checksum() { - let input = SecretKeyInput::parse( - "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(), - ) - .expect("domain shape"); - let error = NostrKeyMaterialProvider - .import(input) - .err() - .expect("invalid checksum"); - assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); - assert_eq!(invalid_secret_key().code(), SafeErrorCode::InvalidSecretKey); - assert_eq!(invalid_public_key().code(), SafeErrorCode::InvalidPublicKey); - } -} diff --git a/crates/studio_nostr/src/lib.rs b/crates/studio_nostr/src/lib.rs @@ -1,9 +0,0 @@ -#![doc = "Radroots Studio Nostr protocol adapters."] - -pub mod client; -pub mod keys; -pub mod profile; - -pub use client::SdkNostrClient; -pub use keys::NostrKeyMaterialProvider; -pub use profile::parse_verified_kind0; diff --git a/crates/studio_nostr/src/profile.rs b/crates/studio_nostr/src/profile.rs @@ -1,182 +0,0 @@ -use nostr::{Event, JsonUtil, Kind, Metadata}; -use radroots_studio_domain::{ - EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode, - SafeMessage, UnixTimestamp, -}; - -const MAX_EVENT_JSON_BYTES: usize = 64 * 1_024; -const MAX_PROFILE_CONTENT_BYTES: usize = 16 * 1_024; - -/// Verifies and converts one serialized Nostr kind-0 event. -/// -/// # Errors -/// -/// Returns a safe profile-refresh error when the event is oversized, -/// malformed, invalidly signed, authored by another key, or not kind 0. -pub fn parse_verified_kind0( - event_json: &str, - expected_author: PublicKey, -) -> Result<Kind0ProfileCandidate, SafeError> { - if event_json.len() > MAX_EVENT_JSON_BYTES { - return Err(invalid_event()); - } - - let event = Event::from_json(event_json).map_err(|_| invalid_event())?; - event.verify().map_err(|_| invalid_event())?; - if event.kind != Kind::Metadata - || event.pubkey.to_bytes() != *expected_author.as_bytes() - || event.content.len() > MAX_PROFILE_CONTENT_BYTES - { - return Err(invalid_event()); - } - - let metadata = Metadata::from_json(&event.content).map_err(|_| invalid_metadata())?; - let profile = ProfileMetadata::new( - metadata.name, - metadata.display_name, - metadata.nip05, - metadata.about, - metadata.picture, - )?; - let created_at = i64::try_from(event.created_at.as_secs()) - .ok() - .and_then(UnixTimestamp::from_seconds) - .ok_or_else(invalid_event)?; - - Ok(Kind0ProfileCandidate::new( - EventId::from_bytes(event.id.to_bytes()), - expected_author, - created_at, - profile, - )) -} - -const fn invalid_event() -> SafeError { - SafeError::new( - SafeErrorCode::ProfileRefreshFailed, - SafeMessage::new("The Nostr profile event is invalid."), - ) -} - -const fn invalid_metadata() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidProfileMetadata, - SafeMessage::new("The Nostr profile metadata is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use nostr::{EventBuilder, JsonUtil, Keys, Metadata, Url}; - use radroots_studio_domain::{PublicKey, SafeErrorCode}; - - use super::parse_verified_kind0; - - fn signed_profile() -> (Keys, String) { - let keys = Keys::generate(); - let event = EventBuilder::metadata( - &Metadata::new() - .name(" farmer ") - .display_name(" Farm Account ") - .nip05("farmer@example.test") - .about("Local grower") - .picture( - Url::parse("https://images.example.test/farmer.png") - .expect("valid picture URL"), - ), - ) - .sign_with_keys(&keys) - .expect("signed metadata event"); - (keys, event.as_json()) - } - - #[test] - fn profile_event_verifies_signature_author_kind_and_metadata() { - let (keys, json) = signed_profile(); - let expected_author = - PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"); - - let candidate = parse_verified_kind0(&json, expected_author).expect("verified profile"); - - assert_eq!(candidate.author(), expected_author); - assert_eq!(candidate.metadata().name(), Some("farmer")); - assert_eq!(candidate.metadata().display_name(), Some("Farm Account")); - assert_eq!(candidate.metadata().nip05(), Some("farmer@example.test")); - assert_eq!(candidate.metadata().about(), Some("Local grower")); - assert_eq!( - candidate.metadata().picture(), - Some("https://images.example.test/farmer.png") - ); - } - - #[test] - fn profile_event_rejects_tampering_wrong_author_kind_and_oversize_content() { - let (keys, json) = signed_profile(); - let expected_author = - PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"); - let wrong_author = PublicKey::from_bytes(Keys::generate().public_key().to_bytes()) - .expect("valid public key"); - let tampered = json.replace("Local grower", "Remote grower"); - let note = EventBuilder::text_note("not metadata") - .sign_with_keys(&keys) - .expect("signed note") - .as_json(); - let oversized = EventBuilder::metadata(&Metadata::new().about("x".repeat(16 * 1_024 + 1))) - .sign_with_keys(&keys) - .expect("signed oversized profile") - .as_json(); - - for rejected in [ - parse_verified_kind0(&tampered, expected_author), - parse_verified_kind0(&json, wrong_author), - parse_verified_kind0(&note, expected_author), - parse_verified_kind0(&oversized, expected_author), - ] { - assert_eq!( - rejected.expect_err("invalid event").code(), - SafeErrorCode::ProfileRefreshFailed - ); - } - } - - #[test] - fn profile_event_rejects_malformed_and_bounded_invalid_metadata() { - let keys = Keys::generate(); - let malformed = EventBuilder::new(nostr::Kind::Metadata, "not json") - .sign_with_keys(&keys) - .expect("signed malformed metadata") - .as_json(); - let invalid = EventBuilder::metadata(&Metadata::new().name("x".repeat(129))) - .sign_with_keys(&keys) - .expect("signed invalid metadata") - .as_json(); - let author = PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"); - - assert_eq!( - parse_verified_kind0(&malformed, author) - .expect_err("malformed metadata") - .code(), - SafeErrorCode::InvalidProfileMetadata - ); - assert_eq!( - parse_verified_kind0(&invalid, author) - .expect_err("bounded metadata") - .code(), - SafeErrorCode::InvalidProfileMetadata - ); - assert_eq!( - parse_verified_kind0(&"x".repeat(64 * 1_024 + 1), author) - .expect_err("oversized event") - .code(), - SafeErrorCode::ProfileRefreshFailed - ); - assert_eq!( - super::invalid_event().code(), - SafeErrorCode::ProfileRefreshFailed - ); - assert_eq!( - super::invalid_metadata().code(), - SafeErrorCode::InvalidProfileMetadata - ); - } -} diff --git a/crates/studio_preferences/Cargo.toml b/crates/studio_preferences/Cargo.toml @@ -1,18 +0,0 @@ -[package] -name = "radroots_studio_preferences" -description = "Private preference model for Radroots Studio" -version = "0.1.0-alpha" -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license = "MPL-2.0" -repository.workspace = true -homepage.workspace = true -publish = false -include = ["src/**", "Cargo.toml"] - -[dependencies] -url = "=2.5.8" - -[lints] -workspace = true diff --git a/crates/studio_preferences/src/lib.rs b/crates/studio_preferences/src/lib.rs @@ -1,328 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -//! UI-neutral Studio preference state. -//! -//! This module carries forward the uniquely required preference behavior from -//! source commit `6074a4745be361f21bb47d4778c74a14b2d57954`. It intentionally -//! excludes that source's process-global state, sample account, and FFI layer. - -use url::Url; - -pub const PREFERENCES_SCHEMA_VERSION: u32 = 1; -const MAX_SUMMARY_BYTES: usize = 256; -const MAX_SERVER_URL_BYTES: usize = 2_048; - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum UpdateChannel { - #[default] - Stable, - Preview, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct StudioPreferences { - pub allow_incoming_connections: bool, - pub use_radroots_dns: bool, - pub use_radroots_subnets: bool, - pub launch_at_login: bool, - pub hide_dock_icon: bool, - pub vpn_on_demand_enabled: bool, - pub run_as_exit_node: bool, - pub allow_local_network_access: bool, - pub automatically_check_for_updates: bool, - pub update_channel: UpdateChannel, - pub last_update_check_summary: String, - pub alternate_server_url: String, -} - -impl Default for StudioPreferences { - fn default() -> Self { - Self { - allow_incoming_connections: true, - use_radroots_dns: true, - use_radroots_subnets: true, - launch_at_login: true, - hide_dock_icon: false, - vpn_on_demand_enabled: false, - run_as_exit_node: false, - allow_local_network_access: false, - automatically_check_for_updates: true, - update_channel: UpdateChannel::Stable, - last_update_check_summary: String::new(), - alternate_server_url: String::new(), - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PreferencesState { - schema_version: u32, - revision: u64, - preferences: StudioPreferences, -} - -impl Default for PreferencesState { - fn default() -> Self { - Self { - schema_version: PREFERENCES_SCHEMA_VERSION, - revision: 1, - preferences: StudioPreferences::default(), - } - } -} - -impl PreferencesState { - #[must_use] - pub const fn schema_version(&self) -> u32 { - self.schema_version - } - - #[must_use] - pub const fn revision(&self) -> u64 { - self.revision - } - - #[must_use] - pub const fn preferences(&self) -> &StudioPreferences { - &self.preferences - } - - pub fn apply(&mut self, change: PreferenceChange) -> Result<bool, PreferencesError> { - let mut candidate = self.preferences.clone(); - change.apply_to(&mut candidate)?; - if candidate == self.preferences { - return Ok(false); - } - self.revision = self - .revision - .checked_add(1) - .ok_or(PreferencesError::RevisionExhausted)?; - self.preferences = candidate; - Ok(true) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PreferenceChange { - AllowIncomingConnections(bool), - UseRadrootsDns(bool), - UseRadrootsSubnets(bool), - LaunchAtLogin(bool), - HideDockIcon(bool), - VpnOnDemandEnabled(bool), - RunAsExitNode(bool), - AllowLocalNetworkAccess(bool), - AutomaticallyCheckForUpdates(bool), - UpdateChannel(UpdateChannel), - LastUpdateCheckSummary(String), - AlternateServerUrl(String), -} - -impl PreferenceChange { - fn apply_to(self, preferences: &mut StudioPreferences) -> Result<(), PreferencesError> { - match self { - Self::AllowIncomingConnections(value) => { - preferences.allow_incoming_connections = value; - } - Self::UseRadrootsDns(value) => preferences.use_radroots_dns = value, - Self::UseRadrootsSubnets(value) => preferences.use_radroots_subnets = value, - Self::LaunchAtLogin(value) => preferences.launch_at_login = value, - Self::HideDockIcon(value) => preferences.hide_dock_icon = value, - Self::VpnOnDemandEnabled(value) => preferences.vpn_on_demand_enabled = value, - Self::RunAsExitNode(value) => preferences.run_as_exit_node = value, - Self::AllowLocalNetworkAccess(value) => { - preferences.allow_local_network_access = value; - } - Self::AutomaticallyCheckForUpdates(value) => { - preferences.automatically_check_for_updates = value; - } - Self::UpdateChannel(value) => preferences.update_channel = value, - Self::LastUpdateCheckSummary(value) => { - preferences.last_update_check_summary = validated_summary(value)?; - } - Self::AlternateServerUrl(value) => { - preferences.alternate_server_url = validated_server_url(value)?; - } - } - Ok(()) - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PreferencesError { - InvalidSummary, - InvalidAlternateServerUrl, - RevisionExhausted, -} - -fn validated_summary(value: String) -> Result<String, PreferencesError> { - let value = value.trim(); - if value.len() > MAX_SUMMARY_BYTES || value.chars().any(char::is_control) { - return Err(PreferencesError::InvalidSummary); - } - Ok(value.to_owned()) -} - -fn validated_server_url(value: String) -> Result<String, PreferencesError> { - let value = value.trim(); - if value.is_empty() { - return Ok(String::new()); - } - if value.len() > MAX_SERVER_URL_BYTES || value.chars().any(char::is_control) { - return Err(PreferencesError::InvalidAlternateServerUrl); - } - let url = Url::parse(value).map_err(|_| PreferencesError::InvalidAlternateServerUrl)?; - if url.scheme() != "https" - || url.host_str().is_none() - || !url.username().is_empty() - || url.password().is_some() - || url.fragment().is_some() - { - return Err(PreferencesError::InvalidAlternateServerUrl); - } - Ok(url.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn defaults_preserve_the_reviewed_boolean_policy_without_sample_identity() { - let state = PreferencesState::default(); - assert_eq!(state.schema_version(), PREFERENCES_SCHEMA_VERSION); - assert_eq!(state.revision(), 1); - assert!(state.preferences().allow_incoming_connections); - assert!(state.preferences().use_radroots_dns); - assert!(state.preferences().use_radroots_subnets); - assert!(state.preferences().launch_at_login); - assert!(state.preferences().automatically_check_for_updates); - assert_eq!(state.preferences().update_channel, UpdateChannel::Stable); - assert!(state.preferences().last_update_check_summary.is_empty()); - assert!(state.preferences().alternate_server_url.is_empty()); - } - - #[test] - fn revisions_advance_only_when_a_valid_canonical_value_changes() { - let mut state = PreferencesState::default(); - assert!( - !state - .apply(PreferenceChange::HideDockIcon(false)) - .expect("unchanged value") - ); - assert_eq!(state.revision(), 1); - assert!( - state - .apply(PreferenceChange::HideDockIcon(true)) - .expect("changed value") - ); - assert_eq!(state.revision(), 2); - assert!(state.preferences().hide_dock_icon); - } - - #[test] - fn every_boolean_and_channel_change_updates_exactly_one_revision() { - let mut state = PreferencesState::default(); - let changes = [ - PreferenceChange::AllowIncomingConnections(false), - PreferenceChange::UseRadrootsDns(false), - PreferenceChange::UseRadrootsSubnets(false), - PreferenceChange::LaunchAtLogin(false), - PreferenceChange::VpnOnDemandEnabled(true), - PreferenceChange::RunAsExitNode(true), - PreferenceChange::AllowLocalNetworkAccess(true), - PreferenceChange::AutomaticallyCheckForUpdates(false), - PreferenceChange::UpdateChannel(UpdateChannel::Preview), - ]; - for (index, change) in changes.into_iter().enumerate() { - assert!(state.apply(change).expect("valid preference change")); - assert_eq!(state.revision(), index as u64 + 2); - } - let preferences = state.preferences(); - assert!(!preferences.allow_incoming_connections); - assert!(!preferences.use_radroots_dns); - assert!(!preferences.use_radroots_subnets); - assert!(!preferences.launch_at_login); - assert!(preferences.vpn_on_demand_enabled); - assert!(preferences.run_as_exit_node); - assert!(preferences.allow_local_network_access); - assert!(!preferences.automatically_check_for_updates); - assert_eq!(preferences.update_channel, UpdateChannel::Preview); - } - - #[test] - fn revision_overflow_is_rejected_without_mutation() { - let mut state = PreferencesState { - revision: u64::MAX, - ..PreferencesState::default() - }; - assert_eq!( - state.apply(PreferenceChange::HideDockIcon(true)), - Err(PreferencesError::RevisionExhausted) - ); - assert!(!state.preferences().hide_dock_icon); - } - - #[test] - fn alternate_server_is_trimmed_canonical_and_credential_free() { - let mut state = PreferencesState::default(); - state - .apply(PreferenceChange::AlternateServerUrl( - " https://example.com/api ".to_owned(), - )) - .expect("valid URL"); - assert_eq!( - state.preferences().alternate_server_url, - "https://example.com/api" - ); - for invalid in [ - "http://example.com", - "https://user@example.com", - "https://user:password@example.com", - "https://example.com/#fragment", - "not a URL", - "https://example.com/a\nb", - ] { - assert_eq!( - state.apply(PreferenceChange::AlternateServerUrl(invalid.to_owned())), - Err(PreferencesError::InvalidAlternateServerUrl) - ); - } - state - .apply(PreferenceChange::AlternateServerUrl(" ".to_owned())) - .expect("empty URL resets the override"); - assert!(state.preferences().alternate_server_url.is_empty()); - assert_eq!( - state.apply(PreferenceChange::AlternateServerUrl(format!( - "https://example.com/{}", - "x".repeat(MAX_SERVER_URL_BYTES) - ))), - Err(PreferencesError::InvalidAlternateServerUrl) - ); - } - - #[test] - fn summary_is_bounded_trimmed_and_control_free() { - let mut state = PreferencesState::default(); - state - .apply(PreferenceChange::LastUpdateCheckSummary( - " Checked today ".to_owned(), - )) - .expect("valid summary"); - assert_eq!( - state.preferences().last_update_check_summary, - "Checked today" - ); - assert_eq!( - state.apply(PreferenceChange::LastUpdateCheckSummary( - "bad\nvalue".to_owned() - )), - Err(PreferencesError::InvalidSummary) - ); - assert_eq!( - state.apply(PreferenceChange::LastUpdateCheckSummary( - "x".repeat(MAX_SUMMARY_BYTES + 1) - )), - Err(PreferencesError::InvalidSummary) - ); - } -} diff --git a/crates/studio_runtime/Cargo.toml b/crates/studio_runtime/Cargo.toml @@ -1,34 +0,0 @@ -[package] -name = "radroots_studio_runtime" -description = "Private supervised composition runtime for Radroots Studio" -version = "0.1.0-alpha" -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license = "GPL-3.0-only" -repository.workspace = true -homepage.workspace = true -publish = false -include = ["src/**", "tests/**", "Cargo.toml"] - -[dependencies] -radroots_studio_application.workspace = true -radroots_studio_domain.workspace = true -radroots_studio_nostr.workspace = true -radroots_studio_storage.workspace = true -tokio = { version = "=1.47.1", features = [ - "macros", - "rt-multi-thread", - "sync", - "time", -] } -uuid.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" } -tempfile = "=3.23.0" - -[lints] -workspace = true diff --git a/crates/studio_runtime/src/blocking.rs b/crates/studio_runtime/src/blocking.rs @@ -1,114 +0,0 @@ -use std::sync::Arc; -use std::time::Instant; - -use tokio::runtime::Handle; -use tokio::sync::Semaphore; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum BlockingExecutionError { - DeadlineElapsed, - Saturated, - TaskFailed, -} - -#[derive(Clone)] -pub(crate) struct BoundedBlockingExecutor { - permits: Arc<Semaphore>, - runtime: Handle, -} - -impl BoundedBlockingExecutor { - pub(crate) fn new(capacity: usize, runtime: &Handle) -> Self { - Self { - permits: Arc::new(Semaphore::new(capacity)), - runtime: runtime.clone(), - } - } - - pub(crate) async fn execute<T, F>( - &self, - deadline: Instant, - operation: F, - ) -> Result<T, BlockingExecutionError> - where - T: Send + 'static, - F: FnOnce() -> T + Send + 'static, - { - if Instant::now() >= deadline { - return Err(BlockingExecutionError::DeadlineElapsed); - } - let permit = self - .permits - .clone() - .try_acquire_owned() - .map_err(|_| BlockingExecutionError::Saturated)?; - self.runtime - .spawn_blocking(move || { - let _permit = permit; - operation() - }) - .await - .map_err(|_| BlockingExecutionError::TaskFailed) - } -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Condvar, Mutex}; - use std::time::{Duration, Instant}; - - use tokio::sync::oneshot; - - use super::{BlockingExecutionError, BoundedBlockingExecutor}; - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn executor_rejects_saturation_without_starting_excess_work() { - let executor = BoundedBlockingExecutor::new(1, &tokio::runtime::Handle::current()); - let release = Arc::new((Mutex::new(false), Condvar::new())); - let first_release = Arc::clone(&release); - let (started, started_rx) = oneshot::channel(); - let first_executor = executor.clone(); - let first = tokio::spawn(async move { - first_executor - .execute(Instant::now() + Duration::from_secs(5), move || { - let _ = started.send(()); - let (lock, ready) = &*first_release; - let mut released = lock.lock().expect("release lock"); - while !*released { - released = ready.wait(released).expect("release wait"); - } - 7 - }) - .await - }); - started_rx.await.expect("first work starts"); - - let second = executor - .execute(Instant::now() + Duration::from_secs(5), || 9) - .await; - assert_eq!(second, Err(BlockingExecutionError::Saturated)); - - let (lock, ready) = &*release; - *lock.lock().expect("release lock") = true; - ready.notify_all(); - assert_eq!(first.await.expect("first join"), Ok(7)); - } - - #[tokio::test] - async fn executor_rejects_expired_work_before_spawn() { - let executor = BoundedBlockingExecutor::new(1, &tokio::runtime::Handle::current()); - let result = executor.execute(Instant::now(), || 1).await; - assert_eq!(result, Err(BlockingExecutionError::DeadlineElapsed)); - } - - #[tokio::test] - async fn executor_classifies_panicked_work_without_panicking_the_actor() { - let executor = BoundedBlockingExecutor::new(1, &tokio::runtime::Handle::current()); - let result = executor - .execute::<(), _>(Instant::now() + Duration::from_secs(1), || { - panic!("test-only blocking task failure"); - }) - .await; - assert_eq!(result, Err(BlockingExecutionError::TaskFailed)); - } -} diff --git a/crates/studio_runtime/src/installation.rs b/crates/studio_runtime/src/installation.rs @@ -1,58 +0,0 @@ -use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; - -#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct InstallationIdentity(String); - -impl InstallationIdentity { - pub fn parse(value: impl Into<String>) -> Result<Self, SafeError> { - let value = value.into(); - if value.len() != 32 - || !value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(invalid_installation_identity()); - } - Ok(Self(value)) - } - - #[must_use] - pub fn as_str(&self) -> &str { - self.0.as_str() - } -} - -pub trait InstallationIdentitySource: Send + Sync { - fn generate(&self) -> Result<InstallationIdentity, SafeError>; -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct UuidInstallationIdentitySource; - -impl InstallationIdentitySource for UuidInstallationIdentitySource { - fn generate(&self) -> Result<InstallationIdentity, SafeError> { - InstallationIdentity::parse(uuid::Uuid::new_v4().simple().to_string()) - } -} - -const fn invalid_installation_identity() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The installation identity is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use super::{InstallationIdentity, InstallationIdentitySource, UuidInstallationIdentitySource}; - - #[test] - fn installation_identity_is_fixed_width_lowercase_hex() { - let identity = UuidInstallationIdentitySource.generate().expect("identity"); - assert_eq!(identity.as_str().len(), 32); - assert!(InstallationIdentity::parse(identity.as_str()).is_ok()); - for denied in ["", "AAaabbccddeeff001122334455667788", "not-an-identity"] { - assert!(InstallationIdentity::parse(denied).is_err()); - } - } -} diff --git a/crates/studio_runtime/src/lib.rs b/crates/studio_runtime/src/lib.rs @@ -1,12 +0,0 @@ -#![doc = "Radroots Studio supervised runtime composition."] - -mod blocking; -mod installation; -mod persistence; -mod runtime_actor; - -pub use installation::{ - InstallationIdentity, InstallationIdentitySource, UuidInstallationIdentitySource, -}; -pub use persistence::PersistentAppCore; -pub use runtime_actor::{RuntimeActorHandle, RuntimeChangeSubscription, RuntimeDependencies}; diff --git a/crates/studio_runtime/src/persistence.rs b/crates/studio_runtime/src/persistence.rs @@ -1,746 +0,0 @@ -use std::path::Path; -use std::sync::Arc; - -use radroots_studio_application::{ - AppCore, AppSnapshot, Clock, DurableRequestId, GenerateAccountReceipt, ImportAccountReceipt, - KeyMaterialProvider, RelayConfiguration, RemovalConfirmationToken, SecretStore, - StagedGeneratedKey, -}; -use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput}; -use radroots_studio_nostr::NostrKeyMaterialProvider; - -use radroots_studio_storage::Database; - -use crate::{InstallationIdentity, InstallationIdentitySource}; - -pub struct PersistentAppCore { - core: AppCore, - database: Database, - key_material: Arc<dyn KeyMaterialProvider>, -} - -impl PersistentAppCore { - pub(crate) fn initialize_installation_identity( - &self, - source: &dyn InstallationIdentitySource, - ) -> Result<InstallationIdentity, SafeError> { - if let Some(existing) = self.database.load_installation_id()? { - return InstallationIdentity::parse(existing); - } - let candidate = source.generate()?; - InstallationIdentity::parse( - self.database - .initialize_installation_id(candidate.as_str())?, - ) - } - - /// Commits an acknowledged generated-key stage through the durable coordinator. - /// - /// # Errors - /// - /// Returns a safe conflict, credential, storage, or recovery error. - pub fn commit_staged_generated_key( - &self, - request_id: &DurableRequestId, - staged: StagedGeneratedKey, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { - self.core.commit_staged_generated_key( - request_id, - staged, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - /// Opens the application database without accessing credentials or relays. - /// - /// # Errors - /// - /// Returns a safe storage error when the database cannot be opened or migrated. - pub fn open(path: &Path, relay_configuration: RelayConfiguration) -> Result<Self, SafeError> { - let key_material: Arc<dyn KeyMaterialProvider> = Arc::new(NostrKeyMaterialProvider); - Ok(Self { - core: AppCore::new(relay_configuration, Arc::clone(&key_material)), - database: Database::open(path)?, - key_material, - }) - } - - /// Creates an isolated persistent-core adapter for tests. - /// - /// # Errors - /// - /// Returns a safe storage error when the database cannot be initialized. - pub fn in_memory(relay_configuration: RelayConfiguration) -> Result<Self, SafeError> { - let key_material: Arc<dyn KeyMaterialProvider> = Arc::new(NostrKeyMaterialProvider); - Ok(Self { - core: AppCore::new(relay_configuration, Arc::clone(&key_material)), - database: Database::in_memory()?, - key_material, - }) - } - - pub(crate) fn key_material(&self) -> &dyn KeyMaterialProvider { - self.key_material.as_ref() - } - - /// Restores public accounts and selection while keeping the session signed out. - /// - /// # Errors - /// - /// Returns a safe storage or application-state error after publishing a fatal - /// snapshot when durable state cannot be restored. - pub fn bootstrap( - &self, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<AppSnapshot, SafeError> { - self.core.recover_durable_operations( - &self.database, - &self.database, - secrets, - &self.database, - clock, - )?; - self.core.recover_pending_operations( - &self.database, - &self.database, - secrets, - &self.database, - clock, - )?; - self.core.bootstrap_from(&self.database, &self.database) - } - - /// Generates and durably persists one selected, signed-out local account. - /// - /// # Errors - /// - /// Returns a safe credential, storage, key, or application-state error. - pub fn generate_account( - &self, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<GenerateAccountReceipt, SafeError> { - self.core.generate_account( - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - /// Imports and durably persists one selected, signed-out local account. - /// - /// # Errors - /// - /// Returns a safe credential, storage, key, or application-state error. - pub fn import_secret_key( - &self, - input: SecretKeyInput, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { - self.core.import_secret_key( - input, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - /// Generates an account through the durable request coordinator. - /// - /// # Errors - /// - /// Returns a safe conflict, credential, storage, or application-state error. - pub fn generate_account_durable( - &self, - request_id: &DurableRequestId, - expected_revision: u64, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<GenerateAccountReceipt, SafeError> { - self.core.generate_account_durable( - request_id, - expected_revision, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - /// Imports or repairs an account through the durable request coordinator. - /// - /// # Errors - /// - /// Returns a safe conflict, validation, credential, storage, or state error. - pub fn import_secret_key_durable( - &self, - request_id: &DurableRequestId, - expected_revision: u64, - input: SecretKeyInput, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { - self.core.import_secret_key_durable( - request_id, - expected_revision, - input, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - /// Persists and publishes one saved-account selection without activation. - /// - /// # Errors - /// - /// Returns a safe account, storage, or application-state error. - pub fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { - self.core - .select_account(public_key, &self.database, &self.database) - } - - /// Activates a saved account after validating its credential and cached profile. - /// - /// # Errors - /// - /// Returns a safe account, credential, storage, or application-state error. - pub fn activate_account( - &self, - public_key: PublicKey, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<AppSnapshot, SafeError> { - self.core.activate_account( - public_key, - &self.database, - &self.database, - &self.database, - secrets, - clock, - ) - } - - /// Signs out while retaining durable account data and credentials. - /// - /// # Errors - /// - /// Returns a safe application-state error if sign out cannot complete. - pub fn sign_out(&self) -> Result<AppSnapshot, SafeError> { - self.core.sign_out() - } - - /// Issues a revision-bound, single-use account-removal confirmation. - /// - /// # Errors - /// - /// Returns a safe error when the target account is not saved. - pub fn request_account_removal( - &self, - public_key: PublicKey, - clock: &(impl Clock + ?Sized), - ) -> Result<RemovalConfirmationToken, SafeError> { - self.core.request_account_removal(public_key, clock) - } - - /// Permanently removes one confirmed account and its credential. - /// - /// # Errors - /// - /// Returns a safe confirmation, credential, storage, recovery, or state error. - pub fn confirm_account_removal( - &self, - token: RemovalConfirmationToken, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<AppSnapshot, SafeError> { - self.core.confirm_account_removal( - token, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - /// Executes a confirmed removal through the durable request coordinator. - /// - /// # Errors - /// - /// Returns a safe expiry, conflict, credential, storage, recovery, or state error. - pub fn confirm_account_removal_durable( - &self, - request_id: &DurableRequestId, - token: RemovalConfirmationToken, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<AppSnapshot, SafeError> { - self.core.confirm_account_removal_durable( - request_id, - token, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - #[must_use] - pub const fn core(&self) -> &AppCore { - &self.core - } - - #[must_use] - pub const fn database(&self) -> &Database { - &self.database - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use radroots_studio_application::{ - AccountOperationKind, AccountOperationPhase, AccountRepository, AppLifecycle, - AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase, - DurableOperationRepository, DurableRequestId, DurableTerminalOutcome, FailureSecretStore, - InMemorySecretStore, OperationJournal, OperationPriorState, RelayConfiguration, - SecretStore, SecretStoreOperation, SessionState, - }; - use radroots_studio_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, SafeErrorCode, SecretKeyInput, UnixTimestamp, - }; - use tempfile::tempdir; - - use super::PersistentAppCore; - - fn account() -> AccountSummary { - let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), - None, - ) - .expect("account") - } - - struct FixedClock; - - impl Clock for FixedClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(25).expect("time") - } - } - - #[test] - fn persistent_bootstrap_handles_fresh_and_existing_signed_out_state() { - let directory = tempdir().expect("directory"); - let path = directory.path().join("studio.sqlite3"); - let public_key = account().public_key(); - let secrets = InMemorySecretStore::default(); - { - let adapter = PersistentAppCore::open(&path, RelayConfiguration::default()) - .expect("open adapter"); - let fresh = adapter - .bootstrap(&secrets, &FixedClock) - .expect("fresh bootstrap"); - assert!(fresh.accounts().is_empty()); - adapter - .database() - .insert_account(&account()) - .expect("account"); - adapter - .database() - .save_selected_account(Some(public_key)) - .expect("selection"); - } - - let adapter = - PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter"); - let restored = adapter.bootstrap(&secrets, &FixedClock).expect("restore"); - assert_eq!(restored.lifecycle(), AppLifecycle::Ready); - assert_eq!(restored.accounts().len(), 1); - assert_eq!(restored.selected_account(), Some(public_key)); - assert_eq!(restored.session(), SessionState::SignedOut); - assert!(restored.active_account().is_none()); - } - - #[test] - fn corrupt_database_fails_safely_without_recreation() { - let directory = tempdir().expect("directory"); - let path = directory.path().join("studio.sqlite3"); - fs::write(&path, b"not a sqlite database").expect("corrupt file"); - - let error = PersistentAppCore::open(&path, RelayConfiguration::default()) - .err() - .expect("safe failure"); - assert_eq!(error.code(), SafeErrorCode::StorageCorrupt); - assert_eq!( - fs::read(&path).expect("unchanged file"), - b"not a sqlite database" - ); - } - - #[test] - fn persisted_generate_and_import_survive_restart_without_secret_bytes() { - let directory = tempdir().expect("directory"); - let path = directory.path().join("studio.sqlite3"); - let secrets = InMemorySecretStore::default(); - let selected; - { - let adapter = - PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter"); - adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); - let generated = adapter - .generate_account(&secrets, &FixedClock) - .expect("generate"); - assert!( - secrets - .contains(generated.account().public_key()) - .expect("generated credential") - ); - let imported = adapter - .import_secret_key( - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" - .to_owned(), - ) - .expect("secret"), - &secrets, - &FixedClock, - ) - .expect("import"); - selected = imported.account().public_key(); - assert_eq!(adapter.core().snapshot().accounts().len(), 2); - } - - let bytes = fs::read(&path).expect("database bytes"); - assert!(!bytes.windows(5).any(|value| value == b"nsec1")); - assert!(!bytes.windows(64).any(|value| { - value == b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" - })); - let reopened = - PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen"); - let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore"); - assert_eq!(restored.accounts().len(), 2); - assert_eq!(restored.selected_account(), Some(selected)); - assert_eq!(restored.session(), SessionState::SignedOut); - } - - #[test] - fn durable_import_commits_each_phase_and_recovers_the_terminal_receipt() { - let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); - let secrets = InMemorySecretStore::default(); - let snapshot = adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); - let request = DurableRequestId::parse("import:adapter:1").expect("request"); - let imported = adapter - .import_secret_key_durable( - &request, - snapshot.revision().value(), - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), - ) - .expect("secret"), - &secrets, - &FixedClock, - ) - .expect("durable import"); - let operation = adapter - .database() - .load_durable_operation(&request) - .expect("operation") - .expect("durable record"); - let receipt = operation.terminal().expect("terminal receipt"); - assert_eq!(receipt.account(), imported.account().public_key()); - assert_eq!( - receipt.resulting_revision(), - Some(adapter.core().snapshot().revision().value()) - ); - } - - #[test] - fn durable_recovery_preserves_repair_metadata_and_deletes_orphan_credentials() { - let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); - let secrets = InMemorySecretStore::default(); - let missing = account().with_binding_availability(BindingAvailability::CredentialMissing); - adapter - .database() - .insert_account(&missing) - .expect("account"); - adapter - .database() - .save_selected_account(Some(missing.public_key())) - .expect("selection"); - let request = DurableRequestId::parse("repair:recovery:1").expect("request"); - adapter - .database() - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - missing.public_key(), - Some(0), - OperationPriorState::new( - Some(missing.public_key()), - Some(BindingAvailability::CredentialMissing), - ), - FixedClock.now(), - ) - .expect("intent"); - secrets - .put( - missing.public_key(), - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), - ) - .expect("secret"), - ) - .expect("credential"); - adapter - .database() - .advance_durable_operation( - &request, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialWritten, - FixedClock.now(), - None, - ) - .expect("credential phase"); - - adapter.bootstrap(&secrets, &FixedClock).expect("recovery"); - let repaired = adapter - .database() - .find_account(missing.public_key()) - .expect("lookup") - .expect("preserved account"); - assert_eq!( - repaired.signer().availability(), - BindingAvailability::CredentialMissing - ); - assert!(!secrets.contains(missing.public_key()).expect("credential")); - assert_eq!( - adapter - .database() - .load_durable_operation(&request) - .expect("operation") - .expect("record") - .terminal() - .expect("receipt") - .outcome(), - DurableTerminalOutcome::Failed - ); - } - - #[test] - fn durable_recovery_covers_response_loss_and_irreversible_removal_windows() { - let secrets = InMemorySecretStore::default(); - let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); - let saved = account(); - adapter.database().insert_account(&saved).expect("account"); - let import = DurableRequestId::parse("import:response-loss:1").expect("request"); - adapter - .database() - .begin_durable_operation( - &import, - DurableOperationKind::Import, - saved.public_key(), - Some(0), - OperationPriorState::new(None, None), - FixedClock.now(), - ) - .expect("intent"); - adapter - .database() - .advance_durable_operation( - &import, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialWritten, - FixedClock.now(), - None, - ) - .expect("credential"); - adapter - .database() - .advance_durable_operation( - &import, - DurableOperationPhase::CredentialWritten, - DurableOperationPhase::MetadataCommitted, - FixedClock.now(), - None, - ) - .expect("metadata"); - let restored = adapter - .bootstrap(&secrets, &FixedClock) - .expect("response recovery"); - assert_eq!(restored.selected_account(), Some(saved.public_key())); - assert_eq!( - adapter - .database() - .load_durable_operation(&import) - .expect("operation") - .expect("record") - .terminal() - .expect("receipt") - .outcome(), - DurableTerminalOutcome::Completed - ); - - let removal_adapter = - PersistentAppCore::in_memory(RelayConfiguration::default()).expect("remove adapter"); - removal_adapter - .database() - .insert_account(&saved) - .expect("remove account"); - removal_adapter - .database() - .save_selected_account(Some(saved.public_key())) - .expect("remove selection"); - let removal = DurableRequestId::parse("remove:response-loss:1").expect("request"); - removal_adapter - .database() - .begin_durable_operation( - &removal, - DurableOperationKind::Remove, - saved.public_key(), - Some(0), - OperationPriorState::new(None, Some(BindingAvailability::Available)), - FixedClock.now(), - ) - .expect("remove intent"); - removal_adapter - .database() - .advance_durable_operation( - &removal, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialDeleted, - FixedClock.now(), - None, - ) - .expect("credential deleted"); - let removed = removal_adapter - .bootstrap(&secrets, &FixedClock) - .expect("removal recovery"); - assert!(removed.accounts().is_empty()); - assert_eq!(removed.selected_account(), None); - } - - #[test] - fn bootstrap_recovery_completes_credential_deleted_removal_and_fallback() { - let directory = tempdir().expect("directory"); - let path = directory.path().join("studio.sqlite3"); - let secrets = InMemorySecretStore::default(); - let first; - let removed; - { - let adapter = - PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter"); - adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); - first = adapter - .generate_account(&secrets, &FixedClock) - .expect("first") - .account() - .public_key(); - removed = adapter - .generate_account(&secrets, &FixedClock) - .expect("removed") - .account() - .public_key(); - let operation = adapter - .database() - .begin_operation(AccountOperationKind::Remove, removed, FixedClock.now()) - .expect("intent"); - secrets.delete(removed).expect("credential deletion"); - adapter - .database() - .update_operation( - operation, - AccountOperationPhase::CredentialDeleted, - FixedClock.now(), - None, - ) - .expect("phase"); - } - - let reopened = - PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen"); - let restored = reopened - .bootstrap(&secrets, &FixedClock) - .expect("recover and bootstrap"); - assert_eq!(restored.accounts().len(), 1); - assert_eq!(restored.selected_account(), Some(first)); - assert_eq!(restored.session(), SessionState::SignedOut); - assert!( - reopened - .database() - .list_pending_operations() - .expect("journal") - .is_empty() - ); - assert!( - reopened - .database() - .find_account(removed) - .expect("removed") - .is_none() - ); - } - - #[test] - fn bootstrap_skips_keyring_when_journal_empty_and_retains_failed_intent() { - let empty = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("empty"); - let unavailable = FailureSecretStore::default(); - unavailable.fail_next(SecretStoreOperation::Delete); - empty - .bootstrap(&unavailable, &FixedClock) - .expect("empty journal does not access keyring"); - - let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); - adapter - .database() - .insert_account(&account()) - .expect("account"); - adapter - .database() - .save_selected_account(Some(account().public_key())) - .expect("selection"); - adapter - .database() - .begin_operation( - AccountOperationKind::Remove, - account().public_key(), - FixedClock.now(), - ) - .expect("intent"); - let failing = FailureSecretStore::default(); - failing.fail_next(SecretStoreOperation::Delete); - let error = adapter - .bootstrap(&failing, &FixedClock) - .expect_err("keyring unavailable"); - assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); - let pending = adapter - .database() - .list_pending_operations() - .expect("pending"); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].phase(), AccountOperationPhase::IntentRecorded); - } -} diff --git a/crates/studio_runtime/src/runtime_actor.rs b/crates/studio_runtime/src/runtime_actor.rs @@ -1,2276 +0,0 @@ -use std::collections::BTreeMap; -use std::future::Future; -use std::num::{NonZeroU64, NonZeroUsize}; -use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use radroots_studio_application::{ - ActorMailbox, AppSnapshot, ChangeSubscriptionId, Clock, CommandContext, CommandEnvelope, - CommandReceipt, CommandResult, CommandSubmission, DurableRequestId, ForegroundSessionBinding, - GenerateAccountReceipt, GeneratedKeyRecoveryHandle, GeneratedKeyStage, ImportAccountReceipt, - LifecycleGate, NostrClient, OrderedSnapshotChanges, ProfileFetchResult, ProfileRefreshPlan, - RecoveryStageId, RelayConfiguration, RemovalConfirmationToken, RequestId, RuntimeCommandClass, - RuntimeLifecycle, SecretStore, SessionGeneration, SnapshotChange, SnapshotChangeReceiver, - SnapshotRevision, StagedGeneratedKey, TaskCorrelation, -}; -use radroots_studio_domain::{ - AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, - SafeMessage, SecretKeyInput, -}; -use tokio::runtime::Handle; -use tokio::sync::{mpsc, oneshot, watch}; - -use crate::blocking::{BlockingExecutionError, BoundedBlockingExecutor}; -use crate::{InstallationIdentity, InstallationIdentitySource, PersistentAppCore}; - -const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); -const DEFAULT_TASK_CAPACITY: usize = 64; -const DEFAULT_BLOCKING_CAPACITY: usize = 4; - -enum RuntimeCommand { - Snapshot, - GenerateAccount { - durable_request: DurableRequestId, - expected_revision: u64, - }, - BeginGeneratedKeyStage, - AcknowledgeGeneratedKeyStage { - id: RecoveryStageId, - durable_request: DurableRequestId, - }, - CancelGeneratedKeyStage, - ImportSecretKey { - input: SecretKeyInput, - durable_request: DurableRequestId, - expected_revision: u64, - }, - SelectAccount(PublicKey), - ActivateAccount(PublicKey), - SignOut, - RefreshActiveProfile, - RequestAccountRemoval(PublicKey), - ConfirmAccountRemoval { - token: RemovalConfirmationToken, - durable_request: DurableRequestId, - }, - SubscribeChanges(NonZeroUsize), - UnsubscribeChanges(ChangeSubscriptionId), - Close, -} - -enum RuntimeCommandValue { - Snapshot(Box<AppSnapshot>), - Generated(GenerateAccountReceipt), - GeneratedKeyStage(GeneratedKeyRecoveryHandle), - GeneratedKeyStageCancelled(bool), - Imported(ImportAccountReceipt), - RemovalRequest(RemovalConfirmationToken), - Subscription(RuntimeChangeSubscription), - Unsubscribed(bool), - Closed, -} - -impl RuntimeCommand { - const fn class(&self) -> RuntimeCommandClass { - match self { - Self::Snapshot | Self::SubscribeChanges(_) | Self::UnsubscribeChanges(_) => { - RuntimeCommandClass::Observe - } - Self::GenerateAccount { .. } - | Self::BeginGeneratedKeyStage - | Self::AcknowledgeGeneratedKeyStage { .. } - | Self::ImportSecretKey { .. } - | Self::ActivateAccount(_) - | Self::ConfirmAccountRemoval { .. } => RuntimeCommandClass::UseCredential, - Self::SelectAccount(_) - | Self::SignOut - | Self::RequestAccountRemoval(_) - | Self::CancelGeneratedKeyStage => RuntimeCommandClass::MutateLocalState, - Self::RefreshActiveProfile => RuntimeCommandClass::UseRelay, - Self::Close => RuntimeCommandClass::Shutdown, - } - } - - const fn resolves_revision_through_durable_replay(&self) -> bool { - matches!( - self, - Self::GenerateAccount { .. } | Self::ImportSecretKey { .. } - ) - } -} - -struct RuntimeActor { - adapter: Arc<PersistentAppCore>, - secrets: Arc<dyn SecretStore>, - clock: Arc<dyn Clock>, - nostr: Arc<dyn NostrClient>, - lifecycle: Arc<Mutex<LifecycleGate>>, - runtime: Handle, - blocking: BoundedBlockingExecutor, - session_generation: SessionGeneration, - published_session_generation: Arc<AtomicU64>, - profile_tasks: BTreeMap<RequestId, PendingProfileTask>, - changes: OrderedSnapshotChanges, - published_foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>, - generated_key_stage: GeneratedKeyStage, -} - -struct PendingProfileTask { - correlation: TaskCorrelation, - plan: ProfileRefreshPlan, - deadline: Instant, - reply: oneshot::Sender<CommandReceipt<RuntimeCommandValue>>, - handle: tokio::task::JoinHandle<()>, -} - -struct ProfileCompletion { - request_id: RequestId, - result: Result<ProfileFetchResult, SafeError>, -} - -#[derive(Clone)] -pub struct RuntimeActorHandle { - mailbox: ActorMailbox<RuntimeCommand, RuntimeCommandValue>, - adapter: Arc<PersistentAppCore>, - lifecycle: Arc<Mutex<LifecycleGate>>, - next_request: Arc<AtomicU64>, - session_generation: Arc<AtomicU64>, - foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>, - installation_identity: InstallationIdentity, - runtime: Handle, - actor_task: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>, - actor_exit: watch::Receiver<bool>, -} - -#[derive(Clone)] -pub struct RuntimeDependencies { - secrets: Arc<dyn SecretStore>, - clock: Arc<dyn Clock>, - nostr: Arc<dyn NostrClient>, - installation_source: Arc<dyn InstallationIdentitySource>, -} - -impl RuntimeDependencies { - #[must_use] - pub fn new( - secrets: Arc<dyn SecretStore>, - clock: Arc<dyn Clock>, - nostr: Arc<dyn NostrClient>, - installation_source: Arc<dyn InstallationIdentitySource>, - ) -> Self { - Self { - secrets, - clock, - nostr, - installation_source, - } - } -} - -pub struct RuntimeChangeSubscription { - id: ChangeSubscriptionId, - receiver: SnapshotChangeReceiver, -} - -impl RuntimeChangeSubscription { - #[must_use] - pub const fn id(&self) -> ChangeSubscriptionId { - self.id - } - - pub async fn receive(&mut self) -> Option<SnapshotChange> { - self.receiver.receive().await - } -} - -impl RuntimeActorHandle { - /// Opens, migrates, recovers, and starts one actor-owned file-backed runtime. - /// - /// # Errors - /// - /// Returns a safe storage, recovery, or lifecycle error before the actor is - /// published when opening cannot reach ready state. - pub async fn open( - path: &Path, - relay_configuration: RelayConfiguration, - dependencies: RuntimeDependencies, - capacity: NonZeroUsize, - runtime: &Handle, - ) -> Result<Self, SafeError> { - let blocking = BoundedBlockingExecutor::new(DEFAULT_BLOCKING_CAPACITY, runtime); - let path = path.to_path_buf(); - let adapter = blocking - .execute(Instant::now() + DEFAULT_COMMAND_TIMEOUT, move || { - PersistentAppCore::open(&path, relay_configuration) - }) - .await - .map_err(blocking_execution_failed)??; - Self::start(adapter, dependencies, capacity, runtime, blocking).await - } - - /// Starts one isolated actor-owned in-memory runtime for tests. - /// - /// # Errors - /// - /// Returns a safe storage, recovery, or lifecycle error before publication. - pub async fn in_memory( - relay_configuration: RelayConfiguration, - dependencies: RuntimeDependencies, - capacity: NonZeroUsize, - runtime: &Handle, - ) -> Result<Self, SafeError> { - let blocking = BoundedBlockingExecutor::new(DEFAULT_BLOCKING_CAPACITY, runtime); - let adapter = blocking - .execute(Instant::now() + DEFAULT_COMMAND_TIMEOUT, move || { - PersistentAppCore::in_memory(relay_configuration) - }) - .await - .map_err(blocking_execution_failed)??; - Self::start(adapter, dependencies, capacity, runtime, blocking).await - } - - async fn start( - adapter: PersistentAppCore, - dependencies: RuntimeDependencies, - capacity: NonZeroUsize, - runtime: &Handle, - blocking: BoundedBlockingExecutor, - ) -> Result<Self, SafeError> { - let mut gate = LifecycleGate::opening(); - gate.begin_compatibility_check()?; - gate.compatibility_accepted()?; - gate.ownership_acquired()?; - gate.migration_complete()?; - let RuntimeDependencies { - secrets, - clock, - nostr, - installation_source, - } = dependencies; - let adapter = Arc::new(adapter); - let bootstrap_adapter = Arc::clone(&adapter); - let bootstrap_secrets = Arc::clone(&secrets); - let bootstrap_clock = Arc::clone(&clock); - let installation_identity = blocking - .execute(Instant::now() + DEFAULT_COMMAND_TIMEOUT, move || { - bootstrap_adapter - .bootstrap(bootstrap_secrets.as_ref(), bootstrap_clock.as_ref())?; - bootstrap_adapter.initialize_installation_identity(installation_source.as_ref()) - }) - .await - .map_err(blocking_execution_failed)??; - gate.recovery_complete()?; - - let lifecycle = Arc::new(Mutex::new(gate)); - let (mailbox, receiver) = ActorMailbox::bounded(capacity); - let session_generation = Arc::new(AtomicU64::new(SessionGeneration::initial().value())); - let foreground_session = Arc::new(Mutex::new(None)); - let changes = OrderedSnapshotChanges::new(adapter.core().snapshot()); - let actor = RuntimeActor { - adapter: Arc::clone(&adapter), - secrets, - clock, - nostr, - lifecycle: Arc::clone(&lifecycle), - runtime: runtime.clone(), - blocking, - session_generation: SessionGeneration::initial(), - published_session_generation: Arc::clone(&session_generation), - profile_tasks: BTreeMap::new(), - changes, - published_foreground_session: Arc::clone(&foreground_session), - generated_key_stage: GeneratedKeyStage::default(), - }; - let (actor_exit_sender, actor_exit) = watch::channel(false); - let actor_task = runtime.spawn(async move { - actor.run(receiver).await; - let _ = actor_exit_sender.send(true); - }); - Ok(Self { - mailbox, - adapter, - lifecycle, - next_request: Arc::new(AtomicU64::new(1)), - session_generation, - foreground_session, - installation_identity, - runtime: runtime.clone(), - actor_task: Arc::new(Mutex::new(Some(actor_task))), - actor_exit, - }) - } - - #[must_use] - pub fn lifecycle(&self) -> RuntimeLifecycle { - self.lifecycle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .lifecycle() - } - - #[must_use] - pub fn session_generation(&self) -> SessionGeneration { - SessionGeneration::from_value(self.session_generation.load(Ordering::Acquire)) - } - - #[must_use] - pub fn foreground_session(&self) -> Option<ForegroundSessionBinding> { - self.foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() - } - - #[must_use] - pub fn installation_identity(&self) -> &InstallationIdentity { - &self.installation_identity - } - - #[must_use] - pub fn snapshot(&self) -> AppSnapshot { - self.adapter.core().snapshot() - } - - /// Returns the ready snapshot through the actor command boundary. - /// - /// # Errors - /// - /// Returns a typed safe actor error. - pub async fn bootstrap(&self) -> Result<AppSnapshot, SafeError> { - Self::expect_snapshot(self.dispatch(RuntimeCommand::Snapshot, None).await?) - } - - /// Generates one account through the serialized actor boundary. - /// - /// # Errors - /// - /// Returns a safe account, storage, keyring, timeout, or actor error. - pub async fn generate_account( - &self, - request: DurableRequestId, - expected_revision: SnapshotRevision, - timeout: Duration, - ) -> Result<GenerateAccountReceipt, SafeError> { - match self - .dispatch_durable( - RuntimeCommand::GenerateAccount { - durable_request: request, - expected_revision: expected_revision.value(), - }, - expected_revision, - timeout, - ) - .await? - { - RuntimeCommandValue::Generated(receipt) => Ok(receipt), - _ => Err(invalid_actor_response()), - } - } - - /// Begins the only actor-owned generated-key recovery stage. - /// - /// # Errors - /// - /// Returns a safe conflict, timeout, key-generation, or actor error. - pub async fn begin_generated_key_stage(&self) -> Result<GeneratedKeyRecoveryHandle, SafeError> { - match self - .dispatch(RuntimeCommand::BeginGeneratedKeyStage, None) - .await? - { - RuntimeCommandValue::GeneratedKeyStage(view) => Ok(view), - _ => Err(invalid_actor_response()), - } - } - - /// Acknowledges recovery and commits the staged account and credential once. - /// - /// # Errors - /// - /// Returns a safe unavailable, conflict, keyring, storage, timeout, or actor error. - pub async fn acknowledge_generated_key_stage( - &self, - id: RecoveryStageId, - request: DurableRequestId, - expected_revision: SnapshotRevision, - timeout: Duration, - ) -> Result<AppSnapshot, SafeError> { - let value = self - .dispatch_durable( - RuntimeCommand::AcknowledgeGeneratedKeyStage { - id, - durable_request: request, - }, - expected_revision, - timeout, - ) - .await?; - Self::expect_snapshot(value) - } - - /// Cancels and zeroizes the active generated-key stage, if present. - /// - /// # Errors - /// - /// Returns a safe timeout or actor error. - pub async fn cancel_generated_key_stage(&self) -> Result<bool, SafeError> { - match self - .dispatch(RuntimeCommand::CancelGeneratedKeyStage, None) - .await? - { - RuntimeCommandValue::GeneratedKeyStageCancelled(cancelled) => Ok(cancelled), - _ => Err(invalid_actor_response()), - } - } - - /// Imports one account through the serialized actor boundary. - /// - /// # Errors - /// - /// Returns a safe account, storage, keyring, timeout, or actor error. - pub async fn import_secret_key( - &self, - request: DurableRequestId, - expected_revision: SnapshotRevision, - input: SecretKeyInput, - timeout: Duration, - ) -> Result<ImportAccountReceipt, SafeError> { - match self - .dispatch_durable( - RuntimeCommand::ImportSecretKey { - input, - durable_request: request, - expected_revision: expected_revision.value(), - }, - expected_revision, - timeout, - ) - .await? - { - RuntimeCommandValue::Imported(receipt) => Ok(receipt), - _ => Err(invalid_actor_response()), - } - } - - /// Selects one account through the serialized actor boundary. - /// - /// # Errors - /// - /// Returns a safe account, storage, timeout, or actor error. - pub async fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { - let value = self - .dispatch(RuntimeCommand::SelectAccount(public_key), None) - .await?; - Self::expect_snapshot(value) - } - - /// Activates one account through the serialized actor boundary. - /// - /// # Errors - /// - /// Returns a safe account, credential, storage, timeout, or actor error. - pub async fn activate_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { - let value = self - .dispatch(RuntimeCommand::ActivateAccount(public_key), None) - .await?; - Self::expect_snapshot(value) - } - - /// Signs out through the serialized actor boundary. - /// - /// # Errors - /// - /// Returns a safe timeout or actor error. - pub async fn sign_out(&self) -> Result<AppSnapshot, SafeError> { - let value = self.dispatch(RuntimeCommand::SignOut, None).await?; - Self::expect_snapshot(value) - } - - /// Refreshes the active profile through the serialized actor boundary. - /// - /// # Errors - /// - /// Returns a safe relay, storage, timeout, or actor error. - pub async fn refresh_active_profile(&self) -> Result<AppSnapshot, SafeError> { - let value = self - .dispatch(RuntimeCommand::RefreshActiveProfile, None) - .await?; - Self::expect_snapshot(value) - } - - /// Creates one removal request through the serialized actor boundary. - /// - /// # Errors - /// - /// Returns a safe account, timeout, or actor error. - pub async fn request_account_removal( - &self, - public_key: PublicKey, - ) -> Result<RemovalConfirmationToken, SafeError> { - match self - .dispatch(RuntimeCommand::RequestAccountRemoval(public_key), None) - .await? - { - RuntimeCommandValue::RemovalRequest(token) => Ok(token), - _ => Err(invalid_actor_response()), - } - } - - /// Confirms one removal through the serialized actor boundary. - /// - /// # Errors - /// - /// Returns a safe account, credential, storage, timeout, or actor error. - pub async fn confirm_account_removal( - &self, - token: RemovalConfirmationToken, - request: DurableRequestId, - expected_revision: SnapshotRevision, - timeout: Duration, - ) -> Result<AppSnapshot, SafeError> { - let value = self - .dispatch_durable( - RuntimeCommand::ConfirmAccountRemoval { - token, - durable_request: request, - }, - expected_revision, - timeout, - ) - .await?; - Self::expect_snapshot(value) - } - - /// Closes command admission and cancels supervised work. - /// - /// # Errors - /// - /// Returns a safe timeout or actor error. Repeated calls return closed. - pub async fn close(&self) -> Result<(), SafeError> { - self.close_with_timeout(DEFAULT_COMMAND_TIMEOUT).await - } - - /// Closes the runtime within the supplied command deadline. - /// - /// # Errors - /// - /// Returns a safe timeout or actor error. An expired queued close cannot - /// later change runtime state. - pub async fn close_with_timeout(&self, timeout: Duration) -> Result<(), SafeError> { - let deadline = Instant::now() + timeout; - if matches!(self.lifecycle(), RuntimeLifecycle::Closed) { - return self.await_actor_exit(deadline).await; - } - let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed); - let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?; - let command_result = match self - .dispatch_with_deadline(RuntimeCommand::Close, None, request_id, deadline) - .await - { - Ok(RuntimeCommandValue::Closed) => Ok(()), - Ok(_) => Err(invalid_actor_response()), - Err(error) => Err(error), - }; - let exit_result = self.await_actor_exit(deadline).await; - if matches!(self.lifecycle(), RuntimeLifecycle::Closed) { - exit_result - } else { - command_result.and(exit_result) - } - } - - async fn await_actor_exit(&self, deadline: Instant) -> Result<(), SafeError> { - let mut actor_exit = self.actor_exit.clone(); - if !*actor_exit.borrow() { - let remaining = deadline.saturating_duration_since(Instant::now()); - self.timeout(remaining, actor_exit.wait_for(|exited| *exited)) - .await - .map_err(|_| command_timed_out())? - .map_err(|_| runtime_closed())?; - } - let actor_task = self - .actor_task - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - if let Some(actor_task) = actor_task { - let remaining = deadline.saturating_duration_since(Instant::now()); - self.timeout(remaining, actor_task) - .await - .map_err(|_| command_timed_out())? - .map_err(|_| runtime_closed())?; - } - Ok(()) - } - - /// Atomically registers a bounded ordered change consumer with its initial snapshot. - /// - /// # Errors - /// - /// Returns a safe actor or subscription error. - pub async fn subscribe_changes( - &self, - capacity: NonZeroUsize, - ) -> Result<RuntimeChangeSubscription, SafeError> { - match self - .dispatch(RuntimeCommand::SubscribeChanges(capacity), None) - .await? - { - RuntimeCommandValue::Subscription(subscription) => Ok(subscription), - _ => Err(invalid_actor_response()), - } - } - - /// Removes a change consumer through the serialized actor boundary. - /// - /// # Errors - /// - /// Returns a safe actor error. - pub async fn unsubscribe_changes(&self, id: ChangeSubscriptionId) -> Result<bool, SafeError> { - match self - .dispatch(RuntimeCommand::UnsubscribeChanges(id), None) - .await? - { - RuntimeCommandValue::Unsubscribed(removed) => Ok(removed), - _ => Err(invalid_actor_response()), - } - } - - async fn dispatch( - &self, - command: RuntimeCommand, - expected_revision: Option<SnapshotRevision>, - ) -> Result<RuntimeCommandValue, SafeError> { - let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed); - let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?; - self.dispatch_with_deadline( - command, - expected_revision, - request_id, - Instant::now() + DEFAULT_COMMAND_TIMEOUT, - ) - .await - } - - async fn dispatch_durable( - &self, - command: RuntimeCommand, - expected_revision: SnapshotRevision, - timeout: Duration, - ) -> Result<RuntimeCommandValue, SafeError> { - let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed); - let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?; - self.dispatch_with_deadline( - command, - Some(expected_revision), - request_id, - Instant::now() + timeout, - ) - .await - } - - async fn dispatch_with_deadline( - &self, - command: RuntimeCommand, - expected_revision: Option<SnapshotRevision>, - request_id: RequestId, - deadline: Instant, - ) -> Result<RuntimeCommandValue, SafeError> { - let context = CommandContext::new(request_id, expected_revision, deadline); - let receipt = match self.mailbox.submit(context, command) { - CommandSubmission::Accepted(ticket) => { - let remaining = deadline.saturating_duration_since(Instant::now()); - match self.timeout(remaining, ticket.receipt()).await { - Ok(receipt) => receipt, - Err(_) => CommandReceipt::new(request_id, CommandResult::TimedOut), - } - } - CommandSubmission::Rejected(receipt) => receipt, - }; - match receipt.into_result() { - CommandResult::Completed(value) => Ok(value), - CommandResult::Conflicted { .. } => Err(command_conflicted()), - CommandResult::Rejected(_) => Err(command_rejected()), - CommandResult::TimedOut => Err(command_timed_out()), - CommandResult::Closed => Err(runtime_closed()), - CommandResult::Failed(error) => Err(error), - } - } - - fn timeout<F>(&self, duration: Duration, future: F) -> tokio::time::Timeout<F> - where - F: Future, - { - let _guard = self.runtime.enter(); - tokio::time::timeout(duration, future) - } - - #[cfg(test)] - async fn import_secret_key_test( - &self, - input: SecretKeyInput, - ) -> Result<ImportAccountReceipt, SafeError> { - let request_number = self.next_request.fetch_add(1, Ordering::Relaxed); - self.import_secret_key( - DurableRequestId::parse(format!("test:import:{request_number}"))?, - self.snapshot().revision(), - input, - DEFAULT_COMMAND_TIMEOUT, - ) - .await - } - - #[cfg(test)] - async fn acknowledge_generated_key_stage_test( - &self, - id: RecoveryStageId, - ) -> Result<AppSnapshot, SafeError> { - let request_number = self.next_request.fetch_add(1, Ordering::Relaxed); - self.acknowledge_generated_key_stage( - id, - DurableRequestId::parse(format!("test:generate:{request_number}"))?, - self.snapshot().revision(), - DEFAULT_COMMAND_TIMEOUT, - ) - .await - } - - #[cfg(test)] - async fn confirm_account_removal_test( - &self, - token: RemovalConfirmationToken, - ) -> Result<AppSnapshot, SafeError> { - let request_number = self.next_request.fetch_add(1, Ordering::Relaxed); - self.confirm_account_removal( - token, - DurableRequestId::parse(format!("test:remove:{request_number}"))?, - self.snapshot().revision(), - DEFAULT_COMMAND_TIMEOUT, - ) - .await - } - - #[cfg(test)] - async fn import_secret_key_with_timeout( - &self, - input: SecretKeyInput, - timeout: Duration, - ) -> Result<ImportAccountReceipt, SafeError> { - let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed); - let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?; - let expected_revision = self.adapter.core().snapshot().revision(); - let durable_request = DurableRequestId::parse(format!("test:timeout:{raw_request}"))?; - match self - .dispatch_with_deadline( - RuntimeCommand::ImportSecretKey { - input, - durable_request, - expected_revision: expected_revision.value(), - }, - Some(expected_revision), - request_id, - Instant::now() + timeout, - ) - .await? - { - RuntimeCommandValue::Imported(receipt) => Ok(receipt), - _ => Err(invalid_actor_response()), - } - } - - fn expect_snapshot(value: RuntimeCommandValue) -> Result<AppSnapshot, SafeError> { - match value { - RuntimeCommandValue::Snapshot(snapshot) => Ok(*snapshot), - _ => Err(invalid_actor_response()), - } - } -} - -impl RuntimeActor { - async fn run( - mut self, - mut receiver: mpsc::Receiver<CommandEnvelope<RuntimeCommand, RuntimeCommandValue>>, - ) { - let (completion_sender, mut completions) = mpsc::channel(DEFAULT_TASK_CAPACITY); - loop { - tokio::select! { - envelope = receiver.recv() => { - let Some(envelope) = envelope else { - break; - }; - if !self.handle_command(envelope, &completion_sender).await { - break; - } - } - completion = completions.recv(), if !self.profile_tasks.is_empty() => { - if let Some(completion) = completion { - self.complete_profile_task(completion).await; - } - } - } - } - self.cancel_profile_tasks(None).await; - } - - async fn handle_command( - &mut self, - envelope: CommandEnvelope<RuntimeCommand, RuntimeCommandValue>, - completion_sender: &mpsc::Sender<ProfileCompletion>, - ) -> bool { - let (context, command, reply) = envelope.into_parts(); - if let Some(result) = self.preflight(context, &command) { - let _ = reply.send(CommandReceipt::new(context.request_id(), result)); - return true; - } - if matches!(command, RuntimeCommand::RefreshActiveProfile) { - self.start_profile_task(context, reply, completion_sender.clone()) - .await; - return true; - } - if matches!(command, RuntimeCommand::Close) { - let result = self.close_actor().await; - let closed = matches!(result, CommandResult::Completed(_)); - let _ = reply.send(CommandReceipt::new(context.request_id(), result)); - return !closed; - } - let changes_session = matches!( - command, - RuntimeCommand::ActivateAccount(_) - | RuntimeCommand::SignOut - | RuntimeCommand::ConfirmAccountRemoval { .. } - ); - let begins_generated_recovery = matches!(&command, RuntimeCommand::BeginGeneratedKeyStage); - let result = self.execute_command(context, command).await; - if begins_generated_recovery && matches!(&result, CommandResult::Completed(_)) { - let snapshot = self.adapter.core().snapshot(); - self.cancel_profile_tasks(Some(&snapshot)).await; - } - if changes_session && matches!(result, CommandResult::Completed(_)) { - self.advance_session_generation().await; - self.synchronize_foreground_session(); - } - if matches!(result, CommandResult::Completed(_)) { - self.changes.publish(self.adapter.core().snapshot()); - } - let _ = reply.send(CommandReceipt::new(context.request_id(), result)); - true - } - - fn preflight( - &self, - context: CommandContext, - command: &RuntimeCommand, - ) -> Option<CommandResult<RuntimeCommandValue>> { - if context.is_expired(Instant::now()) { - return Some(CommandResult::TimedOut); - } - let lifecycle = self - .lifecycle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .to_owned(); - if matches!(lifecycle.lifecycle(), RuntimeLifecycle::Closed) { - return Some(CommandResult::Closed); - } - if !lifecycle.allows(command.class()) { - return Some(CommandResult::Failed(command_unavailable())); - } - if self.generated_key_stage.pending().is_some() - && !matches!( - command, - RuntimeCommand::Snapshot - | RuntimeCommand::AcknowledgeGeneratedKeyStage { .. } - | RuntimeCommand::CancelGeneratedKeyStage - | RuntimeCommand::SubscribeChanges(_) - | RuntimeCommand::UnsubscribeChanges(_) - | RuntimeCommand::Close - ) - { - return Some(CommandResult::Failed(generated_recovery_route_active())); - } - let current_revision = self.adapter.core().snapshot().revision(); - if !command.resolves_revision_through_durable_replay() - && context - .expected_revision() - .is_some_and(|expected| expected != current_revision) - { - return Some(CommandResult::Conflicted { current_revision }); - } - None - } - - async fn execute_command( - &mut self, - context: CommandContext, - command: RuntimeCommand, - ) -> CommandResult<RuntimeCommandValue> { - let result = match command { - RuntimeCommand::Snapshot => Ok(RuntimeCommandValue::Snapshot(Box::new( - self.adapter.core().snapshot(), - ))), - RuntimeCommand::GenerateAccount { - durable_request, - expected_revision, - } => { - self.run_blocking(context.deadline(), move |adapter, secrets, clock| { - adapter - .generate_account_durable( - &durable_request, - expected_revision, - secrets.as_ref(), - clock.as_ref(), - ) - .map(RuntimeCommandValue::Generated) - }) - .await - } - RuntimeCommand::BeginGeneratedKeyStage => { - match NonZeroU64::new(context.request_id().get()).map(RecoveryStageId::new) { - Some(stage_id) => self - .generated_key_stage - .begin( - self.adapter.key_material(), - stage_id, - self.adapter.core().snapshot().revision().value(), - self.clock.now(), - ) - .map(RuntimeCommandValue::GeneratedKeyStage), - None => Err(request_space_exhausted()), - } - } - RuntimeCommand::AcknowledgeGeneratedKeyStage { - id, - durable_request, - } => match self.generated_key_stage.take(id, self.clock.now()) { - Ok(staged) => { - self.run_blocking(context.deadline(), move |adapter, secrets, clock| { - commit_generated_key_stage( - adapter.as_ref(), - secrets.as_ref(), - clock.as_ref(), - &durable_request, - staged, - ) - }) - .await - } - Err(error) => Err(error), - }, - RuntimeCommand::CancelGeneratedKeyStage => Ok( - RuntimeCommandValue::GeneratedKeyStageCancelled(self.generated_key_stage.cancel()), - ), - RuntimeCommand::ImportSecretKey { - input, - durable_request, - expected_revision, - } => { - self.run_blocking(context.deadline(), move |adapter, secrets, clock| { - adapter - .import_secret_key_durable( - &durable_request, - expected_revision, - input, - secrets.as_ref(), - clock.as_ref(), - ) - .map(RuntimeCommandValue::Imported) - }) - .await - } - RuntimeCommand::SelectAccount(public_key) => { - self.run_blocking(context.deadline(), move |adapter, _, _| { - adapter - .select_account(public_key) - .map(Box::new) - .map(RuntimeCommandValue::Snapshot) - }) - .await - } - RuntimeCommand::ActivateAccount(public_key) => { - self.run_blocking(context.deadline(), move |adapter, secrets, clock| { - adapter - .activate_account(public_key, secrets.as_ref(), clock.as_ref()) - .map(Box::new) - .map(RuntimeCommandValue::Snapshot) - }) - .await - } - RuntimeCommand::SignOut => self - .adapter - .sign_out() - .map(Box::new) - .map(RuntimeCommandValue::Snapshot), - RuntimeCommand::RequestAccountRemoval(public_key) => self - .adapter - .request_account_removal(public_key, self.clock.as_ref()) - .map(RuntimeCommandValue::RemovalRequest), - RuntimeCommand::ConfirmAccountRemoval { - token, - durable_request, - } => { - self.run_blocking(context.deadline(), move |adapter, secrets, clock| { - adapter - .confirm_account_removal_durable( - &durable_request, - token, - secrets.as_ref(), - clock.as_ref(), - ) - .map(Box::new) - .map(RuntimeCommandValue::Snapshot) - }) - .await - } - RuntimeCommand::SubscribeChanges(capacity) => self - .changes - .subscribe(capacity) - .map(|(id, receiver)| { - RuntimeCommandValue::Subscription(RuntimeChangeSubscription { id, receiver }) - }) - .ok_or_else(observer_registration_failed), - RuntimeCommand::UnsubscribeChanges(id) => Ok(RuntimeCommandValue::Unsubscribed( - self.changes.unsubscribe(id), - )), - RuntimeCommand::Close | RuntimeCommand::RefreshActiveProfile => { - Err(invalid_actor_response()) - } - }; - result.map_or_else(CommandResult::Failed, CommandResult::Completed) - } - - async fn run_blocking<F>( - &self, - deadline: Instant, - operation: F, - ) -> Result<RuntimeCommandValue, SafeError> - where - F: FnOnce( - Arc<PersistentAppCore>, - Arc<dyn SecretStore>, - Arc<dyn Clock>, - ) -> Result<RuntimeCommandValue, SafeError> - + Send - + 'static, - { - let adapter = Arc::clone(&self.adapter); - let secrets = Arc::clone(&self.secrets); - let clock = Arc::clone(&self.clock); - self.blocking - .execute(deadline, move || operation(adapter, secrets, clock)) - .await - .map_err(blocking_execution_failed)? - } - - async fn start_profile_task( - &mut self, - context: CommandContext, - reply: oneshot::Sender<CommandReceipt<RuntimeCommandValue>>, - completion_sender: mpsc::Sender<ProfileCompletion>, - ) { - let foreground = self - .published_foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone(); - let plan = match self.adapter.core().begin_profile_refresh() { - Ok(Some(plan)) => plan, - Ok(None) => { - let _ = reply.send(CommandReceipt::new( - context.request_id(), - CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new( - self.adapter.core().snapshot(), - ))), - )); - return; - } - Err(error) => { - let _ = reply.send(CommandReceipt::new( - context.request_id(), - CommandResult::Failed(error), - )); - return; - } - }; - let Some(foreground) = foreground.filter(|binding| { - binding.identity().public_key() == plan.public_key() - && binding.generation() == self.session_generation - }) else { - let _ = reply.send(CommandReceipt::new( - context.request_id(), - CommandResult::Failed(stale_profile_binding()), - )); - return; - }; - let correlation = TaskCorrelation::new( - context.request_id(), - plan.public_key(), - foreground.signer(), - plan.expected_revision(), - self.session_generation, - ); - let client = Arc::clone(&self.nostr); - let relays = plan.relays().to_vec(); - let request_id = context.request_id(); - let handle = self.runtime.spawn(async move { - let result = client - .fetch_profile(correlation.account(), &relays, context.deadline()) - .await; - let _ = completion_sender - .send(ProfileCompletion { request_id, result }) - .await; - }); - let previous = self.profile_tasks.insert( - request_id, - PendingProfileTask { - correlation, - plan, - deadline: context.deadline(), - reply, - handle, - }, - ); - if let Some(previous) = previous { - self.lifecycle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .fail(request_space_exhausted()); - previous.handle.abort(); - let _ = previous.handle.await; - let _ = previous.reply.send(CommandReceipt::new( - previous.correlation.request_id(), - CommandResult::Failed(request_space_exhausted()), - )); - self.cancel_profile_tasks(None).await; - } - } - - async fn close_actor(&mut self) -> CommandResult<RuntimeCommandValue> { - let transition = (|| { - let mut lifecycle = self - .lifecycle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - lifecycle.begin_shutdown()?; - lifecycle.finish_shutdown() - })(); - match transition { - Ok(()) => { - self.generated_key_stage.cancel(); - self.cancel_profile_tasks(None).await; - self.changes.close(); - *self - .published_foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; - CommandResult::Completed(RuntimeCommandValue::Closed) - } - Err(error) => CommandResult::Failed(error), - } - } - - async fn complete_profile_task(&mut self, completion: ProfileCompletion) { - let Some(task) = self.profile_tasks.remove(&completion.request_id) else { - return; - }; - let _ = task.handle.await; - let current = self.adapter.core().snapshot(); - let foreground = self - .published_foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone(); - let correlated = task.correlation.session_generation() == self.session_generation - && foreground.is_some_and(|binding| { - binding.generation() == task.correlation.session_generation() - && binding.identity().public_key() == task.correlation.account() - && binding.signer() == task.correlation.binding() - }) - && current - .active_account() - .is_some_and(|active| active.account().public_key() == task.correlation.account()); - let result = if correlated { - let plan = task.plan.clone(); - let completed = self - .run_blocking(task.deadline, move |adapter, _, clock| { - adapter - .core() - .complete_profile_refresh( - &plan, - completion.result, - adapter.database(), - clock.as_ref(), - ) - .map(Box::new) - .map(RuntimeCommandValue::Snapshot) - }) - .await; - completed.map_or_else(CommandResult::Failed, CommandResult::Completed) - } else { - CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new(current))) - }; - if matches!(result, CommandResult::Completed(_)) { - self.changes.publish(self.adapter.core().snapshot()); - } - let _ = task - .reply - .send(CommandReceipt::new(task.correlation.request_id(), result)); - } - - async fn advance_session_generation(&mut self) { - let Some(next) = self.session_generation.next() else { - self.lifecycle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .fail(request_space_exhausted()); - self.cancel_profile_tasks(None).await; - return; - }; - self.session_generation = next; - self.published_session_generation - .store(next.value(), Ordering::Release); - let snapshot = self.adapter.core().snapshot(); - self.cancel_profile_tasks(Some(&snapshot)).await; - } - - fn synchronize_foreground_session(&mut self) { - let session = self - .adapter - .core() - .snapshot() - .active_account() - .map(|active| { - let public_key = active.account().public_key(); - ForegroundSessionBinding::new( - AccountIdentity::derive(public_key)?, - LocalSignerBinding::new(public_key, BindingAvailability::Available), - self.session_generation, - ) - }); - let session = match session.transpose() { - Ok(session) => session, - Err(error) => { - self.lifecycle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .fail(error); - None - } - }; - *self - .published_foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = session; - } - - async fn cancel_profile_tasks(&mut self, snapshot: Option<&AppSnapshot>) { - let tasks = std::mem::take(&mut self.profile_tasks); - for (_, task) in tasks { - task.handle.abort(); - let _ = task.handle.await; - let receipt_result = snapshot.map_or(CommandResult::Closed, |snapshot| { - CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new(snapshot.clone()))) - }); - let _ = task.reply.send(CommandReceipt::new( - task.correlation.request_id(), - receipt_result, - )); - } - } -} - -fn commit_generated_key_stage( - adapter: &PersistentAppCore, - secrets: &dyn SecretStore, - clock: &dyn Clock, - request: &DurableRequestId, - staged: StagedGeneratedKey, -) -> Result<RuntimeCommandValue, SafeError> { - adapter.commit_staged_generated_key(request, staged, secrets, clock)?; - Ok(RuntimeCommandValue::Snapshot(Box::new( - adapter.core().snapshot(), - ))) -} - -const fn blocking_execution_failed(error: BlockingExecutionError) -> SafeError { - match error { - BlockingExecutionError::DeadlineElapsed => command_timed_out(), - BlockingExecutionError::Saturated => command_rejected(), - BlockingExecutionError::TaskFailed => SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The runtime blocking worker failed."), - ), - } -} - -const fn request_space_exhausted() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The runtime request identifier space is exhausted."), - ) -} - -const fn stale_profile_binding() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The active account binding changed before profile refresh."), - ) -} - -const fn command_conflicted() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The command conflicts with newer application state."), - ) -} - -const fn command_rejected() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The runtime is busy. Try again."), - ) -} - -const fn command_timed_out() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The runtime command timed out."), - ) -} - -const fn runtime_closed() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The application runtime is closed."), - ) -} - -const fn command_unavailable() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The command is unavailable in the current runtime state."), - ) -} - -const fn generated_recovery_route_active() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("Complete or cancel generated-key recovery before another action."), - ) -} - -const fn invalid_actor_response() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The runtime returned an invalid command response."), - ) -} - -const fn observer_registration_failed() -> SafeError { - SafeError::new( - SafeErrorCode::ObserverRegistrationFailed, - SafeMessage::new("The application change subscription could not be registered."), - ) -} - -#[cfg(test)] -mod tests { - use std::future::Future; - use std::num::NonZeroUsize; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::{Arc, Condvar, Mutex}; - use std::task::{Context, Poll, Wake, Waker}; - use std::thread::{self, Thread}; - use std::time::{Duration, Instant}; - - use radroots_studio_application::{ - BoxFuture, Clock, DurableRequestId, FailureSecretStore, ForegroundSessionBinding, - InMemorySecretStore, NostrClient, ProfileFetchResult, RelayConfiguration, RuntimeLifecycle, - SecretStore, SecretStoreOperation, SessionGeneration, SessionState, SnapshotRevision, - }; - use radroots_studio_domain::{ - AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, - RelayDestinationPolicy, RelayUrl, SafeError, SafeErrorCode, SecretKeyInput, UnixTimestamp, - }; - - use super::{ - DEFAULT_COMMAND_TIMEOUT, RuntimeActorHandle, RuntimeDependencies, command_unavailable, - }; - use crate::{InstallationIdentity, InstallationIdentitySource, UuidInstallationIdentitySource}; - - struct FixedInstallationIdentity(&'static str); - - impl InstallationIdentitySource for FixedInstallationIdentity { - fn generate(&self) -> Result<InstallationIdentity, SafeError> { - InstallationIdentity::parse(self.0) - } - } - - fn dependencies( - secrets: Arc<dyn SecretStore>, - nostr: Arc<dyn NostrClient>, - ) -> RuntimeDependencies { - RuntimeDependencies::new( - secrets, - Arc::new(FixedClock), - nostr, - Arc::new(UuidInstallationIdentitySource), - ) - } - - struct FixedClock; - - impl Clock for FixedClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(50).expect("time") - } - } - - struct OfflineNostr; - - impl NostrClient for OfflineNostr { - fn fetch_profile<'a>( - &'a self, - _public_key: PublicKey, - _relays: &'a [RelayUrl], - _deadline: Instant, - ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { - Box::pin(async { Ok(ProfileFetchResult::complete(None)) }) - } - } - - struct BlockingNostr { - started: tokio::sync::Semaphore, - release: tokio::sync::Semaphore, - } - - impl BlockingNostr { - fn new() -> Self { - Self { - started: tokio::sync::Semaphore::new(0), - release: tokio::sync::Semaphore::new(0), - } - } - } - - impl NostrClient for BlockingNostr { - fn fetch_profile<'a>( - &'a self, - _public_key: PublicKey, - _relays: &'a [RelayUrl], - _deadline: Instant, - ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { - Box::pin(async move { - self.started.add_permits(1); - let permit = self.release.acquire().await.expect("release"); - permit.forget(); - Ok(ProfileFetchResult::complete(None)) - }) - } - } - - struct BlockingSecretStore { - inner: InMemorySecretStore, - block_next_put: AtomicBool, - put_started: AtomicBool, - released: Mutex<bool>, - release_signal: Condvar, - } - - impl BlockingSecretStore { - fn new() -> Self { - Self { - inner: InMemorySecretStore::default(), - block_next_put: AtomicBool::new(true), - put_started: AtomicBool::new(false), - released: Mutex::new(false), - release_signal: Condvar::new(), - } - } - - async fn wait_until_put_started(&self) { - while !self.put_started.load(Ordering::Acquire) { - tokio::task::yield_now().await; - } - } - - fn release(&self) { - *self - .released - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = true; - self.release_signal.notify_all(); - } - } - - impl SecretStore for BlockingSecretStore { - fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> { - if self.block_next_put.swap(false, Ordering::AcqRel) { - self.put_started.store(true, Ordering::Release); - let released = self - .released - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - drop( - self.release_signal - .wait_while(released, |released| !*released) - .unwrap_or_else(std::sync::PoisonError::into_inner), - ); - } - self.inner.put(public_key, secret) - } - - fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> { - self.inner.load(public_key) - } - - fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> { - self.inner.contains(public_key) - } - - fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> { - self.inner.delete(public_key) - } - } - - async fn actor() -> (RuntimeActorHandle, Arc<InMemorySecretStore>) { - let secrets = Arc::new(InMemorySecretStore::default()); - let secret_port: Arc<dyn SecretStore> = secrets.clone(); - let actor = RuntimeActorHandle::in_memory( - RelayConfiguration::default(), - dependencies(secret_port, Arc::new(OfflineNostr)), - NonZeroUsize::new(8).expect("capacity"), - &tokio::runtime::Handle::current(), - ) - .await - .expect("actor"); - (actor, secrets) - } - - struct ThreadWake(Thread); - - impl Wake for ThreadWake { - fn wake(self: Arc<Self>) { - self.0.unpark(); - } - - fn wake_by_ref(self: &Arc<Self>) { - self.0.unpark(); - } - } - - fn block_on_without_runtime<F: Future>(future: F) -> F::Output { - let waker = Waker::from(Arc::new(ThreadWake(thread::current()))); - let mut context = Context::from_waker(&waker); - let mut future = std::pin::pin!(future); - loop { - match future.as_mut().poll(&mut context) { - Poll::Ready(output) => return output, - Poll::Pending => thread::park(), - } - } - } - - #[test] - fn actor_operations_support_foreign_executor_polling() { - let runtime = tokio::runtime::Runtime::new().expect("runtime"); - let (actor, _) = runtime.block_on(actor()); - - let snapshot = block_on_without_runtime(actor.bootstrap()).expect("bootstrap"); - assert_eq!(snapshot, actor.snapshot()); - block_on_without_runtime(actor.close()).expect("close"); - } - - #[tokio::test(flavor = "multi_thread")] - async fn installation_identity_survives_file_backed_runtime_restart() { - let directory = tempfile::tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let first = RuntimeActorHandle::open( - &path, - RelayConfiguration::default(), - RuntimeDependencies::new( - Arc::new(InMemorySecretStore::default()), - Arc::new(FixedClock), - Arc::new(OfflineNostr), - Arc::new(FixedInstallationIdentity( - "11aabbccddeeff001122334455667788", - )), - ), - NonZeroUsize::new(8).expect("capacity"), - &tokio::runtime::Handle::current(), - ) - .await - .expect("first runtime"); - assert_eq!( - first.installation_identity().as_str(), - "11aabbccddeeff001122334455667788" - ); - first.close().await.expect("first close"); - drop(first); - - let second = RuntimeActorHandle::open( - &path, - RelayConfiguration::default(), - RuntimeDependencies::new( - Arc::new(InMemorySecretStore::default()), - Arc::new(FixedClock), - Arc::new(OfflineNostr), - Arc::new(FixedInstallationIdentity( - "22aabbccddeeff001122334455667788", - )), - ), - NonZeroUsize::new(8).expect("capacity"), - &tokio::runtime::Handle::current(), - ) - .await - .expect("second runtime"); - assert_eq!( - second.installation_identity().as_str(), - "11aabbccddeeff001122334455667788" - ); - second.close().await.expect("second close"); - } - - #[tokio::test(flavor = "multi_thread")] - async fn account_mutations_run_serially_through_one_ready_actor() { - let (actor, secrets) = actor().await; - assert_eq!(actor.lifecycle(), RuntimeLifecycle::Ready); - - let imported = actor - .import_secret_key_test( - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), - ) - .expect("input"), - ) - .await - .expect("import"); - let public_key = imported.account().public_key(); - let activated = actor.activate_account(public_key).await.expect("activate"); - assert_eq!(activated.session(), SessionState::Active); - let foreground = actor.foreground_session().expect("foreground session"); - assert_eq!(foreground.identity().public_key(), public_key); - assert_eq!(foreground.signer().account(), public_key); - assert_eq!(foreground.generation(), actor.session_generation()); - assert!(secrets.contains(public_key).expect("credential")); - - let signed_out = actor.sign_out().await.expect("sign out"); - assert_eq!(signed_out.session(), SessionState::SignedOut); - assert!(actor.foreground_session().is_none()); - let removal = actor - .request_account_removal(public_key) - .await - .expect("removal request"); - let removed = actor - .confirm_account_removal_test(removal) - .await - .expect("remove"); - assert!(removed.accounts().is_empty()); - assert!(!secrets.contains(public_key).expect("credential removed")); - } - - #[tokio::test(flavor = "multi_thread")] - async fn public_actor_commands_cover_generation_selection_and_empty_profile_refresh() { - let (actor, _) = actor().await; - let unchanged = actor - .refresh_active_profile() - .await - .expect("refresh without an active account"); - assert!(unchanged.active_account().is_none()); - - let generated = actor - .generate_account( - DurableRequestId::parse("test:generate:public-surface").expect("request"), - actor.snapshot().revision(), - DEFAULT_COMMAND_TIMEOUT, - ) - .await - .expect("generate account"); - let selected = actor - .select_account(generated.account().public_key()) - .await - .expect("select generated account"); - assert_eq!( - selected.selected_account(), - Some(generated.account().public_key()) - ); - - let missing = - PublicKey::from_hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") - .expect("public key"); - let error = match actor.request_account_removal(missing).await { - Ok(_) => panic!("unknown account removal must fail"), - Err(error) => error, - }; - assert_eq!(error.code(), SafeErrorCode::AccountNotFound); - } - - #[tokio::test(flavor = "multi_thread")] - async fn staged_recovery_rejects_a_stale_expected_revision_before_commit() { - let (actor, _) = actor().await; - let handle = actor - .begin_generated_key_stage() - .await - .expect("generated key stage"); - let stale = SnapshotRevision::from_value(actor.snapshot().revision().value() + 1); - let error = actor - .acknowledge_generated_key_stage( - handle.id(), - DurableRequestId::parse("test:generate:stale-revision").expect("request"), - stale, - DEFAULT_COMMAND_TIMEOUT, - ) - .await - .expect_err("stale revision must conflict"); - assert_eq!( - error.message().as_str(), - "The command conflicts with newer application state." - ); - assert!(actor.cancel_generated_key_stage().await.expect("cancel")); - } - - #[tokio::test(flavor = "multi_thread")] - async fn fatal_lifecycle_rejects_commands_before_execution() { - let (actor, _) = actor().await; - actor - .lifecycle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .fail(command_unavailable()); - - let error = actor - .bootstrap() - .await - .expect_err("fatal lifecycle must reject command admission"); - assert_eq!( - error.message().as_str(), - "The command is unavailable in the current runtime state." - ); - assert!(matches!(actor.lifecycle(), RuntimeLifecycle::Fatal(_))); - } - - #[tokio::test(flavor = "multi_thread")] - async fn generated_key_stage_is_exclusive_cancelable_and_snapshot_free() { - let (actor, secrets) = actor().await; - let initial = actor.snapshot(); - let stage = actor - .begin_generated_key_stage() - .await - .expect("generated key stage"); - - assert!(actor.begin_generated_key_stage().await.is_err()); - assert_eq!(actor.snapshot(), initial); - assert!( - !secrets - .contains(stage.view().account().public_key()) - .expect("keyring") - ); - assert!(actor.sign_out().await.is_err()); - assert_eq!(actor.snapshot(), initial); - assert!(actor.cancel_generated_key_stage().await.expect("cancel")); - assert!( - !actor - .cancel_generated_key_stage() - .await - .expect("cancel empty") - ); - assert_eq!(actor.snapshot(), initial); - - actor - .begin_generated_key_stage() - .await - .expect("replacement stage"); - actor.close().await.expect("close clears stage"); - assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed); - } - - #[tokio::test(flavor = "multi_thread")] - async fn recovery_handle_is_one_use_and_acknowledgement_commits_once() { - let (actor, secrets) = actor().await; - let initial = actor.snapshot(); - let handle = actor - .begin_generated_key_stage() - .await - .expect("generated key stage"); - let public_key = handle.view().account().public_key(); - let recovery = handle.take_recovery_nsec().expect("recovery material"); - assert_eq!(recovery.with_exposed_secret(str::len), 63); - assert!(handle.take_recovery_nsec().is_err()); - assert_eq!(actor.snapshot(), initial); - assert!(!secrets.contains(public_key).expect("not committed")); - - let committed = actor - .acknowledge_generated_key_stage_test(handle.id()) - .await - .expect("acknowledge"); - assert_eq!(committed.accounts().len(), 1); - assert_eq!(committed.selected_account(), Some(public_key)); - assert!(secrets.contains(public_key).expect("credential committed")); - assert!( - actor - .acknowledge_generated_key_stage_test(handle.id()) - .await - .is_err() - ); - } - - #[tokio::test(flavor = "multi_thread")] - async fn failed_generated_commit_consumes_the_stage_without_poisoning_the_actor() { - let secrets = Arc::new(FailureSecretStore::default()); - secrets.fail_next(SecretStoreOperation::Put); - let secret_port: Arc<dyn SecretStore> = secrets.clone(); - let actor = RuntimeActorHandle::in_memory( - RelayConfiguration::default(), - dependencies(secret_port, Arc::new(OfflineNostr)), - NonZeroUsize::new(8).expect("capacity"), - &tokio::runtime::Handle::current(), - ) - .await - .expect("actor"); - let handle = actor - .begin_generated_key_stage() - .await - .expect("generated key stage"); - - let error = actor - .acknowledge_generated_key_stage_test(handle.id()) - .await - .expect_err("injected keyring failure"); - - assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); - assert!(actor.snapshot().accounts().is_empty()); - actor - .begin_generated_key_stage() - .await - .expect("fresh recovery after terminal failure"); - assert!(actor.cancel_generated_key_stage().await.expect("cancel")); - } - - #[tokio::test(flavor = "multi_thread")] - async fn session_generation_cancels_correlated_profile_work_on_sign_out() { - let client = Arc::new(BlockingNostr::new()); - let actor = RuntimeActorHandle::in_memory( - RelayConfiguration::new(vec![ - RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local) - .expect("relay"), - ]) - .expect("relay configuration"), - dependencies(Arc::new(InMemorySecretStore::default()), client.clone()), - NonZeroUsize::new(8).expect("capacity"), - &tokio::runtime::Handle::current(), - ) - .await - .expect("actor"); - let imported = actor - .import_secret_key_test( - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), - ) - .expect("input"), - ) - .await - .expect("import"); - actor - .activate_account(imported.account().public_key()) - .await - .expect("activate"); - assert_eq!(actor.session_generation().value(), 1); - - let refresh_actor = actor.clone(); - let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await }); - let started = client.started.acquire().await.expect("refresh started"); - started.forget(); - let signed_out = actor.sign_out().await.expect("sign out"); - let cancelled = refresh - .await - .expect("refresh task") - .expect("safe cancellation"); - - assert_eq!(actor.session_generation().value(), 2); - assert_eq!(signed_out.session(), SessionState::SignedOut); - assert_eq!(cancelled.session(), SessionState::SignedOut); - assert!(cancelled.active_account().is_none()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn profile_refresh_rejects_stale_bindings_and_discards_stale_completions() { - let client = Arc::new(BlockingNostr::new()); - let actor = RuntimeActorHandle::in_memory( - RelayConfiguration::new(vec![ - RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local) - .expect("relay"), - ]) - .expect("relay configuration"), - dependencies(Arc::new(InMemorySecretStore::default()), client.clone()), - NonZeroUsize::new(8).expect("capacity"), - &tokio::runtime::Handle::current(), - ) - .await - .expect("actor"); - let imported = actor - .import_secret_key_test(secret( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - )) - .await - .expect("import"); - let public_key = imported.account().public_key(); - actor.activate_account(public_key).await.expect("activate"); - let binding = actor.foreground_session().expect("foreground binding"); - let stale_binding = ForegroundSessionBinding::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - SessionGeneration::from_value(binding.generation().value() + 1), - ) - .expect("stale binding fixture"); - let other_public_key = - PublicKey::from_hex("c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5") - .expect("other public key"); - let other_binding = ForegroundSessionBinding::new( - AccountIdentity::derive(other_public_key).expect("other identity"), - LocalSignerBinding::new(other_public_key, BindingAvailability::Available), - binding.generation(), - ) - .expect("other binding fixture"); - - *actor - .foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stale_binding.clone()); - let error = actor - .refresh_active_profile() - .await - .expect_err("stale generation must reject before relay work"); - assert_eq!( - error.message().as_str(), - "The active account binding changed before profile refresh." - ); - - *actor - .foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(other_binding.clone()); - let error = actor - .refresh_active_profile() - .await - .expect_err("different account binding must reject before relay work"); - assert_eq!( - error.message().as_str(), - "The active account binding changed before profile refresh." - ); - - *actor - .foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding.clone()); - let refresh_actor = actor.clone(); - let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await }); - let started = client.started.acquire().await.expect("refresh started"); - started.forget(); - *actor - .foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stale_binding); - client.release.add_permits(1); - let unchanged = refresh - .await - .expect("refresh task") - .expect("stale completion returns current snapshot"); - assert_eq!(unchanged.revision(), actor.snapshot().revision()); - - *actor - .foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding.clone()); - let refresh_actor = actor.clone(); - let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await }); - let started = client - .started - .acquire() - .await - .expect("second refresh started"); - started.forget(); - *actor - .foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; - client.release.add_permits(1); - let unchanged = refresh - .await - .expect("refresh task") - .expect("missing binding returns current snapshot"); - assert_eq!(unchanged.revision(), actor.snapshot().revision()); - - *actor - .foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding.clone()); - let refresh_actor = actor.clone(); - let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await }); - let started = client - .started - .acquire() - .await - .expect("third refresh started"); - started.forget(); - *actor - .foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(other_binding); - client.release.add_permits(1); - let unchanged = refresh - .await - .expect("refresh task") - .expect("different account binding returns current snapshot"); - assert_eq!(unchanged.revision(), actor.snapshot().revision()); - - *actor - .foreground_session - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn bounded_runtime_rejects_saturation_without_dropping_accepted_commands() { - let secrets = Arc::new(BlockingSecretStore::new()); - let actor = RuntimeActorHandle::in_memory( - RelayConfiguration::default(), - dependencies(secrets.clone(), Arc::new(OfflineNostr)), - NonZeroUsize::new(1).expect("capacity"), - &tokio::runtime::Handle::current(), - ) - .await - .expect("actor"); - - let first_actor = actor.clone(); - let first = tokio::spawn(async move { - first_actor - .import_secret_key_test(secret( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - )) - .await - }); - secrets.wait_until_put_started().await; - - let second_actor = actor.clone(); - let second = tokio::spawn(async move { - second_actor - .import_secret_key_test(secret( - "0000000000000000000000000000000000000000000000000000000000000001", - )) - .await - }); - while actor.mailbox.available_capacity() != 0 { - assert!( - !second.is_finished(), - "second command must enter the mailbox" - ); - tokio::task::yield_now().await; - } - let rejected = actor - .import_secret_key_test(secret( - "0000000000000000000000000000000000000000000000000000000000000002", - )) - .await - .expect_err("full mailbox must reject"); - assert_eq!( - rejected.message().as_str(), - "The runtime is busy. Try again." - ); - - secrets.release(); - first.await.expect("first task").expect("first command"); - let second = second - .await - .expect("second task") - .expect_err("accepted stale revision conflicts explicitly"); - assert_eq!( - second.message().as_str(), - "The account operation conflicts with the current application state." - ); - assert_eq!( - actor.bootstrap().await.expect("snapshot").accounts().len(), - 1 - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn queued_command_expiry_returns_timeout_and_prevents_late_mutation() { - let secrets = Arc::new(BlockingSecretStore::new()); - let actor = RuntimeActorHandle::in_memory( - RelayConfiguration::default(), - dependencies(secrets.clone(), Arc::new(OfflineNostr)), - NonZeroUsize::new(1).expect("capacity"), - &tokio::runtime::Handle::current(), - ) - .await - .expect("actor"); - - let first_actor = actor.clone(); - let first = tokio::spawn(async move { - first_actor - .import_secret_key_test(secret( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - )) - .await - }); - secrets.wait_until_put_started().await; - - let expired = actor - .import_secret_key_with_timeout( - secret("0000000000000000000000000000000000000000000000000000000000000001"), - Duration::from_millis(10), - ) - .await - .expect_err("queued command must time out"); - assert_eq!(expired.message().as_str(), "The runtime command timed out."); - - secrets.release(); - first.await.expect("first task").expect("first command"); - assert_eq!( - actor.bootstrap().await.expect("snapshot").accounts().len(), - 1 - ); - } - - #[tokio::test(flavor = "multi_thread")] - async fn close_is_terminal_and_every_later_command_is_rejected_as_closed() { - let (actor, _) = actor().await; - actor.close().await.expect("close"); - assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed); - - let error = actor.bootstrap().await.expect_err("bootstrap after close"); - assert_eq!( - error.message().as_str(), - "The application runtime is closed." - ); - actor.close().await.expect("repeated close is idempotent"); - } - - #[tokio::test(flavor = "multi_thread")] - async fn actor_subscription_atomically_delivers_initial_then_ordered_changes() { - let (actor, _) = actor().await; - let mut subscription = actor - .subscribe_changes(NonZeroUsize::new(4).expect("capacity")) - .await - .expect("subscribe"); - let initial = subscription.receive().await.expect("initial snapshot"); - assert_eq!(initial.revision(), actor.snapshot().revision()); - assert!(initial.previous_revision().is_none()); - - actor - .import_secret_key_test(secret( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - )) - .await - .expect("import"); - let changed = subscription.receive().await.expect("change"); - assert!(changed.revision() > initial.revision()); - assert_eq!(changed.previous_revision(), Some(initial.revision())); - assert!( - actor - .unsubscribe_changes(subscription.id()) - .await - .expect("unsubscribe") - ); - assert!( - !actor - .unsubscribe_changes(subscription.id()) - .await - .expect("second unsubscribe") - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn expired_queued_shutdown_does_not_close_runtime_later() { - let secrets = Arc::new(BlockingSecretStore::new()); - let actor = RuntimeActorHandle::in_memory( - RelayConfiguration::default(), - dependencies(secrets.clone(), Arc::new(OfflineNostr)), - NonZeroUsize::new(1).expect("capacity"), - &tokio::runtime::Handle::current(), - ) - .await - .expect("actor"); - let import_actor = actor.clone(); - let import = tokio::spawn(async move { - import_actor - .import_secret_key_test(secret( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - )) - .await - }); - secrets.wait_until_put_started().await; - - let timeout = actor - .close_with_timeout(Duration::from_millis(10)) - .await - .expect_err("queued shutdown must expire"); - assert_eq!(timeout.message().as_str(), "The runtime command timed out."); - secrets.release(); - import.await.expect("import task").expect("import"); - assert_eq!(actor.lifecycle(), RuntimeLifecycle::Ready); - assert_eq!( - actor - .bootstrap() - .await - .expect("still open") - .accounts() - .len(), - 1 - ); - actor.close().await.expect("later close"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn shutdown_cancels_in_flight_work_and_terminates_publication() { - let client = Arc::new(BlockingNostr::new()); - let actor = RuntimeActorHandle::in_memory( - RelayConfiguration::new(vec![ - RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local) - .expect("relay"), - ]) - .expect("relay configuration"), - dependencies(Arc::new(InMemorySecretStore::default()), client.clone()), - NonZeroUsize::new(8).expect("capacity"), - &tokio::runtime::Handle::current(), - ) - .await - .expect("actor"); - let imported = actor - .import_secret_key_test(secret( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - )) - .await - .expect("import"); - actor - .activate_account(imported.account().public_key()) - .await - .expect("activate"); - let mut changes = actor - .subscribe_changes(NonZeroUsize::new(4).expect("capacity")) - .await - .expect("subscribe"); - changes.receive().await.expect("initial"); - - let refresh_actor = actor.clone(); - let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await }); - let started = client.started.acquire().await.expect("refresh started"); - started.forget(); - actor.close().await.expect("close"); - - let cancelled = refresh - .await - .expect("refresh task") - .expect_err("refresh closes"); - assert_eq!( - cancelled.message().as_str(), - "The application runtime is closed." - ); - assert!(changes.receive().await.is_none()); - assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed); - } - - fn secret(value: &str) -> SecretKeyInput { - SecretKeyInput::parse(value.to_owned()).expect("valid test secret") - } -} diff --git a/crates/studio_runtime/tests/local_relay_e2e.rs b/crates/studio_runtime/tests/local_relay_e2e.rs @@ -1,100 +0,0 @@ -use std::time::Duration; - -use nostr::{EventBuilder, Keys, Metadata}; -use nostr_relay_builder::MockRelay; -use nostr_sdk::Client; -use radroots_studio_application::{ - Clock, InMemorySecretStore, ProfileLoadState, ProfileRepository, RelayConfiguration, - RelayConnectionState, SecretStore, SessionState, -}; -use radroots_studio_domain::{RelayDestinationPolicy, RelayUrl, SecretKeyInput, UnixTimestamp}; -use radroots_studio_nostr::SdkNostrClient; -use radroots_studio_runtime::PersistentAppCore; - -const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - -struct FixedClock; - -impl Clock for FixedClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(100).expect("fixed timestamp") - } -} - -#[tokio::test] -async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { - let local_relay = MockRelay::run().await.expect("local relay"); - let relay_url = local_relay.url().await; - let keys = Keys::parse(SECRET_HEX).expect("known secret key"); - let publisher = Client::new(keys); - publisher - .add_relay(relay_url.clone()) - .await - .expect("publisher relay"); - publisher.connect().await; - publisher.wait_for_connection(Duration::from_secs(2)).await; - publisher - .send_event_builder(EventBuilder::metadata( - &Metadata::new() - .name("farmer") - .display_name("Farm Account") - .about("Local food profile"), - )) - .await - .expect("publish profile"); - - let relay = - RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local).expect("relay URL"); - let adapter = PersistentAppCore::in_memory( - RelayConfiguration::new(vec![relay]).expect("relay configuration"), - ) - .expect("persistent adapter"); - let secrets = InMemorySecretStore::default(); - adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); - let imported = adapter - .import_secret_key( - SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("secret input"), - &secrets, - &FixedClock, - ) - .expect("import account"); - let public_key = imported.account().public_key(); - assert!(secrets.contains(public_key).expect("credential exists")); - adapter - .activate_account(public_key, &secrets, &FixedClock) - .expect("activate account"); - - let refreshed = adapter - .core() - .refresh_active_profile( - adapter.database(), - &SdkNostrClient::new(Duration::from_secs(2)), - &FixedClock, - std::time::Instant::now() + Duration::from_secs(2), - ) - .await - .expect("refresh profile"); - - assert_eq!(refreshed.session(), SessionState::Active); - let active = refreshed.active_account().expect("active account"); - assert_eq!(active.relay_state(), RelayConnectionState::Connected); - assert_eq!(active.profile_state(), ProfileLoadState::Fresh); - assert_eq!( - active.profile().and_then(|profile| profile.display_name()), - Some("Farm Account") - ); - let cached = adapter - .database() - .load_profile(public_key) - .expect("load cache") - .expect("cached profile"); - assert_eq!( - cached.candidate().metadata().preferred_name(), - Some("Farm Account") - ); - let public_debug = format!("{refreshed:?}"); - assert!(!public_debug.contains(SECRET_HEX)); - assert!(!public_debug.contains("nsec1")); - publisher.shutdown().await; - local_relay.shutdown(); -} diff --git a/crates/studio_runtime/tests/restart_isolation.rs b/crates/studio_runtime/tests/restart_isolation.rs @@ -1,95 +0,0 @@ -use std::fs; - -use radroots_studio_application::{ - AccountNamespaceRepository, AccountPreferenceKey, Clock, InMemorySecretStore, - RelayConfiguration, SessionState, -}; -use radroots_studio_domain::{SecretKeyInput, UnixTimestamp}; -use radroots_studio_runtime::PersistentAppCore; -use tempfile::tempdir; - -const SECRET_A: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; -const SECRET_B: &str = "0101010101010101010101010101010101010101010101010101010101010101"; - -struct FixedClock; - -impl Clock for FixedClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(200).expect("fixed timestamp") - } -} - -#[test] -fn restart_restores_selection_and_keeps_account_namespaces_isolated() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let secrets = InMemorySecretStore::default(); - let (owner_a, owner_b); - - { - let adapter = PersistentAppCore::open(&path, RelayConfiguration::default()) - .expect("persistent adapter"); - adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); - owner_a = adapter - .import_secret_key( - SecretKeyInput::parse(SECRET_A.to_owned()).expect("secret A"), - &secrets, - &FixedClock, - ) - .expect("account A") - .account() - .public_key(); - owner_b = adapter - .import_secret_key( - SecretKeyInput::parse(SECRET_B.to_owned()).expect("secret B"), - &secrets, - &FixedClock, - ) - .expect("account B") - .account() - .public_key(); - adapter - .database() - .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "account-a") - .expect("namespace A"); - adapter - .database() - .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "account-b") - .expect("namespace B"); - adapter.select_account(owner_b).expect("select B"); - } - - let reopened = - PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter"); - let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore"); - assert_eq!(restored.accounts().len(), 2); - assert_eq!(restored.selected_account(), Some(owner_b)); - assert_eq!(restored.session(), SessionState::SignedOut); - assert_eq!( - reopened - .database() - .get_value(owner_a, AccountPreferenceKey::NamespaceProbe) - .expect("read A"), - Some("account-a".to_owned()) - ); - assert_eq!( - reopened - .database() - .get_value(owner_b, AccountPreferenceKey::NamespaceProbe) - .expect("read B"), - Some("account-b".to_owned()) - ); - - let database = fs::read(path).expect("database bytes"); - assert!( - !database - .windows(SECRET_A.len()) - .any(|bytes| bytes == SECRET_A.as_bytes()) - ); - assert!( - !database - .windows(SECRET_B.len()) - .any(|bytes| bytes == SECRET_B.as_bytes()) - ); - assert!(!database.windows(5).any(|bytes| bytes == b"nsec1")); -} diff --git a/crates/studio_storage/Cargo.toml b/crates/studio_storage/Cargo.toml @@ -1,35 +0,0 @@ -[package] -name = "radroots_studio_storage" -description = "Private persistence and keyring adapters for Radroots Studio" -version = "0.1.0-alpha" -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license = "GPL-3.0-only" -repository.workspace = true -homepage.workspace = true -publish = false -include = ["src/**", "tests/**", "migrations/**", "Cargo.toml"] - -[dependencies] -fs2 = "=0.4.3" -keyring = "=4.1.6" -radroots_studio_application.workspace = true -radroots_studio_domain.workspace = true -refinery = { version = "=0.9.2", default-features = false, features = [ - "rusqlite", -] } -getrandom.workspace = true -hmac.workspace = true -rusqlite = { version = "=0.39.0", features = ["backup", "bundled"] } -sha2.workspace = true -zeroize = "=1.9.0" - -[target.'cfg(unix)'.dependencies] -rustix.workspace = true - -[dev-dependencies] -tempfile = "=3.23.0" - -[lints] -workspace = true diff --git a/crates/studio_storage/migrations/V10__installation_identity.sql b/crates/studio_storage/migrations/V10__installation_identity.sql @@ -1,7 +0,0 @@ -CREATE TABLE installation_identity ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - installation_id TEXT NOT NULL CHECK ( - length(installation_id) = 32 - AND installation_id NOT GLOB '*[^0-9a-f]*' - ) -) STRICT; diff --git a/crates/studio_storage/migrations/V1__initialize.sql b/crates/studio_storage/migrations/V1__initialize.sql @@ -1,6 +0,0 @@ -CREATE TABLE application_schema ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - schema_version INTEGER NOT NULL CHECK (schema_version >= 1) -); - -INSERT INTO application_schema (singleton, schema_version) VALUES (1, 1); diff --git a/crates/studio_storage/migrations/V2__accounts.sql b/crates/studio_storage/migrations/V2__accounts.sql @@ -1,28 +0,0 @@ -CREATE TABLE accounts ( - pubkey TEXT PRIMARY KEY NOT NULL CHECK ( - length(pubkey) = 64 AND pubkey = lower(pubkey) - ), - npub TEXT NOT NULL CHECK (length(npub) = 63), - signer_kind TEXT NOT NULL CHECK ( - signer_kind IN ('local_secret', 'watch_only', 'remote_nip46') - ), - key_availability TEXT NOT NULL CHECK ( - key_availability IN ( - 'available', - 'credential_missing', - 'store_unavailable', - 'not_required' - ) - ), - label TEXT, - created_at INTEGER NOT NULL CHECK (created_at >= 0), - last_used_at INTEGER CHECK (last_used_at >= 0) -); - -CREATE TABLE app_state ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - selected_pubkey TEXT REFERENCES accounts(pubkey) ON DELETE SET NULL -); - -INSERT INTO app_state (singleton, selected_pubkey) VALUES (1, NULL); -UPDATE application_schema SET schema_version = 2 WHERE singleton = 1; diff --git a/crates/studio_storage/migrations/V3__profile_cache.sql b/crates/studio_storage/migrations/V3__profile_cache.sql @@ -1,12 +0,0 @@ -CREATE TABLE profile_cache ( - subject_pubkey TEXT PRIMARY KEY NOT NULL REFERENCES accounts(pubkey) ON DELETE CASCADE, - event_id TEXT NOT NULL, - event_created_at INTEGER NOT NULL, - name TEXT, - display_name TEXT, - nip05 TEXT, - about TEXT, - picture TEXT, - refreshed_at INTEGER NOT NULL, - refresh_status TEXT NOT NULL CHECK (refresh_status IN ('success', 'offline', 'invalid_data')) -) STRICT; diff --git a/crates/studio_storage/migrations/V4__account_namespace.sql b/crates/studio_storage/migrations/V4__account_namespace.sql @@ -1,6 +0,0 @@ -CREATE TABLE account_namespace ( - owner_pubkey TEXT NOT NULL REFERENCES accounts(pubkey) ON DELETE CASCADE, - preference_key TEXT NOT NULL CHECK (preference_key IN ('namespace_probe')), - preference_value TEXT NOT NULL CHECK (length(preference_value) <= 4096), - PRIMARY KEY (owner_pubkey, preference_key) -) STRICT; diff --git a/crates/studio_storage/migrations/V5__operation_journal.sql b/crates/studio_storage/migrations/V5__operation_journal.sql @@ -1,8 +0,0 @@ -CREATE TABLE operation_journal ( - operation_id INTEGER PRIMARY KEY AUTOINCREMENT, - operation_kind TEXT NOT NULL CHECK (operation_kind IN ('add', 'import', 'remove')), - subject_pubkey TEXT NOT NULL, - phase TEXT NOT NULL CHECK (phase IN ('intent_recorded', 'credential_written', 'metadata_committed', 'compensation_pending', 'credential_deleted', 'metadata_deleted')), - updated_at INTEGER NOT NULL, - diagnostic_code TEXT CHECK (diagnostic_code IN ('storage_unavailable', 'keyring_unavailable', 'credential_missing', 'compensation_failed')) -) STRICT; diff --git a/crates/studio_storage/migrations/V6__normalized_runtime_schema.sql b/crates/studio_storage/migrations/V6__normalized_runtime_schema.sql @@ -1,100 +0,0 @@ -CREATE TABLE account_identities ( - public_key TEXT PRIMARY KEY NOT NULL CHECK ( - length(public_key) = 64 AND public_key = lower(public_key) - ), - npub TEXT NOT NULL UNIQUE CHECK (length(npub) = 63), - label TEXT CHECK (label IS NULL OR length(label) BETWEEN 1 AND 80), - created_at INTEGER NOT NULL CHECK (created_at >= 0), - last_used_at INTEGER CHECK (last_used_at IS NULL OR last_used_at >= 0) -) STRICT; - -CREATE TABLE local_signer_bindings ( - account_public_key TEXT NOT NULL, - binding_public_key TEXT NOT NULL, - binding_kind TEXT NOT NULL CHECK (binding_kind = 'local_secret'), - availability TEXT NOT NULL CHECK ( - availability IN ('available', 'credential_missing', 'store_unavailable') - ), - PRIMARY KEY (account_public_key, binding_public_key), - UNIQUE (account_public_key, binding_kind), - FOREIGN KEY (account_public_key) REFERENCES account_identities(public_key) ON DELETE CASCADE, - CHECK (account_public_key = binding_public_key) -) STRICT; - -CREATE TABLE runtime_state ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - selected_public_key TEXT REFERENCES account_identities(public_key) ON DELETE SET NULL, - active_account_public_key TEXT, - active_binding_public_key TEXT, - session_generation INTEGER NOT NULL DEFAULT 0 CHECK (session_generation >= 0), - FOREIGN KEY (active_account_public_key, active_binding_public_key) - REFERENCES local_signer_bindings(account_public_key, binding_public_key) - ON DELETE SET NULL, - CHECK ( - (active_account_public_key IS NULL AND active_binding_public_key IS NULL) - OR - (active_account_public_key IS NOT NULL AND active_binding_public_key IS NOT NULL) - ) -) STRICT; - -INSERT INTO runtime_state (singleton) VALUES (1); - -CREATE TABLE profile_cache_v6 ( - subject_public_key TEXT PRIMARY KEY NOT NULL - REFERENCES account_identities(public_key) ON DELETE CASCADE, - event_id TEXT NOT NULL CHECK (length(event_id) = 64 AND event_id = lower(event_id)), - event_created_at INTEGER NOT NULL CHECK (event_created_at >= 0), - name TEXT, - display_name TEXT, - nip05 TEXT, - about TEXT, - picture TEXT, - refreshed_at INTEGER NOT NULL CHECK (refreshed_at >= 0), - refresh_status TEXT NOT NULL CHECK ( - refresh_status IN ('success', 'offline', 'invalid_data') - ) -) STRICT; - -CREATE TABLE durable_operations ( - request_id TEXT PRIMARY KEY NOT NULL CHECK (length(request_id) BETWEEN 1 AND 128), - operation_kind TEXT NOT NULL CHECK ( - operation_kind IN ('create', 'import', 'repair', 'remove') - ), - account_public_key TEXT NOT NULL CHECK ( - length(account_public_key) = 64 AND account_public_key = lower(account_public_key) - ), - binding_public_key TEXT NOT NULL CHECK (binding_public_key = account_public_key), - expected_revision INTEGER CHECK (expected_revision IS NULL OR expected_revision >= 0), - phase TEXT NOT NULL CHECK ( - phase IN ( - 'intent_recorded', - 'credential_written', - 'metadata_committed', - 'selection_committed', - 'compensation_pending', - 'credential_deleted', - 'metadata_deleted', - 'finalized' - ) - ), - terminal_outcome TEXT CHECK ( - terminal_outcome IS NULL OR terminal_outcome IN ('completed', 'cancelled', 'failed') - ), - prior_selected_public_key TEXT, - updated_at INTEGER NOT NULL CHECK (updated_at >= 0), - diagnostic_code TEXT CHECK ( - diagnostic_code IS NULL OR diagnostic_code IN ( - 'storage_unavailable', - 'keyring_unavailable', - 'credential_missing', - 'compensation_failed', - 'conflict', - 'expired' - ) - ), - CHECK ( - (phase = 'finalized' AND terminal_outcome IS NOT NULL) - OR - (phase <> 'finalized' AND terminal_outcome IS NULL) - ) -) STRICT; diff --git a/crates/studio_storage/migrations/V7__migrate_v5_runtime_data.sql b/crates/studio_storage/migrations/V7__migrate_v5_runtime_data.sql @@ -1,68 +0,0 @@ -INSERT INTO account_identities ( - public_key, - npub, - label, - created_at, - last_used_at -) -SELECT pubkey, npub, label, created_at, last_used_at -FROM accounts; - -INSERT INTO local_signer_bindings ( - account_public_key, - binding_public_key, - binding_kind, - availability -) -SELECT pubkey, pubkey, 'local_secret', key_availability -FROM accounts; - -UPDATE runtime_state -SET selected_public_key = ( - SELECT selected_pubkey FROM app_state WHERE singleton = 1 -) -WHERE singleton = 1; - -INSERT INTO profile_cache_v6 ( - subject_public_key, - event_id, - event_created_at, - name, - display_name, - nip05, - about, - picture, - refreshed_at, - refresh_status -) -SELECT - subject_pubkey, - event_id, - event_created_at, - name, - display_name, - nip05, - about, - picture, - refreshed_at, - refresh_status -FROM profile_cache; - -INSERT INTO durable_operations ( - request_id, - operation_kind, - account_public_key, - binding_public_key, - phase, - updated_at, - diagnostic_code -) -SELECT - 'legacy-v5-' || operation_id, - CASE operation_kind WHEN 'add' THEN 'create' ELSE operation_kind END, - subject_pubkey, - subject_pubkey, - phase, - updated_at, - diagnostic_code -FROM operation_journal; diff --git a/crates/studio_storage/migrations/V8__normalized_account_preferences.sql b/crates/studio_storage/migrations/V8__normalized_account_preferences.sql @@ -1,10 +0,0 @@ -CREATE TABLE account_preferences ( - owner_public_key TEXT NOT NULL REFERENCES account_identities(public_key) ON DELETE CASCADE, - preference_key TEXT NOT NULL CHECK (preference_key = 'namespace_probe'), - preference_value TEXT NOT NULL CHECK (length(preference_value) <= 4096), - PRIMARY KEY (owner_public_key, preference_key) -) STRICT; - -INSERT INTO account_preferences (owner_public_key, preference_key, preference_value) -SELECT owner_pubkey, preference_key, preference_value -FROM account_namespace; diff --git a/crates/studio_storage/migrations/V9__durable_operation_receipts.sql b/crates/studio_storage/migrations/V9__durable_operation_receipts.sql @@ -1,11 +0,0 @@ -ALTER TABLE durable_operations ADD COLUMN prior_binding_availability TEXT CHECK ( - prior_binding_availability IS NULL OR prior_binding_availability IN ( - 'available', - 'credential_missing', - 'store_unavailable' - ) -); - -ALTER TABLE durable_operations ADD COLUMN resulting_revision INTEGER CHECK ( - resulting_revision IS NULL OR resulting_revision >= 0 -); diff --git a/crates/studio_storage/src/account_namespace.rs b/crates/studio_storage/src/account_namespace.rs @@ -1,189 +0,0 @@ -use radroots_studio_application::{AccountNamespaceRepository, AccountPreferenceKey}; -use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage}; -use rusqlite::{OptionalExtension, params}; - -use crate::Database; - -const MAX_VALUE_CHARS: usize = 4_096; - -impl AccountNamespaceRepository for Database { - fn get_value( - &self, - owner: PublicKey, - key: AccountPreferenceKey, - ) -> Result<Option<String>, SafeError> { - self.connection() - .query_row( - "SELECT preference_value FROM account_preferences \ - WHERE owner_public_key = ?1 AND preference_key = ?2", - params![owner.to_hex(), encode_key(key)], - |row| row.get(0), - ) - .optional() - .map_err(|_| storage_error()) - } - - fn set_value( - &self, - owner: PublicKey, - key: AccountPreferenceKey, - value: &str, - ) -> Result<(), SafeError> { - if value.chars().count() > MAX_VALUE_CHARS || value.chars().any(char::is_control) { - return Err(invalid_preference()); - } - self.connection() - .execute( - "INSERT INTO account_preferences (owner_public_key, preference_key, preference_value) \ - VALUES (?1, ?2, ?3) ON CONFLICT(owner_public_key, preference_key) DO UPDATE SET \ - preference_value = excluded.preference_value", - params![owner.to_hex(), encode_key(key), value], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } - - fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError> { - self.connection() - .execute( - "DELETE FROM account_preferences WHERE owner_public_key = ?1", - [owner.to_hex()], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } -} - -const fn encode_key(key: AccountPreferenceKey) -> &'static str { - match key { - AccountPreferenceKey::NamespaceProbe => "namespace_probe", - } -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The account preference is unavailable."), - ) -} - -const fn invalid_preference() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidAccountMetadata, - SafeMessage::new("The account preference is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use radroots_studio_application::{ - AccountNamespaceRepository, AccountPreferenceKey, AccountRepository, AppStateRepository, - }; - use radroots_studio_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, UnixTimestamp, - }; - - use crate::Database; - - fn public_key(byte: u8) -> PublicKey { - let value = match byte { - 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", - 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", - _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - }; - PublicKey::from_hex(value).expect("valid public key") - } - - fn account(byte: u8) -> AccountSummary { - let public_key = public_key(byte); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(i64::from(byte)).expect("time")), - None, - ) - .expect("account") - } - - #[test] - fn namespace_partitions_same_typed_key_by_owner_and_selection() { - let database = Database::in_memory().expect("database"); - let owner_a = public_key(1); - let owner_b = public_key(2); - database.insert_account(&account(1)).expect("account a"); - database.insert_account(&account(2)).expect("account b"); - database - .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "A") - .expect("set a"); - database - .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "B") - .expect("set b"); - - database - .save_selected_account(Some(owner_b)) - .expect("select b"); - let selected = database - .load_selected_account() - .expect("selection") - .expect("selected owner"); - assert_eq!( - database - .get_value(selected, AccountPreferenceKey::NamespaceProbe) - .expect("selected value"), - Some("B".to_owned()) - ); - assert_eq!( - database - .get_value(owner_a, AccountPreferenceKey::NamespaceProbe) - .expect("owner a value"), - Some("A".to_owned()) - ); - } - - #[test] - fn namespace_updates_and_cascades_with_owner_removal() { - let database = Database::in_memory().expect("database"); - let owner = public_key(3); - database.insert_account(&account(3)).expect("account"); - database - .set_value(owner, AccountPreferenceKey::NamespaceProbe, "before") - .expect("set"); - database - .set_value(owner, AccountPreferenceKey::NamespaceProbe, "after") - .expect("update"); - assert_eq!( - database - .get_value(owner, AccountPreferenceKey::NamespaceProbe) - .expect("value"), - Some("after".to_owned()) - ); - - database.remove_account(owner).expect("remove"); - assert_eq!( - database - .get_value(owner, AccountPreferenceKey::NamespaceProbe) - .expect("deleted value"), - None - ); - } - - #[test] - fn namespace_rejects_oversized_and_control_character_values() { - let database = Database::in_memory().expect("database"); - let owner = public_key(3); - database.insert_account(&account(3)).expect("account"); - let oversized = "a".repeat(super::MAX_VALUE_CHARS + 1); - assert!( - database - .set_value(owner, AccountPreferenceKey::NamespaceProbe, &oversized) - .is_err() - ); - assert!( - database - .set_value(owner, AccountPreferenceKey::NamespaceProbe, "line\nbreak") - .is_err() - ); - } -} diff --git a/crates/studio_storage/src/accounts.rs b/crates/studio_storage/src/accounts.rs @@ -1,516 +0,0 @@ -use radroots_studio_application::{AccountRepository, AppStateRepository}; -use radroots_studio_domain::{ - AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, - LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, -}; -use rusqlite::{OptionalExtension, Row, params}; - -use crate::Database; - -impl AccountRepository for Database { - fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { - let connection = self.connection(); - let mut statement = connection - .prepare( - "SELECT identity.public_key, identity.npub, binding.binding_kind, \ - binding.availability, identity.label, identity.created_at, identity.last_used_at \ - FROM account_identities AS identity \ - JOIN local_signer_bindings AS binding \ - ON binding.account_public_key = identity.public_key \ - ORDER BY identity.created_at ASC, identity.public_key ASC", - ) - .map_err(|_| storage_error())?; - let rows = statement - .query_map([], decode_account) - .map_err(|_| storage_error())?; - rows.map(|row| row.map_err(|_| corrupt_storage_error())) - .collect() - } - - fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { - self.connection() - .query_row( - "SELECT identity.public_key, identity.npub, binding.binding_kind, \ - binding.availability, identity.label, identity.created_at, identity.last_used_at \ - FROM account_identities AS identity \ - JOIN local_signer_bindings AS binding \ - ON binding.account_public_key = identity.public_key \ - WHERE identity.public_key = ?1", - [public_key.to_hex()], - decode_account, - ) - .optional() - .map_err(|_| storage_error()) - } - - fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - let encoded = EncodedAccount::from(account); - let mut connection = self.connection(); - let transaction = connection.transaction().map_err(|_| storage_error())?; - let result = transaction.execute( - "INSERT INTO account_identities (public_key, npub, label, created_at, last_used_at) \ - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - encoded.public_key, - encoded.npub, - encoded.label, - encoded.created_at, - encoded.last_used_at - ], - ); - match result { - Ok(1) => {} - Err(error) if is_constraint_violation(&error) => return Err(account_exists()), - Ok(_) | Err(_) => return Err(storage_error()), - } - if transaction - .execute( - "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, \ - binding_kind, availability) VALUES (?1, ?1, ?2, ?3)", - params![ - encoded.public_key, - encoded.signer_kind, - encoded.key_availability - ], - ) - .map_err(|_| storage_error())? - != 1 - { - return Err(storage_error()); - } - transaction.commit().map_err(|_| storage_error()) - } - - fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - let encoded = EncodedAccount::from(account); - let mut connection = self.connection(); - let transaction = connection.transaction().map_err(|_| storage_error())?; - let identity_rows = transaction - .execute( - "UPDATE account_identities SET npub = ?2, label = ?5, created_at = ?6, \ - last_used_at = ?7 WHERE public_key = ?1", - params![ - encoded.public_key, - encoded.npub, - encoded.signer_kind, - encoded.key_availability, - encoded.label, - encoded.created_at, - encoded.last_used_at, - ], - ) - .map_err(|_| storage_error())?; - if identity_rows == 0 { - return Err(account_not_found()); - } - if identity_rows != 1 { - return Err(storage_error()); - } - let binding_rows = transaction - .execute( - "UPDATE local_signer_bindings SET binding_kind = ?2, availability = ?3 \ - WHERE account_public_key = ?1 AND binding_public_key = ?1", - params![ - encoded.public_key, - encoded.signer_kind, - encoded.key_availability - ], - ) - .map_err(|_| storage_error())?; - if binding_rows != 1 { - return Err(corrupt_storage_error()); - } - transaction.commit().map_err(|_| storage_error()) - } - - fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { - match self.connection().execute( - "DELETE FROM account_identities WHERE public_key = ?1", - [public_key.to_hex()], - ) { - Ok(1) => Ok(()), - Ok(0) => Err(account_not_found()), - Ok(_) | Err(_) => Err(storage_error()), - } - } -} - -impl AppStateRepository for Database { - fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { - let value = self - .connection() - .query_row( - "SELECT selected_public_key FROM runtime_state WHERE singleton = 1", - [], - |row| row.get::<_, Option<String>>(0), - ) - .map_err(|_| corrupt_storage_error())?; - value - .map(|hex| PublicKey::from_hex(&hex).map_err(|_| corrupt_storage_error())) - .transpose() - } - - fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { - let mut connection = self.connection(); - let transaction = connection.transaction().map_err(|_| storage_error())?; - if let Some(public_key) = public_key { - let exists = transaction - .query_row( - "SELECT EXISTS(SELECT 1 FROM account_identities WHERE public_key = ?1)", - [public_key.to_hex()], - |row| row.get::<_, bool>(0), - ) - .map_err(|_| storage_error())?; - if !exists { - return Err(account_not_found()); - } - } - let rows = transaction - .execute( - "UPDATE runtime_state SET selected_public_key = ?1 WHERE singleton = 1", - [public_key.map(PublicKey::to_hex)], - ) - .map_err(|_| storage_error())?; - if rows != 1 { - return Err(corrupt_storage_error()); - } - transaction.commit().map_err(|_| storage_error()) - } -} - -struct EncodedAccount { - public_key: String, - npub: String, - signer_kind: &'static str, - key_availability: &'static str, - label: Option<String>, - created_at: i64, - last_used_at: Option<i64>, -} - -impl From<&AccountSummary> for EncodedAccount { - fn from(account: &AccountSummary) -> Self { - Self { - public_key: account.public_key().to_hex(), - npub: account.npub().as_str().to_owned(), - signer_kind: "local_secret", - key_availability: encode_key_availability(account.signer().availability()), - label: account.label().map(|label| label.as_str().to_owned()), - created_at: account.created_at().timestamp().as_seconds(), - last_used_at: account.last_used_at().map(UnixTimestamp::as_seconds), - } - } -} - -fn decode_account(row: &Row<'_>) -> rusqlite::Result<AccountSummary> { - let public_key = - PublicKey::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?; - let npub: String = row.get(1)?; - if row.get::<_, String>(2)?.as_str() != "local_secret" { - return Err(invalid_column(2)); - } - let key_availability = decode_key_availability(row.get::<_, String>(3)?.as_str())?; - let label = row - .get::<_, Option<String>>(4)? - .map(|value| AccountLabel::parse(&value).map_err(|_| invalid_column(4))) - .transpose()?; - let created_at = UnixTimestamp::from_seconds(row.get(5)?).ok_or_else(|| invalid_column(5))?; - let last_used_at = row - .get::<_, Option<i64>>(6)? - .map(|value| UnixTimestamp::from_seconds(value).ok_or_else(|| invalid_column(6))) - .transpose()?; - - AccountSummary::new( - AccountIdentity::verify(public_key, npub).map_err(|_| invalid_column(1))?, - LocalSignerBinding::new(public_key, key_availability), - label, - AccountCreatedAt::new(created_at), - last_used_at, - ) - .map_err(|_| invalid_column(0)) -} - -const fn encode_key_availability(value: BindingAvailability) -> &'static str { - match value { - BindingAvailability::Available => "available", - BindingAvailability::CredentialMissing => "credential_missing", - BindingAvailability::StoreUnavailable => "store_unavailable", - } -} - -fn decode_key_availability(value: &str) -> rusqlite::Result<BindingAvailability> { - match value { - "available" => Ok(BindingAvailability::Available), - "credential_missing" => Ok(BindingAvailability::CredentialMissing), - "store_unavailable" => Ok(BindingAvailability::StoreUnavailable), - _ => Err(invalid_column(3)), - } -} - -fn invalid_column(index: usize) -> rusqlite::Error { - rusqlite::Error::InvalidColumnType( - index, - "public account metadata".to_owned(), - rusqlite::types::Type::Text, - ) -} - -fn is_constraint_violation(error: &rusqlite::Error) -> bool { - matches!( - error, - rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::ConstraintViolation, - .. - }, - _ - ) - ) -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The application database is unavailable."), - ) -} - -const fn corrupt_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageCorrupt, - SafeMessage::new("The application database could not be read."), - ) -} - -const fn account_exists() -> SafeError { - SafeError::new( - SafeErrorCode::AccountAlreadyExists, - SafeMessage::new("The Nostr account is already saved."), - ) -} - -const fn account_not_found() -> SafeError { - SafeError::new( - SafeErrorCode::AccountNotFound, - SafeMessage::new("The account was not found."), - ) -} - -#[cfg(test)] -mod tests { - use std::fs; - - use radroots_studio_application::{AccountRepository, AppStateRepository}; - use radroots_studio_domain::{ - AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, - LocalSignerBinding, PublicKey, SafeErrorCode, UnixTimestamp, - }; - use tempfile::tempdir; - - use crate::Database; - - fn public_key(key_byte: u8) -> PublicKey { - let value = match key_byte { - 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", - 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", - _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - }; - PublicKey::from_hex(value).expect("valid public key") - } - - fn account(key_byte: u8, created_at: i64) -> AccountSummary { - let public_key = public_key(key_byte); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - Some(AccountLabel::parse("Farm account").expect("valid label")), - AccountCreatedAt::new( - UnixTimestamp::from_seconds(created_at).expect("valid timestamp"), - ), - None, - ) - .expect("account") - } - - #[test] - fn accounts_insert_list_update_and_reject_duplicates() { - let database = Database::in_memory().expect("database"); - let first = account(1, 20); - let second = account(2, 10); - - database.insert_account(&first).expect("insert first"); - database.insert_account(&second).expect("insert second"); - let duplicate = database.insert_account(&first).expect_err("duplicate"); - - assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists); - assert_eq!( - database.list_accounts().expect("list"), - vec![second, first.clone()] - ); - assert_eq!( - database.find_account(first.public_key()).expect("find"), - Some(first) - ); - } - - #[test] - fn accounts_and_selection_survive_restart_without_secret_text() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let account = account(3, 30); - - { - let database = Database::open(&path).expect("database"); - database.insert_account(&account).expect("insert"); - database - .save_selected_account(Some(account.public_key())) - .expect("select"); - } - let reopened = Database::open(&path).expect("reopen"); - - assert_eq!( - reopened.list_accounts().expect("list"), - vec![account.clone()] - ); - assert_eq!( - reopened.load_selected_account().expect("selection"), - Some(account.public_key()) - ); - let bytes = fs::read(path).expect("database bytes"); - assert!(!String::from_utf8_lossy(&bytes).contains("nsec1known-test-secret")); - } - - #[test] - fn selection_requires_an_existing_account_and_clears_on_delete() { - let database = Database::in_memory().expect("database"); - let account = account(4, 40); - - let missing = database - .save_selected_account(Some(account.public_key())) - .expect_err("missing account"); - assert_eq!(missing.code(), SafeErrorCode::AccountNotFound); - - database.insert_account(&account).expect("insert"); - database - .save_selected_account(Some(account.public_key())) - .expect("select"); - database - .remove_account(account.public_key()) - .expect("remove"); - - assert_eq!(database.load_selected_account().expect("selection"), None); - } - - #[test] - fn account_mutations_reject_missing_and_corrupt_rows() { - let database = Database::in_memory().expect("database"); - let missing = account(3, 30); - assert_eq!( - database - .update_account(&missing) - .expect_err("missing update") - .code(), - SafeErrorCode::AccountNotFound - ); - assert_eq!( - database - .remove_account(missing.public_key()) - .expect_err("missing removal") - .code(), - SafeErrorCode::AccountNotFound - ); - assert_eq!( - database.find_account(missing.public_key()).expect("find"), - None - ); - - database.insert_account(&missing).expect("insert"); - database.update_account(&missing).expect("update"); - database - .connection() - .execute( - "DELETE FROM local_signer_bindings WHERE account_public_key = ?1", - [missing.public_key().to_hex()], - ) - .expect("delete binding"); - assert_eq!( - database - .update_account(&missing) - .expect_err("missing binding must fail") - .code(), - SafeErrorCode::StorageCorrupt - ); - database - .connection() - .execute( - "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')", - [missing.public_key().to_hex()], - ) - .expect("restore binding"); - database - .connection() - .pragma_update(None, "ignore_check_constraints", "ON") - .expect("disable check constraints for corruption fixture"); - database - .connection() - .execute( - "UPDATE local_signer_bindings SET binding_kind = 'remote' WHERE account_public_key = ?1", - [missing.public_key().to_hex()], - ) - .expect("corrupt binding kind"); - assert_eq!( - database - .list_accounts() - .expect_err("corrupt binding must fail") - .code(), - SafeErrorCode::StorageCorrupt - ); - - let database = Database::in_memory().expect("database"); - database.insert_account(&missing).expect("insert"); - database - .connection() - .pragma_update(None, "ignore_check_constraints", "ON") - .expect("disable check constraints for corruption fixture"); - database - .connection() - .execute( - "UPDATE local_signer_bindings SET availability = 'invalid' WHERE account_public_key = ?1", - [missing.public_key().to_hex()], - ) - .expect("corrupt availability"); - assert_eq!( - database - .find_account(missing.public_key()) - .expect_err("corrupt availability must fail") - .code(), - SafeErrorCode::StorageUnavailable - ); - - let database = Database::in_memory().expect("database"); - database - .connection() - .execute("DELETE FROM runtime_state", []) - .expect("delete runtime singleton"); - assert_eq!( - database - .save_selected_account(None) - .expect_err("missing runtime singleton must fail") - .code(), - SafeErrorCode::StorageCorrupt - ); - - let read_only = Database::in_memory().expect("read-only database"); - read_only - .connection() - .pragma_update(None, "query_only", "ON") - .expect("enable query-only mode"); - assert_eq!( - read_only - .insert_account(&missing) - .expect_err("non-constraint insertion failure must fail closed") - .code(), - SafeErrorCode::StorageUnavailable - ); - } -} diff --git a/crates/studio_storage/src/compatibility.rs b/crates/studio_storage/src/compatibility.rs @@ -1,474 +0,0 @@ -use std::path::Path; - -use radroots_studio_domain::{ - AccountIdentity, PersistedPublicKeyClassification, SafeError, SafeErrorCode, SafeMessage, - classify_persisted_public_key, -}; -use rusqlite::{Connection, OpenFlags}; -use sha2::{Digest, Sha256}; - -use crate::CURRENT_SCHEMA_VERSION; - -const KNOWN_TABLES: &[(&str, u32)] = &[ - ("application_schema", 1), - ("accounts", 2), - ("app_state", 2), - ("profile_cache", 3), - ("account_namespace", 4), - ("operation_journal", 5), - ("account_identities", 6), - ("local_signer_bindings", 6), - ("runtime_state", 6), - ("profile_cache_v6", 6), - ("durable_operations", 6), - ("account_preferences", 8), - ("installation_identity", 10), -]; - -const PUBLIC_KEY_COLUMNS: &[(&str, &str)] = &[ - ("accounts", "pubkey"), - ("app_state", "selected_pubkey"), - ("profile_cache", "subject_pubkey"), - ("account_namespace", "owner_pubkey"), - ("operation_journal", "subject_pubkey"), - ("account_identities", "public_key"), - ("local_signer_bindings", "account_public_key"), - ("local_signer_bindings", "binding_public_key"), - ("runtime_state", "selected_public_key"), - ("runtime_state", "active_account_public_key"), - ("runtime_state", "active_binding_public_key"), - ("profile_cache_v6", "subject_public_key"), - ("durable_operations", "account_public_key"), - ("durable_operations", "binding_public_key"), - ("durable_operations", "prior_selected_public_key"), - ("account_preferences", "owner_public_key"), -]; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum DatabasePreflight { - Fresh, - Ready { - schema_version: u32, - }, - Quarantined { - schema_version: u32, - issues: Vec<PersistedIdentityIssue>, - }, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum PersistedIdentityIssueKind { - MalformedEncoding, - NonCanonicalEncoding, - InvalidCurvePoint, - DisplayIdentityMismatch, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PersistedIdentityIssue { - table: &'static str, - column: &'static str, - row_id: i64, - kind: PersistedIdentityIssueKind, - fingerprint: [u8; 32], -} - -impl PersistedIdentityIssue { - #[must_use] - pub const fn table(&self) -> &'static str { - self.table - } - - #[must_use] - pub const fn column(&self) -> &'static str { - self.column - } - - #[must_use] - pub const fn row_id(&self) -> i64 { - self.row_id - } - - #[must_use] - pub const fn kind(&self) -> PersistedIdentityIssueKind { - self.kind - } - - #[must_use] - pub const fn fingerprint(&self) -> &[u8; 32] { - &self.fingerprint - } -} - -pub(crate) fn preflight(path: &Path) -> Result<DatabasePreflight, SafeError> { - match std::fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - return Err(corrupt_storage_error()); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(DatabasePreflight::Fresh); - } - Err(_) => return Err(corrupt_storage_error()), - } - let flags = OpenFlags::SQLITE_OPEN_READ_ONLY - | OpenFlags::SQLITE_OPEN_NO_MUTEX - | OpenFlags::SQLITE_OPEN_NOFOLLOW; - let connection = - Connection::open_with_flags(path, flags).map_err(|_| corrupt_storage_error())?; - connection - .pragma_update(None, "trusted_schema", "OFF") - .map_err(|_| corrupt_storage_error())?; - let integrity: String = connection - .pragma_query_value(None, "quick_check", |row| row.get(0)) - .map_err(|_| corrupt_storage_error())?; - if integrity != "ok" { - return Err(corrupt_storage_error()); - } - let schema_version = schema_version(&connection)?; - if schema_version == 0 || schema_version > CURRENT_SCHEMA_VERSION { - return Err(unsupported_schema_error()); - } - validate_schema_inventory(&connection, schema_version)?; - - let mut issues = Vec::new(); - for &(table, column) in PUBLIC_KEY_COLUMNS { - if column_exists(&connection, table, column)? { - scan_public_key_column(&connection, table, column, &mut issues)?; - } - } - scan_display_identities(&connection, "accounts", "pubkey", "npub", &mut issues)?; - scan_display_identities( - &connection, - "account_identities", - "public_key", - "npub", - &mut issues, - )?; - issues.sort_by_key(|issue| (issue.table, issue.column, issue.row_id)); - if issues.is_empty() { - Ok(DatabasePreflight::Ready { schema_version }) - } else { - Ok(DatabasePreflight::Quarantined { - schema_version, - issues, - }) - } -} - -fn schema_version(connection: &Connection) -> Result<u32, SafeError> { - if !table_exists(connection, "refinery_schema_history")? { - return Err(unsupported_schema_error()); - } - connection - .query_row( - "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history", - [], - |row| row.get(0), - ) - .map_err(|_| corrupt_storage_error()) -} - -fn validate_schema_inventory(connection: &Connection, version: u32) -> Result<(), SafeError> { - for &(table, introduced) in KNOWN_TABLES { - let present = table_exists(connection, table)?; - if present != (version >= introduced) { - return Err(corrupt_storage_error()); - } - } - let mut statement = connection - .prepare( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> 'refinery_schema_history'", - ) - .map_err(|_| corrupt_storage_error())?; - let names = statement - .query_map([], |row| row.get::<_, String>(0)) - .map_err(|_| corrupt_storage_error())?; - for name in names { - let name = name.map_err(|_| corrupt_storage_error())?; - if !KNOWN_TABLES.iter().any(|(known, _)| *known == name) { - return Err(corrupt_storage_error()); - } - } - Ok(()) -} - -fn scan_public_key_column( - connection: &Connection, - table: &'static str, - column: &'static str, - issues: &mut Vec<PersistedIdentityIssue>, -) -> Result<(), SafeError> { - let sql = format!("SELECT rowid, {column} FROM {table} WHERE {column} IS NOT NULL"); - let mut statement = connection - .prepare(&sql) - .map_err(|_| corrupt_storage_error())?; - let rows = statement - .query_map([], |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) - }) - .map_err(|_| corrupt_storage_error())?; - for row in rows { - let (row_id, value) = row.map_err(|_| corrupt_storage_error())?; - let kind = match classify_persisted_public_key(&value) { - PersistedPublicKeyClassification::Canonical(_) => continue, - PersistedPublicKeyClassification::MalformedEncoding => { - PersistedIdentityIssueKind::MalformedEncoding - } - PersistedPublicKeyClassification::NonCanonicalEncoding => { - PersistedIdentityIssueKind::NonCanonicalEncoding - } - PersistedPublicKeyClassification::InvalidCurvePoint => { - PersistedIdentityIssueKind::InvalidCurvePoint - } - }; - issues.push(issue(table, column, row_id, kind, &value)); - } - Ok(()) -} - -fn scan_display_identities( - connection: &Connection, - table: &'static str, - key_column: &'static str, - npub_column: &'static str, - issues: &mut Vec<PersistedIdentityIssue>, -) -> Result<(), SafeError> { - if !column_exists(connection, table, key_column)? - || !column_exists(connection, table, npub_column)? - { - return Ok(()); - } - let sql = format!("SELECT rowid, {key_column}, {npub_column} FROM {table}"); - let mut statement = connection - .prepare(&sql) - .map_err(|_| corrupt_storage_error())?; - let rows = statement - .query_map([], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - }) - .map_err(|_| corrupt_storage_error())?; - for row in rows { - let (row_id, key, npub) = row.map_err(|_| corrupt_storage_error())?; - let PersistedPublicKeyClassification::Canonical(public_key) = - classify_persisted_public_key(&key) - else { - continue; - }; - if AccountIdentity::verify(public_key, npub.clone()).is_err() { - issues.push(issue( - table, - npub_column, - row_id, - PersistedIdentityIssueKind::DisplayIdentityMismatch, - &npub, - )); - } - } - Ok(()) -} - -fn issue( - table: &'static str, - column: &'static str, - row_id: i64, - kind: PersistedIdentityIssueKind, - value: &str, -) -> PersistedIdentityIssue { - PersistedIdentityIssue { - table, - column, - row_id, - kind, - fingerprint: Sha256::digest(value.as_bytes()).into(), - } -} - -fn table_exists(connection: &Connection, table: &str) -> Result<bool, SafeError> { - connection - .query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", - [table], - |row| row.get(0), - ) - .map_err(|_| corrupt_storage_error()) -} - -fn column_exists(connection: &Connection, table: &str, column: &str) -> Result<bool, SafeError> { - if !table_exists(connection, table)? { - return Ok(false); - } - let sql = format!("SELECT EXISTS(SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1)"); - connection - .query_row(&sql, [column], |row| row.get(0)) - .map_err(|_| corrupt_storage_error()) -} - -const fn corrupt_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageCorrupt, - SafeMessage::new("The application database could not be read."), - ) -} - -const fn unsupported_schema_error() -> SafeError { - SafeError::new( - SafeErrorCode::UnsupportedSchemaVersion, - SafeMessage::new("The application database schema is not supported."), - ) -} - -pub(crate) const fn quarantined_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageQuarantined, - SafeMessage::new("The application database requires authenticated repair."), - ) -} - -#[cfg(test)] -mod tests { - use rusqlite::{Connection, params}; - use tempfile::tempdir; - - use super::{ - DatabasePreflight, PersistedIdentityIssueKind, column_exists, preflight, - scan_display_identities, scan_public_key_column, - }; - use crate::Database; - use radroots_studio_domain::{AccountIdentity, PublicKey, SafeErrorCode}; - - #[test] - fn preflight_rejects_non_files_missing_schema_zero_version_and_unknown_tables() { - let directory = tempdir().expect("temporary directory"); - let missing = directory.path().join("missing.sqlite3"); - assert_eq!( - preflight(&missing).expect("fresh preflight"), - DatabasePreflight::Fresh - ); - assert_eq!( - preflight(directory.path()) - .expect_err("directory must fail") - .code(), - SafeErrorCode::StorageCorrupt - ); - let regular_parent = directory.path().join("regular-parent"); - std::fs::write(&regular_parent, b"not a directory").expect("write regular parent"); - assert_eq!( - preflight(&regular_parent.join("nested.sqlite3")) - .expect_err("non-directory parent must fail") - .code(), - SafeErrorCode::StorageCorrupt - ); - - let no_schema = directory.path().join("no-schema.sqlite3"); - drop(Connection::open(&no_schema).expect("blank sqlite database")); - assert_eq!( - preflight(&no_schema) - .expect_err("missing schema history") - .code(), - SafeErrorCode::UnsupportedSchemaVersion - ); - - let zero_schema = directory.path().join("zero-schema.sqlite3"); - let connection = Connection::open(&zero_schema).expect("zero schema database"); - connection - .execute( - "CREATE TABLE refinery_schema_history (version INTEGER NOT NULL)", - [], - ) - .expect("schema history"); - connection - .execute( - "INSERT INTO refinery_schema_history (version) VALUES (0)", - [], - ) - .expect("zero version"); - drop(connection); - assert_eq!( - preflight(&zero_schema) - .expect_err("zero schema version") - .code(), - SafeErrorCode::UnsupportedSchemaVersion - ); - - let unknown = directory.path().join("unknown-table.sqlite3"); - drop(Database::open(&unknown).expect("current database")); - let connection = Connection::open(&unknown).expect("open current database"); - connection - .execute("CREATE TABLE ungoverned_table (value INTEGER)", []) - .expect("unknown table"); - drop(connection); - assert_eq!( - preflight(&unknown) - .expect_err("unknown table must fail") - .code(), - SafeErrorCode::StorageCorrupt - ); - } - - #[test] - fn identity_scans_classify_all_persisted_key_and_display_failures() { - let connection = Connection::open_in_memory().expect("database"); - connection - .execute("CREATE TABLE identities (public_key TEXT, npub TEXT)", []) - .expect("identity table"); - let canonical = - PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df") - .expect("canonical key"); - let npub = AccountIdentity::derive(canonical) - .expect("identity") - .npub() - .as_str() - .to_owned(); - let values = [ - (canonical.to_hex(), npub), - (canonical.to_hex().to_uppercase(), "invalid-npub".to_owned()), - ("bad".to_owned(), "invalid-npub".to_owned()), - ("00".repeat(32), "invalid-npub".to_owned()), - (canonical.to_hex(), "invalid-npub".to_owned()), - ]; - for (public_key, npub) in values { - connection - .execute( - "INSERT INTO identities (public_key, npub) VALUES (?1, ?2)", - params![public_key, npub], - ) - .expect("identity row"); - } - - assert!(column_exists(&connection, "identities", "public_key").expect("column")); - assert!(!column_exists(&connection, "missing", "public_key").expect("missing table")); - assert!(!column_exists(&connection, "identities", "missing").expect("missing column")); - let mut issues = Vec::new(); - scan_public_key_column(&connection, "identities", "public_key", &mut issues) - .expect("scan public keys"); - scan_display_identities(&connection, "identities", "public_key", "npub", &mut issues) - .expect("scan display identities"); - scan_display_identities(&connection, "missing", "public_key", "npub", &mut issues) - .expect("skip missing table"); - connection - .execute("CREATE TABLE key_only (public_key TEXT)", []) - .expect("key-only table"); - scan_display_identities(&connection, "key_only", "public_key", "npub", &mut issues) - .expect("skip missing display column"); - - for kind in [ - PersistedIdentityIssueKind::MalformedEncoding, - PersistedIdentityIssueKind::NonCanonicalEncoding, - PersistedIdentityIssueKind::InvalidCurvePoint, - PersistedIdentityIssueKind::DisplayIdentityMismatch, - ] { - assert!(issues.iter().any(|issue| issue.kind() == kind)); - } - for issue in &issues { - assert_eq!(issue.table(), "identities"); - assert!(matches!(issue.column(), "public_key" | "npub")); - assert!(issue.row_id() > 0); - assert_ne!(issue.fingerprint(), &[0_u8; 32]); - } - } -} diff --git a/crates/studio_storage/src/db.rs b/crates/studio_storage/src/db.rs @@ -1,907 +0,0 @@ -use std::fs::{self, File, OpenOptions}; -use std::ops::{Deref, DerefMut}; -use std::path::{Path, PathBuf}; -use std::sync::{Mutex, MutexGuard}; -use std::time::Duration; - -use fs2::FileExt; -use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; -use refinery::embed_migrations; -use rusqlite::{Connection, OpenFlags}; - -use crate::compatibility::{DatabasePreflight, preflight, quarantined_storage_error}; -use crate::recovery::MigrationRecovery; -use crate::repair::{ - QuarantineExportReceipt, RepairAuthorization, RepairCandidate, authenticate_candidate, - export_quarantined, install_candidate, -}; - -pub const CURRENT_SCHEMA_VERSION: u32 = 10; - -mod migrations { - use super::embed_migrations; - - embed_migrations!("migrations"); -} - -pub struct Database { - connection: Mutex<Connection>, - path: Option<PathBuf>, - _ownership: Option<WritableOwnership>, -} - -pub(crate) struct DatabaseConnection<'a> { - connection: MutexGuard<'a, Connection>, - path: Option<&'a Path>, -} - -struct WritableOwnership { - _file: File, -} - -impl Database { - /// Opens, configures, and migrates a file-backed `SQLite` database. - /// - /// # Errors - /// - /// Returns a safe storage error when the file, connection configuration, - /// permission update, or migration cannot complete. - pub fn open(path: &Path) -> Result<Self, SafeError> { - let preflight = preflight(path)?; - if matches!(&preflight, DatabasePreflight::Quarantined { .. }) { - return Err(quarantined_storage_error()); - } - let parent = path.parent().ok_or_else(storage_error)?; - create_secure_directory(parent)?; - restrict_sqlite_sidecars(path)?; - let ownership = WritableOwnership::acquire(path)?; - let recovery_source_schema = match &preflight { - DatabasePreflight::Ready { schema_version } - if *schema_version < CURRENT_SCHEMA_VERSION => - { - Some(*schema_version) - } - _ => None, - }; - let recovery = match preflight { - DatabasePreflight::Ready { schema_version } - if schema_version < CURRENT_SCHEMA_VERSION => - { - Some(MigrationRecovery::prepare( - path, - schema_version, - CURRENT_SCHEMA_VERSION, - )?) - } - DatabasePreflight::Fresh | DatabasePreflight::Ready { .. } => None, - DatabasePreflight::Quarantined { .. } => unreachable!("handled above"), - }; - let flags = OpenFlags::SQLITE_OPEN_READ_WRITE - | OpenFlags::SQLITE_OPEN_CREATE - | OpenFlags::SQLITE_OPEN_NO_MUTEX - | OpenFlags::SQLITE_OPEN_NOFOLLOW; - let mut connection = - Connection::open_with_flags(path, flags).map_err(|_| storage_error())?; - configure(&connection).map_err(|_| corrupt_storage_error())?; - if migrations::migrations::runner() - .run(&mut connection) - .is_err() - { - drop(connection); - if let Some(source_schema) = recovery_source_schema { - MigrationRecovery::restore(path, source_schema, CURRENT_SCHEMA_VERSION)?; - } - return Err(corrupt_storage_error()); - } - let schema_version = connection - .query_row( - "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history", - [], - |row| row.get::<_, u32>(0), - ) - .map_err(|_| corrupt_storage_error())?; - if schema_version != CURRENT_SCHEMA_VERSION { - return Err(corrupt_storage_error()); - } - restrict_file_permissions(path)?; - restrict_sqlite_sidecars(path)?; - if let Some(recovery) = recovery { - recovery.finish(schema_version)?; - } - Ok(Self { - connection: Mutex::new(connection), - path: Some(path.to_path_buf()), - _ownership: Some(ownership), - }) - } - - /// Opens and migrates an isolated in-memory `SQLite` database. - /// - /// # Errors - /// - /// Returns a safe storage error when configuration or migration fails. - pub fn in_memory() -> Result<Self, SafeError> { - let mut connection = Connection::open_in_memory().map_err(|_| storage_error())?; - configure(&connection)?; - migrations::migrations::runner() - .run(&mut connection) - .map_err(|_| corrupt_storage_error())?; - Ok(Self { - connection: Mutex::new(connection), - path: None, - _ownership: None, - }) - } - - /// Inspects schema and persisted identities without mutating the database. - /// - /// # Errors - /// - /// Returns a safe corrupt or unsupported-schema error when the database - /// cannot be classified. - pub fn preflight(path: &Path) -> Result<DatabasePreflight, SafeError> { - preflight(path) - } - - /// Verifies the authenticated, immutable backup retained for a migration. - /// - /// # Errors - /// - /// Returns a safe backup error when any manifest, digest, authentication - /// tag, schema identity, or SQLite integrity check fails. - pub fn verify_migration_backup(path: &Path, source_schema: u32) -> Result<(), SafeError> { - MigrationRecovery::verify_evidence(path, source_schema, CURRENT_SCHEMA_VERSION) - } - - /// Restores an authenticated pre-migration backup while retaining the - /// displaced database as recovery evidence. - /// - /// # Errors - /// - /// Returns a safe storage or backup error without replacing the database - /// when authentication or the atomic replacement fails. - pub fn restore_migration_backup(path: &Path, source_schema: u32) -> Result<(), SafeError> { - let _ownership = WritableOwnership::acquire(path)?; - MigrationRecovery::restore(path, source_schema, CURRENT_SCHEMA_VERSION) - } - - /// Exports a quarantined database without mutating it and authenticates - /// the resulting SQLite artifact with a caller-owned repair capability. - /// - /// # Errors - /// - /// Returns a safe state, authorization, or storage error. - pub fn export_quarantined( - path: &Path, - destination: &Path, - authorization: &RepairAuthorization, - ) -> Result<QuarantineExportReceipt, SafeError> { - export_quarantined(path, destination, authorization) - } - - /// Validates and authenticates a canonical repaired database candidate. - /// - /// # Errors - /// - /// Returns a safe compatibility or storage error for an invalid candidate. - pub fn authenticate_repair_candidate( - path: &Path, - authorization: &RepairAuthorization, - ) -> Result<RepairCandidate, SafeError> { - authenticate_candidate(path, authorization) - } - - /// Atomically installs an authenticated candidate over a quarantined - /// database while retaining the original as immutable evidence. - /// - /// # Errors - /// - /// Returns a safe authorization, ownership, or storage error without - /// replacing the target when any gate fails. - pub fn install_repair_candidate( - path: &Path, - candidate: &RepairCandidate, - authorization: &RepairAuthorization, - ) -> Result<(), SafeError> { - let _ownership = WritableOwnership::acquire(path)?; - install_candidate(path, candidate, authorization) - } - - /// Returns the highest successfully applied migration version. - /// - /// # Errors - /// - /// Returns a safe storage error when migration history cannot be read. - pub fn schema_version(&self) -> Result<u32, SafeError> { - self.connection() - .query_row( - "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history", - [], - |row| row.get(0), - ) - .map_err(|_| corrupt_storage_error()) - } - - pub(crate) fn connection(&self) -> DatabaseConnection<'_> { - DatabaseConnection { - connection: self - .connection - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner), - path: self.path.as_deref(), - } - } -} - -impl Deref for DatabaseConnection<'_> { - type Target = Connection; - - fn deref(&self) -> &Self::Target { - &self.connection - } -} - -impl DerefMut for DatabaseConnection<'_> { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.connection - } -} - -impl Drop for DatabaseConnection<'_> { - fn drop(&mut self) { - if let Some(path) = self.path { - let _ = restrict_sqlite_sidecars(path); - } - } -} - -impl WritableOwnership { - fn acquire(database_path: &Path) -> Result<Self, SafeError> { - let lock_path = database_path.with_extension("sqlite3.lock"); - let mut options = OpenOptions::new(); - options.read(true).write(true).create(true).truncate(false); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags( - (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits() as i32, - ); - } - let file = options.open(&lock_path).map_err(|_| storage_error())?; - restrict_file_permissions(&lock_path)?; - file.try_lock_exclusive().map_err(|_| ownership_error())?; - Ok(Self { _file: file }) - } -} - -fn create_secure_directory(path: &Path) -> Result<(), SafeError> { - let mut existing = path; - loop { - match fs::symlink_metadata(existing) { - Ok(metadata) => { - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(storage_error()); - } - break; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - existing = existing.parent().ok_or_else(storage_error)?; - } - Err(_) => return Err(storage_error()), - } - } - fs::create_dir_all(path).map_err(|_| storage_error())?; - let metadata = fs::symlink_metadata(path).map_err(|_| storage_error())?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(storage_error()); - } - restrict_directory_permissions(path) -} - -fn configure(connection: &Connection) -> Result<(), SafeError> { - connection - .pragma_update(None, "foreign_keys", "ON") - .and_then(|()| connection.pragma_update(None, "trusted_schema", "OFF")) - .and_then(|()| connection.pragma_update(None, "journal_mode", "WAL")) - .and_then(|()| connection.pragma_update(None, "synchronous", "FULL")) - .and_then(|()| connection.pragma_update(None, "secure_delete", "ON")) - .and_then(|()| connection.pragma_update(None, "wal_autocheckpoint", 1_000)) - .and_then(|()| connection.busy_timeout(Duration::from_secs(5))) - .map_err(|_| storage_error()) -} - -fn restrict_sqlite_sidecars(path: &Path) -> Result<(), SafeError> { - for suffix in ["-wal", "-shm"] { - let sidecar = PathBuf::from(format!("{}{suffix}", path.display())); - match fs::symlink_metadata(&sidecar) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - return Err(storage_error()); - } - Ok(_) => restrict_file_permissions(&sidecar)?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return Err(storage_error()), - } - } - Ok(()) -} - -#[cfg(unix)] -pub(crate) fn restrict_file_permissions(path: &Path) -> Result<(), SafeError> { - use std::os::unix::fs::PermissionsExt; - - fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|_| storage_error()) -} - -#[cfg(unix)] -pub(crate) fn restrict_directory_permissions(path: &Path) -> Result<(), SafeError> { - use std::os::unix::fs::PermissionsExt; - - fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|_| storage_error()) -} - -#[cfg(not(unix))] -pub(crate) fn restrict_file_permissions(_path: &Path) -> Result<(), SafeError> { - Ok(()) -} - -#[cfg(not(unix))] -pub(crate) fn restrict_directory_permissions(_path: &Path) -> Result<(), SafeError> { - Ok(()) -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The application database is unavailable."), - ) -} - -const fn corrupt_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageCorrupt, - SafeMessage::new("The application database could not be read."), - ) -} - -const fn ownership_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The application database is already in use."), - ) -} - -#[cfg(test)] -mod tests { - use std::fs; - use std::io::Write; - use std::path::Path; - use std::process::Command; - - use tempfile::tempdir; - - use radroots_studio_application::{AccountRepository, AppStateRepository}; - use radroots_studio_domain::{PublicKey, SafeErrorCode}; - use refinery::Target; - use rusqlite::Connection; - - use super::{ - CURRENT_SCHEMA_VERSION, Database, configure, create_secure_directory, migrations, - restrict_sqlite_sidecars, - }; - use crate::{DatabasePreflight, PersistedIdentityIssueKind, RepairAuthorization}; - - #[test] - fn migration_opens_fresh_memory_database_once() { - let database = Database::in_memory().expect("open memory database"); - - assert_eq!( - database.schema_version().expect("schema version"), - CURRENT_SCHEMA_VERSION - ); - assert_eq!( - database.schema_version().expect("repeat schema version"), - CURRENT_SCHEMA_VERSION - ); - } - - #[test] - fn database_path_guards_reject_files_as_directories_and_sidecars() { - let directory = tempdir().expect("temporary directory"); - let regular = directory.path().join("regular"); - fs::write(&regular, b"file").expect("write regular file"); - assert!(create_secure_directory(&regular).is_err()); - - let database = directory.path().join("studio.sqlite3"); - fs::write(&database, b"database").expect("write database file"); - fs::create_dir(directory.path().join("studio.sqlite3-wal")) - .expect("create invalid WAL sidecar"); - assert!(restrict_sqlite_sidecars(&database).is_err()); - } - - #[test] - fn sqlite_connection_enforces_trust_durability_and_busy_policy() { - let database = Database::in_memory().expect("open memory database"); - let connection = database.connection(); - - assert_eq!( - connection - .pragma_query_value(None, "foreign_keys", |row| row.get::<_, u8>(0)) - .expect("foreign keys"), - 1 - ); - assert_eq!( - connection - .pragma_query_value(None, "trusted_schema", |row| row.get::<_, u8>(0)) - .expect("trusted schema"), - 0 - ); - assert_eq!( - connection - .pragma_query_value(None, "synchronous", |row| row.get::<_, u8>(0)) - .expect("synchronous"), - 2 - ); - assert_eq!( - connection - .pragma_query_value(None, "busy_timeout", |row| row.get::<_, i64>(0)) - .expect("busy timeout"), - 5_000 - ); - } - - #[test] - fn normalized_schema_is_strict_and_enforces_same_account_bindings() { - let database = Database::in_memory().expect("open memory database"); - let connection = database.connection(); - let strict_tables: i64 = connection - .query_row( - "SELECT COUNT(*) FROM pragma_table_list WHERE name IN ('account_identities', 'local_signer_bindings', 'runtime_state', 'profile_cache_v6', 'durable_operations') AND strict = 1", - [], - |row| row.get(0), - ) - .expect("strict table inventory"); - assert_eq!(strict_tables, 5); - - connection - .execute( - "INSERT INTO account_identities (public_key, npub, created_at) VALUES (?1, ?2, 1)", - [ - "07".repeat(32), - "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned(), - ], - ) - .expect("identity"); - assert!( - connection - .execute( - "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?2, 'local_secret', 'available')", - ["07".repeat(32), "08".repeat(32)], - ) - .is_err() - ); - } - - #[test] - fn v5_data_migrates_append_only_with_identity_profile_and_selection() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let public_key = "07".repeat(32); - { - let mut connection = Connection::open(&path).expect("legacy database"); - configure(&connection).expect("configuration"); - migrations::migrations::runner() - .set_target(Target::Version(5)) - .run(&mut connection) - .expect("V5 schema"); - connection - .execute( - "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", - [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"], - ) - .expect("legacy account"); - connection - .execute( - "UPDATE app_state SET selected_pubkey = ?1 WHERE singleton = 1", - [&public_key], - ) - .expect("legacy selection"); - connection - .execute( - "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, name, refreshed_at, refresh_status) VALUES (?1, ?2, 11, 'Farm', 12, 'success')", - [&public_key, &"01".repeat(32)], - ) - .expect("legacy profile"); - } - - let database = Database::open(&path).expect("migrated database"); - assert_eq!(database.schema_version().expect("version"), 10); - assert_eq!(database.list_accounts().expect("accounts").len(), 1); - assert_eq!( - database.load_selected_account().expect("selection"), - Some(PublicKey::from_bytes([7; 32]).expect("valid public key")) - ); - let connection = database.connection(); - let migrated: (i64, i64, i64) = connection - .query_row( - "SELECT (SELECT COUNT(*) FROM account_identities), (SELECT COUNT(*) FROM local_signer_bindings), (SELECT COUNT(*) FROM profile_cache_v6)", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .expect("migrated inventory"); - assert_eq!(migrated, (1, 1, 1)); - drop(connection); - drop(database); - - Database::verify_migration_backup(&path, 5).expect("authenticated backup"); - Database::restore_migration_backup(&path, 5).expect("authenticated restore"); - assert_eq!( - Database::preflight(&path).expect("restored preflight"), - DatabasePreflight::Ready { schema_version: 5 } - ); - let retried = Database::open(&path).expect("idempotent migration retry"); - assert_eq!(retried.schema_version().expect("retried version"), 10); - drop(retried); - - let backup = directory - .path() - .join("studio.sqlite3.recovery/migration-v5-to-v10.sqlite3"); - fs::OpenOptions::new() - .append(true) - .open(backup) - .expect("open backup") - .write_all(b"tamper") - .expect("tamper backup"); - let error = - Database::verify_migration_backup(&path, 5).expect_err("tampered backup must fail"); - assert_eq!(error.code(), SafeErrorCode::StorageBackupInvalid); - } - - #[test] - fn corrupt_v5_identity_fails_before_migration_without_recreation() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - { - let mut connection = Connection::open(&path).expect("legacy database"); - configure(&connection).expect("configuration"); - migrations::migrations::runner() - .set_target(Target::Version(5)) - .run(&mut connection) - .expect("V5 schema"); - connection - .execute( - "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", - ["07".repeat(32), "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg".to_owned()], - ) - .expect("mismatched legacy account"); - } - - assert!(Database::open(&path).is_err()); - let connection = Connection::open(&path).expect("inspect legacy database"); - let version: u32 = connection - .query_row( - "SELECT MAX(version) FROM refinery_schema_history", - [], - |row| row.get(0), - ) - .expect("legacy version"); - let accounts: i64 = connection - .query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get(0)) - .expect("legacy accounts"); - assert_eq!((version, accounts), (5, 1)); - } - - #[test] - fn invalid_curve_identity_is_quarantined_without_mutation() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - { - let mut connection = Connection::open(&path).expect("legacy database"); - configure(&connection).expect("configuration"); - migrations::migrations::runner() - .set_target(Target::Version(5)) - .run(&mut connection) - .expect("V5 schema"); - connection - .execute( - "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", - ["00".repeat(32), "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned()], - ) - .expect("invalid-curve fixture"); - connection - .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)") - .expect("checkpoint"); - } - let before = fs::read(&path).expect("before bytes"); - - let DatabasePreflight::Quarantined { - schema_version, - issues, - } = Database::preflight(&path).expect("classified preflight") - else { - panic!("invalid identity was not quarantined"); - }; - assert_eq!(schema_version, 5); - assert!(issues.iter().any(|issue| { - issue.table() == "accounts" - && issue.column() == "pubkey" - && issue.kind() == PersistedIdentityIssueKind::InvalidCurvePoint - })); - let error = Database::open(&path) - .err() - .expect("quarantined open must fail"); - assert_eq!(error.code(), SafeErrorCode::StorageQuarantined); - assert_eq!(fs::read(&path).expect("after bytes"), before); - assert!(!path.with_extension("sqlite3.lock").exists()); - - let authorization = RepairAuthorization::from_bytes(vec![0x41; 32]) - .unwrap_or_else(|_| panic!("repair authorization")); - let export_path = directory.path().join("quarantine-export.sqlite3"); - let export = Database::export_quarantined(&path, &export_path, &authorization) - .expect("authenticated quarantine export"); - assert_eq!(export.path(), export_path); - assert_eq!(export.sha256().len(), 64); - assert_eq!(export.authentication_tag().len(), 64); - assert_eq!(fs::read(&path).expect("post-export bytes"), before); - - let candidate_path = directory.path().join("repaired.sqlite3"); - drop(Database::open(&candidate_path).expect("canonical repair candidate")); - let candidate = Database::authenticate_repair_candidate(&candidate_path, &authorization) - .expect("authenticate candidate"); - let wrong_authorization = RepairAuthorization::from_bytes(vec![0x42; 32]) - .unwrap_or_else(|_| panic!("wrong authorization shape")); - let error = Database::install_repair_candidate(&path, &candidate, &wrong_authorization) - .expect_err("wrong repair authorization"); - assert_eq!(error.code(), SafeErrorCode::RepairUnauthorized); - assert_eq!(fs::read(&path).expect("unauthorized bytes"), before); - - Database::install_repair_candidate(&path, &candidate, &authorization) - .expect("authenticated repair install"); - assert!(matches!( - Database::preflight(&path).expect("repaired preflight"), - DatabasePreflight::Ready { - schema_version: CURRENT_SCHEMA_VERSION - } - )); - assert!( - directory - .path() - .join("studio.sqlite3.quarantined-evidence") - .is_file() - ); - } - - #[test] - fn newer_and_mixed_schema_inventory_fail_before_mutation() { - let directory = tempdir().expect("temporary directory"); - let newer_path = directory.path().join("newer.sqlite3"); - { - let database = Database::open(&newer_path).expect("current database"); - database - .connection() - .execute( - "UPDATE refinery_schema_history SET version = ?1 WHERE version = ?2", - [CURRENT_SCHEMA_VERSION + 1, CURRENT_SCHEMA_VERSION], - ) - .expect("future schema row"); - } - let newer_before = fs::read(&newer_path).expect("newer bytes"); - let error = Database::preflight(&newer_path).expect_err("newer schema"); - assert_eq!(error.code(), SafeErrorCode::UnsupportedSchemaVersion); - assert_eq!(fs::read(&newer_path).expect("newer after"), newer_before); - - let mixed_path = directory.path().join("mixed.sqlite3"); - { - let mut connection = Connection::open(&mixed_path).expect("legacy database"); - configure(&connection).expect("configuration"); - migrations::migrations::runner() - .set_target(Target::Version(5)) - .run(&mut connection) - .expect("V5 schema"); - connection - .execute("CREATE TABLE installation_identity (singleton INTEGER)", []) - .expect("mixed table"); - } - let mixed_before = fs::read(&mixed_path).expect("mixed bytes"); - let error = Database::preflight(&mixed_path).expect_err("mixed schema"); - assert_eq!(error.code(), SafeErrorCode::StorageCorrupt); - assert_eq!(fs::read(&mixed_path).expect("mixed after"), mixed_before); - } - - #[test] - fn failed_v5_copy_rolls_back_the_active_migration() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let public_key = "07".repeat(32); - { - let mut connection = Connection::open(&path).expect("legacy database"); - configure(&connection).expect("configuration"); - migrations::migrations::runner() - .set_target(Target::Version(5)) - .run(&mut connection) - .expect("V5 schema"); - connection - .execute( - "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", - [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"], - ) - .expect("legacy account"); - connection - .execute( - "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, refreshed_at, refresh_status) VALUES (?1, 'invalid', 11, 12, 'success')", - [&public_key], - ) - .expect("legacy corrupt profile"); - } - - assert!(Database::open(&path).is_err()); - let connection = Connection::open(&path).expect("inspect interrupted migration"); - let version: u32 = connection - .query_row( - "SELECT MAX(version) FROM refinery_schema_history", - [], - |row| row.get(0), - ) - .expect("migration version"); - assert_eq!(version, 5); - assert!(!connection - .query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'account_identities')", - [], - |row| row.get::<_, bool>(0), - ) - .expect("normalized table inventory")); - } - - #[test] - fn foreign_keys_reject_orphan_normalized_records() { - let database = Database::in_memory().expect("database"); - let connection = database.connection(); - assert!( - connection - .execute( - "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')", - ["09".repeat(32)], - ) - .is_err() - ); - } - - #[test] - fn second_process_cannot_acquire_writable_ownership() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let _owner = Database::open(&path).expect("parent owner"); - let status = Command::new(std::env::current_exe().expect("test executable")) - .arg("--exact") - .arg("db::tests::writable_ownership_child_probe") - .arg("--nocapture") - .env("RADROOTS_STUDIO_LOCK_PROBE_PATH", &path) - .status() - .expect("child process"); - assert!(status.success()); - } - - #[test] - fn writable_ownership_child_probe() { - let Ok(path) = std::env::var("RADROOTS_STUDIO_LOCK_PROBE_PATH") else { - return; - }; - assert!(Database::open(Path::new(&path)).is_err()); - } - - #[test] - fn migration_persists_schema_version_across_file_reopen() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - - { - let database = Database::open(&path).expect("open file database"); - assert_eq!( - database.schema_version().expect("schema version"), - CURRENT_SCHEMA_VERSION - ); - } - let reopened = Database::open(&path).expect("reopen file database"); - assert_eq!( - reopened.schema_version().expect("schema version"), - CURRENT_SCHEMA_VERSION - ); - assert!(fs::metadata(path).expect("database metadata").len() > 0); - } - - #[test] - fn writable_ownership_rejects_a_second_runtime_and_releases_on_drop() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let first = Database::open(&path).expect("first owner"); - let Err(error) = Database::open(&path) else { - panic!("second owner must fail"); - }; - assert_eq!( - error.message().as_str(), - "The application database is already in use." - ); - drop(first); - Database::open(&path).expect("ownership released"); - } - - #[cfg(unix)] - #[test] - fn migration_attempts_owner_only_database_permissions() { - use std::os::unix::fs::PermissionsExt; - - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let database = Database::open(&path).expect("open file database"); - let mode = fs::metadata(&path) - .expect("database metadata") - .permissions() - .mode() - & 0o777; - - assert_eq!(mode, 0o600); - let directory_mode = fs::metadata(directory.path()) - .expect("directory metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(directory_mode, 0o700); - - let connection = database.connection(); - connection - .execute_batch("CREATE TABLE sidecar_probe (value INTEGER) STRICT; INSERT INTO sidecar_probe VALUES (1);") - .expect("write through WAL"); - drop(connection); - for suffix in ["-wal", "-shm"] { - let sidecar = std::path::PathBuf::from(format!("{}{suffix}", path.display())); - let sidecar_mode = fs::metadata(sidecar) - .expect("sidecar metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(sidecar_mode, 0o600); - } - } - - #[cfg(unix)] - #[test] - fn database_lock_sidecar_and_recovery_symlinks_fail_closed() { - use std::os::unix::fs::symlink; - - let directory = tempdir().expect("temporary directory"); - let victim = directory.path().join("victim"); - fs::write(&victim, b"unchanged").expect("victim"); - - let database_link = directory.path().join("database-link.sqlite3"); - symlink(&victim, &database_link).expect("database symlink"); - assert!(Database::open(&database_link).is_err()); - assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged"); - - let lock_path = directory.path().join("locked.sqlite3"); - symlink(&victim, lock_path.with_extension("sqlite3.lock")).expect("lock symlink"); - assert!(Database::open(&lock_path).is_err()); - assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged"); - - let sidecar_path = directory.path().join("sidecar.sqlite3"); - let wal = std::path::PathBuf::from(format!("{}-wal", sidecar_path.display())); - symlink(&victim, wal).expect("WAL symlink"); - assert!(Database::open(&sidecar_path).is_err()); - assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged"); - - let legacy_path = directory.path().join("legacy.sqlite3"); - { - let mut connection = Connection::open(&legacy_path).expect("legacy database"); - configure(&connection).expect("configuration"); - migrations::migrations::runner() - .set_target(Target::Version(5)) - .run(&mut connection) - .expect("V5 schema"); - } - symlink( - directory.path().join("not-present"), - directory.path().join("legacy.sqlite3.recovery"), - ) - .expect("recovery symlink"); - assert!(Database::open(&legacy_path).is_err()); - } -} diff --git a/crates/studio_storage/src/installation.rs b/crates/studio_storage/src/installation.rs @@ -1,71 +0,0 @@ -use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; -use rusqlite::OptionalExtension; - -use crate::Database; - -impl Database { - pub fn load_installation_id(&self) -> Result<Option<String>, SafeError> { - self.connection() - .query_row( - "SELECT installation_id FROM installation_identity WHERE singleton = 1", - [], - |row| row.get(0), - ) - .optional() - .map_err(|_| installation_storage_error()) - } - - pub fn initialize_installation_id(&self, candidate: &str) -> Result<String, SafeError> { - let connection = self.connection(); - connection - .execute( - "INSERT INTO installation_identity (singleton, installation_id) VALUES (1, ?1) ON CONFLICT(singleton) DO NOTHING", - [candidate], - ) - .map_err(|_| installation_storage_error())?; - connection - .query_row( - "SELECT installation_id FROM installation_identity WHERE singleton = 1", - [], - |row| row.get(0), - ) - .map_err(|_| installation_storage_error()) - } -} - -const fn installation_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The installation identity is unavailable."), - ) -} - -#[cfg(test)] -mod tests { - use crate::Database; - - #[test] - fn installation_identity_is_insert_once_and_stable() { - let database = Database::in_memory().expect("database"); - assert_eq!(database.load_installation_id().expect("empty"), None); - let first = database - .initialize_installation_id("11aabbccddeeff001122334455667788") - .expect("first identity"); - let second = database - .initialize_installation_id("22aabbccddeeff001122334455667788") - .expect("existing identity"); - assert_eq!(first, "11aabbccddeeff001122334455667788"); - assert_eq!(second, first); - assert_eq!(database.load_installation_id().expect("load"), Some(first)); - } - - #[test] - fn installation_identity_rejects_invalid_values() { - let database = Database::in_memory().expect("database"); - assert!( - database - .initialize_installation_id("not-an-identity") - .is_err() - ); - } -} diff --git a/crates/studio_storage/src/journal.rs b/crates/studio_storage/src/journal.rs @@ -1,774 +0,0 @@ -use radroots_studio_application::{ - AccountOperationKind, AccountOperationPhase, DurableAccountOperation, DurableOperationKind, - DurableOperationPhase, DurableOperationReceipt, DurableOperationRepository, - DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic, - OperationId, OperationJournal, OperationPriorState, PendingAccountOperation, -}; -use radroots_studio_domain::{ - BindingAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, -}; -use rusqlite::{OptionalExtension, Row, params}; - -use crate::Database; - -impl DurableOperationRepository for Database { - fn begin_durable_operation( - &self, - request_id: &DurableRequestId, - kind: DurableOperationKind, - account: PublicKey, - expected_revision: Option<u64>, - prior: OperationPriorState, - updated_at: UnixTimestamp, - ) -> Result<DurableOperationStart, SafeError> { - let encoded_expected_revision = expected_revision - .map(i64::try_from) - .transpose() - .map_err(|_| operation_conflict())?; - let mut connection = self.connection(); - let transaction = connection.transaction().map_err(|_| storage_error())?; - let inserted = transaction - .execute( - "INSERT OR IGNORE INTO durable_operations (request_id, operation_kind, \ - account_public_key, binding_public_key, expected_revision, phase, \ - prior_selected_public_key, updated_at, prior_binding_availability) \ - VALUES (?1, ?2, ?3, ?3, ?4, 'intent_recorded', ?5, ?6, ?7)", - params![ - request_id.as_str(), - encode_durable_kind(kind), - account.to_hex(), - encoded_expected_revision, - prior.selected_account().map(PublicKey::to_hex), - updated_at.as_seconds(), - prior - .binding_availability() - .map(encode_binding_availability), - ], - ) - .map_err(|_| storage_error())?; - let operation = - query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?; - if operation.kind() != kind - || operation.account() != account - || operation.expected_revision() != expected_revision - || operation.prior() != prior - { - return Err(operation_conflict()); - } - transaction.commit().map_err(|_| storage_error())?; - Ok(if inserted == 1 { - DurableOperationStart::Started(operation) - } else { - DurableOperationStart::Existing(operation) - }) - } - - fn load_durable_operation( - &self, - request_id: &DurableRequestId, - ) -> Result<Option<DurableAccountOperation>, SafeError> { - query_durable_operation(&self.connection(), request_id) - } - - fn advance_durable_operation( - &self, - request_id: &DurableRequestId, - expected_phase: DurableOperationPhase, - next_phase: DurableOperationPhase, - updated_at: UnixTimestamp, - diagnostic: Option<OperationDiagnostic>, - ) -> Result<DurableAccountOperation, SafeError> { - let mut connection = self.connection(); - let transaction = connection.transaction().map_err(|_| storage_error())?; - let rows = transaction - .execute( - "UPDATE durable_operations SET phase = ?3, updated_at = ?4, diagnostic_code = ?5 \ - WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL", - params![ - request_id.as_str(), - encode_durable_phase(expected_phase), - encode_durable_phase(next_phase), - updated_at.as_seconds(), - diagnostic.map(encode_diagnostic), - ], - ) - .map_err(|_| storage_error())?; - if rows != 1 { - return Err(operation_conflict()); - } - let operation = - query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?; - transaction.commit().map_err(|_| storage_error())?; - Ok(operation) - } - - fn finalize_durable_operation( - &self, - request_id: &DurableRequestId, - expected_phase: DurableOperationPhase, - outcome: DurableTerminalOutcome, - resulting_revision: Option<u64>, - updated_at: UnixTimestamp, - ) -> Result<DurableOperationReceipt, SafeError> { - if let Some(existing) = self.load_durable_operation(request_id)? - && let Some(receipt) = existing.terminal() - { - return if receipt.outcome() == outcome - && receipt.resulting_revision() == resulting_revision - { - Ok(receipt.clone()) - } else { - Err(operation_conflict()) - }; - } - let resulting_revision = resulting_revision - .map(i64::try_from) - .transpose() - .map_err(|_| operation_conflict())?; - let rows = self - .connection() - .execute( - "UPDATE durable_operations SET phase = 'finalized', terminal_outcome = ?3, \ - resulting_revision = ?4, updated_at = ?5 \ - WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL", - params![ - request_id.as_str(), - encode_durable_phase(expected_phase), - encode_terminal_outcome(outcome), - resulting_revision, - updated_at.as_seconds(), - ], - ) - .map_err(|_| storage_error())?; - if rows != 1 { - return Err(operation_conflict()); - } - self.load_durable_operation(request_id)? - .and_then(|operation| operation.terminal().cloned()) - .ok_or_else(corrupt_storage_error) - } - - fn list_unfinished_durable_operations( - &self, - ) -> Result<Vec<DurableAccountOperation>, SafeError> { - let connection = self.connection(); - let mut statement = connection - .prepare(&format!( - "{DURABLE_OPERATION_SELECT} WHERE terminal_outcome IS NULL ORDER BY request_id ASC" - )) - .map_err(|_| storage_error())?; - let rows = statement - .query_map([], decode_durable_operation) - .map_err(|_| storage_error())?; - rows.map(|row| row.map_err(|_| corrupt_storage_error())) - .collect() - } -} - -const DURABLE_OPERATION_SELECT: &str = "SELECT request_id, operation_kind, account_public_key, \ - expected_revision, phase, prior_selected_public_key, updated_at, diagnostic_code, \ - terminal_outcome, prior_binding_availability, resulting_revision FROM durable_operations"; - -fn query_durable_operation( - connection: &rusqlite::Connection, - request_id: &DurableRequestId, -) -> Result<Option<DurableAccountOperation>, SafeError> { - connection - .query_row( - &format!("{DURABLE_OPERATION_SELECT} WHERE request_id = ?1"), - [request_id.as_str()], - decode_durable_operation, - ) - .optional() - .map_err(|_| corrupt_storage_error()) -} - -fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableAccountOperation> { - let request_id = - DurableRequestId::parse(row.get::<_, String>(0)?).map_err(|_| invalid_column(0))?; - let kind = decode_durable_kind(row.get::<_, String>(1)?.as_str())?; - let account = - PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?; - let expected_revision = row - .get::<_, Option<i64>>(3)? - .map(|value| u64::try_from(value).map_err(|_| invalid_column(3))) - .transpose()?; - let phase = decode_durable_phase(row.get::<_, String>(4)?.as_str())?; - let prior_selected = row - .get::<_, Option<String>>(5)? - .map(|value| PublicKey::from_hex(&value).map_err(|_| invalid_column(5))) - .transpose()?; - let updated_at = UnixTimestamp::from_seconds(row.get(6)?).ok_or_else(|| invalid_column(6))?; - let diagnostic = row - .get::<_, Option<String>>(7)? - .map(|value| decode_diagnostic(&value)) - .transpose()?; - let outcome = row - .get::<_, Option<String>>(8)? - .map(|value| decode_terminal_outcome(&value)) - .transpose()?; - let prior_availability = row - .get::<_, Option<String>>(9)? - .map(|value| decode_binding_availability(&value)) - .transpose()?; - let resulting_revision = row - .get::<_, Option<i64>>(10)? - .map(|value| u64::try_from(value).map_err(|_| invalid_column(10))) - .transpose()?; - let terminal = outcome.map(|outcome| { - DurableOperationReceipt::new(request_id.clone(), account, outcome, resulting_revision) - }); - Ok(DurableAccountOperation::new( - request_id, - kind, - account, - expected_revision, - phase, - OperationPriorState::new(prior_selected, prior_availability), - updated_at, - diagnostic, - terminal, - )) -} - -impl OperationJournal for Database { - fn begin_operation( - &self, - kind: AccountOperationKind, - subject: PublicKey, - updated_at: UnixTimestamp, - ) -> Result<OperationId, SafeError> { - let connection = self.connection(); - connection - .execute( - "INSERT INTO operation_journal (operation_kind, subject_pubkey, phase, \ - updated_at) VALUES (?1, ?2, 'intent_recorded', ?3)", - params![encode_kind(kind), subject.to_hex(), updated_at.as_seconds()], - ) - .map_err(|_| storage_error())?; - let id = - u64::try_from(connection.last_insert_rowid()).map_err(|_| corrupt_storage_error())?; - Ok(OperationId::from_raw(id)) - } - - fn update_operation( - &self, - id: OperationId, - phase: AccountOperationPhase, - updated_at: UnixTimestamp, - diagnostic: Option<OperationDiagnostic>, - ) -> Result<(), SafeError> { - let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?; - match self.connection().execute( - "UPDATE operation_journal SET phase = ?2, updated_at = ?3, diagnostic_code = ?4 \ - WHERE operation_id = ?1", - params![ - encoded_id, - encode_phase(phase), - updated_at.as_seconds(), - diagnostic.map(encode_diagnostic) - ], - ) { - Ok(1) => Ok(()), - Ok(0) => Err(operation_not_found()), - Ok(_) | Err(_) => Err(storage_error()), - } - } - - fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { - let connection = self.connection(); - let mut statement = connection - .prepare( - "SELECT operation_id, operation_kind, subject_pubkey, phase, updated_at, \ - diagnostic_code FROM operation_journal ORDER BY operation_id ASC", - ) - .map_err(|_| storage_error())?; - let rows = statement - .query_map([], decode_operation) - .map_err(|_| storage_error())?; - rows.map(|row| row.map_err(|_| corrupt_storage_error())) - .collect() - } - - fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> { - let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?; - self.connection() - .execute( - "DELETE FROM operation_journal WHERE operation_id = ?1", - [encoded_id], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } -} - -fn decode_operation(row: &Row<'_>) -> rusqlite::Result<PendingAccountOperation> { - let id = u64::try_from(row.get::<_, i64>(0)?).map_err(|_| invalid_column(0))?; - let kind = decode_kind(row.get::<_, String>(1)?.as_str())?; - let subject = - PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?; - let phase = decode_phase(row.get::<_, String>(3)?.as_str())?; - let updated_at = UnixTimestamp::from_seconds(row.get(4)?).ok_or_else(|| invalid_column(4))?; - let diagnostic = row - .get::<_, Option<String>>(5)? - .map(|value| decode_diagnostic(&value)) - .transpose()?; - Ok(PendingAccountOperation::new( - OperationId::from_raw(id), - kind, - subject, - phase, - updated_at, - diagnostic, - )) -} - -const fn encode_durable_kind(value: DurableOperationKind) -> &'static str { - match value { - DurableOperationKind::Create => "create", - DurableOperationKind::Import => "import", - DurableOperationKind::Repair => "repair", - DurableOperationKind::Remove => "remove", - } -} - -fn decode_durable_kind(value: &str) -> rusqlite::Result<DurableOperationKind> { - match value { - "create" => Ok(DurableOperationKind::Create), - "import" => Ok(DurableOperationKind::Import), - "repair" => Ok(DurableOperationKind::Repair), - "remove" => Ok(DurableOperationKind::Remove), - _ => Err(invalid_column(1)), - } -} - -const fn encode_durable_phase(value: DurableOperationPhase) -> &'static str { - match value { - DurableOperationPhase::IntentRecorded => "intent_recorded", - DurableOperationPhase::CredentialWritten => "credential_written", - DurableOperationPhase::MetadataCommitted => "metadata_committed", - DurableOperationPhase::SelectionCommitted => "selection_committed", - DurableOperationPhase::CompensationPending => "compensation_pending", - DurableOperationPhase::CredentialDeleted => "credential_deleted", - DurableOperationPhase::MetadataDeleted => "metadata_deleted", - DurableOperationPhase::Finalized => "finalized", - } -} - -fn decode_durable_phase(value: &str) -> rusqlite::Result<DurableOperationPhase> { - match value { - "intent_recorded" => Ok(DurableOperationPhase::IntentRecorded), - "credential_written" => Ok(DurableOperationPhase::CredentialWritten), - "metadata_committed" => Ok(DurableOperationPhase::MetadataCommitted), - "selection_committed" => Ok(DurableOperationPhase::SelectionCommitted), - "compensation_pending" => Ok(DurableOperationPhase::CompensationPending), - "credential_deleted" => Ok(DurableOperationPhase::CredentialDeleted), - "metadata_deleted" => Ok(DurableOperationPhase::MetadataDeleted), - "finalized" => Ok(DurableOperationPhase::Finalized), - _ => Err(invalid_column(4)), - } -} - -const fn encode_terminal_outcome(value: DurableTerminalOutcome) -> &'static str { - match value { - DurableTerminalOutcome::Completed => "completed", - DurableTerminalOutcome::Cancelled => "cancelled", - DurableTerminalOutcome::Failed => "failed", - } -} - -fn decode_terminal_outcome(value: &str) -> rusqlite::Result<DurableTerminalOutcome> { - match value { - "completed" => Ok(DurableTerminalOutcome::Completed), - "cancelled" => Ok(DurableTerminalOutcome::Cancelled), - "failed" => Ok(DurableTerminalOutcome::Failed), - _ => Err(invalid_column(8)), - } -} - -const fn encode_binding_availability(value: BindingAvailability) -> &'static str { - match value { - BindingAvailability::Available => "available", - BindingAvailability::CredentialMissing => "credential_missing", - BindingAvailability::StoreUnavailable => "store_unavailable", - } -} - -fn decode_binding_availability(value: &str) -> rusqlite::Result<BindingAvailability> { - match value { - "available" => Ok(BindingAvailability::Available), - "credential_missing" => Ok(BindingAvailability::CredentialMissing), - "store_unavailable" => Ok(BindingAvailability::StoreUnavailable), - _ => Err(invalid_column(9)), - } -} - -const fn encode_kind(value: AccountOperationKind) -> &'static str { - match value { - AccountOperationKind::Add => "add", - AccountOperationKind::Import => "import", - AccountOperationKind::Remove => "remove", - } -} - -fn decode_kind(value: &str) -> rusqlite::Result<AccountOperationKind> { - match value { - "add" => Ok(AccountOperationKind::Add), - "import" => Ok(AccountOperationKind::Import), - "remove" => Ok(AccountOperationKind::Remove), - _ => Err(invalid_column(1)), - } -} - -const fn encode_phase(value: AccountOperationPhase) -> &'static str { - match value { - AccountOperationPhase::IntentRecorded => "intent_recorded", - AccountOperationPhase::CredentialWritten => "credential_written", - AccountOperationPhase::MetadataCommitted => "metadata_committed", - AccountOperationPhase::CompensationPending => "compensation_pending", - AccountOperationPhase::CredentialDeleted => "credential_deleted", - AccountOperationPhase::MetadataDeleted => "metadata_deleted", - } -} - -fn decode_phase(value: &str) -> rusqlite::Result<AccountOperationPhase> { - match value { - "intent_recorded" => Ok(AccountOperationPhase::IntentRecorded), - "credential_written" => Ok(AccountOperationPhase::CredentialWritten), - "metadata_committed" => Ok(AccountOperationPhase::MetadataCommitted), - "compensation_pending" => Ok(AccountOperationPhase::CompensationPending), - "credential_deleted" => Ok(AccountOperationPhase::CredentialDeleted), - "metadata_deleted" => Ok(AccountOperationPhase::MetadataDeleted), - _ => Err(invalid_column(3)), - } -} - -const fn encode_diagnostic(value: OperationDiagnostic) -> &'static str { - match value { - OperationDiagnostic::StorageUnavailable => "storage_unavailable", - OperationDiagnostic::KeyringUnavailable => "keyring_unavailable", - OperationDiagnostic::CredentialMissing => "credential_missing", - OperationDiagnostic::CompensationFailed => "compensation_failed", - OperationDiagnostic::Conflict => "conflict", - OperationDiagnostic::Expired => "expired", - } -} - -fn decode_diagnostic(value: &str) -> rusqlite::Result<OperationDiagnostic> { - match value { - "storage_unavailable" => Ok(OperationDiagnostic::StorageUnavailable), - "keyring_unavailable" => Ok(OperationDiagnostic::KeyringUnavailable), - "credential_missing" => Ok(OperationDiagnostic::CredentialMissing), - "compensation_failed" => Ok(OperationDiagnostic::CompensationFailed), - "conflict" => Ok(OperationDiagnostic::Conflict), - "expired" => Ok(OperationDiagnostic::Expired), - _ => Err(invalid_column(5)), - } -} - -fn invalid_column(index: usize) -> rusqlite::Error { - rusqlite::Error::InvalidColumnType( - index, - "account operation journal".to_owned(), - rusqlite::types::Type::Text, - ) -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The account recovery journal is unavailable."), - ) -} - -const fn corrupt_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageCorrupt, - SafeMessage::new("The account recovery journal could not be read."), - ) -} - -const fn operation_not_found() -> SafeError { - SafeError::new( - SafeErrorCode::PendingOperationRecoveryRequired, - SafeMessage::new("The account recovery operation was not found."), - ) -} - -const fn operation_conflict() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The durable account operation conflicts with existing state."), - ) -} - -#[cfg(test)] -mod tests { - use radroots_studio_application::{ - AccountOperationKind, AccountOperationPhase, DurableOperationKind, DurableOperationPhase, - DurableOperationRepository, DurableOperationStart, DurableRequestId, - DurableTerminalOutcome, OperationDiagnostic, OperationJournal, OperationPriorState, - }; - use radroots_studio_domain::{BindingAvailability, PublicKey, UnixTimestamp}; - - use crate::Database; - - fn public_key(discriminator: u8) -> PublicKey { - let value = match discriminator { - 7 => "0707070707070707070707070707070707070707070707070707070707070707", - 8 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", - _ => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", - }; - PublicKey::from_hex(value).expect("valid public key") - } - - #[test] - fn journal_creates_advances_loads_and_finalizes_pending_operations() { - let database = Database::in_memory().expect("database"); - let subject = public_key(7); - let id = database - .begin_operation( - AccountOperationKind::Import, - subject, - UnixTimestamp::from_seconds(10).expect("time"), - ) - .expect("begin"); - database - .update_operation( - id, - AccountOperationPhase::CompensationPending, - UnixTimestamp::from_seconds(11).expect("time"), - Some(OperationDiagnostic::KeyringUnavailable), - ) - .expect("advance"); - - let pending = database.list_pending_operations().expect("pending"); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].subject(), subject); - assert_eq!(pending[0].kind(), AccountOperationKind::Import); - assert_eq!( - pending[0].phase(), - AccountOperationPhase::CompensationPending - ); - assert_eq!( - pending[0].diagnostic(), - Some(OperationDiagnostic::KeyringUnavailable) - ); - - database.finalize_operation(id).expect("finalize"); - assert!( - database - .list_pending_operations() - .expect("pending") - .is_empty() - ); - } - - #[test] - fn journal_schema_and_rows_exclude_secret_payload_columns() { - let database = Database::in_memory().expect("database"); - database - .begin_operation( - AccountOperationKind::Remove, - public_key(8), - UnixTimestamp::from_seconds(12).expect("time"), - ) - .expect("begin"); - let connection = database.connection(); - let schema: String = connection - .query_row( - "SELECT sql FROM sqlite_master WHERE name = 'operation_journal'", - [], - |row| row.get(0), - ) - .expect("schema"); - assert!(!schema.contains("secret")); - assert!(!schema.contains("payload")); - } - - #[test] - fn durable_repository_replays_matching_requests_and_retains_terminal_receipts() { - let database = Database::in_memory().expect("database"); - let request = DurableRequestId::parse("import:test:1").expect("request"); - let account = public_key(9); - let prior = OperationPriorState::new( - Some(public_key(8)), - Some(BindingAvailability::CredentialMissing), - ); - let started = database - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - account, - Some(4), - prior, - UnixTimestamp::from_seconds(10).expect("time"), - ) - .expect("begin"); - assert!(matches!(started, DurableOperationStart::Started(_))); - let replay = database - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - account, - Some(4), - prior, - UnixTimestamp::from_seconds(11).expect("time"), - ) - .expect("replay"); - assert!(matches!(replay, DurableOperationStart::Existing(_))); - assert!( - database - .begin_durable_operation( - &request, - DurableOperationKind::Remove, - account, - Some(4), - prior, - UnixTimestamp::from_seconds(11).expect("time"), - ) - .is_err() - ); - let missing_request = DurableRequestId::parse("import:test:missing").expect("request"); - assert!( - database - .finalize_durable_operation( - &missing_request, - DurableOperationPhase::IntentRecorded, - DurableTerminalOutcome::Completed, - None, - UnixTimestamp::from_seconds(17).expect("time"), - ) - .is_err() - ); - assert!( - database - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - public_key(8), - Some(4), - prior, - UnixTimestamp::from_seconds(11).expect("time"), - ) - .is_err() - ); - assert!( - database - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - account, - Some(5), - prior, - UnixTimestamp::from_seconds(11).expect("time"), - ) - .is_err() - ); - assert!( - database - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - account, - Some(4), - OperationPriorState::new(None, None), - UnixTimestamp::from_seconds(11).expect("time"), - ) - .is_err() - ); - assert!( - database - .advance_durable_operation( - &request, - DurableOperationPhase::CredentialDeleted, - DurableOperationPhase::Finalized, - UnixTimestamp::from_seconds(11).expect("time"), - None, - ) - .is_err() - ); - database - .advance_durable_operation( - &request, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialWritten, - UnixTimestamp::from_seconds(12).expect("time"), - None, - ) - .expect("advance"); - let receipt = database - .finalize_durable_operation( - &request, - DurableOperationPhase::CredentialWritten, - DurableTerminalOutcome::Completed, - Some(5), - UnixTimestamp::from_seconds(13).expect("time"), - ) - .expect("finalize"); - assert_eq!(receipt.resulting_revision(), Some(5)); - assert_eq!( - database - .finalize_durable_operation( - &request, - DurableOperationPhase::CredentialWritten, - DurableTerminalOutcome::Completed, - Some(5), - UnixTimestamp::from_seconds(14).expect("time"), - ) - .expect("receipt replay"), - receipt - ); - assert!( - database - .finalize_durable_operation( - &request, - DurableOperationPhase::CredentialWritten, - DurableTerminalOutcome::Cancelled, - Some(5), - UnixTimestamp::from_seconds(14).expect("time"), - ) - .is_err() - ); - assert!( - database - .finalize_durable_operation( - &request, - DurableOperationPhase::CredentialWritten, - DurableTerminalOutcome::Completed, - Some(6), - UnixTimestamp::from_seconds(14).expect("time"), - ) - .is_err() - ); - let overflow_request = DurableRequestId::parse("import:test:overflow").expect("request"); - database - .begin_durable_operation( - &overflow_request, - DurableOperationKind::Import, - account, - None, - OperationPriorState::new(None, None), - UnixTimestamp::from_seconds(15).expect("time"), - ) - .expect("begin overflow operation"); - assert!( - database - .finalize_durable_operation( - &overflow_request, - DurableOperationPhase::IntentRecorded, - DurableTerminalOutcome::Completed, - Some(u64::MAX), - UnixTimestamp::from_seconds(16).expect("time"), - ) - .is_err() - ); - assert!( - database - .list_unfinished_durable_operations() - .expect("unfinished") - .iter() - .any(|operation| operation.request_id() == &overflow_request) - ); - } -} diff --git a/crates/studio_storage/src/lib.rs b/crates/studio_storage/src/lib.rs @@ -1,20 +0,0 @@ -#![doc = "Radroots Studio persistence adapters."] -#![cfg_attr(coverage_nightly, feature(coverage_attribute))] - -pub mod account_namespace; -pub mod accounts; -mod compatibility; -pub mod db; -mod installation; -pub mod journal; -// The operating-system credential store requires an explicit, ignored host smoke test. -#[cfg_attr(coverage_nightly, coverage(off))] -pub mod os_keyring; -pub mod profiles; -mod recovery; -mod repair; - -pub use compatibility::{DatabasePreflight, PersistedIdentityIssue, PersistedIdentityIssueKind}; -pub use db::{CURRENT_SCHEMA_VERSION, Database}; -pub use os_keyring::{CREDENTIAL_SERVICE, OsKeyringSecretStore}; -pub use repair::{QuarantineExportReceipt, RepairAuthorization, RepairCandidate}; diff --git a/crates/studio_storage/src/os_keyring.rs b/crates/studio_storage/src/os_keyring.rs @@ -1,138 +0,0 @@ -use std::sync::{Mutex, MutexGuard}; - -use keyring::{Entry, Error as KeyringError}; -use radroots_studio_application::SecretStore; -use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput}; -use zeroize::Zeroizing; - -pub const CREDENTIAL_SERVICE: &str = "org.radroots.studio.nostr"; - -#[derive(Default)] -pub struct OsKeyringSecretStore { - operation_lock: Mutex<()>, -} - -impl OsKeyringSecretStore { - fn entry(public_key: PublicKey) -> Result<Entry, SafeError> { - Entry::new(CREDENTIAL_SERVICE, &public_key.to_hex()).map_err(|_| keyring_unavailable()) - } - - fn operation(&self) -> MutexGuard<'_, ()> { - self.operation_lock - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } -} - -impl SecretStore for OsKeyringSecretStore { - fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> { - let _operation = self.operation(); - let entry = Self::entry(public_key)?; - match entry.get_password() { - Ok(password) => { - drop(Zeroizing::new(password)); - return Err(credential_exists()); - } - Err(KeyringError::NoEntry) => {} - Err(_) => return Err(keyring_unavailable()), - } - secret - .with_exposed_secret(|value| entry.set_password(value)) - .map_err(|_| keyring_unavailable()) - } - - fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> { - let _operation = self.operation(); - let password = Self::entry(public_key)? - .get_password() - .map_err(|error| map_read_error(&error))?; - SecretKeyInput::parse(password) - } - - fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> { - let _operation = self.operation(); - match Self::entry(public_key)?.get_password() { - Ok(password) => { - drop(Zeroizing::new(password)); - Ok(true) - } - Err(KeyringError::NoEntry) => Ok(false), - Err(_) => Err(keyring_unavailable()), - } - } - - fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> { - let _operation = self.operation(); - Self::entry(public_key)? - .delete_credential() - .map_err(|error| map_read_error(&error)) - } -} - -const fn map_read_error(error: &KeyringError) -> SafeError { - match error { - KeyringError::NoEntry => credential_missing(), - _ => keyring_unavailable(), - } -} - -const fn credential_exists() -> SafeError { - SafeError::new( - SafeErrorCode::AccountAlreadyExists, - SafeMessage::new("The Nostr account credential already exists."), - ) -} - -const fn credential_missing() -> SafeError { - SafeError::new( - SafeErrorCode::CredentialMissing, - SafeMessage::new("The Nostr account credential is missing."), - ) -} - -const fn keyring_unavailable() -> SafeError { - SafeError::new( - SafeErrorCode::KeyringUnavailable, - SafeMessage::new("The operating system credential store is unavailable."), - ) -} - -#[cfg(test)] -mod tests { - use radroots_studio_application::SecretStore; - use radroots_studio_domain::{PublicKey, SecretKeyInput}; - - use super::{CREDENTIAL_SERVICE, OsKeyringSecretStore}; - - #[test] - fn keyring_coordinates_are_stable_and_public() { - let public_key = - PublicKey::from_hex("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7") - .expect("valid public key"); - assert_eq!(CREDENTIAL_SERVICE, "org.radroots.studio.nostr"); - assert_eq!( - public_key.to_hex(), - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" - ); - } - - #[test] - #[ignore = "mutates the current user's operating-system credential store"] - fn real_keyring_smoke_round_trips_and_deletes() { - let store = OsKeyringSecretStore::default(); - let public_key = - PublicKey::from_hex("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7") - .expect("valid public key"); - let _ = store.delete(public_key); - store - .put( - public_key, - SecretKeyInput::parse("11".repeat(32)).expect("secret"), - ) - .expect("keyring put"); - assert!(store.contains(public_key).expect("keyring contains")); - let loaded = store.load(public_key).expect("keyring load"); - assert_eq!(loaded.with_exposed_secret(str::len), 64); - store.delete(public_key).expect("keyring delete"); - } -} diff --git a/crates/studio_storage/src/profiles.rs b/crates/studio_storage/src/profiles.rs @@ -1,254 +0,0 @@ -use radroots_studio_application::{CachedProfile, ProfileRefreshStatus, ProfileRepository}; -use radroots_studio_domain::{ - EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode, - SafeMessage, UnixTimestamp, -}; -use rusqlite::{OptionalExtension, Row, params}; - -use crate::Database; - -impl ProfileRepository for Database { - fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { - self.connection() - .query_row( - "SELECT event_id, event_created_at, name, display_name, nip05, about, picture, \ - refreshed_at, refresh_status FROM profile_cache_v6 WHERE subject_public_key = ?1", - [public_key.to_hex()], - |row| decode_profile(row, public_key), - ) - .optional() - .map_err(|_| corrupt_storage_error()) - } - - fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> { - let candidate = profile.candidate(); - let metadata = candidate.metadata(); - self.connection() - .execute( - "INSERT INTO profile_cache_v6 (subject_public_key, event_id, event_created_at, name, \ - display_name, nip05, about, picture, refreshed_at, refresh_status) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \ - ON CONFLICT(subject_public_key) DO UPDATE SET \ - event_id = excluded.event_id, event_created_at = excluded.event_created_at, \ - name = excluded.name, display_name = excluded.display_name, nip05 = excluded.nip05, \ - about = excluded.about, picture = excluded.picture, \ - refreshed_at = excluded.refreshed_at, refresh_status = excluded.refresh_status \ - WHERE excluded.event_created_at > profile_cache_v6.event_created_at \ - OR (excluded.event_created_at = profile_cache_v6.event_created_at \ - AND excluded.event_id < profile_cache_v6.event_id)", - params![ - candidate.author().to_hex(), - candidate.event_id().to_hex(), - candidate.created_at().as_seconds(), - metadata.name(), - metadata.display_name(), - metadata.nip05(), - metadata.about(), - metadata.picture(), - profile.refreshed_at().as_seconds(), - encode_refresh_status(profile.refresh_status()), - ], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } - - fn record_refresh_status( - &self, - public_key: PublicKey, - refreshed_at: UnixTimestamp, - status: ProfileRefreshStatus, - ) -> Result<(), SafeError> { - self.connection() - .execute( - "UPDATE profile_cache_v6 SET refreshed_at = ?2, refresh_status = ?3 \ - WHERE subject_public_key = ?1", - params![ - public_key.to_hex(), - refreshed_at.as_seconds(), - encode_refresh_status(status) - ], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } - - fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError> { - self.connection() - .execute( - "DELETE FROM profile_cache_v6 WHERE subject_public_key = ?1", - [public_key.to_hex()], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } -} - -fn decode_profile(row: &Row<'_>, author: PublicKey) -> rusqlite::Result<CachedProfile> { - let event_id = - EventId::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?; - let created_at = UnixTimestamp::from_seconds(row.get(1)?).ok_or_else(|| invalid_column(1))?; - let metadata = ProfileMetadata::new( - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, - row.get(6)?, - ) - .map_err(|_| invalid_column(2))?; - let refreshed_at = UnixTimestamp::from_seconds(row.get(7)?).ok_or_else(|| invalid_column(7))?; - let refresh_status = decode_refresh_status(row.get::<_, String>(8)?.as_str())?; - Ok(CachedProfile::new( - Kind0ProfileCandidate::new(event_id, author, created_at, metadata), - refreshed_at, - refresh_status, - )) -} - -const fn encode_refresh_status(status: ProfileRefreshStatus) -> &'static str { - match status { - ProfileRefreshStatus::Success => "success", - ProfileRefreshStatus::Offline => "offline", - ProfileRefreshStatus::InvalidData => "invalid_data", - } -} - -fn decode_refresh_status(value: &str) -> rusqlite::Result<ProfileRefreshStatus> { - match value { - "success" => Ok(ProfileRefreshStatus::Success), - "offline" => Ok(ProfileRefreshStatus::Offline), - "invalid_data" => Ok(ProfileRefreshStatus::InvalidData), - _ => Err(invalid_column(8)), - } -} - -fn invalid_column(index: usize) -> rusqlite::Error { - rusqlite::Error::InvalidColumnType( - index, - "cached Nostr profile".to_owned(), - rusqlite::types::Type::Text, - ) -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The profile cache is unavailable."), - ) -} - -const fn corrupt_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageCorrupt, - SafeMessage::new("The profile cache could not be read."), - ) -} - -#[cfg(test)] -mod tests { - use radroots_studio_application::{ - AccountRepository, CachedProfile, ProfileRefreshStatus, ProfileRepository, - }; - use radroots_studio_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, EventId, - Kind0ProfileCandidate, LocalSignerBinding, ProfileMetadata, PublicKey, UnixTimestamp, - }; - - use crate::Database; - - fn public_key() -> PublicKey { - PublicKey::from_bytes([7; 32]).expect("valid public key") - } - - fn account(public_key: PublicKey) -> AccountSummary { - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), - None, - ) - .expect("account") - } - - fn profile(public_key: PublicKey, id: u8, created_at: i64, name: &str) -> CachedProfile { - CachedProfile::new( - Kind0ProfileCandidate::new( - EventId::from_bytes([id; 32]), - public_key, - UnixTimestamp::from_seconds(created_at).expect("time"), - ProfileMetadata::new(Some(name.to_owned()), None, None, None, None) - .expect("metadata"), - ), - UnixTimestamp::from_seconds(created_at + 1).expect("refresh time"), - ProfileRefreshStatus::Success, - ) - } - - #[test] - fn profile_cache_round_trips_and_records_refresh_status() { - let database = Database::in_memory().expect("database"); - let public_key = public_key(); - database - .insert_account(&account(public_key)) - .expect("account"); - database - .save_profile(&profile(public_key, 1, 10, "Farm")) - .expect("save profile"); - database - .record_refresh_status( - public_key, - UnixTimestamp::from_seconds(20).expect("time"), - ProfileRefreshStatus::Offline, - ) - .expect("record status"); - - let loaded = database - .load_profile(public_key) - .expect("load profile") - .expect("cached profile"); - assert_eq!(loaded.candidate().metadata().name(), Some("Farm")); - assert_eq!(loaded.refreshed_at().as_seconds(), 20); - assert_eq!(loaded.refresh_status(), ProfileRefreshStatus::Offline); - } - - #[test] - fn profile_cache_keeps_newest_then_lowest_event_id() { - let database = Database::in_memory().expect("database"); - let public_key = public_key(); - database - .insert_account(&account(public_key)) - .expect("account"); - database - .save_profile(&profile(public_key, 9, 20, "High ID")) - .expect("initial"); - database - .save_profile(&profile(public_key, 1, 20, "Low ID")) - .expect("equal newer candidate"); - database - .save_profile(&profile(public_key, 0, 10, "Older")) - .expect("older candidate"); - - let loaded = database - .load_profile(public_key) - .expect("load") - .expect("profile"); - assert_eq!(loaded.candidate().metadata().name(), Some("Low ID")); - assert_eq!(loaded.candidate().event_id(), EventId::from_bytes([1; 32])); - } - - #[test] - fn profile_cache_cascades_with_account_removal() { - let database = Database::in_memory().expect("database"); - let public_key = public_key(); - database - .insert_account(&account(public_key)) - .expect("account"); - database - .save_profile(&profile(public_key, 1, 10, "Farm")) - .expect("profile"); - database.remove_account(public_key).expect("remove account"); - - assert_eq!(database.load_profile(public_key).expect("load"), None); - } -} diff --git a/crates/studio_storage/src/recovery.rs b/crates/studio_storage/src/recovery.rs @@ -1,692 +0,0 @@ -use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; - -use hmac::{Hmac, Mac}; -use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; -use rusqlite::{Connection, MAIN_DB, OpenFlags}; -use sha2::{Digest, Sha256}; -use zeroize::Zeroizing; - -use crate::db::{restrict_directory_permissions, restrict_file_permissions}; - -type HmacSha256 = Hmac<Sha256>; - -const RECOVERY_DIRECTORY_SUFFIX: &str = "recovery"; -const AUTHENTICATION_KEY_FILENAME: &str = "authentication-key-v1"; -const MANIFEST_FORMAT: &str = "radroots-studio-migration-recovery-v1"; - -pub(crate) struct MigrationRecovery { - directory: PathBuf, - backup: PathBuf, - marker: PathBuf, - source_schema: u32, - target_schema: u32, - digest: String, - tag: String, - state: String, -} - -impl MigrationRecovery { - pub(crate) fn prepare( - database_path: &Path, - source_schema: u32, - target_schema: u32, - ) -> Result<Self, SafeError> { - let directory = recovery_directory(database_path)?; - create_recovery_directory(&directory)?; - let key = load_or_create_authentication_key(&directory)?; - let stem = format!("migration-v{source_schema}-to-v{target_schema}"); - let backup = directory.join(format!("{stem}.sqlite3")); - let marker = directory.join(format!("{stem}.marker")); - - if marker.try_exists().map_err(|_| storage_error())? { - let mut recovery = Self::load_existing( - directory, - backup, - marker, - source_schema, - target_schema, - &key, - )?; - if recovery.state == "complete" { - recovery.tag = authentication_tag( - &key, - source_schema, - target_schema, - &recovery.digest, - "prepared", - )?; - recovery.state = "prepared".to_owned(); - recovery.write_marker("prepared", &key)?; - } - return Ok(recovery); - } - if backup.try_exists().map_err(|_| storage_error())? { - return Err(backup_invalid()); - } - - create_verified_backup(database_path, &backup)?; - let digest = file_digest(&backup)?; - let tag = authentication_tag(&key, source_schema, target_schema, &digest, "prepared")?; - let recovery = Self { - directory, - backup, - marker, - source_schema, - target_schema, - digest, - tag, - state: "prepared".to_owned(), - }; - recovery.write_marker("prepared", &key)?; - recovery.verify_backup(&key, "prepared")?; - Ok(recovery) - } - - pub(crate) fn finish(self, current_schema: u32) -> Result<(), SafeError> { - if current_schema != self.target_schema { - return Err(backup_invalid()); - } - let key = load_authentication_key(&self.directory)?; - self.verify_backup(&key, "prepared")?; - self.write_marker("complete", &key) - } - - pub(crate) fn verify_evidence( - database_path: &Path, - source_schema: u32, - target_schema: u32, - ) -> Result<(), SafeError> { - let directory = recovery_directory(database_path)?; - let key = load_authentication_key(&directory)?; - let stem = format!("migration-v{source_schema}-to-v{target_schema}"); - Self::load_existing( - directory.clone(), - directory.join(format!("{stem}.sqlite3")), - directory.join(format!("{stem}.marker")), - source_schema, - target_schema, - &key, - ) - .map(|_| ()) - } - - pub(crate) fn restore( - database_path: &Path, - source_schema: u32, - target_schema: u32, - ) -> Result<(), SafeError> { - let directory = recovery_directory(database_path)?; - let key = load_authentication_key(&directory)?; - let stem = format!("migration-v{source_schema}-to-v{target_schema}"); - let recovery = Self::load_existing( - directory.clone(), - directory.join(format!("{stem}.sqlite3")), - directory.join(format!("{stem}.marker")), - source_schema, - target_schema, - &key, - )?; - recovery.verify_backup(&key, &recovery.state)?; - replace_with_backup(database_path, &recovery.backup) - } - - fn load_existing( - directory: PathBuf, - backup: PathBuf, - marker: PathBuf, - source_schema: u32, - target_schema: u32, - key: &[u8], - ) -> Result<Self, SafeError> { - let manifest = read_bounded_file(&marker, 4_096)?; - let manifest = std::str::from_utf8(&manifest).map_err(|_| backup_invalid())?; - let mut lines = manifest.lines(); - if lines.next() != Some(MANIFEST_FORMAT) - || parse_field(&mut lines, "source_schema")? != source_schema.to_string() - || parse_field(&mut lines, "target_schema")? != target_schema.to_string() - || parse_field(&mut lines, "backup")? - != backup - .file_name() - .ok_or_else(backup_invalid)? - .to_string_lossy() - || lines.clone().count() != 3 - { - return Err(backup_invalid()); - } - let digest = parse_field(&mut lines, "sha256")?; - let state = parse_field(&mut lines, "state")?; - let tag = parse_field(&mut lines, "hmac_sha256")?; - if !matches!(state.as_str(), "prepared" | "complete") { - return Err(backup_invalid()); - } - let recovery = Self { - directory, - backup, - marker, - source_schema, - target_schema, - digest, - tag, - state, - }; - recovery.verify_backup(key, &recovery.state)?; - Ok(recovery) - } - - fn verify_backup(&self, key: &[u8], state: &str) -> Result<(), SafeError> { - if file_digest(&self.backup)? != self.digest { - return Err(backup_invalid()); - } - let expected = authentication_tag( - key, - self.source_schema, - self.target_schema, - &self.digest, - state, - )?; - let expected = decode_hex_32(&expected)?; - let actual = decode_hex_32(&self.tag)?; - if !constant_time_eq(&expected, &actual) { - return Err(backup_invalid()); - } - let flags = OpenFlags::SQLITE_OPEN_READ_ONLY - | OpenFlags::SQLITE_OPEN_NO_MUTEX - | OpenFlags::SQLITE_OPEN_NOFOLLOW; - let connection = - Connection::open_with_flags(&self.backup, flags).map_err(|_| backup_invalid())?; - let integrity: String = connection - .pragma_query_value(None, "quick_check", |row| row.get(0)) - .map_err(|_| backup_invalid())?; - if integrity != "ok" { - return Err(backup_invalid()); - } - Ok(()) - } - - fn write_marker(&self, state: &str, key: &[u8]) -> Result<(), SafeError> { - let tag = authentication_tag( - key, - self.source_schema, - self.target_schema, - &self.digest, - state, - )?; - let content = format!( - "{MANIFEST_FORMAT}\nsource_schema={}\ntarget_schema={}\nbackup={}\nsha256={}\nstate={state}\nhmac_sha256={tag}\n", - self.source_schema, - self.target_schema, - self.backup - .file_name() - .ok_or_else(backup_invalid)? - .to_string_lossy(), - self.digest, - ); - atomic_secure_write(&self.marker, content.as_bytes()) - } -} - -fn recovery_directory(database_path: &Path) -> Result<PathBuf, SafeError> { - let filename = database_path - .file_name() - .ok_or_else(storage_error)? - .to_string_lossy(); - Ok(database_path.with_file_name(format!("{filename}.{RECOVERY_DIRECTORY_SUFFIX}"))) -} - -fn create_recovery_directory(directory: &Path) -> Result<(), SafeError> { - match fs::symlink_metadata(directory) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { - return Err(storage_error()); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - fs::create_dir(directory).map_err(|_| storage_error())?; - } - Err(_) => return Err(storage_error()), - } - restrict_directory_permissions(directory) -} - -fn load_or_create_authentication_key(directory: &Path) -> Result<Zeroizing<Vec<u8>>, SafeError> { - let path = directory.join(AUTHENTICATION_KEY_FILENAME); - if path.try_exists().map_err(|_| storage_error())? { - return load_authentication_key(directory); - } - let mut key = Zeroizing::new(vec![0_u8; 32]); - getrandom::getrandom(&mut key).map_err(|_| storage_error())?; - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600).custom_flags( - i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) - .map_err(|_| storage_error())?, - ); - } - match options.open(&path) { - Ok(mut file) => { - file.write_all(&key).map_err(|_| storage_error())?; - file.sync_all().map_err(|_| storage_error())?; - restrict_file_permissions(&path)?; - Ok(key) - } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - load_authentication_key(directory) - } - Err(_) => Err(storage_error()), - } -} - -fn load_authentication_key(directory: &Path) -> Result<Zeroizing<Vec<u8>>, SafeError> { - let path = directory.join(AUTHENTICATION_KEY_FILENAME); - let key = read_bounded_file(&path, 32)?; - if key.len() != 32 { - return Err(backup_invalid()); - } - Ok(Zeroizing::new(key)) -} - -fn create_verified_backup(source: &Path, destination: &Path) -> Result<(), SafeError> { - let flags = OpenFlags::SQLITE_OPEN_READ_ONLY - | OpenFlags::SQLITE_OPEN_NO_MUTEX - | OpenFlags::SQLITE_OPEN_NOFOLLOW; - let connection = Connection::open_with_flags(source, flags).map_err(|_| backup_invalid())?; - connection - .backup(MAIN_DB, destination, None) - .map_err(|_| backup_invalid())?; - restrict_file_permissions(destination)?; - File::open(destination) - .and_then(|file| file.sync_all()) - .map_err(|_| backup_invalid()) -} - -fn replace_with_backup(database_path: &Path, backup: &Path) -> Result<(), SafeError> { - let parent = database_path.parent().ok_or_else(storage_error)?; - let mut suffix = [0_u8; 8]; - getrandom::getrandom(&mut suffix).map_err(|_| storage_error())?; - let replacement = parent.join(format!(".database-restore-{}.tmp", hex(&suffix))); - let displaced = parent.join(format!(".database-displaced-{}.sqlite3", hex(&suffix))); - let mut source = secure_read(backup)?; - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600).custom_flags( - i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) - .map_err(|_| storage_error())?, - ); - } - let result = (|| { - let mut destination = options.open(&replacement).map_err(|_| storage_error())?; - std::io::copy(&mut source, &mut destination).map_err(|_| storage_error())?; - destination.sync_all().map_err(|_| storage_error())?; - restrict_file_permissions(&replacement)?; - fs::rename(database_path, &displaced).map_err(|_| storage_error())?; - if fs::rename(&replacement, database_path).is_err() { - let _ = fs::rename(&displaced, database_path); - return Err(storage_error()); - } - File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|_| storage_error()) - })(); - if result.is_err() { - let _ = fs::remove_file(&replacement); - } - result -} - -fn secure_read(path: &Path) -> Result<File, SafeError> { - let metadata = fs::symlink_metadata(path).map_err(|_| backup_invalid())?; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(backup_invalid()); - } - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags( - i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) - .map_err(|_| backup_invalid())?, - ); - } - options.open(path).map_err(|_| backup_invalid()) -} - -fn file_digest(path: &Path) -> Result<String, SafeError> { - let metadata = fs::symlink_metadata(path).map_err(|_| backup_invalid())?; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(backup_invalid()); - } - let mut file = secure_read(path)?; - let mut digest = Sha256::new(); - let mut buffer = [0_u8; 64 * 1024]; - loop { - let read = file.read(&mut buffer).map_err(|_| backup_invalid())?; - if read == 0 { - break; - } - digest.update(&buffer[..read]); - } - Ok(hex(&digest.finalize())) -} - -fn read_bounded_file(path: &Path, limit: usize) -> Result<Vec<u8>, SafeError> { - let metadata = fs::symlink_metadata(path).map_err(|_| backup_invalid())?; - if metadata.file_type().is_symlink() - || !metadata.is_file() - || usize::try_from(metadata.len()).map_err(|_| backup_invalid())? > limit - { - return Err(backup_invalid()); - } - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags( - i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) - .map_err(|_| backup_invalid())?, - ); - } - let file = options.open(path).map_err(|_| backup_invalid())?; - let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(0)); - file.take(u64::try_from(limit).map_err(|_| backup_invalid())? + 1) - .read_to_end(&mut bytes) - .map_err(|_| backup_invalid())?; - if bytes.len() > limit { - return Err(backup_invalid()); - } - Ok(bytes) -} - -fn atomic_secure_write(path: &Path, bytes: &[u8]) -> Result<(), SafeError> { - let parent = path.parent().ok_or_else(storage_error)?; - let mut suffix = [0_u8; 8]; - getrandom::getrandom(&mut suffix).map_err(|_| storage_error())?; - let temporary = parent.join(format!(".marker-{}.tmp", hex(&suffix))); - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600).custom_flags( - i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) - .map_err(|_| storage_error())?, - ); - } - let result = (|| { - let mut file = options.open(&temporary).map_err(|_| storage_error())?; - file.write_all(bytes).map_err(|_| storage_error())?; - file.sync_all().map_err(|_| storage_error())?; - restrict_file_permissions(&temporary)?; - fs::rename(&temporary, path).map_err(|_| storage_error())?; - File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|_| storage_error()) - })(); - if result.is_err() { - let _ = fs::remove_file(&temporary); - } - result -} - -fn authentication_tag( - key: &[u8], - source_schema: u32, - target_schema: u32, - digest: &str, - state: &str, -) -> Result<String, SafeError> { - let mut mac = HmacSha256::new_from_slice(key).map_err(|_| backup_invalid())?; - mac.update(MANIFEST_FORMAT.as_bytes()); - mac.update(&source_schema.to_be_bytes()); - mac.update(&target_schema.to_be_bytes()); - mac.update(digest.as_bytes()); - mac.update(state.as_bytes()); - Ok(hex(&mac.finalize().into_bytes())) -} - -fn parse_field<'a>( - lines: &mut impl Iterator<Item = &'a str>, - name: &str, -) -> Result<String, SafeError> { - lines - .next() - .and_then(|line| line.strip_prefix(name)) - .and_then(|value| value.strip_prefix('=')) - .map(str::to_owned) - .ok_or_else(backup_invalid) -} - -fn decode_hex_32(value: &str) -> Result<[u8; 32], SafeError> { - if value.len() != 64 { - return Err(backup_invalid()); - } - let mut bytes = [0_u8; 32]; - for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { - let high = hex_nibble(pair[0]).ok_or_else(backup_invalid)?; - let low = hex_nibble(pair[1]).ok_or_else(backup_invalid)?; - bytes[index] = (high << 4) | low; - } - Ok(bytes) -} - -const fn hex_nibble(byte: u8) -> Option<u8> { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - _ => None, - } -} - -fn constant_time_eq(left: &[u8; 32], right: &[u8; 32]) -> bool { - left.iter() - .zip(right) - .fold(0_u8, |difference, (left, right)| { - difference | (left ^ right) - }) - == 0 -} - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The application database recovery path is unavailable."), - ) -} - -const fn backup_invalid() -> SafeError { - SafeError::new( - SafeErrorCode::StorageBackupInvalid, - SafeMessage::new("The application database recovery backup is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use std::fs; - use std::path::Path; - - use rusqlite::Connection; - use tempfile::tempdir; - - use super::{ - AUTHENTICATION_KEY_FILENAME, MANIFEST_FORMAT, MigrationRecovery, atomic_secure_write, - constant_time_eq, create_recovery_directory, decode_hex_32, file_digest, hex, hex_nibble, - load_authentication_key, load_or_create_authentication_key, parse_field, read_bounded_file, - recovery_directory, replace_with_backup, secure_read, - }; - - fn sqlite_database(path: &Path) { - let connection = Connection::open(path).expect("open sqlite database"); - connection - .execute("CREATE TABLE durable_probe (value INTEGER NOT NULL)", []) - .expect("create probe table"); - connection - .execute("INSERT INTO durable_probe (value) VALUES (7)", []) - .expect("insert probe row"); - } - - #[test] - fn migration_recovery_authenticates_finishes_reopens_and_restores() { - let directory = tempdir().expect("temporary directory"); - let database = directory.path().join("studio.sqlite3"); - sqlite_database(&database); - - let recovery = MigrationRecovery::prepare(&database, 5, 10).expect("prepare recovery"); - MigrationRecovery::verify_evidence(&database, 5, 10).expect("prepared evidence"); - assert!(recovery.finish(9).is_err()); - - MigrationRecovery::prepare(&database, 5, 10) - .expect("reopen prepared recovery") - .finish(10) - .expect("finish recovery"); - MigrationRecovery::verify_evidence(&database, 5, 10).expect("complete evidence"); - - MigrationRecovery::prepare(&database, 5, 10) - .expect("reopen complete recovery") - .finish(10) - .expect("finish reopened recovery"); - fs::write(&database, b"not sqlite").expect("corrupt active database"); - MigrationRecovery::restore(&database, 5, 10).expect("restore authenticated backup"); - let connection = Connection::open(&database).expect("open restored database"); - let value: i64 = connection - .query_row("SELECT value FROM durable_probe", [], |row| row.get(0)) - .expect("restored row"); - assert_eq!(value, 7); - } - - #[test] - fn recovery_manifest_rejects_every_tampered_authority_field() { - let directory = tempdir().expect("temporary directory"); - let database = directory.path().join("studio.sqlite3"); - sqlite_database(&database); - let recovery = MigrationRecovery::prepare(&database, 5, 10).expect("prepare recovery"); - let original = fs::read_to_string(&recovery.marker).expect("read marker"); - let backup_name = recovery - .backup - .file_name() - .expect("backup name") - .to_string_lossy(); - let cases = [ - original.replacen(MANIFEST_FORMAT, "wrong-format", 1), - original.replacen("source_schema=5", "source_schema=4", 1), - original.replacen("target_schema=10", "target_schema=11", 1), - original.replacen(&format!("backup={backup_name}"), "backup=other.sqlite3", 1), - original.replacen("sha256=", "unexpected=value\nsha256=", 1), - original.replacen("state=prepared", "state=invalid", 1), - original.replacen("sha256=", "sha256=00", 1), - original.replacen("hmac_sha256=", "hmac_sha256=gg", 1), - { - let mut lines = original.lines().map(str::to_owned).collect::<Vec<_>>(); - let tag = lines - .iter_mut() - .find(|line| line.starts_with("hmac_sha256=")) - .expect("tag field"); - let replacement = if tag.ends_with('0') { '1' } else { '0' }; - tag.pop(); - tag.push(replacement); - format!("{}\n", lines.join("\n")) - }, - ]; - for tampered in cases { - fs::write(&recovery.marker, tampered).expect("write tampered marker"); - assert!(MigrationRecovery::verify_evidence(&database, 5, 10).is_err()); - } - fs::write(&recovery.marker, original).expect("restore marker"); - MigrationRecovery::verify_evidence(&database, 5, 10).expect("restored evidence"); - } - - #[test] - fn recovery_helpers_reject_invalid_paths_sizes_and_encodings() { - let directory = tempdir().expect("temporary directory"); - let regular = directory.path().join("regular"); - fs::write(&regular, b"abc").expect("write regular file"); - let child = directory.path().join("child"); - fs::create_dir(&child).expect("create child directory"); - - assert!(recovery_directory(Path::new("/")).is_err()); - assert!(create_recovery_directory(&regular).is_err()); - assert!(create_recovery_directory(&regular.join("nested")).is_err()); - assert!(secure_read(&child).is_err()); - assert!(file_digest(&child).is_err()); - assert!(read_bounded_file(&child, 4).is_err()); - assert!(read_bounded_file(&regular, 2).is_err()); - assert_eq!( - read_bounded_file(&regular, 3).expect("bounded read"), - b"abc" - ); - - let missing_key_dir = directory.path().join("missing-key"); - fs::create_dir(&missing_key_dir).expect("create missing key directory"); - assert!(load_authentication_key(&missing_key_dir).is_err()); - fs::write( - missing_key_dir.join(AUTHENTICATION_KEY_FILENAME), - [0_u8; 31], - ) - .expect("write short key"); - assert!(load_authentication_key(&missing_key_dir).is_err()); - - assert!(decode_hex_32("00").is_err()); - assert!(decode_hex_32(&format!("g0{}", "00".repeat(31))).is_err()); - assert!(decode_hex_32(&format!("0g{}", "00".repeat(31))).is_err()); - let zeros = decode_hex_32(&"00".repeat(32)).expect("decode zeros"); - assert!(constant_time_eq(&zeros, &[0_u8; 32])); - assert!(!constant_time_eq(&zeros, &[1_u8; 32])); - assert_eq!(hex(&[0, 15, 255]), "000fff"); - assert_eq!(hex_nibble(b'9'), Some(9)); - assert_eq!(hex_nibble(b'f'), Some(15)); - assert_eq!(hex_nibble(b'G'), None); - - let mut valid = ["field=value"].into_iter(); - assert_eq!(parse_field(&mut valid, "field").expect("field"), "value"); - let mut invalid = ["other=value"].into_iter(); - assert!(parse_field(&mut invalid, "field").is_err()); - let mut missing = std::iter::empty(); - assert!(parse_field(&mut missing, "field").is_err()); - - let absent_parent = directory.path().join("absent").join("marker"); - assert!(atomic_secure_write(&absent_parent, b"marker").is_err()); - - let orphan_database = directory.path().join("orphan.sqlite3"); - sqlite_database(&orphan_database); - let orphan_directory = recovery_directory(&orphan_database).expect("recovery directory"); - create_recovery_directory(&orphan_directory).expect("create recovery directory"); - load_or_create_authentication_key(&orphan_directory).expect("authentication key"); - fs::write( - orphan_directory.join("migration-v5-to-v10.sqlite3"), - b"orphan backup", - ) - .expect("orphan backup"); - assert!(MigrationRecovery::prepare(&orphan_database, 5, 10).is_err()); - - let missing_database = directory.path().join("missing-database.sqlite3"); - assert!(replace_with_backup(&missing_database, &regular).is_err()); - } - - #[cfg(unix)] - #[test] - fn recovery_helpers_reject_symlink_inputs() { - use std::os::unix::fs::symlink; - - let directory = tempdir().expect("temporary directory"); - let regular = directory.path().join("regular"); - fs::write(&regular, b"abc").expect("write regular file"); - let link = directory.path().join("link"); - symlink(&regular, &link).expect("create symlink"); - assert!(secure_read(&link).is_err()); - assert!(file_digest(&link).is_err()); - assert!(read_bounded_file(&link, 3).is_err()); - assert!(create_recovery_directory(&link).is_err()); - } -} diff --git a/crates/studio_storage/src/repair.rs b/crates/studio_storage/src/repair.rs @@ -1,403 +0,0 @@ -use std::fs::{self, File, OpenOptions}; -use std::io::Read; -use std::path::{Path, PathBuf}; - -use hmac::{Hmac, Mac}; -use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; -use rusqlite::{Connection, MAIN_DB, OpenFlags}; -use sha2::{Digest, Sha256}; -use zeroize::Zeroizing; - -use crate::compatibility::{DatabasePreflight, preflight}; -use crate::db::{CURRENT_SCHEMA_VERSION, restrict_file_permissions}; - -type HmacSha256 = Hmac<Sha256>; -const EXPORT_DOMAIN: &[u8] = b"radroots-studio-quarantine-export-v1"; -const REPAIR_DOMAIN: &[u8] = b"radroots-studio-repair-candidate-v1"; - -pub struct RepairAuthorization(Zeroizing<[u8; 32]>); - -impl RepairAuthorization { - /// Moves an exact 256-bit caller authorization secret into zeroizing storage. - /// - /// # Errors - /// - /// Returns a safe authorization error for every other input length. - pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, SafeError> { - let bytes = Zeroizing::new(bytes); - let value = <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| unauthorized())?; - Ok(Self(Zeroizing::new(value))) - } - - fn expose(&self) -> &[u8; 32] { - &self.0 - } -} - -pub struct QuarantineExportReceipt { - path: PathBuf, - sha256: String, - authentication_tag: String, -} - -impl QuarantineExportReceipt { - #[must_use] - pub fn path(&self) -> &Path { - &self.path - } - - #[must_use] - pub fn sha256(&self) -> &str { - &self.sha256 - } - - #[must_use] - pub fn authentication_tag(&self) -> &str { - &self.authentication_tag - } -} - -pub struct RepairCandidate { - path: PathBuf, - sha256: String, - authentication_tag: String, -} - -impl RepairCandidate { - #[must_use] - pub fn path(&self) -> &Path { - &self.path - } -} - -pub(crate) fn export_quarantined( - source: &Path, - destination: &Path, - authorization: &RepairAuthorization, -) -> Result<QuarantineExportReceipt, SafeError> { - if !matches!(preflight(source)?, DatabasePreflight::Quarantined { .. }) { - return Err(not_quarantined()); - } - ensure_new_destination(destination)?; - let flags = OpenFlags::SQLITE_OPEN_READ_ONLY - | OpenFlags::SQLITE_OPEN_NO_MUTEX - | OpenFlags::SQLITE_OPEN_NOFOLLOW; - let connection = Connection::open_with_flags(source, flags).map_err(|_| storage_error())?; - if connection.backup(MAIN_DB, destination, None).is_err() { - let _ = fs::remove_file(destination); - return Err(storage_error()); - } - restrict_file_permissions(destination)?; - File::open(destination) - .and_then(|file| file.sync_all()) - .map_err(|_| storage_error())?; - let sha256 = digest_file(destination)?; - let authentication_tag = authenticate(authorization, EXPORT_DOMAIN, &sha256)?; - Ok(QuarantineExportReceipt { - path: destination.to_path_buf(), - sha256, - authentication_tag, - }) -} - -pub(crate) fn authenticate_candidate( - path: &Path, - authorization: &RepairAuthorization, -) -> Result<RepairCandidate, SafeError> { - if !matches!( - preflight(path)?, - DatabasePreflight::Ready { schema_version } if schema_version <= CURRENT_SCHEMA_VERSION - ) { - return Err(storage_error()); - } - let sha256 = digest_file(path)?; - let authentication_tag = authenticate(authorization, REPAIR_DOMAIN, &sha256)?; - Ok(RepairCandidate { - path: path.to_path_buf(), - sha256, - authentication_tag, - }) -} - -pub(crate) fn install_candidate( - target: &Path, - candidate: &RepairCandidate, - authorization: &RepairAuthorization, -) -> Result<(), SafeError> { - if !matches!(preflight(target)?, DatabasePreflight::Quarantined { .. }) { - return Err(not_quarantined()); - } - let digest = digest_file(&candidate.path)?; - if digest != candidate.sha256 - || authenticate(authorization, REPAIR_DOMAIN, &digest)? != candidate.authentication_tag - { - return Err(unauthorized()); - } - if !matches!(preflight(&candidate.path)?, DatabasePreflight::Ready { .. }) { - return Err(storage_error()); - } - let parent = target.parent().ok_or_else(storage_error)?; - let replacement = parent.join(".authenticated-repair.tmp"); - if replacement.try_exists().map_err(|_| storage_error())? { - return Err(storage_error()); - } - copy_secure(&candidate.path, &replacement)?; - let retained = parent.join("studio.sqlite3.quarantined-evidence"); - if retained.try_exists().map_err(|_| storage_error())? { - let _ = fs::remove_file(&replacement); - return Err(storage_error()); - } - fs::rename(target, &retained).map_err(|_| storage_error())?; - if fs::rename(&replacement, target).is_err() { - let _ = fs::rename(&retained, target); - let _ = fs::remove_file(&replacement); - return Err(storage_error()); - } - File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|_| storage_error()) -} - -fn ensure_new_destination(path: &Path) -> Result<(), SafeError> { - if path.try_exists().map_err(|_| storage_error())? { - return Err(storage_error()); - } - let parent = path.parent().ok_or_else(storage_error)?; - let metadata = fs::symlink_metadata(parent).map_err(|_| storage_error())?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(storage_error()); - } - Ok(()) -} - -fn copy_secure(source: &Path, destination_path: &Path) -> Result<(), SafeError> { - let mut source = secure_read(source)?; - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600).custom_flags( - i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) - .map_err(|_| storage_error())?, - ); - } - let mut destination = options - .open(destination_path) - .map_err(|_| storage_error())?; - std::io::copy(&mut source, &mut destination).map_err(|_| storage_error())?; - destination.sync_all().map_err(|_| storage_error())?; - restrict_file_permissions(destination_path) -} - -fn secure_read(path: &Path) -> Result<File, SafeError> { - let metadata = fs::symlink_metadata(path).map_err(|_| storage_error())?; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(storage_error()); - } - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags( - i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) - .map_err(|_| storage_error())?, - ); - } - options.open(path).map_err(|_| storage_error()) -} - -fn digest_file(path: &Path) -> Result<String, SafeError> { - let mut file = secure_read(path)?; - let mut digest = Sha256::new(); - let mut buffer = [0_u8; 64 * 1024]; - loop { - let read = file.read(&mut buffer).map_err(|_| storage_error())?; - if read == 0 { - break; - } - digest.update(&buffer[..read]); - } - Ok(hex(&digest.finalize())) -} - -fn authenticate( - authorization: &RepairAuthorization, - domain: &[u8], - digest: &str, -) -> Result<String, SafeError> { - let mut hmac = - HmacSha256::new_from_slice(authorization.expose()).map_err(|_| unauthorized())?; - hmac.update(domain); - hmac.update(digest.as_bytes()); - Ok(hex(&hmac.finalize().into_bytes())) -} - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -const fn unauthorized() -> SafeError { - SafeError::new( - SafeErrorCode::RepairUnauthorized, - SafeMessage::new("The database repair authorization is invalid."), - ) -} - -const fn not_quarantined() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The database is not in quarantine."), - ) -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The database repair operation could not be completed."), - ) -} - -#[cfg(test)] -mod tests { - use std::fs; - use std::io::Write; - - use rusqlite::Connection; - use tempfile::tempdir; - - use super::{ - REPAIR_DOMAIN, RepairAuthorization, RepairCandidate, authenticate, authenticate_candidate, - copy_secure, digest_file, ensure_new_destination, export_quarantined, hex, - install_candidate, secure_read, - }; - use crate::Database; - - fn quarantined_database(path: &std::path::Path) { - drop(Database::open(path).expect("current database")); - let connection = Connection::open(path).expect("open database"); - connection - .execute( - "INSERT INTO account_identities (public_key, npub, created_at) VALUES (?1, ?2, 1)", - [ - "00".repeat(32), - "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned(), - ], - ) - .expect("invalid identity fixture"); - connection - .execute( - "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')", - ["00".repeat(32)], - ) - .expect("binding fixture"); - } - - #[test] - fn repair_authority_and_candidate_reject_invalid_states() { - assert!(RepairAuthorization::from_bytes(vec![0_u8; 31]).is_err()); - assert!(RepairAuthorization::from_bytes(vec![0_u8; 33]).is_err()); - let authorization = RepairAuthorization::from_bytes(vec![0x41; 32]).expect("authorization"); - assert_eq!( - authenticate(&authorization, b"domain", "digest") - .expect("authentication tag") - .len(), - 64 - ); - assert_eq!(hex(&[0, 15, 255]), "000fff"); - - let directory = tempdir().expect("temporary directory"); - let ready = directory.path().join("ready.sqlite3"); - drop(Database::open(&ready).expect("ready database")); - let candidate = authenticate_candidate(&ready, &authorization).expect("candidate"); - assert_eq!(candidate.path(), ready); - - let missing = directory.path().join("missing.sqlite3"); - assert!(authenticate_candidate(&missing, &authorization).is_err()); - let export = directory.path().join("export.sqlite3"); - assert!(export_quarantined(&ready, &export, &authorization).is_err()); - assert!(install_candidate(&ready, &candidate, &authorization).is_err()); - } - - #[test] - fn repair_file_boundaries_reject_existing_non_file_and_missing_parent_paths() { - let directory = tempdir().expect("temporary directory"); - let regular = directory.path().join("regular"); - fs::write(&regular, b"repair material").expect("write regular file"); - let child = directory.path().join("child"); - fs::create_dir(&child).expect("create child directory"); - - assert!(ensure_new_destination(&regular).is_err()); - assert!(ensure_new_destination(&regular.join("nested")).is_err()); - assert!(secure_read(&child).is_err()); - assert_eq!(digest_file(&regular).expect("digest").len(), 64); - - let copied = directory.path().join("copied"); - copy_secure(&regular, &copied).expect("secure copy"); - assert_eq!(fs::read(&copied).expect("copied bytes"), b"repair material"); - assert!(copy_secure(&regular, &copied).is_err()); - assert!(copy_secure(&child, &directory.path().join("invalid-copy")).is_err()); - } - - #[test] - fn repair_installation_rejects_tampering_quarantined_candidates_and_staging_collisions() { - let directory = tempdir().expect("temporary directory"); - let authorization = RepairAuthorization::from_bytes(vec![0x41; 32]).expect("authorization"); - let target = directory.path().join("studio.sqlite3"); - quarantined_database(&target); - let candidate_path = directory.path().join("candidate.sqlite3"); - drop(Database::open(&candidate_path).expect("candidate database")); - let candidate = authenticate_candidate(&candidate_path, &authorization).expect("candidate"); - - fs::OpenOptions::new() - .append(true) - .open(&candidate_path) - .expect("open candidate") - .write_all(b"tamper") - .expect("tamper candidate"); - assert!(install_candidate(&target, &candidate, &authorization).is_err()); - - let quarantined_candidate_path = directory.path().join("quarantined-candidate.sqlite3"); - quarantined_database(&quarantined_candidate_path); - let digest = digest_file(&quarantined_candidate_path).expect("candidate digest"); - let quarantined_candidate = RepairCandidate { - path: quarantined_candidate_path, - sha256: digest.clone(), - authentication_tag: authenticate(&authorization, REPAIR_DOMAIN, &digest) - .expect("candidate tag"), - }; - assert!(install_candidate(&target, &quarantined_candidate, &authorization).is_err()); - - let candidate_path = directory.path().join("candidate-two.sqlite3"); - drop(Database::open(&candidate_path).expect("candidate database")); - let candidate = authenticate_candidate(&candidate_path, &authorization).expect("candidate"); - let replacement = directory.path().join(".authenticated-repair.tmp"); - fs::write(&replacement, b"occupied").expect("occupied replacement"); - assert!(install_candidate(&target, &candidate, &authorization).is_err()); - fs::remove_file(&replacement).expect("remove occupied replacement"); - - let retained = directory.path().join("studio.sqlite3.quarantined-evidence"); - fs::write(&retained, b"occupied").expect("occupied retained evidence"); - assert!(install_candidate(&target, &candidate, &authorization).is_err()); - assert!(!replacement.exists()); - } - - #[cfg(unix)] - #[test] - fn repair_file_boundaries_reject_symlinks() { - use std::os::unix::fs::symlink; - - let directory = tempdir().expect("temporary directory"); - let regular = directory.path().join("regular"); - fs::write(&regular, b"repair material").expect("write regular file"); - let link = directory.path().join("link"); - symlink(&regular, &link).expect("create file symlink"); - assert!(secure_read(&link).is_err()); - assert!(digest_file(&link).is_err()); - - let directory_link = directory.path().join("directory-link"); - symlink(directory.path(), &directory_link).expect("create directory symlink"); - assert!(ensure_new_destination(&directory_link.join("export")).is_err()); - } -} diff --git a/crates/studio_storage/tests/redaction.rs b/crates/studio_storage/tests/redaction.rs @@ -1,52 +0,0 @@ -use std::fs; - -use radroots_studio_application::{AccountOperationKind, AccountRepository, OperationJournal}; -use radroots_studio_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, UnixTimestamp, -}; -use radroots_studio_storage::Database; -use tempfile::tempdir; - -const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; -const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; -fn assert_redacted(bytes: &[u8]) { - assert!( - !bytes - .windows(SECRET_HEX.len()) - .any(|value| value == SECRET_HEX.as_bytes()) - ); - assert!( - !bytes - .windows(SECRET_NSEC.len()) - .any(|value| value == SECRET_NSEC.as_bytes()) - ); - assert!(!bytes.windows(5).any(|value| value == b"nsec1")); -} - -#[test] -fn redaction_guards_sqlite_schema_and_non_secret_records() { - let directory = tempdir().expect("directory"); - let path = directory.path().join("studio.sqlite3"); - { - let database = Database::open(&path).expect("database"); - let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); - let account = AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), - None, - ) - .expect("account"); - database.insert_account(&account).expect("account"); - database - .begin_operation( - AccountOperationKind::Add, - account.public_key(), - UnixTimestamp::from_seconds(2).expect("time"), - ) - .expect("journal"); - } - assert_redacted(&fs::read(path).expect("database bytes")); -} diff --git a/crates/studio_uniffi_bindgen/Cargo.toml b/crates/studio_uniffi_bindgen/Cargo.toml @@ -1,18 +0,0 @@ -[package] -name = "radroots_studio_uniffi_bindgen" -description = "Private UniFFI binding generator for Radroots Studio" -version = "0.1.0-alpha" -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license = "GPL-3.0-only" -repository.workspace = true -homepage.workspace = true -publish = false -include = ["src/**", "Cargo.toml"] - -[dependencies] -uniffi = { version = "=0.32.0", features = ["cli"] } - -[lints] -workspace = true diff --git a/crates/studio_uniffi_bindgen/src/main.rs b/crates/studio_uniffi_bindgen/src/main.rs @@ -1,21 +0,0 @@ -#![doc = "Pinned `UniFFI` binding generator entry point."] - -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/supply-chain/config.toml b/supply-chain/config.toml @@ -32,15 +32,6 @@ sha2 = "crypto-reviewed" subtle = "crypto-reviewed" zeroize = "secret-handling-reviewed" -[policy.radroots_studio_ffi] -dependency-criteria = { quote = "build-execution-reviewed", syn = "build-execution-reviewed" } - -[policy.radroots_studio_storage.dependency-criteria] -hmac = "crypto-reviewed" -keyring = "secret-handling-reviewed" -sha2 = "crypto-reviewed" -zeroize = "secret-handling-reviewed" - [policy.radroots_transport_nostr.dependency-criteria] async-wsocket = "network-parser-reviewed" nostr-relay-pool = "network-parser-reviewed" diff --git a/tools/xtask/src/build_control.rs b/tools/xtask/src/build_control.rs @@ -93,11 +93,8 @@ impl ConsumerRoot { .map_err(|error| format!("consumer marker is not UTF-8: {error}"))? .trim() .to_owned(); - if !matches!( - product.as_str(), - "sdk" | "mobile" | "studio" | "myc" | "rhi" - ) { - return Err("consumer marker must contain sdk, mobile, studio, myc, or rhi".to_owned()); + if !matches!(product.as_str(), "sdk" | "mobile" | "myc" | "rhi") { + return Err("consumer marker must contain sdk, mobile, myc, or rhi".to_owned()); } let source_lock_path = canonical.join(SOURCE_LOCK_NAME); let source_lock = parse_source_lock(&source_lock_path)?; @@ -540,7 +537,6 @@ pub fn artifact( let external_names = match product { "sdk" => vec!["radroots", "radroots_sdk"], "mobile" => vec!["RadrootsFFI", "RadrootsKitBindings"], - "studio" => vec!["org.radroots.studio.ffi", "radroots_studio_ffi"], _ => return Err("unsupported artifact product".to_owned()), }; let manifest = ArtifactManifest { @@ -613,7 +609,6 @@ fn validate_artifact_route(product: &str, target: &str, language: &str) -> Resul (target, language), ("ios", "swift") | ("android", "kotlin") | ("wasm", "javascript") ), - "studio" => matches!(target, "linux" | "macos" | "windows") && language == "kotlin", _ => false, }; if valid { @@ -1177,7 +1172,7 @@ mod tests { #[test] fn source_lock_supports_a_contained_nested_lockfile() { - let mut fixture = Fixture::new("studio"); + let mut fixture = Fixture::new("sdk"); let core = fixture.consumer.join("core"); fs::create_dir(&core).expect("create nested capsule"); fs::rename(fixture.consumer.join("Cargo.lock"), core.join("Cargo.lock")) diff --git a/tools/xtask/src/catalog.rs b/tools/xtask/src/catalog.rs @@ -407,7 +407,6 @@ fn validate_catalog(catalog: &Catalog) -> Result<(), String> { "preview", "public_native", "sdk", - "studio", "tools", "wasm", ]); @@ -1246,6 +1245,14 @@ fn expected_retired_packages() -> BTreeSet<&'static str> { "radroots-studio-nostr", "radroots-studio-storage", "radroots-studio-uniffi-bindgen", + "radroots_studio_application", + "radroots_studio_domain", + "radroots_studio_ffi", + "radroots_studio_nostr", + "radroots_studio_preferences", + "radroots_studio_runtime", + "radroots_studio_storage", + "radroots_studio_uniffi_bindgen", "radroots_app_bindgen", "radroots_app_core", "radroots_app_ffi", diff --git a/tools/xtask/src/coverage.rs b/tools/xtask/src/coverage.rs @@ -4094,22 +4094,31 @@ test_threads = 4 #[test] fn coverage_profiles_resolve_validated_downstream_test_packages() { - let root = workspace_root(); - let runtime = read_coverage_profile(&root, "radroots_studio_runtime") - .expect("runtime coverage profile"); - assert_eq!( - runtime.test_packages, - vec!["radroots_studio_ffi".to_string()] + let root = temp_dir_path("profile_downstream_packages"); + write_file( + &root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/target\", \"crates/downstream\"]\n", ); - let storage = read_coverage_profile(&root, "radroots_studio_storage") - .expect("storage coverage profile"); + write_file( + &root.join("crates/target/Cargo.toml"), + "[package]\nname = \"radroots_target\"\nversion = \"0.1.0-alpha\"\n", + ); + write_file( + &root.join("crates/downstream/Cargo.toml"), + "[package]\nname = \"radroots_downstream\"\nversion = \"0.1.0-alpha\"\n", + ); + write_file( + &root.join("contracts/coverage-profiles.toml"), + "[profiles.crates.\"radroots_target\"]\ntest_packages = [\"radroots_downstream\"]\n", + ); + + let profile = + read_coverage_profile(&root, "radroots_target").expect("target coverage profile"); assert_eq!( - storage.test_packages, - vec![ - "radroots_studio_runtime".to_string(), - "radroots_studio_ffi".to_string() - ] + profile.test_packages, + vec!["radroots_downstream".to_string()] ); + fs::remove_dir_all(root).expect("remove root"); } #[test] @@ -4625,10 +4634,27 @@ test_threads = 0 #[test] fn run_crate_credits_declared_downstream_tests_only_to_the_target_report() { + let root = temp_dir_path("run_crate_downstream_tests"); + write_file( + &root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/target\", \"crates/downstream\"]\n", + ); + write_file( + &root.join("crates/target/Cargo.toml"), + "[package]\nname = \"radroots_target\"\nversion = \"0.1.0-alpha\"\n", + ); + write_file( + &root.join("crates/downstream/Cargo.toml"), + "[package]\nname = \"radroots_downstream\"\nversion = \"0.1.0-alpha\"\n", + ); + write_file( + &root.join("contracts/coverage-profiles.toml"), + "[profiles.crates.\"radroots_target\"]\ntest_packages = [\"radroots_downstream\"]\n", + ); let out = temp_dir_path("run_crate_downstream_tests"); let args = vec![ "--crate".to_string(), - "radroots_studio_runtime".to_string(), + "radroots_target".to_string(), "--out".to_string(), out.display().to_string(), ]; @@ -4642,21 +4668,23 @@ test_threads = 0 ); Ok(()) }; - run_crate_with_runner(&args, &mut runner).expect("run crate with downstream tests"); + run_crate_with_runner_at_root(&args, &root, &mut runner) + .expect("run crate with downstream tests"); let test_command = rendered_commands .iter() .find(|command| command.contains("--no-report")) .expect("coverage test command"); - assert!(test_command.contains("-p radroots_studio_runtime")); - assert!(test_command.contains("-p radroots_studio_ffi")); + assert!(test_command.contains("-p radroots_target")); + assert!(test_command.contains("-p radroots_downstream")); for report_command in rendered_commands .iter() .filter(|command| command.starts_with("report ")) { - assert!(report_command.contains("-p radroots_studio_runtime")); - assert!(!report_command.contains("-p radroots_studio_ffi")); + assert!(report_command.contains("-p radroots_target")); + assert!(!report_command.contains("-p radroots_downstream")); } fs::remove_dir_all(out).expect("remove downstream test output dir"); + fs::remove_dir_all(root).expect("remove root"); } #[test] diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -172,7 +172,6 @@ enum SourceMode { enum ArtifactProduct { Sdk, Mobile, - Studio, } impl ArtifactProduct { @@ -180,7 +179,6 @@ impl ArtifactProduct { match self { Self::Sdk => "sdk", Self::Mobile => "mobile", - Self::Studio => "studio", } } } @@ -311,7 +309,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|studio> --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|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>" ); } @@ -652,9 +650,8 @@ mod tests { [ ArtifactProduct::Sdk.as_str(), ArtifactProduct::Mobile.as_str(), - ArtifactProduct::Studio.as_str(), ], - ["sdk", "mobile", "studio"] + ["sdk", "mobile"] ); assert_eq!( [