app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 4eb02491b931713ba07784cb51e239006396ed4f
parent 16a9a67380e23a3ffad45a813db767092df2899e
Author: triesap <tyson@radroots.org>
Date:   Wed, 26 Aug 2026 03:08:40 +0000

refactor(storage): adopt governed sqlx host

- replace the legacy rusqlite and Refinery stack with the sealed service SQLite host
- move repositories and durable UUID operations to bounded asynchronous SQLx transactions
- preserve canonical runtime paths, explicit close, safe diagnostics, and legacy-state isolation
- refresh compatibility evidence and enforce the one-authority package boundary

Diffstat:
MAGENTS.md | 4++++
MREADME.md | 11+++++++++++
Mbuild-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/FfiCompatibilityBaseline.kt | 4++--
Mbuild-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt | 4++--
Mbuild-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt | 4++--
Mcore/Cargo.lock | 182+++++--------------------------------------------------------------------------
Mcore/Cargo.toml | 4++--
Mcore/compatibility/harvestcircle-ffi-v4.properties | 6+++---
Mcore/compatibility/harvestcircle-storage-api-v1.txt | 171+++++++++++++++++--------------------------------------------------------------
Mcore/crates/harvestcircle_application/src/app_core.rs | 29+++++++++++++++--------------
Mcore/crates/harvestcircle_application/src/identities.rs | 923+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mcore/crates/harvestcircle_application/src/lib.rs | 21++++++++++++---------
Mcore/crates/harvestcircle_application/src/ports.rs | 252+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mcore/crates/harvestcircle_application/src/profile_refresh.rs | 124+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcore/crates/harvestcircle_application/src/recovery.rs | 558++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mcore/crates/harvestcircle_application/src/session.rs | 63+++++++++++++++++++++++++++++++++++++++------------------------
Mcore/crates/harvestcircle_ffi/Cargo.toml | 2++
Mcore/crates/harvestcircle_ffi/build.rs | 53+++++++++++++++--------------------------------------
Mcore/crates/harvestcircle_ffi/src/commands.rs | 306++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcore/crates/harvestcircle_ffi/src/contract.rs | 2+-
Mcore/crates/harvestcircle_ffi/src/observer.rs | 26++++----------------------
Mcore/crates/harvestcircle_product/src/provenance.rs | 6++++++
Mcore/crates/harvestcircle_runtime/Cargo.toml | 2++
Mcore/crates/harvestcircle_runtime/src/lib.rs | 1+
Mcore/crates/harvestcircle_runtime/src/persistence.rs | 758++++++++++---------------------------------------------------------------------
Mcore/crates/harvestcircle_runtime/src/runtime_actor.rs | 342++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcore/crates/harvestcircle_runtime/tests/local_relay_e2e.rs | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcore/crates/harvestcircle_runtime/tests/restart_isolation.rs | 112++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcore/crates/harvestcircle_storage/Cargo.toml | 14+++++---------
Dcore/crates/harvestcircle_storage/migrations/V10__installation_identity.sql | 7-------
Dcore/crates/harvestcircle_storage/migrations/V1__initialize.sql | 6------
Dcore/crates/harvestcircle_storage/migrations/V2__accounts.sql | 28----------------------------
Dcore/crates/harvestcircle_storage/migrations/V3__profile_cache.sql | 12------------
Dcore/crates/harvestcircle_storage/migrations/V4__account_namespace.sql | 6------
Dcore/crates/harvestcircle_storage/migrations/V5__operation_journal.sql | 8--------
Dcore/crates/harvestcircle_storage/migrations/V6__normalized_runtime_schema.sql | 100-------------------------------------------------------------------------------
Dcore/crates/harvestcircle_storage/migrations/V7__migrate_v5_runtime_data.sql | 68--------------------------------------------------------------------
Dcore/crates/harvestcircle_storage/migrations/V8__normalized_account_preferences.sql | 10----------
Dcore/crates/harvestcircle_storage/migrations/V9__durable_operation_receipts.sql | 11-----------
Dcore/crates/harvestcircle_storage/src/compatibility.rs | 478-------------------------------------------------------------------------------
Mcore/crates/harvestcircle_storage/src/contract.rs | 51++++++++++++++++++++++++++++-----------------------
Mcore/crates/harvestcircle_storage/src/db.rs | 1021+++++++++++++------------------------------------------------------------------
Mcore/crates/harvestcircle_storage/src/identities.rs | 793++++++++++++++++++++++++++++++++++++-------------------------------------------
Mcore/crates/harvestcircle_storage/src/identity_namespace.rs | 264+++++++++++++++++++++++++++++++------------------------------------------------
Mcore/crates/harvestcircle_storage/src/installation.rs | 154+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mcore/crates/harvestcircle_storage/src/journal.rs | 982+++++++++++++++++++++++++++++--------------------------------------------------
Mcore/crates/harvestcircle_storage/src/lib.rs | 20+++++++-------------
Mcore/crates/harvestcircle_storage/src/os_keyring.rs | 30+++++++++++++++++++++++-------
Mcore/crates/harvestcircle_storage/src/profiles.rs | 418++++++++++++++++++++++++++++++++++++++-----------------------------------------
Dcore/crates/harvestcircle_storage/src/recovery.rs | 696-------------------------------------------------------------------------------
Dcore/crates/harvestcircle_storage/src/repair.rs | 410-------------------------------------------------------------------------------
Acore/crates/harvestcircle_storage/tests/package_boundary.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/harvestcircle_storage/tests/redaction.rs | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Acore/crates/harvestcircle_storage/tests/sqlx_storage.rs | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/harvestcircle_test_bridge/Cargo.toml | 2++
Mcore/crates/harvestcircle_test_bridge/src/lib.rs | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mtools/xtask/src/lib.rs | 15++++++++++++++-
57 files changed, 3739 insertions(+), 6359 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -83,6 +83,10 @@ substitute. governed SQLite mechanics. - `harvestcircle.sqlite3` is legacy evidence only. Never delete, rename, import, dual-read, dual-write, or otherwise treat it as current state. +- SQLx is the only high-level SQLite library. HarvestCircle may use its sealed + application-schema callback inside `radroots_service_sqlite` initialization, + but must not create another pool, expose a raw connection, or reintroduce + Rusqlite, Refinery, arbitrary repair, or a second migration authority. - Native production qualification is limited to macOS aarch64 and Linux x86_64. Do not add or claim another target without an explicit contract change and its complete platform evidence. diff --git a/README.md b/README.md @@ -13,6 +13,7 @@ commercial use. - product-specific Rust core through UniFFI; - local Nostr identity creation and import; - operating-system keyring custody; +- canonical service-instance persistence through the governed SQLx host; - configurable Nostr relay bootstrap; - compatibility-gated native startup; - reproducible source and package verification. @@ -44,6 +45,16 @@ required. Release, signing, and notarization checks are governed-only. Active implementation proceeds on `master`. +## Local state + +HarvestCircle derives one canonical `harvestcircle`/`desktop` runtime context +and stores application state only in its governed `state.sqlite` service +database. SQLx is the sole high-level SQLite library, while +`radroots_service_sqlite` owns connection, authority, migration, integrity, +close, backup, and restore mechanics. The historical `harvestcircle.sqlite3` +file is legacy evidence only and is never imported, repaired, deleted, or +treated as current state. + ## Project documentation The consuming Radroots monorepo owns normative HarvestCircle specifications, diff --git a/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/FfiCompatibilityBaseline.kt b/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/FfiCompatibilityBaseline.kt @@ -49,8 +49,8 @@ public class FfiCompatibilityBaseline private constructor( require(values.getValue("contract.major") == "4") require(values.getValue("contract.minor") == "3") require(values.getValue("snapshot.schema") == "1") - require(values.getValue("storage.schema.minimum") == "5") - require(values.getValue("storage.schema.current") == "10") + require(values.getValue("storage.schema.minimum") == "1") + require(values.getValue("storage.schema.current") == "1") listOf("contract.hash", "product.coordinate_digest", "source.provenance_digest").forEach { key -> require(values.getValue(key).isCanonicalHex(64)) } diff --git a/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt b/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt @@ -217,8 +217,8 @@ class BuildContractsTest { contract.hash=${"a".repeat(64)} product.coordinate_digest=${"b".repeat(64)} snapshot.schema=1 - storage.schema.minimum=5 - storage.schema.current=10 + storage.schema.minimum=1 + storage.schema.current=1 product.version=0.1.0-alpha package.version=1.0.0 source.provenance_digest=${"c".repeat(64)} diff --git a/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt b/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt @@ -602,8 +602,8 @@ class ConventionPluginSmokeTest { contract.hash=b32b9a47d12e445e93866ae0ab668b18de503ba6c999e3a053f26dc9509ddaf9 product.coordinate_digest=bf50f9ea6c2537406de255f025463e670eb6263c295f992f7e4c4db36d957064 snapshot.schema=1 - storage.schema.minimum=5 - storage.schema.current=10 + storage.schema.minimum=1 + storage.schema.current=1 product.version=0.1.0-alpha package.version=1.0.0 source.provenance_digest=daded0256c87be5a413358b346498dcecb412d4eff53d2998999d26ec20f3d40 diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -717,12 +717,6 @@ dependencies = [ ] [[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -861,18 +855,6 @@ dependencies = [ ] [[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] name = "fastrand" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1180,6 +1162,8 @@ dependencies = [ "nostr-relay-builder", "nostr-sdk", "quote", + "radroots_runtime_paths", + "radroots_service_sqlite", "sha2", "syn 2.0.119", "tempfile", @@ -1219,6 +1203,8 @@ dependencies = [ "nostr", "nostr-relay-builder", "nostr-sdk", + "radroots_runtime_paths", + "radroots_service_sqlite", "tempfile", "tokio", "uuid", @@ -1228,20 +1214,18 @@ dependencies = [ name = "harvestcircle_storage" version = "0.1.0-alpha" dependencies = [ - "fs2", "getrandom 0.2.17", "harvestcircle_application", "harvestcircle_domain", + "harvestcircle_nostr", "harvestcircle_product", - "hmac", "keyring", "radroots_runtime_paths", "radroots_service_sqlite", - "refinery", - "rusqlite", - "rustix", - "sha2", + "radroots_storage", + "sqlx", "tempfile", + "tokio", "zeroize", ] @@ -1256,6 +1240,8 @@ dependencies = [ "nostr", "nostr-relay-builder", "nostr-sdk", + "radroots_runtime_paths", + "radroots_service_sqlite", "tokio", "uniffi", ] @@ -1809,12 +1795,6 @@ dependencies = [ ] [[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] name = "num-integer" version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2010,12 +1990,6 @@ dependencies = [ ] [[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2277,47 +2251,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", - "thiserror 2.0.20", - "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.119", -] - -[[package]] name = "regex" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2361,31 +2294,6 @@ dependencies = [ ] [[package]] -name = "rsqlite-vfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.20", -] - -[[package]] -name = "rusqlite" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" -dependencies = [ - "bitflags", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", - "sqlite-wasm-rs", -] - -[[package]] name = "rust_decimal" version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2472,15 +2380,6 @@ dependencies = [ ] [[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2757,18 +2656,6 @@ dependencies = [ ] [[package]] -name = "sqlite-wasm-rs" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - -[[package]] name = "sqlx" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2992,36 +2879,6 @@ dependencies = [ ] [[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] name = "tinystr" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3451,16 +3308,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3585,15 +3432,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -46,11 +46,11 @@ harvestcircle_uniffi_bindgen = { path = "crates/harvestcircle_uniffi_bindgen", v radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "be9db78e060ebc0000fa7827ac32efa3f6504f53", version = "=0.1.0-alpha", default-features = false } radroots_runtime_paths = { git = "https://github.com/radrootslabs/lib", rev = "be9db78e060ebc0000fa7827ac32efa3f6504f53", version = "=0.1.0-alpha", default-features = false } radroots_service_sqlite = { git = "https://github.com/radrootslabs/lib", rev = "be9db78e060ebc0000fa7827ac32efa3f6504f53", version = "=0.1.0-alpha", default-features = false } +radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "be9db78e060ebc0000fa7827ac32efa3f6504f53", version = "=0.1.0-alpha", default-features = false } getrandom = { version = "0.2", default-features = false } -hmac = { version = "0.12", default-features = false } quote = { version = "1" } -rustix = { version = "1", features = ["fs", "process", "std"] } sha2 = { version = "0.10", default-features = false } +sqlx = { version = "=0.9.0", default-features = false, features = ["runtime-tokio", "sqlite"] } syn = { version = "2", features = ["full", "parsing", "visit", "visit-mut"] } toml = { version = "1.1.4" } uuid = { version = "1.22.0", features = ["v4", "v7"] } diff --git a/core/compatibility/harvestcircle-ffi-v4.properties b/core/compatibility/harvestcircle-ffi-v4.properties @@ -2,11 +2,11 @@ schema=harvestcircle.ffi.v4 contract.id=harvestcircle-desktop-ffi-v4 contract.major=4 contract.minor=3 -contract.hash=45e62243f3ce91b400fe7a3735ad6ad0e3f92b7a93673555fb4e2c18ba99f635 +contract.hash=936658b01aca8baf208eba5bc9696fce262e40375edade46873c3106c01046ab product.coordinate_digest=bf50f9ea6c2537406de255f025463e670eb6263c295f992f7e4c4db36d957064 snapshot.schema=1 -storage.schema.minimum=5 -storage.schema.current=10 +storage.schema.minimum=1 +storage.schema.current=1 product.version=0.1.0-alpha package.version=1.0.0 source.provenance_digest=daded0256c87be5a413358b346498dcecb412d4eff53d2998999d26ec20f3d40 diff --git a/core/compatibility/harvestcircle-storage-api-v1.txt b/core/compatibility/harvestcircle-storage-api-v1.txt @@ -1,68 +1,4 @@ pub mod harvestcircle_storage -pub mod harvestcircle_storage::db -pub struct harvestcircle_storage::db::Database -impl harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::authenticate_repair_candidate(&std::path::Path, &harvestcircle_storage::RepairAuthorization) -> core::result::Result<harvestcircle_storage::RepairCandidate, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::export_quarantined(&std::path::Path, &std::path::Path, &harvestcircle_storage::RepairAuthorization) -> core::result::Result<harvestcircle_storage::QuarantineExportReceipt, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::in_memory() -> core::result::Result<Self, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::install_repair_candidate(&std::path::Path, &harvestcircle_storage::RepairCandidate, &harvestcircle_storage::RepairAuthorization) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::open(&std::path::Path) -> core::result::Result<Self, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::preflight(&std::path::Path) -> core::result::Result<harvestcircle_storage::DatabasePreflight, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::restore_migration_backup(&std::path::Path, u32) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::schema_version(&self) -> core::result::Result<u32, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::verify_migration_backup(&std::path::Path, u32) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -impl harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::initialize_installation_id(&self, &str) -> core::result::Result<alloc::string::String, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::load_installation_id(&self) -> core::result::Result<core::option::Option<alloc::string::String>, harvestcircle_domain::error::SafeError> -impl harvestcircle_application::ports::AppStateRepository for harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::load_selected_identity(&self) -> core::result::Result<core::option::Option<harvestcircle_domain::key::PublicKey>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::save_selected_identity(&self, core::option::Option<harvestcircle_domain::key::PublicKey>) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -impl harvestcircle_application::ports::DurableOperationRepository for harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::advance_durable_operation(&self, &harvestcircle_application::ports::DurableRequestId, harvestcircle_application::ports::DurableOperationPhase, harvestcircle_application::ports::DurableOperationPhase, harvestcircle_domain::time::UnixTimestamp, core::option::Option<harvestcircle_application::ports::OperationDiagnostic>) -> core::result::Result<harvestcircle_application::ports::DurableIdentityOperation, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::begin_durable_operation(&self, &harvestcircle_application::ports::DurableRequestId, harvestcircle_application::ports::DurableOperationKind, harvestcircle_domain::key::PublicKey, core::option::Option<u64>, harvestcircle_application::ports::OperationPriorState, harvestcircle_domain::time::UnixTimestamp) -> core::result::Result<harvestcircle_application::ports::DurableOperationStart, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::finalize_durable_operation(&self, &harvestcircle_application::ports::DurableRequestId, harvestcircle_application::ports::DurableOperationPhase, harvestcircle_application::ports::DurableTerminalOutcome, core::option::Option<u64>, harvestcircle_domain::time::UnixTimestamp) -> core::result::Result<harvestcircle_application::ports::DurableOperationReceipt, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::list_unfinished_durable_operations(&self) -> core::result::Result<alloc::vec::Vec<harvestcircle_application::ports::DurableIdentityOperation>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::load_durable_operation(&self, &harvestcircle_application::ports::DurableRequestId) -> core::result::Result<core::option::Option<harvestcircle_application::ports::DurableIdentityOperation>, harvestcircle_domain::error::SafeError> -impl harvestcircle_application::ports::IdentityNamespaceRepository for harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::clear_owner(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::get_value(&self, harvestcircle_domain::key::PublicKey, harvestcircle_application::ports::IdentityPreferenceKey) -> core::result::Result<core::option::Option<alloc::string::String>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::set_value(&self, harvestcircle_domain::key::PublicKey, harvestcircle_application::ports::IdentityPreferenceKey, &str) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -impl harvestcircle_application::ports::IdentityRepository for harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::find_identity(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<core::option::Option<harvestcircle_domain::identity::NostrIdentity>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::insert_identity(&self, &harvestcircle_domain::identity::NostrIdentity) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::list_identities(&self) -> core::result::Result<alloc::vec::Vec<harvestcircle_domain::identity::NostrIdentity>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::remove_identity(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::update_identity(&self, &harvestcircle_domain::identity::NostrIdentity) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -impl harvestcircle_application::ports::OperationJournal for harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::begin_operation(&self, harvestcircle_application::ports::IdentityOperationKind, harvestcircle_domain::key::PublicKey, harvestcircle_domain::time::UnixTimestamp) -> core::result::Result<harvestcircle_application::ports::OperationId, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::finalize_operation(&self, harvestcircle_application::ports::OperationId) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::list_pending_operations(&self) -> core::result::Result<alloc::vec::Vec<harvestcircle_application::ports::PendingIdentityOperation>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::update_operation(&self, harvestcircle_application::ports::OperationId, harvestcircle_application::ports::IdentityOperationPhase, harvestcircle_domain::time::UnixTimestamp, core::option::Option<harvestcircle_application::ports::OperationDiagnostic>) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -impl harvestcircle_application::ports::ProfileRepository for harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::load_profile(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<core::option::Option<harvestcircle_application::ports::CachedProfile>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::record_refresh_status(&self, harvestcircle_domain::key::PublicKey, harvestcircle_domain::time::UnixTimestamp, harvestcircle_application::ports::ProfileRefreshStatus) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::remove_profile(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::save_profile(&self, &harvestcircle_application::ports::CachedProfile) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub const harvestcircle_storage::db::CURRENT_SCHEMA_VERSION: u32 -pub mod harvestcircle_storage::identities -pub mod harvestcircle_storage::identity_namespace -pub mod harvestcircle_storage::journal -pub mod harvestcircle_storage::os_keyring -pub struct harvestcircle_storage::os_keyring::OsKeyringSecretStore -impl harvestcircle_application::secrets::SecretStore for harvestcircle_storage::os_keyring::OsKeyringSecretStore -pub fn harvestcircle_storage::os_keyring::OsKeyringSecretStore::contains(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<bool, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::os_keyring::OsKeyringSecretStore::delete(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::os_keyring::OsKeyringSecretStore::load(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<harvestcircle_domain::key::SecretKeyInput, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::os_keyring::OsKeyringSecretStore::put(&self, harvestcircle_domain::key::PublicKey, harvestcircle_domain::key::SecretKeyInput) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub const harvestcircle_storage::os_keyring::CREDENTIAL_SERVICE: &str -pub mod harvestcircle_storage::profiles -pub enum harvestcircle_storage::DatabasePreflight -pub harvestcircle_storage::DatabasePreflight::Fresh -pub harvestcircle_storage::DatabasePreflight::Quarantined -pub harvestcircle_storage::DatabasePreflight::Quarantined::issues: alloc::vec::Vec<harvestcircle_storage::PersistedIdentityIssue> -pub harvestcircle_storage::DatabasePreflight::Quarantined::schema_version: u32 -pub harvestcircle_storage::DatabasePreflight::Ready -pub harvestcircle_storage::DatabasePreflight::Ready::schema_version: u32 pub enum harvestcircle_storage::HarvestCircleStorageContractError pub harvestcircle_storage::HarvestCircleStorageContractError::CanonicalPaths pub harvestcircle_storage::HarvestCircleStorageContractError::ContextIdentity @@ -71,54 +7,38 @@ pub harvestcircle_storage::HarvestCircleStorageContractError::SchemaCatalog impl core::error::Error for harvestcircle_storage::HarvestCircleStorageContractError impl core::fmt::Display for harvestcircle_storage::HarvestCircleStorageContractError pub fn harvestcircle_storage::HarvestCircleStorageContractError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -pub enum harvestcircle_storage::PersistedIdentityIssueKind -pub harvestcircle_storage::PersistedIdentityIssueKind::DisplayIdentityMismatch -pub harvestcircle_storage::PersistedIdentityIssueKind::InvalidCurvePoint -pub harvestcircle_storage::PersistedIdentityIssueKind::MalformedEncoding -pub harvestcircle_storage::PersistedIdentityIssueKind::NonCanonicalEncoding pub struct harvestcircle_storage::Database -impl harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::authenticate_repair_candidate(&std::path::Path, &harvestcircle_storage::RepairAuthorization) -> core::result::Result<harvestcircle_storage::RepairCandidate, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::export_quarantined(&std::path::Path, &std::path::Path, &harvestcircle_storage::RepairAuthorization) -> core::result::Result<harvestcircle_storage::QuarantineExportReceipt, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::in_memory() -> core::result::Result<Self, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::install_repair_candidate(&std::path::Path, &harvestcircle_storage::RepairCandidate, &harvestcircle_storage::RepairAuthorization) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::open(&std::path::Path) -> core::result::Result<Self, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::preflight(&std::path::Path) -> core::result::Result<harvestcircle_storage::DatabasePreflight, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::restore_migration_backup(&std::path::Path, u32) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::schema_version(&self) -> core::result::Result<u32, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::verify_migration_backup(&std::path::Path, u32) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -impl harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::initialize_installation_id(&self, &str) -> core::result::Result<alloc::string::String, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::load_installation_id(&self) -> core::result::Result<core::option::Option<alloc::string::String>, harvestcircle_domain::error::SafeError> -impl harvestcircle_application::ports::AppStateRepository for harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::load_selected_identity(&self) -> core::result::Result<core::option::Option<harvestcircle_domain::key::PublicKey>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::save_selected_identity(&self, core::option::Option<harvestcircle_domain::key::PublicKey>) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -impl harvestcircle_application::ports::DurableOperationRepository for harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::advance_durable_operation(&self, &harvestcircle_application::ports::DurableRequestId, harvestcircle_application::ports::DurableOperationPhase, harvestcircle_application::ports::DurableOperationPhase, harvestcircle_domain::time::UnixTimestamp, core::option::Option<harvestcircle_application::ports::OperationDiagnostic>) -> core::result::Result<harvestcircle_application::ports::DurableIdentityOperation, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::begin_durable_operation(&self, &harvestcircle_application::ports::DurableRequestId, harvestcircle_application::ports::DurableOperationKind, harvestcircle_domain::key::PublicKey, core::option::Option<u64>, harvestcircle_application::ports::OperationPriorState, harvestcircle_domain::time::UnixTimestamp) -> core::result::Result<harvestcircle_application::ports::DurableOperationStart, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::finalize_durable_operation(&self, &harvestcircle_application::ports::DurableRequestId, harvestcircle_application::ports::DurableOperationPhase, harvestcircle_application::ports::DurableTerminalOutcome, core::option::Option<u64>, harvestcircle_domain::time::UnixTimestamp) -> core::result::Result<harvestcircle_application::ports::DurableOperationReceipt, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::list_unfinished_durable_operations(&self) -> core::result::Result<alloc::vec::Vec<harvestcircle_application::ports::DurableIdentityOperation>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::load_durable_operation(&self, &harvestcircle_application::ports::DurableRequestId) -> core::result::Result<core::option::Option<harvestcircle_application::ports::DurableIdentityOperation>, harvestcircle_domain::error::SafeError> -impl harvestcircle_application::ports::IdentityNamespaceRepository for harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::clear_owner(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::get_value(&self, harvestcircle_domain::key::PublicKey, harvestcircle_application::ports::IdentityPreferenceKey) -> core::result::Result<core::option::Option<alloc::string::String>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::set_value(&self, harvestcircle_domain::key::PublicKey, harvestcircle_application::ports::IdentityPreferenceKey, &str) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -impl harvestcircle_application::ports::IdentityRepository for harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::find_identity(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<core::option::Option<harvestcircle_domain::identity::NostrIdentity>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::insert_identity(&self, &harvestcircle_domain::identity::NostrIdentity) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::list_identities(&self) -> core::result::Result<alloc::vec::Vec<harvestcircle_domain::identity::NostrIdentity>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::remove_identity(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::update_identity(&self, &harvestcircle_domain::identity::NostrIdentity) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -impl harvestcircle_application::ports::OperationJournal for harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::begin_operation(&self, harvestcircle_application::ports::IdentityOperationKind, harvestcircle_domain::key::PublicKey, harvestcircle_domain::time::UnixTimestamp) -> core::result::Result<harvestcircle_application::ports::OperationId, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::finalize_operation(&self, harvestcircle_application::ports::OperationId) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::list_pending_operations(&self) -> core::result::Result<alloc::vec::Vec<harvestcircle_application::ports::PendingIdentityOperation>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::update_operation(&self, harvestcircle_application::ports::OperationId, harvestcircle_application::ports::IdentityOperationPhase, harvestcircle_domain::time::UnixTimestamp, core::option::Option<harvestcircle_application::ports::OperationDiagnostic>) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -impl harvestcircle_application::ports::ProfileRepository for harvestcircle_storage::db::Database -pub fn harvestcircle_storage::db::Database::load_profile(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<core::option::Option<harvestcircle_application::ports::CachedProfile>, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::record_refresh_status(&self, harvestcircle_domain::key::PublicKey, harvestcircle_domain::time::UnixTimestamp, harvestcircle_application::ports::ProfileRefreshStatus) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::remove_profile(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::db::Database::save_profile(&self, &harvestcircle_application::ports::CachedProfile) -> core::result::Result<(), harvestcircle_domain::error::SafeError> +impl harvestcircle_storage::Database +pub async fn harvestcircle_storage::Database::close(&self) -> core::result::Result<(), harvestcircle_domain::error::SafeError> +pub const fn harvestcircle_storage::Database::metadata(&self) -> &radroots_service_sqlite::metadata::ServiceDatabaseMetadata +pub async fn harvestcircle_storage::Database::open(&radroots_runtime_paths::context::RuntimeContext, u64, u64, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<Self, harvestcircle_domain::error::SafeError> +impl harvestcircle_storage::Database +pub async fn harvestcircle_storage::Database::initialize_installation_id(&self, &str) -> core::result::Result<alloc::string::String, harvestcircle_domain::error::SafeError> +pub async fn harvestcircle_storage::Database::load_installation_id(&self) -> core::result::Result<core::option::Option<alloc::string::String>, harvestcircle_domain::error::SafeError> +impl harvestcircle_application::ports::AppStateRepository for harvestcircle_storage::Database +pub fn harvestcircle_storage::Database::load_selected_identity(&self) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<core::option::Option<harvestcircle_domain::key::PublicKey>, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::save_selected_identity(&self, core::option::Option<harvestcircle_domain::key::PublicKey>) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<(), harvestcircle_domain::error::SafeError>> +impl harvestcircle_application::ports::DurableOperationRepository for harvestcircle_storage::Database +pub fn harvestcircle_storage::Database::advance_durable_operation<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_application::ports::DurableOperationPhase, harvestcircle_application::ports::DurableOperationPhase, harvestcircle_domain::time::UnixTimestamp, core::option::Option<harvestcircle_application::ports::OperationDiagnostic>) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<harvestcircle_application::ports::DurableIdentityOperation, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::begin_durable_operation<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_application::ports::DurableOperationKind, harvestcircle_domain::key::PublicKey, core::option::Option<u64>, harvestcircle_application::ports::OperationPriorState, harvestcircle_domain::time::UnixTimestamp) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<harvestcircle_application::ports::DurableOperationStart, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::finalize_durable_operation<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_application::ports::DurableOperationPhase, harvestcircle_application::ports::DurableTerminalOutcome, core::option::Option<u64>, harvestcircle_domain::time::UnixTimestamp) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<harvestcircle_application::ports::DurableOperationReceipt, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::list_unfinished_durable_operations(&self) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<alloc::vec::Vec<harvestcircle_application::ports::DurableIdentityOperation>, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::load_durable_operation<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<core::option::Option<harvestcircle_application::ports::DurableIdentityOperation>, harvestcircle_domain::error::SafeError>> +impl harvestcircle_application::ports::IdentityNamespaceRepository for harvestcircle_storage::Database +pub fn harvestcircle_storage::Database::clear_owner(&self, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::get_value<'a>(&'a self, harvestcircle_domain::key::PublicKey, harvestcircle_application::ports::IdentityPreferenceKey) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<core::option::Option<alloc::string::String>, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::set_value<'a>(&'a self, harvestcircle_domain::key::PublicKey, harvestcircle_application::ports::IdentityPreferenceKey, &'a str) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<(), harvestcircle_domain::error::SafeError>> +impl harvestcircle_application::ports::IdentityRepository for harvestcircle_storage::Database +pub fn harvestcircle_storage::Database::find_identity(&self, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<core::option::Option<harvestcircle_domain::identity::NostrIdentity>, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::insert_identity<'a>(&'a self, &'a harvestcircle_domain::identity::NostrIdentity) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::list_identities(&self) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<alloc::vec::Vec<harvestcircle_domain::identity::NostrIdentity>, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::remove_identity(&self, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::update_identity<'a>(&'a self, &'a harvestcircle_domain::identity::NostrIdentity) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<(), harvestcircle_domain::error::SafeError>> +impl harvestcircle_application::ports::ProfileRepository for harvestcircle_storage::Database +pub fn harvestcircle_storage::Database::load_profile(&self, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<core::option::Option<harvestcircle_application::ports::CachedProfile>, harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::record_refresh_status<'a>(&'a self, harvestcircle_domain::key::PublicKey, harvestcircle_domain::time::UnixTimestamp, harvestcircle_application::ports::ProfileRefreshStatus) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::remove_profile(&self, harvestcircle_domain::key::PublicKey) -> harvestcircle_application::ports::BoxFuture<'_, core::result::Result<(), harvestcircle_domain::error::SafeError>> +pub fn harvestcircle_storage::Database::save_profile<'a>(&'a self, &'a harvestcircle_application::ports::CachedProfile) -> harvestcircle_application::ports::BoxFuture<'a, core::result::Result<(), harvestcircle_domain::error::SafeError>> pub struct harvestcircle_storage::HarvestCircleStorageContract impl harvestcircle_storage::HarvestCircleStorageContract pub fn harvestcircle_storage::HarvestCircleStorageContract::application_id(&self) -> radroots_service_sqlite::metadata::ServiceSqliteApplicationId @@ -130,29 +50,11 @@ pub const fn harvestcircle_storage::HarvestCircleStorageContract::state_schema_v impl core::fmt::Debug for harvestcircle_storage::HarvestCircleStorageContract pub fn harvestcircle_storage::HarvestCircleStorageContract::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct harvestcircle_storage::OsKeyringSecretStore -impl harvestcircle_application::secrets::SecretStore for harvestcircle_storage::os_keyring::OsKeyringSecretStore -pub fn harvestcircle_storage::os_keyring::OsKeyringSecretStore::contains(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<bool, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::os_keyring::OsKeyringSecretStore::delete(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::os_keyring::OsKeyringSecretStore::load(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<harvestcircle_domain::key::SecretKeyInput, harvestcircle_domain::error::SafeError> -pub fn harvestcircle_storage::os_keyring::OsKeyringSecretStore::put(&self, harvestcircle_domain::key::PublicKey, harvestcircle_domain::key::SecretKeyInput) -> core::result::Result<(), harvestcircle_domain::error::SafeError> -pub struct harvestcircle_storage::PersistedIdentityIssue -impl harvestcircle_storage::PersistedIdentityIssue -pub const fn harvestcircle_storage::PersistedIdentityIssue::column(&self) -> &'static str -pub const fn harvestcircle_storage::PersistedIdentityIssue::fingerprint(&self) -> &[u8; 32] -pub const fn harvestcircle_storage::PersistedIdentityIssue::kind(&self) -> harvestcircle_storage::PersistedIdentityIssueKind -pub const fn harvestcircle_storage::PersistedIdentityIssue::row_id(&self) -> i64 -pub const fn harvestcircle_storage::PersistedIdentityIssue::table(&self) -> &'static str -pub struct harvestcircle_storage::QuarantineExportReceipt -impl harvestcircle_storage::QuarantineExportReceipt -pub fn harvestcircle_storage::QuarantineExportReceipt::authentication_tag(&self) -> &str -pub fn harvestcircle_storage::QuarantineExportReceipt::path(&self) -> &std::path::Path -pub fn harvestcircle_storage::QuarantineExportReceipt::sha256(&self) -> &str -pub struct harvestcircle_storage::RepairAuthorization(_) -impl harvestcircle_storage::RepairAuthorization -pub fn harvestcircle_storage::RepairAuthorization::from_bytes(alloc::vec::Vec<u8>) -> core::result::Result<Self, harvestcircle_domain::error::SafeError> -pub struct harvestcircle_storage::RepairCandidate -impl harvestcircle_storage::RepairCandidate -pub fn harvestcircle_storage::RepairCandidate::path(&self) -> &std::path::Path +impl harvestcircle_application::secrets::SecretStore for harvestcircle_storage::OsKeyringSecretStore +pub fn harvestcircle_storage::OsKeyringSecretStore::contains(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<bool, harvestcircle_domain::error::SafeError> +pub fn harvestcircle_storage::OsKeyringSecretStore::delete(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<(), harvestcircle_domain::error::SafeError> +pub fn harvestcircle_storage::OsKeyringSecretStore::load(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<harvestcircle_domain::key::SecretKeyInput, harvestcircle_domain::error::SafeError> +pub fn harvestcircle_storage::OsKeyringSecretStore::put(&self, harvestcircle_domain::key::PublicKey, harvestcircle_domain::key::SecretKeyInput) -> core::result::Result<(), harvestcircle_domain::error::SafeError> pub const harvestcircle_storage::CREDENTIAL_SERVICE: &str pub const harvestcircle_storage::CURRENT_SCHEMA_VERSION: u32 pub const harvestcircle_storage::HARVESTCIRCLE_ACTOR_MAILBOX_CAPACITY: usize @@ -170,6 +72,5 @@ pub const harvestcircle_storage::HARVESTCIRCLE_RELAY_URL_UTF8_BYTES: usize pub const harvestcircle_storage::HARVESTCIRCLE_SERVICE_ID: &str pub const harvestcircle_storage::HARVESTCIRCLE_STATE_SCHEMA_VERSION: u32 pub const harvestcircle_storage::HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY: usize -pub const fn harvestcircle_storage::harvestcircle_initial_schema_sql() -> &'static [&'static str] pub fn harvestcircle_storage::harvestcircle_migration_catalog() -> core::result::Result<radroots_service_sqlite::migration::MigrationCatalog, harvestcircle_storage::HarvestCircleStorageContractError> pub fn harvestcircle_storage::harvestcircle_schema_catalog() -> core::result::Result<radroots_service_sqlite::integrity::catalog::SchemaCatalog, harvestcircle_storage::HarvestCircleStorageContractError> diff --git a/core/crates/harvestcircle_application/src/app_core.rs b/core/crates/harvestcircle_application/src/app_core.rs @@ -118,16 +118,17 @@ impl AppCore { /// /// Returns the safe persistence error after publishing a fatal snapshot when /// durable state cannot be read or violates application invariants. - pub fn bootstrap_from( + pub async fn bootstrap_from( &self, identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), ) -> Result<AppSnapshot, SafeError> { - let loaded = identities.list_identities().and_then(|identities| { - app_state - .load_selected_identity() - .map(|selected| (identities, selected)) - }); + let loaded = async { + let identities = identities.list_identities().await?; + let selected = app_state.load_selected_identity().await?; + Ok::<_, SafeError>((identities, selected)) + } + .await; match loaded { Ok((identities, selected)) => { self.apply_transition(StateTransition::BootstrapRegistry { @@ -277,8 +278,8 @@ mod tests { use crate::{AppCore, AppLifecycle, RelayConfiguration, StateTransition}; - #[test] - fn bootstrap_is_idempotent_and_advances_only_once() { + #[tokio::test] + async 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"); @@ -288,8 +289,8 @@ mod tests { assert_eq!(repeated, ready); } - #[test] - fn core_instances_never_share_state() { + #[tokio::test] + async fn core_instances_never_share_state() { let first = AppCore::in_memory(RelayConfiguration::default()); let second = AppCore::in_memory(RelayConfiguration::default()); @@ -299,8 +300,8 @@ mod tests { assert_eq!(second.snapshot().revision().value(), 0); } - #[test] - fn removal_impact_matches_missing_local_binding() { + #[tokio::test] + async 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 identity = NostrIdentity::new( @@ -325,8 +326,8 @@ mod tests { assert!(!removal.impact().signs_out()); } - #[test] - fn removal_confirmation_rejects_every_tampered_authority_field() { + #[tokio::test] + async 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"); diff --git a/core/crates/harvestcircle_application/src/identities.rs b/core/crates/harvestcircle_application/src/identities.rs @@ -1,11 +1,15 @@ use std::sync::{Mutex, MutexGuard}; use crate::{ - AppCore, AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase, + AppCore, AppStateRepository, BoxFuture, Clock, DurableOperationKind, DurableOperationPhase, DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome, - IdentityOperationKind, IdentityOperationPhase, IdentityRepository, OperationDiagnostic, - OperationId, OperationJournal, OperationPriorState, PendingIdentityOperation, - RemovalConfirmationToken, SecretStore, StagedGeneratedKey, StateTransition, + IdentityRepository, OperationPriorState, RemovalConfirmationToken, SecretStore, + StagedGeneratedKey, StateTransition, +}; +#[cfg(test)] +use crate::{ + IdentityOperationKind, IdentityOperationPhase, OperationDiagnostic, OperationId, + OperationJournal, PendingIdentityOperation, }; use harvestcircle_domain::{ IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, Nsec, PublicKey, @@ -48,7 +52,7 @@ impl AppCore { /// /// Returns a safe conflict, keyring, persistence, or recovery error. #[allow(clippy::too_many_arguments)] - pub fn commit_staged_generated_key( + pub async fn commit_staged_generated_key( &self, request_id: &DurableRequestId, staged: StagedGeneratedKey, @@ -73,7 +77,8 @@ impl AppCore { secrets, operations, clock, - )?; + ) + .await?; Ok(ImportIdentityReceipt { identity }) } @@ -84,7 +89,7 @@ impl AppCore { /// 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_identity_durable( + pub async fn generate_identity_durable( &self, request_id: &DurableRequestId, expected_revision: u64, @@ -116,7 +121,8 @@ impl AppCore { secrets, operations, clock, - )?; + ) + .await?; Ok(GenerateIdentityReceipt { identity, generated_nsec: nsec, @@ -129,7 +135,7 @@ impl AppCore { /// /// Returns a safe conflict, validation, keyring, persistence, or state error. #[allow(clippy::too_many_arguments)] - pub fn import_secret_key_durable( + pub async fn import_secret_key_durable( &self, request_id: &DurableRequestId, expected_revision: u64, @@ -140,13 +146,14 @@ impl AppCore { operations: &(impl DurableOperationRepository + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<ImportIdentityReceipt, SafeError> { - if let Some(existing) = operations.load_durable_operation(request_id)? { + if let Some(existing) = operations.load_durable_operation(request_id).await? { return if existing .terminal() .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) { identities - .find_identity(existing.identity())? + .find_identity(existing.identity()) + .await? .map(|identity| ImportIdentityReceipt { identity }) .ok_or_else(recovery_required) } else { @@ -156,7 +163,7 @@ impl AppCore { self.require_revision(expected_revision)?; let imported = self.key_material().import(input)?; let (public_key, npub, secret) = imported.into_parts(); - let previous = identities.find_identity(public_key)?; + let previous = identities.find_identity(public_key).await?; if let Some(existing) = &previous && (local_keyring_binding(existing)?.availability() != SignerAvailability::CredentialMissing @@ -197,7 +204,8 @@ impl AppCore { secrets, operations, clock, - )?; + ) + .await?; Ok(ImportIdentityReceipt { identity }) } @@ -209,7 +217,7 @@ impl AppCore { } #[allow(clippy::too_many_arguments)] - fn persist_identity_durable( + async fn persist_identity_durable( &self, request_id: &DurableRequestId, kind: DurableOperationKind, @@ -227,16 +235,21 @@ impl AppCore { .map(local_keyring_binding) .transpose()? .map(LocalKeyringBinding::availability); - let prior = - OperationPriorState::new(app_state.load_selected_identity()?, prior_availability); - match operations.begin_durable_operation( - request_id, - kind, - identity.public_key(), - Some(expected_revision), - prior, - clock.now(), - )? { + let prior = OperationPriorState::new( + app_state.load_selected_identity().await?, + prior_availability, + ); + match operations + .begin_durable_operation( + request_id, + kind, + identity.public_key(), + Some(expected_revision), + prior, + clock.now(), + ) + .await? + { DurableOperationStart::Started(_) => {} DurableOperationStart::Existing(operation) => { return if operation @@ -250,43 +263,54 @@ impl AppCore { } } secrets.put(identity.public_key(), secret)?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialWritten, - clock.now(), - None, - )?; - previous.map_or_else( - || identities.insert_identity(identity), - |_| identities.update_identity(identity), - )?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::CredentialWritten, - DurableOperationPhase::MetadataCommitted, - clock.now(), - None, - )?; - app_state.save_selected_identity(Some(identity.public_key()))?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::MetadataCommitted, - DurableOperationPhase::SelectionCommitted, - clock.now(), - None, - )?; + operations + .advance_durable_operation( + request_id, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialWritten, + clock.now(), + None, + ) + .await?; + if previous.is_some() { + identities.update_identity(identity).await?; + } else { + identities.insert_identity(identity).await?; + } + operations + .advance_durable_operation( + request_id, + DurableOperationPhase::CredentialWritten, + DurableOperationPhase::MetadataCommitted, + clock.now(), + None, + ) + .await?; + app_state + .save_selected_identity(Some(identity.public_key())) + .await?; + operations + .advance_durable_operation( + request_id, + DurableOperationPhase::MetadataCommitted, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + ) + .await?; let snapshot = self.apply_transition(StateTransition::ReplaceRegistry { - identities: identities.list_identities()?, + identities: identities.list_identities().await?, selected: Some(identity.public_key()), })?; - operations.finalize_durable_operation( - request_id, - DurableOperationPhase::SelectionCommitted, - DurableTerminalOutcome::Completed, - Some(snapshot.revision().value()), - clock.now(), - )?; + operations + .finalize_durable_operation( + request_id, + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + Some(snapshot.revision().value()), + clock.now(), + ) + .await?; Ok(()) } @@ -312,7 +336,8 @@ impl AppCore { /// # Errors /// /// Returns a safe confirmation, credential, persistence, recovery, or state error. - pub fn confirm_identity_removal( + #[cfg(test)] + pub async fn confirm_identity_removal( &self, token: RemovalConfirmationToken, identities: &(impl IdentityRepository + ?Sized), @@ -322,7 +347,7 @@ impl AppCore { clock: &(impl Clock + ?Sized), ) -> Result<crate::AppSnapshot, SafeError> { let public_key = self.consume_removal_token(token, clock.now())?; - let registry = identities.list_identities()?; + let registry = identities.list_identities().await?; let index = registry .iter() .position(|identity| identity.public_key() == public_key) @@ -335,8 +360,9 @@ impl AppCore { } else { self.snapshot().selected_identity() }; - let operation = - journal.begin_operation(IdentityOperationKind::Remove, public_key, clock.now())?; + let operation = journal + .begin_operation(IdentityOperationKind::Remove, public_key, clock.now()) + .await?; let was_active = self .snapshot() .active_identity() @@ -353,23 +379,27 @@ impl AppCore { && local_keyring.availability() == SignerAvailability::CredentialMissing => {} Err(error) => return Err(error), } - journal.update_operation( - operation, - IdentityOperationPhase::CredentialDeleted, - clock.now(), - None, - )?; - identities.remove_identity(public_key)?; - app_state.save_selected_identity(selected)?; - journal.update_operation( - operation, - IdentityOperationPhase::MetadataDeleted, - clock.now(), - None, - )?; - journal.finalize_operation(operation)?; + journal + .update_operation( + operation, + IdentityOperationPhase::CredentialDeleted, + clock.now(), + None, + ) + .await?; + identities.remove_identity(public_key).await?; + app_state.save_selected_identity(selected).await?; + journal + .update_operation( + operation, + IdentityOperationPhase::MetadataDeleted, + clock.now(), + None, + ) + .await?; + journal.finalize_operation(operation).await?; self.apply_transition(StateTransition::ReplaceRegistryPreservingSession { - identities: identities.list_identities()?, + identities: identities.list_identities().await?, selected, }) } @@ -380,7 +410,7 @@ impl AppCore { /// /// Returns a safe expiry, conflict, credential, persistence, or recovery error. #[allow(clippy::too_many_arguments)] - pub fn confirm_identity_removal_durable( + pub async fn confirm_identity_removal_durable( &self, request_id: &DurableRequestId, token: RemovalConfirmationToken, @@ -393,7 +423,7 @@ impl AppCore { let expected_revision = token.revision().value(); let public_key = self.consume_removal_token(token, clock.now())?; self.require_revision(expected_revision)?; - let registry = identities.list_identities()?; + let registry = identities.list_identities().await?; let index = registry .iter() .position(|identity| identity.public_key() == public_key) @@ -408,14 +438,17 @@ impl AppCore { }; let identity = &registry[index]; let local_keyring = local_keyring_binding(identity)?; - match operations.begin_durable_operation( - request_id, - DurableOperationKind::Remove, - public_key, - Some(expected_revision), - OperationPriorState::new(selected, Some(local_keyring.availability())), - clock.now(), - )? { + match operations + .begin_durable_operation( + request_id, + DurableOperationKind::Remove, + public_key, + Some(expected_revision), + OperationPriorState::new(selected, Some(local_keyring.availability())), + clock.now(), + ) + .await? + { DurableOperationStart::Started(_) => {} DurableOperationStart::Existing(operation) => { return if operation @@ -442,41 +475,49 @@ impl AppCore { && local_keyring.availability() == SignerAvailability::CredentialMissing => {} Err(error) => return Err(error), } - operations.advance_durable_operation( - request_id, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialDeleted, - clock.now(), - None, - )?; - identities.remove_identity(public_key)?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::CredentialDeleted, - DurableOperationPhase::MetadataDeleted, - clock.now(), - None, - )?; - app_state.save_selected_identity(selected)?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::MetadataDeleted, - DurableOperationPhase::SelectionCommitted, - clock.now(), - None, - )?; + operations + .advance_durable_operation( + request_id, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialDeleted, + clock.now(), + None, + ) + .await?; + identities.remove_identity(public_key).await?; + operations + .advance_durable_operation( + request_id, + DurableOperationPhase::CredentialDeleted, + DurableOperationPhase::MetadataDeleted, + clock.now(), + None, + ) + .await?; + app_state.save_selected_identity(selected).await?; + operations + .advance_durable_operation( + request_id, + DurableOperationPhase::MetadataDeleted, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + ) + .await?; let snapshot = self.apply_transition(StateTransition::ReplaceRegistryPreservingSession { - identities: identities.list_identities()?, + identities: identities.list_identities().await?, selected, })?; - operations.finalize_durable_operation( - request_id, - DurableOperationPhase::SelectionCommitted, - DurableTerminalOutcome::Completed, - Some(snapshot.revision().value()), - clock.now(), - )?; + operations + .finalize_durable_operation( + request_id, + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + Some(snapshot.revision().value()), + clock.now(), + ) + .await?; Ok(snapshot) } @@ -485,16 +526,16 @@ impl AppCore { /// # Errors /// /// Returns a safe identity, persistence, or application-state error. - pub fn select_identity( + pub async fn select_identity( &self, public_key: PublicKey, identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), ) -> Result<crate::AppSnapshot, SafeError> { - if identities.find_identity(public_key)?.is_none() { + if identities.find_identity(public_key).await?.is_none() { return Err(identity_not_found()); } - app_state.save_selected_identity(Some(public_key))?; + app_state.save_selected_identity(Some(public_key)).await?; self.apply_transition(StateTransition::Select(public_key)) } @@ -503,7 +544,8 @@ impl AppCore { /// # Errors /// /// Returns a safe key, credential, persistence, or application-state error. - pub fn generate_identity( + #[cfg(test)] + pub async fn generate_identity( &self, identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), @@ -530,8 +572,9 @@ impl AppCore { secrets, journal, clock, - )?; - let registry = identities.list_identities()?; + ) + .await?; + let registry = identities.list_identities().await?; self.apply_transition(StateTransition::ReplaceRegistry { identities: registry, selected: Some(public_key), @@ -547,7 +590,8 @@ impl AppCore { /// # Errors /// /// Returns a safe key, credential, persistence, or application-state error. - pub fn import_secret_key( + #[cfg(test)] + pub async fn import_secret_key( &self, input: SecretKeyInput, identities: &(impl IdentityRepository + ?Sized), @@ -558,7 +602,7 @@ impl AppCore { ) -> Result<ImportIdentityReceipt, SafeError> { let imported = self.key_material().import(input)?; let (public_key, npub, secret) = imported.into_parts(); - if let Some(existing) = identities.find_identity(public_key)? { + if let Some(existing) = identities.find_identity(public_key).await? { if local_keyring_binding(&existing)?.availability() != SignerAvailability::CredentialMissing || secrets.contains(public_key)? @@ -578,9 +622,10 @@ impl AppCore { secrets, journal, clock, - )?; + ) + .await?; self.apply_transition(StateTransition::ReplaceRegistry { - identities: identities.list_identities()?, + identities: identities.list_identities().await?, selected: Some(public_key), })?; return Ok(ImportIdentityReceipt { identity: repaired }); @@ -605,16 +650,18 @@ impl AppCore { secrets, journal, clock, - )?; + ) + .await?; self.apply_transition(StateTransition::ReplaceRegistry { - identities: identities.list_identities()?, + identities: identities.list_identities().await?, selected: Some(public_key), })?; Ok(ImportIdentityReceipt { identity }) } + #[cfg(test)] #[allow(clippy::too_many_arguments)] - fn persist_identity_transaction( + async fn persist_identity_transaction( kind: IdentityOperationKind, identity: &NostrIdentity, secret: SecretKeyInput, @@ -626,18 +673,23 @@ impl AppCore { clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { let public_key = identity.public_key(); - let previous_selection = app_state.load_selected_identity()?; - let operation = journal.begin_operation(kind, public_key, clock.now())?; + let previous_selection = app_state.load_selected_identity().await?; + let operation = journal + .begin_operation(kind, public_key, clock.now()) + .await?; if let Err(error) = secrets.put(public_key, secret) { - let _ = journal.finalize_operation(operation); + let _ = journal.finalize_operation(operation).await; return Err(error); } - if let Err(error) = journal.update_operation( - operation, - IdentityOperationPhase::CredentialWritten, - clock.now(), - None, - ) { + if let Err(error) = journal + .update_operation( + operation, + IdentityOperationPhase::CredentialWritten, + clock.now(), + None, + ) + .await + { return compensate_identity_write( operation, public_key, @@ -649,12 +701,14 @@ impl AppCore { secrets, journal, clock, - ); + ) + .await; } - let metadata_result = previous.map_or_else( - || identities.insert_identity(identity), - |_| identities.update_identity(identity), - ); + let metadata_result = if previous.is_some() { + identities.update_identity(identity).await + } else { + identities.insert_identity(identity).await + }; if let Err(error) = metadata_result { return compensate_identity_write( operation, @@ -667,9 +721,10 @@ impl AppCore { secrets, journal, clock, - ); + ) + .await; } - if let Err(error) = app_state.save_selected_identity(Some(public_key)) { + if let Err(error) = app_state.save_selected_identity(Some(public_key)).await { return compensate_identity_write( operation, public_key, @@ -681,20 +736,24 @@ impl AppCore { secrets, journal, clock, - ); + ) + .await; } - journal.update_operation( - operation, - IdentityOperationPhase::MetadataCommitted, - clock.now(), - None, - )?; - journal.finalize_operation(operation) + journal + .update_operation( + operation, + IdentityOperationPhase::MetadataCommitted, + clock.now(), + None, + ) + .await?; + journal.finalize_operation(operation).await } } +#[cfg(test)] #[allow(clippy::too_many_arguments)] -fn compensate_identity_write( +async fn compensate_identity_write( operation: OperationId, public_key: PublicKey, original_error: SafeError, @@ -707,103 +766,112 @@ fn compensate_identity_write( clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { let metadata_rollback = if let Some(previous) = previous { - identities.update_identity(previous) + identities.update_identity(previous).await } else { - identities.remove_identity(public_key) + identities.remove_identity(public_key).await }; - let selection_rollback = app_state.save_selected_identity(previous_selection); + let selection_rollback = app_state.save_selected_identity(previous_selection).await; 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, - IdentityOperationPhase::CompensationPending, - clock.now(), - Some(OperationDiagnostic::CompensationFailed), - ); + let _ = journal + .update_operation( + operation, + IdentityOperationPhase::CompensationPending, + clock.now(), + Some(OperationDiagnostic::CompensationFailed), + ) + .await; return Err(recovery_required()); } - let _ = journal.finalize_operation(operation); + let _ = journal.finalize_operation(operation).await; Err(original_error) } +#[cfg(test)] #[derive(Default)] pub struct InMemoryOperationJournal { state: Mutex<InMemoryJournalState>, } +#[cfg(test)] #[derive(Default)] struct InMemoryJournalState { next_id: u64, pending: Vec<PendingIdentityOperation>, } +#[cfg(test)] impl OperationJournal for InMemoryOperationJournal { - fn begin_operation( - &self, + fn begin_operation<'a>( + &'a self, kind: IdentityOperationKind, subject: PublicKey, updated_at: harvestcircle_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(PendingIdentityOperation::new( - id, - kind, - subject, - IdentityOperationPhase::IntentRecorded, - updated_at, - None, - )); - Ok(id) + ) -> BoxFuture<'a, Result<OperationId, SafeError>> { + Box::pin(async move { + let mut state = self.state.lock().map_err(|_| recovery_required())?; + 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(PendingIdentityOperation::new( + id, + kind, + subject, + IdentityOperationPhase::IntentRecorded, + updated_at, + None, + )); + Ok(id) + }) } - fn update_operation( - &self, + fn update_operation<'a>( + &'a self, id: OperationId, phase: IdentityOperationPhase, updated_at: harvestcircle_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 = PendingIdentityOperation::new( - id, - operation.kind(), - operation.subject(), - phase, - updated_at, - diagnostic, - ); - Ok(()) + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + let mut state = self.state.lock().map_err(|_| recovery_required())?; + let operation = state + .pending + .iter_mut() + .find(|operation| operation.id() == id) + .ok_or_else(recovery_required)?; + *operation = PendingIdentityOperation::new( + id, + operation.kind(), + operation.subject(), + phase, + updated_at, + diagnostic, + ); + Ok(()) + }) } - fn list_pending_operations(&self) -> Result<Vec<PendingIdentityOperation>, SafeError> { - Ok(self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .pending - .clone()) + fn list_pending_operations( + &self, + ) -> BoxFuture<'_, Result<Vec<PendingIdentityOperation>, SafeError>> { + Box::pin(async move { + Ok(self + .state + .lock() + .map_err(|_| recovery_required())? + .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(()) + fn finalize_operation(&self, id: OperationId) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async move { + self.state + .lock() + .map_err(|_| recovery_required())? + .pending + .retain(|operation| operation.id() != id); + Ok(()) + }) } } @@ -819,83 +887,103 @@ struct InMemoryIdentityState { } impl InMemoryIdentityRepository { - fn state(&self) -> MutexGuard<'_, InMemoryIdentityState> { - self.state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + fn state(&self) -> Result<MutexGuard<'_, InMemoryIdentityState>, SafeError> { + self.state.lock().map_err(|_| recovery_required()) } } impl IdentityRepository for InMemoryIdentityRepository { - fn list_identities(&self) -> Result<Vec<NostrIdentity>, SafeError> { - Ok(self.state().identities.clone()) + fn list_identities(&self) -> BoxFuture<'_, Result<Vec<NostrIdentity>, SafeError>> { + Box::pin(async move { Ok(self.state()?.identities.clone()) }) } - fn find_identity(&self, public_key: PublicKey) -> Result<Option<NostrIdentity>, SafeError> { - Ok(self - .state() - .identities - .iter() - .find(|identity| identity.public_key() == public_key) - .cloned()) + fn find_identity( + &self, + public_key: PublicKey, + ) -> BoxFuture<'_, Result<Option<NostrIdentity>, SafeError>> { + Box::pin(async move { + Ok(self + .state()? + .identities + .iter() + .find(|identity| identity.public_key() == public_key) + .cloned()) + }) } - fn insert_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { - let mut state = self.state(); - if state - .identities - .iter() - .any(|saved| saved.public_key() == identity.public_key()) - { - return Err(identity_exists()); - } - state.identities.push(identity.clone()); - state - .identities - .sort_by_key(|saved| (saved.created_at().timestamp(), saved.public_key())); - Ok(()) + fn insert_identity<'a>( + &'a self, + identity: &'a NostrIdentity, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + let mut state = self.state()?; + if state + .identities + .iter() + .any(|saved| saved.public_key() == identity.public_key()) + { + return Err(identity_exists()); + } + state.identities.push(identity.clone()); + state + .identities + .sort_by_key(|saved| (saved.created_at().timestamp(), saved.public_key())); + Ok(()) + }) } - fn update_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { - let mut state = self.state(); - let saved = state - .identities - .iter_mut() - .find(|saved| saved.public_key() == identity.public_key()) - .ok_or_else(identity_not_found)?; - *saved = identity.clone(); - Ok(()) + fn update_identity<'a>( + &'a self, + identity: &'a NostrIdentity, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + let mut state = self.state()?; + let saved = state + .identities + .iter_mut() + .find(|saved| saved.public_key() == identity.public_key()) + .ok_or_else(identity_not_found)?; + *saved = identity.clone(); + Ok(()) + }) } - fn remove_identity(&self, public_key: PublicKey) -> Result<(), SafeError> { - let mut state = self.state(); - state - .identities - .retain(|identity| identity.public_key() != public_key); - if state.selected == Some(public_key) { - state.selected = None; - } - Ok(()) + fn remove_identity(&self, public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async move { + let mut state = self.state()?; + state + .identities + .retain(|identity| identity.public_key() != public_key); + if state.selected == Some(public_key) { + state.selected = None; + } + Ok(()) + }) } } impl AppStateRepository for InMemoryIdentityRepository { - fn load_selected_identity(&self) -> Result<Option<PublicKey>, SafeError> { - Ok(self.state().selected) + fn load_selected_identity(&self) -> BoxFuture<'_, Result<Option<PublicKey>, SafeError>> { + Box::pin(async move { Ok(self.state()?.selected) }) } - fn save_selected_identity(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { - let mut state = self.state(); - if public_key.is_some_and(|key| { - !state - .identities - .iter() - .any(|identity| identity.public_key() == key) - }) { - return Err(identity_not_found()); - } - state.selected = public_key; - Ok(()) + fn save_selected_identity( + &self, + public_key: Option<PublicKey>, + ) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async move { + let mut state = self.state()?; + if public_key.is_some_and(|key| { + !state + .identities + .iter() + .any(|identity| identity.public_key() == key) + }) { + return Err(identity_not_found()); + } + state.selected = public_key; + Ok(()) + }) } } @@ -945,7 +1033,7 @@ mod tests { use super::InMemoryIdentityRepository; use crate::{ - AppCore, AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase, + AppCore, AppStateRepository, BoxFuture, Clock, DurableOperationKind, DurableOperationPhase, FailureSecretStore, IdentityOperationPhase, IdentityRepository, InMemoryOperationJournal, InMemorySecretStore, OperationJournal, ProfileRefreshStatus, ProfileRepository, RelayConfiguration, SecretStore, SecretStoreOperation, SessionState, StateTransition, @@ -974,25 +1062,28 @@ mod tests { fn load_profile( &self, _public_key: PublicKey, - ) -> Result<Option<crate::CachedProfile>, SafeError> { - Ok(None) + ) -> BoxFuture<'_, Result<Option<crate::CachedProfile>, SafeError>> { + Box::pin(async { Ok(None) }) } - fn save_profile(&self, _profile: &crate::CachedProfile) -> Result<(), SafeError> { - Ok(()) + fn save_profile<'a>( + &'a self, + _profile: &'a crate::CachedProfile, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } - fn record_refresh_status( - &self, + fn record_refresh_status<'a>( + &'a self, _public_key: PublicKey, _refreshed_at: UnixTimestamp, _status: ProfileRefreshStatus, - ) -> Result<(), SafeError> { - Ok(()) + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } - fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { - Ok(()) + fn remove_profile(&self, _public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } } @@ -1000,35 +1091,40 @@ mod tests { struct FailingUpdateJournal(InMemoryOperationJournal); impl OperationJournal for FailingUpdateJournal { - fn begin_operation( - &self, + fn begin_operation<'a>( + &'a self, kind: crate::IdentityOperationKind, subject: PublicKey, updated_at: UnixTimestamp, - ) -> Result<crate::OperationId, SafeError> { + ) -> BoxFuture<'a, Result<crate::OperationId, SafeError>> { self.0.begin_operation(kind, subject, updated_at) } - fn update_operation( - &self, + fn update_operation<'a>( + &'a self, _id: crate::OperationId, _phase: IdentityOperationPhase, _updated_at: UnixTimestamp, _diagnostic: Option<crate::OperationDiagnostic>, - ) -> Result<(), SafeError> { - Err(SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The test journal is unavailable."), - )) + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async { + Err(SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The test journal is unavailable."), + )) + }) } fn list_pending_operations( &self, - ) -> Result<Vec<crate::PendingIdentityOperation>, SafeError> { + ) -> BoxFuture<'_, Result<Vec<crate::PendingIdentityOperation>, SafeError>> { self.0.list_pending_operations() } - fn finalize_operation(&self, id: crate::OperationId) -> Result<(), SafeError> { + fn finalize_operation( + &self, + id: crate::OperationId, + ) -> BoxFuture<'_, Result<(), SafeError>> { self.0.finalize_operation(id) } } @@ -1045,80 +1141,108 @@ mod tests { } impl IdentityRepository for FailingSelectionRepository { - fn list_identities(&self) -> Result<Vec<NostrIdentity>, SafeError> { + fn list_identities(&self) -> BoxFuture<'_, Result<Vec<NostrIdentity>, SafeError>> { self.inner.list_identities() } - fn find_identity(&self, public_key: PublicKey) -> Result<Option<NostrIdentity>, SafeError> { + fn find_identity( + &self, + public_key: PublicKey, + ) -> BoxFuture<'_, Result<Option<NostrIdentity>, SafeError>> { self.inner.find_identity(public_key) } - fn insert_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { + fn insert_identity<'a>( + &'a self, + identity: &'a NostrIdentity, + ) -> BoxFuture<'a, Result<(), SafeError>> { self.inner.insert_identity(identity) } - fn update_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { + fn update_identity<'a>( + &'a self, + identity: &'a NostrIdentity, + ) -> BoxFuture<'a, Result<(), SafeError>> { self.inner.update_identity(identity) } - fn remove_identity(&self, public_key: PublicKey) -> Result<(), SafeError> { + fn remove_identity(&self, public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> { self.inner.remove_identity(public_key) } } impl AppStateRepository for FailingSelectionRepository { - fn load_selected_identity(&self) -> Result<Option<PublicKey>, SafeError> { + fn load_selected_identity(&self) -> BoxFuture<'_, Result<Option<PublicKey>, SafeError>> { self.inner.load_selected_identity() } - fn save_selected_identity(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + fn save_selected_identity( + &self, + public_key: Option<PublicKey>, + ) -> BoxFuture<'_, 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."), - )); + return Box::pin(async { + Err(SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The test selection repository is unavailable."), + )) + }); } self.inner.save_selected_identity(public_key) } } impl IdentityRepository for FailingInsertRepository { - fn list_identities(&self) -> Result<Vec<NostrIdentity>, SafeError> { + fn list_identities(&self) -> BoxFuture<'_, Result<Vec<NostrIdentity>, SafeError>> { self.inner.list_identities() } - fn find_identity(&self, public_key: PublicKey) -> Result<Option<NostrIdentity>, SafeError> { + fn find_identity( + &self, + public_key: PublicKey, + ) -> BoxFuture<'_, Result<Option<NostrIdentity>, SafeError>> { self.inner.find_identity(public_key) } - fn insert_identity(&self, _identity: &NostrIdentity) -> Result<(), SafeError> { - Err(SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The test identity repository is unavailable."), - )) + fn insert_identity<'a>( + &'a self, + _identity: &'a NostrIdentity, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async { + Err(SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The test identity repository is unavailable."), + )) + }) } - fn update_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { + fn update_identity<'a>( + &'a self, + identity: &'a NostrIdentity, + ) -> BoxFuture<'a, Result<(), SafeError>> { self.inner.update_identity(identity) } - fn remove_identity(&self, public_key: PublicKey) -> Result<(), SafeError> { + fn remove_identity(&self, public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> { self.inner.remove_identity(public_key) } } impl AppStateRepository for FailingInsertRepository { - fn load_selected_identity(&self) -> Result<Option<PublicKey>, SafeError> { + fn load_selected_identity(&self) -> BoxFuture<'_, Result<Option<PublicKey>, SafeError>> { self.inner.load_selected_identity() } - fn save_selected_identity(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + fn save_selected_identity( + &self, + public_key: Option<PublicKey>, + ) -> BoxFuture<'_, Result<(), SafeError>> { self.inner.save_selected_identity(public_key) } } - #[test] - fn generate_identity_stores_selects_and_returns_one_time_nsec_without_activation() { + #[tokio::test] + async fn generate_identity_stores_selects_and_returns_one_time_nsec_without_activation() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); @@ -1127,12 +1251,16 @@ mod tests { let receipt = core .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .expect("generate"); let public_key = receipt.identity().public_key(); assert_eq!(public_key.to_hex().len(), 64); assert!(secrets.contains(public_key).expect("credential")); assert_eq!( - identities.load_selected_identity().expect("selection"), + identities + .load_selected_identity() + .await + .expect("selection"), Some(public_key) ); assert_eq!(core.snapshot().selected_identity(), Some(public_key)); @@ -1142,8 +1270,8 @@ mod tests { assert!(!format!("{:?}", core.snapshot()).contains("nsec1")); } - #[test] - fn import_secret_key_accepts_nsec_and_hex_without_exposing_or_activating() { + #[tokio::test] + async fn import_secret_key_accepts_nsec_and_hex_without_exposing_or_activating() { for input in [ "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", @@ -1162,6 +1290,7 @@ mod tests { &journal, &FixedClock, ) + .await .expect("import"); let public_key = receipt.identity().public_key(); assert!(secrets.contains(public_key).expect("credential")); @@ -1171,8 +1300,8 @@ mod tests { } } - #[test] - fn import_secret_key_rejects_invalid_nsec_checksum_before_persistence() { + #[tokio::test] + async fn import_secret_key_rejects_invalid_nsec_checksum_before_persistence() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); @@ -1191,13 +1320,14 @@ mod tests { &journal, &FixedClock, ) + .await .expect_err("invalid import"); assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); assert!(core.snapshot().identities().is_empty()); } - #[test] - fn duplicate_import_preserves_existing_credential_and_snapshot() { + #[tokio::test] + async fn duplicate_import_preserves_existing_credential_and_snapshot() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); @@ -1217,6 +1347,7 @@ mod tests { &journal, &FixedClock, ) + .await .expect("first import"); let before = core.snapshot(); let error = core @@ -1228,14 +1359,15 @@ mod tests { &journal, &FixedClock, ) + .await .expect_err("duplicate"); assert_eq!(error.code(), SafeErrorCode::IdentityAlreadyExists); assert_eq!(core.snapshot(), before); assert_eq!(core.snapshot().identities().len(), 1); } - #[test] - fn duplicate_import_repairs_only_explicit_missing_credential_identity() { + #[tokio::test] + async fn duplicate_import_repairs_only_explicit_missing_credential_identity() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); @@ -1259,9 +1391,11 @@ mod tests { .expect("missing identity"); identities .insert_identity(&missing) + .await .expect("missing metadata"); identities .save_selected_identity(Some(public_key)) + .await .expect("selection"); core.apply_transition(StateTransition::ReplaceRegistry { identities: vec![missing], @@ -1278,6 +1412,7 @@ mod tests { &journal, &FixedClock, ) + .await .expect("repair"); assert_eq!( receipt @@ -1292,8 +1427,8 @@ mod tests { assert_eq!(core.snapshot().identities().len(), 1); } - #[test] - fn identity_transaction_publishes_nothing_when_credential_write_fails() { + #[tokio::test] + async fn identity_transaction_publishes_nothing_when_credential_write_fails() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); let secrets = FailureSecretStore::default(); @@ -1303,6 +1438,7 @@ mod tests { let error = core .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .err() .expect("credential failure"); assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); @@ -1310,13 +1446,14 @@ mod tests { assert!( journal .list_pending_operations() + .await .expect("journal") .is_empty() ); } - #[test] - fn identity_transaction_removes_written_credential_when_metadata_fails() { + #[tokio::test] + async fn identity_transaction_removes_written_credential_when_metadata_fails() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = FailingInsertRepository::default(); let secrets = FailureSecretStore::default(); @@ -1325,6 +1462,7 @@ mod tests { let error = core .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .err() .expect("metadata failure"); assert_eq!(error.code(), SafeErrorCode::StorageUnavailable); @@ -1336,13 +1474,14 @@ mod tests { assert!( journal .list_pending_operations() + .await .expect("journal") .is_empty() ); } - #[test] - fn identity_transaction_rolls_back_metadata_and_credential_when_selection_fails() { + #[tokio::test] + async fn identity_transaction_rolls_back_metadata_and_credential_when_selection_fails() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = FailingSelectionRepository::default(); let secrets = FailureSecretStore::default(); @@ -1352,13 +1491,23 @@ mod tests { let error = core .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .err() .expect("selection failure"); assert_eq!(error.code(), SafeErrorCode::StorageUnavailable); - assert!(identities.list_identities().expect("identities").is_empty()); + assert!( + identities + .list_identities() + .await + .expect("identities") + .is_empty() + ); assert_eq!( - identities.load_selected_identity().expect("selection"), + identities + .load_selected_identity() + .await + .expect("selection"), None ); let calls = secrets.calls(); @@ -1369,13 +1518,14 @@ mod tests { assert!( journal .list_pending_operations() + .await .expect("journal") .is_empty() ); } - #[test] - fn identity_transaction_retains_non_secret_journal_when_compensation_fails() { + #[tokio::test] + async fn identity_transaction_retains_non_secret_journal_when_compensation_fails() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = FailingInsertRepository::default(); let secrets = FailureSecretStore::default(); @@ -1385,13 +1535,14 @@ mod tests { let error = core .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .err() .expect("recovery required"); assert_eq!( error.code(), SafeErrorCode::PendingOperationRecoveryRequired ); - let pending = journal.list_pending_operations().expect("journal"); + let pending = journal.list_pending_operations().await.expect("journal"); assert_eq!(pending.len(), 1); assert_eq!( pending[0].phase(), @@ -1401,8 +1552,8 @@ mod tests { assert!(core.snapshot().identities().is_empty()); } - #[test] - fn select_identity_persists_existing_choice_without_activating() { + #[tokio::test] + async fn select_identity_persists_existing_choice_without_activating() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); @@ -1410,20 +1561,23 @@ mod tests { core.bootstrap().expect("bootstrap"); let first = core .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .expect("first") .identity() .public_key(); core.generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .expect("second"); let selected = core .select_identity(first, &identities, &identities) + .await .expect("select first"); assert_eq!(selected.selected_identity(), Some(first)); assert_eq!(selected.session(), SessionState::SignedOut); assert!(selected.active_identity().is_none()); assert_eq!( - identities.load_selected_identity().expect("saved"), + identities.load_selected_identity().await.expect("saved"), Some(first) ); let missing = core @@ -1435,13 +1589,14 @@ mod tests { &identities, &identities, ) + .await .expect_err("missing identity"); assert_eq!(missing.code(), SafeErrorCode::IdentityNotFound); assert_eq!(core.snapshot(), selected); } - #[test] - fn remove_identity_requires_fresh_single_use_confirmation_and_selects_next_fallback() { + #[tokio::test] + async fn remove_identity_requires_fresh_single_use_confirmation_and_selects_next_fallback() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); @@ -1449,20 +1604,24 @@ mod tests { core.bootstrap().expect("bootstrap"); let first = core .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .expect("first") .identity() .public_key(); let second = core .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .expect("second") .identity() .public_key(); core.select_identity(first, &identities, &identities) + .await .expect("select first"); let stale = core .request_identity_removal(first, &FixedClock) .expect("stale token"); core.select_identity(second, &identities, &identities) + .await .expect("change revision"); let stale_error = core .confirm_identity_removal( @@ -1473,11 +1632,13 @@ mod tests { &journal, &FixedClock, ) + .await .expect_err("stale token"); assert_eq!(stale_error.code(), SafeErrorCode::InvalidApplicationState); assert_eq!(core.snapshot().identities().len(), 2); core.select_identity(first, &identities, &identities) + .await .expect("reselect first"); let token = core .request_identity_removal(first, &FixedClock) @@ -1491,6 +1652,7 @@ mod tests { &journal, &FixedClock, ) + .await .expect("remove"); assert_eq!(removed.identities().len(), 1); assert_eq!(removed.selected_identity(), Some(second)); @@ -1498,8 +1660,8 @@ mod tests { assert_eq!(removed.session(), SessionState::SignedOut); } - #[test] - fn removal_preflight_reports_impact_expires_and_can_be_cancelled() { + #[tokio::test] + async fn removal_preflight_reports_impact_expires_and_can_be_cancelled() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); @@ -1507,6 +1669,7 @@ mod tests { core.bootstrap().expect("bootstrap"); let identity = core .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .expect("identity") .identity() .public_key(); @@ -1524,6 +1687,7 @@ mod tests { &journal, &LateClock, ) + .await .is_err() ); let cancelled = core @@ -1533,8 +1697,8 @@ mod tests { assert_eq!(core.snapshot().identities().len(), 1); } - #[test] - fn import_rejects_orphan_credentials_and_durable_nonterminal_replays() { + #[tokio::test] + async fn import_rejects_orphan_credentials_and_durable_nonterminal_replays() { const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); @@ -1557,6 +1721,7 @@ mod tests { &journal, &FixedClock, ) + .await .expect_err("orphan credential must fail") .code(), SafeErrorCode::IdentityAlreadyExists @@ -1581,14 +1746,15 @@ mod tests { &operations, &FixedClock, ) + .await .expect_err("unfinished replay must require recovery") .code(), SafeErrorCode::PendingOperationRecoveryRequired ); } - #[test] - fn durable_import_covers_new_and_missing_credential_repair_paths() { + #[tokio::test] + async 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()); @@ -1612,9 +1778,11 @@ mod tests { .expect("identity"); identities .insert_identity(&identity) + .await .expect("insert identity"); identities .save_selected_identity(Some(public_key)) + .await .expect("selection"); core.apply_transition(StateTransition::BootstrapRegistry { identities: vec![identity], @@ -1648,6 +1816,7 @@ mod tests { &operations, &FixedClock, ) + .await .expect("durable import"); assert_eq!(receipt.identity().public_key(), public_key); assert_eq!( @@ -1657,8 +1826,8 @@ mod tests { } } - #[test] - fn removal_of_unselected_identity_preserves_the_current_selection() { + #[tokio::test] + async fn removal_of_unselected_identity_preserves_the_current_selection() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); @@ -1666,11 +1835,13 @@ mod tests { core.bootstrap().expect("bootstrap"); let first = core .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .expect("first") .identity() .public_key(); let second = core .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .expect("second") .identity() .public_key(); @@ -1686,6 +1857,7 @@ mod tests { &journal, &FixedClock, ) + .await .expect("remove unselected identity"); assert_eq!(snapshot.selected_identity(), Some(second)); @@ -1693,12 +1865,19 @@ mod tests { assert!( identities .insert_identity(&snapshot.identities()[0]) + .await + .is_err() + ); + assert!( + identities + .save_selected_identity(Some(missing)) + .await .is_err() ); - assert!(identities.save_selected_identity(Some(missing)).is_err()); let third = core .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .expect("third") .identity() .public_key(); @@ -1723,12 +1902,13 @@ mod tests { &operations, &FixedClock, ) + .await .expect("durable unselected removal"); assert_eq!(snapshot.selected_identity(), Some(third)); } - #[test] - fn duplicate_missing_binding_with_orphan_credential_fails_closed() { + #[tokio::test] + async fn duplicate_missing_binding_with_orphan_credential_fails_closed() { const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); @@ -1749,9 +1929,11 @@ mod tests { .expect("identity"); identities .insert_identity(&identity) + .await .expect("insert identity"); identities .save_selected_identity(Some(public_key)) + .await .expect("selection"); secrets.put(public_key, secret).expect("credential"); core.apply_transition(StateTransition::BootstrapRegistry { @@ -1769,6 +1951,7 @@ mod tests { &journal, &FixedClock, ) + .await .expect_err("orphan credential must fail") .code(), SafeErrorCode::IdentityAlreadyExists @@ -1792,14 +1975,15 @@ mod tests { &operations, &FixedClock, ) + .await .expect_err("orphan durable credential must fail") .code(), SafeErrorCode::IdentityAlreadyExists ); } - #[test] - fn removing_an_active_identity_signs_out_for_legacy_and_durable_requests() { + #[tokio::test] + async fn removing_an_active_identity_signs_out_for_legacy_and_durable_requests() { for durable in [false, true] { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); @@ -1819,6 +2003,7 @@ mod tests { &journal, &FixedClock, ) + .await .expect("identity") .identity() .public_key(); @@ -1830,6 +2015,7 @@ mod tests { &secrets, &FixedClock, ) + .await .expect("activate identity"); let token = core .request_identity_removal(public_key, &FixedClock) @@ -1852,6 +2038,7 @@ mod tests { &operations, &FixedClock, ) + .await .expect("durable removal") } else { core.confirm_identity_removal( @@ -1862,14 +2049,15 @@ mod tests { &journal, &FixedClock, ) + .await .expect("removal") }; assert_eq!(snapshot.session(), SessionState::SignedOut); } } - #[test] - fn identity_transaction_compensates_a_journal_phase_failure() { + #[tokio::test] + async fn identity_transaction_compensates_a_journal_phase_failure() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); @@ -1878,11 +2066,12 @@ mod tests { assert_eq!( core.generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .err() .expect("journal failure must be returned") .code(), SafeErrorCode::StorageUnavailable ); - assert!(identities.list_identities().unwrap().is_empty()); + assert!(identities.list_identities().await.unwrap().is_empty()); } } diff --git a/core/crates/harvestcircle_application/src/lib.rs b/core/crates/harvestcircle_application/src/lib.rs @@ -31,19 +31,22 @@ pub use custody::{ GENERATED_KEY_STAGE_TTL, GeneratedKeyRecoveryHandle, GeneratedKeyStage, GeneratedKeyStageView, RecoveryStageId, StagedGeneratedKey, }; -pub use identities::{ - GenerateIdentityReceipt, ImportIdentityReceipt, InMemoryIdentityRepository, - InMemoryOperationJournal, -}; +#[cfg(test)] +pub use identities::InMemoryOperationJournal; +pub use identities::{GenerateIdentityReceipt, ImportIdentityReceipt, InMemoryIdentityRepository}; pub use ports::{ AppStateRepository, BoxFuture, CachedProfile, Clock, DurableIdentityOperation, DurableOperationKind, DurableOperationPhase, DurableOperationReceipt, DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome, - GeneratedKeyMaterial, IdentityNamespaceRepository, IdentityOperationKind, - IdentityOperationPhase, IdentityPreferenceKey, IdentityRepository, ImportedKeyMaterial, - KeyMaterialProvider, NostrClient, OperationDiagnostic, OperationId, OperationJournal, - OperationPriorState, PendingIdentityOperation, ProfileFetchResult, ProfileRefreshStatus, - ProfileRepository, RelayFetchCompleteness, + GeneratedKeyMaterial, IdentityNamespaceRepository, IdentityPreferenceKey, IdentityRepository, + ImportedKeyMaterial, KeyMaterialProvider, NostrClient, OperationDiagnostic, + OperationPriorState, ProfileFetchResult, ProfileRefreshStatus, ProfileRepository, + RelayFetchCompleteness, +}; +#[cfg(test)] +pub use ports::{ + IdentityOperationKind, IdentityOperationPhase, OperationId, OperationJournal, + PendingIdentityOperation, }; pub use profile_refresh::ProfileRefreshPlan; pub use secrets::{ diff --git a/core/crates/harvestcircle_application/src/ports.rs b/core/crates/harvestcircle_application/src/ports.rs @@ -284,6 +284,7 @@ pub enum IdentityPreferenceKey { NamespaceProbe, } +#[cfg(test)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum IdentityOperationKind { Add, @@ -291,6 +292,7 @@ pub enum IdentityOperationKind { Remove, } +#[cfg(test)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum IdentityOperationPhase { IntentRecorded, @@ -311,9 +313,11 @@ pub enum OperationDiagnostic { Expired, } +#[cfg(test)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct OperationId(u64); +#[cfg(test)] impl OperationId { #[must_use] pub const fn from_raw(value: u64) -> Self { @@ -326,6 +330,7 @@ impl OperationId { } } +#[cfg(test)] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PendingIdentityOperation { id: OperationId, @@ -336,6 +341,7 @@ pub struct PendingIdentityOperation { diagnostic: Option<OperationDiagnostic>, } +#[cfg(test)] impl PendingIdentityOperation { #[must_use] pub const fn new( @@ -418,32 +424,41 @@ pub trait IdentityRepository: Send + Sync { /// # Errors /// /// Returns a safe storage error when records cannot be read. - fn list_identities(&self) -> Result<Vec<NostrIdentity>, SafeError>; + fn list_identities(&self) -> BoxFuture<'_, Result<Vec<NostrIdentity>, SafeError>>; /// Finds one saved public identity record. /// /// # Errors /// /// Returns a safe storage error when the lookup cannot complete. - fn find_identity(&self, public_key: PublicKey) -> Result<Option<NostrIdentity>, SafeError>; + fn find_identity( + &self, + public_key: PublicKey, + ) -> BoxFuture<'_, Result<Option<NostrIdentity>, SafeError>>; /// Inserts one public identity record. /// /// # Errors /// /// Returns a safe storage error when the durable write fails. - fn insert_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError>; + fn insert_identity<'a>( + &'a self, + identity: &'a NostrIdentity, + ) -> BoxFuture<'a, Result<(), SafeError>>; /// Updates one existing public identity record. /// /// # Errors /// /// Returns a safe storage or identity-not-found error when the durable /// update cannot complete. - fn update_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError>; + fn update_identity<'a>( + &'a self, + identity: &'a NostrIdentity, + ) -> BoxFuture<'a, Result<(), SafeError>>; /// Removes one public identity record. /// /// # Errors /// /// Returns a safe storage error when the durable delete fails. - fn remove_identity(&self, public_key: PublicKey) -> Result<(), SafeError>; + fn remove_identity(&self, public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>>; } pub trait ProfileRepository: Send + Sync { @@ -452,30 +467,36 @@ pub trait ProfileRepository: Send + Sync { /// # Errors /// /// Returns a safe storage error when the cache cannot be read. - fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError>; + fn load_profile( + &self, + public_key: PublicKey, + ) -> BoxFuture<'_, 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>; + fn save_profile<'a>( + &'a self, + profile: &'a CachedProfile, + ) -> BoxFuture<'a, 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, + fn record_refresh_status<'a>( + &'a self, public_key: PublicKey, refreshed_at: UnixTimestamp, status: ProfileRefreshStatus, - ) -> Result<(), SafeError>; + ) -> BoxFuture<'a, Result<(), SafeError>>; /// Removes cached profile metadata for an identity. /// /// # Errors /// /// Returns a safe storage error when the cache cannot be deleted. - fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError>; + fn remove_profile(&self, public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>>; } pub trait IdentityNamespaceRepository: Send + Sync { @@ -484,28 +505,28 @@ pub trait IdentityNamespaceRepository: Send + Sync { /// # Errors /// /// Returns a safe storage error when the value cannot be read. - fn get_value( - &self, + fn get_value<'a>( + &'a self, owner: PublicKey, key: IdentityPreferenceKey, - ) -> Result<Option<String>, SafeError>; + ) -> BoxFuture<'a, Result<Option<String>, SafeError>>; /// Writes one internal non-secret identity-scoped value. /// /// # Errors /// /// Returns a safe storage error when the value cannot be committed. - fn set_value( - &self, + fn set_value<'a>( + &'a self, owner: PublicKey, key: IdentityPreferenceKey, - value: &str, - ) -> Result<(), SafeError>; + value: &'a str, + ) -> BoxFuture<'a, Result<(), SafeError>>; /// Removes all internal values owned by an identity. /// /// # Errors /// /// Returns a safe storage error when cleanup cannot be committed. - fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError>; + fn clear_owner(&self, owner: PublicKey) -> BoxFuture<'_, Result<(), SafeError>>; } pub trait AppStateRepository: Send + Sync { @@ -514,51 +535,57 @@ pub trait AppStateRepository: Send + Sync { /// # Errors /// /// Returns a safe storage error when application state cannot be read. - fn load_selected_identity(&self) -> Result<Option<PublicKey>, SafeError>; + fn load_selected_identity(&self) -> BoxFuture<'_, Result<Option<PublicKey>, SafeError>>; /// Persists the selected identity or the empty selection. /// /// # Errors /// /// Returns a safe storage error when application state cannot be committed. - fn save_selected_identity(&self, public_key: Option<PublicKey>) -> Result<(), SafeError>; + fn save_selected_identity( + &self, + public_key: Option<PublicKey>, + ) -> BoxFuture<'_, Result<(), SafeError>>; } +#[cfg(test)] pub trait OperationJournal: Send + Sync { /// Records one cross-resource identity operation intent. /// /// # Errors /// /// Returns a safe storage error when the entry cannot be committed. - fn begin_operation( - &self, + fn begin_operation<'a>( + &'a self, kind: IdentityOperationKind, subject: PublicKey, updated_at: UnixTimestamp, - ) -> Result<OperationId, SafeError>; + ) -> BoxFuture<'a, 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, + fn update_operation<'a>( + &'a self, id: OperationId, phase: IdentityOperationPhase, updated_at: UnixTimestamp, diagnostic: Option<OperationDiagnostic>, - ) -> Result<(), SafeError>; + ) -> BoxFuture<'a, 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<PendingIdentityOperation>, SafeError>; + fn list_pending_operations( + &self, + ) -> BoxFuture<'_, Result<Vec<PendingIdentityOperation>, 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>; + fn finalize_operation(&self, id: OperationId) -> BoxFuture<'_, Result<(), SafeError>>; } pub trait DurableOperationRepository: Send + Sync { @@ -568,50 +595,50 @@ pub trait DurableOperationRepository: Send + Sync { /// /// 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, + fn begin_durable_operation<'a>( + &'a self, + request_id: &'a DurableRequestId, kind: DurableOperationKind, identity: PublicKey, expected_revision: Option<u64>, prior: OperationPriorState, updated_at: UnixTimestamp, - ) -> Result<DurableOperationStart, SafeError>; + ) -> BoxFuture<'a, 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<DurableIdentityOperation>, SafeError>; + fn load_durable_operation<'a>( + &'a self, + request_id: &'a DurableRequestId, + ) -> BoxFuture<'a, Result<Option<DurableIdentityOperation>, 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, + fn advance_durable_operation<'a>( + &'a self, + request_id: &'a DurableRequestId, expected_phase: DurableOperationPhase, next_phase: DurableOperationPhase, updated_at: UnixTimestamp, diagnostic: Option<OperationDiagnostic>, - ) -> Result<DurableIdentityOperation, SafeError>; + ) -> BoxFuture<'a, Result<DurableIdentityOperation, 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, + fn finalize_durable_operation<'a>( + &'a self, + request_id: &'a DurableRequestId, expected_phase: DurableOperationPhase, outcome: DurableTerminalOutcome, resulting_revision: Option<u64>, updated_at: UnixTimestamp, - ) -> Result<DurableOperationReceipt, SafeError>; + ) -> BoxFuture<'a, Result<DurableOperationReceipt, SafeError>>; /// Lists unfinished operations in deterministic request order. /// /// # Errors @@ -619,7 +646,7 @@ pub trait DurableOperationRepository: Send + Sync { /// Returns a safe storage error when operations cannot be read. fn list_unfinished_durable_operations( &self, - ) -> Result<Vec<DurableIdentityOperation>, SafeError>; + ) -> BoxFuture<'_, Result<Vec<DurableIdentityOperation>, SafeError>>; } pub trait NostrClient: Send + Sync { @@ -718,8 +745,8 @@ mod tests { ProfileFetchResult, ProfileRefreshStatus, ProfileRepository, }; - #[test] - fn durable_request_ids_and_terminal_receipts_are_bounded_and_public() { + #[tokio::test] + async fn durable_request_ids_and_terminal_receipts_are_bounded_and_public() { let request = DurableRequestId::parse("01890f3e-7b1c-7000-8000-000000000031").expect("request id"); let receipt = DurableOperationReceipt::new( @@ -741,8 +768,8 @@ mod tests { } } - #[test] - fn durable_request_id_source_is_stateless_canonical_and_unique() { + #[tokio::test] + async fn durable_request_id_source_is_stateless_canonical_and_unique() { let first = DurableRequestId::new_v7(); let second = DurableRequestId::new_v7(); @@ -765,113 +792,132 @@ mod tests { } impl IdentityRepository for FakePorts { - fn list_identities(&self) -> Result<Vec<NostrIdentity>, SafeError> { - Ok(Vec::new()) + fn list_identities(&self) -> BoxFuture<'_, Result<Vec<NostrIdentity>, SafeError>> { + Box::pin(async { Ok(Vec::new()) }) } fn find_identity( &self, _public_key: PublicKey, - ) -> Result<Option<NostrIdentity>, SafeError> { - Ok(None) + ) -> BoxFuture<'_, Result<Option<NostrIdentity>, SafeError>> { + Box::pin(async { Ok(None) }) } - fn insert_identity(&self, _identity: &NostrIdentity) -> Result<(), SafeError> { - Ok(()) + fn insert_identity<'a>( + &'a self, + _identity: &'a NostrIdentity, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } - fn update_identity(&self, _identity: &NostrIdentity) -> Result<(), SafeError> { - Ok(()) + fn update_identity<'a>( + &'a self, + _identity: &'a NostrIdentity, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } - fn remove_identity(&self, _public_key: PublicKey) -> Result<(), SafeError> { - Ok(()) + fn remove_identity(&self, _public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } } impl ProfileRepository for FakePorts { - fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { - Ok(None) + fn load_profile( + &self, + _public_key: PublicKey, + ) -> BoxFuture<'_, Result<Option<CachedProfile>, SafeError>> { + Box::pin(async { Ok(None) }) } - fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> { - Ok(()) + fn save_profile<'a>( + &'a self, + _profile: &'a CachedProfile, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } - fn record_refresh_status( - &self, + fn record_refresh_status<'a>( + &'a self, _public_key: PublicKey, _refreshed_at: UnixTimestamp, _status: ProfileRefreshStatus, - ) -> Result<(), SafeError> { - Ok(()) + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } - fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { - Ok(()) + fn remove_profile(&self, _public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } } impl IdentityNamespaceRepository for FakePorts { - fn get_value( - &self, + fn get_value<'a>( + &'a self, _owner: PublicKey, _key: IdentityPreferenceKey, - ) -> Result<Option<String>, SafeError> { - Ok(None) + ) -> BoxFuture<'a, Result<Option<String>, SafeError>> { + Box::pin(async { Ok(None) }) } - fn set_value( - &self, + fn set_value<'a>( + &'a self, _owner: PublicKey, _key: IdentityPreferenceKey, - _value: &str, - ) -> Result<(), SafeError> { - Ok(()) + _value: &'a str, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } - fn clear_owner(&self, _owner: PublicKey) -> Result<(), SafeError> { - Ok(()) + fn clear_owner(&self, _owner: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } } impl AppStateRepository for FakePorts { - fn load_selected_identity(&self) -> Result<Option<PublicKey>, SafeError> { - Ok(*self.selected.lock().expect("selected lock")) + fn load_selected_identity(&self) -> BoxFuture<'_, Result<Option<PublicKey>, SafeError>> { + Box::pin(async move { Ok(*self.selected.lock().expect("selected lock")) }) } - fn save_selected_identity(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { - *self.selected.lock().expect("selected lock") = public_key; - Ok(()) + fn save_selected_identity( + &self, + public_key: Option<PublicKey>, + ) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async move { + *self.selected.lock().expect("selected lock") = public_key; + Ok(()) + }) } } impl OperationJournal for FakePorts { - fn begin_operation( - &self, + fn begin_operation<'a>( + &'a self, _kind: IdentityOperationKind, _subject: PublicKey, _updated_at: UnixTimestamp, - ) -> Result<OperationId, SafeError> { - Ok(OperationId::from_raw(1)) + ) -> BoxFuture<'a, Result<OperationId, SafeError>> { + Box::pin(async { Ok(OperationId::from_raw(1)) }) } - fn update_operation( - &self, + fn update_operation<'a>( + &'a self, _id: OperationId, _phase: IdentityOperationPhase, _updated_at: UnixTimestamp, _diagnostic: Option<OperationDiagnostic>, - ) -> Result<(), SafeError> { - Ok(()) + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } - fn list_pending_operations(&self) -> Result<Vec<PendingIdentityOperation>, SafeError> { - Ok(Vec::new()) + fn list_pending_operations( + &self, + ) -> BoxFuture<'_, Result<Vec<PendingIdentityOperation>, SafeError>> { + Box::pin(async { Ok(Vec::new()) }) } - fn finalize_operation(&self, _id: OperationId) -> Result<(), SafeError> { - Ok(()) + fn finalize_operation(&self, _id: OperationId) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } } @@ -894,8 +940,8 @@ mod tests { fn assert_send_sync<T: Send + Sync>() {} - #[test] - fn ports_accept_send_sync_test_fakes() { + #[tokio::test] + async fn ports_accept_send_sync_test_fakes() { assert_send_sync::<FakePorts>(); let ports = FakePorts::default(); @@ -903,9 +949,13 @@ mod tests { .save_selected_identity(Some( PublicKey::from_bytes([7_u8; 32]).expect("valid public key"), )) + .await .expect("save selection"); assert_eq!( - ports.load_selected_identity().expect("load selection"), + ports + .load_selected_identity() + .await + .expect("load selection"), Some(PublicKey::from_bytes([7_u8; 32]).expect("valid public key")) ); assert_eq!(ports.now().as_seconds(), 1); diff --git a/core/crates/harvestcircle_application/src/profile_refresh.rs b/core/crates/harvestcircle_application/src/profile_refresh.rs @@ -80,6 +80,7 @@ impl AppCore { .fetch_profile(plan.public_key(), plan.relays(), deadline) .await; self.complete_profile_refresh(&plan, result, profiles, clock) + .await } /// Begins a refresh on the actor and returns the immutable network plan. @@ -116,7 +117,7 @@ impl AppCore { /// /// Returns a safe storage or application-state error. Stale results are /// discarded without persistence or publication. - pub fn complete_profile_refresh( + pub async fn complete_profile_refresh( &self, plan: &ProfileRefreshPlan, result: Result<ProfileFetchResult, SafeError>, @@ -144,10 +145,13 @@ impl AppCore { profiles, clock, ) + .await } Err(error) => { let status = refresh_status(error); - profiles.record_refresh_status(plan.public_key(), clock.now(), status)?; + profiles + .record_refresh_status(plan.public_key(), clock.now(), status) + .await?; self.apply_transition(StateTransition::UpdateActiveIdentity { expected: plan.public_key(), active_identity: Box::new(ActiveIdentitySnapshot::new( @@ -162,7 +166,7 @@ impl AppCore { } } - fn complete_successful_profile_fetch( + async fn complete_successful_profile_fetch( &self, plan: &ProfileRefreshPlan, current_active: ActiveIdentitySnapshot, @@ -186,8 +190,8 @@ impl AppCore { clock.now(), ProfileRefreshStatus::Success, ); - profiles.save_profile(&cached)?; - let winning_profile = profiles.load_profile(plan.public_key())?.map_or_else( + profiles.save_profile(&cached).await?; + let winning_profile = profiles.load_profile(plan.public_key()).await?.map_or_else( || candidate.metadata().clone(), |profile| profile.candidate().metadata().clone(), ); @@ -271,43 +275,56 @@ mod tests { 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 load_profile( + &self, + _public_key: PublicKey, + ) -> BoxFuture<'_, Result<Option<CachedProfile>, SafeError>> { + Box::pin(async move { 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 save_profile<'a>( + &'a self, + profile: &'a CachedProfile, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + 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, + fn record_refresh_status<'a>( + &'a 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(()) + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + 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(()) + fn remove_profile(&self, _public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async move { + *self.0.lock().expect("profiles") = None; + Ok(()) + }) } } @@ -372,7 +389,10 @@ mod tests { ) } - fn active_core(profiles: &MemoryProfiles, cached_name: Option<&str>) -> (AppCore, PublicKey) { + async fn active_core( + profiles: &MemoryProfiles, + cached_name: Option<&str>, + ) -> (AppCore, PublicKey) { let relays = RelayConfiguration::new(vec![ RelayEndpoint::parse( "ws://localhost:8080", @@ -400,6 +420,7 @@ mod tests { &journal, &FixedClock, ) + .await .expect("import") .identity() .public_key(); @@ -410,6 +431,7 @@ mod tests { UnixTimestamp::from_seconds(11).expect("time"), ProfileRefreshStatus::Success, )) + .await .expect("cache"); } core.activate_identity( @@ -420,6 +442,7 @@ mod tests { &secrets, &FixedClock, ) + .await .expect("activate"); (core, public_key) } @@ -427,7 +450,7 @@ mod tests { #[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")); + let (core, public_key) = active_core(&profiles, Some("Cached")).await; assert_eq!( core.snapshot() .active_identity() @@ -456,6 +479,7 @@ mod tests { .fetch_profile(plan.public_key(), plan.relays(), deadline()) .await; core.complete_profile_refresh(&plan, result, &profiles, &FixedClock) + .await .expect("complete refresh"); assert_eq!( core.snapshot() @@ -469,7 +493,7 @@ mod tests { #[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 (core, public_key) = active_core(&profiles, Some("Cached")).await; let cached = profile(public_key, "Cached", 10); let error = SafeError::new( SafeErrorCode::RelayConnectionFailed, @@ -496,6 +520,7 @@ mod tests { assert_eq!( profiles .load_profile(public_key) + .await .expect("load") .expect("cache") .candidate(), @@ -506,7 +531,7 @@ mod tests { #[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 (core, public_key) = active_core(&profiles, Some("Cached")).await; let client = BlockingClient::new(Ok(Some(profile(public_key, "Stale", 20)))); let refresh = @@ -528,6 +553,7 @@ mod tests { assert_eq!( profiles .load_profile(public_key) + .await .expect("load") .expect("cached") .candidate() @@ -540,7 +566,7 @@ mod tests { #[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 (core, public_key) = active_core(&profiles, None).await; let first = core .refresh_active_profile( &profiles, @@ -580,10 +606,10 @@ mod tests { Instant::now() + Duration::from_secs(5) } - #[test] - fn partial_relay_success_retains_verified_data_and_marks_degraded_connectivity() { + #[tokio::test] + async fn partial_relay_success_retains_verified_data_and_marks_degraded_connectivity() { let profiles = MemoryProfiles::default(); - let (core, public_key) = active_core(&profiles, None); + let (core, public_key) = active_core(&profiles, None).await; let plan = core .begin_profile_refresh() .expect("begin") @@ -597,6 +623,7 @@ mod tests { &profiles, &FixedClock, ) + .await .expect("partial completion"); let active = snapshot.active_identity().expect("active identity"); assert_eq!(active.relay_state(), RelayConnectionState::Degraded); @@ -611,10 +638,10 @@ mod tests { ); } - #[test] - fn overlapping_refreshes_keep_the_newest_event_regardless_of_completion_order() { + #[tokio::test] + async 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 (core, public_key) = active_core(&profiles, Some("Cached")).await; let first = core .begin_profile_refresh() .expect("first") @@ -632,6 +659,7 @@ mod tests { &profiles, &FixedClock, ) + .await .expect("newest completes first"); let final_snapshot = core .complete_profile_refresh( @@ -642,6 +670,7 @@ mod tests { &profiles, &FixedClock, ) + .await .expect("older completes last"); assert_eq!( @@ -654,6 +683,7 @@ mod tests { assert_eq!( profiles .load_profile(public_key) + .await .expect("cache") .expect("profile") .candidate() diff --git a/core/crates/harvestcircle_application/src/recovery.rs b/core/crates/harvestcircle_application/src/recovery.rs @@ -1,11 +1,14 @@ -use harvestcircle_domain::{PublicKey, SafeError}; +#[cfg(test)] +use harvestcircle_domain::PublicKey; +use harvestcircle_domain::SafeError; use crate::{ AppCore, AppStateRepository, Clock, DurableIdentityOperation, DurableOperationKind, - DurableOperationPhase, DurableOperationRepository, DurableTerminalOutcome, - IdentityOperationKind, IdentityOperationPhase, IdentityRepository, OperationJournal, + DurableOperationPhase, DurableOperationRepository, DurableTerminalOutcome, IdentityRepository, SecretStore, }; +#[cfg(test)] +use crate::{IdentityOperationKind, IdentityOperationPhase, OperationJournal}; impl AppCore { /// Reconciles durable request operations before public state is restored. @@ -14,7 +17,7 @@ impl AppCore { /// /// 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( + pub async fn recover_durable_operations( &self, identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), @@ -22,16 +25,22 @@ impl AppCore { operations: &(impl DurableOperationRepository + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { - for operation in operations.list_unfinished_durable_operations()? { + for operation in operations.list_unfinished_durable_operations().await? { match operation.kind() { DurableOperationKind::Create | DurableOperationKind::Import - | DurableOperationKind::Repair => recover_durable_addition( - &operation, identities, app_state, secrets, operations, clock, - )?, - DurableOperationKind::Remove => recover_durable_removal( - &operation, identities, app_state, secrets, operations, clock, - )?, + | DurableOperationKind::Repair => { + recover_durable_addition( + &operation, identities, app_state, secrets, operations, clock, + ) + .await? + } + DurableOperationKind::Remove => { + recover_durable_removal( + &operation, identities, app_state, secrets, operations, clock, + ) + .await? + } } } Ok(()) @@ -45,7 +54,8 @@ impl AppCore { /// /// Returns a safe credential, persistence, or recovery error while retaining /// the unfinished journal entry for a later retry. - pub fn recover_pending_operations( + #[cfg(test)] + pub async fn recover_pending_operations( &self, identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), @@ -53,13 +63,14 @@ impl AppCore { journal: &(impl OperationJournal + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { - for operation in journal.list_pending_operations()? { + for operation in journal.list_pending_operations().await? { match operation.kind() { IdentityOperationKind::Remove => { - recover_removal(&operation, identities, app_state, secrets, journal, clock)?; + recover_removal(&operation, identities, app_state, secrets, journal, clock) + .await?; } IdentityOperationKind::Add | IdentityOperationKind::Import => { - recover_addition(&operation, identities, secrets, journal, clock)?; + recover_addition(&operation, identities, secrets, journal, clock).await?; } } } @@ -67,7 +78,7 @@ impl AppCore { } } -fn recover_durable_removal( +async fn recover_durable_removal( operation: &DurableIdentityOperation, identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), @@ -82,52 +93,62 @@ fn recover_durable_removal( if secrets.contains(identity)? { secrets.delete(identity)?; } - operations.advance_durable_operation( - request, - phase, - DurableOperationPhase::CredentialDeleted, - clock.now(), - None, - )?; + operations + .advance_durable_operation( + request, + phase, + DurableOperationPhase::CredentialDeleted, + clock.now(), + None, + ) + .await?; phase = DurableOperationPhase::CredentialDeleted; } if phase == DurableOperationPhase::CredentialDeleted { - if identities.find_identity(identity)?.is_some() { - identities.remove_identity(identity)?; + if identities.find_identity(identity).await?.is_some() { + identities.remove_identity(identity).await?; } - operations.advance_durable_operation( - request, - phase, - DurableOperationPhase::MetadataDeleted, - clock.now(), - None, - )?; + operations + .advance_durable_operation( + request, + phase, + DurableOperationPhase::MetadataDeleted, + clock.now(), + None, + ) + .await?; phase = DurableOperationPhase::MetadataDeleted; } if phase == DurableOperationPhase::MetadataDeleted { - app_state.save_selected_identity(operation.prior().selected_identity())?; - operations.advance_durable_operation( - request, - phase, - DurableOperationPhase::SelectionCommitted, - clock.now(), - None, - )?; + app_state + .save_selected_identity(operation.prior().selected_identity()) + .await?; + operations + .advance_durable_operation( + request, + phase, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + ) + .await?; phase = DurableOperationPhase::SelectionCommitted; } if phase == DurableOperationPhase::SelectionCommitted { - operations.finalize_durable_operation( - request, - phase, - DurableTerminalOutcome::Completed, - None, - clock.now(), - )?; + operations + .finalize_durable_operation( + request, + phase, + DurableTerminalOutcome::Completed, + None, + clock.now(), + ) + .await?; } Ok(()) } -fn recover_durable_addition( +async fn recover_durable_addition( operation: &DurableIdentityOperation, identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), @@ -142,16 +163,18 @@ fn recover_durable_addition( if secrets.contains(identity)? { secrets.delete(identity)?; } - operations.finalize_durable_operation( - request, - DurableOperationPhase::IntentRecorded, - DurableTerminalOutcome::Failed, - None, - clock.now(), - )?; + operations + .finalize_durable_operation( + request, + DurableOperationPhase::IntentRecorded, + DurableTerminalOutcome::Failed, + None, + clock.now(), + ) + .await?; } DurableOperationPhase::CredentialWritten => { - let metadata = identities.find_identity(identity)?; + let metadata = identities.find_identity(identity).await?; let committed = metadata.as_ref().is_some_and(|saved| { saved .signer_binding() @@ -162,83 +185,99 @@ fn recover_durable_addition( }) }); if committed { - operations.advance_durable_operation( - request, - DurableOperationPhase::CredentialWritten, - DurableOperationPhase::MetadataCommitted, - clock.now(), - None, - )?; - finish_durable_selection(operation, app_state, operations, clock)?; + operations + .advance_durable_operation( + request, + DurableOperationPhase::CredentialWritten, + DurableOperationPhase::MetadataCommitted, + clock.now(), + None, + ) + .await?; + finish_durable_selection(operation, app_state, operations, clock).await?; } else { - operations.advance_durable_operation( - request, - DurableOperationPhase::CredentialWritten, - DurableOperationPhase::CompensationPending, - clock.now(), - None, - )?; + operations + .advance_durable_operation( + request, + DurableOperationPhase::CredentialWritten, + DurableOperationPhase::CompensationPending, + clock.now(), + None, + ) + .await?; compensate_durable_addition( operation, identities, app_state, secrets, operations, clock, - )?; + ) + .await?; } } DurableOperationPhase::MetadataCommitted => { - finish_durable_selection(operation, app_state, operations, clock)?; + finish_durable_selection(operation, app_state, operations, clock).await?; } DurableOperationPhase::SelectionCommitted => { - operations.finalize_durable_operation( - request, - DurableOperationPhase::SelectionCommitted, - DurableTerminalOutcome::Completed, - None, - clock.now(), - )?; + operations + .finalize_durable_operation( + request, + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + None, + clock.now(), + ) + .await?; } DurableOperationPhase::CompensationPending => { compensate_durable_addition( operation, identities, app_state, secrets, operations, clock, - )?; + ) + .await?; } DurableOperationPhase::CredentialDeleted | DurableOperationPhase::MetadataDeleted => { - operations.finalize_durable_operation( - request, - operation.phase(), - DurableTerminalOutcome::Failed, - None, - clock.now(), - )?; + operations + .finalize_durable_operation( + request, + operation.phase(), + DurableTerminalOutcome::Failed, + None, + clock.now(), + ) + .await?; } DurableOperationPhase::Finalized => {} } Ok(()) } -fn finish_durable_selection( +async fn finish_durable_selection( operation: &DurableIdentityOperation, app_state: &(impl AppStateRepository + ?Sized), operations: &(impl DurableOperationRepository + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { - app_state.save_selected_identity(Some(operation.identity()))?; - 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(), - )?; + app_state + .save_selected_identity(Some(operation.identity())) + .await?; + operations + .advance_durable_operation( + operation.request_id(), + DurableOperationPhase::MetadataCommitted, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + ) + .await?; + operations + .finalize_durable_operation( + operation.request_id(), + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + None, + clock.now(), + ) + .await?; Ok(()) } -fn compensate_durable_addition( +async fn compensate_durable_addition( operation: &DurableIdentityOperation, identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), @@ -250,30 +289,40 @@ fn compensate_durable_addition( secrets.delete(operation.identity())?; } if let Some(availability) = operation.prior().binding_availability() { - if let Some(previous) = identities.find_identity(operation.identity())? { + if let Some(previous) = identities.find_identity(operation.identity()).await? { let restored = previous .with_local_keyring_availability(availability) .ok_or_else(recovery_required)?; - identities.update_identity(&restored)?; + identities.update_identity(&restored).await?; } - } else if identities.find_identity(operation.identity())?.is_some() { - identities.remove_identity(operation.identity())?; + } else if identities + .find_identity(operation.identity()) + .await? + .is_some() + { + identities.remove_identity(operation.identity()).await?; } - app_state.save_selected_identity(operation.prior().selected_identity())?; - 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(), - )?; + app_state + .save_selected_identity(operation.prior().selected_identity()) + .await?; + operations + .advance_durable_operation( + operation.request_id(), + DurableOperationPhase::CompensationPending, + DurableOperationPhase::CredentialDeleted, + clock.now(), + None, + ) + .await?; + operations + .finalize_durable_operation( + operation.request_id(), + DurableOperationPhase::CredentialDeleted, + DurableTerminalOutcome::Failed, + None, + clock.now(), + ) + .await?; Ok(()) } @@ -286,7 +335,8 @@ const fn recovery_required() -> SafeError { ) } -fn recover_removal( +#[cfg(test)] +async fn recover_removal( operation: &crate::PendingIdentityOperation, identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), @@ -302,39 +352,51 @@ fn recover_removal( if error.code() == harvestcircle_domain::SafeErrorCode::CredentialMissing => {} Err(error) => return Err(error), } - journal.update_operation( - operation.id(), - IdentityOperationPhase::CredentialDeleted, - clock.now(), - None, - )?; + journal + .update_operation( + operation.id(), + IdentityOperationPhase::CredentialDeleted, + clock.now(), + None, + ) + .await?; } if matches!( operation.phase(), IdentityOperationPhase::IntentRecorded | IdentityOperationPhase::CredentialDeleted ) { - let registry = identities.list_identities()?; - let selected = removal_fallback(&registry, app_state.load_selected_identity()?, public_key); - identities.remove_identity(public_key)?; - app_state.save_selected_identity(selected)?; - journal.update_operation( - operation.id(), - IdentityOperationPhase::MetadataDeleted, - clock.now(), - None, - )?; + let registry = identities.list_identities().await?; + let selected = removal_fallback( + &registry, + app_state.load_selected_identity().await?, + public_key, + ); + identities.remove_identity(public_key).await?; + app_state.save_selected_identity(selected).await?; + journal + .update_operation( + operation.id(), + IdentityOperationPhase::MetadataDeleted, + clock.now(), + None, + ) + .await?; } - journal.finalize_operation(operation.id()) + journal.finalize_operation(operation.id()).await } -fn recover_addition( +#[cfg(test)] +async fn recover_addition( operation: &crate::PendingIdentityOperation, identities: &(impl IdentityRepository + ?Sized), secrets: &(impl SecretStore + ?Sized), journal: &(impl OperationJournal + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { - let has_metadata = identities.find_identity(operation.subject())?.is_some(); + let has_metadata = identities + .find_identity(operation.subject()) + .await? + .is_some(); match operation.phase() { IdentityOperationPhase::CredentialWritten | IdentityOperationPhase::CompensationPending if !has_metadata => @@ -345,18 +407,21 @@ fn recover_addition( if error.code() == harvestcircle_domain::SafeErrorCode::CredentialMissing => {} Err(error) => return Err(error), } - journal.update_operation( - operation.id(), - IdentityOperationPhase::MetadataDeleted, - clock.now(), - None, - )?; + journal + .update_operation( + operation.id(), + IdentityOperationPhase::MetadataDeleted, + clock.now(), + None, + ) + .await?; } _ => {} } - journal.finalize_operation(operation.id()) + journal.finalize_operation(operation.id()).await } +#[cfg(test)] fn removal_fallback( registry: &[harvestcircle_domain::NostrIdentity], selected: Option<PublicKey>, @@ -385,9 +450,9 @@ pub(crate) mod tests { use super::*; use crate::{ - DurableOperationReceipt, DurableOperationStart, DurableRequestId, FailureSecretStore, - InMemoryIdentityRepository, InMemoryOperationJournal, InMemorySecretStore, - RelayConfiguration, SecretStore, SecretStoreOperation, + BoxFuture, DurableOperationReceipt, DurableOperationStart, DurableRequestId, + FailureSecretStore, InMemoryIdentityRepository, InMemoryOperationJournal, + InMemorySecretStore, RelayConfiguration, SecretStore, SecretStoreOperation, }; struct FixedClock; @@ -445,81 +510,89 @@ pub(crate) mod tests { } impl DurableOperationRepository for TestDurableRepository { - fn begin_durable_operation( - &self, - _request_id: &DurableRequestId, + fn begin_durable_operation<'a>( + &'a self, + _request_id: &'a DurableRequestId, _kind: DurableOperationKind, _identity: 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) + ) -> BoxFuture<'a, Result<DurableOperationStart, SafeError>> { + Box::pin(async move { + 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<DurableIdentityOperation>, SafeError> { - if !self.return_existing { - return Ok(None); - } - let operation = self.operation(); - Ok((operation.request_id() == request_id).then(|| operation.clone())) + fn load_durable_operation<'a>( + &'a self, + request_id: &'a DurableRequestId, + ) -> BoxFuture<'a, Result<Option<DurableIdentityOperation>, SafeError>> { + Box::pin(async move { + 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, + fn advance_durable_operation<'a>( + &'a self, + request_id: &'a DurableRequestId, expected_phase: DurableOperationPhase, next_phase: DurableOperationPhase, _updated_at: UnixTimestamp, diagnostic: Option<crate::OperationDiagnostic>, - ) -> Result<DurableIdentityOperation, 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()) + ) -> BoxFuture<'a, Result<DurableIdentityOperation, SafeError>> { + Box::pin(async move { + 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, + fn finalize_durable_operation<'a>( + &'a self, + request_id: &'a 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.identity(), - outcome, - resulting_revision, - ); - *operation = Self::replace( - &operation, - DurableOperationPhase::Finalized, - operation.diagnostic(), - Some(receipt.clone()), - ); - Ok(receipt) + ) -> BoxFuture<'a, Result<DurableOperationReceipt, SafeError>> { + Box::pin(async move { + 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.identity(), + outcome, + resulting_revision, + ); + *operation = Self::replace( + &operation, + DurableOperationPhase::Finalized, + operation.diagnostic(), + Some(receipt.clone()), + ); + Ok(receipt) + }) } fn list_unfinished_durable_operations( &self, - ) -> Result<Vec<DurableIdentityOperation>, SafeError> { - Ok(vec![self.operation().clone()]) + ) -> BoxFuture<'_, Result<Vec<DurableIdentityOperation>, SafeError>> { + Box::pin(async move { Ok(vec![self.operation().clone()]) }) } } @@ -530,7 +603,7 @@ pub(crate) mod tests { ) } - fn seeded() -> ( + async fn seeded() -> ( AppCore, InMemoryIdentityRepository, InMemorySecretStore, @@ -544,6 +617,7 @@ pub(crate) mod tests { core.bootstrap().expect("bootstrap"); let receipt = core .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .await .expect("seed identity"); ( core, @@ -573,7 +647,7 @@ pub(crate) mod tests { ) } - fn run_durable( + async fn run_durable( core: &AppCore, identities: &InMemoryIdentityRepository, secrets: &InMemorySecretStore, @@ -581,12 +655,13 @@ pub(crate) mod tests { ) -> DurableIdentityOperation { let repository = TestDurableRepository::new(operation); core.recover_durable_operations(identities, identities, secrets, &repository, &FixedClock) + .await .expect("durable recovery"); repository.operation().clone() } - #[test] - fn durable_recovery_exercises_every_removal_phase_and_presence_branch() { + #[tokio::test] + async fn durable_recovery_exercises_every_removal_phase_and_presence_branch() { for phase in [ DurableOperationPhase::IntentRecorded, DurableOperationPhase::CredentialDeleted, @@ -594,7 +669,7 @@ pub(crate) mod tests { DurableOperationPhase::SelectionCommitted, DurableOperationPhase::Finalized, ] { - let (core, identities, secrets, _journal, public_key) = seeded(); + let (core, identities, secrets, _journal, public_key) = seeded().await; if phase != DurableOperationPhase::IntentRecorded { secrets.delete(public_key).expect("delete credential"); } @@ -606,6 +681,7 @@ pub(crate) mod tests { ) { identities .remove_identity(public_key) + .await .expect("remove identity"); } let recovered = run_durable( @@ -613,14 +689,16 @@ pub(crate) mod tests { &identities, &secrets, operation(DurableOperationKind::Remove, phase, public_key, None), - ); + ) + .await; assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); } - let (core, identities, secrets, _journal, public_key) = seeded(); + let (core, identities, secrets, _journal, public_key) = seeded().await; secrets.delete(public_key).expect("delete credential"); identities .remove_identity(public_key) + .await .expect("remove identity"); let recovered = run_durable( &core, @@ -632,12 +710,13 @@ pub(crate) mod tests { public_key, None, ), - ); + ) + .await; assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); } - #[test] - fn durable_recovery_exercises_every_addition_phase_and_compensation_shape() { + #[tokio::test] + async fn durable_recovery_exercises_every_addition_phase_and_compensation_shape() { for phase in [ DurableOperationPhase::IntentRecorded, DurableOperationPhase::CredentialWritten, @@ -647,10 +726,11 @@ pub(crate) mod tests { DurableOperationPhase::MetadataDeleted, DurableOperationPhase::Finalized, ] { - let (core, identities, secrets, _journal, public_key) = seeded(); + let (core, identities, secrets, _journal, public_key) = seeded().await; if phase == DurableOperationPhase::IntentRecorded { identities .remove_identity(public_key) + .await .expect("remove metadata"); } let recovered = run_durable( @@ -658,7 +738,8 @@ pub(crate) mod tests { &identities, &secrets, operation(DurableOperationKind::Create, phase, public_key, None), - ); + ) + .await; assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); } @@ -668,10 +749,11 @@ pub(crate) mod tests { (None, true, true), (None, false, false), ] { - let (core, identities, secrets, _journal, public_key) = seeded(); + let (core, identities, secrets, _journal, public_key) = seeded().await; if !retain_metadata { identities .remove_identity(public_key) + .await .expect("remove metadata"); } if !retain_secret { @@ -687,13 +769,15 @@ pub(crate) mod tests { public_key, prior, ), - ); + ) + .await; assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); } - let (core, identities, secrets, _journal, public_key) = seeded(); + let (core, identities, secrets, _journal, public_key) = seeded().await; identities .remove_identity(public_key) + .await .expect("remove metadata"); let recovered = run_durable( &core, @@ -705,12 +789,14 @@ pub(crate) mod tests { public_key, None, ), - ); + ) + .await; assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); - let (core, identities, secrets, _journal, public_key) = seeded(); + let (core, identities, secrets, _journal, public_key) = seeded().await; identities .remove_identity(public_key) + .await .expect("remove metadata"); secrets.delete(public_key).expect("delete credential"); let recovered = run_durable( @@ -723,22 +809,25 @@ pub(crate) mod tests { public_key, None, ), - ); + ) + .await; assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); } - #[test] - fn pending_recovery_exercises_removal_and_addition_presence_branches() { + #[tokio::test] + async fn pending_recovery_exercises_removal_and_addition_presence_branches() { for credential_present in [true, false] { - let (core, identities, secrets, journal, public_key) = seeded(); + let (core, identities, secrets, journal, public_key) = seeded().await; if !credential_present { secrets.delete(public_key).expect("delete credential"); identities .save_selected_identity(None) + .await .expect("clear selection"); } journal .begin_operation(IdentityOperationKind::Remove, public_key, FixedClock.now()) + .await .expect("removal intent"); core.recover_pending_operations( &identities, @@ -747,8 +836,9 @@ pub(crate) mod tests { &journal, &FixedClock, ) + .await .expect("removal recovery"); - assert!(journal.list_pending_operations().unwrap().is_empty()); + assert!(journal.list_pending_operations().await.unwrap().is_empty()); } for (kind, metadata_present, credential_present) in [ @@ -756,10 +846,11 @@ pub(crate) mod tests { (IdentityOperationKind::Import, false, false), (IdentityOperationKind::Add, true, true), ] { - let (core, identities, secrets, journal, public_key) = seeded(); + let (core, identities, secrets, journal, public_key) = seeded().await; if !metadata_present { identities .remove_identity(public_key) + .await .expect("remove metadata"); } if !credential_present { @@ -767,6 +858,7 @@ pub(crate) mod tests { } let id = journal .begin_operation(kind, public_key, FixedClock.now()) + .await .expect("addition intent"); journal .update_operation( @@ -775,6 +867,7 @@ pub(crate) mod tests { FixedClock.now(), None, ) + .await .expect("credential phase"); core.recover_pending_operations( &identities, @@ -783,13 +876,15 @@ pub(crate) mod tests { &journal, &FixedClock, ) + .await .expect("addition recovery"); - assert!(journal.list_pending_operations().unwrap().is_empty()); + assert!(journal.list_pending_operations().await.unwrap().is_empty()); } - let (core, identities, _secrets, journal, public_key) = seeded(); + let (core, identities, _secrets, journal, public_key) = seeded().await; identities .remove_identity(public_key) + .await .expect("remove metadata"); let secrets = FailureSecretStore::default(); secrets @@ -804,6 +899,7 @@ pub(crate) mod tests { secrets.fail_next(SecretStoreOperation::Delete); let id = journal .begin_operation(IdentityOperationKind::Add, public_key, FixedClock.now()) + .await .expect("addition intent"); journal .update_operation( @@ -812,6 +908,7 @@ pub(crate) mod tests { FixedClock.now(), None, ) + .await .expect("credential phase"); assert!( core.recover_pending_operations( @@ -821,6 +918,7 @@ pub(crate) mod tests { &journal, &FixedClock, ) + .await .is_err() ); } diff --git a/core/crates/harvestcircle_application/src/session.rs b/core/crates/harvestcircle_application/src/session.rs @@ -23,7 +23,7 @@ impl AppCore { /// /// Returns a safe identity, credential, profile-cache, persistence, or state /// error while preserving any previously active session. - pub fn activate_identity( + pub async fn activate_identity( &self, public_key: PublicKey, identities: &(impl IdentityRepository + ?Sized), @@ -33,10 +33,11 @@ impl AppCore { clock: &(impl Clock + ?Sized), ) -> Result<AppSnapshot, SafeError> { let identity = identities - .find_identity(public_key)? + .find_identity(public_key) + .await? .ok_or_else(identity_not_found)?; self.apply_transition(StateTransition::BeginActivation(public_key))?; - let prepared = (|| { + let prepared = async { let credential = secrets.load(public_key)?; let imported = self.key_material().import(credential)?; let (derived_public_key, _npub, canonical_secret) = imported.into_parts(); @@ -44,7 +45,7 @@ impl AppCore { if derived_public_key != public_key { return Err(invalid_credential()); } - let cached = profiles.load_profile(public_key)?; + let cached = profiles.load_profile(public_key).await?; let active = ActiveIdentitySnapshot::new( identity.with_last_used_at(clock.now()), RelayConnectionState::Disconnected, @@ -55,10 +56,11 @@ impl AppCore { }, cached.map(|profile| profile.candidate().metadata().clone()), ); - identities.update_identity(active.identity())?; - app_state.save_selected_identity(Some(public_key))?; + identities.update_identity(active.identity()).await?; + app_state.save_selected_identity(Some(public_key)).await?; Ok(active) - })(); + } + .await; match prepared { Ok(active) => { self.apply_transition(StateTransition::ActivationSucceeded(Box::new(active))) @@ -90,34 +92,40 @@ mod tests { use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; use crate::{ - AppCore, CachedProfile, Clock, InMemoryIdentityRepository, InMemoryOperationJournal, - InMemorySecretStore, ProfileRefreshStatus, ProfileRepository, RelayConfiguration, - SecretStore, SessionState, + AppCore, BoxFuture, CachedProfile, Clock, InMemoryIdentityRepository, + 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 load_profile( + &self, + _public_key: PublicKey, + ) -> BoxFuture<'_, Result<Option<CachedProfile>, SafeError>> { + Box::pin(async { Ok(None) }) } - fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> { - Ok(()) + fn save_profile<'a>( + &'a self, + _profile: &'a CachedProfile, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } - fn record_refresh_status( - &self, + fn record_refresh_status<'a>( + &'a self, _public_key: PublicKey, _refreshed_at: UnixTimestamp, _status: ProfileRefreshStatus, - ) -> Result<(), SafeError> { - Ok(()) + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } - fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { - Ok(()) + fn remove_profile(&self, _public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async { Ok(()) }) } } @@ -133,8 +141,8 @@ mod tests { SecretKeyInput::parse(value.to_owned()).expect("input") } - #[test] - fn activate_identity_switches_only_after_candidate_is_ready() { + #[tokio::test] + async fn activate_identity_switches_only_after_candidate_is_ready() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); @@ -150,6 +158,7 @@ mod tests { &journal, &FixedClock, ) + .await .expect("first") .identity() .public_key(); @@ -162,6 +171,7 @@ mod tests { &journal, &FixedClock, ) + .await .expect("second") .identity() .public_key(); @@ -174,6 +184,7 @@ mod tests { &secrets, &FixedClock, ) + .await .expect("activate first"); assert_eq!(core.snapshot().session(), SessionState::Active); assert_eq!( @@ -201,6 +212,7 @@ mod tests { &secrets, &FixedClock, ) + .await .expect_err("missing credential"); assert_eq!( error.code(), @@ -221,6 +233,7 @@ mod tests { &secrets, &FixedClock, ) + .await .expect_err("mismatched credential"); assert_eq!( invalid.code(), @@ -235,8 +248,8 @@ mod tests { ); } - #[test] - fn sign_out_retains_saved_identity_selection_and_credential() { + #[tokio::test] + async fn sign_out_retains_saved_identity_selection_and_credential() { let core = AppCore::in_memory(RelayConfiguration::default()); let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); @@ -252,6 +265,7 @@ mod tests { &journal, &FixedClock, ) + .await .expect("import") .identity() .public_key(); @@ -263,6 +277,7 @@ mod tests { &secrets, &FixedClock, ) + .await .expect("activate"); let signed_out = core.sign_out().expect("sign out"); diff --git a/core/crates/harvestcircle_ffi/Cargo.toml b/core/crates/harvestcircle_ffi/Cargo.toml @@ -23,6 +23,8 @@ harvestcircle_nostr.workspace = true harvestcircle_product.workspace = true harvestcircle_runtime.workspace = true harvestcircle_storage.workspace = true +radroots_runtime_paths.workspace = true +radroots_service_sqlite.workspace = true tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } uniffi = "=0.32.0" diff --git a/core/crates/harvestcircle_ffi/build.rs b/core/crates/harvestcircle_ffi/build.rs @@ -17,6 +17,7 @@ const CONTRACT_SOURCES: &[&str] = &[ const BASELINE_PATH: &str = "../../compatibility/harvestcircle-ffi-v4.properties"; const PRODUCT_MANIFEST_PATH: &str = "../../../config/product/harvestcircle-v1.properties"; const SOURCE_PROVENANCE_PATH: &str = "../../provenance/harvestcircle-v1.toml"; +const STORAGE_CONTRACT_PATH: &str = "../harvestcircle_storage/src/contract.rs"; const BASELINE_KEYS: &[&str] = &[ "schema", "contract.id", @@ -48,7 +49,7 @@ fn main() { for source in CONTRACT_SOURCES { println!("cargo:rerun-if-changed={source}"); } - println!("cargo:rerun-if-changed=../harvestcircle_storage/migrations"); + println!("cargo:rerun-if-changed={STORAGE_CONTRACT_PATH}"); println!("cargo:rerun-if-changed={BASELINE_PATH}"); println!("cargo:rerun-if-changed={PRODUCT_MANIFEST_PATH}"); println!("cargo:rerun-if-changed={SOURCE_PROVENANCE_PATH}"); @@ -62,22 +63,10 @@ fn main() { for source in CONTRACT_SOURCES { collect_public_metadata(Path::new(source), &mut metadata); } - let mut migrations = fs::read_dir("../harvestcircle_storage/migrations") - .expect("read HarvestCircle 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.push(format!( + "storage-contract:{}", + hex_digest(&fs::read(STORAGE_CONTRACT_PATH).expect("read storage contract")) + )); metadata.sort(); metadata.dedup(); let normalized = metadata.join("\n"); @@ -112,12 +101,16 @@ fn main() { required(&baseline, "source.foundation_baseline"), ); emit("HARVESTCIRCLE_FFI_CONTRACT_DIGEST", &contract_digest); + let provenance_source = + fs::read_to_string(SOURCE_PROVENANCE_PATH).expect("read source provenance as UTF-8"); + let provenance = + source_provenance::parse(&provenance_source).expect("canonicalize source provenance"); let mut build_provenance = Vec::new(); for (output, input, default) in [ ( "HARVESTCIRCLE_BUILD_SOURCE_COMMIT", "HARVESTCIRCLE_BUILD_SOURCE_COMMIT", - "unknown", + provenance.foundation_baseline(), ), ( "HARVESTCIRCLE_BUILD_SOURCE_DIRTY", @@ -127,7 +120,7 @@ fn main() { ( "HARVESTCIRCLE_BUILD_RADROOTS_REVISION", "HARVESTCIRCLE_BUILD_RADROOTS_REVISION", - "unknown", + provenance.canonical_radroots_revision(), ), ( "HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN", @@ -225,8 +218,8 @@ fn validate_baseline_inputs(baseline: &BTreeMap<String, String>) { assert_eq!(required(baseline, "contract.major"), "4"); assert_eq!(required(baseline, "contract.minor"), "3"); assert_eq!(required(baseline, "snapshot.schema"), "1"); - assert_eq!(required(baseline, "storage.schema.minimum"), "5"); - assert_eq!(required(baseline, "storage.schema.current"), "10"); + assert_eq!(required(baseline, "storage.schema.minimum"), "1"); + assert_eq!(required(baseline, "storage.schema.current"), "1"); assert_eq!( required(baseline, "product.version"), env!("CARGO_PKG_VERSION") @@ -253,23 +246,7 @@ fn validate_baseline_inputs(baseline: &BTreeMap<String, String>) { required(baseline, "source.foundation_baseline") ); - let current_migration = fs::read_dir("../harvestcircle_storage/migrations") - .expect("read migration catalog") - .map(|entry| entry.expect("read migration entry")) - .filter_map(|entry| { - let file_name = entry.file_name(); - let file_name = file_name.to_string_lossy(); - file_name - .strip_prefix('V') - .and_then(|name| name.split_once("__")) - .and_then(|(version, _)| version.parse::<u32>().ok()) - }) - .max() - .expect("at least one storage migration"); - assert_eq!( - required(baseline, "storage.schema.current"), - current_migration.to_string() - ); + assert_eq!(required(baseline, "storage.schema.current"), "1"); } fn required<'a>(baseline: &'a BTreeMap<String, String>, key: &str) -> &'a str { diff --git a/core/crates/harvestcircle_ffi/src/commands.rs b/core/crates/harvestcircle_ffi/src/commands.rs @@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use directories::ProjectDirs; +use directories::BaseDirs; use harvestcircle_application::{ Clock, DurableRequestId, GeneratedKeyRecoveryHandle, RelayConfiguration, RelayEndpointInput, RemovalConfirmationToken, relay_configuration_from_endpoints, @@ -15,14 +15,15 @@ use harvestcircle_domain::{ PublicKey, RelayDestinationPolicy, SafeError, SecretKeyInput, UnixTimestamp, }; use harvestcircle_nostr::SdkNostrClient; -use harvestcircle_product::{ - LEGACY_DATABASE_APPLICATION, LEGACY_DATABASE_FILENAME, LEGACY_DATABASE_ORGANIZATION, - LEGACY_DATABASE_QUALIFIER, -}; use harvestcircle_runtime::{ RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, }; use harvestcircle_storage::OsKeyringSecretStore; +use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, +}; +use radroots_service_sqlite::MigrationBuildIdentity; use crate::{ AppSnapshotDto, IdentityDto, RelayDestinationDto, RelayEndpointDto, WireErrorCategory, @@ -279,6 +280,8 @@ pub(crate) struct RuntimeCore { >, pub(crate) closed: AtomicBool, pub(crate) startup_relay_problem: Option<SafeError>, + #[cfg(test)] + pub(crate) _test_directory: Option<Arc<tempfile::TempDir>>, } impl RuntimeCore { @@ -322,8 +325,8 @@ impl HarvestCircleAppCore { expectation: CompatibilityExpectation, input: RuntimeOpenInputDto, ) -> Result<Arc<Self>, HarvestCircleError> { - let path = application_database_path(&input)?; - Self::open_path_compatible(&path, &expectation, input.relay_input) + let context = application_runtime_context(&input)?; + Self::open_context_compatible(&context, &expectation, input.relay_input) } /// Restores durable public application state. @@ -591,23 +594,21 @@ fn verify_compatibility(expectation: &CompatibilityExpectation) -> Result<(), Ha } impl HarvestCircleAppCore { - fn open_path_compatible( - path: &Path, + fn open_context_compatible( + context: &RuntimeContext, expectation: &CompatibilityExpectation, relay_input: RelayBootstrapInputDto, ) -> Result<Arc<Self>, HarvestCircleError> { verify_compatibility(expectation)?; - std::fs::create_dir_all(path.parent().ok_or_else(path_unavailable)?) - .map_err(|_| path_unavailable())?; - Self::open_path(path, relay_input) + Self::open_context(context, relay_input) } // 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, + fn open_context( + context: &RuntimeContext, relay_input: RelayBootstrapInputDto, ) -> Result<Arc<Self>, HarvestCircleError> { let relay_endpoints = relay_input @@ -631,8 +632,9 @@ impl HarvestCircleAppCore { let (relays, startup_relay_problem) = local_first_relay_configuration(relay_configuration_from_endpoints(&relay_endpoints)); let runtime = runtime()?; + let build = migration_build_identity()?; let actor = runtime.block_on(RuntimeActorHandle::open( - path, + context, relays, RuntimeDependencies::new( Arc::new(OsKeyringSecretStore::default()), @@ -640,6 +642,7 @@ impl HarvestCircleAppCore { Arc::new(SdkNostrClient::new(Duration::from_secs(5))), Arc::new(UuidInstallationIdentitySource), ), + &build, actor_mailbox_capacity()?, runtime.handle(), ))?; @@ -649,6 +652,8 @@ impl HarvestCircleAppCore { observers: Mutex::new(BTreeMap::new()), closed: AtomicBool::new(false), startup_relay_problem, + #[cfg(test)] + _test_directory: None, }), })) } @@ -677,36 +682,86 @@ impl Clock for SystemClock { } } -// ProjectDirs is the production host integration boundary. Development paths -// are supplied explicitly by the desktop host and never inferred from Rust -// process environment. +// BaseDirs is the production host integration boundary. Development roots are +// supplied explicitly by the desktop host and runtime_paths receives only +// validated injected values. #[cfg_attr(coverage_nightly, coverage(off))] -fn application_database_path(input: &RuntimeOpenInputDto) -> Result<PathBuf, HarvestCircleError> { - if let Some(raw_directory) = input.explicit_data_directory.as_deref() { - if !input.development_mode || raw_directory.is_empty() { - return Err(path_unavailable()); - } - let directory = PathBuf::from(raw_directory); - if !directory.is_absolute() { - return Err(path_unavailable()); - } - let metadata = std::fs::symlink_metadata(&directory).map_err(|_| path_unavailable())?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(path_unavailable()); - } - let canonical = std::fs::canonicalize(&directory).map_err(|_| path_unavailable())?; - if canonical != directory { - return Err(path_unavailable()); - } - return Ok(canonical.join(LEGACY_DATABASE_FILENAME)); - } - ProjectDirs::from( - LEGACY_DATABASE_QUALIFIER, - LEGACY_DATABASE_ORGANIZATION, - LEGACY_DATABASE_APPLICATION, +fn application_runtime_context( + input: &RuntimeOpenInputDto, +) -> Result<RuntimeContext, HarvestCircleError> { + let (profile, root, environment, profile_source) = + if let Some(raw_directory) = input.explicit_data_directory.as_deref() { + if !input.development_mode || raw_directory.is_empty() { + return Err(path_unavailable()); + } + let directory = PathBuf::from(raw_directory); + if !directory.is_absolute() { + return Err(path_unavailable()); + } + let metadata = std::fs::symlink_metadata(&directory).map_err(|_| path_unavailable())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(path_unavailable()); + } + let canonical = std::fs::canonicalize(&directory).map_err(|_| path_unavailable())?; + if canonical != directory { + return Err(path_unavailable()); + } + ( + RadrootsPathProfile::RepoLocal, + Some(canonical), + RadrootsHostEnvironment::default(), + RuntimeContextSource::BootstrapCli, + ) + } else { + let base = BaseDirs::new().ok_or_else(path_unavailable)?; + ( + RadrootsPathProfile::InteractiveUser, + None, + RadrootsHostEnvironment { + home_dir: Some(base.home_dir().to_path_buf()), + xdg_config_home: Some(base.config_dir().to_path_buf()), + xdg_data_home: Some(base.data_dir().to_path_buf()), + xdg_state_home: base.state_dir().map(Path::to_path_buf), + xdg_cache_home: Some(base.cache_dir().to_path_buf()), + xdg_runtime_dir: base.runtime_dir().map(Path::to_path_buf), + appdata_dir: None, + localappdata_dir: None, + }, + RuntimeContextSource::SafeDefault, + ) + }; + let resolver = RadrootsPathResolver::new(RadrootsPlatform::current(), environment); + let bootstrap = RuntimeContextBootstrap::new( + profile, + root, + profile_source, + RuntimeContextSource::SafeDefault, + ) + .map_err(|_| path_unavailable())?; + RuntimeContext::resolve( + &resolver, + bootstrap, + ServiceId::new("harvestcircle").map_err(|_| path_unavailable())?, + InstanceId::new("desktop").map_err(|_| path_unavailable())?, + ) + .map_err(|_| path_unavailable()) +} + +fn migration_build_identity() -> Result<MigrationBuildIdentity, HarvestCircleError> { + MigrationBuildIdentity::new( + PRODUCT_VERSION, + BUILD_SOURCE_COMMIT, + BUILD_RADROOTS_REVISION, + BUILD_RUST_TOOLCHAIN, + format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS), + "desktop", + 1, + 1, + 1, + 1, + 1, ) - .map(|project| project.data_dir().join(LEGACY_DATABASE_FILENAME)) - .ok_or_else(path_unavailable) + .map_err(|_| path_unavailable()) } fn parse_public_key(value: &str) -> Result<PublicKey, HarvestCircleError> { @@ -741,6 +796,45 @@ pub(crate) fn runtime() -> Result<&'static tokio::runtime::Runtime, HarvestCircl .map_err(|()| runtime_unavailable()) } +#[cfg(test)] +pub(crate) async fn test_actor( + relays: RelayConfiguration, +) -> (RuntimeActorHandle, Arc<tempfile::TempDir>) { + let directory = Arc::new(tempfile::tempdir().expect("temporary runtime root")); + let context = application_runtime_context(&RuntimeOpenInputDto { + development_mode: true, + explicit_data_directory: Some( + directory + .path() + .canonicalize() + .expect("canonical runtime root") + .to_string_lossy() + .into_owned(), + ), + relay_input: RelayBootstrapInputDto { + endpoints: Vec::new(), + }, + }) + .expect("runtime context"); + let build = migration_build_identity().expect("migration build identity"); + let actor = RuntimeActorHandle::open( + &context, + relays, + RuntimeDependencies::new( + Arc::new(harvestcircle_application::InMemorySecretStore::default()), + Arc::new(SystemClock), + Arc::new(SdkNostrClient::new(Duration::from_millis(10))), + Arc::new(UuidInstallationIdentitySource), + ), + &build, + actor_mailbox_capacity().expect("capacity"), + runtime().expect("runtime").handle(), + ) + .await + .expect("test actor"); + (actor, directory) +} + fn actor_mailbox_capacity() -> Result<NonZeroUsize, HarvestCircleError> { NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).ok_or_else(runtime_unavailable) } @@ -817,53 +911,35 @@ fn compatibility_mismatch() -> HarvestCircleError { #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { - use std::num::NonZeroUsize; use std::sync::Arc; use harvestcircle_application::{ - InMemorySecretStore, RelayConfiguration, RelayEndpointInput, - relay_configuration_from_endpoints, + RelayConfiguration, RelayEndpointInput, relay_configuration_from_endpoints, }; use harvestcircle_domain::{RelayDestinationPolicy, SafeError}; - use harvestcircle_nostr::SdkNostrClient; - use harvestcircle_runtime::{ - RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, + use harvestcircle_storage::{ + CREDENTIAL_SERVICE, CURRENT_SCHEMA_VERSION, HarvestCircleStorageContract, }; - use harvestcircle_storage::{CREDENTIAL_SERVICE, CURRENT_SCHEMA_VERSION}; - use super::{ - ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, FFI_CONTRACT_HASH, FFI_CONTRACT_ID, - FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, HarvestCircleAppCore, HarvestCircleError, - LEGACY_DATABASE_APPLICATION, LEGACY_DATABASE_FILENAME, LEGACY_DATABASE_ORGANIZATION, - LEGACY_DATABASE_QUALIFIER, PRODUCT_COORDINATE_DIGEST, ProjectDirs, RelayBootstrapInputDto, - RequestContextDto, RuntimeCore, RuntimeOpenInputDto, SNAPSHOT_SCHEMA_VERSION, SystemClock, - WireErrorCategory, WireErrorCode, WireRecoveryAction, actor_mailbox_capacity, - application_database_path, compatibility_descriptor, confirmation_expired, - generated_commit_failed, local_first_relay_configuration, path_unavailable, runtime, - runtime_unavailable, verify_compatibility, + CompatibilityExpectation, FFI_CONTRACT_HASH, FFI_CONTRACT_ID, FFI_CONTRACT_MAJOR, + FFI_CONTRACT_MINOR, HarvestCircleAppCore, HarvestCircleError, PRODUCT_COORDINATE_DIGEST, + RelayBootstrapInputDto, RequestContextDto, RuntimeCore, RuntimeOpenInputDto, + SNAPSHOT_SCHEMA_VERSION, WireErrorCategory, WireErrorCode, WireRecoveryAction, + actor_mailbox_capacity, application_runtime_context, compatibility_descriptor, + confirmation_expired, generated_commit_failed, local_first_relay_configuration, + path_unavailable, runtime_unavailable, test_actor, verify_compatibility, }; async fn in_memory_core() -> Arc<HarvestCircleAppCore> { - 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"); + let (actor, directory) = test_actor(RelayConfiguration::default()).await; Arc::new(HarvestCircleAppCore { 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, + _test_directory: Some(directory), }), }) } @@ -1132,7 +1208,7 @@ mod tests { contract_hash: FFI_CONTRACT_HASH.to_owned(), product_coordinate_digest: PRODUCT_COORDINATE_DIGEST.to_owned(), snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION, - minimum_schema_version: 5, + minimum_schema_version: 1, maximum_schema_version: CURRENT_SCHEMA_VERSION, }; verify_compatibility(&compatible).expect("compatible"); @@ -1175,45 +1251,64 @@ mod tests { } let directory = tempfile::tempdir().expect("directory"); - let rejected = directory + let canonical = directory .path() - .join("rejected") - .join("harvestcircle.sqlite3"); + .canonicalize() + .expect("canonical directory"); + let input = RuntimeOpenInputDto { + development_mode: true, + explicit_data_directory: Some(canonical.to_string_lossy().into_owned()), + relay_input: RelayBootstrapInputDto { + endpoints: Vec::new(), + }, + }; + let context = application_runtime_context(&input).expect("context"); + let rejected = HarvestCircleStorageContract::from_runtime_context(&context) + .expect("storage contract") + .paths() + .state_database() + .to_path_buf(); let incompatible = CompatibilityExpectation { contract_major: FFI_CONTRACT_MAJOR + 1, ..compatible }; assert!( - HarvestCircleAppCore::open_path_compatible( - &rejected, + HarvestCircleAppCore::open_context_compatible( + &context, &incompatible, - RelayBootstrapInputDto { - endpoints: Vec::new(), - }, + input.relay_input, ) .is_err() ); - assert!(!rejected.parent().expect("parent").exists()); + assert!(!rejected.exists()); } #[test] fn final_product_coordinates_do_not_adopt_the_temporary_namespace() { - assert_eq!(LEGACY_DATABASE_QUALIFIER, "org"); - assert_eq!(LEGACY_DATABASE_ORGANIZATION, "harvestcircle"); - assert_eq!(LEGACY_DATABASE_APPLICATION, "desktop"); - assert_eq!(LEGACY_DATABASE_FILENAME, "harvestcircle.sqlite3"); assert_eq!(CREDENTIAL_SERVICE, "org.harvestcircle.desktop.nostr"); - - let current = ProjectDirs::from( - LEGACY_DATABASE_QUALIFIER, - LEGACY_DATABASE_ORGANIZATION, - LEGACY_DATABASE_APPLICATION, - ) - .expect("current product coordinates"); - let temporary = - ProjectDirs::from("org", "radroots", "harvestcircle").expect("temporary coordinates"); - assert_ne!(current.data_dir(), temporary.data_dir()); - assert_eq!(CURRENT_SCHEMA_VERSION, 10); + let temporary = tempfile::tempdir().expect("directory"); + let canonical = temporary + .path() + .canonicalize() + .expect("canonical directory"); + let context = application_runtime_context(&RuntimeOpenInputDto { + development_mode: true, + explicit_data_directory: Some(canonical.to_string_lossy().into_owned()), + relay_input: RelayBootstrapInputDto { + endpoints: Vec::new(), + }, + }) + .expect("context"); + assert_eq!(context.service().as_str(), "harvestcircle"); + assert_eq!(context.instance().as_str(), "desktop"); + let database = HarvestCircleStorageContract::from_runtime_context(&context) + .expect("storage contract") + .paths() + .state_database() + .to_path_buf(); + assert!(database.ends_with("data/services/harvestcircle/desktop/state.sqlite")); + assert!(!database.to_string_lossy().contains("harvestcircle.sqlite3")); + assert_eq!(CURRENT_SCHEMA_VERSION, 1); } #[test] @@ -1231,9 +1326,14 @@ mod tests { explicit_data_directory: Some(canonical.to_string_lossy().into_owned()), relay_input: relay_input.clone(), }; - assert_eq!( - application_database_path(&explicit).expect("explicit path"), - canonical.join(LEGACY_DATABASE_FILENAME), + let context = application_runtime_context(&explicit).expect("explicit context"); + assert_eq!(context.repo_local_root(), Some(canonical.as_path())); + assert!( + HarvestCircleStorageContract::from_runtime_context(&context) + .expect("storage contract") + .paths() + .state_database() + .ends_with("data/services/harvestcircle/desktop/state.sqlite") ); for rejected in [ @@ -1255,7 +1355,7 @@ mod tests { relay_input, }, ] { - assert!(application_database_path(&rejected).is_err()); + assert!(application_runtime_context(&rejected).is_err()); } } @@ -1277,7 +1377,7 @@ mod tests { }, }; - assert!(application_database_path(&input).is_err()); + assert!(application_runtime_context(&input).is_err()); } #[test] diff --git a/core/crates/harvestcircle_ffi/src/contract.rs b/core/crates/harvestcircle_ffi/src/contract.rs @@ -5,7 +5,7 @@ pub const FFI_CONTRACT_MAJOR: u16 = 4; pub const FFI_CONTRACT_MINOR: u16 = 3; pub const PRODUCT_COORDINATE_DIGEST: &str = env!("HARVESTCIRCLE_PRODUCT_COORDINATE_DIGEST"); pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1; -pub const MINIMUM_SCHEMA_VERSION: u32 = 5; +pub const MINIMUM_SCHEMA_VERSION: u32 = 1; pub const SOURCE_PROVENANCE_DIGEST: &str = env!("HARVESTCIRCLE_SOURCE_PROVENANCE_DIGEST"); pub const SOURCE_FOUNDATION_BASELINE: &str = env!("HARVESTCIRCLE_SOURCE_FOUNDATION_BASELINE"); pub const FFI_CONTRACT_HASH: &str = env!("HARVESTCIRCLE_FFI_CONTRACT_DIGEST"); diff --git a/core/crates/harvestcircle_ffi/src/observer.rs b/core/crates/harvestcircle_ffi/src/observer.rs @@ -218,29 +218,22 @@ fn observer_registration_error() -> HarvestCircleError { #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { - use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; use std::time::Duration; - use harvestcircle_application::{InMemorySecretStore, RelayConfiguration}; + use harvestcircle_application::RelayConfiguration; use harvestcircle_domain::{RelayDestinationPolicy, RelayEndpoint}; - use harvestcircle_nostr::SdkNostrClient; - use harvestcircle_runtime::{ - RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, - }; use nostr::{EventBuilder, Keys, Metadata}; use nostr_relay_builder::MockRelay; use nostr_sdk::Client; - use crate::commands::{ACTOR_MAILBOX_CAPACITY, RuntimeCore, SystemClock, runtime}; + use crate::commands::{RuntimeCore, runtime, test_actor}; use crate::{ AppSnapshotDto, HarvestCircleAppCore, HarvestCircleChangeObserver, ProfileLoadStateDto, SnapshotChangeDto, }; const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - const TEST_RELAY_TIMEOUT: Duration = Duration::from_secs(2); - #[derive(Default)] struct RecordingObserver { snapshots: Mutex<Vec<AppSnapshotDto>>, @@ -270,25 +263,14 @@ mod tests { } async fn core_with_relays(relays: RelayConfiguration) -> Arc<HarvestCircleAppCore> { - let actor = RuntimeActorHandle::in_memory( - relays, - RuntimeDependencies::new( - Arc::new(InMemorySecretStore::default()), - Arc::new(SystemClock), - Arc::new(SdkNostrClient::new(TEST_RELAY_TIMEOUT)), - Arc::new(UuidInstallationIdentitySource), - ), - NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("capacity"), - runtime().expect("runtime").handle(), - ) - .await - .expect("actor"); + let (actor, directory) = test_actor(relays).await; Arc::new(HarvestCircleAppCore { inner: Arc::new(RuntimeCore { actor, observers: Mutex::new(std::collections::BTreeMap::new()), closed: std::sync::atomic::AtomicBool::new(false), startup_relay_problem: None, + _test_directory: Some(directory), }), }) } diff --git a/core/crates/harvestcircle_product/src/provenance.rs b/core/crates/harvestcircle_product/src/provenance.rs @@ -26,6 +26,12 @@ impl SourceProvenance { .expect("validated provenance has a foundation baseline") } + pub fn canonical_radroots_revision(&self) -> &str { + self.root + .get("canonical_radroots_revision") + .expect("validated provenance has a canonical Radroots revision") + } + pub fn canonical(&self) -> String { let mut canonical = String::new(); for key in ROOT_KEYS { diff --git a/core/crates/harvestcircle_runtime/Cargo.toml b/core/crates/harvestcircle_runtime/Cargo.toml @@ -16,6 +16,8 @@ harvestcircle_application.workspace = true harvestcircle_domain.workspace = true harvestcircle_nostr.workspace = true harvestcircle_storage.workspace = true +radroots_runtime_paths.workspace = true +radroots_service_sqlite.workspace = true tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } uuid.workspace = true diff --git a/core/crates/harvestcircle_runtime/src/lib.rs b/core/crates/harvestcircle_runtime/src/lib.rs @@ -1,5 +1,6 @@ #![doc = "HarvestCircle supervised runtime composition."] +#[cfg(test)] mod blocking; mod installation; mod persistence; diff --git a/core/crates/harvestcircle_runtime/src/persistence.rs b/core/crates/harvestcircle_runtime/src/persistence.rs @@ -1,4 +1,3 @@ -use std::path::Path; use std::sync::Arc; use harvestcircle_application::{ @@ -8,8 +7,9 @@ use harvestcircle_application::{ }; use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput}; use harvestcircle_nostr::NostrKeyMaterialProvider; - use harvestcircle_storage::Database; +use radroots_runtime_paths::RuntimeContext; +use radroots_service_sqlite::MigrationBuildIdentity; use crate::{InstallationIdentity, InstallationIdentitySource}; @@ -20,67 +20,33 @@ pub struct PersistentAppCore { } impl PersistentAppCore { - pub(crate) fn initialize_installation_identity( + pub(crate) async fn initialize_installation_identity( &self, source: &dyn InstallationIdentitySource, ) -> Result<InstallationIdentity, SafeError> { - if let Some(existing) = self.database.load_installation_id()? { + if let Some(existing) = self.database.load_installation_id().await? { return InstallationIdentity::parse(existing); } let candidate = source.generate()?; InstallationIdentity::parse( self.database - .initialize_installation_id(candidate.as_str())?, + .initialize_installation_id(candidate.as_str()) + .await?, ) } - /// 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<ImportIdentityReceipt, 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> { + /// Opens the canonical application database without accessing credentials or relays. + pub async fn open( + context: &RuntimeContext, + relay_configuration: RelayConfiguration, + created_at_unix_ms: u64, + applied_at_unix_s: u64, + build: &MigrationBuildIdentity, + ) -> 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()?, + database: Database::open(context, created_at_unix_ms, applied_at_unix_s, build).await?, key_material, }) } @@ -89,103 +55,67 @@ impl PersistentAppCore { self.key_material.as_ref() } - /// Restores public identities 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( + /// Reconciles the UUID ledger and restores public state without activating a session. + pub async 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 identity. - /// - /// # Errors - /// - /// Returns a safe credential, storage, key, or application-state error. - pub fn generate_identity( - &self, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<GenerateIdentityReceipt, SafeError> { - self.core.generate_identity( - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) + self.core + .recover_durable_operations( + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + .await?; + self.core + .bootstrap_from(&self.database, &self.database) + .await } - /// Imports and durably persists one selected, signed-out local identity. - /// - /// # Errors - /// - /// Returns a safe credential, storage, key, or application-state error. - pub fn import_secret_key( + pub async fn commit_staged_generated_key( &self, - input: SecretKeyInput, + request_id: &DurableRequestId, + staged: StagedGeneratedKey, secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<ImportIdentityReceipt, SafeError> { - self.core.import_secret_key( - input, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) + self.core + .commit_staged_generated_key( + request_id, + staged, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + .await } - /// Generates an identity through the durable request coordinator. - /// - /// # Errors - /// - /// Returns a safe conflict, credential, storage, or application-state error. - pub fn generate_identity_durable( + pub async fn generate_identity_durable( &self, request_id: &DurableRequestId, expected_revision: u64, secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<GenerateIdentityReceipt, SafeError> { - self.core.generate_identity_durable( - request_id, - expected_revision, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) + self.core + .generate_identity_durable( + request_id, + expected_revision, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + .await } - /// Imports or repairs an identity through the durable request coordinator. - /// - /// # Errors - /// - /// Returns a safe conflict, validation, credential, storage, or state error. - pub fn import_secret_key_durable( + pub async fn import_secret_key_durable( &self, request_id: &DurableRequestId, expected_revision: u64, @@ -193,63 +123,48 @@ impl PersistentAppCore { secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<ImportIdentityReceipt, SafeError> { - self.core.import_secret_key_durable( - request_id, - expected_revision, - input, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) + self.core + .import_secret_key_durable( + request_id, + expected_revision, + input, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + .await } - /// Persists and publishes one saved-identity selection without activation. - /// - /// # Errors - /// - /// Returns a safe identity, storage, or application-state error. - pub fn select_identity(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { + pub async fn select_identity(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { self.core .select_identity(public_key, &self.database, &self.database) + .await } - /// Activates a saved identity after validating its credential and cached profile. - /// - /// # Errors - /// - /// Returns a safe identity, credential, storage, or application-state error. - pub fn activate_identity( + pub async fn activate_identity( &self, public_key: PublicKey, secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<AppSnapshot, SafeError> { - self.core.activate_identity( - public_key, - &self.database, - &self.database, - &self.database, - secrets, - clock, - ) + self.core + .activate_identity( + public_key, + &self.database, + &self.database, + &self.database, + secrets, + clock, + ) + .await } - /// Signs out while retaining durable identity 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 identity-removal confirmation. - /// - /// # Errors - /// - /// Returns a safe error when the target identity is not saved. pub fn request_identity_removal( &self, public_key: PublicKey, @@ -258,48 +173,28 @@ impl PersistentAppCore { self.core.request_identity_removal(public_key, clock) } - /// Permanently removes one confirmed identity and its credential. - /// - /// # Errors - /// - /// Returns a safe confirmation, credential, storage, recovery, or state error. - pub fn confirm_identity_removal( + pub async fn confirm_identity_removal_durable( &self, + request_id: &DurableRequestId, token: RemovalConfirmationToken, secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<AppSnapshot, SafeError> { - self.core.confirm_identity_removal( - token, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) + self.core + .confirm_identity_removal_durable( + request_id, + token, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + .await } - /// Executes a confirmed removal through the durable request coordinator. - /// - /// # Errors - /// - /// Returns a safe expiry, conflict, credential, storage, recovery, or state error. - pub fn confirm_identity_removal_durable( - &self, - request_id: &DurableRequestId, - token: RemovalConfirmationToken, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<AppSnapshot, SafeError> { - self.core.confirm_identity_removal_durable( - request_id, - token, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) + pub async fn close(&self) -> Result<(), SafeError> { + self.database.close().await } #[must_use] @@ -312,464 +207,3 @@ impl PersistentAppCore { &self.database } } - -#[cfg(test)] -mod tests { - use std::fs; - - use harvestcircle_application::{ - AppLifecycle, AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase, - DurableOperationRepository, DurableRequestId, DurableTerminalOutcome, FailureSecretStore, - IdentityOperationKind, IdentityOperationPhase, IdentityRepository, InMemorySecretStore, - OperationJournal, OperationPriorState, RelayConfiguration, SecretStore, - SecretStoreOperation, SessionState, - }; - use harvestcircle_domain::{ - IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, PublicKey, - SafeErrorCode, SecretKeyInput, SignerAvailability, UnixTimestamp, - }; - use tempfile::tempdir; - - use super::PersistentAppCore; - - fn identity() -> NostrIdentity { - let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); - NostrIdentity::new( - NostrIdentityReference::derive(public_key).expect("identity"), - LocalKeyringBinding::new(public_key, SignerAvailability::Available), - None, - IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), - None, - ) - .expect("identity") - } - - 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() - .canonicalize() - .expect("canonical temporary directory") - .join("harvestcircle.sqlite3"); - let public_key = identity().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.identities().is_empty()); - adapter - .database() - .insert_identity(&identity()) - .expect("identity"); - adapter - .database() - .save_selected_identity(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.identities().len(), 1); - assert_eq!(restored.selected_identity(), Some(public_key)); - assert_eq!(restored.session(), SessionState::SignedOut); - assert!(restored.active_identity().is_none()); - } - - #[test] - fn corrupt_database_fails_safely_without_recreation() { - let directory = tempdir().expect("directory"); - let path = directory - .path() - .canonicalize() - .expect("canonical temporary directory") - .join("harvestcircle.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() - .canonicalize() - .expect("canonical temporary directory") - .join("harvestcircle.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_identity(&secrets, &FixedClock) - .expect("generate"); - assert!( - secrets - .contains(generated.identity().public_key()) - .expect("generated credential") - ); - let imported = adapter - .import_secret_key( - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" - .to_owned(), - ) - .expect("secret"), - &secrets, - &FixedClock, - ) - .expect("import"); - selected = imported.identity().public_key(); - assert_eq!(adapter.core().snapshot().identities().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.identities().len(), 2); - assert_eq!(restored.selected_identity(), 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("01890f3e-7b1c-7000-8000-000000000021").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.identity(), imported.identity().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 = identity() - .with_local_keyring_availability(SignerAvailability::CredentialMissing) - .expect("local keyring"); - adapter - .database() - .insert_identity(&missing) - .expect("identity"); - adapter - .database() - .save_selected_identity(Some(missing.public_key())) - .expect("selection"); - let request = - DurableRequestId::parse("01890f3e-7b1c-7000-8000-000000000022").expect("request"); - adapter - .database() - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - missing.public_key(), - Some(0), - OperationPriorState::new( - Some(missing.public_key()), - Some(SignerAvailability::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_identity(missing.public_key()) - .expect("lookup") - .expect("preserved identity"); - assert_eq!( - repaired - .signer_binding() - .as_local_keyring() - .expect("local keyring") - .availability(), - SignerAvailability::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 = identity(); - adapter - .database() - .insert_identity(&saved) - .expect("identity"); - let import = - DurableRequestId::parse("01890f3e-7b1c-7000-8000-000000000023").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_identity(), 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_identity(&saved) - .expect("remove identity"); - removal_adapter - .database() - .save_selected_identity(Some(saved.public_key())) - .expect("remove selection"); - let removal = - DurableRequestId::parse("01890f3e-7b1c-7000-8000-000000000024").expect("request"); - removal_adapter - .database() - .begin_durable_operation( - &removal, - DurableOperationKind::Remove, - saved.public_key(), - Some(0), - OperationPriorState::new(None, Some(SignerAvailability::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.identities().is_empty()); - assert_eq!(removed.selected_identity(), None); - } - - #[test] - fn bootstrap_recovery_completes_credential_deleted_removal_and_fallback() { - let directory = tempdir().expect("directory"); - let path = directory - .path() - .canonicalize() - .expect("canonical temporary directory") - .join("harvestcircle.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_identity(&secrets, &FixedClock) - .expect("first") - .identity() - .public_key(); - removed = adapter - .generate_identity(&secrets, &FixedClock) - .expect("removed") - .identity() - .public_key(); - let operation = adapter - .database() - .begin_operation(IdentityOperationKind::Remove, removed, FixedClock.now()) - .expect("intent"); - secrets.delete(removed).expect("credential deletion"); - adapter - .database() - .update_operation( - operation, - IdentityOperationPhase::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.identities().len(), 1); - assert_eq!(restored.selected_identity(), Some(first)); - assert_eq!(restored.session(), SessionState::SignedOut); - assert!( - reopened - .database() - .list_pending_operations() - .expect("journal") - .is_empty() - ); - assert!( - reopened - .database() - .find_identity(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_identity(&identity()) - .expect("identity"); - adapter - .database() - .save_selected_identity(Some(identity().public_key())) - .expect("selection"); - adapter - .database() - .begin_operation( - IdentityOperationKind::Remove, - identity().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(), IdentityOperationPhase::IntentRecorded); - } -} diff --git a/core/crates/harvestcircle_runtime/src/runtime_actor.rs b/core/crates/harvestcircle_runtime/src/runtime_actor.rs @@ -1,7 +1,6 @@ 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}; @@ -19,15 +18,20 @@ use harvestcircle_domain::{ LocalKeyringBinding, NostrIdentityReference, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, SignerAvailability, }; +use radroots_runtime_paths::RuntimeContext; +#[cfg(test)] +use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, +}; +use radroots_service_sqlite::MigrationBuildIdentity; 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, @@ -108,7 +112,6 @@ struct RuntimeActor { 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>, @@ -142,6 +145,8 @@ pub struct RuntimeActorHandle { runtime: Handle, actor_task: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>, actor_exit: watch::Receiver<bool>, + #[cfg(test)] + _test_directory: Option<Arc<tempfile::TempDir>>, } #[derive(Clone)] @@ -193,42 +198,27 @@ impl RuntimeActorHandle { /// 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( + context: &RuntimeContext, relay_configuration: RelayConfiguration, dependencies: RuntimeDependencies, + build: &MigrationBuildIdentity, 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 + let applied_at_unix_s = u64::try_from(dependencies.clock.now().as_seconds()) + .map_err(|_| invalid_runtime_evidence())?; + let created_at_unix_ms = applied_at_unix_s + .checked_mul(1_000) + .ok_or_else(invalid_runtime_evidence)?; + let adapter = PersistentAppCore::open( + context, + relay_configuration, + created_at_unix_ms, + applied_at_unix_s, + build, + ) + .await?; + Self::start(adapter, dependencies, capacity, runtime).await } async fn start( @@ -236,7 +226,6 @@ impl RuntimeActorHandle { dependencies: RuntimeDependencies, capacity: NonZeroUsize, runtime: &Handle, - blocking: BoundedBlockingExecutor, ) -> Result<Self, SafeError> { let mut gate = LifecycleGate::opening(); gate.begin_compatibility_check()?; @@ -250,17 +239,10 @@ impl RuntimeActorHandle { 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)??; + adapter.bootstrap(secrets.as_ref(), clock.as_ref()).await?; + let installation_identity = adapter + .initialize_installation_identity(installation_source.as_ref()) + .await?; gate.recovery_complete()?; let lifecycle = Arc::new(Mutex::new(gate)); @@ -275,7 +257,6 @@ impl RuntimeActorHandle { nostr, lifecycle: Arc::clone(&lifecycle), runtime: runtime.clone(), - blocking, session_generation: SessionGeneration::initial(), published_session_generation: Arc::clone(&session_generation), profile_tasks: BTreeMap::new(), @@ -299,9 +280,34 @@ impl RuntimeActorHandle { runtime: runtime.clone(), actor_task: Arc::new(Mutex::new(Some(actor_task))), actor_exit, + #[cfg(test)] + _test_directory: None, }) } + #[cfg(test)] + async fn in_memory( + relay_configuration: RelayConfiguration, + dependencies: RuntimeDependencies, + capacity: NonZeroUsize, + runtime: &Handle, + ) -> Result<Self, SafeError> { + let directory = Arc::new(tempfile::tempdir().map_err(|_| invalid_runtime_evidence())?); + let context = test_runtime_context(directory.path())?; + let build = test_migration_build_identity()?; + let mut actor = Self::open( + &context, + relay_configuration, + dependencies, + &build, + capacity, + runtime, + ) + .await?; + actor._test_directory = Some(directory); + Ok(actor) + } + #[must_use] pub fn lifecycle(&self) -> RuntimeLifecycle { self.lifecycle @@ -925,16 +931,20 @@ impl RuntimeActor { durable_request, expected_revision, } => { - self.run_blocking(context.deadline(), move |adapter, secrets, clock| { - adapter - .generate_identity_durable( - &durable_request, - expected_revision, - secrets.as_ref(), - clock.as_ref(), - ) - .map(RuntimeCommandValue::Generated) - }) + self.run_async( + context.deadline(), + move |adapter, secrets, clock| async move { + adapter + .generate_identity_durable( + &durable_request, + expected_revision, + secrets.as_ref(), + clock.as_ref(), + ) + .await + .map(RuntimeCommandValue::Generated) + }, + ) .await } RuntimeCommand::BeginGeneratedKeyStage => { @@ -956,15 +966,19 @@ impl RuntimeActor { 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, - ) - }) + self.run_async( + context.deadline(), + move |adapter, secrets, clock| async move { + commit_generated_key_stage( + adapter.as_ref(), + secrets.as_ref(), + clock.as_ref(), + &durable_request, + staged, + ) + .await + }, + ) .await } Err(error) => Err(error), @@ -977,35 +991,44 @@ impl RuntimeActor { 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) - }) + self.run_async( + context.deadline(), + move |adapter, secrets, clock| async move { + adapter + .import_secret_key_durable( + &durable_request, + expected_revision, + input, + secrets.as_ref(), + clock.as_ref(), + ) + .await + .map(RuntimeCommandValue::Imported) + }, + ) .await } RuntimeCommand::SelectIdentity(public_key) => { - self.run_blocking(context.deadline(), move |adapter, _, _| { + self.run_async(context.deadline(), move |adapter, _, _| async move { adapter .select_identity(public_key) + .await .map(Box::new) .map(RuntimeCommandValue::Snapshot) }) .await } RuntimeCommand::ActivateIdentity(public_key) => { - self.run_blocking(context.deadline(), move |adapter, secrets, clock| { - adapter - .activate_identity(public_key, secrets.as_ref(), clock.as_ref()) - .map(Box::new) - .map(RuntimeCommandValue::Snapshot) - }) + self.run_async( + context.deadline(), + move |adapter, secrets, clock| async move { + adapter + .activate_identity(public_key, secrets.as_ref(), clock.as_ref()) + .await + .map(Box::new) + .map(RuntimeCommandValue::Snapshot) + }, + ) .await } RuntimeCommand::SignOut => self @@ -1021,17 +1044,21 @@ impl RuntimeActor { token, durable_request, } => { - self.run_blocking(context.deadline(), move |adapter, secrets, clock| { - adapter - .confirm_identity_removal_durable( - &durable_request, - token, - secrets.as_ref(), - clock.as_ref(), - ) - .map(Box::new) - .map(RuntimeCommandValue::Snapshot) - }) + self.run_async( + context.deadline(), + move |adapter, secrets, clock| async move { + adapter + .confirm_identity_removal_durable( + &durable_request, + token, + secrets.as_ref(), + clock.as_ref(), + ) + .await + .map(Box::new) + .map(RuntimeCommandValue::Snapshot) + }, + ) .await } RuntimeCommand::SubscribeChanges(capacity) => self @@ -1051,27 +1078,22 @@ impl RuntimeActor { result.map_or_else(CommandResult::Failed, CommandResult::Completed) } - async fn run_blocking<F>( + async fn run_async<F, Fut>( &self, deadline: Instant, operation: F, ) -> Result<RuntimeCommandValue, SafeError> where - F: FnOnce( - Arc<PersistentAppCore>, - Arc<dyn SecretStore>, - Arc<dyn Clock>, - ) -> Result<RuntimeCommandValue, SafeError> - + Send - + 'static, + F: FnOnce(Arc<PersistentAppCore>, Arc<dyn SecretStore>, Arc<dyn Clock>) -> Fut, + Fut: Future<Output = Result<RuntimeCommandValue, SafeError>>, { 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)) + let remaining = deadline.saturating_duration_since(Instant::now()); + tokio::time::timeout(remaining, operation(adapter, secrets, clock)) .await - .map_err(blocking_execution_failed)? + .map_err(|_| command_timed_out())? } async fn start_profile_task( @@ -1158,24 +1180,38 @@ impl RuntimeActor { } async fn close_actor(&mut self) -> CommandResult<RuntimeCommandValue> { - let transition = (|| { + let begin = { let mut lifecycle = self .lifecycle .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - lifecycle.begin_shutdown()?; - lifecycle.finish_shutdown() - })(); - match transition { + lifecycle.begin_shutdown() + }; + match begin { Ok(()) => { self.generated_key_stage.cancel(); self.cancel_profile_tasks(None).await; + if let Err(error) = self.adapter.close().await { + self.lifecycle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .fail(error); + return CommandResult::Failed(error); + } self.changes.close(); *self .published_foreground_session .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = None; - CommandResult::Completed(RuntimeCommandValue::Closed) + match self + .lifecycle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .finish_shutdown() + { + Ok(()) => CommandResult::Completed(RuntimeCommandValue::Closed), + Err(error) => CommandResult::Failed(error), + } } Err(error) => CommandResult::Failed(error), } @@ -1204,7 +1240,7 @@ impl RuntimeActor { let result = if correlated { let plan = task.plan.clone(); let completed = self - .run_blocking(task.deadline, move |adapter, _, clock| { + .run_async(task.deadline, move |adapter, _, clock| async move { adapter .core() .complete_profile_refresh( @@ -1213,6 +1249,7 @@ impl RuntimeActor { adapter.database(), clock.as_ref(), ) + .await .map(Box::new) .map(RuntimeCommandValue::Snapshot) }) @@ -1291,28 +1328,62 @@ impl RuntimeActor { } } -fn commit_generated_key_stage( +async 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)?; + adapter + .commit_staged_generated_key(request, staged, secrets, clock) + .await?; 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."), - ), - } +#[cfg(test)] +fn test_runtime_context(root: &std::path::Path) -> Result<RuntimeContext, SafeError> { + let canonical = root + .canonicalize() + .map_err(|_| invalid_runtime_evidence())?; + let resolver = RadrootsPathResolver::new( + RadrootsPlatform::current(), + RadrootsHostEnvironment::default(), + ); + let bootstrap = RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some(canonical), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::SafeDefault, + ) + .map_err(|_| invalid_runtime_evidence())?; + RuntimeContext::resolve( + &resolver, + bootstrap, + ServiceId::new("harvestcircle").map_err(|_| invalid_runtime_evidence())?, + InstanceId::new("desktop").map_err(|_| invalid_runtime_evidence())?, + ) + .map_err(|_| invalid_runtime_evidence()) +} + +#[cfg(test)] +fn test_migration_build_identity() -> Result<MigrationBuildIdentity, SafeError> { + MigrationBuildIdentity::new( + "0.1.0-alpha", + "1111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222", + "1.97.1", + "test", + "test", + 1, + 1, + 1, + 1, + 1, + ) + .map_err(|_| invalid_runtime_evidence()) } const fn request_space_exhausted() -> SafeError { @@ -1322,6 +1393,13 @@ const fn request_space_exhausted() -> SafeError { ) } +const fn invalid_runtime_evidence() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The runtime startup evidence is invalid."), + ) +} + const fn stale_profile_binding() -> SafeError { SafeError::new( SafeErrorCode::InvalidApplicationState, @@ -1407,6 +1485,7 @@ mod tests { use super::{ DEFAULT_COMMAND_TIMEOUT, RuntimeActorHandle, RuntimeDependencies, command_unavailable, + test_migration_build_identity, test_runtime_context, }; use crate::{InstallationIdentity, InstallationIdentitySource, UuidInstallationIdentitySource}; @@ -1596,13 +1675,10 @@ mod tests { #[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() - .canonicalize() - .expect("canonical temporary directory") - .join("harvestcircle.sqlite3"); + let context = test_runtime_context(directory.path()).expect("runtime context"); + let build = test_migration_build_identity().expect("build identity"); let first = RuntimeActorHandle::open( - &path, + &context, RelayConfiguration::default(), RuntimeDependencies::new( Arc::new(InMemorySecretStore::default()), @@ -1612,6 +1688,7 @@ mod tests { "11aabbccddeeff001122334455667788", )), ), + &build, NonZeroUsize::new(8).expect("capacity"), &tokio::runtime::Handle::current(), ) @@ -1625,7 +1702,7 @@ mod tests { drop(first); let second = RuntimeActorHandle::open( - &path, + &context, RelayConfiguration::default(), RuntimeDependencies::new( Arc::new(InMemorySecretStore::default()), @@ -1635,6 +1712,7 @@ mod tests { "22aabbccddeeff001122334455667788", )), ), + &build, NonZeroUsize::new(8).expect("capacity"), &tokio::runtime::Handle::current(), ) diff --git a/core/crates/harvestcircle_runtime/tests/local_relay_e2e.rs b/core/crates/harvestcircle_runtime/tests/local_relay_e2e.rs @@ -1,8 +1,8 @@ use std::time::Duration; use harvestcircle_application::{ - Clock, InMemorySecretStore, ProfileLoadState, ProfileRepository, RelayConfiguration, - RelayConnectionState, SecretStore, SessionState, + Clock, DurableRequestId, InMemorySecretStore, ProfileLoadState, ProfileRepository, + RelayConfiguration, RelayConnectionState, SecretStore, SessionState, }; use harvestcircle_domain::{RelayDestinationPolicy, RelayEndpoint, SecretKeyInput, UnixTimestamp}; use harvestcircle_nostr::SdkNostrClient; @@ -10,6 +10,11 @@ use harvestcircle_runtime::PersistentAppCore; use nostr::{EventBuilder, Keys, Metadata}; use nostr_relay_builder::MockRelay; use nostr_sdk::Client; +use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, +}; +use radroots_service_sqlite::MigrationBuildIdentity; const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; @@ -23,6 +28,37 @@ impl Clock for FixedClock { #[tokio::test] async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { + let directory = tempfile::tempdir().expect("temporary directory"); + let context = RuntimeContext::resolve( + &RadrootsPathResolver::new( + RadrootsPlatform::current(), + RadrootsHostEnvironment::default(), + ), + RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some(directory.path().canonicalize().expect("canonical root")), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::SafeDefault, + ) + .expect("bootstrap"), + ServiceId::new("harvestcircle").expect("service"), + InstanceId::new("desktop").expect("instance"), + ) + .expect("context"); + let build = MigrationBuildIdentity::new( + "0.1.0-alpha", + "1111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222", + "1.97.1", + "test", + "test", + 1, + 1, + 1, + 1, + 1, + ) + .expect("build"); 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"); @@ -50,23 +86,35 @@ async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { true, ) .expect("relay endpoint"); - let adapter = PersistentAppCore::in_memory( + let adapter = PersistentAppCore::open( + &context, RelayConfiguration::new(vec![relay]).expect("relay configuration"), + 100_000, + 100, + &build, ) + .await .expect("persistent adapter"); let secrets = InMemorySecretStore::default(); - adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + adapter + .bootstrap(&secrets, &FixedClock) + .await + .expect("bootstrap"); let imported = adapter - .import_secret_key( + .import_secret_key_durable( + &DurableRequestId::parse("01890f3e-7b1c-7000-8000-000000000201").expect("request"), + adapter.core().snapshot().revision().value(), SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("secret input"), &secrets, &FixedClock, ) + .await .expect("import identity"); let public_key = imported.identity().public_key(); assert!(secrets.contains(public_key).expect("credential exists")); adapter .activate_identity(public_key, &secrets, &FixedClock) + .await .expect("activate identity"); let refreshed = adapter @@ -91,6 +139,7 @@ async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { let cached = adapter .database() .load_profile(public_key) + .await .expect("load cache") .expect("cached profile"); assert_eq!( @@ -100,6 +149,7 @@ async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { let public_debug = format!("{refreshed:?}"); assert!(!public_debug.contains(SECRET_HEX)); assert!(!public_debug.contains("nsec1")); + adapter.close().await.expect("close"); publisher.shutdown().await; local_relay.shutdown(); } diff --git a/core/crates/harvestcircle_runtime/tests/restart_isolation.rs b/core/crates/harvestcircle_runtime/tests/restart_isolation.rs @@ -1,11 +1,17 @@ use std::fs; use harvestcircle_application::{ - Clock, IdentityNamespaceRepository, IdentityPreferenceKey, InMemorySecretStore, - RelayConfiguration, SessionState, + Clock, DurableRequestId, IdentityNamespaceRepository, IdentityPreferenceKey, + InMemorySecretStore, RelayConfiguration, SessionState, }; use harvestcircle_domain::{SecretKeyInput, UnixTimestamp}; use harvestcircle_runtime::PersistentAppCore; +use harvestcircle_storage::HarvestCircleStorageContract; +use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, +}; +use radroots_service_sqlite::MigrationBuildIdentity; use tempfile::tempdir; const SECRET_A: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; @@ -19,53 +25,120 @@ impl Clock for FixedClock { } } -#[test] -fn restart_restores_selection_and_keeps_identity_namespaces_isolated() { +fn context(root: &std::path::Path) -> RuntimeContext { + RuntimeContext::resolve( + &RadrootsPathResolver::new( + RadrootsPlatform::current(), + RadrootsHostEnvironment::default(), + ), + RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some(root.canonicalize().expect("canonical root")), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::SafeDefault, + ) + .expect("bootstrap"), + ServiceId::new("harvestcircle").expect("service"), + InstanceId::new("desktop").expect("instance"), + ) + .expect("context") +} + +fn build() -> MigrationBuildIdentity { + MigrationBuildIdentity::new( + "0.1.0-alpha", + "1111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222", + "1.97.1", + "test", + "test", + 1, + 1, + 1, + 1, + 1, + ) + .expect("build") +} + +#[tokio::test] +async fn restart_restores_selection_and_keeps_identity_namespaces_isolated() { let directory = tempdir().expect("temporary directory"); - let path = directory - .path() - .canonicalize() - .expect("canonical temporary directory") - .join("harvestcircle.sqlite3"); + let context = context(directory.path()); + let build = build(); + let path = HarvestCircleStorageContract::from_runtime_context(&context) + .expect("contract") + .paths() + .state_database() + .to_path_buf(); 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"); + let adapter = PersistentAppCore::open( + &context, + RelayConfiguration::default(), + 200_000, + 200, + &build, + ) + .await + .expect("persistent adapter"); + adapter + .bootstrap(&secrets, &FixedClock) + .await + .expect("bootstrap"); owner_a = adapter - .import_secret_key( + .import_secret_key_durable( + &DurableRequestId::parse("01890f3e-7b1c-7000-8000-000000000101").expect("request"), + adapter.core().snapshot().revision().value(), SecretKeyInput::parse(SECRET_A.to_owned()).expect("secret A"), &secrets, &FixedClock, ) + .await .expect("identity A") .identity() .public_key(); owner_b = adapter - .import_secret_key( + .import_secret_key_durable( + &DurableRequestId::parse("01890f3e-7b1c-7000-8000-000000000102").expect("request"), + adapter.core().snapshot().revision().value(), SecretKeyInput::parse(SECRET_B.to_owned()).expect("secret B"), &secrets, &FixedClock, ) + .await .expect("identity B") .identity() .public_key(); adapter .database() .set_value(owner_a, IdentityPreferenceKey::NamespaceProbe, "identity-a") + .await .expect("namespace A"); adapter .database() .set_value(owner_b, IdentityPreferenceKey::NamespaceProbe, "identity-b") + .await .expect("namespace B"); - adapter.select_identity(owner_b).expect("select B"); + adapter.select_identity(owner_b).await.expect("select B"); + adapter.close().await.expect("close"); } - let reopened = - PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter"); - let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore"); + let reopened = PersistentAppCore::open( + &context, + RelayConfiguration::default(), + 200_000, + 200, + &build, + ) + .await + .expect("reopen adapter"); + let restored = reopened + .bootstrap(&secrets, &FixedClock) + .await + .expect("restore"); assert_eq!(restored.identities().len(), 2); assert_eq!(restored.selected_identity(), Some(owner_b)); assert_eq!(restored.session(), SessionState::SignedOut); @@ -73,6 +146,7 @@ fn restart_restores_selection_and_keeps_identity_namespaces_isolated() { reopened .database() .get_value(owner_a, IdentityPreferenceKey::NamespaceProbe) + .await .expect("read A"), Some("identity-a".to_owned()) ); @@ -80,10 +154,12 @@ fn restart_restores_selection_and_keeps_identity_namespaces_isolated() { reopened .database() .get_value(owner_b, IdentityPreferenceKey::NamespaceProbe) + .await .expect("read B"), Some("identity-b".to_owned()) ); + reopened.close().await.expect("close reopened"); let database = fs::read(path).expect("database bytes"); assert!( !database diff --git a/core/crates/harvestcircle_storage/Cargo.toml b/core/crates/harvestcircle_storage/Cargo.toml @@ -9,28 +9,24 @@ license = "GPL-3.0-only" repository.workspace = true homepage.workspace = true publish = false -include = ["src/**", "tests/**", "migrations/**", "Cargo.toml"] +include = ["src/**", "tests/**", "Cargo.toml"] [dependencies] -fs2 = "=0.4.3" keyring = "=4.1.6" harvestcircle_application.workspace = true harvestcircle_domain.workspace = true harvestcircle_product.workspace = true radroots_runtime_paths.workspace = true radroots_service_sqlite.workspace = true -refinery = { version = "=0.9.2", default-features = false, features = ["rusqlite"] } +radroots_storage.workspace = true +sqlx.workspace = true 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] +harvestcircle_nostr.workspace = true tempfile = "=3.23.0" +tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread"] } [lints] workspace = true diff --git a/core/crates/harvestcircle_storage/migrations/V10__installation_identity.sql b/core/crates/harvestcircle_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/core/crates/harvestcircle_storage/migrations/V1__initialize.sql b/core/crates/harvestcircle_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/core/crates/harvestcircle_storage/migrations/V2__accounts.sql b/core/crates/harvestcircle_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/core/crates/harvestcircle_storage/migrations/V3__profile_cache.sql b/core/crates/harvestcircle_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/core/crates/harvestcircle_storage/migrations/V4__account_namespace.sql b/core/crates/harvestcircle_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/core/crates/harvestcircle_storage/migrations/V5__operation_journal.sql b/core/crates/harvestcircle_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/core/crates/harvestcircle_storage/migrations/V6__normalized_runtime_schema.sql b/core/crates/harvestcircle_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/core/crates/harvestcircle_storage/migrations/V7__migrate_v5_runtime_data.sql b/core/crates/harvestcircle_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/core/crates/harvestcircle_storage/migrations/V8__normalized_account_preferences.sql b/core/crates/harvestcircle_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/core/crates/harvestcircle_storage/migrations/V9__durable_operation_receipts.sql b/core/crates/harvestcircle_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/core/crates/harvestcircle_storage/src/compatibility.rs b/core/crates/harvestcircle_storage/src/compatibility.rs @@ -1,478 +0,0 @@ -use std::path::Path; - -use harvestcircle_domain::{ - NostrIdentityReference, 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 NostrIdentityReference::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, tempdir_in}; - - use super::{ - DatabasePreflight, PersistedIdentityIssueKind, column_exists, preflight, - scan_display_identities, scan_public_key_column, - }; - use crate::Database; - use harvestcircle_domain::{NostrIdentityReference, PublicKey, SafeErrorCode}; - - fn tempdir() -> std::io::Result<TempDir> { - tempdir_in(std::env::temp_dir().canonicalize()?) - } - - #[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 accounts (public_key TEXT, npub TEXT)", []) - .expect("identity table"); - let canonical = - PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df") - .expect("canonical key"); - let npub = NostrIdentityReference::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 accounts (public_key, npub) VALUES (?1, ?2)", - params![public_key, npub], - ) - .expect("identity row"); - } - - assert!(column_exists(&connection, "accounts", "public_key").expect("column")); - assert!(!column_exists(&connection, "missing", "public_key").expect("missing table")); - assert!(!column_exists(&connection, "accounts", "missing").expect("missing column")); - let mut issues = Vec::new(); - scan_public_key_column(&connection, "accounts", "public_key", &mut issues) - .expect("scan public keys"); - scan_display_identities(&connection, "accounts", "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(), "accounts"); - assert!(matches!(issue.column(), "public_key" | "npub")); - assert!(issue.row_id() > 0); - assert_ne!(issue.fingerprint(), &[0_u8; 32]); - } - } -} diff --git a/core/crates/harvestcircle_storage/src/contract.rs b/core/crates/harvestcircle_storage/src/contract.rs @@ -311,7 +311,7 @@ impl fmt::Display for HarvestCircleStorageContractError { impl Error for HarvestCircleStorageContractError {} #[must_use] -pub const fn harvestcircle_initial_schema_sql() -> &'static [&'static str] { +pub(crate) const fn harvestcircle_initial_schema_sql() -> &'static [&'static str] { &INITIAL_SCHEMA_SQL } @@ -400,6 +400,7 @@ mod tests { InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, }; + use sqlx::{Connection, Row}; fn context(service: &str, instance: &str) -> RuntimeContext { RuntimeContext::resolve( @@ -556,34 +557,38 @@ mod tests { ); } - #[test] - fn schema_sql_executes_as_one_fresh_strict_v1_inventory() { - let connection = rusqlite::Connection::open_in_memory().expect("memory database"); - connection - .execute_batch("PRAGMA foreign_keys = ON;") + #[tokio::test] + async fn schema_sql_executes_as_one_fresh_strict_v1_inventory() { + let mut connection = sqlx::SqliteConnection::connect(":memory:") + .await + .expect("memory database"); + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&mut connection) + .await .expect("foreign keys"); for statement in harvestcircle_initial_schema_sql() { - connection - .execute_batch(statement) + sqlx::query(*statement) + .execute(&mut connection) + .await .expect("schema statement"); } - let mut statement = connection - .prepare( - "SELECT type, name, tbl_name FROM sqlite_schema \ + let rows = sqlx::query( + "SELECT type, name, tbl_name FROM sqlite_schema \ WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name LIMIT 10", - ) - .expect("inventory statement"); - let inventory = statement - .query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) + ) + .fetch_all(&mut connection) + .await + .expect("inventory rows"); + let inventory = rows + .iter() + .map(|row| { + ( + row.get::<String, _>("type"), + row.get::<String, _>("name"), + row.get::<String, _>("tbl_name"), + ) }) - .expect("inventory") - .collect::<Result<Vec<_>, _>>() - .expect("inventory rows"); + .collect::<Vec<_>>(); assert_eq!(inventory.len(), 9); assert_eq!( inventory diff --git a/core/crates/harvestcircle_storage/src/db.rs b/core/crates/harvestcircle_storage/src/db.rs @@ -1,911 +1,210 @@ -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 core::num::NonZeroU32; +use std::fs; +use std::path::Path; -use fs2::FileExt; use harvestcircle_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, +use radroots_runtime_paths::RuntimeContext; +use radroots_service_sqlite::{ + ExistingServiceDatabaseIntent, MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, + ServiceDatabaseMetadata, ServiceSqliteConnectionOptions, ServiceSqliteErrorKind, + ServiceSqliteHost, ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind, + initialize_database, }; +use radroots_storage::event::SourceGeneration; +use sqlx::sqlite::SqliteConnectOptions; +use sqlx::{Connection, SqliteConnection}; -pub const CURRENT_SCHEMA_VERSION: u32 = 10; - -mod migrations { - use super::embed_migrations; +use crate::contract::harvestcircle_initial_schema_sql; +use crate::{HARVESTCIRCLE_STATE_SCHEMA_VERSION, HarvestCircleStorageContract}; - embed_migrations!("migrations"); -} +pub const CURRENT_SCHEMA_VERSION: u32 = HARVESTCIRCLE_STATE_SCHEMA_VERSION; +/// The sole HarvestCircle SQLite host. +/// +/// The underlying pool and connections remain sealed by `radroots_service_sqlite`. 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, + host: ServiceSqliteHost, + metadata: ServiceDatabaseMetadata, } 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() + /// Opens or initializes the canonical HarvestCircle service-instance state. + /// + /// The caller injects creation and migration evidence. Fresh state receives + /// a new opaque source generation from host entropy; existing state is + /// admitted through its persisted identity without guessing that generation. + pub async fn open( + context: &RuntimeContext, + created_at_unix_ms: u64, + applied_at_unix_s: u64, + build: &MigrationBuildIdentity, + ) -> Result<Self, SafeError> { + let contract = HarvestCircleStorageContract::from_runtime_context(context) + .map_err(|_| invalid_storage_contract())?; + provision_state_directory(contract.paths().state_database())?; + let applied_at = MigrationAppliedAtUnixSeconds::new(applied_at_unix_s) + .map_err(|_| invalid_storage_contract())?; + let options = ServiceSqliteConnectionOptions::reviewed(); + + if contract + .paths() + .state_database() + .try_exists() + .map_err(|_| storage_unavailable())? { - 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), + let intent = ExistingServiceDatabaseIntent::new( + contract.paths(), + contract.state_schema_version(), + contract.application_id(), + ); + let (opened, _) = ServiceSqliteHost::open_read_write_existing_with_intent( + contract.paths(), + &intent, + contract.migrations(), + contract.schema(), + options, + applied_at, + build, + &[], ) - .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) + .await + .map_err(map_service_error)?; + let (host, metadata) = opened.into_parts(); + return Ok(Self { host, metadata }); + } + + let mut generation = [0_u8; 32]; + getrandom::getrandom(&mut generation).map_err(|_| storage_unavailable())?; + let generation = SourceGeneration::new(generation).map_err(|_| storage_unavailable())?; + let metadata = ServiceDatabaseMetadata::new( + contract.paths(), + generation, + NonZeroU32::new(CURRENT_SCHEMA_VERSION).expect("schema v1 is nonzero"), + created_at_unix_ms, + contract.application_id(), + ) + .map_err(|_| invalid_storage_contract())?; + let authority = initialize_database( + contract.paths(), + OpenMode::Initialize, + &metadata, + contract.schema(), + initialize_application_schema, + ) + .await + .map_err(map_service_error)?; + let (host, _) = ServiceSqliteHost::open_initialized( + contract.paths(), + &metadata.identity(), + contract.migrations(), + contract.schema(), + options, + authority, + applied_at, + build, + &[], + ) + .await + .map_err(map_service_error)?; + Ok(Self { host, metadata }) } - /// 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()) + /// Explicitly drains SQLite work, checkpoints WAL state, and releases authority. + pub async fn close(&self) -> Result<(), SafeError> { + self.host.close().await.map_err(map_service_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(), - } + #[must_use] + pub const fn metadata(&self) -> &ServiceDatabaseMetadata { + &self.metadata } -} - -impl Deref for DatabaseConnection<'_> { - type Target = Connection; - fn deref(&self) -> &Self::Target { - &self.connection + pub(crate) const fn host(&self) -> &ServiceSqliteHost { + &self.host } } -impl DerefMut for DatabaseConnection<'_> { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.connection +async fn initialize_application_schema(path: std::path::PathBuf) -> Result<(), sqlx::Error> { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false); + let mut connection = SqliteConnection::connect_with(&options).await?; + for statement in harvestcircle_initial_schema_sql() { + sqlx::query(*statement).execute(&mut connection).await?; } + sqlx::query("INSERT INTO runtime_state (singleton) VALUES (1)") + .execute(&mut connection) + .await?; + connection.close().await } -impl Drop for DatabaseConnection<'_> { - fn drop(&mut self) { - if let Some(path) = self.path { - let _ = restrict_sqlite_sidecars(path); - } +fn provision_state_directory(database: &Path) -> Result<(), SafeError> { + let directory = database.parent().ok_or_else(invalid_storage_contract)?; + fs::create_dir_all(directory).map_err(|_| storage_unavailable())?; + let metadata = fs::symlink_metadata(directory).map_err(|_| storage_unavailable())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(storage_unavailable()); } -} - -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 }) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)) + .map_err(|_| storage_unavailable())?; } + Ok(()) } -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()), - } +pub(crate) fn map_transaction_error(error: ServiceSqliteTransactionError<SafeError>) -> SafeError { + if let Some(operation) = error.operation_error() { + return *operation; } - 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()); + match error.kind() { + ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown => commit_outcome_unknown(), + ServiceSqliteTransactionErrorKind::NotCommitted + | ServiceSqliteTransactionErrorKind::OperationRolledBack + | ServiceSqliteTransactionErrorKind::RollbackFailed => storage_unavailable(), } - 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()), - } +fn map_service_error(error: radroots_service_sqlite::ServiceSqliteError) -> SafeError { + match error.kind() { + ServiceSqliteErrorKind::Metadata + | ServiceSqliteErrorKind::Migration + | ServiceSqliteErrorKind::Integrity + | ServiceSqliteErrorKind::Recovery => corrupt_storage(), + ServiceSqliteErrorKind::Authority + | ServiceSqliteErrorKind::Open + | ServiceSqliteErrorKind::Create + | ServiceSqliteErrorKind::Pragma + | ServiceSqliteErrorKind::Backup + | ServiceSqliteErrorKind::Restore => storage_unavailable(), } - 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 { +pub(crate) const fn storage_unavailable() -> SafeError { SafeError::new( SafeErrorCode::StorageUnavailable, - SafeMessage::new("The application database is unavailable."), + SafeMessage::new("The application state is unavailable."), ) } -const fn corrupt_storage_error() -> SafeError { +pub(crate) const fn corrupt_storage() -> SafeError { SafeError::new( SafeErrorCode::StorageCorrupt, - SafeMessage::new("The application database could not be read."), + SafeMessage::new("The application state could not be verified."), ) } -const fn ownership_error() -> SafeError { +const fn invalid_storage_contract() -> SafeError { SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The application database is already in use."), + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The application storage contract is invalid."), ) } -#[cfg(test)] -mod tests { - use std::fs; - use std::io::Write; - use std::path::Path; - use std::process::Command; - - use tempfile::{TempDir, tempdir_in}; - - use harvestcircle_application::{AppStateRepository, IdentityRepository}; - use harvestcircle_domain::{PublicKey, SafeErrorCode}; - use refinery::Target; - use rusqlite::Connection; - - use super::{ - CURRENT_SCHEMA_VERSION, Database, configure, create_secure_directory, migrations, - restrict_sqlite_sidecars, - }; - - fn tempdir() -> std::io::Result<TempDir> { - tempdir_in(std::env::temp_dir().canonicalize()?) - } - 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("harvestcircle.sqlite3"); - fs::write(&database, b"database").expect("write database file"); - fs::create_dir(directory.path().join("harvestcircle.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_identity_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("harvestcircle.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 identity"); - 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_identities().expect("identities").len(), 1); - assert_eq!( - database.load_selected_identity().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("harvestcircle.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("harvestcircle.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 identity"); - } - - 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 identities: i64 = connection - .query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get(0)) - .expect("legacy identities"); - assert_eq!((version, identities), (5, 1)); - } - - #[test] - fn invalid_curve_identity_is_quarantined_without_mutation() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("harvestcircle.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("harvestcircle.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("harvestcircle.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 identity"); - 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("harvestcircle.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("HARVESTCIRCLE_LOCK_PROBE_PATH", &path) - .status() - .expect("child process"); - assert!(status.success()); - } - - #[test] - fn writable_ownership_child_probe() { - let Ok(path) = std::env::var("HARVESTCIRCLE_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("harvestcircle.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("harvestcircle.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("harvestcircle.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()); - } +const fn commit_outcome_unknown() -> SafeError { + SafeError::new( + SafeErrorCode::PendingOperationRecoveryRequired, + SafeMessage::new("The storage commit outcome must be reconciled."), + ) } diff --git a/core/crates/harvestcircle_storage/src/identities.rs b/core/crates/harvestcircle_storage/src/identities.rs @@ -1,187 +1,304 @@ -use harvestcircle_application::{AppStateRepository, IdentityRepository}; +use harvestcircle_application::{AppStateRepository, BoxFuture, IdentityRepository}; use harvestcircle_domain::{ IdentityCreatedAt, IdentityLabel, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, PublicKey, SafeError, SafeErrorCode, SafeMessage, SignerAvailability, UnixTimestamp, }; -use rusqlite::{OptionalExtension, Row, params}; - -use crate::Database; +use sqlx::Row; + +use crate::db::{corrupt_storage, map_transaction_error, storage_unavailable}; +use crate::{Database, HARVESTCIRCLE_IDENTITY_CAPACITY}; + +const IDENTITY_PROJECTION: &str = "SELECT substr(identity.public_key, 1, 33) AS public_key, length(identity.public_key) AS public_key_bytes, \ + substr(CAST(identity.npub AS BLOB), 1, 64) AS npub, length(CAST(identity.npub AS BLOB)) AS npub_bytes, \ + substr(CAST(binding.binding_kind AS BLOB), 1, 17) AS binding_kind, \ + length(CAST(binding.binding_kind AS BLOB)) AS binding_kind_bytes, \ + substr(CAST(binding.availability AS BLOB), 1, 19) AS availability, \ + length(CAST(binding.availability AS BLOB)) AS availability_bytes, \ + CASE WHEN identity.label IS NULL THEN NULL ELSE substr(CAST(identity.label AS BLOB), 1, 81) END AS label, \ + CASE WHEN identity.label IS NULL THEN NULL ELSE length(CAST(identity.label AS BLOB)) END AS label_bytes, \ + identity.created_at_unix_s, identity.last_used_at_unix_s \ + FROM account_identities AS identity JOIN local_signer_bindings AS binding \ + ON binding.account_public_key = identity.public_key"; impl IdentityRepository for Database { - fn list_identities(&self) -> Result<Vec<NostrIdentity>, 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_identity) - .map_err(|_| storage_error())?; - rows.map(|row| row.map_err(|_| corrupt_storage_error())) - .collect() + fn list_identities(&self) -> BoxFuture<'_, Result<Vec<NostrIdentity>, SafeError>> { + Box::pin(async move { + self.host() + .transaction(|transaction| { + Box::pin(async move { + let sql = format!( + "{IDENTITY_PROJECTION} ORDER BY identity.created_at_unix_s, identity.public_key LIMIT {}", + HARVESTCIRCLE_IDENTITY_CAPACITY + 1 + ); + let rows = sqlx::query(sqlx::AssertSqlSafe(sql.as_str())) + .fetch_all(&mut *transaction) + .await + .map_err(|_| corrupt_storage())?; + if rows.len() > HARVESTCIRCLE_IDENTITY_CAPACITY { + return Err(corrupt_storage()); + } + rows.iter().map(decode_identity).collect() + }) + }) + .await + .map_err(map_transaction_error) + }) } - fn find_identity(&self, public_key: PublicKey) -> Result<Option<NostrIdentity>, 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_identity, - ) - .optional() - .map_err(|_| storage_error()) + fn find_identity( + &self, + public_key: PublicKey, + ) -> BoxFuture<'_, Result<Option<NostrIdentity>, SafeError>> { + Box::pin(async move { + self.host() + .transaction(|transaction| { + Box::pin(async move { + let sql = + format!("{IDENTITY_PROJECTION} WHERE identity.public_key = ? LIMIT 2"); + let rows = sqlx::query(sqlx::AssertSqlSafe(sql.as_str())) + .bind(public_key.as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| corrupt_storage())?; + match rows.as_slice() { + [] => Ok(None), + [row] => decode_identity(row).map(Some), + _ => Err(corrupt_storage()), + } + }) + }) + .await + .map_err(map_transaction_error) + }) } - fn insert_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { - let encoded = EncodedIdentity::try_from(identity)?; - 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(identity_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 insert_identity<'a>( + &'a self, + identity: &'a NostrIdentity, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + let encoded = EncodedIdentity::try_from(identity)?; + self.host() + .transaction(|transaction| { + Box::pin(async move { + let existing: i64 = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM account_identities WHERE public_key = ?)", + ) + .bind(encoded.public_key.as_slice()) + .fetch_one(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + match existing { + 0 => {} + 1 => return Err(identity_exists()), + _ => return Err(corrupt_storage()), + } + let admitted: i64 = sqlx::query_scalar( + "SELECT count(*) FROM (SELECT 1 FROM account_identities LIMIT 257)", + ) + .fetch_one(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + if usize::try_from(admitted) + .ok() + .is_none_or(|count| count >= HARVESTCIRCLE_IDENTITY_CAPACITY) + { + return Err(identity_capacity_exhausted()); + } + let result = sqlx::query( + "INSERT INTO account_identities \ + (public_key, npub, label, created_at_unix_s, last_used_at_unix_s) \ + VALUES (?, ?, ?, ?, ?)", + ) + .bind(encoded.public_key.as_slice()) + .bind(&encoded.npub) + .bind(&encoded.label) + .bind(encoded.created_at) + .bind(encoded.last_used_at) + .execute(&mut *transaction) + .await; + match result { + Ok(result) if result.rows_affected() == 1 => {} + Err(error) if is_unique_violation(&error) => { + return Err(identity_exists()); + } + Ok(_) | Err(_) => return Err(storage_unavailable()), + } + let result = sqlx::query( + "INSERT INTO local_signer_bindings \ + (account_public_key, binding_public_key, binding_kind, availability) \ + VALUES (?, ?, 'local_secret', ?)", + ) + .bind(encoded.public_key.as_slice()) + .bind(encoded.public_key.as_slice()) + .bind(encoded.key_availability) + .execute(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + if result.rows_affected() != 1 { + return Err(storage_unavailable()); + } + Ok(()) + }) + }) + .await + .map_err(map_transaction_error) + }) } - fn update_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { - let encoded = EncodedIdentity::try_from(identity)?; - 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(identity_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 update_identity<'a>( + &'a self, + identity: &'a NostrIdentity, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + let encoded = EncodedIdentity::try_from(identity)?; + self.host() + .transaction(|transaction| { + Box::pin(async move { + let result = sqlx::query( + "UPDATE account_identities SET npub = ?, label = ?, \ + created_at_unix_s = ?, last_used_at_unix_s = ? WHERE public_key = ?", + ) + .bind(&encoded.npub) + .bind(&encoded.label) + .bind(encoded.created_at) + .bind(encoded.last_used_at) + .bind(encoded.public_key.as_slice()) + .execute(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + if result.rows_affected() == 0 { + return Err(identity_not_found()); + } + if result.rows_affected() != 1 { + return Err(corrupt_storage()); + } + let result = sqlx::query( + "UPDATE local_signer_bindings SET availability = ? \ + WHERE account_public_key = ? AND binding_public_key = ? \ + AND binding_kind = 'local_secret'", + ) + .bind(encoded.key_availability) + .bind(encoded.public_key.as_slice()) + .bind(encoded.public_key.as_slice()) + .execute(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + if result.rows_affected() != 1 { + return Err(corrupt_storage()); + } + Ok(()) + }) + }) + .await + .map_err(map_transaction_error) + }) } - fn remove_identity(&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(identity_not_found()), - Ok(_) | Err(_) => Err(storage_error()), - } + fn remove_identity(&self, public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async move { + self.host() + .transaction(|transaction| { + Box::pin(async move { + let result = + sqlx::query("DELETE FROM account_identities WHERE public_key = ?") + .bind(public_key.as_bytes().as_slice()) + .execute(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + match result.rows_affected() { + 1 => Ok(()), + 0 => Err(identity_not_found()), + _ => Err(corrupt_storage()), + } + }) + }) + .await + .map_err(map_transaction_error) + }) } } impl AppStateRepository for Database { - fn load_selected_identity(&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 load_selected_identity(&self) -> BoxFuture<'_, Result<Option<PublicKey>, SafeError>> { + Box::pin(async move { + self.host() + .transaction(|transaction| { + Box::pin(async move { + let rows = sqlx::query( + "SELECT CASE WHEN selected_public_key IS NULL THEN NULL \ + ELSE substr(selected_public_key, 1, 33) END AS selected_public_key, \ + CASE WHEN selected_public_key IS NULL THEN NULL \ + ELSE length(selected_public_key) END AS selected_bytes \ + FROM runtime_state WHERE singleton = 1 LIMIT 2", + ) + .fetch_all(&mut *transaction) + .await + .map_err(|_| corrupt_storage())?; + let [row] = rows.as_slice() else { + return Err(corrupt_storage()); + }; + let value = row + .try_get::<Option<Vec<u8>>, _>("selected_public_key") + .map_err(|_| corrupt_storage())?; + let length = row + .try_get::<Option<i64>, _>("selected_bytes") + .map_err(|_| corrupt_storage())?; + match (value, length) { + (None, None) => Ok(None), + (Some(value), Some(32)) => public_key_from_bytes(&value).map(Some), + _ => Err(corrupt_storage()), + } + }) + }) + .await + .map_err(map_transaction_error) + }) } - fn save_selected_identity(&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(identity_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()) + fn save_selected_identity( + &self, + public_key: Option<PublicKey>, + ) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async move { + self.host() + .transaction(|transaction| { + Box::pin(async move { + if let Some(public_key) = public_key { + let exists: i64 = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM account_identities WHERE public_key = ?)", + ) + .bind(public_key.as_bytes().as_slice()) + .fetch_one(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + if exists != 1 { + return Err(identity_not_found()); + } + } + let selected = public_key.map(|key| key.as_bytes().to_vec()); + let result = sqlx::query( + "UPDATE runtime_state SET selected_public_key = ? WHERE singleton = 1", + ) + .bind(selected) + .execute(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + if result.rows_affected() != 1 { + return Err(corrupt_storage()); + } + Ok(()) + }) + }) + .await + .map_err(map_transaction_error) + }) } } struct EncodedIdentity { - public_key: String, + public_key: [u8; 32], npub: String, - signer_kind: &'static str, key_availability: &'static str, label: Option<String>, created_at: i64, @@ -195,11 +312,10 @@ impl TryFrom<&NostrIdentity> for EncodedIdentity { let binding = identity .signer_binding() .as_local_keyring() - .ok_or_else(storage_error)?; + .ok_or_else(storage_unavailable)?; Ok(Self { - public_key: identity.public_key().to_hex(), + public_key: *identity.public_key().as_bytes(), npub: identity.npub().as_str().to_owned(), - signer_kind: "local_secret", key_availability: encode_key_availability(binding.availability()), label: identity.label().map(|label| label.as_str().to_owned()), created_at: identity.created_at().timestamp().as_seconds(), @@ -208,32 +324,80 @@ impl TryFrom<&NostrIdentity> for EncodedIdentity { } } -fn decode_identity(row: &Row<'_>) -> rusqlite::Result<NostrIdentity> { - 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)); +fn decode_identity(row: &sqlx::sqlite::SqliteRow) -> Result<NostrIdentity, SafeError> { + let public_key_bytes = row + .try_get::<Vec<u8>, _>("public_key") + .map_err(|_| corrupt_storage())?; + if row + .try_get::<i64, _>("public_key_bytes") + .map_err(|_| corrupt_storage())? + != 32 + { + return Err(corrupt_storage()); + } + let public_key = public_key_from_bytes(&public_key_bytes)?; + let npub = bounded_utf8(row, "npub", "npub_bytes", 63)?.ok_or_else(corrupt_storage)?; + let binding_kind = + bounded_utf8(row, "binding_kind", "binding_kind_bytes", 16)?.ok_or_else(corrupt_storage)?; + if binding_kind != "local_secret" { + return Err(corrupt_storage()); } - let key_availability = decode_key_availability(row.get::<_, String>(3)?.as_str())?; - let label = row - .get::<_, Option<String>>(4)? - .map(|value| IdentityLabel::parse(&value).map_err(|_| invalid_column(4))) + let availability = + bounded_utf8(row, "availability", "availability_bytes", 18)?.ok_or_else(corrupt_storage)?; + let availability = decode_key_availability(&availability)?; + let label = bounded_utf8(row, "label", "label_bytes", 80)? + .map(|value| IdentityLabel::parse(&value).map_err(|_| corrupt_storage())) .transpose()?; - let created_at = UnixTimestamp::from_seconds(row.get(5)?).ok_or_else(|| invalid_column(5))?; + let created_at = UnixTimestamp::from_seconds( + row.try_get("created_at_unix_s") + .map_err(|_| corrupt_storage())?, + ) + .ok_or_else(corrupt_storage)?; let last_used_at = row - .get::<_, Option<i64>>(6)? - .map(|value| UnixTimestamp::from_seconds(value).ok_or_else(|| invalid_column(6))) + .try_get::<Option<i64>, _>("last_used_at_unix_s") + .map_err(|_| corrupt_storage())? + .map(|value| UnixTimestamp::from_seconds(value).ok_or_else(corrupt_storage)) .transpose()?; - NostrIdentity::new( - NostrIdentityReference::verify(public_key, npub).map_err(|_| invalid_column(1))?, - LocalKeyringBinding::new(public_key, key_availability), + NostrIdentityReference::verify(public_key, npub).map_err(|_| corrupt_storage())?, + LocalKeyringBinding::new(public_key, availability), label, IdentityCreatedAt::new(created_at), last_used_at, ) - .map_err(|_| invalid_column(0)) + .map_err(|_| corrupt_storage()) +} + +fn bounded_utf8( + row: &sqlx::sqlite::SqliteRow, + value_column: &str, + length_column: &str, + maximum: usize, +) -> Result<Option<String>, SafeError> { + let value = row + .try_get::<Option<Vec<u8>>, _>(value_column) + .map_err(|_| corrupt_storage())?; + let length = row + .try_get::<Option<i64>, _>(length_column) + .map_err(|_| corrupt_storage())?; + match (value, length) { + (None, None) => Ok(None), + (Some(value), Some(length)) + if usize::try_from(length) + .ok() + .is_some_and(|length| length <= maximum && length == value.len()) => + { + String::from_utf8(value) + .map(Some) + .map_err(|_| corrupt_storage()) + } + _ => Err(corrupt_storage()), + } +} + +fn public_key_from_bytes(bytes: &[u8]) -> Result<PublicKey, SafeError> { + let bytes: [u8; 32] = bytes.try_into().map_err(|_| corrupt_storage())?; + PublicKey::from_bytes(bytes).map_err(|_| corrupt_storage()) } const fn encode_key_availability(value: SignerAvailability) -> &'static str { @@ -244,283 +408,38 @@ const fn encode_key_availability(value: SignerAvailability) -> &'static str { } } -fn decode_key_availability(value: &str) -> rusqlite::Result<SignerAvailability> { +fn decode_key_availability(value: &str) -> Result<SignerAvailability, SafeError> { match value { "available" => Ok(SignerAvailability::Available), "credential_missing" => Ok(SignerAvailability::CredentialMissing), "store_unavailable" => Ok(SignerAvailability::StoreUnavailable), - _ => Err(invalid_column(3)), + _ => Err(corrupt_storage()), } } -fn invalid_column(index: usize) -> rusqlite::Error { - rusqlite::Error::InvalidColumnType( - index, - "public identity 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."), - ) +fn is_unique_violation(error: &sqlx::Error) -> bool { + error + .as_database_error() + .is_some_and(sqlx::error::DatabaseError::is_unique_violation) } const fn identity_exists() -> SafeError { SafeError::new( SafeErrorCode::IdentityAlreadyExists, - SafeMessage::new("The Nostr identity is already saved."), + SafeMessage::new("That identity is already saved."), ) } const fn identity_not_found() -> SafeError { SafeError::new( SafeErrorCode::IdentityNotFound, - SafeMessage::new("The identity was not found."), + SafeMessage::new("That identity is not saved."), ) } -#[cfg(test)] -mod tests { - use std::fs; - - use harvestcircle_application::{AppStateRepository, IdentityRepository}; - use harvestcircle_domain::{ - IdentityCreatedAt, IdentityLabel, LocalKeyringBinding, NostrIdentity, - NostrIdentityReference, PublicKey, SafeErrorCode, SignerAvailability, UnixTimestamp, - }; - use tempfile::{TempDir, tempdir_in}; - - use crate::Database; - - fn tempdir() -> std::io::Result<TempDir> { - tempdir_in(std::env::temp_dir().canonicalize()?) - } - - 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 identity(key_byte: u8, created_at: i64) -> NostrIdentity { - let public_key = public_key(key_byte); - NostrIdentity::new( - NostrIdentityReference::derive(public_key).expect("identity"), - LocalKeyringBinding::new(public_key, SignerAvailability::Available), - Some(IdentityLabel::parse("Farm identity").expect("valid label")), - IdentityCreatedAt::new( - UnixTimestamp::from_seconds(created_at).expect("valid timestamp"), - ), - None, - ) - .expect("identity") - } - - #[test] - fn identities_insert_list_update_and_reject_duplicates() { - let database = Database::in_memory().expect("database"); - let first = identity(1, 20); - let second = identity(2, 10); - - database.insert_identity(&first).expect("insert first"); - database.insert_identity(&second).expect("insert second"); - let duplicate = database.insert_identity(&first).expect_err("duplicate"); - - assert_eq!(duplicate.code(), SafeErrorCode::IdentityAlreadyExists); - assert_eq!( - database.list_identities().expect("list"), - vec![second, first.clone()] - ); - assert_eq!( - database.find_identity(first.public_key()).expect("find"), - Some(first) - ); - } - - #[test] - fn identities_and_selection_survive_restart_without_secret_text() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("harvestcircle.sqlite3"); - let identity = identity(3, 30); - - { - let database = Database::open(&path).expect("database"); - database.insert_identity(&identity).expect("insert"); - database - .save_selected_identity(Some(identity.public_key())) - .expect("select"); - } - let reopened = Database::open(&path).expect("reopen"); - - assert_eq!( - reopened.list_identities().expect("list"), - vec![identity.clone()] - ); - assert_eq!( - reopened.load_selected_identity().expect("selection"), - Some(identity.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_identity_and_clears_on_delete() { - let database = Database::in_memory().expect("database"); - let identity = identity(4, 40); - - let missing = database - .save_selected_identity(Some(identity.public_key())) - .expect_err("missing identity"); - assert_eq!(missing.code(), SafeErrorCode::IdentityNotFound); - - database.insert_identity(&identity).expect("insert"); - database - .save_selected_identity(Some(identity.public_key())) - .expect("select"); - database - .remove_identity(identity.public_key()) - .expect("remove"); - - assert_eq!(database.load_selected_identity().expect("selection"), None); - } - - #[test] - fn identity_mutations_reject_missing_and_corrupt_rows() { - let database = Database::in_memory().expect("database"); - let missing = identity(3, 30); - assert_eq!( - database - .update_identity(&missing) - .expect_err("missing update") - .code(), - SafeErrorCode::IdentityNotFound - ); - assert_eq!( - database - .remove_identity(missing.public_key()) - .expect_err("missing removal") - .code(), - SafeErrorCode::IdentityNotFound - ); - assert_eq!( - database.find_identity(missing.public_key()).expect("find"), - None - ); - - database.insert_identity(&missing).expect("insert"); - database.update_identity(&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_identity(&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_identities() - .expect_err("corrupt binding must fail") - .code(), - SafeErrorCode::StorageCorrupt - ); - - let database = Database::in_memory().expect("database"); - database.insert_identity(&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_identity(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_identity(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_identity(&missing) - .expect_err("non-constraint insertion failure must fail closed") - .code(), - SafeErrorCode::StorageUnavailable - ); - } +const fn identity_capacity_exhausted() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The saved identity capacity is exhausted."), + ) } diff --git a/core/crates/harvestcircle_storage/src/identity_namespace.rs b/core/crates/harvestcircle_storage/src/identity_namespace.rs @@ -1,56 +1,117 @@ -use harvestcircle_application::{IdentityNamespaceRepository, IdentityPreferenceKey}; +use harvestcircle_application::{BoxFuture, IdentityNamespaceRepository, IdentityPreferenceKey}; use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage}; -use rusqlite::{OptionalExtension, params}; +use sqlx::Row; -use crate::Database; - -const MAX_VALUE_CHARS: usize = 4_096; +use crate::db::{corrupt_storage, map_transaction_error, storage_unavailable}; +use crate::{Database, HARVESTCIRCLE_PREFERENCE_VALUE_UTF8_BYTES}; impl IdentityNamespaceRepository for Database { - fn get_value( - &self, + fn get_value<'a>( + &'a self, owner: PublicKey, key: IdentityPreferenceKey, - ) -> 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()) + ) -> BoxFuture<'a, Result<Option<String>, SafeError>> { + Box::pin(async move { + self.host() + .transaction(|transaction| { + Box::pin(async move { + let rows = sqlx::query( + "SELECT substr(CAST(preference_value AS BLOB), 1, 4097) AS value, \ + length(CAST(preference_value AS BLOB)) AS value_bytes \ + FROM account_preferences WHERE owner_public_key = ? \ + AND preference_key = ? LIMIT 2", + ) + .bind(owner.as_bytes().as_slice()) + .bind(encode_key(key)) + .fetch_all(&mut *transaction) + .await + .map_err(|_| corrupt_storage())?; + match rows.as_slice() { + [] => Ok(None), + [row] => { + let value = row + .try_get::<Vec<u8>, _>("value") + .map_err(|_| corrupt_storage())?; + let length = row + .try_get::<i64, _>("value_bytes") + .map_err(|_| corrupt_storage())?; + let length = + usize::try_from(length).map_err(|_| corrupt_storage())?; + if length == 0 + || length > HARVESTCIRCLE_PREFERENCE_VALUE_UTF8_BYTES + || length != value.len() + { + return Err(corrupt_storage()); + } + String::from_utf8(value) + .map(Some) + .map_err(|_| corrupt_storage()) + } + _ => Err(corrupt_storage()), + } + }) + }) + .await + .map_err(map_transaction_error) + }) } - fn set_value( - &self, + fn set_value<'a>( + &'a self, owner: PublicKey, key: IdentityPreferenceKey, - 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()) + value: &'a str, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + if value.is_empty() + || value.len() > HARVESTCIRCLE_PREFERENCE_VALUE_UTF8_BYTES + || value.chars().any(char::is_control) + { + return Err(invalid_preference()); + } + let value = value.to_owned(); + self.host() + .transaction(|transaction| { + Box::pin(async move { + let result = sqlx::query( + "INSERT INTO account_preferences \ + (owner_public_key, preference_key, preference_value) VALUES (?, ?, ?) \ + ON CONFLICT(owner_public_key, preference_key) DO UPDATE SET \ + preference_value = excluded.preference_value", + ) + .bind(owner.as_bytes().as_slice()) + .bind(encode_key(key)) + .bind(&value) + .execute(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + if result.rows_affected() != 1 { + return Err(storage_unavailable()); + } + Ok(()) + }) + }) + .await + .map_err(map_transaction_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()) + fn clear_owner(&self, owner: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async move { + self.host() + .transaction(|transaction| { + Box::pin(async move { + sqlx::query("DELETE FROM account_preferences WHERE owner_public_key = ?") + .bind(owner.as_bytes().as_slice()) + .execute(&mut *transaction) + .await + .map(|_| ()) + .map_err(|_| storage_unavailable()) + }) + }) + .await + .map_err(map_transaction_error) + }) } } @@ -60,130 +121,9 @@ const fn encode_key(key: IdentityPreferenceKey) -> &'static str { } } -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The identity preference is unavailable."), - ) -} - const fn invalid_preference() -> SafeError { SafeError::new( SafeErrorCode::InvalidIdentityMetadata, SafeMessage::new("The identity preference is invalid."), ) } - -#[cfg(test)] -mod tests { - use harvestcircle_application::{ - AppStateRepository, IdentityNamespaceRepository, IdentityPreferenceKey, IdentityRepository, - }; - use harvestcircle_domain::{ - IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, PublicKey, - SignerAvailability, 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 identity(byte: u8) -> NostrIdentity { - let public_key = public_key(byte); - NostrIdentity::new( - NostrIdentityReference::derive(public_key).expect("identity"), - LocalKeyringBinding::new(public_key, SignerAvailability::Available), - None, - IdentityCreatedAt::new(UnixTimestamp::from_seconds(i64::from(byte)).expect("time")), - None, - ) - .expect("identity") - } - - #[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_identity(&identity(1)).expect("identity a"); - database.insert_identity(&identity(2)).expect("identity b"); - database - .set_value(owner_a, IdentityPreferenceKey::NamespaceProbe, "A") - .expect("set a"); - database - .set_value(owner_b, IdentityPreferenceKey::NamespaceProbe, "B") - .expect("set b"); - - database - .save_selected_identity(Some(owner_b)) - .expect("select b"); - let selected = database - .load_selected_identity() - .expect("selection") - .expect("selected owner"); - assert_eq!( - database - .get_value(selected, IdentityPreferenceKey::NamespaceProbe) - .expect("selected value"), - Some("B".to_owned()) - ); - assert_eq!( - database - .get_value(owner_a, IdentityPreferenceKey::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_identity(&identity(3)).expect("identity"); - database - .set_value(owner, IdentityPreferenceKey::NamespaceProbe, "before") - .expect("set"); - database - .set_value(owner, IdentityPreferenceKey::NamespaceProbe, "after") - .expect("update"); - assert_eq!( - database - .get_value(owner, IdentityPreferenceKey::NamespaceProbe) - .expect("value"), - Some("after".to_owned()) - ); - - database.remove_identity(owner).expect("remove"); - assert_eq!( - database - .get_value(owner, IdentityPreferenceKey::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_identity(&identity(3)).expect("identity"); - let oversized = "a".repeat(super::MAX_VALUE_CHARS + 1); - assert!( - database - .set_value(owner, IdentityPreferenceKey::NamespaceProbe, &oversized) - .is_err() - ); - assert!( - database - .set_value(owner, IdentityPreferenceKey::NamespaceProbe, "line\nbreak") - .is_err() - ); - } -} diff --git a/core/crates/harvestcircle_storage/src/installation.rs b/core/crates/harvestcircle_storage/src/installation.rs @@ -1,71 +1,117 @@ use harvestcircle_domain::{SafeError, SafeErrorCode, SafeMessage}; -use rusqlite::OptionalExtension; +use sqlx::Row; use crate::Database; +use crate::db::{corrupt_storage, map_transaction_error, storage_unavailable}; 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 async fn load_installation_id(&self) -> Result<Option<String>, SafeError> { + self.host() + .transaction(|transaction| { + Box::pin(async move { + let rows = sqlx::query( + "SELECT substr(installation_id, 1, 17) AS installation_id, \ + length(installation_id) AS installation_id_bytes \ + FROM installation_identity WHERE singleton = 1 LIMIT 2", + ) + .fetch_all(&mut *transaction) + .await + .map_err(|_| corrupt_storage())?; + match rows.as_slice() { + [] => Ok(None), + [row] => decode_installation_id(row).map(Some), + _ => Err(corrupt_storage()), + } + }) + }) + .await + .map_err(map_transaction_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()) + pub async fn initialize_installation_id(&self, candidate: &str) -> Result<String, SafeError> { + let candidate = decode_hex(candidate)?; + self.host() + .transaction(|transaction| { + Box::pin(async move { + let result = sqlx::query( + "INSERT INTO installation_identity (singleton, installation_id) \ + VALUES (1, ?) ON CONFLICT(singleton) DO NOTHING", + ) + .bind(candidate.as_slice()) + .execute(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + if result.rows_affected() > 1 { + return Err(corrupt_storage()); + } + let rows = sqlx::query( + "SELECT substr(installation_id, 1, 17) AS installation_id, \ + length(installation_id) AS installation_id_bytes \ + FROM installation_identity WHERE singleton = 1 LIMIT 2", + ) + .fetch_all(&mut *transaction) + .await + .map_err(|_| corrupt_storage())?; + let [row] = rows.as_slice() else { + return Err(corrupt_storage()); + }; + decode_installation_id(row) + }) + }) + .await + .map_err(map_transaction_error) } } -const fn installation_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The installation identity is unavailable."), - ) +fn decode_installation_id(row: &sqlx::sqlite::SqliteRow) -> Result<String, SafeError> { + let value = row + .try_get::<Vec<u8>, _>("installation_id") + .map_err(|_| corrupt_storage())?; + if row + .try_get::<i64, _>("installation_id_bytes") + .map_err(|_| corrupt_storage())? + != 16 + || value.len() != 16 + { + return Err(corrupt_storage()); + } + Ok(encode_hex(&value)) } -#[cfg(test)] -mod tests { - use crate::Database; +fn decode_hex(value: &str) -> Result<[u8; 16], SafeError> { + if value.len() != 32 { + return Err(invalid_installation_identity()); + } + let mut output = [0_u8; 16]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let high = hex_nibble(pair[0]).ok_or_else(invalid_installation_identity)?; + let low = hex_nibble(pair[1]).ok_or_else(invalid_installation_identity)?; + output[index] = (high << 4) | low; + } + Ok(output) +} - #[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)); +fn encode_hex(value: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(value.len() * 2); + for byte in value { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); } + output +} - #[test] - fn installation_identity_rejects_invalid_values() { - let database = Database::in_memory().expect("database"); - assert!( - database - .initialize_installation_id("not-an-identity") - .is_err() - ); +const fn hex_nibble(value: u8) -> Option<u8> { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + _ => None, } } + +const fn invalid_installation_identity() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The installation identity is invalid."), + ) +} diff --git a/core/crates/harvestcircle_storage/src/journal.rs b/core/crates/harvestcircle_storage/src/journal.rs @@ -1,220 +1,299 @@ use harvestcircle_application::{ - DurableIdentityOperation, DurableOperationKind, DurableOperationPhase, DurableOperationReceipt, - DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome, - IdentityOperationKind, IdentityOperationPhase, OperationDiagnostic, OperationId, - OperationJournal, OperationPriorState, PendingIdentityOperation, + BoxFuture, DurableIdentityOperation, DurableOperationKind, DurableOperationPhase, + DurableOperationReceipt, DurableOperationRepository, DurableOperationStart, DurableRequestId, + DurableTerminalOutcome, OperationDiagnostic, OperationPriorState, }; use harvestcircle_domain::{ PublicKey, SafeError, SafeErrorCode, SafeMessage, SignerAvailability, UnixTimestamp, }; -use rusqlite::{OptionalExtension, Row, params}; - -use crate::Database; +use radroots_service_sqlite::ServiceSqliteTransaction; +use sqlx::Row; + +use crate::db::{corrupt_storage, map_transaction_error, storage_unavailable}; +use crate::{Database, HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY}; + +const DURABLE_OPERATION_PROJECTION: &str = "SELECT \ + substr(CAST(request_id AS BLOB), 1, 37) AS request_id, length(CAST(request_id AS BLOB)) AS request_id_bytes, \ + substr(CAST(operation_kind AS BLOB), 1, 7) AS operation_kind, length(CAST(operation_kind AS BLOB)) AS operation_kind_bytes, \ + substr(account_public_key, 1, 33) AS account_public_key, length(account_public_key) AS account_public_key_bytes, \ + expected_revision, substr(CAST(phase AS BLOB), 1, 21) AS phase, length(CAST(phase AS BLOB)) AS phase_bytes, \ + CASE WHEN prior_selected_public_key IS NULL THEN NULL ELSE substr(prior_selected_public_key, 1, 33) END AS prior_selected_public_key, \ + CASE WHEN prior_selected_public_key IS NULL THEN NULL ELSE length(prior_selected_public_key) END AS prior_selected_public_key_bytes, \ + updated_at_unix_s, \ + CASE WHEN diagnostic_code IS NULL THEN NULL ELSE substr(CAST(diagnostic_code AS BLOB), 1, 21) END AS diagnostic_code, \ + CASE WHEN diagnostic_code IS NULL THEN NULL ELSE length(CAST(diagnostic_code AS BLOB)) END AS diagnostic_code_bytes, \ + CASE WHEN terminal_outcome IS NULL THEN NULL ELSE substr(CAST(terminal_outcome AS BLOB), 1, 10) END AS terminal_outcome, \ + CASE WHEN terminal_outcome IS NULL THEN NULL ELSE length(CAST(terminal_outcome AS BLOB)) END AS terminal_outcome_bytes, \ + CASE WHEN prior_binding_availability IS NULL THEN NULL ELSE substr(CAST(prior_binding_availability AS BLOB), 1, 19) END AS prior_binding_availability, \ + CASE WHEN prior_binding_availability IS NULL THEN NULL ELSE length(CAST(prior_binding_availability AS BLOB)) END AS prior_binding_availability_bytes, \ + resulting_revision FROM durable_operations"; impl DurableOperationRepository for Database { - fn begin_durable_operation( - &self, - request_id: &DurableRequestId, + #[allow(clippy::too_many_arguments)] + fn begin_durable_operation<'a>( + &'a self, + request_id: &'a DurableRequestId, kind: DurableOperationKind, identity: 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), - identity.to_hex(), - encoded_expected_revision, - prior.selected_identity().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.identity() != identity - || 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) + ) -> BoxFuture<'a, Result<DurableOperationStart, SafeError>> { + Box::pin(async move { + let expected_revision = encode_revision(expected_revision)?; + let request_id = request_id.as_str().to_owned(); + self.host() + .transaction(|transaction| { + Box::pin(async move { + if let Some(operation) = query_operation(transaction, &request_id).await? { + if operation.kind() != kind + || operation.identity() != identity + || operation.expected_revision() + != expected_revision.map(|value| value as u64) + || operation.prior() != prior + { + return Err(operation_conflict()); + } + return Ok(DurableOperationStart::Existing(operation)); + } + let unfinished: i64 = sqlx::query_scalar( + "SELECT count(*) FROM (SELECT 1 FROM durable_operations \ + WHERE terminal_outcome IS NULL LIMIT 1025)", + ) + .fetch_one(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + if usize::try_from(unfinished).ok().is_none_or(|count| { + count >= HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY + }) { + return Err(operation_capacity_exhausted()); + } + let result = sqlx::query( + "INSERT INTO durable_operations (request_id, operation_kind, \ + account_public_key, binding_public_key, expected_revision, phase, \ + prior_selected_public_key, updated_at_unix_s, prior_binding_availability) \ + VALUES (?, ?, ?, ?, ?, 'intent_recorded', ?, ?, ?)", + ) + .bind(&request_id) + .bind(encode_kind(kind)) + .bind(identity.as_bytes().as_slice()) + .bind(identity.as_bytes().as_slice()) + .bind(expected_revision) + .bind(prior.selected_identity().map(|value| value.as_bytes().to_vec())) + .bind(updated_at.as_seconds()) + .bind(prior.binding_availability().map(encode_availability)) + .execute(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + if result.rows_affected() != 1 { + return Err(corrupt_storage()); + } + let operation = query_operation(transaction, &request_id) + .await? + .ok_or_else(corrupt_storage)?; + Ok(DurableOperationStart::Started(operation)) + }) + }) + .await + .map_err(map_transaction_error) }) } - fn load_durable_operation( - &self, - request_id: &DurableRequestId, - ) -> Result<Option<DurableIdentityOperation>, SafeError> { - query_durable_operation(&self.connection(), request_id) + fn load_durable_operation<'a>( + &'a self, + request_id: &'a DurableRequestId, + ) -> BoxFuture<'a, Result<Option<DurableIdentityOperation>, SafeError>> { + Box::pin(async move { + let request_id = request_id.as_str().to_owned(); + self.host() + .transaction(|transaction| { + Box::pin(async move { query_operation(transaction, &request_id).await }) + }) + .await + .map_err(map_transaction_error) + }) } - fn advance_durable_operation( - &self, - request_id: &DurableRequestId, + fn advance_durable_operation<'a>( + &'a self, + request_id: &'a DurableRequestId, expected_phase: DurableOperationPhase, next_phase: DurableOperationPhase, updated_at: UnixTimestamp, diagnostic: Option<OperationDiagnostic>, - ) -> Result<DurableIdentityOperation, 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) + ) -> BoxFuture<'a, Result<DurableIdentityOperation, SafeError>> { + Box::pin(async move { + let request_id = request_id.as_str().to_owned(); + self.host() + .transaction(|transaction| { + Box::pin(async move { + let result = sqlx::query( + "UPDATE durable_operations SET phase = ?, updated_at_unix_s = ?, \ + diagnostic_code = ? WHERE request_id = ? AND phase = ? \ + AND terminal_outcome IS NULL", + ) + .bind(encode_phase(next_phase)) + .bind(updated_at.as_seconds()) + .bind(diagnostic.map(encode_diagnostic)) + .bind(&request_id) + .bind(encode_phase(expected_phase)) + .execute(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + if result.rows_affected() != 1 { + return Err(operation_conflict()); + } + query_operation(transaction, &request_id) + .await? + .ok_or_else(corrupt_storage) + }) + }) + .await + .map_err(map_transaction_error) + }) } - fn finalize_durable_operation( - &self, - request_id: &DurableRequestId, + fn finalize_durable_operation<'a>( + &'a self, + request_id: &'a 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) + ) -> BoxFuture<'a, Result<DurableOperationReceipt, SafeError>> { + Box::pin(async move { + let resulting_revision = encode_revision(resulting_revision)?; + let request_id = request_id.as_str().to_owned(); + self.host() + .transaction(|transaction| { + Box::pin(async move { + if let Some(existing) = query_operation(transaction, &request_id).await? + && let Some(receipt) = existing.terminal() + { + return if receipt.outcome() == outcome + && receipt.resulting_revision() + == resulting_revision.map(|value| value as u64) + { + Ok(receipt.clone()) + } else { + Err(operation_conflict()) + }; + } + let result = sqlx::query( + "UPDATE durable_operations SET phase = 'finalized', \ + terminal_outcome = ?, resulting_revision = ?, updated_at_unix_s = ? \ + WHERE request_id = ? AND phase = ? AND terminal_outcome IS NULL", + ) + .bind(encode_outcome(outcome)) + .bind(resulting_revision) + .bind(updated_at.as_seconds()) + .bind(&request_id) + .bind(encode_phase(expected_phase)) + .execute(&mut *transaction) + .await + .map_err(|_| storage_unavailable())?; + if result.rows_affected() != 1 { + return Err(operation_conflict()); + } + query_operation(transaction, &request_id) + .await? + .and_then(|operation| operation.terminal().cloned()) + .ok_or_else(corrupt_storage) + }) + }) + .await + .map_err(map_transaction_error) + }) } fn list_unfinished_durable_operations( &self, - ) -> Result<Vec<DurableIdentityOperation>, 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() + ) -> BoxFuture<'_, Result<Vec<DurableIdentityOperation>, SafeError>> { + Box::pin(async move { + self.host() + .transaction(|transaction| { + Box::pin(async move { + let sql = format!( + "{DURABLE_OPERATION_PROJECTION} WHERE terminal_outcome IS NULL \ + ORDER BY request_id LIMIT {}", + HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY + 1 + ); + let rows = sqlx::query(sqlx::AssertSqlSafe(sql.as_str())) + .fetch_all(&mut *transaction) + .await + .map_err(|_| corrupt_storage())?; + if rows.len() > HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY { + return Err(corrupt_storage()); + } + rows.iter().map(decode_operation).collect() + }) + }) + .await + .map_err(map_transaction_error) + }) } } -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, +async fn query_operation( + transaction: &mut ServiceSqliteTransaction<'_>, + request_id: &str, ) -> Result<Option<DurableIdentityOperation>, 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()) + let sql = format!("{DURABLE_OPERATION_PROJECTION} WHERE request_id = ? LIMIT 2"); + let rows = sqlx::query(sqlx::AssertSqlSafe(sql.as_str())) + .bind(request_id) + .fetch_all(&mut *transaction) + .await + .map_err(|_| corrupt_storage())?; + match rows.as_slice() { + [] => Ok(None), + [row] => decode_operation(row).map(Some), + _ => Err(corrupt_storage()), + } } -fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableIdentityOperation> { +fn decode_operation(row: &sqlx::sqlite::SqliteRow) -> Result<DurableIdentityOperation, SafeError> { 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 identity = - 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)? + DurableRequestId::parse(required_text(row, "request_id", "request_id_bytes", 36)?)?; + let kind = decode_kind(&required_text( + row, + "operation_kind", + "operation_kind_bytes", + 6, + )?)?; + let identity = required_key(row, "account_public_key", "account_public_key_bytes")?; + let expected_revision = decode_revision( + row.try_get("expected_revision") + .map_err(|_| corrupt_storage())?, + )?; + let phase = decode_phase(&required_text(row, "phase", "phase_bytes", 20)?)?; + let prior_selected = optional_key( + row, + "prior_selected_public_key", + "prior_selected_public_key_bytes", + )?; + let updated_at = UnixTimestamp::from_seconds( + row.try_get("updated_at_unix_s") + .map_err(|_| corrupt_storage())?, + ) + .ok_or_else(corrupt_storage)?; + let diagnostic = optional_text(row, "diagnostic_code", "diagnostic_code_bytes", 20)? .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))) + let outcome = optional_text(row, "terminal_outcome", "terminal_outcome_bytes", 9)? + .map(|value| decode_outcome(&value)) .transpose()?; + let prior_availability = optional_text( + row, + "prior_binding_availability", + "prior_binding_availability_bytes", + 18, + )? + .map(|value| decode_availability(&value)) + .transpose()?; + let resulting_revision = decode_revision( + row.try_get("resulting_revision") + .map_err(|_| corrupt_storage())?, + )?; let terminal = outcome.map(|outcome| { DurableOperationReceipt::new(request_id.clone(), identity, outcome, resulting_revision) }); @@ -231,99 +310,88 @@ fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableIdentityOp )) } -impl OperationJournal for Database { - fn begin_operation( - &self, - kind: IdentityOperationKind, - 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 required_key( + row: &sqlx::sqlite::SqliteRow, + value: &str, + length: &str, +) -> Result<PublicKey, SafeError> { + optional_key(row, value, length)?.ok_or_else(corrupt_storage) +} - fn update_operation( - &self, - id: OperationId, - phase: IdentityOperationPhase, - 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 optional_key( + row: &sqlx::sqlite::SqliteRow, + value: &str, + length: &str, +) -> Result<Option<PublicKey>, SafeError> { + optional_blob(row, value, length, 32)? + .map(|bytes| { + let bytes: [u8; 32] = bytes.try_into().map_err(|_| corrupt_storage())?; + PublicKey::from_bytes(bytes).map_err(|_| corrupt_storage()) + }) + .transpose() +} - fn list_pending_operations(&self) -> Result<Vec<PendingIdentityOperation>, 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 required_text( + row: &sqlx::sqlite::SqliteRow, + value: &str, + length: &str, + maximum: usize, +) -> Result<String, SafeError> { + optional_text(row, value, length, maximum)?.ok_or_else(corrupt_storage) +} - 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 optional_text( + row: &sqlx::sqlite::SqliteRow, + value: &str, + length: &str, + maximum: usize, +) -> Result<Option<String>, SafeError> { + optional_blob(row, value, length, maximum)? + .map(String::from_utf8) + .transpose() + .map_err(|_| corrupt_storage()) +} + +fn optional_blob( + row: &sqlx::sqlite::SqliteRow, + value_column: &str, + length_column: &str, + maximum: usize, +) -> Result<Option<Vec<u8>>, SafeError> { + let value = row + .try_get::<Option<Vec<u8>>, _>(value_column) + .map_err(|_| corrupt_storage())?; + let length = row + .try_get::<Option<i64>, _>(length_column) + .map_err(|_| corrupt_storage())?; + match (value, length) { + (None, None) => Ok(None), + (Some(value), Some(length)) + if usize::try_from(length) + .ok() + .is_some_and(|length| length <= maximum && length == value.len()) => + { + Ok(Some(value)) + } + _ => Err(corrupt_storage()), } } -fn decode_operation(row: &Row<'_>) -> rusqlite::Result<PendingIdentityOperation> { - 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(PendingIdentityOperation::new( - OperationId::from_raw(id), - kind, - subject, - phase, - updated_at, - diagnostic, - )) +fn encode_revision(value: Option<u64>) -> Result<Option<i64>, SafeError> { + value + .map(i64::try_from) + .transpose() + .map_err(|_| operation_conflict()) } -const fn encode_durable_kind(value: DurableOperationKind) -> &'static str { +fn decode_revision(value: Option<i64>) -> Result<Option<u64>, SafeError> { + value + .map(u64::try_from) + .transpose() + .map_err(|_| corrupt_storage()) +} + +const fn encode_kind(value: DurableOperationKind) -> &'static str { match value { DurableOperationKind::Create => "create", DurableOperationKind::Import => "import", @@ -332,17 +400,17 @@ const fn encode_durable_kind(value: DurableOperationKind) -> &'static str { } } -fn decode_durable_kind(value: &str) -> rusqlite::Result<DurableOperationKind> { +fn decode_kind(value: &str) -> Result<DurableOperationKind, SafeError> { match value { "create" => Ok(DurableOperationKind::Create), "import" => Ok(DurableOperationKind::Import), "repair" => Ok(DurableOperationKind::Repair), "remove" => Ok(DurableOperationKind::Remove), - _ => Err(invalid_column(1)), + _ => Err(corrupt_storage()), } } -const fn encode_durable_phase(value: DurableOperationPhase) -> &'static str { +const fn encode_phase(value: DurableOperationPhase) -> &'static str { match value { DurableOperationPhase::IntentRecorded => "intent_recorded", DurableOperationPhase::CredentialWritten => "credential_written", @@ -355,7 +423,7 @@ const fn encode_durable_phase(value: DurableOperationPhase) -> &'static str { } } -fn decode_durable_phase(value: &str) -> rusqlite::Result<DurableOperationPhase> { +fn decode_phase(value: &str) -> Result<DurableOperationPhase, SafeError> { match value { "intent_recorded" => Ok(DurableOperationPhase::IntentRecorded), "credential_written" => Ok(DurableOperationPhase::CredentialWritten), @@ -365,11 +433,11 @@ fn decode_durable_phase(value: &str) -> rusqlite::Result<DurableOperationPhase> "credential_deleted" => Ok(DurableOperationPhase::CredentialDeleted), "metadata_deleted" => Ok(DurableOperationPhase::MetadataDeleted), "finalized" => Ok(DurableOperationPhase::Finalized), - _ => Err(invalid_column(4)), + _ => Err(corrupt_storage()), } } -const fn encode_terminal_outcome(value: DurableTerminalOutcome) -> &'static str { +const fn encode_outcome(value: DurableTerminalOutcome) -> &'static str { match value { DurableTerminalOutcome::Completed => "completed", DurableTerminalOutcome::Cancelled => "cancelled", @@ -377,69 +445,12 @@ const fn encode_terminal_outcome(value: DurableTerminalOutcome) -> &'static str } } -fn decode_terminal_outcome(value: &str) -> rusqlite::Result<DurableTerminalOutcome> { +fn decode_outcome(value: &str) -> Result<DurableTerminalOutcome, SafeError> { match value { "completed" => Ok(DurableTerminalOutcome::Completed), "cancelled" => Ok(DurableTerminalOutcome::Cancelled), "failed" => Ok(DurableTerminalOutcome::Failed), - _ => Err(invalid_column(8)), - } -} - -const fn encode_binding_availability(value: SignerAvailability) -> &'static str { - match value { - SignerAvailability::Available => "available", - SignerAvailability::CredentialMissing => "credential_missing", - SignerAvailability::StoreUnavailable => "store_unavailable", - } -} - -fn decode_binding_availability(value: &str) -> rusqlite::Result<SignerAvailability> { - match value { - "available" => Ok(SignerAvailability::Available), - "credential_missing" => Ok(SignerAvailability::CredentialMissing), - "store_unavailable" => Ok(SignerAvailability::StoreUnavailable), - _ => Err(invalid_column(9)), - } -} - -const fn encode_kind(value: IdentityOperationKind) -> &'static str { - match value { - IdentityOperationKind::Add => "add", - IdentityOperationKind::Import => "import", - IdentityOperationKind::Remove => "remove", - } -} - -fn decode_kind(value: &str) -> rusqlite::Result<IdentityOperationKind> { - match value { - "add" => Ok(IdentityOperationKind::Add), - "import" => Ok(IdentityOperationKind::Import), - "remove" => Ok(IdentityOperationKind::Remove), - _ => Err(invalid_column(1)), - } -} - -const fn encode_phase(value: IdentityOperationPhase) -> &'static str { - match value { - IdentityOperationPhase::IntentRecorded => "intent_recorded", - IdentityOperationPhase::CredentialWritten => "credential_written", - IdentityOperationPhase::MetadataCommitted => "metadata_committed", - IdentityOperationPhase::CompensationPending => "compensation_pending", - IdentityOperationPhase::CredentialDeleted => "credential_deleted", - IdentityOperationPhase::MetadataDeleted => "metadata_deleted", - } -} - -fn decode_phase(value: &str) -> rusqlite::Result<IdentityOperationPhase> { - match value { - "intent_recorded" => Ok(IdentityOperationPhase::IntentRecorded), - "credential_written" => Ok(IdentityOperationPhase::CredentialWritten), - "metadata_committed" => Ok(IdentityOperationPhase::MetadataCommitted), - "compensation_pending" => Ok(IdentityOperationPhase::CompensationPending), - "credential_deleted" => Ok(IdentityOperationPhase::CredentialDeleted), - "metadata_deleted" => Ok(IdentityOperationPhase::MetadataDeleted), - _ => Err(invalid_column(3)), + _ => Err(corrupt_storage()), } } @@ -454,7 +465,7 @@ const fn encode_diagnostic(value: OperationDiagnostic) -> &'static str { } } -fn decode_diagnostic(value: &str) -> rusqlite::Result<OperationDiagnostic> { +fn decode_diagnostic(value: &str) -> Result<OperationDiagnostic, SafeError> { match value { "storage_unavailable" => Ok(OperationDiagnostic::StorageUnavailable), "keyring_unavailable" => Ok(OperationDiagnostic::KeyringUnavailable), @@ -462,316 +473,37 @@ fn decode_diagnostic(value: &str) -> rusqlite::Result<OperationDiagnostic> { "compensation_failed" => Ok(OperationDiagnostic::CompensationFailed), "conflict" => Ok(OperationDiagnostic::Conflict), "expired" => Ok(OperationDiagnostic::Expired), - _ => Err(invalid_column(5)), + _ => Err(corrupt_storage()), } } -fn invalid_column(index: usize) -> rusqlite::Error { - rusqlite::Error::InvalidColumnType( - index, - "identity operation journal".to_owned(), - rusqlite::types::Type::Text, - ) -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The identity recovery journal is unavailable."), - ) +const fn encode_availability(value: SignerAvailability) -> &'static str { + match value { + SignerAvailability::Available => "available", + SignerAvailability::CredentialMissing => "credential_missing", + SignerAvailability::StoreUnavailable => "store_unavailable", + } } -const fn corrupt_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageCorrupt, - SafeMessage::new("The identity recovery journal could not be read."), - ) +fn decode_availability(value: &str) -> Result<SignerAvailability, SafeError> { + match value { + "available" => Ok(SignerAvailability::Available), + "credential_missing" => Ok(SignerAvailability::CredentialMissing), + "store_unavailable" => Ok(SignerAvailability::StoreUnavailable), + _ => Err(corrupt_storage()), + } } -const fn operation_not_found() -> SafeError { +const fn operation_conflict() -> SafeError { SafeError::new( - SafeErrorCode::PendingOperationRecoveryRequired, - SafeMessage::new("The identity recovery operation was not found."), + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The durable operation conflicts with existing state."), ) } -const fn operation_conflict() -> SafeError { +const fn operation_capacity_exhausted() -> SafeError { SafeError::new( SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The durable identity operation conflicts with existing state."), + SafeMessage::new("The unfinished operation capacity is exhausted."), ) } - -#[cfg(test)] -mod tests { - use harvestcircle_application::{ - DurableOperationKind, DurableOperationPhase, DurableOperationRepository, - DurableOperationStart, DurableRequestId, DurableTerminalOutcome, IdentityOperationKind, - IdentityOperationPhase, OperationDiagnostic, OperationJournal, OperationPriorState, - }; - use harvestcircle_domain::{PublicKey, SignerAvailability, 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( - IdentityOperationKind::Import, - subject, - UnixTimestamp::from_seconds(10).expect("time"), - ) - .expect("begin"); - database - .update_operation( - id, - IdentityOperationPhase::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(), IdentityOperationKind::Import); - assert_eq!( - pending[0].phase(), - IdentityOperationPhase::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( - IdentityOperationKind::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("01890f3e-7b1c-7000-8000-000000000011").expect("request"); - let identity = public_key(9); - let prior = OperationPriorState::new( - Some(public_key(8)), - Some(SignerAvailability::CredentialMissing), - ); - let started = database - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - identity, - 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, - identity, - Some(4), - prior, - UnixTimestamp::from_seconds(11).expect("time"), - ) - .expect("replay"); - assert!(matches!(replay, DurableOperationStart::Existing(_))); - assert!( - database - .begin_durable_operation( - &request, - DurableOperationKind::Remove, - identity, - Some(4), - prior, - UnixTimestamp::from_seconds(11).expect("time"), - ) - .is_err() - ); - let missing_request = - DurableRequestId::parse("01890f3e-7b1c-7000-8000-000000000012").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, - identity, - Some(5), - prior, - UnixTimestamp::from_seconds(11).expect("time"), - ) - .is_err() - ); - assert!( - database - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - identity, - 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("01890f3e-7b1c-7000-8000-000000000013").expect("request"); - database - .begin_durable_operation( - &overflow_request, - DurableOperationKind::Import, - identity, - 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/core/crates/harvestcircle_storage/src/lib.rs b/core/crates/harvestcircle_storage/src/lib.rs @@ -1,21 +1,17 @@ #![doc = "HarvestCircle persistence adapters."] #![cfg_attr(coverage_nightly, feature(coverage_attribute))] -mod compatibility; mod contract; -pub mod db; -pub mod identities; -pub mod identity_namespace; +mod db; +mod identities; +mod identity_namespace; mod installation; -pub mod journal; +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; +mod os_keyring; +mod profiles; -pub use compatibility::{DatabasePreflight, PersistedIdentityIssue, PersistedIdentityIssueKind}; pub use contract::{ HARVESTCIRCLE_ACTOR_MAILBOX_CAPACITY, HARVESTCIRCLE_APPLICATION_ID, HARVESTCIRCLE_COMMAND_DEADLINE_MAX_MS, HARVESTCIRCLE_COMMAND_DEADLINE_MIN_MS, @@ -25,9 +21,7 @@ pub use contract::{ HARVESTCIRCLE_RELAY_URL_UTF8_BYTES, HARVESTCIRCLE_SERVICE_ID, HARVESTCIRCLE_STATE_SCHEMA_VERSION, HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY, HarvestCircleStorageContract, HarvestCircleStorageContractError, - harvestcircle_initial_schema_sql, harvestcircle_migration_catalog, - harvestcircle_schema_catalog, + harvestcircle_migration_catalog, harvestcircle_schema_catalog, }; pub use db::{CURRENT_SCHEMA_VERSION, Database}; pub use os_keyring::{CREDENTIAL_SERVICE, OsKeyringSecretStore}; -pub use repair::{QuarantineExportReceipt, RepairAuthorization, RepairCandidate}; diff --git a/core/crates/harvestcircle_storage/src/os_keyring.rs b/core/crates/harvestcircle_storage/src/os_keyring.rs @@ -18,16 +18,16 @@ impl OsKeyringSecretStore { Entry::new(CREDENTIAL_SERVICE, &public_key.to_hex()).map_err(|_| keyring_unavailable()) } - fn operation(&self) -> MutexGuard<'_, ()> { + fn operation(&self) -> Result<MutexGuard<'_, ()>, SafeError> { self.operation_lock .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + .map_err(|_| keyring_unavailable()) } } impl SecretStore for OsKeyringSecretStore { fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> { - let _operation = self.operation(); + let _operation = self.operation()?; let entry = Self::entry(public_key)?; match entry.get_password() { Ok(password) => { @@ -43,7 +43,7 @@ impl SecretStore for OsKeyringSecretStore { } fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> { - let _operation = self.operation(); + let _operation = self.operation()?; let password = Self::entry(public_key)? .get_password() .map_err(|error| map_read_error(&error))?; @@ -51,7 +51,7 @@ impl SecretStore for OsKeyringSecretStore { } fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> { - let _operation = self.operation(); + let _operation = self.operation()?; match Self::entry(public_key)?.get_password() { Ok(password) => { drop(Zeroizing::new(password)); @@ -63,7 +63,7 @@ impl SecretStore for OsKeyringSecretStore { } fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> { - let _operation = self.operation(); + let _operation = self.operation()?; Self::entry(public_key)? .delete_credential() .map_err(|error| map_read_error(&error)) @@ -101,7 +101,7 @@ const fn keyring_unavailable() -> SafeError { #[cfg(test)] mod tests { use harvestcircle_application::SecretStore; - use harvestcircle_domain::{PublicKey, SecretKeyInput}; + use harvestcircle_domain::{PublicKey, SafeErrorCode, SecretKeyInput}; use super::{CREDENTIAL_SERVICE, OsKeyringSecretStore}; @@ -118,6 +118,22 @@ mod tests { } #[test] + fn poisoned_operation_lock_fails_closed_before_keyring_access() { + let store = OsKeyringSecretStore::default(); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _operation = store.operation_lock.lock().expect("operation lock"); + panic!("injected operation failure"); + })); + assert!(panic.is_err()); + + let public_key = + PublicKey::from_hex("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7") + .expect("valid public key"); + let error = store.contains(public_key).expect_err("poison must reject"); + assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); + } + + #[test] #[ignore = "mutates the current user's operating-system credential store"] fn real_keyring_smoke_round_trips_and_deletes() { let store = OsKeyringSecretStore::default(); diff --git a/core/crates/harvestcircle_storage/src/profiles.rs b/core/crates/harvestcircle_storage/src/profiles.rs @@ -1,110 +1,229 @@ -use harvestcircle_application::{CachedProfile, ProfileRefreshStatus, ProfileRepository}; +use harvestcircle_application::{ + BoxFuture, CachedProfile, ProfileRefreshStatus, ProfileRepository, +}; use harvestcircle_domain::{ - EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode, - SafeMessage, UnixTimestamp, + EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, UnixTimestamp, }; -use rusqlite::{OptionalExtension, Row, params}; +use sqlx::Row; use crate::Database; +use crate::db::{corrupt_storage, map_transaction_error, storage_unavailable}; + +const PROFILE_PROJECTION: &str = "SELECT substr(event_id, 1, 33) AS event_id, \ + length(event_id) AS event_id_bytes, event_created_at_unix_s, \ + CASE WHEN name IS NULL THEN NULL ELSE substr(CAST(name AS BLOB), 1, 129) END AS name, \ + CASE WHEN name IS NULL THEN NULL ELSE length(CAST(name AS BLOB)) END AS name_bytes, \ + CASE WHEN display_name IS NULL THEN NULL ELSE substr(CAST(display_name AS BLOB), 1, 129) END AS display_name, \ + CASE WHEN display_name IS NULL THEN NULL ELSE length(CAST(display_name AS BLOB)) END AS display_name_bytes, \ + CASE WHEN nip05 IS NULL THEN NULL ELSE substr(CAST(nip05 AS BLOB), 1, 321) END AS nip05, \ + CASE WHEN nip05 IS NULL THEN NULL ELSE length(CAST(nip05 AS BLOB)) END AS nip05_bytes, \ + CASE WHEN about IS NULL THEN NULL ELSE substr(CAST(about AS BLOB), 1, 4097) END AS about, \ + CASE WHEN about IS NULL THEN NULL ELSE length(CAST(about AS BLOB)) END AS about_bytes, \ + CASE WHEN picture IS NULL THEN NULL ELSE substr(CAST(picture AS BLOB), 1, 2049) END AS picture, \ + CASE WHEN picture IS NULL THEN NULL ELSE length(CAST(picture AS BLOB)) END AS picture_bytes, \ + refreshed_at_unix_s, substr(CAST(refresh_status AS BLOB), 1, 13) AS refresh_status, \ + length(CAST(refresh_status AS BLOB)) AS refresh_status_bytes FROM profile_cache"; 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 load_profile( + &self, + public_key: PublicKey, + ) -> BoxFuture<'_, Result<Option<CachedProfile>, SafeError>> { + Box::pin(async move { + self.host() + .transaction(|transaction| { + Box::pin(async move { + let sql = + format!("{PROFILE_PROJECTION} WHERE subject_public_key = ? LIMIT 2"); + let rows = sqlx::query(sqlx::AssertSqlSafe(sql.as_str())) + .bind(public_key.as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| corrupt_storage())?; + match rows.as_slice() { + [] => Ok(None), + [row] => decode_profile(row, public_key).map(Some), + _ => Err(corrupt_storage()), + } + }) + }) + .await + .map_err(map_transaction_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 save_profile<'a>( + &'a self, + profile: &'a CachedProfile, + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + let candidate = profile.candidate(); + let metadata = candidate.metadata(); + let author = *candidate.author().as_bytes(); + let event_id = candidate.event_id().as_bytes(); + let event_created_at = candidate.created_at().as_seconds(); + let name = metadata.name().map(str::to_owned); + let display_name = metadata.display_name().map(str::to_owned); + let nip05 = metadata.nip05().map(str::to_owned); + let about = metadata.about().map(str::to_owned); + let picture = metadata.picture().map(str::to_owned); + let refreshed_at = profile.refreshed_at().as_seconds(); + let refresh_status = encode_refresh_status(profile.refresh_status()); + self.host() + .transaction(|transaction| { + Box::pin(async move { + sqlx::query( + "INSERT INTO profile_cache (subject_public_key, event_id, \ + event_created_at_unix_s, name, display_name, nip05, about, picture, \ + refreshed_at_unix_s, refresh_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(subject_public_key) DO UPDATE SET event_id = excluded.event_id, \ + event_created_at_unix_s = excluded.event_created_at_unix_s, name = excluded.name, \ + display_name = excluded.display_name, nip05 = excluded.nip05, about = excluded.about, \ + picture = excluded.picture, refreshed_at_unix_s = excluded.refreshed_at_unix_s, \ + refresh_status = excluded.refresh_status WHERE \ + excluded.event_created_at_unix_s > profile_cache.event_created_at_unix_s \ + OR (excluded.event_created_at_unix_s = profile_cache.event_created_at_unix_s \ + AND excluded.event_id < profile_cache.event_id)", + ) + .bind(author.as_slice()) + .bind(event_id.as_slice()) + .bind(event_created_at) + .bind(&name) + .bind(&display_name) + .bind(&nip05) + .bind(&about) + .bind(&picture) + .bind(refreshed_at) + .bind(refresh_status) + .execute(&mut *transaction) + .await + .map(|_| ()) + .map_err(|_| storage_unavailable()) + }) + }) + .await + .map_err(map_transaction_error) + }) } - fn record_refresh_status( - &self, + fn record_refresh_status<'a>( + &'a 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()) + ) -> BoxFuture<'a, Result<(), SafeError>> { + Box::pin(async move { + self.host() + .transaction(|transaction| { + Box::pin(async move { + sqlx::query( + "UPDATE profile_cache SET refreshed_at_unix_s = ?, refresh_status = ? \ + WHERE subject_public_key = ?", + ) + .bind(refreshed_at.as_seconds()) + .bind(encode_refresh_status(status)) + .bind(public_key.as_bytes().as_slice()) + .execute(&mut *transaction) + .await + .map(|_| ()) + .map_err(|_| storage_unavailable()) + }) + }) + .await + .map_err(map_transaction_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 remove_profile(&self, public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> { + Box::pin(async move { + self.host() + .transaction(|transaction| { + Box::pin(async move { + sqlx::query("DELETE FROM profile_cache WHERE subject_public_key = ?") + .bind(public_key.as_bytes().as_slice()) + .execute(&mut *transaction) + .await + .map(|_| ()) + .map_err(|_| storage_unavailable()) + }) + }) + .await + .map_err(map_transaction_error) + }) } } -fn decode_profile(row: &Row<'_>, author: PublicKey) -> rusqlite::Result<CachedProfile> { +fn decode_profile( + row: &sqlx::sqlite::SqliteRow, + author: PublicKey, +) -> Result<CachedProfile, SafeError> { 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))?; + bounded_blob(row, "event_id", "event_id_bytes", 32)?.ok_or_else(corrupt_storage)?; + let event_id: [u8; 32] = event_id.try_into().map_err(|_| corrupt_storage())?; + let created_at = UnixTimestamp::from_seconds( + row.try_get("event_created_at_unix_s") + .map_err(|_| corrupt_storage())?, + ) + .ok_or_else(corrupt_storage)?; let metadata = ProfileMetadata::new( - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, - row.get(6)?, + bounded_text(row, "name", "name_bytes", 128)?, + bounded_text(row, "display_name", "display_name_bytes", 128)?, + bounded_text(row, "nip05", "nip05_bytes", 320)?, + bounded_text(row, "about", "about_bytes", 4_096)?, + bounded_text(row, "picture", "picture_bytes", 2_048)?, ) - .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())?; + .map_err(|_| corrupt_storage())?; + let refreshed_at = UnixTimestamp::from_seconds( + row.try_get("refreshed_at_unix_s") + .map_err(|_| corrupt_storage())?, + ) + .ok_or_else(corrupt_storage)?; + let status = bounded_text(row, "refresh_status", "refresh_status_bytes", 12)? + .ok_or_else(corrupt_storage)?; Ok(CachedProfile::new( - Kind0ProfileCandidate::new(event_id, author, created_at, metadata), + Kind0ProfileCandidate::new(EventId::from_bytes(event_id), author, created_at, metadata), refreshed_at, - refresh_status, + decode_refresh_status(&status)?, )) } +fn bounded_text( + row: &sqlx::sqlite::SqliteRow, + value_column: &str, + length_column: &str, + maximum: usize, +) -> Result<Option<String>, SafeError> { + bounded_blob(row, value_column, length_column, maximum)? + .map(String::from_utf8) + .transpose() + .map_err(|_| corrupt_storage()) +} + +fn bounded_blob( + row: &sqlx::sqlite::SqliteRow, + value_column: &str, + length_column: &str, + maximum: usize, +) -> Result<Option<Vec<u8>>, SafeError> { + let value = row + .try_get::<Option<Vec<u8>>, _>(value_column) + .map_err(|_| corrupt_storage())?; + let length = row + .try_get::<Option<i64>, _>(length_column) + .map_err(|_| corrupt_storage())?; + match (value, length) { + (None, None) => Ok(None), + (Some(value), Some(length)) + if usize::try_from(length) + .ok() + .is_some_and(|length| length <= maximum && length == value.len()) => + { + Ok(Some(value)) + } + _ => Err(corrupt_storage()), + } +} + const fn encode_refresh_status(status: ProfileRefreshStatus) -> &'static str { match status { ProfileRefreshStatus::Success => "success", @@ -113,144 +232,11 @@ const fn encode_refresh_status(status: ProfileRefreshStatus) -> &'static str { } } -fn decode_refresh_status(value: &str) -> rusqlite::Result<ProfileRefreshStatus> { +fn decode_refresh_status(value: &str) -> Result<ProfileRefreshStatus, SafeError> { 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 harvestcircle_application::{ - CachedProfile, IdentityRepository, ProfileRefreshStatus, ProfileRepository, - }; - use harvestcircle_domain::{ - EventId, IdentityCreatedAt, Kind0ProfileCandidate, LocalKeyringBinding, NostrIdentity, - NostrIdentityReference, ProfileMetadata, PublicKey, SignerAvailability, UnixTimestamp, - }; - - use crate::Database; - - fn public_key() -> PublicKey { - PublicKey::from_bytes([7; 32]).expect("valid public key") - } - - fn identity(public_key: PublicKey) -> NostrIdentity { - NostrIdentity::new( - NostrIdentityReference::derive(public_key).expect("identity"), - LocalKeyringBinding::new(public_key, SignerAvailability::Available), - None, - IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), - None, - ) - .expect("identity") - } - - 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_identity(&identity(public_key)) - .expect("identity"); - 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_identity(&identity(public_key)) - .expect("identity"); - 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_identity_removal() { - let database = Database::in_memory().expect("database"); - let public_key = public_key(); - database - .insert_identity(&identity(public_key)) - .expect("identity"); - database - .save_profile(&profile(public_key, 1, 10, "Farm")) - .expect("profile"); - database - .remove_identity(public_key) - .expect("remove identity"); - - assert_eq!(database.load_profile(public_key).expect("load"), None); + _ => Err(corrupt_storage()), } } diff --git a/core/crates/harvestcircle_storage/src/recovery.rs b/core/crates/harvestcircle_storage/src/recovery.rs @@ -1,696 +0,0 @@ -use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; - -use harvestcircle_domain::{SafeError, SafeErrorCode, SafeMessage}; -use hmac::{Hmac, Mac}; -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 = "harvestcircle-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, tempdir_in}; - - 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 tempdir() -> std::io::Result<TempDir> { - tempdir_in(std::env::temp_dir().canonicalize()?) - } - - 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("harvestcircle.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("harvestcircle.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/core/crates/harvestcircle_storage/src/repair.rs b/core/crates/harvestcircle_storage/src/repair.rs @@ -1,410 +0,0 @@ -use std::fs::{self, File, OpenOptions}; -use std::io::Read; -use std::path::{Path, PathBuf}; - -use harvestcircle_domain::{SafeError, SafeErrorCode, SafeMessage}; -use harvestcircle_product::LEGACY_DATABASE_FILENAME; -use hmac::{Hmac, Mac}; -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"harvestcircle-quarantine-export-v1"; -const REPAIR_DOMAIN: &[u8] = b"harvestcircle-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(format!("{LEGACY_DATABASE_FILENAME}.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, tempdir_in}; - - 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 tempdir() -> std::io::Result<TempDir> { - tempdir_in(std::env::temp_dir().canonicalize()?) - } - - 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("harvestcircle.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("harvestcircle.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/core/crates/harvestcircle_storage/tests/package_boundary.rs b/core/crates/harvestcircle_storage/tests/package_boundary.rs @@ -0,0 +1,78 @@ +use std::{fs, path::Path}; + +fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_else(|error| panic!("{}: {error}", path.display())) +} + +#[test] +fn storage_package_keeps_one_sqlite_authority_and_a_sealed_public_surface() { + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace_root = crate_root.join("../.."); + let manifest = read(&crate_root.join("Cargo.toml")); + let workspace_manifest = read(&workspace_root.join("Cargo.toml")); + let lock = read(&workspace_root.join("Cargo.lock")); + let root_source = read(&crate_root.join("src/lib.rs")); + let database_source = read(&crate_root.join("src/db.rs")); + let keyring_source = read(&crate_root.join("src/os_keyring.rs")); + let api = read(&workspace_root.join("compatibility/harvestcircle-storage-api-v1.txt")); + + for forbidden in ["rusqlite", "refinery", "hmac", "rustix"] { + assert!( + !manifest.contains(forbidden), + "storage manifest reintroduced direct {forbidden} authority" + ); + } + assert!(manifest.contains("radroots_service_sqlite.workspace = true")); + assert!(manifest.contains("sqlx.workspace = true")); + assert!(workspace_manifest.contains("sqlx = { version = \"=0.9.0\"")); + for forbidden_package in ["rusqlite", "refinery"] { + assert!( + !lock.contains(&format!("\nname = \"{forbidden_package}\"\n")), + "lock contains forbidden SQLite package {forbidden_package}" + ); + } + + for module in [ + "contract", + "db", + "identities", + "identity_namespace", + "installation", + "journal", + "os_keyring", + "profiles", + ] { + assert!(root_source.contains(&format!("mod {module};"))); + assert!(!root_source.contains(&format!("pub mod {module};"))); + } + assert!(!root_source.contains("harvestcircle_initial_schema_sql")); + assert!(!keyring_source.contains("PoisonError::into_inner")); + assert!(!database_source.contains("pub fn host")); + assert!(!database_source.contains("pub const fn host")); + + for required in [ + "pub struct harvestcircle_storage::Database", + "pub async fn harvestcircle_storage::Database::open", + "pub async fn harvestcircle_storage::Database::close", + "impl harvestcircle_application::ports::DurableOperationRepository for harvestcircle_storage::Database", + "harvestcircle_application::ports::BoxFuture", + "pub fn harvestcircle_storage::harvestcircle_migration_catalog()", + "pub fn harvestcircle_storage::harvestcircle_schema_catalog()", + ] { + assert!(api.contains(required), "API baseline is missing {required}"); + } + for forbidden in [ + "rusqlite::", + "refinery::", + "sqlx::", + "OperationJournal", + "harvestcircle_initial_schema_sql", + "repair", + "preflight", + ] { + assert!( + !api.contains(forbidden), + "API baseline exposes forbidden surface {forbidden}" + ); + } +} diff --git a/core/crates/harvestcircle_storage/tests/redaction.rs b/core/crates/harvestcircle_storage/tests/redaction.rs @@ -1,11 +1,16 @@ use std::fs; -use harvestcircle_application::{IdentityOperationKind, IdentityRepository, OperationJournal}; +use harvestcircle_application::IdentityRepository; use harvestcircle_domain::{ IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, PublicKey, SignerAvailability, UnixTimestamp, }; use harvestcircle_storage::Database; +use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, +}; +use radroots_service_sqlite::MigrationBuildIdentity; use tempfile::{TempDir, tempdir_in}; fn tempdir() -> std::io::Result<TempDir> { @@ -28,12 +33,56 @@ fn assert_redacted(bytes: &[u8]) { assert!(!bytes.windows(5).any(|value| value == b"nsec1")); } -#[test] -fn redaction_guards_sqlite_schema_and_non_secret_records() { +fn runtime_context(directory: &TempDir) -> RuntimeContext { + RuntimeContext::resolve( + &RadrootsPathResolver::new( + RadrootsPlatform::current(), + RadrootsHostEnvironment::default(), + ), + RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some( + directory + .path() + .canonicalize() + .expect("canonical directory"), + ), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::SafeDefault, + ) + .expect("bootstrap"), + ServiceId::new("harvestcircle").expect("service"), + InstanceId::new("desktop").expect("instance"), + ) + .expect("runtime context") +} + +fn build_identity() -> MigrationBuildIdentity { + MigrationBuildIdentity::new( + "0.1.0-alpha", + "1111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222", + "1.97.1", + "test", + "test", + 1, + 1, + 1, + 1, + 1, + ) + .expect("build identity") +} + +#[tokio::test] +async fn redaction_guards_sqlite_schema_and_non_secret_records() { let directory = tempdir().expect("directory"); - let path = directory.path().join("harvestcircle.sqlite3"); + let context = runtime_context(&directory); + let path = context.paths().state().join("state.sqlite"); { - let database = Database::open(&path).expect("database"); + let database = Database::open(&context, 1, 1, &build_identity()) + .await + .expect("database"); let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); let identity = NostrIdentity::new( NostrIdentityReference::derive(public_key).expect("identity"), @@ -43,14 +92,8 @@ fn redaction_guards_sqlite_schema_and_non_secret_records() { None, ) .expect("identity"); - database.insert_identity(&identity).expect("identity"); - database - .begin_operation( - IdentityOperationKind::Add, - identity.public_key(), - UnixTimestamp::from_seconds(2).expect("time"), - ) - .expect("journal"); + database.insert_identity(&identity).await.expect("identity"); + database.close().await.expect("close"); } assert_redacted(&fs::read(path).expect("database bytes")); } diff --git a/core/crates/harvestcircle_storage/tests/sqlx_storage.rs b/core/crates/harvestcircle_storage/tests/sqlx_storage.rs @@ -0,0 +1,252 @@ +use std::fs; + +use harvestcircle_application::{ + DurableOperationKind, DurableOperationPhase, DurableOperationRepository, DurableOperationStart, + DurableRequestId, DurableTerminalOutcome, IdentityRepository, KeyMaterialProvider, + OperationPriorState, +}; +use harvestcircle_domain::{ + IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, PublicKey, + SafeErrorCode, SignerAvailability, UnixTimestamp, +}; +use harvestcircle_nostr::NostrKeyMaterialProvider; +use harvestcircle_storage::{ + Database, HARVESTCIRCLE_IDENTITY_CAPACITY, HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY, +}; +use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, +}; +use radroots_service_sqlite::MigrationBuildIdentity; +use sqlx::sqlite::SqliteConnectOptions; +use sqlx::{Connection, SqliteConnection}; +use tempfile::{TempDir, tempdir_in}; + +fn tempdir() -> std::io::Result<TempDir> { + tempdir_in(std::env::temp_dir().canonicalize()?) +} + +fn runtime_context(directory: &TempDir) -> RuntimeContext { + RuntimeContext::resolve( + &RadrootsPathResolver::new( + RadrootsPlatform::current(), + RadrootsHostEnvironment::default(), + ), + RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some( + directory + .path() + .canonicalize() + .expect("canonical directory"), + ), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::SafeDefault, + ) + .expect("bootstrap"), + ServiceId::new("harvestcircle").expect("service"), + InstanceId::new("desktop").expect("instance"), + ) + .expect("runtime context") +} + +fn build_identity() -> MigrationBuildIdentity { + MigrationBuildIdentity::new( + "0.1.0-alpha", + "1111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222", + "1.97.1", + "test", + "test", + 1, + 1, + 1, + 1, + 1, + ) + .expect("build identity") +} + +fn identity(index: usize) -> NostrIdentity { + let (public_key, npub, secret, nsec) = NostrKeyMaterialProvider + .generate() + .expect("generated key material") + .into_parts(); + drop((secret, nsec)); + NostrIdentity::new( + NostrIdentityReference::verify(public_key, npub.as_str().to_owned()) + .expect("identity reference"), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), + None, + IdentityCreatedAt::new(UnixTimestamp::from_seconds(index as i64 + 1).expect("time")), + None, + ) + .expect("identity") +} + +#[tokio::test] +async fn canonical_database_preserves_legacy_state_and_enforces_identity_capacity() { + let directory = tempdir().expect("directory"); + let legacy = directory.path().join("harvestcircle.sqlite3"); + fs::write(&legacy, b"legacy-state-must-remain-untouched").expect("legacy sentinel"); + let context = runtime_context(&directory); + let database_path = context.paths().state().join("state.sqlite"); + let build = build_identity(); + let database = Database::open(&context, 1, 1, &build) + .await + .expect("database"); + let generation = database.metadata().source_generation(); + + let first_identity = identity(0); + database + .insert_identity(&first_identity) + .await + .expect("first identity"); + for index in 1..HARVESTCIRCLE_IDENTITY_CAPACITY { + database + .insert_identity(&identity(index)) + .await + .expect("identity within capacity"); + } + assert_eq!( + database + .list_identities() + .await + .expect("identity list") + .len(), + HARVESTCIRCLE_IDENTITY_CAPACITY + ); + let duplicate = database + .insert_identity(&first_identity) + .await + .expect_err("duplicate at capacity"); + assert_eq!(duplicate.code(), SafeErrorCode::IdentityAlreadyExists); + let excess = database + .insert_identity(&identity(HARVESTCIRCLE_IDENTITY_CAPACITY)) + .await + .expect_err("capacity must reject"); + assert_eq!(excess.code(), SafeErrorCode::InvalidApplicationState); + database.close().await.expect("close"); + + assert_eq!( + fs::read(&legacy).expect("legacy sentinel"), + b"legacy-state-must-remain-untouched" + ); + assert!(database_path.is_file()); + assert_ne!(database_path, legacy); + + let reopened = Database::open(&context, 2, 2, &build) + .await + .expect("reopen"); + assert_eq!(reopened.metadata().source_generation(), generation); + assert_eq!( + reopened + .list_identities() + .await + .expect("reopened identities") + .len(), + HARVESTCIRCLE_IDENTITY_CAPACITY + ); + reopened.close().await.expect("reopened close"); +} + +#[tokio::test] +async fn unfinished_uuid_ledger_enforces_exact_capacity_and_recovers_after_finalize() { + let directory = tempdir().expect("directory"); + let context = runtime_context(&directory); + let database_path = context.paths().state().join("state.sqlite"); + let build = build_identity(); + let database = Database::open(&context, 1, 1, &build) + .await + .expect("database"); + database.close().await.expect("close before fixture load"); + + let options = SqliteConnectOptions::new() + .filename(&database_path) + .create_if_missing(false); + let mut connection = SqliteConnection::connect_with(&options) + .await + .expect("fixture connection"); + let mut transaction = connection.begin().await.expect("fixture transaction"); + let identity = PublicKey::from_bytes([9; 32]).expect("public key"); + let mut requests = Vec::with_capacity(HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY); + for _ in 0..HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY { + let request = DurableRequestId::new_v7(); + sqlx::query( + "INSERT INTO durable_operations (request_id, operation_kind, account_public_key, \ + binding_public_key, phase, updated_at_unix_s) \ + VALUES (?, 'import', ?, ?, 'intent_recorded', 1)", + ) + .bind(request.as_str()) + .bind(identity.as_bytes().as_slice()) + .bind(identity.as_bytes().as_slice()) + .execute(&mut *transaction) + .await + .expect("fixture operation"); + requests.push(request); + } + transaction.commit().await.expect("fixture commit"); + connection.close().await.expect("fixture close"); + + let database = Database::open(&context, 2, 2, &build) + .await + .expect("reopen"); + assert_eq!( + database + .list_unfinished_durable_operations() + .await + .expect("unfinished operations") + .len(), + HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY + ); + assert!(matches!( + database + .begin_durable_operation( + &requests[0], + DurableOperationKind::Import, + identity, + None, + OperationPriorState::new(None, None), + UnixTimestamp::from_seconds(2).expect("time"), + ) + .await + .expect("matching replay at capacity"), + DurableOperationStart::Existing(_) + )); + let excess = DurableRequestId::new_v7(); + let error = database + .begin_durable_operation( + &excess, + DurableOperationKind::Import, + identity, + None, + OperationPriorState::new(None, None), + UnixTimestamp::from_seconds(2).expect("time"), + ) + .await + .expect_err("capacity must reject"); + assert_eq!(error.code(), SafeErrorCode::InvalidApplicationState); + + database + .finalize_durable_operation( + &requests[0], + DurableOperationPhase::IntentRecorded, + DurableTerminalOutcome::Completed, + None, + UnixTimestamp::from_seconds(3).expect("time"), + ) + .await + .expect("finalize one operation"); + database + .begin_durable_operation( + &excess, + DurableOperationKind::Import, + identity, + None, + OperationPriorState::new(None, None), + UnixTimestamp::from_seconds(4).expect("time"), + ) + .await + .expect("capacity recovered"); + database.close().await.expect("close"); +} diff --git a/core/crates/harvestcircle_test_bridge/Cargo.toml b/core/crates/harvestcircle_test_bridge/Cargo.toml @@ -19,6 +19,8 @@ harvestcircle_application.workspace = true harvestcircle_domain.workspace = true harvestcircle_nostr.workspace = true harvestcircle_runtime.workspace = true +radroots_runtime_paths.workspace = true +radroots_service_sqlite.workspace = true 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" } diff --git a/core/crates/harvestcircle_test_bridge/src/lib.rs b/core/crates/harvestcircle_test_bridge/src/lib.rs @@ -24,6 +24,11 @@ use harvestcircle_runtime::{ use nostr::{EventBuilder, Keys, Metadata}; use nostr_relay_builder::MockRelay; use nostr_sdk::Client; +use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, +}; +use radroots_service_sqlite::MigrationBuildIdentity; use tokio::runtime::{Builder, Runtime}; const ACTOR_CAPACITY: usize = 16; @@ -176,7 +181,7 @@ pub struct HarvestCircleTestBridge { clock: Arc<FixedClock>, relay: Mutex<Option<MockRelay>>, relay_url: String, - database_path: PathBuf, + context: RuntimeContext, network_degraded: AtomicBool, } @@ -191,7 +196,7 @@ impl HarvestCircleTestBridge { safe_message: "The integration test runtime could not start.".to_owned(), })?; let data_root = prepare_data_root(Path::new(&data_directory))?; - let database_path = data_root.join("harvestcircle-integration.sqlite3"); + let context = runtime_context(&data_root)?; let relay = runtime .block_on(MockRelay::run()) .map_err(|_| TestBridgeError::Failure { @@ -201,7 +206,7 @@ impl HarvestCircleTestBridge { let secrets = Arc::new(InMemorySecretStore::default()); let clock = Arc::new(FixedClock); let actor = runtime.block_on(open_actor( - &database_path, + &context, &relay_url, Arc::clone(&secrets), Arc::clone(&clock), @@ -215,7 +220,7 @@ impl HarvestCircleTestBridge { clock, relay: Mutex::new(Some(relay)), relay_url, - database_path, + context, network_degraded: AtomicBool::new(false), })) } @@ -464,7 +469,7 @@ impl HarvestCircleTestBridge { let _ = self.stop_observer(); self.close_actor()?; let actor = self.runtime.block_on(open_actor( - &self.database_path, + &self.context, &self.relay_url, Arc::clone(&self.secrets), Arc::clone(&self.clock), @@ -540,7 +545,7 @@ impl HarvestCircleTestBridge { } async fn open_actor( - database_path: &Path, + context: &RuntimeContext, relay_url: &str, secrets: Arc<InMemorySecretStore>, clock: Arc<FixedClock>, @@ -555,10 +560,12 @@ async fn open_actor( ))), Arc::new(FixedInstallationIdentity), ); + let build = migration_build_identity()?; Ok(RuntimeActorHandle::open( - database_path, + context, RelayConfiguration::new(vec![relay])?, dependencies, + &build, NonZeroUsize::new(ACTOR_CAPACITY).expect("actor capacity"), runtime, ) @@ -570,6 +577,50 @@ fn prepare_data_root(path: &Path) -> Result<PathBuf, TestBridgeError> { Ok(path.canonicalize()?) } +fn runtime_context(root: &Path) -> Result<RuntimeContext, TestBridgeError> { + let resolver = RadrootsPathResolver::new( + RadrootsPlatform::current(), + RadrootsHostEnvironment::default(), + ); + let bootstrap = RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some(root.to_path_buf()), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::SafeDefault, + ) + .map_err(|_| invalid_runtime_evidence())?; + RuntimeContext::resolve( + &resolver, + bootstrap, + ServiceId::new("harvestcircle").map_err(|_| invalid_runtime_evidence())?, + InstanceId::new("desktop").map_err(|_| invalid_runtime_evidence())?, + ) + .map_err(|_| invalid_runtime_evidence()) +} + +fn migration_build_identity() -> Result<MigrationBuildIdentity, TestBridgeError> { + MigrationBuildIdentity::new( + "0.1.0-alpha", + "1111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222", + "1.97.1", + "test", + "test", + 1, + 1, + 1, + 1, + 1, + ) + .map_err(|_| invalid_runtime_evidence()) +} + +fn invalid_runtime_evidence() -> TestBridgeError { + TestBridgeError::Failure { + safe_message: "The integration test runtime evidence is invalid.".to_owned(), + } +} + fn to_identity(identity: &harvestcircle_domain::NostrIdentity) -> TestIdentity { TestIdentity { public_key_hex: identity.public_key().to_hex(), diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -875,12 +875,25 @@ fn provenance_check(root: &Path, inventory: &Inventory, findings: &mut Vec<Strin "pub struct harvestcircle_storage::HarvestCircleStorageContract", "pub const harvestcircle_storage::HARVESTCIRCLE_APPLICATION_ID: u32", "pub fn harvestcircle_storage::harvestcircle_schema_catalog()", + "pub struct harvestcircle_storage::Database", + "pub async fn harvestcircle_storage::Database::open", + "pub async fn harvestcircle_storage::Database::close", + "impl harvestcircle_application::ports::DurableOperationRepository for harvestcircle_storage::Database", + "harvestcircle_application::ports::BoxFuture", ] { if !storage_api.contains(required) { findings.push(format!("{STORAGE_API_BASELINE}: missing {required}")); } } - for forbidden in ["rusqlite::", "refinery::", "sqlx::"] { + for forbidden in [ + "rusqlite::", + "refinery::", + "sqlx::", + "OperationJournal", + "harvestcircle_initial_schema_sql", + "repair", + "preflight", + ] { if storage_api.contains(forbidden) { findings.push(format!( "{STORAGE_API_BASELINE}: dependency-owned API leaked: {forbidden}"