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:
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",