lib

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

commit 27f385d54390717e98e659b501df481dc921612e
parent 6a34786b3595371b45ae94f6c265bcaa720315cf
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 21:12:59 +0000

storage-sqlite: verify and atomically finalize backups

- validate exact manifests, inventories, types, lengths, hashes, and schema catalogs
- reject missing, unexpected, symlinked, corrupt, and referentially invalid members
- atomically rename verified staging bundles and synchronize the backup root
- make finalized retries idempotent while refusing ambiguous filesystem states

Diffstat:
Acontracts/storage/backup_finalize_policy_v1.toml | 9+++++++++
Mcrates/storage_sqlite/src/backup.rs | 443++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/storage_sqlite/src/integrity.rs | 6+++++-
Mcrates/storage_sqlite/src/open.rs | 23+++++++++++++++++++++++
4 files changed, 479 insertions(+), 2 deletions(-)

diff --git a/contracts/storage/backup_finalize_policy_v1.toml b/contracts/storage/backup_finalize_policy_v1.toml @@ -0,0 +1,9 @@ +schema_version = 1 +verification = ["exact_plan_manifest", "exact_inventory", "no_symlinks", "exact_length", "sha256", "current_schema_catalog", "sqlite_integrity_check", "foreign_key_check"] +finalization = "same_root_atomic_directory_rename" +root_sync_after_rename = true +finalized_retry = "verify_and_succeed" +missing_bundle = "reject" +staging_and_final_present = "reject" +unexpected_entry = "reject" +mutation_before_complete_verification = false diff --git a/crates/storage_sqlite/src/backup.rs b/crates/storage_sqlite/src/backup.rs @@ -1,6 +1,7 @@ //! Consistent SQLite backup capture and bundle layout. use std::{ + collections::BTreeSet, fs::{self, File}, io::Read, path::{Component, Path, PathBuf}, @@ -12,8 +13,9 @@ use radroots_storage::backup::{ }; use sha2::{Digest, Sha256}; use sqlx::SqlitePool; +use sqlx::{Connection, SqliteConnection, sqlite::SqliteConnectOptions}; -use crate::{Error, SqliteStorage}; +use crate::{Error, OpenMode, SqliteStorage, integrity, migration}; const RUNTIME_DATABASE: &str = "runtime.sqlite"; const PRIVATE_DATABASE: &str = "private.sqlite"; @@ -80,6 +82,67 @@ impl SqliteStorage { ) .map_err(|_| Error::BackupCaptureFailed { member: "manifest" }) } + + /// Verifies the complete staged bundle without mutating or finalizing it. + pub async fn verify_backup( + &self, + plan: &BackupPlan, + manifest: &BackupManifest, + ) -> Result<(), Error> { + self.lifecycle + .require_open() + .map_err(|_| Error::BackupBackendUnavailable)?; + let backup_root = self + .backup_root + .as_deref() + .ok_or(Error::BackupRootRequired)?; + validate_backup_root(backup_root)?; + let layout = BackupLayout::new(backup_root, plan); + verify_bundle(&layout.staging, plan, manifest).await + } + + /// Verifies and atomically renames a complete staging bundle. A retry + /// against an already finalized valid bundle succeeds idempotently. + pub async fn finalize_backup( + &self, + plan: &BackupPlan, + manifest: &BackupManifest, + ) -> Result<PathBuf, Error> { + self.lifecycle + .require_open() + .map_err(|_| Error::BackupBackendUnavailable)?; + let backup_root = self + .backup_root + .as_deref() + .ok_or(Error::BackupRootRequired)?; + validate_backup_root(backup_root)?; + let layout = BackupLayout::new(backup_root, plan); + let staging = entry_kind(&layout.staging)?; + let finalized = entry_kind(&layout.finalized)?; + match (staging, finalized) { + (EntryKind::Missing, EntryKind::Directory) => { + verify_bundle(&layout.finalized, plan, manifest).await?; + Ok(layout.finalized) + } + (EntryKind::Directory, EntryKind::Missing) => { + verify_bundle(&layout.staging, plan, manifest).await?; + fs::rename(&layout.staging, &layout.finalized).map_err(|source| { + Error::BackupFilesystem { + operation: "atomically finalize backup bundle", + source, + } + })?; + sync_directory(backup_root, "sync finalized backup root")?; + Ok(layout.finalized) + } + (EntryKind::Missing, EntryKind::Missing) => { + Err(Error::BackupBundleMissing(layout.staging)) + } + (_, EntryKind::Directory) => Err(Error::BackupBundleAlreadyExists(layout.finalized)), + (EntryKind::Other, _) => Err(Error::BackupUnexpectedEntry(layout.staging)), + (_, EntryKind::Other) => Err(Error::BackupUnexpectedEntry(layout.finalized)), + } + } } pub(crate) fn validate_backup_root(path: &Path) -> Result<(), Error> { @@ -115,6 +178,27 @@ struct BackupLayout { private_file: PathBuf, } +#[derive(Clone, Copy, Eq, PartialEq)] +enum EntryKind { + Missing, + Directory, + Other, +} + +fn entry_kind(path: &Path) -> Result<EntryKind, Error> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + Ok(EntryKind::Directory) + } + Ok(_) => Ok(EntryKind::Other), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(EntryKind::Missing), + Err(source) => Err(Error::BackupFilesystem { + operation: "inspect backup bundle", + source, + }), + } +} + impl BackupLayout { fn new(root: &Path, plan: &BackupPlan) -> Self { let id = encode_backup_id(plan); @@ -252,6 +336,202 @@ fn sync_directory(path: &Path, operation: &'static str) -> Result<(), Error> { .map_err(|source| Error::BackupFilesystem { operation, source }) } +async fn verify_bundle( + bundle: &Path, + plan: &BackupPlan, + manifest: &BackupManifest, +) -> Result<(), Error> { + if entry_kind(bundle)? != EntryKind::Directory { + return Err(Error::BackupBundleMissing(bundle.to_path_buf())); + } + validate_manifest(plan, manifest)?; + let expected_root = if plan.secret_policy() == BackupSecretPolicy::IncludeProtectedStorage { + BTreeSet::from(["private", "runtime"]) + } else { + BTreeSet::from(["runtime"]) + }; + validate_entries(bundle, &expected_root)?; + let runtime_directory = bundle.join("runtime"); + validate_entries(&runtime_directory, &BTreeSet::from([RUNTIME_DATABASE]))?; + verify_member( + &runtime_directory.join(RUNTIME_DATABASE), + manifest + .member(RUNTIME_MEMBER) + .ok_or(Error::BackupVerificationFailed { + member: RUNTIME_MEMBER, + })?, + BackupMemberKind::Runtime, + RUNTIME_MEMBER, + true, + ) + .await?; + if plan.secret_policy() == BackupSecretPolicy::IncludeProtectedStorage { + let private_directory = bundle.join("private"); + validate_entries(&private_directory, &BTreeSet::from([PRIVATE_DATABASE]))?; + verify_member( + &private_directory.join(PRIVATE_DATABASE), + manifest + .member(PRIVATE_MEMBER) + .ok_or(Error::BackupVerificationFailed { + member: PRIVATE_MEMBER, + })?, + BackupMemberKind::Protected, + PRIVATE_MEMBER, + false, + ) + .await?; + } + Ok(()) +} + +fn validate_manifest(plan: &BackupPlan, manifest: &BackupManifest) -> Result<(), Error> { + if plan.format_version() != BackupFormatVersion::V1 + || manifest.format_version() != plan.format_version() + || manifest.backup_id() != plan.backup_id() + || manifest.secret_policy() != plan.secret_policy() + || manifest.created_at_unix_ms() != plan.requested_at_unix_ms() + { + return Err(Error::BackupVerificationFailed { member: "manifest" }); + } + let expected = if plan.secret_policy() == BackupSecretPolicy::IncludeProtectedStorage { + BTreeSet::from([PRIVATE_MEMBER, RUNTIME_MEMBER]) + } else { + BTreeSet::from([RUNTIME_MEMBER]) + }; + let actual = manifest + .members() + .iter() + .map(BackupMember::relative_path) + .collect::<BTreeSet<_>>(); + if actual == expected { + Ok(()) + } else { + Err(Error::BackupVerificationFailed { member: "manifest" }) + } +} + +fn validate_entries(directory: &Path, expected: &BTreeSet<&str>) -> Result<(), Error> { + let mut actual = BTreeSet::new(); + let entries = fs::read_dir(directory).map_err(|source| Error::BackupFilesystem { + operation: "read backup bundle directory", + source, + })?; + for entry in entries { + let entry = entry.map_err(|source| Error::BackupFilesystem { + operation: "read backup bundle entry", + source, + })?; + let name = entry + .file_name() + .into_string() + .map_err(|_| Error::BackupUnexpectedEntry(entry.path()))?; + let metadata = + fs::symlink_metadata(entry.path()).map_err(|source| Error::BackupFilesystem { + operation: "inspect backup bundle entry", + source, + })?; + if metadata.file_type().is_symlink() || !expected.contains(name.as_str()) { + return Err(Error::BackupUnexpectedEntry(entry.path())); + } + actual.insert(name); + } + if actual.iter().map(String::as_str).collect::<BTreeSet<_>>() == *expected { + Ok(()) + } else { + Err(Error::BackupVerificationFailed { + member: "inventory", + }) + } +} + +async fn verify_member( + path: &Path, + expected: &BackupMember, + expected_kind: BackupMemberKind, + member_name: &'static str, + runtime: bool, +) -> Result<(), Error> { + if expected.kind() != expected_kind || !entry_kind_file(path)? { + return Err(Error::BackupVerificationFailed { + member: member_name, + }); + } + let (byte_length, sha256) = fingerprint(path)?; + if byte_length != expected.byte_length() || sha256 != expected.sha256() { + return Err(Error::BackupVerificationFailed { + member: member_name, + }); + } + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(path) + .read_only(true) + .foreign_keys(true), + ) + .await + .map_err(|_| Error::BackupVerificationFailed { + member: member_name, + })?; + let schema = if runtime { + migration::migrate_runtime(&mut connection, OpenMode::ReadOnly).await + } else { + migration::migrate_private(&mut connection, OpenMode::ReadOnly).await + }; + if schema.is_err() + || integrity::check_connection(&mut connection).await != integrity::MemberOutcome::Verified + { + return Err(Error::BackupVerificationFailed { + member: member_name, + }); + } + connection + .close() + .await + .map_err(|_| Error::BackupVerificationFailed { + member: member_name, + }) +} + +fn entry_kind_file(path: &Path) -> Result<bool, Error> { + match fs::symlink_metadata(path) { + Ok(metadata) => Ok(metadata.is_file() && !metadata.file_type().is_symlink()), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(Error::BackupFilesystem { + operation: "inspect backup member", + source, + }), + } +} + +fn fingerprint(path: &Path) -> Result<(u64, MemberDigest), Error> { + let mut file = File::open(path).map_err(|source| Error::BackupFilesystem { + operation: "open backup member for verification", + source, + })?; + let byte_length = file + .metadata() + .map_err(|source| Error::BackupFilesystem { + operation: "inspect backup member for verification", + 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 backup member for verification", + source, + })?; + if read == 0 { + break; + } + sha256.update(&buffer[..read]); + } + Ok((byte_length, MemberDigest::new(sha256.finalize().into()))) +} + #[cfg(test)] mod tests { use radroots_storage::{ @@ -266,6 +546,8 @@ mod tests { use super::*; const POLICY: &str = include_str!("../../../contracts/storage/backup_capture_policy_v1.toml"); + const FINALIZE_POLICY: &str = + include_str!("../../../contracts/storage/backup_finalize_policy_v1.toml"); #[derive(Deserialize)] struct Policy { @@ -288,6 +570,19 @@ mod tests { unsafe_ffi: bool, } + #[derive(Deserialize)] + struct FinalizePolicy { + schema_version: u32, + verification: Vec<String>, + finalization: String, + root_sync_after_rename: bool, + finalized_retry: String, + missing_bundle: String, + staging_and_final_present: String, + unexpected_entry: String, + mutation_before_complete_verification: bool, + } + fn generation(byte: u8) -> SourceGeneration { SourceGeneration::new([byte; 32]).expect("source generation") } @@ -369,6 +664,32 @@ mod tests { assert!(!policy.unsafe_ffi); } + #[test] + fn implementation_matches_the_governed_backup_finalize_policy() { + let policy = toml::from_str::<FinalizePolicy>(FINALIZE_POLICY).expect("finalize policy"); + assert_eq!(policy.schema_version, 1); + assert_eq!( + policy.verification, + [ + "exact_plan_manifest", + "exact_inventory", + "no_symlinks", + "exact_length", + "sha256", + "current_schema_catalog", + "sqlite_integrity_check", + "foreign_key_check" + ] + ); + assert_eq!(policy.finalization, "same_root_atomic_directory_rename"); + assert!(policy.root_sync_after_rename); + assert_eq!(policy.finalized_retry, "verify_and_succeed"); + assert_eq!(policy.missing_bundle, "reject"); + assert_eq!(policy.staging_and_final_present, "reject"); + assert_eq!(policy.unexpected_entry, "reject"); + assert!(!policy.mutation_before_complete_verification); + } + #[tokio::test] async fn capture_excludes_protected_storage_and_includes_latest_wal_state() { let database_root = tempfile::tempdir().expect("database root"); @@ -537,4 +858,124 @@ mod tests { )); } } + + #[tokio::test] + async fn complete_bundle_verifies_finalizes_atomically_and_retries_idempotently() { + 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; + let plan = plan(97, BackupSecretPolicy::IncludeProtectedStorage, 9_700); + let manifest = store.capture_backup(&plan).await.expect("capture backup"); + let layout = BackupLayout::new(&backup_root, &plan); + + store + .verify_backup(&plan, &manifest) + .await + .expect("verify staging bundle"); + let finalized = store + .finalize_backup(&plan, &manifest) + .await + .expect("finalize backup"); + assert_eq!(finalized, layout.finalized); + assert!(!layout.staging.exists()); + assert!(layout.finalized.is_dir()); + assert_eq!( + store + .finalize_backup(&plan, &manifest) + .await + .expect("idempotent finalization"), + layout.finalized + ); + } + + #[tokio::test] + async fn verification_rejects_tampering_unexpected_entries_and_missing_bundles() { + 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; + + let tampered_plan = plan(98, BackupSecretPolicy::ExcludeProtectedStorage, 9_800); + let tampered_manifest = store + .capture_backup(&tampered_plan) + .await + .expect("capture tamper target"); + let tampered_layout = BackupLayout::new(&backup_root, &tampered_plan); + use std::io::Write; + fs::OpenOptions::new() + .append(true) + .open(&tampered_layout.runtime_file) + .expect("open tamper target") + .write_all(b"tamper") + .expect("tamper member"); + assert!(matches!( + store + .verify_backup(&tampered_plan, &tampered_manifest) + .await, + Err(Error::BackupVerificationFailed { + member: RUNTIME_MEMBER + }) + )); + assert!(!tampered_layout.finalized.exists()); + + let unexpected_plan = plan(99, BackupSecretPolicy::ExcludeProtectedStorage, 9_900); + let unexpected_manifest = store + .capture_backup(&unexpected_plan) + .await + .expect("capture unexpected target"); + let unexpected_layout = BackupLayout::new(&backup_root, &unexpected_plan); + fs::write(unexpected_layout.staging.join("unexpected"), b"data").expect("unexpected entry"); + assert!(matches!( + store + .verify_backup(&unexpected_plan, &unexpected_manifest) + .await, + Err(Error::BackupUnexpectedEntry(_)) + )); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + fs::remove_file(unexpected_layout.staging.join("unexpected")) + .expect("remove unexpected entry"); + fs::remove_file(&unexpected_layout.runtime_file).expect("remove captured member"); + symlink( + database_root.path().join(RUNTIME_DATABASE), + &unexpected_layout.runtime_file, + ) + .expect("symlink captured member"); + assert!(matches!( + store + .verify_backup(&unexpected_plan, &unexpected_manifest) + .await, + Err(Error::BackupUnexpectedEntry(_)) + )); + } + + let missing_plan = plan(100, BackupSecretPolicy::ExcludeProtectedStorage, 10_000); + let missing_manifest = BackupManifest::new( + missing_plan.format_version(), + missing_plan.backup_id(), + missing_plan.requested_at_unix_ms(), + missing_plan.secret_policy(), + vec![ + BackupMember::new( + RUNTIME_MEMBER, + BackupMemberKind::Runtime, + 1, + MemberDigest::new([1; 32]), + ) + .expect("member"), + ], + ) + .expect("manifest"); + assert!(matches!( + store + .finalize_backup(&missing_plan, &missing_manifest) + .await, + Err(Error::BackupBundleMissing(_)) + )); + } } diff --git a/crates/storage_sqlite/src/integrity.rs b/crates/storage_sqlite/src/integrity.rs @@ -54,7 +54,7 @@ pub(crate) fn unknown() -> Result<IntegrityStatus, Error> { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum MemberOutcome { +pub(crate) enum MemberOutcome { Verified, Corrupt, Unavailable, @@ -65,6 +65,10 @@ async fn check_member(pool: &SqlitePool) -> MemberOutcome { Ok(connection) => connection, Err(_) => return MemberOutcome::Unavailable, }; + check_connection(&mut connection).await +} + +pub(crate) async fn check_connection(connection: &mut sqlx::SqliteConnection) -> MemberOutcome { let integrity = match sqlx::query_scalar::<_, String>("PRAGMA integrity_check") .fetch_all(&mut *connection) .await diff --git a/crates/storage_sqlite/src/open.rs b/crates/storage_sqlite/src/open.rs @@ -247,6 +247,11 @@ pub enum Error { BackupCaptureFailed { member: &'static str, }, + BackupBundleMissing(PathBuf), + BackupVerificationFailed { + member: &'static str, + }, + BackupUnexpectedEntry(PathBuf), BackupFilesystem { operation: &'static str, source: std::io::Error, @@ -426,6 +431,24 @@ impl fmt::Display for Error { Self::BackupCaptureFailed { member } => { write!(formatter, "failed to capture SQLite backup member {member}") } + Self::BackupBundleMissing(path) => { + write!( + formatter, + "SQLite backup bundle is missing: {}", + path.display() + ) + } + Self::BackupVerificationFailed { member } => { + write!( + formatter, + "SQLite backup member failed verification: {member}" + ) + } + Self::BackupUnexpectedEntry(path) => write!( + formatter, + "SQLite backup bundle contains an unexpected entry: {}", + path.display() + ), Self::BackupFilesystem { operation, .. } => { write!( formatter,