lib

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

commit a5985c1a5b2b063f063c42c7f72a92669cdf5d79
parent 8332b86a7d3a9cbc2210038a1e9a21fca6f73312
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 12:42:03 +0000

service-sqlite: bind database identity

- bind canonical service, instance, generation, schema, creation, and application metadata
- verify immutable identity during initialization, pool opening, and every checkout
- permit only monotonic supported schema advancement for the next migration checkpoints
- cover native behavior and warning-free Windows and Android compile boundaries

Diffstat:
MCargo.lock | 1+
Mcrates/service_sqlite/Cargo.toml | 1+
Mcrates/service_sqlite/README.md | 3++-
Mcrates/service_sqlite/src/initialize.rs | 253++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mcrates/service_sqlite/src/lib.rs | 5+++++
Acrates/service_sqlite/src/metadata.rs | 796+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_sqlite/src/open.rs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/service_sqlite/tests/package_boundary.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++--
8 files changed, 1212 insertions(+), 96 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3911,6 +3911,7 @@ version = "0.1.0-alpha" dependencies = [ "fs2", "radroots_runtime_paths", + "radroots_storage", "rustix 1.1.4", "serde", "serde_json", diff --git a/crates/service_sqlite/Cargo.toml b/crates/service_sqlite/Cargo.toml @@ -14,6 +14,7 @@ readme = "README.md" [dependencies] fs2 = { workspace = true } radroots_runtime_paths = { workspace = true } +radroots_storage = { workspace = true } rustix = { workspace = true } serde = { workspace = true, features = ["derive", "std"] } sqlx = { workspace = true, features = ["runtime-tokio", "sqlite-bundled"] } diff --git a/crates/service_sqlite/README.md b/crates/service_sqlite/README.md @@ -3,7 +3,8 @@ `radroots_service_sqlite` is the unpublished, native SQLite-mechanism crate for Radroots services. It provides narrow, reusable building blocks for exclusive writer authority, instance locking, versioned schema mechanics, bounded -transactions, integrity checks, backup, restore, and passive storage status. +transactions, immutable service-instance database identity, integrity checks, +backup, restore, and passive storage status. The crate owns mechanics only. Service-specific tables, SQL, repositories, backup content policy, identity material, process lifecycle, and readiness diff --git a/crates/service_sqlite/src/initialize.rs b/crates/service_sqlite/src/initialize.rs @@ -4,18 +4,22 @@ use core::{fmt, future::Future}; use std::{error::Error, path::PathBuf}; use crate::{ - OpenMode, ServiceSqliteError, ServiceSqliteErrorKind, ServiceSqlitePaths, WriterAuthority, + OpenMode, ServiceDatabaseMetadata, ServiceSqliteError, ServiceSqliteErrorKind, + ServiceSqlitePaths, WriterAuthority, }; /// Creates and initializes a missing service database while holding sole writer authority. /// /// The callback receives the already-reserved canonical database path. It must /// open that file without create or replacement flags, initialize its schema, -/// close every database handle, and only then resolve its future. Cancellation -/// or failure removes only the exact inode reserved by this call. +/// close every database handle, and only then resolve its future. The supplied +/// metadata must derive from the same paths; it is written and verified after +/// the callback but before the database becomes durable. Cancellation or +/// failure removes only the exact inode reserved by this call. pub async fn initialize_database<F, Fut, E>( paths: &ServiceSqlitePaths, mode: OpenMode, + metadata: &ServiceDatabaseMetadata, initialize_schema: F, ) -> Result<WriterAuthority, ServiceSqliteError> where @@ -28,6 +32,9 @@ where InitializationFailureKind::UnsupportedMode, ))); } + if !metadata.matches_paths(paths) { + return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata)); + } let authority = WriterAuthority::acquire(paths, mode)?.ok_or_else(|| { initialization_error(InitializationCause::new( @@ -40,6 +47,7 @@ where initialize_with_ops( paths, authority, + metadata, initialize_schema, &SystemInitializationOperations, ) @@ -48,7 +56,7 @@ where #[cfg(not(any(target_os = "linux", target_os = "macos")))] { - drop((authority, initialize_schema)); + drop((authority, metadata, initialize_schema)); Err(initialization_error(InitializationCause::new( InitializationFailureKind::CreateUnavailable, ))) @@ -414,9 +422,23 @@ mod supported { } } + async fn fail_metadata_with_rollback<O: InitializationOperations>( + mut pending: PendingDatabase<'_, O>, + primary: ServiceSqliteError, + ) -> Result<WriterAuthority, ServiceSqliteError> { + match pending.rollback() { + Ok(()) => Err(primary), + Err(cleanup) => Err(initialization_error(InitializationCause::with_source( + cleanup.kind, + primary, + ))), + } + } + pub(super) async fn initialize_with_ops<F, Fut, E, O>( paths: &ServiceSqlitePaths, authority: WriterAuthority, + metadata: &ServiceDatabaseMetadata, initialize_schema: F, operations: &O, ) -> Result<WriterAuthority, ServiceSqliteError> @@ -451,6 +473,41 @@ mod supported { if let Err(error) = pending.validate_canonical_path(paths.state_database()) { return fail_with_rollback(pending, error).await; } + let metadata_result = async { + use sqlx::{ConnectOptions, Connection, sqlite::SqliteConnectOptions}; + + let options = SqliteConnectOptions::new() + .filename(paths.state_database()) + .create_if_missing(false) + .disable_statement_logging(); + let mut connection = sqlx::SqliteConnection::connect_with(&options) + .await + .map_err(|source| { + ServiceSqliteError::with_source(ServiceSqliteErrorKind::Metadata, source) + })?; + let write_result = + crate::metadata::write_database_metadata(&mut connection, metadata).await; + let close_result = connection.close().await.map_err(|source| { + ServiceSqliteError::with_source(ServiceSqliteErrorKind::Metadata, source) + }); + write_result.and(close_result) + } + .await; + if let Err(error) = pending.validate() { + return fail_with_rollback(pending, error).await; + } + if let Err(error) = pending.validate_canonical_path(paths.state_database()) { + return fail_with_rollback(pending, error).await; + } + if let Err(error) = metadata_result { + return fail_metadata_with_rollback(pending, error).await; + } + if let Err(error) = pending.validate() { + return fail_with_rollback(pending, error).await; + } + if let Err(error) = pending.validate_canonical_path(paths.state_database()) { + return fail_with_rollback(pending, error).await; + } if let Err(error) = pending.commit(paths.state_database()) { return fail_with_rollback(pending, error).await; } @@ -462,9 +519,10 @@ mod supported { mod tests { use std::{ cell::{Cell, RefCell}, - fs::{self, OpenOptions}, + fs, future::{Future, pending, ready}, - io::{self, Write}, + io, + num::NonZeroU32, os::unix::fs::{MetadataExt, PermissionsExt, symlink}, path::Path, pin::Pin, @@ -476,6 +534,7 @@ mod supported { RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, }; + use radroots_storage::event::SourceGeneration; use super::*; @@ -542,14 +601,6 @@ mod supported { (result, future) } - fn run_ready<F: Future>(future: F) -> F::Output { - let (result, _) = poll_once(future); - match result { - Poll::Ready(output) => output, - Poll::Pending => panic!("test future must be immediately ready"), - } - } - fn paths(root: &Path, instance: &str) -> ServiceSqlitePaths { let context = RuntimeContext::resolve( &RadrootsPathResolver::new( @@ -575,28 +626,46 @@ mod supported { .expect("create state directory"); } - #[test] - fn successful_initialization_is_create_new_mode_0600_and_retains_authority() { + fn metadata(paths: &ServiceSqlitePaths) -> ServiceDatabaseMetadata { + ServiceDatabaseMetadata::new( + paths, + SourceGeneration::new([7; 32]).expect("source generation"), + NonZeroU32::new(1).expect("schema version"), + 1_700_000_000_000, + crate::ServiceSqliteApplicationId::new(0x5244_5351).expect("application ID"), + ) + .expect("database metadata") + } + + #[tokio::test(flavor = "current_thread")] + async fn successful_initialization_is_create_new_mode_0600_and_retains_authority() { let root = tempfile::tempdir().expect("root"); let paths = paths(root.path(), "success"); prepare(&paths); let expected_path = paths.state_database().to_path_buf(); - let mut authority = run_ready(initialize_database( - &paths, - OpenMode::Initialize, - |path| async move { + let metadata = metadata(&paths); + let mut authority = + initialize_database(&paths, OpenMode::Initialize, &metadata, |path| async move { assert_eq!(path, expected_path); - let mut file = OpenOptions::new().write(true).open(path)?; - file.write_all(b"schema-v1")?; - Ok::<(), io::Error>(()) - }, - )) - .expect("initialize"); - - assert_eq!(fs::read(paths.state_database()).unwrap(), b"schema-v1"); - let metadata = fs::metadata(paths.state_database()).unwrap(); - assert_eq!(metadata.permissions().mode() & 0o777, 0o600); - assert_eq!(metadata.nlink(), 1); + use sqlx::{ConnectOptions, Connection, sqlite::SqliteConnectOptions}; + + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false) + .disable_statement_logging(); + let mut connection = sqlx::SqliteConnection::connect_with(&options).await?; + sqlx::query("CREATE TABLE service_schema (id INTEGER PRIMARY KEY)") + .execute(&mut connection) + .await?; + connection.close().await?; + Ok::<(), sqlx::Error>(()) + }) + .await + .expect("initialize"); + + let filesystem_metadata = fs::metadata(paths.state_database()).unwrap(); + assert_eq!(filesystem_metadata.permissions().mode() & 0o777, 0o600); + assert_eq!(filesystem_metadata.nlink(), 1); assert!(authority.is_held()); assert!(WriterAuthority::acquire(&paths, OpenMode::ReadWriteExisting).is_err()); authority.release().expect("release"); @@ -607,8 +676,8 @@ mod supported { ); } - #[test] - fn existing_regular_symlink_directory_and_hardlink_are_never_mutated() { + #[tokio::test(flavor = "current_thread")] + async fn existing_regular_symlink_directory_and_hardlink_are_never_mutated() { for shape in ["regular", "symlink", "directory", "hardlink"] { let root = tempfile::tempdir().expect("root"); let paths = paths(root.path(), shape); @@ -623,10 +692,12 @@ mod supported { _ => unreachable!(), } let called = Cell::new(false); - let error = run_ready(initialize_database(&paths, OpenMode::Initialize, |_| { + let metadata = metadata(&paths); + let error = initialize_database(&paths, OpenMode::Initialize, &metadata, |_| { called.set(true); ready(Ok::<(), CallbackFailure>(())) - })) + }) + .await .expect_err("existing state must fail"); assert_eq!(error.kind(), ServiceSqliteErrorKind::Create); assert!(!called.get()); @@ -634,16 +705,18 @@ mod supported { } } - #[test] - fn non_initialize_modes_have_zero_callback_and_filesystem_effects() { + #[tokio::test(flavor = "current_thread")] + async fn non_initialize_modes_have_zero_callback_and_filesystem_effects() { for mode in [OpenMode::ReadWriteExisting, OpenMode::ReadOnlyInspection] { let root = tempfile::tempdir().expect("root"); let paths = paths(root.path(), "wrong-mode"); let called = Cell::new(false); - let error = run_ready(initialize_database(&paths, mode, |_| { + let metadata = metadata(&paths); + let error = initialize_database(&paths, mode, &metadata, |_| { called.set(true); ready(Ok::<(), CallbackFailure>(())) - })) + }) + .await .expect_err("mode must reject"); assert_eq!(error.kind(), ServiceSqliteErrorKind::Create); assert!(!called.get()); @@ -653,14 +726,16 @@ mod supported { } } - #[test] - fn callback_failure_cleans_up_releases_authority_and_preserves_trusted_cause() { + #[tokio::test(flavor = "current_thread")] + async fn callback_failure_cleans_up_releases_authority_and_preserves_trusted_cause() { let root = tempfile::tempdir().expect("root"); let paths = paths(root.path(), "callback-failure"); prepare(&paths); - let error = run_ready(initialize_database(&paths, OpenMode::Initialize, |_| { + let metadata = metadata(&paths); + let error = initialize_database(&paths, OpenMode::Initialize, &metadata, |_| { ready(Err::<(), _>(CallbackFailure)) - })) + }) + .await .expect_err("callback failure"); assert_eq!(error.kind(), ServiceSqliteErrorKind::Create); assert!(!paths.state_database().exists()); @@ -681,23 +756,60 @@ mod supported { Some("secret callback path=/private/state.sqlite") ); - let retry = run_ready(initialize_database(&paths, OpenMode::Initialize, |_| { + let retry = initialize_database(&paths, OpenMode::Initialize, &metadata, |_| { ready(Ok::<(), CallbackFailure>(())) - })) + }) + .await .expect("retry after cleanup"); assert!(retry.is_held()); assert!(paths.state_database().exists()); } - #[test] - fn cancellation_rolls_back_and_releases_authority() { + #[tokio::test(flavor = "current_thread")] + async fn metadata_write_failure_cleans_the_exact_reserved_database() { + let root = tempfile::tempdir().expect("root"); + let paths = paths(root.path(), "metadata-failure"); + prepare(&paths); + let metadata = metadata(&paths); + let error = + initialize_database(&paths, OpenMode::Initialize, &metadata, |path| async move { + use sqlx::{ConnectOptions, Connection, sqlite::SqliteConnectOptions}; + + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false) + .disable_statement_logging(); + let mut connection = sqlx::SqliteConnection::connect_with(&options).await?; + sqlx::query("CREATE TABLE radroots_service_metadata (value TEXT)") + .execute(&mut connection) + .await?; + connection.close().await?; + Ok::<(), sqlx::Error>(()) + }) + .await + .expect_err("conflicting metadata must fail"); + + assert_eq!(error.kind(), ServiceSqliteErrorKind::Metadata); + assert!(!paths.state_database().exists()); + assert!( + WriterAuthority::acquire(&paths, OpenMode::Initialize) + .expect("reacquire") + .is_some() + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn cancellation_rolls_back_and_releases_authority() { let root = tempfile::tempdir().expect("root"); let paths = paths(root.path(), "cancelled"); prepare(&paths); - let (poll, future) = - poll_once(initialize_database(&paths, OpenMode::Initialize, |_| { - pending::<Result<(), CallbackFailure>>() - })); + let metadata = metadata(&paths); + let (poll, future) = poll_once(initialize_database( + &paths, + OpenMode::Initialize, + &metadata, + |_| pending::<Result<(), CallbackFailure>>(), + )); assert!(poll.is_pending()); assert!(paths.state_database().exists()); assert!(WriterAuthority::acquire(&paths, OpenMode::Initialize).is_err()); @@ -710,39 +822,44 @@ mod supported { ); } - #[test] - fn replacement_is_detected_and_never_deleted() { + #[tokio::test(flavor = "current_thread")] + async fn replacement_is_detected_and_never_deleted() { let root = tempfile::tempdir().expect("root"); let paths = paths(root.path(), "replacement"); prepare(&paths); + let metadata = metadata(&paths); let replacement_path = paths.state_database().to_path_buf(); - let error = run_ready(initialize_database( + let error = initialize_database( &paths, OpenMode::Initialize, + &metadata, move |path| async move { fs::remove_file(&path)?; fs::write(&replacement_path, b"replacement")?; Ok::<(), io::Error>(()) }, - )) + ) + .await .expect_err("replacement must fail"); assert_eq!(error.kind(), ServiceSqliteErrorKind::Create); assert_eq!(fs::read(paths.state_database()).unwrap(), b"replacement"); } - #[test] - fn parent_directory_replacement_cannot_rebind_the_callback_path() { + #[tokio::test(flavor = "current_thread")] + async fn parent_directory_replacement_cannot_rebind_the_callback_path() { let root = tempfile::tempdir().expect("root"); let paths = paths(root.path(), "parent-replacement"); prepare(&paths); + let metadata = metadata(&paths); let state_directory = paths.state_database().parent().unwrap().to_path_buf(); let displaced_directory = state_directory.with_file_name("parent-replacement-old"); let displaced_for_callback = displaced_directory.clone(); let replacement_path = paths.state_database().to_path_buf(); - let error = run_ready(initialize_database( + let error = initialize_database( &paths, OpenMode::Initialize, + &metadata, move |_| async move { fs::rename(&state_directory, &displaced_for_callback)?; fs::create_dir(&state_directory)?; @@ -750,7 +867,8 @@ mod supported { fs::set_permissions(&replacement_path, fs::Permissions::from_mode(0o600))?; Ok::<(), io::Error>(()) }, - )) + ) + .await .expect_err("canonical path replacement must fail"); assert_eq!(error.kind(), ServiceSqliteErrorKind::Create); @@ -758,8 +876,8 @@ mod supported { assert!(!displaced_directory.join("state.sqlite").exists()); } - #[test] - fn injected_sync_and_cleanup_failures_preserve_exact_ordering() { + #[tokio::test(flavor = "current_thread")] + async fn injected_sync_and_cleanup_failures_preserve_exact_ordering() { let scenarios = [ "reservation-sync", "database-sync", @@ -770,6 +888,7 @@ mod supported { let root = tempfile::tempdir().expect("root"); let paths = paths(root.path(), scenario); prepare(&paths); + let metadata = metadata(&paths); let authority = WriterAuthority::acquire(&paths, OpenMode::Initialize) .unwrap() .unwrap(); @@ -786,19 +905,23 @@ mod supported { _ => unreachable!(), } let result = if scenario == "cleanup" { - run_ready(initialize_with_ops( + initialize_with_ops( &paths, authority, + &metadata, |_| ready(Err::<(), _>(CallbackFailure)), &operations, - )) + ) + .await } else { - run_ready(initialize_with_ops( + initialize_with_ops( &paths, authority, + &metadata, |_| ready(Ok::<(), CallbackFailure>(())), &operations, - )) + ) + .await }; assert_eq!( result.expect_err("injected failure").kind(), diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs @@ -6,6 +6,7 @@ mod authority; mod config; mod error; mod initialize; +mod metadata; mod open; mod status; @@ -15,5 +16,9 @@ pub use error::{ SafeServiceSqliteError, ServiceSqliteError, ServiceSqliteErrorCode, ServiceSqliteErrorKind, }; pub use initialize::initialize_database; +pub use metadata::{ + ServiceDatabaseIdentity, ServiceDatabaseMetadata, ServiceSqliteApplicationId, + ServiceSqliteMetadataValueError, +}; pub use open::{OpenMode, ServiceSqlitePathError, ServiceSqlitePaths}; pub use status::{StorageHealth, StorageIntegrity, StorageStatus}; diff --git a/crates/service_sqlite/src/metadata.rs b/crates/service_sqlite/src/metadata.rs @@ -0,0 +1,796 @@ +//! Immutable database identity metadata for one service instance. + +use core::{fmt, num::NonZeroU32}; +use std::error::Error; + +use radroots_runtime_paths::{InstanceId, ServiceId}; +use radroots_storage::event::SourceGeneration; + +use crate::ServiceSqlitePaths; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use crate::{ServiceSqliteError, ServiceSqliteErrorKind}; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use sqlx::{Connection, Row, SqliteConnection}; + +const MAX_APPLICATION_ID: u32 = i32::MAX as u32; +const MAX_CREATED_AT_UNIX_MS: u64 = i64::MAX as u64; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +const CREATE_METADATA_SQL: &str = r#" +CREATE TABLE radroots_service_metadata ( + singleton INTEGER NOT NULL PRIMARY KEY CHECK (singleton = 1), + service_id TEXT NOT NULL, + instance_id TEXT NOT NULL, + source_generation BLOB NOT NULL CHECK (length(source_generation) = 32), + state_schema_version INTEGER NOT NULL + CHECK (state_schema_version BETWEEN 1 AND 4294967295), + created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0) +) STRICT; +CREATE TRIGGER radroots_service_metadata_guard_update +BEFORE UPDATE ON radroots_service_metadata +WHEN NEW.singleton != OLD.singleton + OR NEW.service_id != OLD.service_id + OR NEW.instance_id != OLD.instance_id + OR NEW.source_generation != OLD.source_generation + OR NEW.created_at_unix_ms != OLD.created_at_unix_ms + OR NEW.state_schema_version <= OLD.state_schema_version +BEGIN + SELECT RAISE(ABORT, 'service metadata identity is immutable'); +END; +CREATE TRIGGER radroots_service_metadata_no_delete +BEFORE DELETE ON radroots_service_metadata +BEGIN + SELECT RAISE(ABORT, 'service metadata is immutable'); +END; +"#; + +/// A validated nonzero SQLite application identifier. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ServiceSqliteApplicationId(u32); + +impl ServiceSqliteApplicationId { + /// Validates a caller-owned application identifier for SQLite's signed range. + pub const fn new(value: u32) -> Result<Self, ServiceSqliteMetadataValueError> { + if value == 0 || value > MAX_APPLICATION_ID { + return Err(ServiceSqliteMetadataValueError::InvalidApplicationId); + } + Ok(Self(value)) + } + + /// Returns the validated application identifier. + #[must_use] + pub const fn get(self) -> u32 { + self.0 + } +} + +/// Invalid caller-supplied database metadata. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ServiceSqliteMetadataValueError { + InvalidApplicationId, + InvalidCreationTime, +} + +impl fmt::Display for ServiceSqliteMetadataValueError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidApplicationId => "SQLite application ID is out of range", + Self::InvalidCreationTime => "SQLite creation time is out of range", + }) + } +} + +impl Error for ServiceSqliteMetadataValueError {} + +/// Exact immutable identity expected from one service database. +#[derive(Clone, PartialEq, Eq)] +pub struct ServiceDatabaseMetadata { + service: ServiceId, + instance: InstanceId, + source_generation: SourceGeneration, + state_schema_version: NonZeroU32, + created_at_unix_ms: u64, + application_id: ServiceSqliteApplicationId, +} + +/// Exact mount identity and maximum schema version accepted by one service binary. +#[derive(Clone, PartialEq, Eq)] +pub struct ServiceDatabaseIdentity { + service: ServiceId, + instance: InstanceId, + source_generation: SourceGeneration, + supported_state_schema_version: NonZeroU32, + application_id: ServiceSqliteApplicationId, +} + +impl ServiceDatabaseMetadata { + /// Constructs metadata bound to the service and instance in canonical paths. + pub fn new( + paths: &ServiceSqlitePaths, + source_generation: SourceGeneration, + state_schema_version: NonZeroU32, + created_at_unix_ms: u64, + application_id: ServiceSqliteApplicationId, + ) -> Result<Self, ServiceSqliteMetadataValueError> { + if created_at_unix_ms == 0 || created_at_unix_ms > MAX_CREATED_AT_UNIX_MS { + return Err(ServiceSqliteMetadataValueError::InvalidCreationTime); + } + Ok(Self { + service: paths.service().clone(), + instance: paths.instance().clone(), + source_generation, + state_schema_version, + created_at_unix_ms, + 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 opaque nonzero source generation. + #[must_use] + pub const fn source_generation(&self) -> SourceGeneration { + self.source_generation + } + + /// Returns the expected nonzero state schema version. + #[must_use] + pub const fn state_schema_version(&self) -> NonZeroU32 { + self.state_schema_version + } + + /// Returns the injected positive creation time in Unix milliseconds. + #[must_use] + pub const fn created_at_unix_ms(&self) -> u64 { + self.created_at_unix_ms + } + + /// Returns the caller-owned SQLite application identifier. + #[must_use] + pub const fn application_id(&self) -> ServiceSqliteApplicationId { + self.application_id + } + + /// Returns the reopen identity derived from this initialization record. + #[must_use] + pub fn identity(&self) -> ServiceDatabaseIdentity { + ServiceDatabaseIdentity { + service: self.service.clone(), + instance: self.instance.clone(), + source_generation: self.source_generation, + supported_state_schema_version: self.state_schema_version, + application_id: self.application_id, + } + } + + pub(crate) fn matches_paths(&self, paths: &ServiceSqlitePaths) -> bool { + self.service == *paths.service() && self.instance == *paths.instance() + } +} + +impl ServiceDatabaseIdentity { + /// Constructs a reopen expectation bound to canonical service-instance paths. + #[must_use] + pub fn new( + paths: &ServiceSqlitePaths, + source_generation: SourceGeneration, + supported_state_schema_version: NonZeroU32, + application_id: ServiceSqliteApplicationId, + ) -> Self { + Self { + service: paths.service().clone(), + instance: paths.instance().clone(), + source_generation, + 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 expected opaque source generation. + #[must_use] + pub const fn source_generation(&self) -> SourceGeneration { + self.source_generation + } + + /// 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 + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) fn matches_paths(&self, paths: &ServiceSqlitePaths) -> bool { + self.service == *paths.service() && self.instance == *paths.instance() + } +} + +impl fmt::Debug for ServiceDatabaseIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ServiceDatabaseIdentity") + .field("service", &"[redacted]") + .field("instance", &"[redacted]") + .field("source_generation", &"[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 + .debug_struct("ServiceDatabaseMetadata") + .field("service", &"[redacted]") + .field("instance", &"[redacted]") + .field("source_generation", &"[redacted]") + .field("state_schema_version", &self.state_schema_version) + .field("created_at_unix_ms", &self.created_at_unix_ms) + .field("application_id", &self.application_id) + .finish() + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MetadataFailureKind { + AlreadyPresent, + Missing, + Corrupt, + Mismatch, + Storage, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[derive(Debug)] +struct MetadataFailure(MetadataFailureKind); + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl fmt::Display for MetadataFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.0 { + MetadataFailureKind::AlreadyPresent => "SQLite metadata already exists", + MetadataFailureKind::Missing => "SQLite metadata is missing", + MetadataFailureKind::Corrupt => "SQLite metadata is corrupt", + MetadataFailureKind::Mismatch => "SQLite metadata identity does not match", + MetadataFailureKind::Storage => "SQLite metadata could not be accessed", + }) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Error for MetadataFailure {} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn metadata_error(kind: MetadataFailureKind) -> ServiceSqliteError { + ServiceSqliteError::with_source(ServiceSqliteErrorKind::Metadata, MetadataFailure(kind)) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) async fn write_database_metadata( + connection: &mut SqliteConnection, + expected: &ServiceDatabaseMetadata, +) -> Result<(), ServiceSqliteError> { + if read_application_id(connection).await? != 0 { + return Err(metadata_error(MetadataFailureKind::AlreadyPresent)); + } + + let mut transaction = connection + .begin() + .await + .map_err(|_| metadata_error(MetadataFailureKind::Storage))?; + sqlx::raw_sql(CREATE_METADATA_SQL) + .execute(&mut *transaction) + .await + .map_err(|_| metadata_error(MetadataFailureKind::AlreadyPresent))?; + sqlx::query( + "INSERT INTO radroots_service_metadata ( + singleton, service_id, instance_id, source_generation, + state_schema_version, created_at_unix_ms + ) VALUES (1, ?, ?, ?, ?, ?)", + ) + .bind(expected.service().as_str()) + .bind(expected.instance().as_str()) + .bind(expected.source_generation().as_bytes().as_slice()) + .bind(i64::from(expected.state_schema_version().get())) + .bind( + i64::try_from(expected.created_at_unix_ms()) + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?, + ) + .execute(&mut *transaction) + .await + .map_err(|_| metadata_error(MetadataFailureKind::Storage))?; + let set_application_id = format!( + "PRAGMA application_id = {}", + expected.application_id().get() + ); + // The only dynamic token is a validated decimal u31 value. + sqlx::query(sqlx::AssertSqlSafe(set_application_id.as_str())) + .execute(&mut *transaction) + .await + .map_err(|_| metadata_error(MetadataFailureKind::Storage))?; + transaction + .commit() + .await + .map_err(|_| metadata_error(MetadataFailureKind::Storage))?; + + let actual = read_database_metadata(connection).await?; + if actual != *expected { + return Err(metadata_error(MetadataFailureKind::Mismatch)); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) async fn verify_database_metadata( + connection: &mut SqliteConnection, + expected: &ServiceDatabaseIdentity, +) -> Result<ServiceDatabaseMetadata, ServiceSqliteError> { + let actual = read_database_metadata(connection).await?; + if actual.service != expected.service + || actual.instance != expected.instance + || actual.source_generation != expected.source_generation + || actual.application_id != expected.application_id + || actual.state_schema_version > expected.supported_state_schema_version + { + return Err(metadata_error(MetadataFailureKind::Mismatch)); + } + Ok(actual) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +async fn read_database_metadata( + connection: &mut SqliteConnection, +) -> Result<ServiceDatabaseMetadata, ServiceSqliteError> { + let application_id = read_application_id(connection).await?; + let application_id = ServiceSqliteApplicationId::new( + u32::try_from(application_id).map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?, + ) + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?; + let rows = sqlx::query( + "SELECT + singleton, service_id, instance_id, source_generation, + state_schema_version, created_at_unix_ms, + typeof(singleton) AS singleton_type, + typeof(service_id) AS service_id_type, + typeof(instance_id) AS instance_id_type, + typeof(source_generation) AS source_generation_type, + typeof(state_schema_version) AS state_schema_version_type, + typeof(created_at_unix_ms) AS created_at_unix_ms_type + FROM radroots_service_metadata + ORDER BY singleton + LIMIT 2", + ) + .fetch_all(&mut *connection) + .await + .map_err(|_| metadata_error(MetadataFailureKind::Missing))?; + let [row] = rows.as_slice() else { + return Err(metadata_error(if rows.is_empty() { + MetadataFailureKind::Missing + } else { + MetadataFailureKind::Corrupt + })); + }; + for (column, expected_type) in [ + ("singleton_type", "integer"), + ("service_id_type", "text"), + ("instance_id_type", "text"), + ("source_generation_type", "blob"), + ("state_schema_version_type", "integer"), + ("created_at_unix_ms_type", "integer"), + ] { + if row + .try_get::<String, _>(column) + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))? + != expected_type + { + return Err(metadata_error(MetadataFailureKind::Corrupt)); + } + } + if row + .try_get::<i64, _>("singleton") + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))? + != 1 + { + return Err(metadata_error(MetadataFailureKind::Corrupt)); + } + let service = ServiceId::new( + row.try_get::<String, _>("service_id") + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?, + ) + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?; + let instance = InstanceId::new( + row.try_get::<String, _>("instance_id") + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?, + ) + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?; + let source_generation = SourceGeneration::new( + row.try_get::<Vec<u8>, _>("source_generation") + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))? + .try_into() + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?, + ) + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?; + let state_schema_version = NonZeroU32::new( + u32::try_from( + row.try_get::<i64, _>("state_schema_version") + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?, + ) + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?, + ) + .ok_or_else(|| metadata_error(MetadataFailureKind::Corrupt))?; + let created_at_unix_ms = u64::try_from( + row.try_get::<i64, _>("created_at_unix_ms") + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?, + ) + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?; + if created_at_unix_ms == 0 { + return Err(metadata_error(MetadataFailureKind::Corrupt)); + } + + Ok(ServiceDatabaseMetadata { + service, + instance, + source_generation, + state_schema_version, + created_at_unix_ms, + application_id, + }) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +async fn read_application_id(connection: &mut SqliteConnection) -> Result<i64, ServiceSqliteError> { + sqlx::query_scalar::<_, i64>("PRAGMA application_id") + .fetch_one(connection) + .await + .map_err(|_| metadata_error(MetadataFailureKind::Storage)) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use radroots_runtime_paths::{ + RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, + RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, + }; + + use super::*; + + fn sqlite_paths(service: &str, instance: &str) -> ServiceSqlitePaths { + let context = RuntimeContext::resolve( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some(PathBuf::from("/isolated/service-metadata")), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::BootstrapCli, + ) + .expect("bootstrap"), + ServiceId::new(service).expect("service"), + InstanceId::new(instance).expect("instance"), + ) + .expect("runtime context"); + ServiceSqlitePaths::from_runtime_context(&context).expect("SQLite paths") + } + + fn metadata( + paths: &ServiceSqlitePaths, + generation_byte: u8, + schema_version: u32, + creation_time: u64, + application_id: u32, + ) -> ServiceDatabaseMetadata { + ServiceDatabaseMetadata::new( + paths, + SourceGeneration::new([generation_byte; 32]).expect("source generation"), + NonZeroU32::new(schema_version).expect("schema version"), + creation_time, + ServiceSqliteApplicationId::new(application_id).expect("application ID"), + ) + .expect("database metadata") + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + async fn memory_connection() -> SqliteConnection { + SqliteConnection::connect("sqlite::memory:") + .await + .expect("memory SQLite") + } + + #[test] + fn application_id_and_creation_time_bounds_are_exact() { + assert_eq!( + ServiceSqliteApplicationId::new(0), + Err(ServiceSqliteMetadataValueError::InvalidApplicationId) + ); + assert_eq!( + ServiceSqliteApplicationId::new(MAX_APPLICATION_ID + 1), + Err(ServiceSqliteMetadataValueError::InvalidApplicationId) + ); + assert_eq!( + ServiceSqliteApplicationId::new(MAX_APPLICATION_ID) + .expect("maximum application ID") + .get(), + MAX_APPLICATION_ID + ); + + let paths = sqlite_paths("myc", "primary"); + let generation = SourceGeneration::new([7; 32]).expect("source generation"); + let schema = NonZeroU32::new(1).expect("schema version"); + let application = ServiceSqliteApplicationId::new(1).expect("application ID"); + assert_eq!( + ServiceDatabaseMetadata::new(&paths, generation, schema, 0, application), + Err(ServiceSqliteMetadataValueError::InvalidCreationTime) + ); + assert_eq!( + ServiceDatabaseMetadata::new( + &paths, + generation, + schema, + MAX_CREATED_AT_UNIX_MS + 1, + application, + ), + Err(ServiceSqliteMetadataValueError::InvalidCreationTime) + ); + assert!( + ServiceDatabaseMetadata::new( + &paths, + generation, + schema, + MAX_CREATED_AT_UNIX_MS, + application, + ) + .is_ok() + ); + assert!(SourceGeneration::new([0; 32]).is_err()); + assert!(NonZeroU32::new(0).is_none()); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[tokio::test(flavor = "current_thread")] + async fn fresh_metadata_write_read_and_immutability_are_exact() { + 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) + .await + .expect("write metadata"); + verify_database_metadata(&mut connection, &expected.identity()) + .await + .expect("verify metadata"); + let actual = read_database_metadata(&mut connection) + .await + .expect("read metadata"); + assert_eq!(actual, expected); + assert_eq!(actual.service().as_str(), "myc"); + assert_eq!(actual.instance().as_str(), "primary"); + assert_eq!(actual.source_generation().as_bytes(), &[7; 32]); + assert_eq!(actual.state_schema_version().get(), 1); + assert_eq!(actual.created_at_unix_ms(), 1_700_000_000_000); + assert_eq!(actual.application_id().get(), 0x5244_5351); + assert_eq!( + read_application_id(&mut connection).await.unwrap(), + 0x5244_5351 + ); + + sqlx::query( + "UPDATE radroots_service_metadata SET state_schema_version = 2 WHERE singleton = 1", + ) + .execute(&mut connection) + .await + .expect("monotonic schema advance"); + assert_eq!( + read_database_metadata(&mut connection) + .await + .expect("advanced metadata") + .state_schema_version() + .get(), + 2 + ); + for statement in [ + "UPDATE radroots_service_metadata SET service_id = 'rhi' WHERE singleton = 1", + "UPDATE radroots_service_metadata SET state_schema_version = 2 WHERE singleton = 1", + "UPDATE radroots_service_metadata SET state_schema_version = 1 WHERE singleton = 1", + "DELETE FROM radroots_service_metadata WHERE singleton = 1", + "INSERT INTO radroots_service_metadata VALUES (2, 'rhi', 'default', zeroblob(32), 1, 1)", + ] { + assert!( + sqlx::query(statement) + .execute(&mut connection) + .await + .is_err(), + "immutable metadata accepted `{statement}`" + ); + } + assert_eq!( + write_database_metadata(&mut connection, &expected) + .await + .expect_err("second write must fail") + .kind(), + ServiceSqliteErrorKind::Metadata + ); + + let debug = format!("{actual:?}"); + for sensitive in ["myc", "primary", "07070707"] { + assert!(!debug.contains(sensitive)); + } + assert!(debug.contains("source_generation: \"[redacted]\"")); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[tokio::test(flavor = "current_thread")] + async fn every_identity_dimension_must_match() { + 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) + .await + .expect("write metadata"); + + let alternatives = [ + ServiceDatabaseIdentity::new( + &sqlite_paths("rhi", "primary"), + SourceGeneration::new([7; 32]).unwrap(), + NonZeroU32::new(1).unwrap(), + ServiceSqliteApplicationId::new(0x5244_5351).unwrap(), + ), + ServiceDatabaseIdentity::new( + &sqlite_paths("myc", "secondary"), + SourceGeneration::new([7; 32]).unwrap(), + NonZeroU32::new(1).unwrap(), + ServiceSqliteApplicationId::new(0x5244_5351).unwrap(), + ), + ServiceDatabaseIdentity::new( + &paths, + SourceGeneration::new([8; 32]).unwrap(), + NonZeroU32::new(1).unwrap(), + ServiceSqliteApplicationId::new(0x5244_5351).unwrap(), + ), + ServiceDatabaseIdentity::new( + &paths, + SourceGeneration::new([7; 32]).unwrap(), + NonZeroU32::new(1).unwrap(), + ServiceSqliteApplicationId::new(0x5244_5352).unwrap(), + ), + ]; + for alternative in alternatives { + assert_eq!( + verify_database_metadata(&mut connection, &alternative) + .await + .expect_err("identity mismatch") + .kind(), + ServiceSqliteErrorKind::Metadata + ); + } + + let newer_binary = ServiceDatabaseIdentity::new( + &paths, + expected.source_generation(), + NonZeroU32::new(2).unwrap(), + expected.application_id(), + ); + let stored = verify_database_metadata(&mut connection, &newer_binary) + .await + .expect("older schema is migration eligible"); + assert_eq!(stored.created_at_unix_ms(), 1_700_000_000_000); + assert_eq!(stored.state_schema_version().get(), 1); + + let mut newer_state = memory_connection().await; + let version_two = metadata(&paths, 7, 2, 1_700_000_000_001, 0x5244_5351); + write_database_metadata(&mut newer_state, &version_two) + .await + .expect("write newer metadata"); + assert_eq!( + verify_database_metadata(&mut newer_state, &expected.identity()) + .await + .expect_err("newer state must fail closed") + .kind(), + ServiceSqliteErrorKind::Metadata + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[tokio::test(flavor = "current_thread")] + async fn missing_duplicate_and_corrupt_metadata_fail_closed() { + const PERMISSIVE_TABLE: &str = "CREATE TABLE radroots_service_metadata ( + singleton, service_id, instance_id, source_generation, + state_schema_version, created_at_unix_ms + )"; + let paths = sqlite_paths("myc", "primary"); + let expected = metadata(&paths, 7, 1, 1_700_000_000_000, 0x5244_5351); + + let mut missing_table = memory_connection().await; + assert_eq!( + verify_database_metadata(&mut missing_table, &expected.identity()) + .await + .expect_err("missing table") + .kind(), + ServiceSqliteErrorKind::Metadata + ); + + let corrupt_rows = [ + "", + "INSERT INTO radroots_service_metadata VALUES + (1, 'myc', 'primary', randomblob(32), 1, 1700000000000), + (2, 'myc', 'primary', randomblob(32), 1, 1700000000000)", + "INSERT INTO radroots_service_metadata VALUES + (1, NULL, 'primary', randomblob(32), 1, 1700000000000)", + "INSERT INTO radroots_service_metadata VALUES + (1, 'Myc', 'primary', randomblob(32), 1, 1700000000000)", + "INSERT INTO radroots_service_metadata VALUES + (1, 'myc', 'Primary', randomblob(32), 1, 1700000000000)", + "INSERT INTO radroots_service_metadata VALUES + (1, 'myc', 'primary', zeroblob(31), 1, 1700000000000)", + "INSERT INTO radroots_service_metadata VALUES + (1, 'myc', 'primary', zeroblob(32), 1, 1700000000000)", + "INSERT INTO radroots_service_metadata VALUES + (1, 'myc', 'primary', randomblob(33), 1, 1700000000000)", + "INSERT INTO radroots_service_metadata VALUES + (1, 'myc', 'primary', randomblob(32), 0, 1700000000000)", + "INSERT INTO radroots_service_metadata VALUES + (1, 'myc', 'primary', randomblob(32), 1, 0)", + "INSERT INTO radroots_service_metadata VALUES + ('1', 'myc', 'primary', randomblob(32), 1, 1700000000000)", + ]; + for corrupt_row in corrupt_rows { + let mut connection = memory_connection().await; + sqlx::raw_sql(PERMISSIVE_TABLE) + .execute(&mut connection) + .await + .expect("permissive metadata table"); + sqlx::query("PRAGMA application_id = 1380209489") + .execute(&mut connection) + .await + .expect("application ID"); + if !corrupt_row.is_empty() { + sqlx::raw_sql(corrupt_row) + .execute(&mut connection) + .await + .expect("corrupt metadata row"); + } + assert_eq!( + verify_database_metadata(&mut connection, &expected.identity()) + .await + .expect_err("corrupt metadata") + .kind(), + ServiceSqliteErrorKind::Metadata, + "accepted corrupt fixture `{corrupt_row}`" + ); + } + } +} diff --git a/crates/service_sqlite/src/open.rs b/crates/service_sqlite/src/open.rs @@ -30,7 +30,8 @@ use sqlx::{ #[cfg(any(target_os = "linux", target_os = "macos"))] use crate::{ - ServiceSqliteConnectionOptions, ServiceSqliteError, ServiceSqliteErrorKind, WriterAuthority, + ServiceDatabaseIdentity, ServiceSqliteConnectionOptions, ServiceSqliteError, + ServiceSqliteErrorKind, WriterAuthority, }; #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -191,6 +192,7 @@ struct PrivateConnectionPool { authority: Option<WriterAuthority>, inspection_guard: Option<ReadOnlyInspectionGuard>, authority_failure: Arc<AtomicBool>, + metadata_failure: Arc<AtomicBool>, pragma_failure: Arc<AtomicBool>, } @@ -207,6 +209,8 @@ impl PrivateConnectionPool { result.map_err(|source| { let kind = if self.authority_failure.load(Ordering::Acquire) { ServiceSqliteErrorKind::Authority + } else if self.metadata_failure.load(Ordering::Acquire) { + ServiceSqliteErrorKind::Metadata } else if self.pragma_failure.load(Ordering::Acquire) { ServiceSqliteErrorKind::Pragma } else { @@ -230,6 +234,7 @@ impl PrivateConnectionPool { )] async fn open_existing_connection_pool( paths: &ServiceSqlitePaths, + identity: &ServiceDatabaseIdentity, mode: OpenMode, policy: ServiceSqliteConnectionOptions, ) -> Result<PrivateConnectionPool, ServiceSqliteError> { @@ -244,7 +249,7 @@ async fn open_existing_connection_pool( OpenMode::ReadOnlyInspection => (None, Some(ReadOnlyInspectionGuard::acquire(paths)?)), OpenMode::Initialize => unreachable!("initialize mode returned above"), }; - open_connection_pool(paths, mode, policy, authority, inspection_guard).await + open_connection_pool(paths, identity, mode, policy, authority, inspection_guard).await } #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -254,11 +259,20 @@ async fn open_existing_connection_pool( )] async fn open_initialized_connection_pool( paths: &ServiceSqlitePaths, + identity: &ServiceDatabaseIdentity, policy: ServiceSqliteConnectionOptions, authority: WriterAuthority, ) -> Result<PrivateConnectionPool, ServiceSqliteError> { authority.validate_for(paths)?; - open_connection_pool(paths, OpenMode::Initialize, policy, Some(authority), None).await + open_connection_pool( + paths, + identity, + OpenMode::Initialize, + policy, + Some(authority), + None, + ) + .await } #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -268,11 +282,15 @@ async fn open_initialized_connection_pool( )] async fn open_connection_pool( paths: &ServiceSqlitePaths, + identity: &ServiceDatabaseIdentity, mode: OpenMode, policy: ServiceSqliteConnectionOptions, authority: Option<WriterAuthority>, inspection_guard: Option<ReadOnlyInspectionGuard>, ) -> Result<PrivateConnectionPool, ServiceSqliteError> { + if !identity.matches_paths(paths) { + return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata)); + } let binding = match (mode, authority.as_ref(), inspection_guard.as_ref()) { (OpenMode::Initialize | OpenMode::ReadWriteExisting, Some(authority), None) => { authority.validate_for(paths)?; @@ -298,6 +316,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; + binding.validate(paths)?; + preflight_metadata?; let preflight_close = preflight.close().await; binding.validate(paths)?; preflight_close.map_err(|source| connection_source(ServiceSqliteErrorKind::Open, source))?; @@ -312,11 +334,16 @@ async fn open_connection_pool( let pool_binding = binding; let after_paths = paths.clone(); let before_paths = paths.clone(); + let after_metadata = identity.clone(); + let before_metadata = identity.clone(); let authority_failure = Arc::new(AtomicBool::new(false)); + let metadata_failure = Arc::new(AtomicBool::new(false)); let pragma_failure = Arc::new(AtomicBool::new(false)); let after_authority_failure = Arc::clone(&authority_failure); + let after_metadata_failure = Arc::clone(&metadata_failure); let after_pragma_failure = Arc::clone(&pragma_failure); let before_authority_failure = Arc::clone(&authority_failure); + let before_metadata_failure = Arc::clone(&metadata_failure); let before_pragma_failure = Arc::clone(&pragma_failure); let pool_result = SqlitePoolOptions::new() .min_connections(1) @@ -328,7 +355,9 @@ async fn open_connection_pool( .after_connect(move |connection, _metadata| { let binding = after_binding.clone(); let paths = after_paths.clone(); + let metadata = after_metadata.clone(); let authority_failure = Arc::clone(&after_authority_failure); + let metadata_failure = Arc::clone(&after_metadata_failure); let pragma_failure = Arc::clone(&after_pragma_failure); Box::pin(async move { if binding.validate(&paths).is_err() { @@ -354,13 +383,29 @@ async fn open_connection_pool( "SQLite connection policy mismatch".to_owned(), )); } + let metadata_result = + crate::metadata::verify_database_metadata(connection, &metadata).await; + if binding.validate(&paths).is_err() { + authority_failure.store(true, Ordering::Release); + return Err(sqlx::Error::Protocol( + "SQLite connection authority mismatch".to_owned(), + )); + } + if metadata_result.is_err() { + metadata_failure.store(true, Ordering::Release); + return Err(sqlx::Error::Protocol( + "SQLite connection metadata mismatch".to_owned(), + )); + } Ok(()) }) }) .before_acquire(move |connection, _metadata| { let binding = before_binding.clone(); let paths = before_paths.clone(); + let metadata = before_metadata.clone(); let authority_failure = Arc::clone(&before_authority_failure); + let metadata_failure = Arc::clone(&before_metadata_failure); let pragma_failure = Arc::clone(&before_pragma_failure); Box::pin(async move { if binding.validate(&paths).is_err() { @@ -384,6 +429,20 @@ async fn open_connection_pool( pragma_failure.store(true, Ordering::Release); return Ok(false); } + let metadata_result = + crate::metadata::verify_database_metadata(connection, &metadata).await; + if binding.validate(&paths).is_err() { + authority_failure.store(true, Ordering::Release); + return Err(sqlx::Error::Protocol( + "SQLite connection authority mismatch".to_owned(), + )); + } + if metadata_result.is_err() { + metadata_failure.store(true, Ordering::Release); + return Err(sqlx::Error::Protocol( + "SQLite connection metadata mismatch".to_owned(), + )); + } Ok(true) }) }) @@ -393,6 +452,8 @@ async fn open_connection_pool( let pool = pool_result.map_err(|source| { let kind = if authority_failure.load(Ordering::Acquire) { ServiceSqliteErrorKind::Authority + } else if metadata_failure.load(Ordering::Acquire) { + ServiceSqliteErrorKind::Metadata } else if pragma_failure.load(Ordering::Acquire) { ServiceSqliteErrorKind::Pragma } else { @@ -408,6 +469,7 @@ async fn open_connection_pool( authority, inspection_guard, authority_failure, + metadata_failure, pragma_failure, }) } @@ -913,7 +975,7 @@ impl DirectoryBinding { #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::{num::NonZeroU32, path::PathBuf}; #[cfg(any(target_os = "linux", target_os = "macos"))] use std::{ @@ -928,6 +990,9 @@ mod tests { RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, RuntimeContextBootstrap, RuntimeContextSource, }; + use radroots_storage::event::SourceGeneration; + + use crate::{ServiceDatabaseMetadata, ServiceSqliteApplicationId}; use super::*; @@ -960,10 +1025,22 @@ mod tests { } #[cfg(any(target_os = "linux", target_os = "macos"))] + fn database_metadata(paths: &ServiceSqlitePaths) -> ServiceDatabaseMetadata { + ServiceDatabaseMetadata::new( + paths, + SourceGeneration::new([7; 32]).expect("source generation"), + NonZeroU32::new(1).expect("schema version"), + 1_700_000_000_000, + ServiceSqliteApplicationId::new(0x5244_5351).expect("application ID"), + ) + .expect("database metadata") + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] async fn initialized_authority( root: &Path, instance: &str, - ) -> (ServiceSqlitePaths, WriterAuthority) { + ) -> (ServiceSqlitePaths, ServiceDatabaseIdentity, WriterAuthority) { let paths = ServiceSqlitePaths::from_runtime_context(&runtime_context( RadrootsPathProfile::RepoLocal, Some(root.to_path_buf()), @@ -973,8 +1050,12 @@ mod tests { .expect("SQLite paths"); fs::create_dir_all(paths.state_database().parent().expect("state directory")) .expect("create state directory"); - let authority = - crate::initialize_database(&paths, OpenMode::Initialize, |database_path| async move { + let metadata = database_metadata(&paths); + let authority = crate::initialize_database( + &paths, + OpenMode::Initialize, + &metadata, + |database_path| async move { let options = SqliteConnectOptions::new() .filename(database_path) .create_if_missing(false); @@ -983,10 +1064,12 @@ mod tests { .expect("open reserved database"); connection.close().await.expect("close reserved database"); Ok::<_, Infallible>(()) - }) - .await - .expect("initialize database"); - (paths, authority) + }, + ) + .await + .expect("initialize database"); + let identity = metadata.identity(); + (paths, identity, authority) } #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -994,8 +1077,8 @@ mod tests { root: &Path, policy: ServiceSqliteConnectionOptions, ) -> (ServiceSqlitePaths, PrivateConnectionPool) { - let (paths, authority) = initialized_authority(root, "primary").await; - let pool = open_initialized_connection_pool(&paths, policy, authority) + let (paths, identity, authority) = initialized_authority(root, "primary").await; + let pool = open_initialized_connection_pool(&paths, &identity, policy, authority) .await .expect("open initialized pool"); (paths, pool) @@ -1196,6 +1279,42 @@ mod tests { #[cfg(any(target_os = "linux", target_os = "macos"))] #[tokio::test(flavor = "current_thread")] + async fn metadata_mismatch_fails_open_and_checkout_before_use() { + let directory = tempfile::tempdir().expect("temporary directory"); + let policy = ServiceSqliteConnectionOptions::new(Duration::from_millis(500), 1).unwrap(); + let (paths, pool) = initialized_pool(directory.path(), policy).await; + + let mut connection = pool.acquire().await.expect("pooled connection"); + sqlx::query("PRAGMA application_id = 1380209490") + .execute(&mut *connection) + .await + .expect("drift application ID"); + drop(connection); + let error = pool + .acquire() + .await + .expect_err("metadata drift must prevent checkout"); + assert_eq!(error.kind(), ServiceSqliteErrorKind::Metadata); + let authority = pool.close().await.expect("writer authority retained"); + drop(authority); + + let wrong = ServiceDatabaseIdentity::new( + &paths, + SourceGeneration::new([8; 32]).expect("wrong generation"), + NonZeroU32::new(1).expect("schema version"), + ServiceSqliteApplicationId::new(0x5244_5351).expect("application ID"), + ); + let result = + open_existing_connection_pool(&paths, &wrong, OpenMode::ReadWriteExisting, policy) + .await; + let Err(error) = result else { + panic!("wrong generation must fail open"); + }; + assert_eq!(error.kind(), ServiceSqliteErrorKind::Metadata); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[tokio::test(flavor = "current_thread")] async fn existing_modes_never_create_missing_state_and_enforce_authority() { let directory = tempfile::tempdir().expect("temporary directory"); let paths = ServiceSqlitePaths::from_runtime_context(&runtime_context( @@ -1207,10 +1326,12 @@ mod tests { .expect("SQLite paths"); fs::create_dir_all(paths.state_database().parent().expect("state directory")) .expect("create state directory"); + let metadata = database_metadata(&paths).identity(); for mode in [OpenMode::ReadWriteExisting, OpenMode::ReadOnlyInspection] { let result = open_existing_connection_pool( &paths, + &metadata, mode, ServiceSqliteConnectionOptions::reviewed(), ) @@ -1223,6 +1344,7 @@ mod tests { } let result = open_existing_connection_pool( &paths, + &metadata, OpenMode::Initialize, ServiceSqliteConnectionOptions::reviewed(), ) @@ -1237,7 +1359,8 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn initialized_pool_rejects_mismatched_paths_and_rebound_directory() { let directory = tempfile::tempdir().expect("temporary directory"); - let (_paths, authority) = initialized_authority(directory.path(), "primary").await; + let (_paths, metadata, authority) = + initialized_authority(directory.path(), "primary").await; let other = ServiceSqlitePaths::from_runtime_context(&runtime_context( RadrootsPathProfile::RepoLocal, Some(directory.path().to_path_buf()), @@ -1254,6 +1377,7 @@ mod tests { .expect("create other state directory"); let result = open_initialized_connection_pool( &other, + &metadata, ServiceSqliteConnectionOptions::reviewed(), authority, ) @@ -1263,7 +1387,7 @@ mod tests { }; assert_eq!(error.kind(), ServiceSqliteErrorKind::Authority); - let (paths, authority) = initialized_authority(directory.path(), "rebound").await; + let (paths, metadata, authority) = initialized_authority(directory.path(), "rebound").await; let state_directory = paths.state_database().parent().expect("state directory"); let displaced = directory.path().join("displaced-state"); fs::rename(state_directory, &displaced).expect("displace state directory"); @@ -1272,6 +1396,7 @@ mod tests { .expect("copy replacement database"); let result = open_initialized_connection_pool( &paths, + &metadata, ServiceSqliteConnectionOptions::reviewed(), authority, ) @@ -1313,7 +1438,7 @@ mod tests { let directory = tempfile::tempdir().expect("temporary directory"); let policy = ServiceSqliteConnectionOptions::reviewed(); - let (symlink_paths, symlink_authority) = + let (symlink_paths, symlink_metadata, symlink_authority) = initialized_authority(directory.path(), "symlink-database").await; drop(symlink_authority); let symlink_backing = symlink_paths @@ -1324,15 +1449,19 @@ mod tests { fs::rename(symlink_paths.state_database(), &symlink_backing) .expect("displace symlink database"); symlink(&symlink_backing, symlink_paths.state_database()).expect("database symlink"); - let symlink_result = - open_existing_connection_pool(&symlink_paths, OpenMode::ReadWriteExisting, policy) - .await; + let symlink_result = open_existing_connection_pool( + &symlink_paths, + &symlink_metadata, + OpenMode::ReadWriteExisting, + policy, + ) + .await; let Err(symlink_error) = symlink_result else { panic!("database symlink must fail"); }; assert_eq!(symlink_error.kind(), ServiceSqliteErrorKind::Authority); - let (hardlink_paths, hardlink_authority) = + let (hardlink_paths, hardlink_metadata, hardlink_authority) = initialized_authority(directory.path(), "hardlink-database").await; drop(hardlink_authority); let hardlink_alias = hardlink_paths @@ -1341,9 +1470,13 @@ mod tests { .expect("state directory") .join("alias.sqlite"); fs::hard_link(hardlink_paths.state_database(), hardlink_alias).expect("database hard link"); - let hardlink_result = - open_existing_connection_pool(&hardlink_paths, OpenMode::ReadWriteExisting, policy) - .await; + let hardlink_result = open_existing_connection_pool( + &hardlink_paths, + &hardlink_metadata, + OpenMode::ReadWriteExisting, + policy, + ) + .await; let Err(hardlink_error) = hardlink_result else { panic!("database hard link must fail"); }; @@ -1384,6 +1517,7 @@ mod tests { let directory = tempfile::tempdir().expect("temporary directory"); let policy = ServiceSqliteConnectionOptions::reviewed(); let (paths, writable) = initialized_pool(directory.path(), policy).await; + let metadata = database_metadata(&paths).identity(); let mut connection = writable.acquire().await.expect("writable connection"); sqlx::query("CREATE TABLE inspection_fixture (value INTEGER NOT NULL)") .execute(&mut *connection) @@ -1396,7 +1530,8 @@ mod tests { drop(connection); let contended = - open_existing_connection_pool(&paths, OpenMode::ReadOnlyInspection, policy).await; + open_existing_connection_pool(&paths, &metadata, OpenMode::ReadOnlyInspection, policy) + .await; let Err(error) = contended else { panic!("inspection must reject an active writer"); }; @@ -1408,7 +1543,8 @@ mod tests { let stale_wal = state_directory.join(WAL_FILE_NAME); fs::write(&stale_wal, b"stale-wal-evidence").expect("write stale WAL evidence"); let stale = - open_existing_connection_pool(&paths, OpenMode::ReadOnlyInspection, policy).await; + open_existing_connection_pool(&paths, &metadata, OpenMode::ReadOnlyInspection, policy) + .await; let Err(error) = stale else { panic!("inspection must reject stale WAL state"); }; @@ -1417,9 +1553,10 @@ mod tests { fs::remove_file(stale_wal).expect("remove test WAL evidence"); let before = directory_snapshot(state_directory); - let read_only = open_existing_connection_pool(&paths, OpenMode::ReadOnlyInspection, policy) - .await - .expect("offline read-only inspection"); + let read_only = + open_existing_connection_pool(&paths, &metadata, OpenMode::ReadOnlyInspection, policy) + .await + .expect("offline read-only inspection"); let mut connection = read_only.acquire().await.expect("inspection connection"); assert_eq!( sqlx::query_scalar::<_, i64>("SELECT value FROM inspection_fixture") @@ -1473,6 +1610,7 @@ mod tests { let directory = tempfile::tempdir().expect("temporary directory"); let policy = ServiceSqliteConnectionOptions::new(Duration::from_millis(500), 1).unwrap(); let (paths, pool) = initialized_pool(directory.path(), policy).await; + let metadata = database_metadata(&paths).identity(); let observer = pool.pool.clone(); let held = pool.acquire().await.expect("only connection"); let saturated = pool.pool.try_acquire(); @@ -1488,6 +1626,7 @@ mod tests { let read_only = open_existing_connection_pool( &paths, + &metadata, OpenMode::ReadOnlyInspection, ServiceSqliteConnectionOptions::reviewed(), ) diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs @@ -6,6 +6,7 @@ const AUTHORITY_SOURCE: &str = include_str!("../src/authority.rs"); const CONFIG_SOURCE: &str = include_str!("../src/config.rs"); const ERROR_SOURCE: &str = include_str!("../src/error.rs"); const INITIALIZE_SOURCE: &str = include_str!("../src/initialize.rs"); +const METADATA_SOURCE: &str = include_str!("../src/metadata.rs"); const OPEN_SOURCE: &str = include_str!("../src/open.rs"); const STATUS_SOURCE: &str = include_str!("../src/status.rs"); @@ -25,7 +26,14 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { assert_eq!( dependency_keys(MANIFEST, "[dependencies]"), - BTreeSet::from(["fs2", "radroots_runtime_paths", "rustix", "serde", "sqlx"]) + BTreeSet::from([ + "fs2", + "radroots_runtime_paths", + "radroots_storage", + "rustix", + "serde", + "sqlx" + ]) ); assert_eq!( dependency_keys(MANIFEST, "[dev-dependencies]"), @@ -38,6 +46,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "config", "error", "initialize", + "metadata", "open", "status" ]) @@ -61,6 +70,10 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "ServiceSqliteConnectionOptions", "ServiceSqliteConnectionOptionsError", "initialize_database", + "ServiceDatabaseIdentity", + "ServiceDatabaseMetadata", + "ServiceSqliteApplicationId", + "ServiceSqliteMetadataValueError", "ServiceSqlitePathError", "ServiceSqlitePaths", "OpenMode", @@ -92,6 +105,44 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { } for required in [ + "radroots_service_metadata", + "PRAGMA application_id", + "source_generation BLOB", + "state_schema_version INTEGER", + "created_at_unix_ms INTEGER", + "radroots_service_metadata_guard_update", + "radroots_service_metadata_no_delete", + "LIMIT 2", + "SourceGeneration", + "NonZeroU32", + "pub(crate) async fn write_database_metadata", + "pub(crate) async fn verify_database_metadata", + ] { + assert!( + METADATA_SOURCE.contains(required), + "Step 057 metadata source is missing `{required}`" + ); + } + + for forbidden in [ + "pub use sqlx", + "pub fn write_database_metadata", + "pub async fn write_database_metadata", + "pub fn verify_database_metadata", + "pub async fn verify_database_metadata", + "myc_", + "rhi_", + "SystemTime::now", + "getrandom", + "tokio::runtime", + ] { + assert!( + !METADATA_SOURCE.contains(forbidden), + "Step 057 metadata source contains forbidden surface `{forbidden}`" + ); + } + + for required in [ "journal_mode(SqliteJournalMode::Wal)", "synchronous(SqliteSynchronous::Full)", ".foreign_keys(true)", @@ -162,7 +213,6 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "OpenOptions::new", "remove_file", "tokio", - "sqlx", "rusqlite", "Command::new", "std::process",