lib

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

commit f14b25fd3b1c0271fa90e779d66368e012d82b30
parent 0f50fc836019e3435e1a8324bbaa6befe7f4efa1
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 20:03:53 +0000

service-sqlite: verify untrusted backup bundles

- bind canonical manifests to independently protected digests and typed intent
- retain exact directory and member descriptors in a sealed verification proof
- enforce bounded SQLite metadata integrity policy and restrictive inventory
- document and test fail-closed races corruption permissions and redaction

Diffstat:
MAGENTS.md | 9+++++++++
Mcrates/service_sqlite/README.md | 18++++++++++++++++++
Mcrates/service_sqlite/src/backup/manifest.rs | 6++++++
Mcrates/service_sqlite/src/backup/mod.rs | 2++
Acrates/service_sqlite/src/backup/verify.rs | 1511+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_sqlite/src/connection.rs | 13+++++++++++++
Mcrates/service_sqlite/src/lib.rs | 2+-
Mcrates/service_sqlite/src/metadata.rs | 22++++++++++++++++++++++
Mcrates/service_sqlite/tests/package_boundary.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1652 insertions(+), 1 deletion(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -185,6 +185,15 @@ Before editing code: exact-artifact cancellation cleanup completes. Do not expose raw backup handles, capture credentials, invent a manifest filename, read an ambient clock, or fold untrusted verification or restore behavior into capture. +- Untrusted backup verification is the synchronous, task-free + `verify_backup_bundle` boundary. Require an independently protected manifest + digest, exact `ServiceDatabaseIdentity`, and caller-supplied positive member + limit; retain the verified directory and member descriptors in the sealed + non-cloneable proof. Do not expose paths or raw handles, treat pathname-only + verification as restore authority, create an internal task/deadline, mutate + the bundle, or introduce restore markers, staging, replacement, or recovery + into verification. Later restore work must consume the retained member and + reverify its staged copy. - Runtime-management flows consume a sealed `RuntimeContext` for every service instance. They must not reconstruct service paths from raw identifiers, ambient selectors, or manager-owned roots, and registries must not persist diff --git a/crates/service_sqlite/README.md b/crates/service_sqlite/README.md @@ -74,6 +74,24 @@ is still untrusted backup input until the separate verifier binds its manifest, member bytes, expected intent, application metadata, and integrity; capture does not provide restore or replacement behavior. +`verify_backup_bundle` is the synchronous, task-free boundary for an untrusted +manifest and bundle. The caller supplies the independently protected manifest +SHA-256, expected service database identity, and a positive maximum state-file +size. Verification requires canonical manifest bytes, exact service, instance, +source-generation, schema, and application intent, a restrictive owner-only +directory containing only `state.sqlite`, the exact bounded member length and +digest, immutable read-only/query-only SQLite access, main-only attachment, +bounded application metadata, `integrity_check(1)`, and an empty foreign-key +check. It performs no filesystem mutation and does not create a task or hidden +deadline. + +Success returns a non-forgeable `VerifiedServiceBackup` that retains the exact +verified directory and member descriptors while exposing only the canonical +manifest and actual database metadata. It is not restore or replacement +authority, and it exposes no path or raw handle. Later restore work must copy +from the retained member and reverify the staged copy under its own supervised +blocking worker and deadline; pathname verification alone is insufficient. + The crate owns mechanics only. Service-specific tables, SQL, repositories, backup content policy, identity material, process lifecycle, and readiness policy remain with the consuming service. The crate does not provide callers diff --git a/crates/service_sqlite/src/backup/manifest.rs b/crates/service_sqlite/src/backup/manifest.rs @@ -70,6 +70,12 @@ impl fmt::Debug for BackupMemberSha256 { pub struct BackupManifestSha256([u8; 32]); impl BackupManifestSha256 { + /// Constructs a digest pinned by independently protected provenance. + #[must_use] + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + /// Returns the exact digest bytes. #[must_use] pub const fn as_bytes(&self) -> &[u8; 32] { diff --git a/crates/service_sqlite/src/backup/mod.rs b/crates/service_sqlite/src/backup/mod.rs @@ -3,6 +3,7 @@ #[cfg(any(target_os = "linux", target_os = "macos"))] mod capture; mod manifest; +mod verify; #[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) use capture::capture_online_backup; @@ -22,3 +23,4 @@ pub use manifest::{ BackupManifestIntegrity, BackupManifestSha256, BackupMemberSha256, ServiceBackupManifest, ServiceBackupMember, }; +pub use verify::{VerifiedServiceBackup, verify_backup_bundle}; diff --git a/crates/service_sqlite/src/backup/verify.rs b/crates/service_sqlite/src/backup/verify.rs @@ -0,0 +1,1511 @@ +//! Untrusted backup bundle verification. + +use core::{fmt, num::NonZeroU64}; +use std::path::Path; + +use crate::{ + BackupManifestSha256, ServiceBackupManifest, ServiceDatabaseIdentity, ServiceDatabaseMetadata, + ServiceSqliteError, ServiceSqliteErrorKind, +}; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use { + core::num::NonZeroU32, + radroots_runtime_paths::{InstanceId, ServiceId}, + radroots_storage::event::SourceGeneration, + rusqlite::{Connection, OpenFlags, OptionalExtension, types::ValueRef}, + rustix::{ + fs::{Dir, FileType, Mode, OFlags, fstat, open, openat}, + process::geteuid, + }, + sha2::{Digest, Sha256}, + std::{ + error::Error, + fs::File, + io::{Read, Seek, SeekFrom}, + os::fd::AsRawFd, + os::unix::ffi::OsStrExt, + path::PathBuf, + }, +}; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +const MAX_BUNDLE_PATH_BYTES: usize = 4_096; +#[cfg(any(target_os = "linux", target_os = "macos"))] +const HASH_BUFFER_BYTES: usize = 64 * 1_024; +#[cfg(any(target_os = "linux", target_os = "macos"))] +const MAX_ID_UTF8_BYTES: i64 = 128; +#[cfg(any(target_os = "linux", target_os = "macos"))] +const MAX_INTEGRITY_RESULT_UTF8_BYTES: usize = 64; + +/// Non-forgeable proof that one retained backup member passed v1 verification. +/// +/// The proof is bound to retained directory and file descriptors, not only to +/// their pathnames. It is not restore or replacement authority. Later restore +/// work must copy from the retained member and reverify the staged copy. +/// +/// External callers cannot construct the proof or obtain its raw handles: +/// +/// ```compile_fail +/// use radroots_service_sqlite::VerifiedServiceBackup; +/// let _forged = VerifiedServiceBackup {}; +/// ``` +/// +/// ```compile_fail +/// # fn inspect(proof: &radroots_service_sqlite::VerifiedServiceBackup) { +/// let _raw = proof.state_file(); +/// # } +/// ``` +pub struct VerifiedServiceBackup { + manifest: ServiceBackupManifest, + database_metadata: ServiceDatabaseMetadata, + #[cfg(any(target_os = "linux", target_os = "macos"))] + binding: VerifiedBundleBinding, +} + +impl VerifiedServiceBackup { + /// Returns the exact canonical manifest bound by this proof. + #[must_use] + pub const fn manifest(&self) -> &ServiceBackupManifest { + &self.manifest + } + + /// Returns the actual validated application metadata read from the member. + #[must_use] + pub const fn database_metadata(&self) -> &ServiceDatabaseMetadata { + &self.database_metadata + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[allow(dead_code)] + pub(crate) fn state_file(&self) -> &File { + &self.binding.state + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[allow(dead_code)] + pub(crate) fn validate_binding(&self) -> Result<(), ServiceSqliteError> { + self.binding.validate() + } +} + +impl fmt::Debug for VerifiedServiceBackup { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedServiceBackup") + .field("service", &"[redacted]") + .field("instance", &"[redacted]") + .field("manifest_digest", &"[redacted]") + .field( + "state_schema_version", + &self.database_metadata.state_schema_version(), + ) + .field("member", &"[retained]") + .finish() + } +} + +/// Verifies untrusted canonical manifest bytes and their exact singleton bundle. +/// +/// The expected digest, expected database identity, and positive maximum member +/// size are trusted caller inputs. This function is deliberately synchronous: +/// callers own the supervised blocking worker and positive deadline. It never +/// creates, copies, deletes, restores, or replaces filesystem state. +pub fn verify_backup_bundle( + manifest_bytes: &[u8], + expected_manifest_digest: BackupManifestSha256, + bundle_directory: &Path, + expected_identity: &ServiceDatabaseIdentity, + maximum_state_bytes: NonZeroU64, +) -> Result<VerifiedServiceBackup, ServiceSqliteError> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + verify_backup_bundle_native( + manifest_bytes, + expected_manifest_digest, + bundle_directory, + expected_identity, + maximum_state_bytes, + ) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = ( + manifest_bytes, + expected_manifest_digest, + bundle_directory, + expected_identity, + maximum_state_bytes, + ); + Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Backup)) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn verify_backup_bundle_native( + manifest_bytes: &[u8], + expected_manifest_digest: BackupManifestSha256, + bundle_directory: &Path, + expected_identity: &ServiceDatabaseIdentity, + maximum_state_bytes: NonZeroU64, +) -> Result<VerifiedServiceBackup, ServiceSqliteError> { + if manifest_bytes.len() > crate::BACKUP_MANIFEST_CANONICAL_MAX_BYTES { + return Err(verification_error(VerificationFailureKind::Manifest)); + } + let actual_manifest_digest: [u8; 32] = Sha256::digest(manifest_bytes).into(); + if &actual_manifest_digest != expected_manifest_digest.as_bytes() { + return Err(verification_error(VerificationFailureKind::ManifestDigest)); + } + let manifest = ServiceBackupManifest::from_canonical_bytes(manifest_bytes) + .map_err(|source| verification_source(VerificationFailureKind::Manifest, source))?; + verify_manifest_intent(&manifest, expected_identity)?; + + let member = manifest + .members() + .first() + .ok_or_else(|| verification_error(VerificationFailureKind::Inventory))?; + if member.byte_length() > i64::MAX as u64 || member.byte_length() > maximum_state_bytes.get() { + return Err(verification_error(VerificationFailureKind::MemberLength)); + } + + let binding = VerifiedBundleBinding::open(bundle_directory, member.byte_length())?; + binding.validate_inventory()?; + let first_digest = binding.hash_state(maximum_state_bytes)?; + if &first_digest != member.sha256().as_bytes() { + return Err(verification_error(VerificationFailureKind::MemberDigest)); + } + binding.validate()?; + + let connection = open_sqlite_from_retained_state(&binding)?; + apply_connection_policy(&connection)?; + binding.validate()?; + verify_database_inventory(&connection)?; + let database_metadata = verify_database_metadata(&connection, &manifest, expected_identity)?; + verify_integrity(&connection)?; + connection + .close() + .map_err(|(_, source)| integrity_source(source))?; + + binding.validate_inventory()?; + let final_digest = binding.hash_state(maximum_state_bytes)?; + if &final_digest != member.sha256().as_bytes() || final_digest != first_digest { + return Err(verification_error(VerificationFailureKind::MemberDigest)); + } + binding.validate()?; + + Ok(VerifiedServiceBackup { + manifest, + database_metadata, + binding, + }) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn verify_manifest_intent( + manifest: &ServiceBackupManifest, + expected: &ServiceDatabaseIdentity, +) -> Result<(), ServiceSqliteError> { + if manifest.service() != expected.service() + || manifest.instance() != expected.instance() + || manifest.source_generation() != expected.source_generation() + || manifest.state_schema_version() > expected.supported_state_schema_version() + { + return Err(verification_error(VerificationFailureKind::Intent)); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct FileIdentity { + device: u64, + inode: u64, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +struct VerifiedBundleBinding { + path: PathBuf, + directory: File, + directory_identity: FileIdentity, + state: File, + state_identity: FileIdentity, + state_length: u64, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl VerifiedBundleBinding { + fn open(path: &Path, expected_length: u64) -> Result<Self, ServiceSqliteError> { + validate_bundle_path(path)?; + let directory = File::from( + open( + path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|source| { + verification_source(VerificationFailureKind::BundleDirectory, source) + })?, + ); + let directory_identity = validate_directory(&directory)?; + let state = File::from( + openat( + &directory, + crate::BACKUP_STATE_MEMBER_NAME, + OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|source| verification_source(VerificationFailureKind::Inventory, source))?, + ); + let (state_identity, state_length) = validate_state(&state)?; + if state_length != expected_length { + return Err(verification_error(VerificationFailureKind::MemberLength)); + } + let binding = Self { + path: path.to_path_buf(), + directory, + directory_identity, + state, + state_identity, + state_length, + }; + binding.validate()?; + Ok(binding) + } + + fn validate(&self) -> Result<(), ServiceSqliteError> { + let current_directory = File::from( + open( + &self.path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|source| { + verification_source(VerificationFailureKind::BindingChanged, source) + })?, + ); + if validate_directory(&self.directory)? != self.directory_identity + || validate_directory(&current_directory)? != self.directory_identity + { + return Err(verification_error(VerificationFailureKind::BindingChanged)); + } + let current_state = File::from( + openat( + &self.directory, + crate::BACKUP_STATE_MEMBER_NAME, + OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|source| { + verification_source(VerificationFailureKind::BindingChanged, source) + })?, + ); + for state in [&self.state, &current_state] { + let (identity, length) = validate_state(state)?; + if identity != self.state_identity || length != self.state_length { + return Err(verification_error(VerificationFailureKind::BindingChanged)); + } + } + Ok(()) + } + + fn validate_inventory(&self) -> Result<(), ServiceSqliteError> { + self.validate()?; + let mut directory = Dir::read_from(&self.directory) + .map_err(|source| verification_source(VerificationFailureKind::Inventory, source))?; + let mut seen_state = false; + let mut meaningful = 0_u8; + while let Some(entry) = directory.read() { + let entry = entry.map_err(|source| { + verification_source(VerificationFailureKind::Inventory, source) + })?; + let name = entry.file_name().to_bytes(); + if name == b"." || name == b".." { + continue; + } + meaningful = meaningful + .checked_add(1) + .ok_or_else(|| verification_error(VerificationFailureKind::Inventory))?; + if meaningful > 1 || name != crate::BACKUP_STATE_MEMBER_NAME.as_bytes() { + return Err(verification_error(VerificationFailureKind::Inventory)); + } + seen_state = true; + } + if !seen_state { + return Err(verification_error(VerificationFailureKind::Inventory)); + } + self.validate() + } + + fn hash_state(&self, maximum: NonZeroU64) -> Result<[u8; 32], ServiceSqliteError> { + self.validate()?; + let mut state = self + .state + .try_clone() + .map_err(|source| verification_source(VerificationFailureKind::MemberDigest, source))?; + state + .seek(SeekFrom::Start(0)) + .map_err(|source| verification_source(VerificationFailureKind::MemberDigest, source))?; + let mut hasher = Sha256::new(); + let mut length = 0_u64; + let mut buffer = [0_u8; HASH_BUFFER_BYTES]; + loop { + let count = state.read(&mut buffer).map_err(|source| { + verification_source(VerificationFailureKind::MemberDigest, source) + })?; + if count == 0 { + break; + } + length = length + .checked_add( + u64::try_from(count) + .map_err(|_| verification_error(VerificationFailureKind::MemberLength))?, + ) + .ok_or_else(|| verification_error(VerificationFailureKind::MemberLength))?; + if length > i64::MAX as u64 || length > maximum.get() { + return Err(verification_error(VerificationFailureKind::MemberLength)); + } + hasher.update(&buffer[..count]); + } + if length == 0 || length != self.state_length { + return Err(verification_error(VerificationFailureKind::MemberLength)); + } + self.validate()?; + Ok(hasher.finalize().into()) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn validate_bundle_path(path: &Path) -> Result<(), ServiceSqliteError> { + if !path.is_absolute() + || path.as_os_str().as_bytes().is_empty() + || path.as_os_str().as_bytes().len() > MAX_BUNDLE_PATH_BYTES + || path.components().any(|part| { + matches!( + part, + std::path::Component::CurDir | std::path::Component::ParentDir + ) + }) + { + return Err(verification_error(VerificationFailureKind::BundleDirectory)); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn validate_directory(directory: &File) -> Result<FileIdentity, ServiceSqliteError> { + let status = fstat(directory) + .map_err(|source| verification_source(VerificationFailureKind::BundleDirectory, source))?; + let mode = u32::from(status.st_mode) & 0o777; + if !FileType::from_raw_mode(status.st_mode).is_dir() + || status.st_uid != geteuid().as_raw() + || !matches!(mode, 0o500 | 0o700) + { + return Err(verification_error(VerificationFailureKind::Permissions)); + } + file_identity(&status) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn validate_state(file: &File) -> Result<(FileIdentity, u64), ServiceSqliteError> { + let status = fstat(file) + .map_err(|source| verification_source(VerificationFailureKind::Inventory, source))?; + let mode = u32::from(status.st_mode) & 0o777; + let length = u64::try_from(status.st_size) + .map_err(|_| verification_error(VerificationFailureKind::MemberLength))?; + if !FileType::from_raw_mode(status.st_mode).is_file() + || u64::from(status.st_nlink) != 1 + || status.st_uid != geteuid().as_raw() + || !matches!(mode, 0o400 | 0o600) + { + return Err(verification_error(VerificationFailureKind::Permissions)); + } + if length == 0 || length > i64::MAX as u64 { + return Err(verification_error(VerificationFailureKind::MemberLength)); + } + Ok((file_identity(&status)?, length)) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn file_identity(status: &rustix::fs::Stat) -> Result<FileIdentity, ServiceSqliteError> { + Ok(FileIdentity { + device: u64::try_from(status.st_dev) + .map_err(|_| verification_error(VerificationFailureKind::BindingChanged))?, + inode: status.st_ino, + }) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn open_sqlite_from_retained_state( + binding: &VerifiedBundleBinding, +) -> Result<Connection, ServiceSqliteError> { + let descriptor = binding.state.as_raw_fd(); + #[cfg(target_os = "linux")] + let descriptor_path = format!("/proc/self/fd/{descriptor}"); + #[cfg(target_os = "macos")] + let descriptor_path = format!("/dev/fd/{descriptor}"); + let uri = format!("file:{descriptor_path}?mode=ro&immutable=1"); + Connection::open_with_flags( + uri, + OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_URI + | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|source| verification_source(VerificationFailureKind::Inventory, source)) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn apply_connection_policy(connection: &Connection) -> Result<(), ServiceSqliteError> { + connection + .pragma_update(None, "query_only", true) + .map_err(integrity_source)?; + connection + .pragma_update(None, "trusted_schema", false) + .map_err(integrity_source)?; + verify_connection_policy(connection) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn verify_connection_policy(connection: &Connection) -> Result<(), ServiceSqliteError> { + let query_only: i64 = connection + .pragma_query_value(None, "query_only", |row| row.get(0)) + .map_err(integrity_source)?; + let trusted_schema: i64 = connection + .pragma_query_value(None, "trusted_schema", |row| row.get(0)) + .map_err(integrity_source)?; + if query_only != 1 || trusted_schema != 0 { + return Err(integrity_error(IntegrityFailureKind::Policy)); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn verify_database_inventory(connection: &Connection) -> Result<(), ServiceSqliteError> { + let mut statement = connection + .prepare("PRAGMA database_list") + .map_err(integrity_source)?; + let mut rows = statement.query([]).map_err(integrity_source)?; + let first = rows + .next() + .map_err(integrity_source)? + .ok_or_else(|| integrity_error(IntegrityFailureKind::DatabaseInventory))?; + let sequence = first.get_ref(0).map_err(integrity_source)?; + let name = first.get_ref(1).map_err(integrity_source)?; + if !matches!(sequence, ValueRef::Integer(0)) || !matches!(name, ValueRef::Text(b"main")) { + return Err(integrity_error(IntegrityFailureKind::DatabaseInventory)); + } + if rows.next().map_err(integrity_source)?.is_some() { + return Err(integrity_error(IntegrityFailureKind::DatabaseInventory)); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn verify_database_metadata( + connection: &Connection, + manifest: &ServiceBackupManifest, + expected: &ServiceDatabaseIdentity, +) -> Result<ServiceDatabaseMetadata, ServiceSqliteError> { + let mut object_statement = connection + .prepare( + "SELECT type + FROM main.sqlite_schema + WHERE name = 'radroots_service_metadata' + LIMIT 2", + ) + .map_err(metadata_source)?; + let mut object_rows = object_statement.query([]).map_err(metadata_source)?; + let object = object_rows + .next() + .map_err(metadata_source)? + .ok_or_else(metadata_error)?; + if !matches!( + object.get_ref(0).map_err(metadata_source)?, + ValueRef::Text(b"table") + ) || object_rows.next().map_err(metadata_source)?.is_some() + { + return Err(metadata_error()); + } + + let application_id: i64 = connection + .pragma_query_value(None, "application_id", |row| row.get(0)) + .map_err(metadata_source)?; + let application_id = u32::try_from(application_id) + .ok() + .and_then(|value| crate::ServiceSqliteApplicationId::new(value).ok()) + .ok_or_else(metadata_error)?; + + let mut statement = connection + .prepare( + "SELECT + CASE WHEN typeof(singleton) = 'integer' THEN singleton END, + CASE WHEN typeof(service_id) = 'text' + AND length(CAST(service_id AS BLOB)) BETWEEN 1 AND ?1 + THEN service_id END, + CASE WHEN typeof(instance_id) = 'text' + AND length(CAST(instance_id AS BLOB)) BETWEEN 1 AND ?1 + THEN instance_id END, + CASE WHEN typeof(source_generation) = 'blob' + AND length(source_generation) = 32 + THEN source_generation END, + CASE WHEN typeof(state_schema_version) = 'integer' + THEN state_schema_version END, + CASE WHEN typeof(created_at_unix_ms) = 'integer' + THEN created_at_unix_ms END + FROM radroots_service_metadata + LIMIT 2", + ) + .map_err(metadata_source)?; + let mut rows = statement + .query([MAX_ID_UTF8_BYTES]) + .map_err(metadata_source)?; + let row = rows + .next() + .map_err(metadata_source)? + .ok_or_else(metadata_error)?; + let singleton: Option<i64> = row.get(0).map_err(metadata_source)?; + let service: Option<String> = row.get(1).map_err(metadata_source)?; + let instance: Option<String> = row.get(2).map_err(metadata_source)?; + let generation: Option<Vec<u8>> = row.get(3).map_err(metadata_source)?; + let schema: Option<i64> = row.get(4).map_err(metadata_source)?; + let created_at: Option<i64> = row.get(5).map_err(metadata_source)?; + if rows.next().map_err(metadata_source)?.is_some() { + return Err(metadata_error()); + } + let (Some(1), Some(service), Some(instance), Some(generation), Some(schema), Some(created_at)) = + (singleton, service, instance, generation, schema, created_at) + else { + return Err(metadata_error()); + }; + let service = ServiceId::new(service).map_err(|_| metadata_error())?; + let instance = InstanceId::new(instance).map_err(|_| metadata_error())?; + let generation = SourceGeneration::new(generation.try_into().map_err(|_| metadata_error())?) + .map_err(|_| metadata_error())?; + let schema = NonZeroU32::new(u32::try_from(schema).map_err(|_| metadata_error())?) + .ok_or_else(metadata_error)?; + let created_at = u64::try_from(created_at).map_err(|_| metadata_error())?; + + if service != *expected.service() + || instance != *expected.instance() + || generation != expected.source_generation() + || application_id != expected.application_id() + || service != *manifest.service() + || instance != *manifest.instance() + || generation != manifest.source_generation() + || schema != manifest.state_schema_version() + || schema > expected.supported_state_schema_version() + { + return Err(metadata_error()); + } + ServiceDatabaseMetadata::from_verified_backup( + service, + instance, + generation, + schema, + created_at, + application_id, + ) + .map_err(|_| metadata_error()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn verify_integrity(connection: &Connection) -> Result<(), ServiceSqliteError> { + let mut statement = connection + .prepare("PRAGMA integrity_check(1)") + .map_err(integrity_source)?; + let mut rows = statement.query([]).map_err(integrity_source)?; + let row = rows + .next() + .map_err(integrity_source)? + .ok_or_else(|| integrity_error(IntegrityFailureKind::Sqlite))?; + let value = row.get_ref(0).map_err(integrity_source)?; + if !matches!( + value, + ValueRef::Text(bytes) + if !bytes.is_empty() + && bytes.len() <= MAX_INTEGRITY_RESULT_UTF8_BYTES + && bytes == b"ok" + ) || rows.next().map_err(integrity_source)?.is_some() + { + return Err(integrity_error(IntegrityFailureKind::Sqlite)); + } + let violation = connection + .query_row("PRAGMA foreign_key_check", [], |_| Ok(())) + .optional() + .map_err(integrity_source)?; + if violation.is_some() { + return Err(integrity_error(IntegrityFailureKind::ForeignKeys)); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum VerificationFailureKind { + Manifest, + ManifestDigest, + Intent, + BundleDirectory, + Inventory, + Permissions, + MemberLength, + MemberDigest, + BindingChanged, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +struct VerificationFailure { + kind: VerificationFailureKind, + source: Option<Box<dyn Error + Send + Sync + 'static>>, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl fmt::Debug for VerificationFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BackupVerificationFailure") + .field("kind", &self.kind) + .field("source", &self.source.as_ref().map(|_| "[redacted]")) + .finish() + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl fmt::Display for VerificationFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + VerificationFailureKind::Manifest => "backup manifest is invalid", + VerificationFailureKind::ManifestDigest => "backup manifest digest does not match", + VerificationFailureKind::Intent => "backup intent does not match", + VerificationFailureKind::BundleDirectory => "backup bundle directory is invalid", + VerificationFailureKind::Inventory => "backup member inventory is invalid", + VerificationFailureKind::Permissions => "backup permissions are invalid", + VerificationFailureKind::MemberLength => "backup member length is invalid", + VerificationFailureKind::MemberDigest => "backup member digest does not match", + VerificationFailureKind::BindingChanged => "backup member binding changed", + }) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Error for VerificationFailure { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source + .as_deref() + .map(|source| source as &(dyn Error + 'static)) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn verification_error(kind: VerificationFailureKind) -> ServiceSqliteError { + ServiceSqliteError::with_source( + ServiceSqliteErrorKind::Backup, + VerificationFailure { kind, source: None }, + ) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn verification_source( + kind: VerificationFailureKind, + source: impl Error + Send + Sync + 'static, +) -> ServiceSqliteError { + ServiceSqliteError::with_source( + ServiceSqliteErrorKind::Backup, + VerificationFailure { + kind, + source: Some(Box::new(source)), + }, + ) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum IntegrityFailureKind { + Policy, + DatabaseInventory, + Sqlite, + ForeignKeys, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[derive(Debug)] +struct IntegrityFailure(IntegrityFailureKind); + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl fmt::Display for IntegrityFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.0 { + IntegrityFailureKind::Policy => "backup SQLite policy is invalid", + IntegrityFailureKind::DatabaseInventory => "backup database inventory is invalid", + IntegrityFailureKind::Sqlite => "backup SQLite integrity is invalid", + IntegrityFailureKind::ForeignKeys => "backup foreign-key integrity is invalid", + }) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Error for IntegrityFailure {} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn integrity_error(kind: IntegrityFailureKind) -> ServiceSqliteError { + ServiceSqliteError::with_source(ServiceSqliteErrorKind::Integrity, IntegrityFailure(kind)) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn integrity_source(source: rusqlite::Error) -> ServiceSqliteError { + ServiceSqliteError::with_source(ServiceSqliteErrorKind::Integrity, source) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn metadata_error() -> ServiceSqliteError { + ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn metadata_source(source: rusqlite::Error) -> ServiceSqliteError { + ServiceSqliteError::with_source(ServiceSqliteErrorKind::Metadata, source) +} + +#[cfg(test)] +mod tests { + #[cfg(any(target_os = "linux", target_os = "macos"))] + use { + super::*, + crate::{ServiceSqliteApplicationId, ServiceSqlitePaths}, + radroots_runtime_paths::{ + RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, + RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, + }, + rusqlite::params, + std::{ + collections::BTreeSet, fs, io::Write, os::unix::fs::PermissionsExt, path::Path, + process::Command, + }, + }; + + #[cfg(any(target_os = "linux", target_os = "macos"))] + struct Fixture { + _root: tempfile::TempDir, + bundle: PathBuf, + paths: ServiceSqlitePaths, + metadata: ServiceDatabaseMetadata, + identity: ServiceDatabaseIdentity, + manifest: ServiceBackupManifest, + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + impl Fixture { + fn new(bundle_name: &str) -> Self { + let root = tempfile::tempdir().expect("temporary root"); + let paths = paths(root.path(), "myc", "primary"); + let metadata = ServiceDatabaseMetadata::new( + &paths, + SourceGeneration::new([9; 32]).expect("source generation"), + NonZeroU32::new(1).expect("schema"), + 1_700_000_000_000, + ServiceSqliteApplicationId::new(0x5244_5351).expect("application ID"), + ) + .expect("metadata"); + let bundle = root.path().join(bundle_name); + fs::create_dir(&bundle).expect("bundle"); + fs::set_permissions(&bundle, fs::Permissions::from_mode(0o700)).expect("bundle mode"); + create_database(&bundle.join(crate::BACKUP_STATE_MEMBER_NAME), &metadata); + let manifest = manifest_for(&bundle, &metadata); + let identity = metadata.identity(); + Self { + _root: root, + bundle, + paths, + metadata, + identity, + manifest, + } + } + + fn maximum(&self) -> NonZeroU64 { + NonZeroU64::new(self.manifest.members()[0].byte_length()).expect("member length") + } + + fn verify(&self) -> Result<VerifiedServiceBackup, ServiceSqliteError> { + verify_backup_bundle( + self.manifest.canonical_bytes(), + self.manifest.digest(), + &self.bundle, + &self.identity, + self.maximum(), + ) + } + + fn refresh_manifest(&mut self) { + self.manifest = manifest_for(&self.bundle, &self.metadata); + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn paths(root: &Path, service: &str, 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(service).expect("service"), + InstanceId::new(instance).expect("instance"), + ) + .expect("context"); + ServiceSqlitePaths::from_runtime_context(&context).expect("paths") + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn create_database(path: &Path, metadata: &ServiceDatabaseMetadata) { + let connection = Connection::open(path).expect("database"); + connection + .execute_batch( + "CREATE TABLE radroots_service_metadata ( + singleton INTEGER PRIMARY KEY, + service_id TEXT NOT NULL, + instance_id TEXT NOT NULL, + source_generation BLOB NOT NULL, + state_schema_version INTEGER NOT NULL, + created_at_unix_ms INTEGER NOT NULL + ); + CREATE TABLE verify_probe (value INTEGER NOT NULL); + INSERT INTO verify_probe (value) VALUES (41), (42);", + ) + .expect("schema"); + connection + .execute( + "INSERT INTO radroots_service_metadata ( + singleton, service_id, instance_id, source_generation, + state_schema_version, created_at_unix_ms + ) VALUES (1, ?1, ?2, ?3, ?4, ?5)", + params![ + metadata.service().as_str(), + metadata.instance().as_str(), + metadata.source_generation().as_bytes().as_slice(), + i64::from(metadata.state_schema_version().get()), + i64::try_from(metadata.created_at_unix_ms()).expect("creation time"), + ], + ) + .expect("metadata row"); + connection + .pragma_update(None, "application_id", metadata.application_id().get()) + .expect("application ID"); + connection.close().expect("close fixture"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("state mode"); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn manifest_for(bundle: &Path, metadata: &ServiceDatabaseMetadata) -> ServiceBackupManifest { + let bytes = fs::read(bundle.join(crate::BACKUP_STATE_MEMBER_NAME)).expect("state bytes"); + ServiceBackupManifest::from_capture( + metadata, + crate::BackupCreatedAtUnixMs::new(1_700_000_000_100).expect("capture time"), + u64::try_from(bytes.len()).expect("length"), + crate::BackupMemberSha256::from_bytes(Sha256::digest(&bytes).into()), + ) + .expect("manifest") + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn digest(bytes: &[u8]) -> BackupManifestSha256 { + BackupManifestSha256::from_bytes(Sha256::digest(bytes).into()) + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn inventory(path: &Path) -> BTreeSet<std::ffi::OsString> { + fs::read_dir(path) + .expect("inventory") + .map(|entry| entry.expect("entry").file_name()) + .collect() + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn verifies_exact_bundle_without_mutating_it_and_redacts_the_capability() { + let fixture = Fixture::new("verified bundle %?#"); + let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); + let before_bytes = fs::read(&state).expect("before bytes"); + let before_modified = fs::metadata(&state) + .expect("before metadata") + .modified() + .expect("before mtime"); + let before_modes = ( + fs::metadata(&fixture.bundle) + .expect("directory metadata") + .permissions() + .mode() + & 0o777, + fs::metadata(&state) + .expect("state metadata") + .permissions() + .mode() + & 0o777, + ); + let before_inventory = inventory(&fixture.bundle); + + let verified = fixture.verify().expect("verified backup"); + assert_eq!(verified.manifest(), &fixture.manifest); + assert_eq!(verified.database_metadata(), &fixture.metadata); + let debug = format!("{verified:?}"); + for forbidden in [ + fixture.bundle.to_string_lossy().as_ref(), + "myc", + "primary", + "52445351", + ] { + assert!(!debug.contains(forbidden)); + } + assert_eq!(fs::read(&state).expect("after bytes"), before_bytes); + assert_eq!( + fs::metadata(&state) + .expect("after metadata") + .modified() + .expect("after mtime"), + before_modified + ); + assert_eq!( + ( + fs::metadata(&fixture.bundle) + .expect("directory metadata") + .permissions() + .mode() + & 0o777, + fs::metadata(&state) + .expect("state metadata") + .permissions() + .mode() + & 0o777, + ), + before_modes + ); + assert_eq!(inventory(&fixture.bundle), before_inventory); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn digest_intent_schema_and_member_size_fail_closed() { + let fixture = Fixture::new("intent"); + let wrong_digest = BackupManifestSha256::from_bytes([7; 32]); + assert_eq!( + verify_backup_bundle( + fixture.manifest.canonical_bytes(), + wrong_digest, + &fixture.bundle, + &fixture.identity, + fixture.maximum(), + ) + .expect_err("digest mismatch") + .kind(), + ServiceSqliteErrorKind::Backup + ); + + let tampered_time = String::from_utf8(fixture.manifest.canonical_bytes().to_vec()) + .expect("manifest text") + .replace("1700000000100", "1700000000101"); + assert_eq!( + verify_backup_bundle( + tampered_time.as_bytes(), + fixture.manifest.digest(), + &fixture.bundle, + &fixture.identity, + fixture.maximum(), + ) + .expect_err("creation time tamper") + .kind(), + ServiceSqliteErrorKind::Backup + ); + + let smaller = NonZeroU64::new(fixture.maximum().get() - 1).expect("smaller limit"); + assert_eq!( + verify_backup_bundle( + fixture.manifest.canonical_bytes(), + fixture.manifest.digest(), + &fixture.bundle, + &fixture.identity, + smaller, + ) + .expect_err("member over caller limit") + .kind(), + ServiceSqliteErrorKind::Backup + ); + + let wrong_generation = ServiceDatabaseIdentity::new( + &fixture.paths, + SourceGeneration::new([8; 32]).expect("generation"), + NonZeroU32::new(1).expect("schema"), + fixture.identity.application_id(), + ); + assert_eq!( + verify_backup_bundle( + fixture.manifest.canonical_bytes(), + fixture.manifest.digest(), + &fixture.bundle, + &wrong_generation, + fixture.maximum(), + ) + .expect_err("generation intent") + .kind(), + ServiceSqliteErrorKind::Backup + ); + + for (service, instance) in [("rhi", "primary"), ("myc", "secondary")] { + let wrong_paths = paths(fixture._root.path(), service, instance); + let wrong_identity = ServiceDatabaseIdentity::new( + &wrong_paths, + fixture.identity.source_generation(), + fixture.identity.supported_state_schema_version(), + fixture.identity.application_id(), + ); + assert_eq!( + verify_backup_bundle( + fixture.manifest.canonical_bytes(), + fixture.manifest.digest(), + &fixture.bundle, + &wrong_identity, + fixture.maximum(), + ) + .expect_err("service-instance intent") + .kind(), + ServiceSqliteErrorKind::Backup + ); + } + + let wrong_application = ServiceDatabaseIdentity::new( + &fixture.paths, + fixture.identity.source_generation(), + NonZeroU32::new(1).expect("schema"), + ServiceSqliteApplicationId::new(7).expect("application"), + ); + assert_eq!( + verify_backup_bundle( + fixture.manifest.canonical_bytes(), + fixture.manifest.digest(), + &fixture.bundle, + &wrong_application, + fixture.maximum(), + ) + .expect_err("application intent") + .kind(), + ServiceSqliteErrorKind::Metadata + ); + + let schema_two = String::from_utf8(fixture.manifest.canonical_bytes().to_vec()) + .expect("manifest text") + .replace("\"state_schema_version\":1", "\"state_schema_version\":2"); + let supports_two = ServiceDatabaseIdentity::new( + &fixture.paths, + fixture.identity.source_generation(), + NonZeroU32::new(2).expect("schema"), + fixture.identity.application_id(), + ); + assert_eq!( + verify_backup_bundle( + schema_two.as_bytes(), + digest(schema_two.as_bytes()), + &fixture.bundle, + &supports_two, + fixture.maximum(), + ) + .expect_err("manifest and database schema mismatch") + .kind(), + ServiceSqliteErrorKind::Metadata + ); + + let too_large = String::from_utf8(fixture.manifest.canonical_bytes().to_vec()) + .expect("manifest text") + .replace( + &format!("\"byte_length\":{}", fixture.maximum().get()), + "\"byte_length\":9223372036854775808", + ); + assert_eq!( + verify_backup_bundle( + too_large.as_bytes(), + digest(too_large.as_bytes()), + &fixture.bundle, + &fixture.identity, + NonZeroU64::new(u64::MAX).expect("maximum"), + ) + .expect_err("signed representation overflow") + .kind(), + ServiceSqliteErrorKind::Backup + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn restrictive_read_only_modes_are_allowed_and_broader_modes_reject() { + for (directory_mode, state_mode) in [ + (0o700, 0o600), + (0o700, 0o400), + (0o500, 0o600), + (0o500, 0o400), + ] { + let fixture = Fixture::new("allowed-modes"); + let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); + fs::set_permissions(&state, fs::Permissions::from_mode(state_mode)) + .expect("state mode"); + fs::set_permissions(&fixture.bundle, fs::Permissions::from_mode(directory_mode)) + .expect("directory mode"); + fixture.verify().expect("restrictive mode verifies"); + } + for directory_mode in [0o400, 0o710, 0o750] { + let fixture = Fixture::new("bad-directory-mode"); + fs::set_permissions(&fixture.bundle, fs::Permissions::from_mode(directory_mode)) + .expect("directory mode"); + assert_eq!( + fixture.verify().expect_err("invalid directory mode").kind(), + ServiceSqliteErrorKind::Backup + ); + } + for state_mode in [0o000, 0o500, 0o640, 0o700] { + let fixture = Fixture::new("bad-state-mode"); + fs::set_permissions( + fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME), + fs::Permissions::from_mode(state_mode), + ) + .expect("state mode"); + assert_eq!( + fixture.verify().expect_err("invalid state mode").kind(), + ServiceSqliteErrorKind::Backup + ); + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn inventory_links_and_same_length_tampering_reject() { + let fixture = Fixture::new("extra"); + fs::write(fixture.bundle.join("state.sqlite-wal"), b"foreign").expect("sidecar"); + assert_eq!( + fixture.verify().expect_err("extra member").kind(), + ServiceSqliteErrorKind::Backup + ); + + let fixture = Fixture::new("hardlink"); + fs::hard_link( + fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME), + fixture.bundle.join("other"), + ) + .expect("hard link"); + assert_eq!( + fixture.verify().expect_err("hard-linked member").kind(), + ServiceSqliteErrorKind::Backup + ); + + let fixture = Fixture::new("missing"); + fs::remove_file(fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME)) + .expect("remove state"); + assert_eq!( + fixture.verify().expect_err("missing member").kind(), + ServiceSqliteErrorKind::Backup + ); + + let fixture = Fixture::new("symlink"); + let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); + let held = fixture.bundle.join("held-state"); + fs::rename(&state, &held).expect("move state"); + std::os::unix::fs::symlink(&held, &state).expect("symlink state"); + assert_eq!( + fixture.verify().expect_err("symlink member").kind(), + ServiceSqliteErrorKind::Backup + ); + + let fixture = Fixture::new("directory-member"); + let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); + fs::remove_file(&state).expect("remove state"); + fs::create_dir(&state).expect("directory member"); + fs::set_permissions(&state, fs::Permissions::from_mode(0o700)) + .expect("directory member mode"); + assert_eq!( + fixture.verify().expect_err("directory member").kind(), + ServiceSqliteErrorKind::Backup + ); + + let fixture = Fixture::new("fifo-member"); + let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); + fs::remove_file(&state).expect("remove state"); + assert!( + Command::new("mkfifo") + .arg(&state) + .status() + .expect("mkfifo") + .success() + ); + fs::set_permissions(&state, fs::Permissions::from_mode(0o600)).expect("fifo mode"); + assert_eq!( + fixture.verify().expect_err("fifo member").kind(), + ServiceSqliteErrorKind::Backup + ); + + let fixture = Fixture::new("tamper"); + let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); + let mut bytes = fs::read(&state).expect("state"); + let index = bytes.len() - 1; + bytes[index] ^= 1; + fs::write(&state, bytes).expect("same-length tamper"); + fs::set_permissions(&state, fs::Permissions::from_mode(0o600)).expect("state mode"); + assert_eq!( + fixture.verify().expect_err("member digest mismatch").kind(), + ServiceSqliteErrorKind::Backup + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn bounded_metadata_and_foreign_key_integrity_reject() { + let mut metadata_fixture = Fixture::new("oversized-metadata"); + let state = metadata_fixture + .bundle + .join(crate::BACKUP_STATE_MEMBER_NAME); + let connection = Connection::open(&state).expect("database"); + connection + .execute( + "UPDATE radroots_service_metadata SET service_id = ?1", + ["x".repeat(129)], + ) + .expect("oversized metadata"); + connection.close().expect("close"); + metadata_fixture.refresh_manifest(); + assert_eq!( + metadata_fixture + .verify() + .expect_err("oversized metadata") + .kind(), + ServiceSqliteErrorKind::Metadata + ); + + let mut view_fixture = Fixture::new("metadata-view"); + let state = view_fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); + let connection = Connection::open(&state).expect("database"); + let generation = "09".repeat(32); + connection + .execute_batch(&format!( + "DROP TABLE radroots_service_metadata; + CREATE VIEW radroots_service_metadata AS + SELECT + 1 AS singleton, + 'myc' AS service_id, + 'primary' AS instance_id, + X'{generation}' AS source_generation, + 1 AS state_schema_version, + 1700000000000 AS created_at_unix_ms;" + )) + .expect("metadata view"); + connection.close().expect("close"); + view_fixture.refresh_manifest(); + assert_eq!( + view_fixture.verify().expect_err("metadata view").kind(), + ServiceSqliteErrorKind::Metadata + ); + + let mut foreign_key_fixture = Fixture::new("foreign-key"); + let state = foreign_key_fixture + .bundle + .join(crate::BACKUP_STATE_MEMBER_NAME); + let connection = Connection::open(&state).expect("database"); + connection + .execute_batch( + "PRAGMA foreign_keys = OFF; + CREATE TABLE parent (id INTEGER PRIMARY KEY); + CREATE TABLE child ( + id INTEGER PRIMARY KEY, + parent_id INTEGER NOT NULL REFERENCES parent(id) + ); + INSERT INTO child (id, parent_id) VALUES (1, 99);", + ) + .expect("foreign-key violation"); + connection.close().expect("close"); + foreign_key_fixture.refresh_manifest(); + assert_eq!( + foreign_key_fixture + .verify() + .expect_err("foreign-key violation") + .kind(), + ServiceSqliteErrorKind::Integrity + ); + + let mut corrupt_fixture = Fixture::new("corrupt-sqlite"); + let state = corrupt_fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); + let connection = Connection::open(&state).expect("database"); + let page_size: i64 = connection + .pragma_query_value(None, "page_size", |row| row.get(0)) + .expect("page size"); + let page_size = u64::try_from(page_size).expect("positive page size"); + let root_page: i64 = connection + .query_row( + "SELECT rootpage FROM sqlite_schema WHERE name = 'verify_probe'", + [], + |row| row.get(0), + ) + .expect("probe root page"); + let root_page = u64::try_from(root_page).expect("positive root page"); + connection.close().expect("close"); + let corrupt_offset = root_page + .checked_sub(1) + .and_then(|page| page.checked_mul(page_size)) + .expect("corrupt offset"); + let mut state_file = fs::OpenOptions::new() + .write(true) + .open(&state) + .expect("open corrupt state"); + state_file + .seek(SeekFrom::Start(corrupt_offset)) + .expect("seek corrupt page"); + state_file.write_all(&[0xff]).expect("corrupt page type"); + state_file.sync_all().expect("sync corruption"); + drop(state_file); + corrupt_fixture.refresh_manifest(); + assert_eq!( + corrupt_fixture + .verify() + .expect_err("corrupt SQLite with trusted hashes") + .kind(), + ServiceSqliteErrorKind::Integrity + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn sqlite_connection_policy_drift_fails_closed() { + let connection = Connection::open_in_memory().expect("memory database"); + apply_connection_policy(&connection).expect("governed policy"); + verify_connection_policy(&connection).expect("policy readback"); + + connection + .pragma_update(None, "trusted_schema", true) + .expect("drift trusted schema"); + assert_eq!( + verify_connection_policy(&connection) + .expect_err("trusted-schema drift") + .kind(), + ServiceSqliteErrorKind::Integrity + ); + + connection + .pragma_update(None, "trusted_schema", false) + .expect("restore trusted schema"); + connection + .pragma_update(None, "query_only", false) + .expect("drift query-only"); + assert_eq!( + verify_connection_policy(&connection) + .expect_err("query-only drift") + .kind(), + ServiceSqliteErrorKind::Integrity + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn attached_database_and_replaced_bindings_fail_closed() { + let connection = Connection::open_in_memory().expect("memory database"); + connection + .execute("ATTACH DATABASE ':memory:' AS extra", []) + .expect("attach"); + assert_eq!( + verify_database_inventory(&connection) + .expect_err("extra attachment") + .kind(), + ServiceSqliteErrorKind::Integrity + ); + + let fixture = Fixture::new("replace-directory"); + let binding = VerifiedBundleBinding::open( + &fixture.bundle, + fixture.manifest.members()[0].byte_length(), + ) + .expect("binding"); + let moved = fixture.bundle.with_extension("held"); + fs::rename(&fixture.bundle, &moved).expect("move held directory"); + fs::create_dir(&fixture.bundle).expect("replacement directory"); + fs::set_permissions(&fixture.bundle, fs::Permissions::from_mode(0o700)) + .expect("replacement mode"); + fs::write( + fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME), + b"replacement", + ) + .expect("replacement state"); + fs::set_permissions( + fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME), + fs::Permissions::from_mode(0o600), + ) + .expect("replacement state mode"); + + let retained_connection = + open_sqlite_from_retained_state(&binding).expect("open retained member"); + apply_connection_policy(&retained_connection).expect("retained connection policy"); + verify_database_inventory(&retained_connection).expect("retained database inventory"); + assert_eq!( + verify_database_metadata(&retained_connection, &fixture.manifest, &fixture.identity,) + .expect("retained metadata"), + fixture.metadata + ); + verify_integrity(&retained_connection).expect("retained integrity"); + retained_connection.close().expect("close retained member"); + + assert_eq!( + binding + .validate() + .expect_err("directory replacement") + .kind(), + ServiceSqliteErrorKind::Backup + ); + let retained = fs::read(moved.join(crate::BACKUP_STATE_MEMBER_NAME)) + .expect("retained member through moved directory"); + let replacement = fs::read(fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME)) + .expect("replacement member"); + assert_ne!(retained, replacement); + assert_eq!(replacement, b"replacement"); + + let fixture = Fixture::new("replace-member"); + let binding = VerifiedBundleBinding::open( + &fixture.bundle, + fixture.manifest.members()[0].byte_length(), + ) + .expect("binding"); + let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); + let moved = fixture.bundle.join("held-state"); + fs::rename(&state, &moved).expect("move held member"); + fs::copy(&moved, &state).expect("replacement member"); + fs::set_permissions(&state, fs::Permissions::from_mode(0o600)).expect("replacement mode"); + assert_eq!( + binding.validate().expect_err("member replacement").kind(), + ServiceSqliteErrorKind::Backup + ); + assert_eq!( + fs::read(&moved).expect("held member"), + fs::read(&state).expect("replacement") + ); + + fs::remove_file(&state).expect("remove replacement"); + assert!( + Command::new("mkfifo") + .arg(&state) + .status() + .expect("mkfifo") + .success() + ); + fs::set_permissions(&state, fs::Permissions::from_mode(0o600)).expect("fifo mode"); + assert_eq!( + binding.validate().expect_err("replacement fifo").kind(), + ServiceSqliteErrorKind::Backup + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn public_diagnostics_do_not_render_untrusted_paths_or_contents() { + let fixture = Fixture::new("secret-path-value"); + fs::write(fixture.bundle.join("secret-extra"), b"secret-content").expect("extra"); + let error = fixture.verify().expect_err("invalid inventory"); + assert_eq!(error.kind(), ServiceSqliteErrorKind::Backup); + for rendered in [error.to_string(), format!("{error:?}")] { + assert!(!rendered.contains("secret-path-value")); + assert!(!rendered.contains("secret-extra")); + assert!(!rendered.contains("secret-content")); + } + } +} diff --git a/crates/service_sqlite/src/connection.rs b/crates/service_sqlite/src/connection.rs @@ -1094,6 +1094,19 @@ mod tests { .await .expect("online backup"); + let verified = crate::verify_backup_bundle( + manifest.canonical_bytes(), + manifest.digest(), + &stage, + &identity, + std::num::NonZeroU64::new(manifest.members()[0].byte_length()) + .expect("positive captured member length"), + ) + .expect("independently verify captured bundle"); + assert_eq!(verified.manifest(), &manifest); + assert_eq!(verified.database_metadata().service(), identity.service()); + assert_eq!(verified.database_metadata().instance(), identity.instance()); + assert_eq!(manifest.service(), paths.service()); assert_eq!(manifest.instance(), paths.instance()); assert_eq!(manifest.source_generation(), identity.source_generation()); diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs @@ -20,7 +20,7 @@ pub use backup::{ BACKUP_MANIFEST_CANONICAL_MAX_BYTES, BACKUP_MANIFEST_SCHEMA, BACKUP_MANIFEST_SCHEMA_VERSION, BACKUP_STATE_MEMBER_NAME, BackupCreatedAtUnixMs, BackupManifestContractError, BackupManifestIntegrity, BackupManifestSha256, BackupMemberSha256, ServiceBackupManifest, - ServiceBackupMember, + ServiceBackupMember, VerifiedServiceBackup, verify_backup_bundle, }; pub use config::{ServiceSqliteConnectionOptions, ServiceSqliteConnectionOptionsError}; pub use connection::{ diff --git a/crates/service_sqlite/src/metadata.rs b/crates/service_sqlite/src/metadata.rs @@ -77,6 +77,28 @@ pub struct ServiceDatabaseIdentity { } impl ServiceDatabaseMetadata { + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) fn from_verified_backup( + service: ServiceId, + instance: InstanceId, + source_generation: SourceGeneration, + state_schema_version: NonZeroU32, + created_at_unix_ms: u64, + application_id: ServiceSqliteApplicationId, + ) -> Result<Self, ServiceSqliteMetadataValueError> { + if created_at_unix_ms == 0 || created_at_unix_ms > MAX_CREATED_AT_UNIX_MS { + return Err(ServiceSqliteMetadataValueError::InvalidCreationTime); + } + Ok(Self { + service, + instance, + source_generation, + state_schema_version, + created_at_unix_ms, + application_id, + }) + } + /// Constructs metadata bound to the service and instance in canonical paths. pub fn new( paths: &ServiceSqlitePaths, diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs @@ -6,6 +6,7 @@ const ROOT: &str = include_str!("../src/lib.rs"); const AUTHORITY_SOURCE: &str = include_str!("../src/authority.rs"); const BACKUP_SOURCE: &str = include_str!("../src/backup/manifest.rs"); const BACKUP_CAPTURE_SOURCE: &str = include_str!("../src/backup/capture.rs"); +const BACKUP_VERIFY_SOURCE: &str = include_str!("../src/backup/verify.rs"); const CONFIG_SOURCE: &str = include_str!("../src/config.rs"); const CONNECTION_SOURCE: &str = include_str!("../src/connection.rs"); const ERROR_SOURCE: &str = include_str!("../src/error.rs"); @@ -123,6 +124,16 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "Capture has no hidden timeout", "callers own any deadline by cancelling the future", "does not provide restore or replacement behavior", + "`verify_backup_bundle` is the synchronous, task-free boundary", + "independently protected manifest SHA-256", + "positive maximum state-file size", + "restrictive owner-only directory containing only `state.sqlite`", + "performs no filesystem mutation and does not create a task or hidden deadline", + "non-forgeable `VerifiedServiceBackup`", + "exposing only the canonical manifest and actual database metadata", + "It is not restore or replacement authority", + "copy from the retained member and reverify the staged copy", + "pathname verification alone is insufficient", ] { assert!( readme_words.contains(required), @@ -153,6 +164,9 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { .split_once("#[cfg(test)]\nmod tests") .map(|(production, _)| production) .expect("backup capture source must keep tests separated"); + let backup_verify_production = BACKUP_VERIFY_SOURCE + .split_once("#[cfg(test)]") + .map_or(BACKUP_VERIFY_SOURCE, |(production, _)| production); for required in [ "ServiceSqliteErrorCode", @@ -167,6 +181,8 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "BackupMemberSha256", "ServiceBackupManifest", "ServiceBackupMember", + "VerifiedServiceBackup", + "verify_backup_bundle", "BACKUP_MANIFEST_CANONICAL_MAX_BYTES", "BACKUP_MANIFEST_SCHEMA", "BACKUP_MANIFEST_SCHEMA_VERSION", @@ -399,6 +415,60 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { } for required in [ + "pub fn verify_backup_bundle(", + "NonZeroU64", + "pub struct VerifiedServiceBackup", + "pub const fn manifest(&self)", + "pub const fn database_metadata(&self)", + "Sha256::digest(manifest_bytes)", + "BACKUP_MANIFEST_CANONICAL_MAX_BYTES", + "OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC", + "OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC", + "Dir::read_from(&self.directory)", + "matches!(mode, 0o500 | 0o700)", + "matches!(mode, 0o400 | 0o600)", + "open_sqlite_from_retained_state", + "/proc/self/fd/{descriptor}", + "/dev/fd/{descriptor}", + "?mode=ro&immutable=1", + "pragma_update(None, \"query_only\", true)", + "pragma_update(None, \"trusted_schema\", false)", + "verify_connection_policy", + "PRAGMA database_list", + "FROM main.sqlite_schema", + "ValueRef::Text(b\"table\")", + "PRAGMA integrity_check(1)", + "PRAGMA foreign_key_check", + "state_schema_version() > expected.supported_state_schema_version()", + "binding.hash_state(maximum_state_bytes)", + ] { + assert!( + backup_verify_production.contains(required), + "Step 065 backup verifier is missing `{required}`" + ); + } + for forbidden in [ + "tokio::task::spawn_blocking", + "pub fn state_file", + "pub fn directory", + "pub fn path", + "pub fn restore", + "pub async fn restore", + "ServiceSqliteErrorKind::Restore", + "create_dir", + "remove_file", + "std::fs::copy", + "rename", + "SystemTime::now", + "tokio::time::timeout", + ] { + assert!( + !backup_verify_production.contains(forbidden), + "Step 065 backup verifier contains deferred or raw authority `{forbidden}`" + ); + } + + for required in [ "self.pool.close().await", "PRAGMA wal_checkpoint(TRUNCATE)", "if busy == 0",