lib

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

commit a646d918daedbf60e17d90d77c045d127759a243
parent b44119fbac5985be8127ad1bf56d2950e6399427
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 05:10:05 +0000

service-sqlite: bind existing opens to stored metadata

- add a sealed existing-database intent without caller-supplied generation
- retain writer or inspection authority with the verified actual metadata
- preserve exact recovery, migration, schema, and redaction boundaries
- verify unit, integration, package, API, Clippy, doctest, and Rustdoc gates

Diffstat:
Mcontracts/api_baselines/radroots_service_sqlite.txt | 18++++++++++++++++++
Mcrates/service_sqlite/README.md | 12++++++++++++
Mcrates/service_sqlite/src/connection.rs | 228+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/service_sqlite/src/lib.rs | 9+++++----
Mcrates/service_sqlite/src/metadata.rs | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_sqlite/src/open.rs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/service_sqlite/src/restore/marker.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/service_sqlite/src/restore/mod.rs | 2+-
Mcrates/service_sqlite/src/restore/recover.rs | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/service_sqlite/tests/package_boundary.rs | 10++++++++++
10 files changed, 686 insertions(+), 31 deletions(-)

diff --git a/contracts/api_baselines/radroots_service_sqlite.txt b/contracts/api_baselines/radroots_service_sqlite.txt @@ -176,6 +176,15 @@ pub const fn radroots_service_sqlite::BackupMemberSha256::as_bytes(&self) -> &[u pub const fn radroots_service_sqlite::BackupMemberSha256::from_bytes([u8; 32]) -> Self impl core::fmt::Debug for radroots_service_sqlite::BackupMemberSha256 pub fn radroots_service_sqlite::BackupMemberSha256::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_service_sqlite::ExistingServiceDatabaseIntent +impl radroots_service_sqlite::ExistingServiceDatabaseIntent +pub const fn radroots_service_sqlite::ExistingServiceDatabaseIntent::application_id(&self) -> radroots_service_sqlite::ServiceSqliteApplicationId +pub fn radroots_service_sqlite::ExistingServiceDatabaseIntent::instance(&self) -> &radroots_runtime_paths::identifier::InstanceId +pub fn radroots_service_sqlite::ExistingServiceDatabaseIntent::new(&radroots_service_sqlite::ServiceSqlitePaths, core::num::nonzero::NonZeroU32, radroots_service_sqlite::ServiceSqliteApplicationId) -> Self +pub fn radroots_service_sqlite::ExistingServiceDatabaseIntent::service(&self) -> &radroots_runtime_paths::identifier::ServiceId +pub const fn radroots_service_sqlite::ExistingServiceDatabaseIntent::supported_state_schema_version(&self) -> core::num::nonzero::NonZeroU32 +impl core::fmt::Debug for radroots_service_sqlite::ExistingServiceDatabaseIntent +pub fn radroots_service_sqlite::ExistingServiceDatabaseIntent::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct radroots_service_sqlite::IntegrityCheckedAtUnixMs(_) impl radroots_service_sqlite::IntegrityCheckedAtUnixMs pub const fn radroots_service_sqlite::IntegrityCheckedAtUnixMs::get(self) -> u64 @@ -249,6 +258,13 @@ pub const fn radroots_service_sqlite::MinimumFreeBytes::get(self) -> u64 pub const fn radroots_service_sqlite::MinimumFreeBytes::new(u64) -> core::result::Result<Self, radroots_service_sqlite::StateFilesystemCapacityError> impl<'de> serde_core::de::Deserialize<'de> for radroots_service_sqlite::MinimumFreeBytes pub fn radroots_service_sqlite::MinimumFreeBytes::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_service_sqlite::OpenedExistingServiceDatabase +impl radroots_service_sqlite::OpenedExistingServiceDatabase +pub const fn radroots_service_sqlite::OpenedExistingServiceDatabase::database_metadata(&self) -> &radroots_service_sqlite::ServiceDatabaseMetadata +pub const fn radroots_service_sqlite::OpenedExistingServiceDatabase::host(&self) -> &radroots_service_sqlite::ServiceSqliteHost +pub fn radroots_service_sqlite::OpenedExistingServiceDatabase::into_parts(self) -> (radroots_service_sqlite::ServiceSqliteHost, radroots_service_sqlite::ServiceDatabaseMetadata) +impl core::fmt::Debug for radroots_service_sqlite::OpenedExistingServiceDatabase +pub fn radroots_service_sqlite::OpenedExistingServiceDatabase::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct radroots_service_sqlite::PlatformStateFilesystemCapacitySource impl radroots_service_sqlite::StateFilesystemCapacitySource for radroots_service_sqlite::PlatformStateFilesystemCapacitySource pub fn radroots_service_sqlite::PlatformStateFilesystemCapacitySource::available_bytes(&self, &radroots_service_sqlite::ServiceSqlitePaths) -> core::result::Result<u64, radroots_service_sqlite::StateFilesystemCapacityError> @@ -368,7 +384,9 @@ pub async fn radroots_service_sqlite::ServiceSqliteHost::inspect_integrity(&self pub const fn radroots_service_sqlite::ServiceSqliteHost::mode(&self) -> radroots_service_sqlite::OpenMode pub async fn radroots_service_sqlite::ServiceSqliteHost::open_initialized(&radroots_service_sqlite::ServiceSqlitePaths, &radroots_service_sqlite::ServiceDatabaseIdentity, &radroots_service_sqlite::MigrationCatalog, &radroots_service_sqlite::SchemaCatalog, radroots_service_sqlite::ServiceSqliteConnectionOptions, radroots_service_sqlite::WriterAuthority, radroots_service_sqlite::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::MigrationBuildIdentity, &[radroots_service_sqlite::MigrationCallbackBinding]) -> core::result::Result<(Self, radroots_service_sqlite::MigrationApplicationOutcome), radroots_service_sqlite::ServiceSqliteError> pub async fn radroots_service_sqlite::ServiceSqliteHost::open_read_only_inspection(&radroots_service_sqlite::ServiceSqlitePaths, &radroots_service_sqlite::ServiceDatabaseIdentity, &radroots_service_sqlite::MigrationCatalog, &radroots_service_sqlite::SchemaCatalog, radroots_service_sqlite::ServiceSqliteConnectionOptions) -> core::result::Result<Self, radroots_service_sqlite::ServiceSqliteError> +pub async fn radroots_service_sqlite::ServiceSqliteHost::open_read_only_inspection_with_intent(&radroots_service_sqlite::ServiceSqlitePaths, &radroots_service_sqlite::ExistingServiceDatabaseIntent, &radroots_service_sqlite::MigrationCatalog, &radroots_service_sqlite::SchemaCatalog, radroots_service_sqlite::ServiceSqliteConnectionOptions) -> core::result::Result<radroots_service_sqlite::OpenedExistingServiceDatabase, radroots_service_sqlite::ServiceSqliteError> pub async fn radroots_service_sqlite::ServiceSqliteHost::open_read_write_existing(&radroots_service_sqlite::ServiceSqlitePaths, &radroots_service_sqlite::ServiceDatabaseIdentity, &radroots_service_sqlite::MigrationCatalog, &radroots_service_sqlite::SchemaCatalog, radroots_service_sqlite::ServiceSqliteConnectionOptions, radroots_service_sqlite::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::MigrationBuildIdentity, &[radroots_service_sqlite::MigrationCallbackBinding]) -> core::result::Result<(Self, radroots_service_sqlite::MigrationApplicationOutcome), radroots_service_sqlite::ServiceSqliteError> +pub async fn radroots_service_sqlite::ServiceSqliteHost::open_read_write_existing_with_intent(&radroots_service_sqlite::ServiceSqlitePaths, &radroots_service_sqlite::ExistingServiceDatabaseIntent, &radroots_service_sqlite::MigrationCatalog, &radroots_service_sqlite::SchemaCatalog, radroots_service_sqlite::ServiceSqliteConnectionOptions, radroots_service_sqlite::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::MigrationBuildIdentity, &[radroots_service_sqlite::MigrationCallbackBinding]) -> core::result::Result<(radroots_service_sqlite::OpenedExistingServiceDatabase, radroots_service_sqlite::MigrationApplicationOutcome), radroots_service_sqlite::ServiceSqliteError> pub async fn radroots_service_sqlite::ServiceSqliteHost::transaction<T, E, F>(&self, F) -> core::result::Result<T, radroots_service_sqlite::ServiceSqliteTransactionError<E>> where T: core::marker::Send + 'static, E: core::marker::Send + 'static, F: for<'a> core::ops::function::FnOnce(&'a mut radroots_service_sqlite::ServiceSqliteTransaction<'_>) -> radroots_service_sqlite::ServiceSqliteTransactionFuture<'a, T, E> + core::marker::Send impl core::fmt::Debug for radroots_service_sqlite::ServiceSqliteHost pub fn radroots_service_sqlite::ServiceSqliteHost::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result diff --git a/crates/service_sqlite/README.md b/crates/service_sqlite/README.md @@ -15,6 +15,18 @@ runner-owned. Writable host opening finishes every pending governed migration before returning, and read-only inspection opens only current migration and schema state. +Existing databases can be admitted without a caller guessing their stored +source generation. `ExistingServiceDatabaseIntent` seals the canonical +service and instance, supported schema ceiling, and SQLite application ID; +`ServiceSqliteHost::open_read_write_existing_with_intent` and +`ServiceSqliteHost::open_read_only_inspection_with_intent` discover and verify +the actual immutable metadata while retaining the corresponding writer or +inspection authority. Success returns an `OpenedExistingServiceDatabase`, +which keeps the host and verified metadata inseparable until the caller +consumes them together. Recovery remains fail closed and may use this intent +only to discover the marker-bound generation; every other identity dimension, +artifact binding, migration prefix, and schema catalog remains governed. + Service-controlled SQL is screened before SQLite compilation through both the borrowed transaction executor and migration callback executor. The closed statement-control inventory is `PRAGMA`, `ATTACH`, `DETACH`, `BEGIN`, `COMMIT`, diff --git a/crates/service_sqlite/src/connection.rs b/crates/service_sqlite/src/connection.rs @@ -19,10 +19,11 @@ use sqlx::{ }; use crate::{ - MigrationApplicationOutcome, MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, - MigrationCallbackBinding, MigrationCatalog, OpenMode, SchemaCatalog, ServiceDatabaseIdentity, - ServiceSqliteConnectionOptions, ServiceSqliteError, ServiceSqliteErrorKind, - ServiceSqliteIntegrityReport, ServiceSqlitePaths, WriterAuthority, + ExistingServiceDatabaseIntent, MigrationApplicationOutcome, MigrationAppliedAtUnixSeconds, + MigrationBuildIdentity, MigrationCallbackBinding, MigrationCatalog, OpenMode, SchemaCatalog, + ServiceDatabaseIdentity, ServiceDatabaseMetadata, ServiceSqliteConnectionOptions, + ServiceSqliteError, ServiceSqliteErrorKind, ServiceSqliteIntegrityReport, ServiceSqlitePaths, + WriterAuthority, }; #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -55,6 +56,56 @@ pub struct ServiceSqliteHost { failpoints: crate::failpoint::DurabilityFailpoints, } +/// Existing database opened under retained authority with its verified metadata. +/// +/// This result cannot be assembled independently from a host and metadata: +/// +/// ```compile_fail +/// use radroots_service_sqlite::{OpenedExistingServiceDatabase, ServiceSqliteHost}; +/// +/// fn forge(host: ServiceSqliteHost) { +/// let _ = OpenedExistingServiceDatabase { host }; +/// } +/// ``` +pub struct OpenedExistingServiceDatabase { + host: ServiceSqliteHost, + metadata: ServiceDatabaseMetadata, +} + +impl OpenedExistingServiceDatabase { + fn new(host: ServiceSqliteHost, metadata: ServiceDatabaseMetadata) -> Self { + Self { host, metadata } + } + + /// Borrows the authority-retaining host. + #[must_use] + pub const fn host(&self) -> &ServiceSqliteHost { + &self.host + } + + /// Borrows the metadata discovered and verified by the retained open. + #[must_use] + pub const fn database_metadata(&self) -> &ServiceDatabaseMetadata { + &self.metadata + } + + /// Consumes the binding into the authority-retaining host and actual metadata. + #[must_use] + pub fn into_parts(self) -> (ServiceSqliteHost, ServiceDatabaseMetadata) { + (self.host, self.metadata) + } +} + +impl fmt::Debug for OpenedExistingServiceDatabase { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OpenedExistingServiceDatabase") + .field("mode", &self.host.mode()) + .field("database_metadata", &"[redacted]") + .finish() + } +} + #[cfg(any(target_os = "linux", target_os = "macos"))] enum ServiceSqliteHostCloseState { Pending, @@ -244,6 +295,62 @@ impl ServiceSqliteHost { } } + /// Opens existing writable state and discovers its stored generation under authority. + /// + /// The intent binds service, instance, application ID, and the supported + /// schema ceiling before filesystem or SQLite admission. The returned + /// metadata is read from the same authority-retaining host after governed + /// migrations finish, so callers never guess a source generation or reopen + /// the database between discovery and use. + #[allow(clippy::too_many_arguments)] + pub async fn open_read_write_existing_with_intent( + paths: &ServiceSqlitePaths, + intent: &ExistingServiceDatabaseIntent, + migrations: &MigrationCatalog, + schema: &SchemaCatalog, + options: ServiceSqliteConnectionOptions, + applied_at: MigrationAppliedAtUnixSeconds, + build: &MigrationBuildIdentity, + callbacks: &[MigrationCallbackBinding], + ) -> Result<(OpenedExistingServiceDatabase, MigrationApplicationOutcome), ServiceSqliteError> + { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let pool = crate::open::open_existing_connection_pool_with_intent( + paths, + intent, + migrations, + schema, + OpenMode::ReadWriteExisting, + options, + ) + .await?; + let outcome = match pool.apply_migrations(applied_at, build, callbacks).await { + Ok(outcome) => outcome, + Err(error) => { + drop(pool.close().await); + return Err(error); + } + }; + let metadata = match pool.database_metadata().await { + Ok(metadata) => metadata, + Err(error) => { + drop(pool.close().await); + return Err(error); + } + }; + let host = Self::from_pool(OpenMode::ReadWriteExisting, pool); + Ok((OpenedExistingServiceDatabase::new(host, metadata), outcome)) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = ( + paths, intent, migrations, schema, options, applied_at, build, callbacks, + ); + Err(unsupported_host()) + } + } + /// Opens state created under a retained initialization writer authority. #[allow(clippy::too_many_arguments)] pub async fn open_initialized( @@ -309,6 +416,42 @@ impl ServiceSqliteHost { } } + /// Opens existing state for immutable inspection and discovers its metadata. + pub async fn open_read_only_inspection_with_intent( + paths: &ServiceSqlitePaths, + intent: &ExistingServiceDatabaseIntent, + migrations: &MigrationCatalog, + schema: &SchemaCatalog, + options: ServiceSqliteConnectionOptions, + ) -> Result<OpenedExistingServiceDatabase, ServiceSqliteError> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let pool = crate::open::open_existing_connection_pool_with_intent( + paths, + intent, + migrations, + schema, + OpenMode::ReadOnlyInspection, + options, + ) + .await?; + let metadata = match pool.database_metadata().await { + Ok(metadata) => metadata, + Err(error) => { + drop(pool.close().await); + return Err(error); + } + }; + let host = Self::from_pool(OpenMode::ReadOnlyInspection, pool); + Ok(OpenedExistingServiceDatabase::new(host, metadata)) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (paths, intent, migrations, schema, options); + Err(unsupported_host()) + } + } + /// Returns the fixed mode selected when the host was opened. #[must_use] pub const fn mode(&self) -> OpenMode { @@ -1449,6 +1592,83 @@ mod tests { #[cfg(any(target_os = "linux", target_os = "macos"))] #[tokio::test] + async fn existing_intent_returns_actual_metadata_without_releasing_authority() { + let (_root, paths, identity, migrations, schema, initialized) = initialized_host().await; + initialized.close().await.expect("close initialized host"); + let intent = ExistingServiceDatabaseIntent::new( + &paths, + identity.supported_state_schema_version(), + identity.application_id(), + ); + + let wrong_application = ExistingServiceDatabaseIntent::new( + &paths, + identity.supported_state_schema_version(), + crate::ServiceSqliteApplicationId::new(7).expect("other application"), + ); + let error = ServiceSqliteHost::open_read_write_existing_with_intent( + &paths, + &wrong_application, + &migrations, + &schema, + ServiceSqliteConnectionOptions::reviewed(), + MigrationAppliedAtUnixSeconds::new(1_700_000_001).expect("migration time"), + &build_identity(), + &[], + ) + .await + .expect_err("application mismatch"); + assert_eq!(error.kind(), ServiceSqliteErrorKind::Metadata); + + let (opened, outcome) = ServiceSqliteHost::open_read_write_existing_with_intent( + &paths, + &intent, + &migrations, + &schema, + ServiceSqliteConnectionOptions::reviewed(), + MigrationAppliedAtUnixSeconds::new(1_700_000_001).expect("migration time"), + &build_identity(), + &[], + ) + .await + .expect("open writable from intent"); + assert_eq!(outcome.applied_count(), 0); + assert_eq!( + opened.database_metadata().source_generation(), + identity.source_generation() + ); + assert_eq!(opened.host().mode(), OpenMode::ReadWriteExisting); + let debug = format!("{opened:?}"); + assert!(debug.contains("OpenedExistingServiceDatabase")); + assert!(!debug.contains("09090909")); + assert!(WriterAuthority::acquire(&paths, OpenMode::ReadWriteExisting).is_err()); + let (writable, actual) = opened.into_parts(); + assert_eq!(actual.source_generation(), identity.source_generation()); + assert_eq!(row_count(&writable).await, 0); + writable.close().await.expect("close writable host"); + + let inspected = ServiceSqliteHost::open_read_only_inspection_with_intent( + &paths, + &intent, + &migrations, + &schema, + ServiceSqliteConnectionOptions::reviewed(), + ) + .await + .expect("open inspection from intent"); + assert_eq!(inspected.host().mode(), OpenMode::ReadOnlyInspection); + assert_eq!( + inspected.database_metadata().source_generation(), + identity.source_generation() + ); + assert!(WriterAuthority::acquire(&paths, OpenMode::ReadWriteExisting).is_err()); + let (inspection, actual) = inspected.into_parts(); + assert_eq!(actual.source_generation(), identity.source_generation()); + inspection.close().await.expect("close inspection host"); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[tokio::test] async fn integrity_inspection_is_explicit_safe_and_available_in_every_host_mode() { let _serial = crate::integrity::integrity_test_seam::LOCK.lock().await; crate::integrity::integrity_test_seam::release(); diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs @@ -55,8 +55,9 @@ pub use backup::{ }; pub use config::{ServiceSqliteConnectionOptions, ServiceSqliteConnectionOptionsError}; pub use connection::{ - ServiceSqliteHost, ServiceSqliteTransaction, ServiceSqliteTransactionError, - ServiceSqliteTransactionErrorKind, ServiceSqliteTransactionFuture, + OpenedExistingServiceDatabase, ServiceSqliteHost, ServiceSqliteTransaction, + ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind, + ServiceSqliteTransactionFuture, }; pub use error::{ SafeServiceSqliteError, ServiceSqliteError, ServiceSqliteErrorCode, ServiceSqliteErrorKind, @@ -68,8 +69,8 @@ pub use integrity::{ ServiceSqliteIntegrityReport, }; pub use metadata::{ - ServiceDatabaseIdentity, ServiceDatabaseMetadata, ServiceSqliteApplicationId, - ServiceSqliteMetadataValueError, + ExistingServiceDatabaseIntent, ServiceDatabaseIdentity, ServiceDatabaseMetadata, + ServiceSqliteApplicationId, ServiceSqliteMetadataValueError, }; pub use migration::{ MigrationApplicationOutcome, MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, diff --git a/crates/service_sqlite/src/metadata.rs b/crates/service_sqlite/src/metadata.rs @@ -80,6 +80,15 @@ pub struct ServiceDatabaseIdentity { application_id: ServiceSqliteApplicationId, } +/// Sealed expectation for opening an existing database without guessing its generation. +#[derive(Clone, PartialEq, Eq)] +pub struct ExistingServiceDatabaseIntent { + service: ServiceId, + instance: InstanceId, + supported_state_schema_version: NonZeroU32, + application_id: ServiceSqliteApplicationId, +} + impl ServiceDatabaseMetadata { #[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) fn from_verified_backup( @@ -237,6 +246,67 @@ impl ServiceDatabaseIdentity { } } +impl ExistingServiceDatabaseIntent { + /// Binds an existing-only open to canonical paths and the binary's fixed contract. + #[must_use] + pub fn new( + paths: &ServiceSqlitePaths, + supported_state_schema_version: NonZeroU32, + application_id: ServiceSqliteApplicationId, + ) -> Self { + Self { + service: paths.service().clone(), + instance: paths.instance().clone(), + supported_state_schema_version, + application_id, + } + } + + /// Returns the bound service identity. + #[must_use] + pub fn service(&self) -> &ServiceId { + &self.service + } + + /// Returns the bound instance identity. + #[must_use] + pub fn instance(&self) -> &InstanceId { + &self.instance + } + + /// Returns the newest state schema version this binary accepts. + #[must_use] + pub const fn supported_state_schema_version(&self) -> NonZeroU32 { + self.supported_state_schema_version + } + + /// Returns the expected SQLite application identifier. + #[must_use] + pub const fn application_id(&self) -> ServiceSqliteApplicationId { + self.application_id + } + + pub(crate) fn matches_paths(&self, paths: &ServiceSqlitePaths) -> bool { + crate::all_constraints([ + self.service == *paths.service(), + self.instance == *paths.instance(), + ]) + } + + pub(crate) fn identity_for( + &self, + metadata: &ServiceDatabaseMetadata, + ) -> ServiceDatabaseIdentity { + ServiceDatabaseIdentity { + service: self.service.clone(), + instance: self.instance.clone(), + source_generation: metadata.source_generation, + supported_state_schema_version: self.supported_state_schema_version, + application_id: self.application_id, + } + } +} + impl fmt::Debug for ServiceDatabaseIdentity { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -253,6 +323,21 @@ impl fmt::Debug for ServiceDatabaseIdentity { } } +impl fmt::Debug for ExistingServiceDatabaseIntent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ExistingServiceDatabaseIntent") + .field("service", &"[redacted]") + .field("instance", &"[redacted]") + .field( + "supported_state_schema_version", + &self.supported_state_schema_version, + ) + .field("application_id", &self.application_id) + .finish() + } +} + impl fmt::Debug for ServiceDatabaseMetadata { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -420,6 +505,24 @@ pub(crate) async fn verify_database_metadata( } #[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) async fn verify_existing_database_intent( + connection: &mut SqliteConnection, + intent: &ExistingServiceDatabaseIntent, +) -> Result<ServiceDatabaseMetadata, ServiceSqliteError> { + let actual = read_database_metadata(connection).await?; + require_metadata_condition( + crate::all_constraints([ + actual.service == intent.service, + actual.instance == intent.instance, + actual.application_id == intent.application_id, + actual.state_schema_version <= intent.supported_state_schema_version, + ]), + MetadataFailureKind::Mismatch, + )?; + Ok(actual) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] async fn read_database_metadata( connection: &mut SqliteConnection, ) -> Result<ServiceDatabaseMetadata, ServiceSqliteError> { @@ -757,6 +860,84 @@ mod tests { #[cfg(any(target_os = "linux", target_os = "macos"))] #[tokio::test(flavor = "current_thread")] + async fn existing_intent_discovers_generation_and_binds_every_trusted_dimension() { + let paths = sqlite_paths("myc", "primary"); + let expected = metadata(&paths, 7, 1, 1_700_000_000_000, 0x5244_5351); + let mut connection = memory_connection().await; + write_database_metadata(&mut connection, &expected, &base_schema_catalog()) + .await + .expect("write metadata"); + + let intent = ExistingServiceDatabaseIntent::new( + &paths, + NonZeroU32::new(2).expect("schema ceiling"), + expected.application_id(), + ); + let actual = verify_existing_database_intent(&mut connection, &intent) + .await + .expect("discover metadata"); + assert_eq!(actual, expected); + assert_eq!(actual.source_generation().as_bytes(), &[7; 32]); + assert_eq!( + intent.identity_for(&actual).source_generation(), + actual.source_generation() + ); + + let debug = format!("{intent:?}"); + assert!(debug.contains("ExistingServiceDatabaseIntent")); + assert!(!debug.contains("myc")); + assert!(!debug.contains("primary")); + assert!(!debug.contains("07070707")); + + let other_paths = sqlite_paths("rhi", "primary"); + let wrong_service = ExistingServiceDatabaseIntent::new( + &other_paths, + intent.supported_state_schema_version(), + intent.application_id(), + ); + let other_instance_paths = sqlite_paths("myc", "secondary"); + let wrong_instance = ExistingServiceDatabaseIntent::new( + &other_instance_paths, + intent.supported_state_schema_version(), + intent.application_id(), + ); + let wrong_application = ExistingServiceDatabaseIntent::new( + &paths, + intent.supported_state_schema_version(), + ServiceSqliteApplicationId::new(7).expect("other application"), + ); + for rejected in [&wrong_service, &wrong_instance, &wrong_application] { + assert_eq!( + verify_existing_database_intent(&mut connection, rejected) + .await + .expect_err("intent mismatch") + .kind(), + ServiceSqliteErrorKind::Metadata + ); + } + + sqlx::query( + "UPDATE radroots_service_metadata SET state_schema_version = 2 WHERE singleton = 1", + ) + .execute(&mut connection) + .await + .expect("advance stored schema"); + let older_binary = ExistingServiceDatabaseIntent::new( + &paths, + NonZeroU32::new(1).expect("older ceiling"), + expected.application_id(), + ); + assert_eq!( + verify_existing_database_intent(&mut connection, &older_binary) + .await + .expect_err("newer stored schema") + .kind(), + ServiceSqliteErrorKind::Metadata + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[tokio::test(flavor = "current_thread")] async fn schema_mismatch_rolls_back_shared_objects_metadata_and_application_id() { let paths = sqlite_paths("myc", "primary"); let expected = metadata(&paths, 7, 1, 1_700_000_000_000, 0x5244_5351); diff --git a/crates/service_sqlite/src/open.rs b/crates/service_sqlite/src/open.rs @@ -32,9 +32,9 @@ use sqlx::{ #[cfg(any(target_os = "linux", target_os = "macos"))] use crate::{ - MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, MigrationCatalog, SchemaCatalog, - ServiceDatabaseIdentity, ServiceSqliteConnectionOptions, ServiceSqliteError, - ServiceSqliteErrorKind, WriterAuthority, + ExistingServiceDatabaseIntent, MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, + MigrationCatalog, SchemaCatalog, ServiceDatabaseIdentity, ServiceDatabaseMetadata, + ServiceSqliteConnectionOptions, ServiceSqliteError, ServiceSqliteErrorKind, WriterAuthority, }; #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -61,6 +61,51 @@ struct PoolConnectionValidation { } #[cfg(any(target_os = "linux", target_os = "macos"))] +#[derive(Clone, Copy)] +enum ServiceDatabaseExpectation<'a> { + Exact(&'a ServiceDatabaseIdentity), + Existing(&'a ExistingServiceDatabaseIntent), +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl ServiceDatabaseExpectation<'_> { + fn matches_paths(self, paths: &ServiceSqlitePaths) -> bool { + match self { + Self::Exact(identity) => identity.matches_paths(paths), + Self::Existing(intent) => intent.matches_paths(paths), + } + } + + fn supported_state_schema_version(self) -> core::num::NonZeroU32 { + match self { + Self::Exact(identity) => identity.supported_state_schema_version(), + Self::Existing(intent) => intent.supported_state_schema_version(), + } + } + + async fn verify_metadata( + self, + connection: &mut SqliteConnection, + ) -> Result<ServiceDatabaseMetadata, ServiceSqliteError> { + match self { + Self::Exact(identity) => { + crate::metadata::verify_database_metadata(connection, identity).await + } + Self::Existing(intent) => { + crate::metadata::verify_existing_database_intent(connection, intent).await + } + } + } + + fn exact_identity(self, metadata: &ServiceDatabaseMetadata) -> ServiceDatabaseIdentity { + match self { + Self::Exact(identity) => identity.clone(), + Self::Existing(intent) => intent.identity_for(metadata), + } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] enum PoolConnectionValidationFailure { Authority, Pragma(sqlx::Error), @@ -383,6 +428,17 @@ impl PrivateConnectionPool { &self.identity } + pub(crate) async fn database_metadata( + &self, + ) -> Result<ServiceDatabaseMetadata, ServiceSqliteError> { + self.validate()?; + let mut connection = self.acquire().await?; + let result = + crate::metadata::verify_database_metadata(&mut connection, &self.identity).await; + self.validate()?; + result + } + pub(crate) fn backup_source_validator(&self) -> BackupSourceValidator { BackupSourceValidator { binding: self.binding.clone(), @@ -765,6 +821,46 @@ pub(crate) async fn open_existing_connection_pool( mode: OpenMode, policy: ServiceSqliteConnectionOptions, ) -> Result<PrivateConnectionPool, ServiceSqliteError> { + open_existing_connection_pool_for( + paths, + ServiceDatabaseExpectation::Exact(identity), + catalog, + schema_catalog, + mode, + policy, + ) + .await +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) async fn open_existing_connection_pool_with_intent( + paths: &ServiceSqlitePaths, + intent: &ExistingServiceDatabaseIntent, + catalog: &MigrationCatalog, + schema_catalog: &SchemaCatalog, + mode: OpenMode, + policy: ServiceSqliteConnectionOptions, +) -> Result<PrivateConnectionPool, ServiceSqliteError> { + open_existing_connection_pool_for( + paths, + ServiceDatabaseExpectation::Existing(intent), + catalog, + schema_catalog, + mode, + policy, + ) + .await +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +async fn open_existing_connection_pool_for( + paths: &ServiceSqlitePaths, + expectation: ServiceDatabaseExpectation<'_>, + catalog: &MigrationCatalog, + schema_catalog: &SchemaCatalog, + mode: OpenMode, + policy: ServiceSqliteConnectionOptions, +) -> Result<PrivateConnectionPool, ServiceSqliteError> { if mode == OpenMode::Initialize { return Err(connection_error( ServiceSqliteErrorKind::Open, @@ -778,7 +874,7 @@ pub(crate) async fn open_existing_connection_pool( }; open_connection_pool( paths, - identity, + expectation, catalog, schema_catalog, mode, @@ -801,7 +897,7 @@ pub(crate) async fn open_initialized_connection_pool( authority.validate_for(paths)?; open_connection_pool( paths, - identity, + ServiceDatabaseExpectation::Exact(identity), catalog, schema_catalog, OpenMode::Initialize, @@ -857,7 +953,7 @@ impl PoolConnectionValidation { #[allow(clippy::too_many_arguments)] async fn open_connection_pool( paths: &ServiceSqlitePaths, - identity: &ServiceDatabaseIdentity, + expectation: ServiceDatabaseExpectation<'_>, catalog: &MigrationCatalog, schema_catalog: &SchemaCatalog, mode: OpenMode, @@ -865,10 +961,10 @@ async fn open_connection_pool( authority: Option<WriterAuthority>, inspection_guard: Option<ReadOnlyInspectionGuard>, ) -> Result<PrivateConnectionPool, ServiceSqliteError> { - if !identity.matches_paths(paths) { + if !expectation.matches_paths(paths) { return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata)); } - if identity.supported_state_schema_version().get() != catalog.current_version() { + if expectation.supported_state_schema_version().get() != catalog.current_version() { return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Migration)); } if !schema_catalog.matches_migrations(catalog) { @@ -881,9 +977,14 @@ async fn open_connection_pool( authority.validate_for(paths)?; result } - (OpenMode::ReadWriteExisting, Some(authority), None) => { - crate::restore::recover_for_open(paths, identity, authority) - } + (OpenMode::ReadWriteExisting, Some(authority), None) => match expectation { + ServiceDatabaseExpectation::Exact(identity) => { + crate::restore::recover_for_open(paths, identity, authority) + } + ServiceDatabaseExpectation::Existing(intent) => { + crate::restore::recover_for_open_with_intent(paths, intent, authority) + } + }, (OpenMode::ReadOnlyInspection, None, Some(inspection_guard)) => { inspection_guard.validate_for(paths)?; let result = crate::restore::refuse_unresolved_recovery(&inspection_guard.directory); @@ -921,10 +1022,10 @@ async fn open_connection_pool( let preflight_policy = verify_connection_policy(&mut preflight, mode, policy).await; binding.validate(paths)?; preflight_policy.map_err(|source| connection_source(ServiceSqliteErrorKind::Pragma, source))?; - let preflight_metadata = - crate::metadata::verify_database_metadata(&mut preflight, identity).await; + let preflight_metadata = expectation.verify_metadata(&mut preflight).await; binding.validate(paths)?; - preflight_metadata?; + let preflight_metadata = preflight_metadata?; + let identity = expectation.exact_identity(&preflight_metadata); let preflight_history = crate::migration::verify_migration_history( &mut preflight, catalog, @@ -2944,7 +3045,7 @@ mod tests { ); let Err(error) = open_connection_pool( &paths, - &wrong_identity, + ServiceDatabaseExpectation::Exact(&wrong_identity), &base_catalog(), &base_schema_catalog(), OpenMode::Initialize, @@ -2968,7 +3069,7 @@ mod tests { ); let Err(error) = open_connection_pool( &paths, - &newer_identity, + ServiceDatabaseExpectation::Exact(&newer_identity), &base_catalog(), &base_schema_catalog(), OpenMode::Initialize, @@ -2986,7 +3087,7 @@ mod tests { initialized_authority(directory.path(), "schema-drift-preflight").await; let Err(error) = open_connection_pool( &paths, - &identity, + ServiceDatabaseExpectation::Exact(&identity), &base_catalog(), &migration_schema_catalog(), OpenMode::Initialize, diff --git a/crates/service_sqlite/src/restore/marker.rs b/crates/service_sqlite/src/restore/marker.rs @@ -14,8 +14,8 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{ - BackupManifestSha256, ServiceDatabaseIdentity, ServiceDatabaseMetadata, - ServiceSqliteApplicationId, ServiceSqlitePaths, + BackupManifestSha256, ExistingServiceDatabaseIntent, ServiceDatabaseIdentity, + ServiceDatabaseMetadata, ServiceSqliteApplicationId, ServiceSqlitePaths, }; #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -397,6 +397,15 @@ impl RestoreRecoveryMarker { self.state_schema_version <= identity.supported_state_schema_version(), ]) } + + pub(crate) fn matches_existing_intent(&self, intent: &ExistingServiceDatabaseIntent) -> bool { + crate::all_constraints([ + self.service == *intent.service(), + self.instance == *intent.instance(), + self.application_id == intent.application_id(), + self.state_schema_version <= intent.supported_state_schema_version(), + ]) + } } impl fmt::Debug for RestoreRecoveryMarker { @@ -1944,6 +1953,50 @@ mod tests { } #[test] + fn marker_existing_intent_discovers_generation_but_binds_other_dimensions() { + let root = tempfile::tempdir().expect("root"); + let paths = paths(root.path()); + let marker = marker(&paths); + let exact = ExistingServiceDatabaseIntent::new( + &paths, + NonZeroU32::new(3).expect("schema ceiling"), + ServiceSqliteApplicationId::new(0x5244_5254).expect("application"), + ); + assert!(marker.matches_existing_intent(&exact)); + + let other_service_paths = paths_for(root.path(), "rhi", "primary"); + assert!( + !marker.matches_existing_intent(&ExistingServiceDatabaseIntent::new( + &other_service_paths, + exact.supported_state_schema_version(), + exact.application_id(), + )) + ); + let other_instance_paths = paths_for(root.path(), "myc", "secondary"); + assert!( + !marker.matches_existing_intent(&ExistingServiceDatabaseIntent::new( + &other_instance_paths, + exact.supported_state_schema_version(), + exact.application_id(), + )) + ); + assert!( + !marker.matches_existing_intent(&ExistingServiceDatabaseIntent::new( + &paths, + NonZeroU32::new(2).expect("older schema ceiling"), + exact.application_id(), + )) + ); + assert!( + !marker.matches_existing_intent(&ExistingServiceDatabaseIntent::new( + &paths, + exact.supported_state_schema_version(), + ServiceSqliteApplicationId::new(7).expect("other application"), + )) + ); + } + + #[test] fn all_phase_edges_and_idempotent_bytes_are_exact() { let root = tempfile::tempdir().expect("root"); let prepared = marker(&paths(root.path())); diff --git a/crates/service_sqlite/src/restore/mod.rs b/crates/service_sqlite/src/restore/mod.rs @@ -16,7 +16,7 @@ pub use stage::{StagedServiceRestore, stage_verified_restore}; pub(crate) use recover::refuse_unresolved_recovery; #[cfg(any(target_os = "linux", target_os = "macos"))] -pub(crate) use recover::recover_for_open; +pub(crate) use recover::{recover_for_open, recover_for_open_with_intent}; #[allow(unused_imports)] pub(crate) use marker::{ diff --git a/crates/service_sqlite/src/restore/recover.rs b/crates/service_sqlite/src/restore/recover.rs @@ -13,8 +13,8 @@ use { }, }, crate::{ - ServiceDatabaseIdentity, ServiceSqliteError, ServiceSqliteErrorKind, ServiceSqlitePaths, - WriterAuthority, + ExistingServiceDatabaseIntent, ServiceDatabaseIdentity, ServiceSqliteError, + ServiceSqliteErrorKind, ServiceSqlitePaths, WriterAuthority, }, rustix::{ fs::{ @@ -37,6 +37,39 @@ pub(crate) fn recover_for_open( identity: &ServiceDatabaseIdentity, authority: &WriterAuthority, ) -> Result<(), ServiceSqliteError> { + recover_for_open_expectation( + paths, + RecoveryDatabaseExpectation::Exact(identity), + authority, + ) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn recover_for_open_with_intent( + paths: &ServiceSqlitePaths, + intent: &ExistingServiceDatabaseIntent, + authority: &WriterAuthority, +) -> Result<(), ServiceSqliteError> { + recover_for_open_expectation( + paths, + RecoveryDatabaseExpectation::Existing(intent), + authority, + ) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[derive(Clone, Copy)] +enum RecoveryDatabaseExpectation<'a> { + Exact(&'a ServiceDatabaseIdentity), + Existing(&'a ExistingServiceDatabaseIntent), +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn recover_for_open_expectation( + paths: &ServiceSqlitePaths, + expectation: RecoveryDatabaseExpectation<'_>, + authority: &WriterAuthority, +) -> Result<(), ServiceSqliteError> { authority.validate_for(paths)?; let Some(mut marker) = RestoreMarkerBinding::load_for_recovery(paths, authority)? else { authority_checked(authority, paths, || { @@ -44,7 +77,13 @@ pub(crate) fn recover_for_open( })?; return Ok(()); }; - if !marker.marker().matches_identity(identity) { + let intent_matches = match expectation { + RecoveryDatabaseExpectation::Exact(identity) => marker.marker().matches_identity(identity), + RecoveryDatabaseExpectation::Existing(intent) => { + marker.marker().matches_existing_intent(intent) + } + }; + if !intent_matches { return Err(recovery_error(RecoveryFailureKind::Intent)); } @@ -759,6 +798,15 @@ mod tests { fn recover(&self) -> Result<(), ServiceSqliteError> { recover_for_open(&self.paths, &self.identity, &self.authority) } + + fn recover_with_intent(&self) -> Result<(), ServiceSqliteError> { + let intent = ExistingServiceDatabaseIntent::new( + &self.paths, + self.identity.supported_state_schema_version(), + self.identity.application_id(), + ); + recover_for_open_with_intent(&self.paths, &intent, &self.authority) + } } #[test] @@ -790,6 +838,17 @@ mod tests { } #[test] + fn generation_discovering_intent_recovers_when_live_database_is_retained() { + let fixture = Fixture::new(); + fixture.retain_live(false); + fixture + .recover_with_intent() + .expect("recover from marker-bound existing intent"); + assert_eq!(fs::read(fixture.paths.state_database()).unwrap(), NEW_BYTES); + assert_no_recovery_evidence(&fixture.paths); + } + + #[test] fn live_retained_with_proven_second_rename_rolls_forward() { let fixture = Fixture::new(); fixture.retain_live(true); diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs @@ -170,6 +170,12 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "is revalidated before commit and before a connection can return to the pool", "Writable host opening finishes every pending governed migration", "read-only inspection opens only current migration and", + "Existing databases can be admitted without a caller guessing their stored source generation", + "`ExistingServiceDatabaseIntent` seals the canonical service and instance, supported schema ceiling, and SQLite application ID", + "discover and verify the actual immutable metadata while retaining the corresponding writer or inspection authority", + "Success returns an `OpenedExistingServiceDatabase`", + "keeps the host and verified metadata inseparable until the caller consumes them together", + "Recovery remains fail closed", "with raw database authority", "before the runner enables outer commit", "leaves no authoritative transaction effect", @@ -391,6 +397,8 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "pub struct radroots_service_sqlite::ServiceSqliteHost", "pub struct radroots_service_sqlite::ServiceSqliteTransaction", "pub struct radroots_service_sqlite::ServiceSqlitePaths", + "pub struct radroots_service_sqlite::ExistingServiceDatabaseIntent", + "pub struct radroots_service_sqlite::OpenedExistingServiceDatabase", "pub struct radroots_service_sqlite::MigrationCatalog", "pub struct radroots_service_sqlite::SchemaCatalog", "pub struct radroots_service_sqlite::VerifiedServiceBackup", @@ -399,6 +407,8 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "pub fn radroots_service_sqlite::verify_backup_bundle", "pub async fn radroots_service_sqlite::finalize_staged_restore", "pub async fn radroots_service_sqlite::stage_verified_restore", + "pub async fn radroots_service_sqlite::ServiceSqliteHost::open_read_write_existing_with_intent", + "pub async fn radroots_service_sqlite::ServiceSqliteHost::open_read_only_inspection_with_intent", "impl<'executor, 'connection> sqlx_core::executor::Executor<'executor> for &'executor mut radroots_service_sqlite::ServiceSqliteTransaction<'connection>", ] { assert!(