commit 6a34786b3595371b45ae94f6c265bcaa720315cf
parent e70cd74b89597c9aa3532a12f2d4f137506d0be9
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 21:05:18 +0000
storage-sqlite: implement consistent backup capture
- require an explicit validated host-owned root for deterministic backup bundles
- capture WAL-consistent runtime and policy-selected private SQLite snapshots
- sync exact member bytes and derive versioned SHA-256 manifest inventories
- prove read-only capture, secret exclusion, collisions, paths, and lifecycle refusal
Diffstat:
6 files changed, 650 insertions(+), 18 deletions(-)
diff --git a/contracts/storage/backup_capture_policy_v1.toml b/contracts/storage/backup_capture_policy_v1.toml
@@ -0,0 +1,17 @@
+schema_version = 1
+format_version = 1
+backup_root = "explicit_existing_host_owned_absolute_utf8_directory"
+staging_name = ".radroots-backup-{backup_id_hex}.staging"
+final_name = "radroots-backup-{backup_id_hex}"
+capture = "sqlite_vacuum_into"
+runtime_member = "runtime/runtime.sqlite"
+protected_member = "private/private.sqlite"
+exclude_protected_members = ["runtime/runtime.sqlite"]
+include_protected_members = ["runtime/runtime.sqlite", "private/private.sqlite"]
+created_at = "plan_requested_at_unix_ms"
+member_digest = "sha256"
+member_length = "exact_bytes"
+filesystem_sync = ["member_file", "member_directory", "staging_directory", "backup_root"]
+existing_staging_or_final = "reject"
+hidden_clock = false
+unsafe_ffi = false
diff --git a/crates/storage_sqlite/README.md b/crates/storage_sqlite/README.md
@@ -13,6 +13,13 @@ Backend status and the last integrity result are passive. Hosts invoke
full SQLite and foreign-key validation across both owned files. `close` drains
both pools and releases writable authority explicitly and idempotently.
+Backup capture requires an explicit existing host-owned root configured with
+`OpenOptions::with_backup_root`. V1 capture creates a deterministic staging
+bundle, uses SQLite's online snapshot mechanism for each policy-selected
+member, synchronizes captured files and directories, and returns exact lengths
+and SHA-256 digests in the backend-neutral manifest. Protected storage is
+included only when the plan requests it explicitly.
+
```rust,no_run
use radroots_storage::event::SourceGeneration;
use radroots_storage_sqlite::{OpenMode, OpenOptions, Paths, SqliteStorage};
diff --git a/crates/storage_sqlite/src/backup.rs b/crates/storage_sqlite/src/backup.rs
@@ -1 +1,540 @@
-//! SQLite backup and restore implementation boundary.
+//! Consistent SQLite backup capture and bundle layout.
+
+use std::{
+ fs::{self, File},
+ io::Read,
+ path::{Component, Path, PathBuf},
+};
+
+use radroots_storage::backup::{
+ BackupFormatVersion, BackupManifest, BackupMember, BackupMemberKind, BackupPlan,
+ BackupSecretPolicy, MemberDigest,
+};
+use sha2::{Digest, Sha256};
+use sqlx::SqlitePool;
+
+use crate::{Error, SqliteStorage};
+
+const RUNTIME_DATABASE: &str = "runtime.sqlite";
+const PRIVATE_DATABASE: &str = "private.sqlite";
+const RUNTIME_MEMBER: &str = "runtime/runtime.sqlite";
+const PRIVATE_MEMBER: &str = "private/private.sqlite";
+
+impl SqliteStorage {
+ /// Captures consistent SQLite snapshots into a new deterministic staging
+ /// bundle under the configured host-owned backup root.
+ pub async fn capture_backup(&self, plan: &BackupPlan) -> Result<BackupManifest, Error> {
+ self.lifecycle
+ .require_open()
+ .map_err(|_| Error::BackupBackendUnavailable)?;
+ if plan.format_version() != BackupFormatVersion::V1 {
+ return Err(Error::UnsupportedBackupVersion);
+ }
+ let backup_root = self
+ .backup_root
+ .as_deref()
+ .ok_or(Error::BackupRootRequired)?;
+ validate_backup_root(backup_root)?;
+ let layout = BackupLayout::new(backup_root, plan);
+ layout.create(plan.secret_policy())?;
+
+ let mut members = Vec::with_capacity(
+ if plan.secret_policy() == BackupSecretPolicy::IncludeProtectedStorage {
+ 2
+ } else {
+ 1
+ },
+ );
+ members.push(
+ capture_member(
+ &self.pool,
+ &layout.runtime_file,
+ RUNTIME_MEMBER,
+ BackupMemberKind::Runtime,
+ )
+ .await?,
+ );
+ sync_directory(&layout.runtime_directory, "sync runtime member directory")?;
+
+ if plan.secret_policy() == BackupSecretPolicy::IncludeProtectedStorage {
+ members.push(
+ capture_member(
+ &self.private_pool,
+ &layout.private_file,
+ PRIVATE_MEMBER,
+ BackupMemberKind::Protected,
+ )
+ .await?,
+ );
+ sync_directory(&layout.private_directory, "sync private member directory")?;
+ }
+ sync_directory(&layout.staging, "sync staging bundle directory")?;
+ sync_directory(backup_root, "sync backup root")?;
+
+ BackupManifest::new(
+ plan.format_version(),
+ plan.backup_id(),
+ plan.requested_at_unix_ms(),
+ plan.secret_policy(),
+ members,
+ )
+ .map_err(|_| Error::BackupCaptureFailed { member: "manifest" })
+ }
+}
+
+pub(crate) fn validate_backup_root(path: &Path) -> Result<(), Error> {
+ if !path.is_absolute()
+ || path.to_str().is_none()
+ || path
+ .components()
+ .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
+ {
+ return Err(Error::InvalidBackupRoot(path.to_path_buf()));
+ }
+ match fs::symlink_metadata(path) {
+ Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
+ Err(Error::InvalidBackupRoot(path.to_path_buf()))
+ }
+ Ok(_) => Ok(()),
+ Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
+ Err(Error::InvalidBackupRoot(path.to_path_buf()))
+ }
+ Err(source) => Err(Error::BackupFilesystem {
+ operation: "inspect backup root",
+ source,
+ }),
+ }
+}
+
+struct BackupLayout {
+ staging: PathBuf,
+ finalized: PathBuf,
+ runtime_directory: PathBuf,
+ private_directory: PathBuf,
+ runtime_file: PathBuf,
+ private_file: PathBuf,
+}
+
+impl BackupLayout {
+ fn new(root: &Path, plan: &BackupPlan) -> Self {
+ let id = encode_backup_id(plan);
+ let staging = root.join(format!(".radroots-backup-{id}.staging"));
+ let finalized = root.join(format!("radroots-backup-{id}"));
+ let runtime_directory = staging.join("runtime");
+ let private_directory = staging.join("private");
+ let runtime_file = runtime_directory.join(RUNTIME_DATABASE);
+ let private_file = private_directory.join(PRIVATE_DATABASE);
+ Self {
+ staging,
+ finalized,
+ runtime_directory,
+ private_directory,
+ runtime_file,
+ private_file,
+ }
+ }
+
+ fn create(&self, secret_policy: BackupSecretPolicy) -> Result<(), Error> {
+ for path in [&self.staging, &self.finalized] {
+ if path
+ .try_exists()
+ .map_err(|source| Error::BackupFilesystem {
+ operation: "inspect bundle path",
+ source,
+ })?
+ {
+ return Err(Error::BackupBundleAlreadyExists(path.clone()));
+ }
+ }
+ create_private_directory(&self.staging, "create staging bundle")?;
+ create_private_directory(&self.runtime_directory, "create runtime member directory")?;
+ if secret_policy == BackupSecretPolicy::IncludeProtectedStorage {
+ create_private_directory(&self.private_directory, "create private member directory")?;
+ }
+ Ok(())
+ }
+}
+
+fn encode_backup_id(plan: &BackupPlan) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut encoded = String::with_capacity(32);
+ for byte in plan.backup_id().as_bytes() {
+ encoded.push(char::from(HEX[usize::from(byte >> 4)]));
+ encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
+ }
+ encoded
+}
+
+fn create_private_directory(path: &Path, operation: &'static str) -> Result<(), Error> {
+ let mut builder = fs::DirBuilder::new();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::DirBuilderExt;
+ builder.mode(0o700);
+ }
+ builder
+ .create(path)
+ .map_err(|source| Error::BackupFilesystem { operation, source })
+}
+
+async fn capture_member(
+ pool: &SqlitePool,
+ destination: &Path,
+ relative_path: &'static str,
+ kind: BackupMemberKind,
+) -> Result<BackupMember, Error> {
+ let destination = destination
+ .to_str()
+ .ok_or_else(|| Error::InvalidBackupRoot(destination.to_path_buf()))?;
+ let mut connection = pool
+ .acquire()
+ .await
+ .map_err(|_| Error::BackupBackendUnavailable)?;
+ sqlx::query("VACUUM INTO ?")
+ .bind(destination)
+ .execute(&mut *connection)
+ .await
+ .map_err(|_| Error::BackupCaptureFailed {
+ member: relative_path,
+ })?;
+ drop(connection);
+ member_from_file(Path::new(destination), relative_path, kind)
+}
+
+fn member_from_file(
+ path: &Path,
+ relative_path: &'static str,
+ kind: BackupMemberKind,
+) -> Result<BackupMember, Error> {
+ let mut file = File::open(path).map_err(|source| Error::BackupFilesystem {
+ operation: "open captured member",
+ source,
+ })?;
+ file.sync_all().map_err(|source| Error::BackupFilesystem {
+ operation: "sync captured member",
+ source,
+ })?;
+ let byte_length = file
+ .metadata()
+ .map_err(|source| Error::BackupFilesystem {
+ operation: "inspect captured member",
+ source,
+ })?
+ .len();
+ let mut sha256 = Sha256::new();
+ let mut buffer = [0_u8; 16 * 1_024];
+ loop {
+ let read = file
+ .read(&mut buffer)
+ .map_err(|source| Error::BackupFilesystem {
+ operation: "hash captured member",
+ source,
+ })?;
+ if read == 0 {
+ break;
+ }
+ sha256.update(&buffer[..read]);
+ }
+ BackupMember::new(
+ relative_path,
+ kind,
+ byte_length,
+ MemberDigest::new(sha256.finalize().into()),
+ )
+ .map_err(|_| Error::BackupCaptureFailed {
+ member: relative_path,
+ })
+}
+
+fn sync_directory(path: &Path, operation: &'static str) -> Result<(), Error> {
+ File::open(path)
+ .and_then(|directory| directory.sync_all())
+ .map_err(|source| Error::BackupFilesystem { operation, source })
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_storage::{
+ backup::{BackupFormatVersion, BackupId, BackupMemberKind, BackupPlan, BackupSecretPolicy},
+ event::SourceGeneration,
+ };
+ use serde::Deserialize;
+ use sqlx::{Connection, Row, SqliteConnection, sqlite::SqliteConnectOptions};
+
+ use crate::{OpenMode, OpenOptions, Paths};
+
+ use super::*;
+
+ const POLICY: &str = include_str!("../../../contracts/storage/backup_capture_policy_v1.toml");
+
+ #[derive(Deserialize)]
+ struct Policy {
+ schema_version: u32,
+ format_version: u16,
+ backup_root: String,
+ staging_name: String,
+ final_name: String,
+ capture: String,
+ runtime_member: String,
+ protected_member: String,
+ exclude_protected_members: Vec<String>,
+ include_protected_members: Vec<String>,
+ created_at: String,
+ member_digest: String,
+ member_length: String,
+ filesystem_sync: Vec<String>,
+ existing_staging_or_final: String,
+ hidden_clock: bool,
+ unsafe_ffi: bool,
+ }
+
+ fn generation(byte: u8) -> SourceGeneration {
+ SourceGeneration::new([byte; 32]).expect("source generation")
+ }
+
+ fn plan(byte: u8, policy: BackupSecretPolicy, at: u64) -> BackupPlan {
+ BackupPlan::new(
+ BackupId::new([byte; 16]).expect("backup id"),
+ BackupFormatVersion::V1,
+ policy,
+ at,
+ )
+ .expect("backup plan")
+ }
+
+ async fn create(database_root: &Path, backup_root: Option<&Path>) -> (Paths, SqliteStorage) {
+ let paths = Paths::from_directory(database_root).expect("owned paths");
+ let mut options = OpenOptions::new(paths.clone(), OpenMode::Create)
+ .with_source_generation(generation(91), 9_100)
+ .expect("source generation");
+ if let Some(root) = backup_root {
+ options = options.with_backup_root(root).expect("backup root");
+ }
+ let store = SqliteStorage::open(options).await.expect("create storage");
+ (paths, store)
+ }
+
+ async fn scalar(path: &Path, query: &'static str) -> i64 {
+ let mut connection = SqliteConnection::connect_with(
+ &SqliteConnectOptions::new().filename(path).read_only(true),
+ )
+ .await
+ .expect("open captured member");
+ let value = sqlx::query(query)
+ .fetch_one(&mut connection)
+ .await
+ .expect("query captured member")
+ .try_get(0)
+ .expect("decode captured member");
+ connection.close().await.expect("close captured member");
+ value
+ }
+
+ #[test]
+ fn implementation_matches_the_governed_backup_capture_policy() {
+ let policy = toml::from_str::<Policy>(POLICY).expect("backup capture policy");
+ assert_eq!(policy.schema_version, 1);
+ assert_eq!(policy.format_version, 1);
+ assert_eq!(
+ policy.backup_root,
+ "explicit_existing_host_owned_absolute_utf8_directory"
+ );
+ assert_eq!(
+ policy.staging_name,
+ ".radroots-backup-{backup_id_hex}.staging"
+ );
+ assert_eq!(policy.final_name, "radroots-backup-{backup_id_hex}");
+ assert_eq!(policy.capture, "sqlite_vacuum_into");
+ assert_eq!(policy.runtime_member, RUNTIME_MEMBER);
+ assert_eq!(policy.protected_member, PRIVATE_MEMBER);
+ assert_eq!(policy.exclude_protected_members, [RUNTIME_MEMBER]);
+ assert_eq!(
+ policy.include_protected_members,
+ [RUNTIME_MEMBER, PRIVATE_MEMBER]
+ );
+ assert_eq!(policy.created_at, "plan_requested_at_unix_ms");
+ assert_eq!(policy.member_digest, "sha256");
+ assert_eq!(policy.member_length, "exact_bytes");
+ assert_eq!(
+ policy.filesystem_sync,
+ [
+ "member_file",
+ "member_directory",
+ "staging_directory",
+ "backup_root"
+ ]
+ );
+ assert_eq!(policy.existing_staging_or_final, "reject");
+ assert!(!policy.hidden_clock);
+ assert!(!policy.unsafe_ffi);
+ }
+
+ #[tokio::test]
+ async fn capture_excludes_protected_storage_and_includes_latest_wal_state() {
+ let database_root = tempfile::tempdir().expect("database root");
+ let backup_parent = tempfile::tempdir().expect("backup parent");
+ let backup_root = backup_parent.path().join("backups");
+ fs::create_dir(&backup_root).expect("backup root");
+ let (_, store) = create(database_root.path(), Some(&backup_root)).await;
+ sqlx::raw_sql(
+ "CREATE TABLE runtime_backup_probe (value INTEGER NOT NULL);
+ INSERT INTO runtime_backup_probe (value) VALUES (41);",
+ )
+ .execute(&store.pool)
+ .await
+ .expect("runtime WAL mutation");
+ sqlx::raw_sql(
+ "CREATE TABLE private_backup_probe (value INTEGER NOT NULL);
+ INSERT INTO private_backup_probe (value) VALUES (42);",
+ )
+ .execute(&store.private_pool)
+ .await
+ .expect("private WAL mutation");
+
+ let plan = plan(92, BackupSecretPolicy::ExcludeProtectedStorage, 9_200);
+ let manifest = store.capture_backup(&plan).await.expect("capture backup");
+ let layout = BackupLayout::new(&backup_root, &plan);
+ assert_eq!(manifest.created_at_unix_ms(), 9_200);
+ assert_eq!(
+ manifest.secret_policy(),
+ BackupSecretPolicy::ExcludeProtectedStorage
+ );
+ assert_eq!(manifest.members().len(), 1);
+ let runtime = &manifest.members()[0];
+ assert_eq!(runtime.relative_path(), RUNTIME_MEMBER);
+ assert_eq!(runtime.kind(), BackupMemberKind::Runtime);
+ assert_eq!(
+ runtime.byte_length(),
+ fs::metadata(&layout.runtime_file)
+ .expect("runtime metadata")
+ .len()
+ );
+ assert_eq!(
+ runtime.sha256(),
+ member_from_file(
+ &layout.runtime_file,
+ RUNTIME_MEMBER,
+ BackupMemberKind::Runtime
+ )
+ .expect("rehash runtime member")
+ .sha256()
+ );
+ assert_eq!(
+ scalar(
+ &layout.runtime_file,
+ "SELECT value FROM runtime_backup_probe"
+ )
+ .await,
+ 41
+ );
+ assert!(!layout.private_directory.exists());
+ assert!(!layout.finalized.exists());
+ assert!(matches!(
+ store.capture_backup(&plan).await,
+ Err(Error::BackupBundleAlreadyExists(path)) if path == layout.staging
+ ));
+ }
+
+ #[tokio::test]
+ async fn read_only_capture_includes_protected_member_and_rejects_invalid_lifecycle() {
+ let database_root = tempfile::tempdir().expect("database root");
+ let backup_parent = tempfile::tempdir().expect("backup parent");
+ let backup_root = backup_parent.path().join("backups");
+ fs::create_dir(&backup_root).expect("backup root");
+ let (paths, writer) = create(database_root.path(), Some(&backup_root)).await;
+ writer.close().await.expect("close writer");
+
+ let reader = SqliteStorage::open(
+ OpenOptions::new(paths, OpenMode::ReadOnly)
+ .with_backup_root(&backup_root)
+ .expect("backup root"),
+ )
+ .await
+ .expect("read-only storage");
+ let include_plan = plan(93, BackupSecretPolicy::IncludeProtectedStorage, 9_300);
+ let manifest = reader
+ .capture_backup(&include_plan)
+ .await
+ .expect("read-only capture");
+ let layout = BackupLayout::new(&backup_root, &include_plan);
+ assert_eq!(manifest.members().len(), 2);
+ assert_eq!(manifest.members()[1].relative_path(), PRIVATE_MEMBER);
+ assert_eq!(manifest.members()[1].kind(), BackupMemberKind::Protected);
+ assert_eq!(
+ scalar(
+ &layout.private_file,
+ "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'radroots_private_artifacts'"
+ )
+ .await,
+ 1
+ );
+
+ let unsupported = BackupPlan::new(
+ BackupId::new([94; 16]).expect("backup id"),
+ BackupFormatVersion::new(2).expect("version"),
+ BackupSecretPolicy::ExcludeProtectedStorage,
+ 9_400,
+ )
+ .expect("unsupported plan");
+ assert!(matches!(
+ reader.capture_backup(&unsupported).await,
+ Err(Error::UnsupportedBackupVersion)
+ ));
+ reader.close().await.expect("close reader");
+ assert!(matches!(
+ reader
+ .capture_backup(&plan(
+ 95,
+ BackupSecretPolicy::ExcludeProtectedStorage,
+ 9_500
+ ))
+ .await,
+ Err(Error::BackupBackendUnavailable)
+ ));
+ }
+
+ #[tokio::test]
+ async fn backup_root_is_explicit_and_fail_closed() {
+ let database_root = tempfile::tempdir().expect("database root");
+ let (_, store) = create(database_root.path(), None).await;
+ assert!(matches!(
+ store
+ .capture_backup(&plan(
+ 96,
+ BackupSecretPolicy::ExcludeProtectedStorage,
+ 9_600
+ ))
+ .await,
+ Err(Error::BackupRootRequired)
+ ));
+
+ let relative = PathBuf::from("backups");
+ assert!(matches!(
+ OpenOptions::new(
+ Paths::from_directory(database_root.path()).expect("owned paths"),
+ OpenMode::ReadOnly
+ )
+ .with_backup_root(relative),
+ Err(Error::InvalidBackupRoot(_))
+ ));
+ let file = database_root.path().join("backup-file");
+ fs::write(&file, b"not a directory").expect("backup file");
+ assert!(matches!(
+ validate_backup_root(&file),
+ Err(Error::InvalidBackupRoot(_))
+ ));
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::symlink;
+ let directory = database_root.path().join("real-backups");
+ let alias = database_root.path().join("backup-alias");
+ fs::create_dir(&directory).expect("real backup root");
+ symlink(&directory, &alias).expect("backup root symlink");
+ assert!(matches!(
+ validate_backup_root(&alias),
+ Err(Error::InvalidBackupRoot(_))
+ ));
+ }
+ }
+}
diff --git a/crates/storage_sqlite/src/config.rs b/crates/storage_sqlite/src/config.rs
@@ -1,6 +1,9 @@
//! Validated SQLite connection configuration.
-use std::time::Duration;
+use std::{
+ path::{Path, PathBuf},
+ time::Duration,
+};
use crate::{Error, OpenMode, Paths};
use radroots_storage::{event::SourceGeneration, status::WriterPolicy};
@@ -19,6 +22,7 @@ pub struct OpenOptions {
mode: OpenMode,
busy_timeout: Duration,
source_generation: Option<(SourceGeneration, u64)>,
+ backup_root: Option<PathBuf>,
}
impl OpenOptions {
@@ -29,6 +33,7 @@ impl OpenOptions {
mode,
busy_timeout: DEFAULT_BUSY_TIMEOUT,
source_generation: None,
+ backup_root: None,
}
}
@@ -61,6 +66,15 @@ impl OpenOptions {
Ok(self)
}
+ /// Configures the existing host-owned directory where versioned backup
+ /// bundle directories will be staged and finalized.
+ pub fn with_backup_root(mut self, backup_root: impl Into<PathBuf>) -> Result<Self, Error> {
+ let backup_root = backup_root.into();
+ crate::backup::validate_backup_root(&backup_root)?;
+ self.backup_root = Some(backup_root);
+ Ok(self)
+ }
+
/// Returns the two database paths owned by this backend instance.
pub fn paths(&self) -> &Paths {
&self.paths
@@ -105,12 +119,21 @@ impl OpenOptions {
self.source_generation.map(|(_, created_at)| created_at)
}
+ /// Returns the optional host-owned backup root.
+ pub fn backup_root(&self) -> Option<&Path> {
+ self.backup_root.as_deref()
+ }
+
pub(crate) const fn source_generation_bootstrap(&self) -> Option<(SourceGeneration, u64)> {
self.source_generation
}
/// Validates current filesystem state without creating or modifying files.
pub fn validate_filesystem(&self) -> Result<(), Error> {
- self.paths.validate_filesystem(self.mode)
+ self.paths.validate_filesystem(self.mode)?;
+ if let Some(backup_root) = &self.backup_root {
+ crate::backup::validate_backup_root(backup_root)?;
+ }
+ Ok(())
}
}
diff --git a/crates/storage_sqlite/src/event/mod.rs b/crates/storage_sqlite/src/event/mod.rs
@@ -10,7 +10,7 @@ use radroots_storage::{
status::{EventStoreHealth, EventStoreMode, EventStoreStatus},
};
use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool};
-use std::sync::Arc;
+use std::{path::PathBuf, sync::Arc};
use crate::lock::WriterLock;
use crate::status::StorageLifecycle;
@@ -22,6 +22,7 @@ pub struct SqliteStorage {
pub(crate) generation: SourceGeneration,
pub(crate) mode: EventStoreMode,
pub(crate) lifecycle: Arc<StorageLifecycle>,
+ pub(crate) backup_root: Option<Arc<PathBuf>>,
}
struct StoredEventRow {
@@ -43,6 +44,7 @@ impl SqliteStorage {
generation,
mode,
lifecycle: Arc::new(StorageLifecycle::scaffold(mode)),
+ backup_root: None,
}
}
@@ -59,6 +61,7 @@ impl SqliteStorage {
generation,
mode,
lifecycle: Arc::new(StorageLifecycle::scaffold(mode)),
+ backup_root: None,
}
}
@@ -66,17 +69,27 @@ impl SqliteStorage {
pool: SqlitePool,
private_pool: SqlitePool,
generation: SourceGeneration,
- mode: EventStoreMode,
- open_mode: crate::OpenMode,
- busy_timeout: std::time::Duration,
+ options: &crate::OpenOptions,
writer_lock: Option<WriterLock>,
) -> Self {
+ let mode = if options.mode().is_writable() {
+ EventStoreMode::ReadWrite
+ } else {
+ EventStoreMode::ReadOnly
+ };
Self {
pool,
private_pool,
generation,
mode,
- lifecycle: Arc::new(StorageLifecycle::new(open_mode, busy_timeout, writer_lock)),
+ lifecycle: Arc::new(StorageLifecycle::new(
+ options.mode(),
+ options.busy_timeout(),
+ writer_lock,
+ )),
+ backup_root: options
+ .backup_root()
+ .map(|path| Arc::new(path.to_path_buf())),
}
}
diff --git a/crates/storage_sqlite/src/open.rs b/crates/storage_sqlite/src/open.rs
@@ -6,7 +6,7 @@ use std::path::{Component, Path, PathBuf};
use std::time::Duration;
use crate::{OpenOptions, event::SqliteStorage, lock::WriterLock, migration};
-use radroots_storage::{event::SourceGeneration, status::EventStoreMode};
+use radroots_storage::event::SourceGeneration;
use sqlx::{
ConnectOptions, Connection, Row, SqliteConnection, SqlitePool,
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
@@ -150,7 +150,7 @@ fn validate_parent(path: &Path) -> Result<(), Error> {
}
}
-/// Configuration or filesystem failure detected before SQLite is opened.
+/// Stable, secret-safe SQLite lifecycle or filesystem failure.
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
@@ -239,6 +239,18 @@ pub enum Error {
SourceGenerationUnavailable,
SourceGenerationMismatch,
CorruptSourceGeneration,
+ InvalidBackupRoot(PathBuf),
+ BackupRootRequired,
+ BackupBundleAlreadyExists(PathBuf),
+ BackupBackendUnavailable,
+ UnsupportedBackupVersion,
+ BackupCaptureFailed {
+ member: &'static str,
+ },
+ BackupFilesystem {
+ operation: &'static str,
+ source: std::io::Error,
+ },
}
impl fmt::Display for Error {
@@ -394,6 +406,32 @@ impl fmt::Display for Error {
Self::CorruptSourceGeneration => {
formatter.write_str("SQLite storage source generation is corrupt")
}
+ Self::InvalidBackupRoot(path) => {
+ write!(formatter, "invalid SQLite backup root: {}", path.display())
+ }
+ Self::BackupRootRequired => {
+ formatter.write_str("SQLite backup requires a configured host-owned root")
+ }
+ Self::BackupBundleAlreadyExists(path) => write!(
+ formatter,
+ "SQLite backup bundle path already exists: {}",
+ path.display()
+ ),
+ Self::BackupBackendUnavailable => {
+ formatter.write_str("SQLite backup backend is unavailable")
+ }
+ Self::UnsupportedBackupVersion => {
+ formatter.write_str("SQLite backup format version is unsupported")
+ }
+ Self::BackupCaptureFailed { member } => {
+ write!(formatter, "failed to capture SQLite backup member {member}")
+ }
+ Self::BackupFilesystem { operation, .. } => {
+ write!(
+ formatter,
+ "SQLite backup filesystem operation failed: {operation}"
+ )
+ }
}
}
}
@@ -475,13 +513,7 @@ impl SqliteStorage {
runtime_pool,
private_pool,
generation,
- if options.mode().is_writable() {
- EventStoreMode::ReadWrite
- } else {
- EventStoreMode::ReadOnly
- },
- options.mode(),
- options.busy_timeout(),
+ &options,
writer_lock,
))
}
@@ -658,7 +690,8 @@ impl StdError for Error {
Self::Inspect { source, .. }
| Self::WriterLockOpen { source, .. }
| Self::WriterLockFailed { source, .. }
- | Self::WriterUnlockFailed { source, .. } => Some(source),
+ | Self::WriterUnlockFailed { source, .. }
+ | Self::BackupFilesystem { source, .. } => Some(source),
_ => None,
}
}