lib

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

commit 62ef402ac1af945b2daf1deccd1c87a1ce455282
parent ec25f669a14ca0f6957d4094af249af9c1ca0fc1
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 11:13:03 +0000

service-sqlite: initialize create-new state

- reserve the canonical database beneath retained writer authority
- durably synchronize success and roll back exact failed reservations
- reject mode, metadata, inode, callback-path, and replacement drift
- cover cancellation, failpoint ordering, redaction, and platform fencing

Diffstat:
Mcrates/service_sqlite/src/authority.rs | 13++++++++++++-
Acrates/service_sqlite/src/initialize.rs | 846+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_sqlite/src/lib.rs | 2++
Mcrates/service_sqlite/tests/package_boundary.rs | 42+++++++++++++++++++++++++++++++++++++++++-
4 files changed, 901 insertions(+), 2 deletions(-)

diff --git a/crates/service_sqlite/src/authority.rs b/crates/service_sqlite/src/authority.rs @@ -20,6 +20,8 @@ use crate::{OpenMode, ServiceSqliteError, ServiceSqliteErrorKind, ServiceSqliteP /// ``` pub struct WriterAuthority { file: Option<File>, + #[cfg(any(target_os = "linux", target_os = "macos"))] + directory: File, } impl WriterAuthority { @@ -43,6 +45,11 @@ impl WriterAuthority { self.file.is_some() } + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) fn directory(&self) -> &File { + &self.directory + } + /// Explicitly releases writer authority; subsequent calls are no-ops. pub fn release(&mut self) -> Result<(), ServiceSqliteError> { let Some(file) = self.file.as_ref() else { @@ -176,8 +183,12 @@ fn acquire_supported(paths: &ServiceSqlitePaths) -> Result<WriterAuthority, Writ .map_err(|_| WriterAuthorityCause::LockUnavailable)?; let file = File::from(descriptor); + let directory = File::from(directory); match FileExt::try_lock_exclusive(&file) { - Ok(()) => Ok(WriterAuthority { file: Some(file) }), + Ok(()) => Ok(WriterAuthority { + file: Some(file), + directory, + }), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { Err(WriterAuthorityCause::Contended) } diff --git a/crates/service_sqlite/src/initialize.rs b/crates/service_sqlite/src/initialize.rs @@ -0,0 +1,846 @@ +//! Create-new initialization for one service-owned SQLite database. + +use core::{fmt, future::Future}; +use std::{error::Error, path::PathBuf}; + +use crate::{ + OpenMode, 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. +pub async fn initialize_database<F, Fut, E>( + paths: &ServiceSqlitePaths, + mode: OpenMode, + initialize_schema: F, +) -> Result<WriterAuthority, ServiceSqliteError> +where + F: FnOnce(PathBuf) -> Fut, + Fut: Future<Output = Result<(), E>>, + E: Error + Send + Sync + 'static, +{ + if mode != OpenMode::Initialize { + return Err(initialization_error(InitializationCause::new( + InitializationFailureKind::UnsupportedMode, + ))); + } + + let authority = WriterAuthority::acquire(paths, mode)?.ok_or_else(|| { + initialization_error(InitializationCause::new( + InitializationFailureKind::UnsupportedMode, + )) + })?; + + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + initialize_with_ops( + paths, + authority, + initialize_schema, + &SystemInitializationOperations, + ) + .await + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + drop((authority, initialize_schema)); + Err(initialization_error(InitializationCause::new( + InitializationFailureKind::CreateUnavailable, + ))) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum InitializationFailureKind { + UnsupportedMode, + #[cfg(any(target_os = "linux", target_os = "macos"))] + StateAlreadyExists, + CreateUnavailable, + #[cfg(any(target_os = "linux", target_os = "macos"))] + InvalidDatabase, + #[cfg(any(target_os = "linux", target_os = "macos"))] + SchemaInitializationFailed, + #[cfg(any(target_os = "linux", target_os = "macos"))] + DatabaseSyncFailed, + #[cfg(any(target_os = "linux", target_os = "macos"))] + DatabaseReplaced, + #[cfg(any(target_os = "linux", target_os = "macos"))] + DirectorySyncFailed, + #[cfg(any(target_os = "linux", target_os = "macos"))] + CleanupFailed, +} + +struct InitializationCause { + kind: InitializationFailureKind, + source: Option<Box<dyn Error + Send + Sync + 'static>>, +} + +impl InitializationCause { + const fn new(kind: InitializationFailureKind) -> Self { + Self { kind, source: None } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn with_source( + kind: InitializationFailureKind, + source: impl Error + Send + Sync + 'static, + ) -> Self { + Self { + kind, + source: Some(Box::new(source)), + } + } +} + +impl fmt::Debug for InitializationCause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("InitializationCause") + .field("kind", &self.kind) + .field("source", &self.source.as_ref().map(|_| "[redacted]")) + .finish() + } +} + +impl fmt::Display for InitializationCause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + InitializationFailureKind::UnsupportedMode => { + "SQLite initialization requires initialize mode" + } + #[cfg(any(target_os = "linux", target_os = "macos"))] + InitializationFailureKind::StateAlreadyExists => "SQLite state already exists", + InitializationFailureKind::CreateUnavailable => "SQLite state could not be reserved", + #[cfg(any(target_os = "linux", target_os = "macos"))] + InitializationFailureKind::InvalidDatabase => "SQLite state file has invalid metadata", + #[cfg(any(target_os = "linux", target_os = "macos"))] + InitializationFailureKind::SchemaInitializationFailed => { + "SQLite schema initialization failed" + } + #[cfg(any(target_os = "linux", target_os = "macos"))] + InitializationFailureKind::DatabaseSyncFailed => { + "SQLite state could not be synchronized" + } + #[cfg(any(target_os = "linux", target_os = "macos"))] + InitializationFailureKind::DatabaseReplaced => { + "SQLite state identity changed during initialization" + } + #[cfg(any(target_os = "linux", target_os = "macos"))] + InitializationFailureKind::DirectorySyncFailed => { + "SQLite state directory could not be synchronized" + } + #[cfg(any(target_os = "linux", target_os = "macos"))] + InitializationFailureKind::CleanupFailed => "SQLite initialization cleanup failed", + }) + } +} + +impl Error for InitializationCause { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source + .as_deref() + .map(|source| source as &(dyn Error + 'static)) + } +} + +fn initialization_error(cause: InitializationCause) -> ServiceSqliteError { + ServiceSqliteError::with_source(ServiceSqliteErrorKind::Create, cause) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod supported { + use std::fs::File; + + use rustix::{ + fs::{AtFlags, FileType, Mode, OFlags, fchmod, fstat, lstat, openat, statat, unlinkat}, + process::geteuid, + }; + + use super::*; + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct FileIdentity { + device: u64, + inode: u64, + } + + pub(super) trait InitializationOperations { + fn sync_database(&self, database: &File) -> Result<(), InitializationCause>; + fn sync_directory(&self, directory: &File) -> Result<(), InitializationCause>; + fn unlink_database(&self, directory: &File) -> Result<(), InitializationCause>; + } + + pub(super) struct SystemInitializationOperations; + + impl InitializationOperations for SystemInitializationOperations { + fn sync_database(&self, database: &File) -> Result<(), InitializationCause> { + database.sync_all().map_err(|_| { + InitializationCause::new(InitializationFailureKind::DatabaseSyncFailed) + }) + } + + fn sync_directory(&self, directory: &File) -> Result<(), InitializationCause> { + directory.sync_all().map_err(|_| { + InitializationCause::new(InitializationFailureKind::DirectorySyncFailed) + }) + } + + fn unlink_database(&self, directory: &File) -> Result<(), InitializationCause> { + unlinkat( + directory, + radroots_runtime_paths::SERVICE_STATE_DATABASE_FILE_NAME, + AtFlags::empty(), + ) + .map_err(|_| InitializationCause::new(InitializationFailureKind::CleanupFailed)) + } + } + + struct PendingDatabase<'a, O: InitializationOperations> { + directory: &'a File, + database: File, + identity: FileIdentity, + operations: &'a O, + committed: bool, + } + + impl<'a, O: InitializationOperations> PendingDatabase<'a, O> { + fn create(directory: &'a File, operations: &'a O) -> Result<Self, InitializationCause> { + let descriptor = openat( + directory, + radroots_runtime_paths::SERVICE_STATE_DATABASE_FILE_NAME, + OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::RUSR | Mode::WUSR, + ) + .map_err(|error| { + if error == rustix::io::Errno::EXIST { + InitializationCause::new(InitializationFailureKind::StateAlreadyExists) + } else { + InitializationCause::new(InitializationFailureKind::CreateUnavailable) + } + })?; + let database = File::from(descriptor); + let identity = descriptor_identity(&database)?; + let pending = Self { + directory, + database, + identity, + operations, + committed: false, + }; + fchmod(&pending.database, Mode::RUSR | Mode::WUSR).map_err(|_| { + InitializationCause::new(InitializationFailureKind::InvalidDatabase) + })?; + pending.validate()?; + Ok(pending) + } + + fn validate(&self) -> Result<(), InitializationCause> { + let descriptor_identity = validate_descriptor(&self.database)?; + if descriptor_identity != self.identity { + return Err(InitializationCause::new( + InitializationFailureKind::InvalidDatabase, + )); + } + self.validate_entry() + } + + fn validate_entry(&self) -> Result<(), InitializationCause> { + let status = statat( + self.directory, + radroots_runtime_paths::SERVICE_STATE_DATABASE_FILE_NAME, + AtFlags::SYMLINK_NOFOLLOW, + ) + .map_err(|_| InitializationCause::new(InitializationFailureKind::DatabaseReplaced))?; + let device = u64::try_from(status.st_dev).map_err(|_| { + InitializationCause::new(InitializationFailureKind::InvalidDatabase) + })?; + let current = validate_status( + FileType::from_raw_mode(status.st_mode).is_file(), + u64::from(status.st_nlink), + status.st_uid, + u32::from(status.st_mode), + device, + status.st_ino, + )?; + if current != self.identity { + return Err(InitializationCause::new( + InitializationFailureKind::DatabaseReplaced, + )); + } + Ok(()) + } + + fn current_entry_identity(&self) -> Result<FileIdentity, InitializationCause> { + let status = statat( + self.directory, + radroots_runtime_paths::SERVICE_STATE_DATABASE_FILE_NAME, + AtFlags::SYMLINK_NOFOLLOW, + ) + .map_err(|_| InitializationCause::new(InitializationFailureKind::DatabaseReplaced))?; + let device = u64::try_from(status.st_dev).map_err(|_| { + InitializationCause::new(InitializationFailureKind::DatabaseReplaced) + })?; + Ok(FileIdentity { + device, + inode: status.st_ino, + }) + } + + fn validate_canonical_path( + &self, + path: &std::path::Path, + ) -> Result<(), InitializationCause> { + let status = lstat(path).map_err(|_| { + InitializationCause::new(InitializationFailureKind::DatabaseReplaced) + })?; + let device = u64::try_from(status.st_dev).map_err(|_| { + InitializationCause::new(InitializationFailureKind::InvalidDatabase) + })?; + let current = validate_status( + FileType::from_raw_mode(status.st_mode).is_file(), + u64::from(status.st_nlink), + status.st_uid, + u32::from(status.st_mode), + device, + status.st_ino, + )?; + if current != self.identity { + return Err(InitializationCause::new( + InitializationFailureKind::DatabaseReplaced, + )); + } + Ok(()) + } + + fn commit(&mut self, canonical_path: &std::path::Path) -> Result<(), InitializationCause> { + self.operations.sync_database(&self.database)?; + self.validate()?; + self.validate_canonical_path(canonical_path)?; + self.operations.sync_directory(self.directory)?; + self.committed = true; + Ok(()) + } + + fn rollback(&mut self) -> Result<(), InitializationCause> { + if self.committed { + return Ok(()); + } + if self.current_entry_identity()? != self.identity { + return Err(InitializationCause::new( + InitializationFailureKind::DatabaseReplaced, + )); + } + self.operations.unlink_database(self.directory)?; + self.operations.sync_directory(self.directory)?; + self.committed = true; + Ok(()) + } + } + + impl<O: InitializationOperations> Drop for PendingDatabase<'_, O> { + fn drop(&mut self) { + let _ = self.rollback(); + } + } + + fn validate_descriptor( + descriptor: &impl std::os::fd::AsFd, + ) -> Result<FileIdentity, InitializationCause> { + let status = fstat(descriptor) + .map_err(|_| InitializationCause::new(InitializationFailureKind::InvalidDatabase))?; + let device = u64::try_from(status.st_dev) + .map_err(|_| InitializationCause::new(InitializationFailureKind::InvalidDatabase))?; + validate_status( + FileType::from_raw_mode(status.st_mode).is_file(), + u64::from(status.st_nlink), + status.st_uid, + u32::from(status.st_mode), + device, + status.st_ino, + ) + } + + fn descriptor_identity( + descriptor: &impl std::os::fd::AsFd, + ) -> Result<FileIdentity, InitializationCause> { + let status = fstat(descriptor) + .map_err(|_| InitializationCause::new(InitializationFailureKind::InvalidDatabase))?; + let device = u64::try_from(status.st_dev) + .map_err(|_| InitializationCause::new(InitializationFailureKind::InvalidDatabase))?; + Ok(FileIdentity { + device, + inode: status.st_ino, + }) + } + + fn validate_status( + is_regular_file: bool, + link_count: u64, + actual_uid: u32, + mode: u32, + device: u64, + inode: u64, + ) -> Result<FileIdentity, InitializationCause> { + if !is_regular_file + || link_count != 1 + || actual_uid != geteuid().as_raw() + || mode & 0o777 != 0o600 + { + return Err(InitializationCause::new( + InitializationFailureKind::InvalidDatabase, + )); + } + Ok(FileIdentity { device, inode }) + } + + async fn fail_with_rollback<O: InitializationOperations>( + mut pending: PendingDatabase<'_, O>, + primary: InitializationCause, + ) -> Result<WriterAuthority, ServiceSqliteError> { + match pending.rollback() { + Ok(()) => Err(initialization_error(primary)), + Err(_cleanup) if primary.kind == InitializationFailureKind::DatabaseReplaced => { + Err(initialization_error(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, + initialize_schema: F, + operations: &O, + ) -> Result<WriterAuthority, ServiceSqliteError> + where + F: FnOnce(PathBuf) -> Fut, + Fut: Future<Output = Result<(), E>>, + E: Error + Send + Sync + 'static, + O: InitializationOperations, + { + let mut pending = PendingDatabase::create(authority.directory(), operations) + .map_err(initialization_error)?; + if let Err(error) = operations.sync_directory(authority.directory()) { + 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; + } + let callback_result = initialize_schema(paths.state_database().to_path_buf()).await; + if let Err(error) = callback_result { + return fail_with_rollback( + pending, + InitializationCause::with_source( + InitializationFailureKind::SchemaInitializationFailed, + 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; + } + drop(pending); + Ok(authority) + } + + #[cfg(test)] + mod tests { + use std::{ + cell::{Cell, RefCell}, + fs::{self, OpenOptions}, + future::{Future, pending, ready}, + io::{self, Write}, + os::unix::fs::{MetadataExt, PermissionsExt, symlink}, + path::Path, + pin::Pin, + task::{Context, Poll, Waker}, + }; + + use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, + ServiceId, + }; + + use super::*; + + #[derive(Debug)] + struct CallbackFailure; + + impl fmt::Display for CallbackFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("secret callback path=/private/state.sqlite") + } + } + + impl Error for CallbackFailure {} + + #[derive(Default)] + struct RecordingOperations { + events: RefCell<Vec<&'static str>>, + fail_database_sync: Cell<bool>, + fail_directory_sync_on_call: Cell<Option<usize>>, + directory_sync_calls: Cell<usize>, + fail_unlink: Cell<bool>, + } + + impl InitializationOperations for RecordingOperations { + fn sync_database(&self, _database: &File) -> Result<(), InitializationCause> { + self.events.borrow_mut().push("sync_database"); + if self.fail_database_sync.replace(false) { + Err(InitializationCause::new( + InitializationFailureKind::DatabaseSyncFailed, + )) + } else { + Ok(()) + } + } + + fn sync_directory(&self, _directory: &File) -> Result<(), InitializationCause> { + self.events.borrow_mut().push("sync_directory"); + let call = self.directory_sync_calls.get() + 1; + self.directory_sync_calls.set(call); + if self.fail_directory_sync_on_call.get() == Some(call) { + Err(InitializationCause::new( + InitializationFailureKind::DirectorySyncFailed, + )) + } else { + Ok(()) + } + } + + fn unlink_database(&self, directory: &File) -> Result<(), InitializationCause> { + self.events.borrow_mut().push("unlink_database"); + if self.fail_unlink.get() { + return Err(InitializationCause::new( + InitializationFailureKind::CleanupFailed, + )); + } + SystemInitializationOperations.unlink_database(directory) + } + } + + fn poll_once<F: Future>(future: F) -> (Poll<F::Output>, Pin<Box<F>>) { + let mut future = Box::pin(future); + let mut context = Context::from_waker(Waker::noop()); + let result = future.as_mut().poll(&mut context); + (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( + RadrootsPlatform::Linux, + RadrootsHostEnvironment::default(), + ), + RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some(root.to_path_buf()), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::BootstrapCli, + ) + .expect("bootstrap"), + ServiceId::new("myc").expect("service"), + InstanceId::new(instance).expect("instance"), + ) + .expect("runtime context"); + ServiceSqlitePaths::from_runtime_context(&context).expect("SQLite paths") + } + + fn prepare(paths: &ServiceSqlitePaths) { + fs::create_dir_all(paths.state_database().parent().expect("state directory")) + .expect("create state directory"); + } + + #[test] + 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 { + 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); + assert!(authority.is_held()); + assert!(WriterAuthority::acquire(&paths, OpenMode::ReadWriteExisting).is_err()); + authority.release().expect("release"); + assert!( + WriterAuthority::acquire(&paths, OpenMode::ReadWriteExisting) + .expect("reacquire") + .is_some() + ); + } + + #[test] + 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); + prepare(&paths); + let target = root.path().join("existing-target"); + fs::write(&target, b"preserve-me").expect("target"); + match shape { + "regular" => fs::write(paths.state_database(), b"existing").unwrap(), + "symlink" => symlink(&target, paths.state_database()).unwrap(), + "directory" => fs::create_dir(paths.state_database()).unwrap(), + "hardlink" => fs::hard_link(&target, paths.state_database()).unwrap(), + _ => unreachable!(), + } + let called = Cell::new(false); + let error = run_ready(initialize_database(&paths, OpenMode::Initialize, |_| { + called.set(true); + ready(Ok::<(), CallbackFailure>(())) + })) + .expect_err("existing state must fail"); + assert_eq!(error.kind(), ServiceSqliteErrorKind::Create); + assert!(!called.get()); + assert_eq!(fs::read(&target).unwrap(), b"preserve-me"); + } + } + + #[test] + 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, |_| { + called.set(true); + ready(Ok::<(), CallbackFailure>(())) + })) + .expect_err("mode must reject"); + assert_eq!(error.kind(), ServiceSqliteErrorKind::Create); + assert!(!called.get()); + assert!(!paths.state_database().exists()); + assert!(!paths.state_lock().exists()); + assert!(!paths.state_database().parent().unwrap().exists()); + } + } + + #[test] + 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, |_| { + ready(Err::<(), _>(CallbackFailure)) + })) + .expect_err("callback failure"); + assert_eq!(error.kind(), ServiceSqliteErrorKind::Create); + assert!(!paths.state_database().exists()); + assert!( + WriterAuthority::acquire(&paths, OpenMode::Initialize) + .unwrap() + .is_some() + ); + + let display = error.to_string(); + let debug = format!("{error:?}"); + assert!(!display.contains("secret")); + assert!(!debug.contains("secret")); + let first = error.source().expect("initialization cause"); + assert_eq!(first.to_string(), "SQLite schema initialization failed"); + assert_eq!( + first.source().map(ToString::to_string).as_deref(), + Some("secret callback path=/private/state.sqlite") + ); + + let retry = run_ready(initialize_database(&paths, OpenMode::Initialize, |_| { + ready(Ok::<(), CallbackFailure>(())) + })) + .expect("retry after cleanup"); + assert!(retry.is_held()); + assert!(paths.state_database().exists()); + } + + #[test] + 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>>() + })); + assert!(poll.is_pending()); + assert!(paths.state_database().exists()); + assert!(WriterAuthority::acquire(&paths, OpenMode::Initialize).is_err()); + drop(future); + assert!(!paths.state_database().exists()); + assert!( + WriterAuthority::acquire(&paths, OpenMode::Initialize) + .unwrap() + .is_some() + ); + } + + #[test] + fn replacement_is_detected_and_never_deleted() { + let root = tempfile::tempdir().expect("root"); + let paths = paths(root.path(), "replacement"); + prepare(&paths); + let replacement_path = paths.state_database().to_path_buf(); + let error = run_ready(initialize_database( + &paths, + OpenMode::Initialize, + move |path| async move { + fs::remove_file(&path)?; + fs::write(&replacement_path, b"replacement")?; + Ok::<(), io::Error>(()) + }, + )) + .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() { + let root = tempfile::tempdir().expect("root"); + let paths = paths(root.path(), "parent-replacement"); + prepare(&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( + &paths, + OpenMode::Initialize, + move |_| async move { + fs::rename(&state_directory, &displaced_for_callback)?; + fs::create_dir(&state_directory)?; + fs::write(&replacement_path, b"replacement")?; + fs::set_permissions(&replacement_path, fs::Permissions::from_mode(0o600))?; + Ok::<(), io::Error>(()) + }, + )) + .expect_err("canonical path replacement must fail"); + + assert_eq!(error.kind(), ServiceSqliteErrorKind::Create); + assert_eq!(fs::read(paths.state_database()).unwrap(), b"replacement"); + assert!(!displaced_directory.join("state.sqlite").exists()); + } + + #[test] + fn injected_sync_and_cleanup_failures_preserve_exact_ordering() { + let scenarios = [ + "reservation-sync", + "database-sync", + "directory-sync", + "cleanup", + ]; + for scenario in scenarios { + let root = tempfile::tempdir().expect("root"); + let paths = paths(root.path(), scenario); + prepare(&paths); + let authority = WriterAuthority::acquire(&paths, OpenMode::Initialize) + .unwrap() + .unwrap(); + let operations = RecordingOperations::default(); + match scenario { + "reservation-sync" => { + operations.fail_directory_sync_on_call.set(Some(1)); + } + "database-sync" => operations.fail_database_sync.set(true), + "directory-sync" => { + operations.fail_directory_sync_on_call.set(Some(2)); + } + "cleanup" => operations.fail_unlink.set(true), + _ => unreachable!(), + } + let result = if scenario == "cleanup" { + run_ready(initialize_with_ops( + &paths, + authority, + |_| ready(Err::<(), _>(CallbackFailure)), + &operations, + )) + } else { + run_ready(initialize_with_ops( + &paths, + authority, + |_| ready(Ok::<(), CallbackFailure>(())), + &operations, + )) + }; + assert_eq!( + result.expect_err("injected failure").kind(), + ServiceSqliteErrorKind::Create + ); + let events = operations.events.borrow(); + match scenario { + "reservation-sync" => assert_eq!( + events.as_slice(), + ["sync_directory", "unlink_database", "sync_directory"] + ), + "database-sync" => assert_eq!( + events.as_slice(), + [ + "sync_directory", + "sync_database", + "unlink_database", + "sync_directory" + ] + ), + "directory-sync" => assert_eq!( + events.as_slice(), + [ + "sync_directory", + "sync_database", + "sync_directory", + "unlink_database", + "sync_directory" + ] + ), + "cleanup" => { + assert_eq!( + events.as_slice(), + ["sync_directory", "unlink_database", "unlink_database"] + ) + } + _ => unreachable!(), + } + } + } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use supported::{SystemInitializationOperations, initialize_with_ops}; diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs @@ -4,6 +4,7 @@ mod authority; mod error; +mod initialize; mod open; mod status; @@ -11,5 +12,6 @@ pub use authority::WriterAuthority; pub use error::{ SafeServiceSqliteError, ServiceSqliteError, ServiceSqliteErrorCode, ServiceSqliteErrorKind, }; +pub use initialize::initialize_database; pub use open::{OpenMode, ServiceSqlitePathError, ServiceSqlitePaths}; pub use status::{StorageHealth, StorageIntegrity, StorageStatus}; diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs @@ -4,6 +4,7 @@ const MANIFEST: &str = include_str!("../Cargo.toml"); const ROOT: &str = include_str!("../src/lib.rs"); const AUTHORITY_SOURCE: &str = include_str!("../src/authority.rs"); const ERROR_SOURCE: &str = include_str!("../src/error.rs"); +const INITIALIZE_SOURCE: &str = include_str!("../src/initialize.rs"); const OPEN_SOURCE: &str = include_str!("../src/open.rs"); const STATUS_SOURCE: &str = include_str!("../src/status.rs"); @@ -31,7 +32,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { ); assert_eq!( private_modules(ROOT), - BTreeSet::from(["authority", "error", "open", "status"]) + BTreeSet::from(["authority", "error", "initialize", "open", "status"]) ); assert!(public_modules(ROOT).is_empty()); let authority_production = AUTHORITY_SOURCE @@ -45,6 +46,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "SafeServiceSqliteError", "ServiceSqliteError", "WriterAuthority", + "initialize_database", "ServiceSqlitePathError", "ServiceSqlitePaths", "OpenMode", @@ -79,6 +81,44 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { } for required in [ + "OFlags::EXCL", + "OFlags::NOFOLLOW", + "OFlags::CLOEXEC", + "SERVICE_STATE_DATABASE_FILE_NAME", + "sync_database", + "sync_directory", + "validate_entry", + "unlink_database", + ] { + assert!( + INITIALIZE_SOURCE.contains(required), + "Step 055 initialization source is missing `{required}`" + ); + } + + for forbidden in [ + "create_dir", + "create_dir_all", + "OpenOptions::new", + "remove_file", + "tokio", + "sqlx", + "rusqlite", + "Command::new", + "std::process", + "pub fn directory", + ] { + let production = INITIALIZE_SOURCE + .split_once("#[cfg(test)]") + .map(|(source, _)| source) + .expect("initialization source must keep tests separated"); + assert!( + !production.contains(forbidden), + "Step 055 production source contains deferred or bypass surface `{forbidden}`" + ); + } + + for required in [ "fs2::FileExt", "rustix", "try_lock_exclusive",