lib

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

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:
MCargo.lock | 1+
Mcrates/storage_sqlite/Cargo.toml | 3+++
Mcrates/storage_sqlite/src/config.rs | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/storage_sqlite/src/lib.rs | 3+++
Mcrates/storage_sqlite/src/open.rs | 235++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Acrates/storage_sqlite/tests/open_options.rs | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 { .. }) + )); + } +}