commit d3ef9e2e86839534b64a04a832567208a632f7e6 parent 7ddbcd3a94895aad6936ccacb6373c71153f5bb8 Author: triesap <tyson@radroots.org> Date: Tue, 22 Sep 2026 10:33:30 +0000 storage: expose verified restore through canonical owner - Delegate verified restore staging and installation through SDK - Retain canceled restore writers until explicit pool drain - Preserve historical formats and unsupported-backend refusal - Qualify restore errors, API surfaces and unchanged coverage Diffstat:
16 files changed, 739 insertions(+), 12 deletions(-)
diff --git a/contracts/api_baselines/radroots_sdk.txt b/contracts/api_baselines/radroots_sdk.txt @@ -341,8 +341,10 @@ pub async fn radroots_sdk::storage::Operations<'a>::begin_backup(&self, radroots pub async fn radroots_sdk::storage::Operations<'a>::begin_restore(&self, radroots_storage::backup::RestorePlan) -> core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::error::Error> pub async fn radroots_sdk::storage::Operations<'a>::capture_backup(&self, radroots_storage::backup::BackupPlan) -> core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::capability::BackupCapabilityError> pub async fn radroots_sdk::storage::Operations<'a>::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> core::result::Result<(), radroots_storage::backup::capability::BackupCapabilityError> +pub async fn radroots_sdk::storage::Operations<'a>::finalize_restore(&self, radroots_storage::backup::RestorePlan) -> core::result::Result<(), radroots_storage::backup::restore_capability::RestoreCapabilityError> pub async fn radroots_sdk::storage::Operations<'a>::integrity(&self) -> core::result::Result<radroots_sdk::storage::IntegrityStatus, radroots_storage::error::Error> pub async fn radroots_sdk::storage::Operations<'a>::settle_backup_writes(&self) -> core::result::Result<(), radroots_storage::backup::capability::BackupCapabilityError> +pub async fn radroots_sdk::storage::Operations<'a>::stage_restore(&self, radroots_storage::backup::RestorePlan) -> core::result::Result<alloc::vec::Vec<radroots_storage::backup::RestoreMemberStatus>, radroots_storage::backup::restore_capability::RestoreCapabilityError> pub async fn radroots_sdk::storage::Operations<'a>::status(&self) -> core::result::Result<radroots_sdk::storage::Status, radroots_storage::error::Error> pub async fn radroots_sdk::storage::Operations<'a>::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::error::Error> pub async fn radroots_sdk::storage::Operations<'a>::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::error::Error> diff --git a/contracts/api_baselines/radroots_storage.txt b/contracts/api_baselines/radroots_storage.txt @@ -618,6 +618,17 @@ pub radroots_storage::backup::MemberVerification::Missing pub radroots_storage::backup::MemberVerification::Unexpected pub radroots_storage::backup::MemberVerification::UnsafePath pub radroots_storage::backup::MemberVerification::Verified +#[non_exhaustive] pub enum radroots_storage::backup::RestoreCapabilityError +pub radroots_storage::backup::RestoreCapabilityError::Conflict +pub radroots_storage::backup::RestoreCapabilityError::Failed +pub radroots_storage::backup::RestoreCapabilityError::InvalidConfiguration +pub radroots_storage::backup::RestoreCapabilityError::Unavailable +pub radroots_storage::backup::RestoreCapabilityError::Unsupported +pub radroots_storage::backup::RestoreCapabilityError::UnsupportedVersion +pub radroots_storage::backup::RestoreCapabilityError::VerificationFailed +impl core::error::Error for radroots_storage::backup::RestoreCapabilityError +impl core::fmt::Display for radroots_storage::backup::RestoreCapabilityError +pub fn radroots_storage::backup::RestoreCapabilityError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub enum radroots_storage::backup::RestoreStage pub radroots_storage::backup::RestoreStage::Failed pub radroots_storage::backup::RestoreStage::Finalized @@ -723,8 +734,10 @@ pub fn radroots_storage::backup::StorageReliability::begin_restore(&self, radroo pub fn radroots_storage::backup::StorageReliability::capture_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::backup::StorageReliability::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::backup::StorageReliability::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> +pub fn radroots_storage::backup::StorageReliability::finalize_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::RestoreCapabilityError>> pub fn radroots_storage::backup::StorageReliability::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> pub fn radroots_storage::backup::StorageReliability::settle_backup_writes(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> +pub fn radroots_storage::backup::StorageReliability::stage_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<alloc::vec::Vec<radroots_storage::backup::RestoreMemberStatus>, radroots_storage::backup::RestoreCapabilityError>> pub fn radroots_storage::backup::StorageReliability::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::backup::StorageReliability::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::backup::StorageReliability::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> @@ -735,8 +748,10 @@ pub fn radroots_storage::memory::MemoryStorage::begin_restore(&self, radroots_st pub fn radroots_storage::memory::MemoryStorage::capture_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> +pub fn radroots_storage::memory::MemoryStorage::finalize_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::RestoreCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::settle_backup_writes(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> +pub fn radroots_storage::memory::MemoryStorage::stage_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<alloc::vec::Vec<radroots_storage::backup::RestoreMemberStatus>, radroots_storage::backup::RestoreCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> @@ -999,8 +1014,10 @@ pub fn radroots_storage::memory::MemoryStorage::begin_restore(&self, radroots_st pub fn radroots_storage::memory::MemoryStorage::capture_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> +pub fn radroots_storage::memory::MemoryStorage::finalize_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::RestoreCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::settle_backup_writes(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> +pub fn radroots_storage::memory::MemoryStorage::stage_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<alloc::vec::Vec<radroots_storage::backup::RestoreMemberStatus>, radroots_storage::backup::RestoreCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> @@ -1844,8 +1861,10 @@ pub fn radroots_storage::BackupSource::begin_restore(&self, radroots_storage::ba pub fn radroots_storage::BackupSource::capture_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::BackupSource::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::BackupSource::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> +pub fn radroots_storage::BackupSource::finalize_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::RestoreCapabilityError>> pub fn radroots_storage::BackupSource::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> pub fn radroots_storage::BackupSource::settle_backup_writes(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> +pub fn radroots_storage::BackupSource::stage_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<alloc::vec::Vec<radroots_storage::backup::RestoreMemberStatus>, radroots_storage::backup::RestoreCapabilityError>> pub fn radroots_storage::BackupSource::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::BackupSource::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::BackupSource::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> @@ -1856,8 +1875,10 @@ pub fn radroots_storage::memory::MemoryStorage::begin_restore(&self, radroots_st pub fn radroots_storage::memory::MemoryStorage::capture_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> +pub fn radroots_storage::memory::MemoryStorage::finalize_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::RestoreCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::settle_backup_writes(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> +pub fn radroots_storage::memory::MemoryStorage::stage_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<alloc::vec::Vec<radroots_storage::backup::RestoreMemberStatus>, radroots_storage::backup::RestoreCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> diff --git a/contracts/api_baselines/radroots_storage_sqlite.txt b/contracts/api_baselines/radroots_storage_sqlite.txt @@ -569,8 +569,10 @@ pub fn radroots_storage_sqlite::SqliteStorage::begin_restore(&self, radroots_sto pub fn radroots_storage_sqlite::SqliteStorage::capture_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::capability::BackupCapabilityError>> pub fn radroots_storage_sqlite::SqliteStorage::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::error::Error>> pub fn radroots_storage_sqlite::SqliteStorage::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::capability::BackupCapabilityError>> +pub fn radroots_storage_sqlite::SqliteStorage::finalize_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::restore_capability::RestoreCapabilityError>> pub fn radroots_storage_sqlite::SqliteStorage::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::error::Error>> pub fn radroots_storage_sqlite::SqliteStorage::settle_backup_writes(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::capability::BackupCapabilityError>> +pub fn radroots_storage_sqlite::SqliteStorage::stage_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<alloc::vec::Vec<radroots_storage::backup::RestoreMemberStatus>, radroots_storage::backup::restore_capability::RestoreCapabilityError>> pub fn radroots_storage_sqlite::SqliteStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::error::Error>> pub fn radroots_storage_sqlite::SqliteStorage::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::error::Error>> pub fn radroots_storage_sqlite::SqliteStorage::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::error::Error>> diff --git a/contracts/architecture/decisions/storage_restore_capability.v1.json b/contracts/architecture/decisions/storage_restore_capability.v1.json @@ -0,0 +1,20 @@ +{ + "schema": "radroots.storage-restore-capability.v1", + "status": "approved", + "scope": "Existing canonical owner restore staging and finalization through StorageReliability and SDK Operations", + "source_boundary": "Only the SQLite owner verifies, stages, closes and replaces database members. The SPI returns member verification or completion, never paths, SQL or backend handles.", + "unsupported_backends": "Actual restore operations fail closed. Reliability metadata transitions do not prove that storage was staged or restored.", + "compatibility": "Existing restore plans, manifests, member names, schema checks, filesystem algorithms and concrete-owner operations remain unchanged. Default unsupported methods preserve other backend implementations. Backup errors are unchanged.", + "error_boundary": "Distinct bounded RestoreCapabilityError values omit filesystem paths, member details and nested database or I/O sources.", + "staging": "The existing owner verifies finalized backup members, creates adjacent staging without changing live state and rejects existing staging. Partial or conflicting evidence is retained.", + "finalization": "The owner reverifies staging before closing both pools, then uses its existing durable marker and replacement protocol. Installation attempts require a fresh open. Caller cancellation can retain closed-owner or recovery state and never proves success.", + "host_obligations": "The host excludes concurrent application commands, validates identity and related media, retains a durable guard against automatic historical delivery, and explicitly closes/reopens and reconciles restored operations. Storage restore is not authority to republish.", + "excluded": [ + "application restore policy", + "automatic delivery", + "secret export consent", + "new filesystem owner", + "coverage exclusions" + ], + "cancellation": "A private attempt guard changes an abandoned restoring state to closing without releasing writer authority. Explicit close still drains both pools before releasing the writer. No automatic reopen or delivery follows." +} diff --git a/contracts/architecture/deviations.toml b/contracts/architecture/deviations.toml @@ -2,6 +2,27 @@ schema_version = 1 architecture_id = "radroots.crates.release.v1" [[deviation]] +id = "RCRV1-DEV-021" +date = "2026-09-22" +status = "closed" +approval = "Explicit user authorization covers required shared-owner repairs, verified checkpoints and non-force integration publication." +affected_steps = ["158", "163", "178"] +spec_anchors = ["contracts/crates/release_v1/radroots_crates_release_v1.toml#package.radroots_storage", "contracts/crates/release_v1/radroots_crates_release_v1.toml#package.radroots_storage_sqlite", "contracts/crates/release_v1/radroots_crates_release_v1.toml#package.radroots_sdk"] +source_evidence = ["Actual cancellation regression reproduces a stranded RESTORING state after dropping finalization; later close drains pools but cannot release writer authority.", "The SQLite owner already implements verified staging and close-before-replacement restore with durable marker recovery.", "StorageReliability and SDK Operations expose restore metadata transitions but cannot invoke these actual owner operations."] +replacement_action = "Expose existing owner staging and finalization through the bounded capability governed by storage_restore_capability.v1.json; repair abandoned restore-close ownership through a private attempt guard, and retain application restore and historical-delivery policy with the host." +verification = ["Cancellation retains the active writer until explicit close drains both pools; close then releases authority and unchanged live state reopens.", "Qualify real SPI and SDK restore round trips, unchanged live state during staging, exact restored data and close/reopen behavior.", "Refuse unsupported, closed, read-only, conflicting, malformed and unconfigured operations with bounded path-free errors; preserve existing evidence.", "Review generated additive API and pass affected/minimal checks, unchanged coverage gates, full workspace, portable and release preflight."] +unresolved_risk = "Owner and SDK restore, cancellation recovery, additive API, unchanged coverage, full workspace, portability and preflight passed. The host still owns identity, media and durable historical-delivery fencing. No automatic delivery or release qualification is implied." +normative_architecture_change = false +adr_required = false +closure_evidence = [ + "Actual SPI and SDK staging preserve live changes; finalization closes the owner and explicit reopen restores exact historical draft IDs and bytes. Unsupported metadata-only backends, closed and read-only owners, malformed members, missing configuration and future formats refuse without leaking paths.", + "The initial cancellation regression reproduced a stranded restoring state. A private attempt guard now retains writer authority and closed admission after cancellation while allowing explicit close to drain both pools. Held runtime and protected connections prevent early writer release; the final close and reopen preserve live state.", + "Public API additions contain only the bounded restore error and staging/finalization methods. Concrete owner signatures, backup errors, manifests, schema, dependencies and filesystem replacement algorithms remain unchanged.", + "All 45 required coverage gates pass unchanged 90 percent thresholds. Three affected packages are freshly measured; 42 unchanged package-source reports retain provenance. No coverage exclusion was added.", + "Affected/minimal profiles, full workspace check/tests/Clippy, Rustdoc, catalog, contracts, architecture, API boundaries, graph, portability, preflight and explicit native/WASM generators passed. Final metadata checks revalidate this closure before publication.", +] + +[[deviation]] id = "RCRV1-DEV-020" date = "2026-09-22" status = "closed" diff --git a/crates/sdk/README.md b/crates/sdk/README.md @@ -105,6 +105,16 @@ Before inventory and capture, hosts exclude new writes and await caller was cancelled. Keep that host exclusion until capture completes; the settling call does not grant a continuing reservation or stop new commands. +`storage_operations().stage_restore(plan)` verifies and stages a retained +snapshot without replacing live state. `finalize_restore(plan)` delegates +verification, close and installation to the same owner. These operations +return bounded `RestoreCapabilityError` values, never paths or handles. +Unsupported backends fail closed. After an installation attempt, explicitly +close and reopen before inspecting restored state. Canceling finalization does +not release the writer early: explicit close must still drain both pools. +The host must preserve a durable delivery guard and reconcile historical IDs +and remote outcomes; a restored snapshot never authorizes automatic resend. + Native mobile hosts can retain one client while changing host-owned identity and relay selection. The host injects one opaque implementation of the canonical `radroots_signing::Signer` SPI; the SDK has no mutable secret slot and diff --git a/crates/sdk/src/storage.rs b/crates/sdk/src/storage.rs @@ -20,7 +20,8 @@ pub type SqlitePaths = radroots_storage_sqlite::Paths; use radroots_storage::backup::{ BackupCapabilityError, BackupId, BackupManifest, BackupOperation, BackupPlan, BackupTransition, - ReliabilityRevision, RestoreOperation, RestorePlan, RestoreTransition, StorageReliability, + ReliabilityRevision, RestoreCapabilityError, RestoreMemberStatus, RestoreOperation, + RestorePlan, RestoreTransition, StorageReliability, }; /// Borrowed reliability operations over the canonical backend-neutral SPI. @@ -69,6 +70,22 @@ impl<'a> Operations<'a> { StorageReliability::finalize_backup(self.storage, plan, manifest).await } + /// Stages actual verified members through the canonical owner without + /// changing live state. The host owns identity, media and command exclusion. + pub async fn stage_restore( + &self, + plan: RestorePlan, + ) -> Result<Vec<RestoreMemberStatus>, RestoreCapabilityError> { + StorageReliability::stage_restore(self.storage, plan).await + } + + /// Verifies staging, closes the canonical owner and installs its retained + /// snapshot. Reopen explicitly after an installation attempt, then reconcile + /// historical operations before allowing delivery. No path is returned. + pub async fn finalize_restore(&self, plan: RestorePlan) -> Result<(), RestoreCapabilityError> { + StorageReliability::finalize_restore(self.storage, plan).await + } + /// Begins or resumes one idempotent backup plan. pub async fn begin_backup( &self, @@ -144,6 +161,9 @@ impl std::fmt::Debug for Operations<'_> { #[cfg(all(test, any(feature = "memory", feature = "sqlite")))] mod backup_tests; +#[cfg(all(test, any(feature = "memory", feature = "sqlite")))] +mod restore_tests; + #[cfg(all(test, feature = "memory"))] mod tests { use std::sync::Arc; diff --git a/crates/sdk/src/storage/restore_tests.rs b/crates/sdk/src/storage/restore_tests.rs @@ -0,0 +1,172 @@ +use super::*; +use radroots_storage::backup::{BackupFormatVersion, BackupSecretPolicy}; +#[cfg(feature = "memory")] +use radroots_storage::backup::{BackupMember, BackupMemberKind, MemberDigest}; + +#[cfg(feature = "memory")] +#[tokio::test] +async fn restore_metadata_is_not_a_successful_restore_capability() { + let client = crate::ClientBuilder::memory_default().build().unwrap(); + let operations = client.storage_operations().unwrap(); + let manifest = BackupManifest::new( + BackupFormatVersion::V1, + BackupId::new([6; 16]).unwrap(), + 100, + BackupSecretPolicy::ExcludeProtectedStorage, + vec![ + BackupMember::new( + "runtime/events.bin", + BackupMemberKind::Runtime, + 1, + MemberDigest::new([8; 32]), + ) + .unwrap(), + ], + ) + .unwrap(); + let restore = + RestorePlan::new(manifest, BackupSecretPolicy::ExcludeProtectedStorage, 200).unwrap(); + operations.begin_restore(restore.clone()).await.unwrap(); + assert_eq!( + operations.stage_restore(restore.clone()).await, + Err(RestoreCapabilityError::Unsupported) + ); + assert_eq!( + operations.finalize_restore(restore).await, + Err(RestoreCapabilityError::Unsupported) + ); + client.close().await.unwrap(); +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sdk_restore_retains_historical_ids_and_requires_an_explicit_reopen() { + use radroots_storage::{ + authored_draft::{AuthoredDraft, AuthoredDraftId, AuthoredDraftStage}, + event::SourceGeneration, + status::ShutdownState, + }; + let root = tempfile::tempdir().unwrap(); + let backup = tempfile::tempdir().unwrap(); + let paths = SqlitePaths::from_directory(root.path()).unwrap(); + let client = crate::ClientBuilder::sqlite( + SqliteOptions::new(paths.clone(), SqliteOpenMode::Create) + .with_source_generation(SourceGeneration::new([9; 32]).unwrap(), 100) + .unwrap() + .with_backup_root(backup.path()) + .unwrap(), + ) + .await + .unwrap() + .build() + .unwrap(); + let draft = AuthoredDraft::initial( + AuthoredDraftId::new([7; 16]).unwrap(), + [3; 32], + "fixture.restore.v1", + b"historical identity".to_vec(), + AuthoredDraftStage::Draft, + None, + 100, + ) + .unwrap(); + let later = AuthoredDraft::initial( + AuthoredDraftId::new([8; 16]).unwrap(), + [3; 32], + "fixture.restore.v1", + b"later live state".to_vec(), + AuthoredDraftStage::Draft, + None, + 200, + ) + .unwrap(); + client + .storage() + .unwrap() + .append_authored_draft(draft.clone(), None) + .await + .unwrap(); + let operations = client.storage_operations().unwrap(); + let plan = BackupPlan::new( + BackupId::new([6; 16]).unwrap(), + BackupFormatVersion::V1, + BackupSecretPolicy::IncludeProtectedStorage, + 100, + ) + .unwrap(); + let manifest = operations.capture_backup(plan.clone()).await.unwrap(); + operations + .finalize_backup(plan, manifest.clone()) + .await + .unwrap(); + client + .storage() + .unwrap() + .append_authored_draft(later.clone(), None) + .await + .unwrap(); + let restore = + RestorePlan::new(manifest, BackupSecretPolicy::IncludeProtectedStorage, 200).unwrap(); + drop(operations.stage_restore(restore.clone())); + drop(operations.finalize_restore(restore.clone())); + assert_eq!( + operations.status().await.unwrap().shutdown(), + ShutdownState::Open + ); + assert_eq!( + operations + .stage_restore(restore.clone()) + .await + .unwrap() + .len(), + 2 + ); + assert_eq!( + client + .storage() + .unwrap() + .authored_draft_head(later.draft_id()) + .await + .unwrap(), + Some(later.clone()) + ); + operations.finalize_restore(restore.clone()).await.unwrap(); + assert_eq!( + operations.status().await.unwrap().shutdown(), + ShutdownState::Closed + ); + assert_eq!( + operations.stage_restore(restore.clone()).await, + Err(RestoreCapabilityError::Unavailable) + ); + assert_eq!( + operations.finalize_restore(restore).await, + Err(RestoreCapabilityError::Unavailable) + ); + client.close().await.unwrap(); + let reopened = + crate::ClientBuilder::sqlite(SqliteOptions::new(paths, SqliteOpenMode::ReadWriteExisting)) + .await + .unwrap() + .build() + .unwrap(); + assert_eq!( + reopened + .storage() + .unwrap() + .authored_draft_head(draft.draft_id()) + .await + .unwrap(), + Some(draft) + ); + assert_eq!( + reopened + .storage() + .unwrap() + .authored_draft_head(later.draft_id()) + .await + .unwrap(), + None + ); + reopened.close().await.unwrap(); +} diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs @@ -173,6 +173,7 @@ fn package_contains_only_reachable_sources_and_registered_targets() { "signing.rs".to_owned(), "storage.rs".to_owned(), "storage/backup_tests.rs".to_owned(), + "storage/restore_tests.rs".to_owned(), "sync.rs".to_owned(), "sync/tests/selected.rs".to_owned(), "trade.rs".to_owned(), diff --git a/crates/storage/README.md b/crates/storage/README.md @@ -191,6 +191,18 @@ all expected members are present. Restore uses isolated staging and cannot replace live state before complete verification. Relative member paths reject absolute paths, traversal, duplicates, and unsafe separators. +`BackupSource::stage_restore` and `finalize_restore` invoke actual owner +operations. Unsupported backends return `RestoreCapabilityError::Unsupported`; +reliability metadata cannot substitute for restored storage. Staging retains +live state and refuses existing staging. Finalization verifies, closes the +owner and installs through its recovery protocol. Hosts must reopen explicitly +and reconcile historical operations before delivery. Identity, related media +and durable application delivery guards remain host responsibilities. + +Canceling SQLite finalization retains writer authority. A subsequent explicit +close drains both pools before releasing that authority, permitting guarded +reopen. Cancellation is not evidence of either successful restore or rollback. + Status and integrity inspection are passive. `close` is explicit and idempotent; once closed, an implementation rejects ordinary operations. Backend-specific durability fields are discriminated by `StorageBackend`: diff --git a/crates/storage/src/backup.rs b/crates/storage/src/backup.rs @@ -10,6 +10,8 @@ use crate::{ mod capability; pub use capability::BackupCapabilityError; +mod restore_capability; +pub use restore_capability::RestoreCapabilityError; pub const BACKUP_MEMBER_PATH_MAX_BYTES: usize = 512; pub const BACKUP_MEMBER_MAX: usize = 1_024; @@ -725,6 +727,26 @@ pub trait StorageReliability: Send + Sync { Box::pin(async { Err(BackupCapabilityError::Unsupported) }) } + /// Stages verified retained members without replacing live state. The host + /// owns application identity, related media and command exclusion. Existing + /// staging is recovery evidence, not permission to overwrite it. + fn stage_restore( + &self, + _plan: RestorePlan, + ) -> BoxFuture<'_, Result<Vec<RestoreMemberStatus>, RestoreCapabilityError>> { + Box::pin(async { Err(RestoreCapabilityError::Unsupported) }) + } + + /// Verifies staging, closes the owner and installs through its recovery + /// protocol. After installation begins, callers must reopen the owner even + /// if they lose the result. Restore never authorizes historical delivery. + fn finalize_restore( + &self, + _plan: RestorePlan, + ) -> BoxFuture<'_, Result<(), RestoreCapabilityError>> { + Box::pin(async { Err(RestoreCapabilityError::Unsupported) }) + } + fn begin_backup(&self, plan: BackupPlan) -> BoxFuture<'_, Result<BackupOperation, Error>>; fn transition_backup( &self, diff --git a/crates/storage/src/backup/restore_capability.rs b/crates/storage/src/backup/restore_capability.rs @@ -0,0 +1,62 @@ +use core::fmt; + +/// Path-free failures from actual owner restore operations. These are not +/// optimistic reliability-record transition errors or permission to deliver +/// historical operations after reopening restored storage. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum RestoreCapabilityError { + /// The backend provides no actual restore capability. + Unsupported, + /// The owner is closed, unavailable or not writable. + Unavailable, + /// An explicit valid host-owned backup location is required. + InvalidConfiguration, + /// The retained backup format is unsupported. + UnsupportedVersion, + /// Existing staging or recovery state requires reconciliation. + Conflict, + /// The supplied inventory or retained members could not be verified. + VerificationFailed, + /// Staging or installation did not complete; retain recovery evidence. + Failed, +} + +impl fmt::Display for RestoreCapabilityError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Unsupported => "storage restore capability is unsupported", + Self::Unavailable => "storage restore owner is unavailable", + Self::InvalidConfiguration => "storage restore configuration is invalid", + Self::UnsupportedVersion => "storage restore format is unsupported", + Self::Conflict => "storage restore state requires reconciliation", + Self::VerificationFailed => "storage restore verification failed", + Self::Failed => "storage restore did not complete", + }) + } +} + +impl std::error::Error for RestoreCapabilityError {} + +#[cfg(test)] +mod tests { + use super::RestoreCapabilityError as E; + + #[test] + fn restore_errors_expose_only_bounded_public_reports() { + for error in [ + E::Unsupported, + E::Unavailable, + E::InvalidConfiguration, + E::UnsupportedVersion, + E::Conflict, + E::VerificationFailed, + E::Failed, + ] { + let report = error.to_string(); + assert!(report.len() < 80); + assert!(!report.contains('/')); + assert!(std::error::Error::source(&error).is_none()); + } + } +} diff --git a/crates/storage_sqlite/src/backup.rs b/crates/storage_sqlite/src/backup.rs @@ -10,8 +10,8 @@ use std::{ use radroots_storage::backup::{ BackupCapabilityError, BackupFormatVersion, BackupId, BackupManifest, BackupMember, BackupMemberKind, BackupOperation, BackupPlan, BackupSecretPolicy, BackupTransition, - MemberDigest, MemberVerification, ReliabilityRevision, RestoreMemberStatus, RestoreOperation, - RestorePlan, RestoreTransition, StorageReliability, + MemberDigest, MemberVerification, ReliabilityRevision, RestoreCapabilityError, + RestoreMemberStatus, RestoreOperation, RestorePlan, RestoreTransition, StorageReliability, }; use radroots_storage::status::EventStoreMode; use radroots_storage::{Error as StorageError, outbox::BoxFuture}; @@ -31,6 +31,8 @@ const RESTORE_MARKER_BYTES: usize = 105; mod capability; #[cfg(test)] mod capability_tests; +#[cfg(test)] +mod restore_capability_tests; mod settling; #[derive(Default)] @@ -91,6 +93,28 @@ impl StorageReliability for SqliteStorage { }) } + fn stage_restore( + &self, + plan: RestorePlan, + ) -> BoxFuture<'_, Result<Vec<RestoreMemberStatus>, RestoreCapabilityError>> { + Box::pin(async move { + SqliteStorage::stage_restore(self, &plan) + .await + .map_err(capability::map_restore_error) + }) + } + + fn finalize_restore( + &self, + plan: RestorePlan, + ) -> BoxFuture<'_, Result<(), RestoreCapabilityError>> { + Box::pin(async move { + SqliteStorage::finalize_restore(self, &plan) + .await + .map_err(capability::map_restore_error) + }) + } + fn begin_backup( &self, plan: BackupPlan, @@ -421,7 +445,8 @@ impl SqliteStorage { verify_staged_restore(&layout, &marker).await?; layout.require_previous_absent(marker.secret_policy())?; - self.lifecycle + let restoration = self + .lifecycle .begin_restore_close() .map_err(|_| Error::BackupBackendUnavailable)?; self.pool.close().await; @@ -432,7 +457,7 @@ impl SqliteStorage { recover_interrupted_restore(paths, OpenMode::ReadWriteExisting).await } .await; - let close = self.lifecycle.finish_restore_close(); + let close = restoration.finish(); installation?; close.map_err(|_| Error::BackupBackendUnavailable) } diff --git a/crates/storage_sqlite/src/backup/capability.rs b/crates/storage_sqlite/src/backup/capability.rs @@ -1,4 +1,4 @@ -use radroots_storage::backup::BackupCapabilityError; +use radroots_storage::backup::{BackupCapabilityError, RestoreCapabilityError}; pub(super) fn map_error(error: crate::Error) -> BackupCapabilityError { use crate::Error as E; @@ -15,3 +15,25 @@ pub(super) fn map_error(error: crate::Error) -> BackupCapabilityError { _ => BackupCapabilityError::Failed, } } + +pub(super) fn map_restore_error(error: crate::Error) -> RestoreCapabilityError { + use crate::Error as E; + match error { + E::BackupBackendUnavailable | E::RestoreRequiresWritableStorage => { + RestoreCapabilityError::Unavailable + } + E::BackupRootRequired | E::InvalidBackupRoot(_) => { + RestoreCapabilityError::InvalidConfiguration + } + E::UnsupportedBackupVersion => RestoreCapabilityError::UnsupportedVersion, + E::BackupBundleAlreadyExists(_) + | E::RestoreStagingAlreadyExists(_) + | E::RestoreRecoveryConflict(_) => RestoreCapabilityError::Conflict, + E::BackupBundleMissing(_) + | E::BackupVerificationFailed { .. } + | E::BackupUnexpectedEntry(_) + | E::RestoreStagingFailed { .. } + | E::RestoreMarkerCorrupt(_) => RestoreCapabilityError::VerificationFailed, + _ => RestoreCapabilityError::Failed, + } +} diff --git a/crates/storage_sqlite/src/backup/restore_capability_tests.rs b/crates/storage_sqlite/src/backup/restore_capability_tests.rs @@ -0,0 +1,293 @@ +use super::*; +use crate::OpenOptions; +use radroots_storage::{ + authored_draft::{AuthoredDraft, AuthoredDraftId, AuthoredDraftStage, AuthoredDraftStore}, + event::SourceGeneration, + status::ShutdownState, +}; + +fn draft(id: u8) -> AuthoredDraft { + AuthoredDraft::initial( + AuthoredDraftId::new([id; 16]).unwrap(), + [3; 32], + "fixture.restore.v1", + vec![id], + AuthoredDraftStage::Draft, + None, + 100, + ) + .unwrap() +} + +async fn fixture(root: &Path, backup: &Path) -> (SqliteStorage, RestorePlan) { + let store = SqliteStorage::open( + OpenOptions::new( + crate::Paths::from_directory(root).unwrap(), + OpenMode::Create, + ) + .with_source_generation(SourceGeneration::new([7; 32]).unwrap(), 100) + .unwrap() + .with_backup_root(backup) + .unwrap(), + ) + .await + .unwrap(); + store.append_authored_draft(draft(1), None).await.unwrap(); + let plan = BackupPlan::new( + BackupId::new([6; 16]).unwrap(), + BackupFormatVersion::V1, + BackupSecretPolicy::IncludeProtectedStorage, + 100, + ) + .unwrap(); + let owner: &dyn StorageReliability = &store; + let manifest = owner.capture_backup(plan.clone()).await.unwrap(); + owner.finalize_backup(plan, manifest.clone()).await.unwrap(); + store.append_authored_draft(draft(2), None).await.unwrap(); + let restore = + RestorePlan::new(manifest, BackupSecretPolicy::IncludeProtectedStorage, 200).unwrap(); + (store, restore) +} + +#[tokio::test] +async fn restore_spi_stages_without_live_changes_then_closes_and_reopens_exact_history() { + let root = tempfile::tempdir().unwrap(); + let backup = tempfile::tempdir().unwrap(); + let (store, restore) = fixture(root.path(), backup.path()).await; + let owner: &dyn StorageReliability = &store; + let paths = crate::Paths::from_directory(root.path()).unwrap(); + let staging = RestoreStaging::new(&paths, restore.manifest()).unwrap(); + drop(owner.stage_restore(restore.clone())); + drop(owner.finalize_restore(restore.clone())); + assert!(!staging.runtime.exists() && !staging.private.exists()); + assert_eq!( + store.storage_status().await.unwrap().shutdown(), + ShutdownState::Open + ); + let statuses = owner.stage_restore(restore.clone()).await.unwrap(); + assert_eq!(statuses.len(), 2); + assert!( + statuses + .iter() + .all(|v| v.verification() == MemberVerification::Verified) + ); + assert_eq!( + store + .authored_draft_head(draft(2).draft_id()) + .await + .unwrap(), + Some(draft(2)) + ); + assert_eq!( + owner.stage_restore(restore.clone()).await, + Err(RestoreCapabilityError::Conflict) + ); + owner.finalize_restore(restore.clone()).await.unwrap(); + assert_eq!( + store.storage_status().await.unwrap().shutdown(), + ShutdownState::Closed + ); + assert_eq!( + owner.stage_restore(restore.clone()).await, + Err(RestoreCapabilityError::Unavailable) + ); + assert_eq!( + owner.finalize_restore(restore).await, + Err(RestoreCapabilityError::Unavailable) + ); + let reopened = SqliteStorage::open(OpenOptions::new(paths, OpenMode::ReadWriteExisting)) + .await + .unwrap(); + assert_eq!( + reopened + .authored_draft_head(draft(1).draft_id()) + .await + .unwrap(), + Some(draft(1)) + ); + assert_eq!( + reopened + .authored_draft_head(draft(2).draft_id()) + .await + .unwrap(), + None + ); + reopened.close().await.unwrap(); +} + +#[tokio::test] +async fn restore_spi_rejects_configuration_version_and_tampering_without_live_changes() { + let root = tempfile::tempdir().unwrap(); + let backup = tempfile::tempdir().unwrap(); + let (mut store, restore) = fixture(root.path(), backup.path()).await; + let backup_root = store.backup_root.take(); + assert_eq!( + StorageReliability::stage_restore(&store, restore.clone()).await, + Err(RestoreCapabilityError::InvalidConfiguration) + ); + store.backup_root = Some(std::sync::Arc::new(backup.path().join("absent"))); + assert_eq!( + StorageReliability::stage_restore(&store, restore.clone()).await, + Err(RestoreCapabilityError::InvalidConfiguration) + ); + store.backup_root = backup_root; + let original = restore.manifest(); + for future in [false, true] { + let mut members = original.members().to_vec(); + if !future { + let member = &members[0]; + members[0] = BackupMember::new( + member.relative_path(), + member.kind(), + member.byte_length() + 1, + member.sha256(), + ) + .unwrap(); + } + let manifest = BackupManifest::new( + if future { + BackupFormatVersion::new(2).unwrap() + } else { + BackupFormatVersion::V1 + }, + original.backup_id(), + original.created_at_unix_ms(), + original.secret_policy(), + members, + ) + .unwrap(); + let bad = RestorePlan::new(manifest, original.secret_policy(), 200).unwrap(); + assert_eq!( + StorageReliability::stage_restore(&store, bad.clone()).await, + Err(RestoreCapabilityError::VerificationFailed) + ); + let finalization = StorageReliability::finalize_restore(&store, bad).await; + if future { + assert_eq!( + finalization, + Err(RestoreCapabilityError::UnsupportedVersion) + ); + } else { + assert_eq!(finalization, Err(RestoreCapabilityError::Failed)); + } + assert_eq!( + store.storage_status().await.unwrap().shutdown(), + ShutdownState::Open + ); + } + assert_eq!( + store + .authored_draft_head(draft(2).draft_id()) + .await + .unwrap(), + Some(draft(2)) + ); + store.close().await.unwrap(); + let reader = SqliteStorage::open( + OpenOptions::new( + crate::Paths::from_directory(root.path()).unwrap(), + OpenMode::ReadOnly, + ) + .with_backup_root(backup.path()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + StorageReliability::stage_restore(&reader, restore.clone()).await, + Err(RestoreCapabilityError::Unavailable) + ); + assert_eq!( + StorageReliability::finalize_restore(&reader, restore).await, + Err(RestoreCapabilityError::Unavailable) + ); + reader.close().await.unwrap(); +} + +#[tokio::test] +async fn cancelled_restore_close_can_be_drained_without_releasing_a_live_writer() { + let root = tempfile::tempdir().unwrap(); + let backup = tempfile::tempdir().unwrap(); + let (store, restore) = fixture(root.path(), backup.path()).await; + StorageReliability::stage_restore(&store, restore.clone()) + .await + .unwrap(); + let held = store.pool.acquire().await.unwrap(); + let held_private = store.private_pool.acquire().await.unwrap(); + let mut finalization = Box::pin(StorageReliability::finalize_restore(&store, restore)); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + std::future::poll_fn(|context| { + assert!(finalization.as_mut().poll(context).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + if store.storage_status().await.unwrap().shutdown() == ShutdownState::Closing { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + drop(finalization); + let paths = crate::Paths::from_directory(root.path()).unwrap(); + assert!(matches!( + SqliteStorage::open(OpenOptions::new(paths.clone(), OpenMode::ReadWriteExisting)).await, + Err(Error::WriterAlreadyActive { .. }) + )); + drop(held); + let mut close = Box::pin(store.close()); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + std::future::poll_fn(|context| { + assert!(close.as_mut().poll(context).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + if store.private_pool.is_closed() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!(matches!( + SqliteStorage::open(OpenOptions::new(paths.clone(), OpenMode::ReadWriteExisting)).await, + Err(Error::WriterAlreadyActive { .. }) + )); + drop(held_private); + assert_eq!(close.await.unwrap().shutdown(), ShutdownState::Closed); + let reopened = SqliteStorage::open(OpenOptions::new(paths, OpenMode::ReadWriteExisting)) + .await + .unwrap(); + assert_eq!( + reopened + .authored_draft_head(draft(2).draft_id()) + .await + .unwrap(), + Some(draft(2)) + ); + reopened.close().await.unwrap(); +} + +#[test] +fn restore_spi_errors_do_not_expose_filesystem_evidence_or_nested_causes() { + for raw in [ + Error::RestoreFilesystem { + operation: "/private/canary", + source: std::io::Error::other("credential-canary"), + }, + Error::RestoreMarkerCorrupt(PathBuf::from("/private/canary")), + Error::RestoreRecoveryConflict(PathBuf::from("/private/canary")), + Error::RestoreStagingFailed { + member: "credential-canary", + }, + ] { + let bounded = capability::map_restore_error(raw); + let report = format!("{bounded:?} {bounded}"); + assert!(!report.contains("canary") && !report.contains('/')); + assert!(std::error::Error::source(&bounded).is_none()); + } +} diff --git a/crates/storage_sqlite/src/status.rs b/crates/storage_sqlite/src/status.rs @@ -117,10 +117,10 @@ impl StorageLifecycle { .compare_exchange(OPEN, CLOSING, Ordering::AcqRel, Ordering::Acquire); } - pub(crate) fn begin_restore_close(&self) -> Result<(), Error> { + pub(crate) fn begin_restore_close(&self) -> Result<RestoreCloseAttempt<'_>, Error> { self.shutdown .compare_exchange(OPEN, RESTORING, Ordering::AcqRel, Ordering::Acquire) - .map(|_| ()) + .map(|_| RestoreCloseAttempt(self)) .map_err(|_| Error::BackendUnavailable) } @@ -167,6 +167,28 @@ impl StorageLifecycle { } } +/// Keeps writer authority reserved while finalization owns the close. A lost +/// caller only hands draining back to ordinary close; it never releases a lock +/// while either pool can still have active work. +pub(crate) struct RestoreCloseAttempt<'a>(&'a StorageLifecycle); + +impl RestoreCloseAttempt<'_> { + pub(crate) fn finish(self) -> Result<(), Error> { + self.0.finish_restore_close() + } +} + +impl Drop for RestoreCloseAttempt<'_> { + fn drop(&mut self) { + let _ = self.0.shutdown.compare_exchange( + RESTORING, + CLOSING, + Ordering::AcqRel, + Ordering::Acquire, + ); + } +} + impl SqliteStorage { /// Returns backend-level status without opening a connection or initiating /// integrity checks, checkpoints, migrations, or other maintenance. @@ -278,13 +300,13 @@ mod tests { Err(Error::InvalidIntegrityStatus) ); - assert_eq!(reader.lifecycle.begin_restore_close(), Ok(())); - assert_eq!( + let restoring = reader.lifecycle.begin_restore_close().unwrap(); + assert!(matches!( reader.lifecycle.begin_restore_close(), Err(Error::BackendUnavailable) - ); + )); assert_eq!(reader.lifecycle.finish_close(), Ok(())); - assert_eq!(reader.lifecycle.finish_restore_close(), Ok(())); + assert_eq!(restoring.finish(), Ok(())); assert_eq!( reader.lifecycle.finish_restore_close(), Err(Error::BackendUnavailable)