commit 47842f1339562a92b8fdf2a97ad05e27a008cf37
parent 682b62c0650bc6e8478a98f41dce4c9bdfb155c1
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 16:53:46 +0000
storage-sqlite: define explicit SQLite open options and paths
- model read-only existing-writer and create lifecycle modes
- derive validated runtime and private database ownership paths
- fix foreign-key WAL and bounded busy-timeout invariants
- reject missing invalid and non-file paths before SQLite opens
Diffstat:
6 files changed, 413 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -5139,6 +5139,7 @@ dependencies = [
"radroots_event_codec",
"radroots_secrets",
"radroots_storage",
+ "tempfile",
]
[[package]]
diff --git a/crates/storage_sqlite/Cargo.toml b/crates/storage_sqlite/Cargo.toml
@@ -19,5 +19,8 @@ radroots_event_codec = { workspace = true, default-features = false }
radroots_secrets = { workspace = true, default-features = false }
radroots_storage = { workspace = true, default-features = false }
+[dev-dependencies]
+tempfile = { workspace = true }
+
[lints]
workspace = true
diff --git a/crates/storage_sqlite/src/config.rs b/crates/storage_sqlite/src/config.rs
@@ -1 +1,74 @@
-//! Validated SQLite configuration boundary.
+//! Validated SQLite connection configuration.
+
+use std::time::Duration;
+
+use crate::{Error, OpenMode, Paths};
+
+const DEFAULT_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
+const MIN_BUSY_TIMEOUT: Duration = Duration::from_millis(1);
+const MAX_BUSY_TIMEOUT: Duration = Duration::from_secs(60);
+
+/// Validated options for opening the runtime and private SQLite stores.
+///
+/// Foreign-key enforcement is always enabled. Writable stores always use WAL;
+/// neither invariant can be disabled through the public API.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct OpenOptions {
+ paths: Paths,
+ mode: OpenMode,
+ busy_timeout: Duration,
+}
+
+impl OpenOptions {
+ /// Creates options with the governed five-second busy timeout.
+ pub fn new(paths: Paths, mode: OpenMode) -> Self {
+ Self {
+ paths,
+ mode,
+ busy_timeout: DEFAULT_BUSY_TIMEOUT,
+ }
+ }
+
+ /// Replaces the busy timeout after enforcing the supported bound.
+ pub fn with_busy_timeout(mut self, busy_timeout: Duration) -> Result<Self, Error> {
+ if !(MIN_BUSY_TIMEOUT..=MAX_BUSY_TIMEOUT).contains(&busy_timeout) {
+ return Err(Error::InvalidBusyTimeout {
+ minimum: MIN_BUSY_TIMEOUT,
+ maximum: MAX_BUSY_TIMEOUT,
+ actual: busy_timeout,
+ });
+ }
+ self.busy_timeout = busy_timeout;
+ Ok(self)
+ }
+
+ /// Returns the two database paths owned by this backend instance.
+ pub fn paths(&self) -> &Paths {
+ &self.paths
+ }
+
+ /// Returns the requested lifecycle mode.
+ pub fn mode(&self) -> OpenMode {
+ self.mode
+ }
+
+ /// Returns the busy timeout applied to every owned connection.
+ pub fn busy_timeout(&self) -> Duration {
+ self.busy_timeout
+ }
+
+ /// Reports the non-configurable foreign-key policy.
+ pub fn foreign_keys_enabled(&self) -> bool {
+ true
+ }
+
+ /// Reports whether the mode requires WAL for writable connections.
+ pub fn wal_enabled(&self) -> bool {
+ self.mode.is_writable()
+ }
+
+ /// Validates current filesystem state without creating or modifying files.
+ pub fn validate_filesystem(&self) -> Result<(), Error> {
+ self.paths.validate_filesystem(self.mode)
+ }
+}
diff --git a/crates/storage_sqlite/src/lib.rs b/crates/storage_sqlite/src/lib.rs
@@ -7,3 +7,6 @@ pub mod lock;
pub mod migration;
pub mod open;
pub mod status;
+
+pub use config::OpenOptions;
+pub use open::{Error, OpenMode, Paths};
diff --git a/crates/storage_sqlite/src/open.rs b/crates/storage_sqlite/src/open.rs
@@ -1 +1,234 @@
-//! SQLite storage opening boundary.
+//! SQLite storage lifecycle modes and owned paths.
+
+use std::error::Error as StdError;
+use std::fmt;
+use std::path::{Component, Path, PathBuf};
+use std::time::Duration;
+
+const RUNTIME_DATABASE_NAME: &str = "runtime.sqlite";
+const PRIVATE_DATABASE_NAME: &str = "private.sqlite";
+
+/// Explicit behavior for opening owned SQLite files.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[non_exhaustive]
+pub enum OpenMode {
+ /// Open both existing files without permitting mutation or migration.
+ ReadOnly,
+ /// Open both existing files with write and migration authority.
+ ReadWriteExisting,
+ /// Open or create the two owned files with write and migration authority.
+ Create,
+}
+
+impl OpenMode {
+ /// Returns whether the mode may mutate owned database files.
+ pub fn is_writable(self) -> bool {
+ matches!(self, Self::ReadWriteExisting | Self::Create)
+ }
+
+ /// Returns whether missing owned files may be created.
+ pub fn may_create(self) -> bool {
+ matches!(self, Self::Create)
+ }
+}
+
+/// The complete SQLite file set owned by one Radroots storage backend.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Paths {
+ runtime: PathBuf,
+ private: PathBuf,
+}
+
+impl Paths {
+ /// Derives the governed file names from an absolute owner directory.
+ pub fn from_directory(directory: impl AsRef<Path>) -> Result<Self, Error> {
+ let directory = directory.as_ref();
+ validate_absolute_normal_path(directory)?;
+ Self::from_files(
+ directory.join(RUNTIME_DATABASE_NAME),
+ directory.join(PRIVATE_DATABASE_NAME),
+ )
+ }
+
+ /// Validates explicit runtime and private file paths.
+ pub fn from_files(
+ runtime: impl Into<PathBuf>,
+ private: impl Into<PathBuf>,
+ ) -> Result<Self, Error> {
+ let runtime = runtime.into();
+ let private = private.into();
+ validate_owned_file_path(&runtime, RUNTIME_DATABASE_NAME)?;
+ validate_owned_file_path(&private, PRIVATE_DATABASE_NAME)?;
+ if runtime == private {
+ return Err(Error::PathsOverlap(runtime));
+ }
+ Ok(Self { runtime, private })
+ }
+
+ /// Returns the canonical event, journal, outbox, and projection database.
+ pub fn runtime(&self) -> &Path {
+ &self.runtime
+ }
+
+ /// Returns the encrypted private-artifact database.
+ pub fn private(&self) -> &Path {
+ &self.private
+ }
+
+ pub(crate) fn validate_filesystem(&self, mode: OpenMode) -> Result<(), Error> {
+ for path in [&self.runtime, &self.private] {
+ validate_parent(path)?;
+ match std::fs::symlink_metadata(path) {
+ Ok(metadata) if metadata.file_type().is_symlink() => {
+ return Err(Error::SymlinkPath(path.clone()));
+ }
+ Ok(metadata) if !metadata.is_file() => {
+ return Err(Error::NotAFile(path.clone()));
+ }
+ Ok(_) => {}
+ Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
+ if !mode.may_create() {
+ return Err(Error::MissingFile(path.clone()));
+ }
+ }
+ Err(source) => {
+ return Err(Error::Inspect {
+ path: path.clone(),
+ source,
+ });
+ }
+ }
+ }
+ Ok(())
+ }
+}
+
+fn validate_owned_file_path(path: &Path, expected_name: &'static str) -> Result<(), Error> {
+ validate_absolute_normal_path(path)?;
+ if path.file_name().and_then(|name| name.to_str()) != Some(expected_name) {
+ return Err(Error::UnexpectedFileName {
+ path: path.to_path_buf(),
+ expected: expected_name,
+ });
+ }
+ Ok(())
+}
+
+fn validate_absolute_normal_path(path: &Path) -> Result<(), Error> {
+ if !path.is_absolute()
+ || path
+ .components()
+ .any(|part| matches!(part, Component::CurDir | Component::ParentDir))
+ {
+ return Err(Error::InvalidPath(path.to_path_buf()));
+ }
+ Ok(())
+}
+
+fn validate_parent(path: &Path) -> Result<(), Error> {
+ let parent = path
+ .parent()
+ .ok_or_else(|| Error::InvalidPath(path.to_path_buf()))?;
+ match std::fs::metadata(parent) {
+ Ok(metadata) if metadata.is_dir() => Ok(()),
+ Ok(_) => Err(Error::ParentNotDirectory(parent.to_path_buf())),
+ Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
+ Err(Error::MissingParent(parent.to_path_buf()))
+ }
+ Err(source) => Err(Error::Inspect {
+ path: parent.to_path_buf(),
+ source,
+ }),
+ }
+}
+
+/// Configuration or filesystem failure detected before SQLite is opened.
+#[derive(Debug)]
+#[non_exhaustive]
+pub enum Error {
+ InvalidPath(PathBuf),
+ UnexpectedFileName {
+ path: PathBuf,
+ expected: &'static str,
+ },
+ PathsOverlap(PathBuf),
+ MissingParent(PathBuf),
+ ParentNotDirectory(PathBuf),
+ MissingFile(PathBuf),
+ SymlinkPath(PathBuf),
+ NotAFile(PathBuf),
+ Inspect {
+ path: PathBuf,
+ source: std::io::Error,
+ },
+ InvalidBusyTimeout {
+ minimum: Duration,
+ maximum: Duration,
+ actual: Duration,
+ },
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidPath(path) => {
+ write!(formatter, "invalid owned SQLite path: {}", path.display())
+ }
+ Self::UnexpectedFileName { path, expected } => write!(
+ formatter,
+ "owned SQLite path {} must use file name {expected}",
+ path.display()
+ ),
+ Self::PathsOverlap(path) => {
+ write!(formatter, "owned SQLite paths overlap: {}", path.display())
+ }
+ Self::MissingParent(path) => write!(
+ formatter,
+ "owned SQLite parent is missing: {}",
+ path.display()
+ ),
+ Self::ParentNotDirectory(path) => write!(
+ formatter,
+ "owned SQLite parent is not a directory: {}",
+ path.display()
+ ),
+ Self::MissingFile(path) => write!(
+ formatter,
+ "owned SQLite file is missing: {}",
+ path.display()
+ ),
+ Self::SymlinkPath(path) => write!(
+ formatter,
+ "owned SQLite path cannot be a symlink: {}",
+ path.display()
+ ),
+ Self::NotAFile(path) => write!(
+ formatter,
+ "owned SQLite path is not a file: {}",
+ path.display()
+ ),
+ Self::Inspect { path, .. } => write!(
+ formatter,
+ "failed to inspect owned SQLite path: {}",
+ path.display()
+ ),
+ Self::InvalidBusyTimeout {
+ minimum,
+ maximum,
+ actual,
+ } => write!(
+ formatter,
+ "SQLite busy timeout {actual:?} must be within {minimum:?}..={maximum:?}"
+ ),
+ }
+ }
+}
+
+impl StdError for Error {
+ fn source(&self) -> Option<&(dyn StdError + 'static)> {
+ match self {
+ Self::Inspect { source, .. } => Some(source),
+ _ => None,
+ }
+ }
+}
diff --git a/crates/storage_sqlite/tests/open_options.rs b/crates/storage_sqlite/tests/open_options.rs
@@ -0,0 +1,98 @@
+use std::fs;
+use std::time::Duration;
+
+use radroots_storage_sqlite::{Error, OpenMode, OpenOptions, Paths};
+
+#[test]
+fn paths_derive_only_runtime_and_private_database_ownership() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let paths = Paths::from_directory(directory.path()).expect("owned paths");
+
+ assert_eq!(paths.runtime(), directory.path().join("runtime.sqlite"));
+ assert_eq!(paths.private(), directory.path().join("private.sqlite"));
+ assert!(!format!("{paths:?}").contains("studio.sqlite"));
+}
+
+#[test]
+fn paths_reject_relative_traversal_and_wrong_owned_names() {
+ assert!(matches!(
+ Paths::from_directory("relative"),
+ Err(Error::InvalidPath(_))
+ ));
+
+ let directory = tempfile::tempdir().expect("temporary directory");
+ assert!(matches!(
+ Paths::from_files(
+ directory.path().join("../runtime.sqlite"),
+ directory.path().join("private.sqlite"),
+ ),
+ Err(Error::InvalidPath(_))
+ ));
+ assert!(matches!(
+ Paths::from_files(
+ directory.path().join("studio.sqlite"),
+ directory.path().join("private.sqlite"),
+ ),
+ Err(Error::UnexpectedFileName { .. })
+ ));
+}
+
+#[test]
+fn create_mode_accepts_missing_files_but_existing_modes_do_not() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let paths = Paths::from_directory(directory.path()).expect("owned paths");
+
+ OpenOptions::new(paths.clone(), OpenMode::Create)
+ .validate_filesystem()
+ .expect("create plan");
+ assert!(matches!(
+ OpenOptions::new(paths.clone(), OpenMode::ReadOnly).validate_filesystem(),
+ Err(Error::MissingFile(_))
+ ));
+ assert!(matches!(
+ OpenOptions::new(paths, OpenMode::ReadWriteExisting).validate_filesystem(),
+ Err(Error::MissingFile(_))
+ ));
+}
+
+#[test]
+fn existing_modes_accept_only_regular_owned_files() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let paths = Paths::from_directory(directory.path()).expect("owned paths");
+ fs::write(paths.runtime(), []).expect("runtime file");
+ fs::write(paths.private(), []).expect("private file");
+
+ for mode in [OpenMode::ReadOnly, OpenMode::ReadWriteExisting] {
+ OpenOptions::new(paths.clone(), mode)
+ .validate_filesystem()
+ .expect("existing plan");
+ }
+}
+
+#[test]
+fn options_fix_connection_invariants_and_bound_busy_timeout() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let paths = Paths::from_directory(directory.path()).expect("owned paths");
+ let read_only = OpenOptions::new(paths.clone(), OpenMode::ReadOnly);
+ assert_eq!(read_only.busy_timeout(), Duration::from_secs(5));
+ assert!(read_only.foreign_keys_enabled());
+ assert!(!read_only.wal_enabled());
+ assert!(!read_only.mode().is_writable());
+ assert!(!read_only.mode().may_create());
+
+ let create = OpenOptions::new(paths, OpenMode::Create)
+ .with_busy_timeout(Duration::from_secs(30))
+ .expect("bounded timeout");
+ assert_eq!(create.busy_timeout(), Duration::from_secs(30));
+ assert!(create.foreign_keys_enabled());
+ assert!(create.wal_enabled());
+ assert!(create.mode().is_writable());
+ assert!(create.mode().may_create());
+
+ for invalid in [Duration::ZERO, Duration::from_secs(61)] {
+ assert!(matches!(
+ OpenOptions::new(create.paths().clone(), OpenMode::Create).with_busy_timeout(invalid),
+ Err(Error::InvalidBusyTimeout { .. })
+ ));
+ }
+}