lib

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

commit 2ed63b905dd3a70b52f94c80a4144f2f074d8ad1
parent f14b25fd3b1c0271fa90e779d66368e012d82b30
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 20:41:42 +0000

service-sqlite: define restore recovery markers

- freeze canonical marker identity, checksum framing, and phase transitions
- bind recovery evidence to actual backup metadata and artifact identities
- persist markers through authority-checked create-new atomic replacement
- qualify tamper, collision, replacement, and durability failure behavior

Diffstat:
MAGENTS.md | 11+++++++++++
Mcrates/service_sqlite/README.md | 28++++++++++++++++++++++++++++
Mcrates/service_sqlite/src/lib.rs | 1+
Acrates/service_sqlite/src/restore/marker.rs | 1695+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/service_sqlite/src/restore/mod.rs | 13+++++++++++++
Mcrates/service_sqlite/tests/package_boundary.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 1825 insertions(+), 0 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -194,6 +194,17 @@ Before editing code: 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. +- Restore recovery markers are private `radroots_service_sqlite` mechanics. + Preserve the fixed sibling names, exact 2,048-byte canonical v1 JSON, + domain-separated self-checksum, typed database and backup intent, and the + only legal durable sequence `prepared -> live_retained -> + replacement_installed`. Marker creation and advancement require retained + writer authority, descriptor-relative owner-only files, exact inode + revalidation, file and parent synchronization, and create-new scratch plus + atomic replacement. Reads never repair or remove evidence. Do not expose + marker types or paths, truncate markers in place, accept caller-selected + names, or move, copy, open, or delete a database in the marker checkpoint; + restore staging, replacement, and open-time recovery remain separate steps. - 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 @@ -92,6 +92,34 @@ 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. +Restore crash recovery uses a private sealed v1 marker stored beside canonical +service state. Its fixed layout names the live `state.sqlite`, staged +`state.restore-staged.sqlite`, retained `state.restore-backup.sqlite`, durable +`state.restore-marker.v1`, and create-new update scratch +`state.restore-marker.v1.next`. The compact canonical JSON is capped at 2,048 +bytes and binds typed database intent, the protected source-manifest digest, +and exact live, staged, and retained-backup device, inode, length, and SHA-256 +expectations. A domain-separated checksum binds the canonical fields and +detects corruption; it is not an authenticity credential. + +The only legal durable sequence is `prepared` to `live_retained` to +`replacement_installed`; repeating the current phase is byte-idempotent and +every skip, reversal, or post-install transition fails closed. Marker files are +descriptor-relative, no-follow, single-link, owner-owned regular files with +mode `0600`. Creation synchronizes the file and state directory. Advancement +compare-and-reloads the current bytes, writes and synchronizes the fixed +create-new scratch, atomically replaces the marker, synchronizes the directory, +and reopens the exact new bytes. Stale scratch, tamper, collision, binding +replacement, insecure directory, or malformed marker remains evidence and +fails closed; reads do not repair or remove it. + +This marker checkpoint does not stage, copy, open, rename, replace, or delete a +database. Later restore staging must consume a retained verified backup; +replacement must invoke the marker sequence around its governed renames; and +open-time recovery must reconcile durable marker and artifact identities before +removing any recovery evidence. No marker type, path, raw descriptor, or store +operation is public API. + 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/lib.rs b/crates/service_sqlite/src/lib.rs @@ -12,6 +12,7 @@ mod integrity; mod metadata; mod migration; mod open; +mod restore; mod status; mod transaction_control; diff --git a/crates/service_sqlite/src/restore/marker.rs b/crates/service_sqlite/src/restore/marker.rs @@ -0,0 +1,1695 @@ +//! Sealed v1 restore-recovery marker and durable descriptor-relative store. + +#![allow(dead_code)] // Step 066 freezes private primitives consumed by Steps 067-069. + +use core::{fmt, num::NonZeroU32}; +use std::{error::Error, path::PathBuf}; + +#[cfg(test)] +use std::path::Path; + +use radroots_runtime_paths::{InstanceId, ServiceId}; +use radroots_storage::event::SourceGeneration; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{ + BackupManifestSha256, ServiceDatabaseMetadata, ServiceSqliteApplicationId, ServiceSqlitePaths, +}; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use crate::{ServiceSqliteError, ServiceSqliteErrorKind, WriterAuthority}; + +const RESTORE_MARKER_SCHEMA: &str = "radroots.service-sqlite.restore-marker"; +const RESTORE_MARKER_SCHEMA_VERSION: u32 = 1; +const RESTORE_MARKER_MAX_BYTES: usize = 2_048; +const RESTORE_MARKER_CHECKSUM_DOMAIN: &[u8] = b"radroots.service_sqlite.restore_marker.v1\0"; +const LIVE_FILE_NAME: &str = radroots_runtime_paths::SERVICE_STATE_DATABASE_FILE_NAME; +const STAGED_FILE_NAME: &str = "state.restore-staged.sqlite"; +const BACKUP_FILE_NAME: &str = "state.restore-backup.sqlite"; +const MARKER_FILE_NAME: &str = "state.restore-marker.v1"; +const MARKER_NEXT_FILE_NAME: &str = "state.restore-marker.v1.next"; + +/// Exact retained identity and content expected for one restore artifact. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) struct RestoreArtifactExpectation { + device: u64, + inode: u64, + byte_length: u64, + sha256: [u8; 32], +} + +impl RestoreArtifactExpectation { + pub(crate) const fn new( + device: u64, + inode: u64, + byte_length: u64, + sha256: [u8; 32], + ) -> Result<Self, RestoreMarkerContractError> { + if byte_length == 0 || byte_length > i64::MAX as u64 { + return Err(RestoreMarkerContractError::InvalidIdentity); + } + Ok(Self { + device, + inode, + byte_length, + sha256, + }) + } +} + +impl fmt::Debug for RestoreArtifactExpectation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RestoreArtifactExpectation([redacted])") + } +} + +/// Durable phases in the v1 restore-replacement protocol. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RestoreRecoveryPhase { + Prepared, + LiveRetained, + ReplacementInstalled, +} + +impl RestoreRecoveryPhase { + const fn as_str(self) -> &'static str { + match self { + Self::Prepared => "prepared", + Self::LiveRetained => "live_retained", + Self::ReplacementInstalled => "replacement_installed", + } + } + + fn parse(value: &str) -> Result<Self, RestoreMarkerContractError> { + match value { + "prepared" => Ok(Self::Prepared), + "live_retained" => Ok(Self::LiveRetained), + "replacement_installed" => Ok(Self::ReplacementInstalled), + _ => Err(RestoreMarkerContractError::UnsupportedValue), + } + } + + const fn may_transition_to(self, next: Self) -> bool { + self as u8 == next as u8 + || matches!( + (self, next), + (Self::Prepared, Self::LiveRetained) + | (Self::LiveRetained, Self::ReplacementInstalled) + ) + } +} + +/// Fixed recovery-artifact layout adjacent to canonical service state. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct RestoreRecoveryLayout { + state_directory: PathBuf, + live: PathBuf, + staged: PathBuf, + backup: PathBuf, + marker: PathBuf, + marker_next: PathBuf, +} + +impl RestoreRecoveryLayout { + pub(crate) fn for_paths( + paths: &ServiceSqlitePaths, + ) -> Result<Self, RestoreMarkerContractError> { + let live = paths.state_database(); + let state_directory = live + .parent() + .filter(|parent| Some(*parent) == paths.state_lock().parent()) + .filter(|_| live.file_name().is_some_and(|name| name == LIVE_FILE_NAME)) + .filter(|parent| parent.is_absolute()) + .ok_or(RestoreMarkerContractError::InvalidLayout)?; + Ok(Self { + state_directory: state_directory.to_path_buf(), + live: state_directory.join(LIVE_FILE_NAME), + staged: state_directory.join(STAGED_FILE_NAME), + backup: state_directory.join(BACKUP_FILE_NAME), + marker: state_directory.join(MARKER_FILE_NAME), + marker_next: state_directory.join(MARKER_NEXT_FILE_NAME), + }) + } + + #[cfg(test)] + fn file_names(&self) -> [&str; 5] { + [ + LIVE_FILE_NAME, + STAGED_FILE_NAME, + BACKUP_FILE_NAME, + MARKER_FILE_NAME, + MARKER_NEXT_FILE_NAME, + ] + } +} + +impl fmt::Debug for RestoreRecoveryLayout { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RestoreRecoveryLayout([redacted])") + } +} + +/// Immutable canonical v1 restore-recovery marker. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct RestoreRecoveryMarker { + phase: RestoreRecoveryPhase, + service: ServiceId, + instance: InstanceId, + source_generation: SourceGeneration, + state_schema_version: NonZeroU32, + application_id: ServiceSqliteApplicationId, + source_manifest_sha256: BackupManifestSha256, + live: RestoreArtifactExpectation, + staged: RestoreArtifactExpectation, + backup: RestoreArtifactExpectation, + canonical_bytes: Box<[u8]>, +} + +impl RestoreRecoveryMarker { + #[allow(clippy::too_many_arguments)] + pub(crate) fn prepared( + metadata: &ServiceDatabaseMetadata, + source_manifest_sha256: BackupManifestSha256, + live: RestoreArtifactExpectation, + staged: RestoreArtifactExpectation, + ) -> Result<Self, RestoreMarkerContractError> { + Self::build( + RestoreRecoveryPhase::Prepared, + metadata.service().clone(), + metadata.instance().clone(), + metadata.source_generation(), + metadata.state_schema_version(), + metadata.application_id(), + source_manifest_sha256, + live, + staged, + live, + ) + } + + pub(crate) fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, RestoreMarkerContractError> { + if bytes.is_empty() { + return Err(RestoreMarkerContractError::MalformedEncoding); + } + if bytes.len() > RESTORE_MARKER_MAX_BYTES { + return Err(RestoreMarkerContractError::MarkerTooLarge); + } + let wire: WireMarker = serde_json::from_slice(bytes) + .map_err(|_| RestoreMarkerContractError::MalformedEncoding)?; + if wire.schema != RESTORE_MARKER_SCHEMA { + return Err(RestoreMarkerContractError::UnsupportedValue); + } + if wire.schema_version != RESTORE_MARKER_SCHEMA_VERSION { + return Err(RestoreMarkerContractError::UnsupportedValue); + } + let marker = Self::build( + RestoreRecoveryPhase::parse(&wire.phase)?, + ServiceId::new(wire.service) + .map_err(|_| RestoreMarkerContractError::InvalidIdentity)?, + InstanceId::new(wire.instance) + .map_err(|_| RestoreMarkerContractError::InvalidIdentity)?, + SourceGeneration::new(decode_hex_32(&wire.source_generation)?) + .map_err(|_| RestoreMarkerContractError::InvalidIdentity)?, + NonZeroU32::new(wire.state_schema_version) + .ok_or(RestoreMarkerContractError::InvalidIdentity)?, + ServiceSqliteApplicationId::new(wire.application_id) + .map_err(|_| RestoreMarkerContractError::InvalidIdentity)?, + BackupManifestSha256::from_bytes(decode_hex_32(&wire.source_manifest_sha256)?), + RestoreArtifactExpectation::try_from(wire.live)?, + RestoreArtifactExpectation::try_from(wire.staged)?, + RestoreArtifactExpectation::try_from(wire.backup)?, + )?; + let claimed = decode_hex_32(&wire.marker_sha256)?; + let actual = marker_checksum(&marker.payload_bytes()?); + if claimed != actual { + return Err(RestoreMarkerContractError::ChecksumMismatch); + } + if marker.canonical_bytes.as_ref() != bytes { + return Err(RestoreMarkerContractError::NonCanonicalEncoding); + } + Ok(marker) + } + + pub(crate) fn canonical_bytes(&self) -> &[u8] { + &self.canonical_bytes + } + + pub(crate) const fn phase(&self) -> RestoreRecoveryPhase { + self.phase + } + + pub(crate) fn transitioned_to( + &self, + next: RestoreRecoveryPhase, + ) -> Result<Self, RestoreMarkerContractError> { + if !self.phase.may_transition_to(next) { + return Err(RestoreMarkerContractError::IllegalTransition); + } + if self.phase == next { + return Ok(self.clone()); + } + Self::build( + next, + self.service.clone(), + self.instance.clone(), + self.source_generation, + self.state_schema_version, + self.application_id, + self.source_manifest_sha256, + self.live, + self.staged, + self.backup, + ) + } + + #[allow(clippy::too_many_arguments)] + fn build( + phase: RestoreRecoveryPhase, + service: ServiceId, + instance: InstanceId, + source_generation: SourceGeneration, + state_schema_version: NonZeroU32, + application_id: ServiceSqliteApplicationId, + source_manifest_sha256: BackupManifestSha256, + live: RestoreArtifactExpectation, + staged: RestoreArtifactExpectation, + backup: RestoreArtifactExpectation, + ) -> Result<Self, RestoreMarkerContractError> { + if live != backup || (live.device, live.inode) == (staged.device, staged.inode) { + return Err(RestoreMarkerContractError::InvalidIdentity); + } + let mut marker = Self { + phase, + service, + instance, + source_generation, + state_schema_version, + application_id, + source_manifest_sha256, + live, + staged, + backup, + canonical_bytes: Box::new([]), + }; + let payload = marker.payload_bytes()?; + let checksum = encode_hex(&marker_checksum(&payload)); + let canonical = marker.wire(&checksum); + let canonical_bytes = serde_json::to_vec(&canonical) + .map_err(|_| RestoreMarkerContractError::EncodingFailure)?; + if canonical_bytes.len() > RESTORE_MARKER_MAX_BYTES { + return Err(RestoreMarkerContractError::MarkerTooLarge); + } + marker.canonical_bytes = canonical_bytes.into_boxed_slice(); + Ok(marker) + } + + fn payload_bytes(&self) -> Result<Vec<u8>, RestoreMarkerContractError> { + serde_json::to_vec(&self.payload()).map_err(|_| RestoreMarkerContractError::EncodingFailure) + } + + fn payload(&self) -> CanonicalPayload<'_> { + CanonicalPayload { + schema: RESTORE_MARKER_SCHEMA, + schema_version: RESTORE_MARKER_SCHEMA_VERSION, + phase: self.phase.as_str(), + service: self.service.as_str(), + instance: self.instance.as_str(), + source_generation: encode_hex(self.source_generation.as_bytes()), + state_schema_version: self.state_schema_version.get(), + application_id: self.application_id.get(), + source_manifest_sha256: encode_hex(self.source_manifest_sha256.as_bytes()), + live: self.live.into(), + staged: self.staged.into(), + backup: self.backup.into(), + } + } + + fn wire<'a>(&'a self, marker_sha256: &'a str) -> CanonicalMarker<'a> { + CanonicalMarker { + payload: self.payload(), + marker_sha256, + } + } + + fn matches_paths(&self, paths: &ServiceSqlitePaths) -> bool { + self.service == *paths.service() && self.instance == *paths.instance() + } +} + +impl fmt::Debug for RestoreRecoveryMarker { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RestoreRecoveryMarker") + .field("schema_version", &RESTORE_MARKER_SCHEMA_VERSION) + .field("phase", &self.phase) + .finish_non_exhaustive() + } +} + +/// Source-free validation failure for private restore markers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RestoreMarkerContractError { + MarkerTooLarge, + MalformedEncoding, + NonCanonicalEncoding, + EncodingFailure, + UnsupportedValue, + ChecksumMismatch, + InvalidIdentity, + InvalidLayout, + IllegalTransition, +} + +impl fmt::Display for RestoreMarkerContractError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::MarkerTooLarge => "restore marker exceeds its byte limit", + Self::MalformedEncoding => "restore marker encoding is malformed", + Self::NonCanonicalEncoding => "restore marker encoding is not canonical", + Self::EncodingFailure => "restore marker could not be encoded", + Self::UnsupportedValue => "restore marker schema or value is unsupported", + Self::ChecksumMismatch => "restore marker checksum does not match", + Self::InvalidIdentity => "restore marker identity is invalid", + Self::InvalidLayout => "restore recovery layout is invalid", + Self::IllegalTransition => "restore marker transition is illegal", + }) + } +} + +impl Error for RestoreMarkerContractError {} + +#[derive(Serialize)] +struct CanonicalPayload<'a> { + schema: &'static str, + schema_version: u32, + phase: &'static str, + service: &'a str, + instance: &'a str, + source_generation: String, + state_schema_version: u32, + application_id: u32, + source_manifest_sha256: String, + live: CanonicalArtifact, + staged: CanonicalArtifact, + backup: CanonicalArtifact, +} + +#[derive(Serialize)] +struct CanonicalMarker<'a> { + #[serde(flatten)] + payload: CanonicalPayload<'a>, + marker_sha256: &'a str, +} + +#[derive(Clone, Serialize)] +struct CanonicalArtifact { + device: u64, + inode: u64, + byte_length: u64, + sha256: String, +} + +impl From<RestoreArtifactExpectation> for CanonicalArtifact { + fn from(value: RestoreArtifactExpectation) -> Self { + Self { + device: value.device, + inode: value.inode, + byte_length: value.byte_length, + sha256: encode_hex(&value.sha256), + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct WireMarker { + schema: String, + schema_version: u32, + phase: String, + service: String, + instance: String, + source_generation: String, + state_schema_version: u32, + application_id: u32, + source_manifest_sha256: String, + live: WireArtifact, + staged: WireArtifact, + backup: WireArtifact, + marker_sha256: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct WireArtifact { + device: u64, + inode: u64, + byte_length: u64, + sha256: String, +} + +impl TryFrom<WireArtifact> for RestoreArtifactExpectation { + type Error = RestoreMarkerContractError; + + fn try_from(value: WireArtifact) -> Result<Self, Self::Error> { + Self::new( + value.device, + value.inode, + value.byte_length, + decode_hex_32(&value.sha256)?, + ) + } +} + +fn marker_checksum(payload: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(RESTORE_MARKER_CHECKSUM_DOMAIN); + hasher.update( + u64::try_from(payload.len()) + .expect("bounded marker payload") + .to_be_bytes(), + ); + hasher.update(payload); + hasher.finalize().into() +} + +fn encode_hex(bytes: &[u8; 32]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(64); + for byte in bytes { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output +} + +fn decode_hex_32(value: &str) -> Result<[u8; 32], RestoreMarkerContractError> { + if value.len() != 64 { + return Err(RestoreMarkerContractError::InvalidIdentity); + } + let mut output = [0_u8; 32]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + output[index] = (decode_nibble(pair[0])? << 4) | decode_nibble(pair[1])?; + } + Ok(output) +} + +fn decode_nibble(value: u8) -> Result<u8, RestoreMarkerContractError> { + match value { + b'0'..=b'9' => Ok(value - b'0'), + b'a'..=b'f' => Ok(value - b'a' + 10), + _ => Err(RestoreMarkerContractError::InvalidIdentity), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod store { + use std::{ + fs::File, + io::{Read, Seek, SeekFrom, Write}, + }; + + use rustix::{ + fs::{ + AtFlags, FileType, Mode, OFlags, fchmod, fstat, open, openat, renameat, statat, + unlinkat, + }, + io::Errno, + process::geteuid, + }; + + use super::*; + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct FileIdentity { + device: u64, + inode: u64, + } + + pub(crate) struct RestoreMarkerBinding { + directory: File, + directory_identity: FileIdentity, + marker_file: File, + marker_identity: FileIdentity, + marker: RestoreRecoveryMarker, + } + + impl fmt::Debug for RestoreMarkerBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RestoreMarkerBinding") + .field("phase", &self.marker.phase()) + .field("artifacts", &"[redacted]") + .finish() + } + } + + impl RestoreMarkerBinding { + pub(crate) fn create( + paths: &ServiceSqlitePaths, + authority: &WriterAuthority, + marker: &RestoreRecoveryMarker, + ) -> Result<Self, ServiceSqliteError> { + authority.validate_for(paths)?; + if !marker.matches_paths(paths) { + return Err(restore_contract( + RestoreMarkerContractError::InvalidIdentity, + )); + } + if marker.phase() != RestoreRecoveryPhase::Prepared { + return Err(restore_contract( + RestoreMarkerContractError::IllegalTransition, + )); + } + let layout = RestoreRecoveryLayout::for_paths(paths).map_err(restore_contract)?; + let directory = authority_checked(authority, paths, || { + authority + .directory() + .try_clone() + .map_err(|_| StoreFailure::Directory) + })? + .map_err(restore_store)?; + let directory_identity = + authority_checked(authority, paths, || validate_directory(&directory))? + .map_err(restore_store)?; + authority_checked(authority, paths, || { + require_absent(&directory, MARKER_NEXT_FILE_NAME) + })? + .map_err(restore_store)?; + let (marker_file, marker_identity) = authority_checked(authority, paths, || { + let file = create_marker_file(&directory, MARKER_FILE_NAME)?; + let identity = file_identity(&file)?; + Ok::<_, StoreFailure>((file, identity)) + })? + .map_err(restore_store)?; + let operations = SystemStoreOperations; + let write_result = authority_checked(authority, paths, || { + write_and_sync( + &marker_file, + marker.canonical_bytes(), + &directory, + &operations, + ) + })?; + if let Err(cause) = write_result { + cleanup_with_authority( + authority, + paths, + &directory, + MARKER_FILE_NAME, + marker_identity, + )?; + return Err(restore_store(cause)); + } + let parent_sync = authority_checked(authority, paths, || { + operations + .sync_directory(&directory) + .map_err(|_| StoreFailure::Sync) + })?; + if parent_sync.is_err() { + cleanup_with_authority( + authority, + paths, + &directory, + MARKER_FILE_NAME, + marker_identity, + )?; + return Err(restore_store(StoreFailure::Sync)); + } + let binding = Self { + directory, + directory_identity, + marker_file, + marker_identity, + marker: marker.clone(), + }; + authority_checked(authority, paths, || binding.validate_for_restore(paths))??; + if layout + .marker + .file_name() + .is_none_or(|name| name != MARKER_FILE_NAME) + { + return Err(restore_contract(RestoreMarkerContractError::InvalidLayout)); + } + Ok(binding) + } + + pub(crate) fn load(paths: &ServiceSqlitePaths) -> Result<Option<Self>, ServiceSqliteError> { + let layout = RestoreRecoveryLayout::for_paths(paths).map_err(recovery_contract)?; + let directory = open( + &layout.state_directory, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|_| recovery_store(StoreFailure::Directory))?; + let directory = File::from(directory); + let directory_identity = validate_directory(&directory).map_err(recovery_store)?; + require_absent(&directory, MARKER_NEXT_FILE_NAME).map_err(recovery_store)?; + let marker_file = match open_marker_file(&directory, MARKER_FILE_NAME) { + Ok(file) => file, + Err(StoreFailure::Missing) => return Ok(None), + Err(cause) => return Err(recovery_store(cause)), + }; + let marker_identity = file_identity(&marker_file).map_err(recovery_store)?; + let marker = read_marker(&marker_file).map_err(recovery_store)?; + if !marker.matches_paths(paths) { + return Err(recovery_contract( + RestoreMarkerContractError::InvalidIdentity, + )); + } + let binding = Self { + directory, + directory_identity, + marker_file, + marker_identity, + marker, + }; + binding.validate(paths)?; + Ok(Some(binding)) + } + + pub(crate) fn advance( + self, + paths: &ServiceSqlitePaths, + authority: &WriterAuthority, + next: RestoreRecoveryPhase, + ) -> Result<Self, ServiceSqliteError> { + self.advance_with_operations(paths, authority, next, &SystemStoreOperations) + } + + fn advance_with_operations( + self, + paths: &ServiceSqlitePaths, + authority: &WriterAuthority, + next: RestoreRecoveryPhase, + operations: &dyn StoreOperations, + ) -> Result<Self, ServiceSqliteError> { + authority_checked(authority, paths, || self.validate_for_restore(paths))??; + let current = authority_checked(authority, paths, || read_marker(&self.marker_file))? + .map_err(restore_store)?; + if current.canonical_bytes() != self.marker.canonical_bytes() { + return Err(restore_store(StoreFailure::Conflict)); + } + let next_marker = self + .marker + .transitioned_to(next) + .map_err(restore_contract)?; + if next_marker.canonical_bytes() == self.marker.canonical_bytes() { + return Ok(self); + } + authority_checked(authority, paths, || { + require_absent(&self.directory, MARKER_NEXT_FILE_NAME) + })? + .map_err(restore_store)?; + let (scratch, scratch_identity) = authority_checked(authority, paths, || { + let file = create_marker_file(&self.directory, MARKER_NEXT_FILE_NAME)?; + let identity = file_identity(&file)?; + Ok::<_, StoreFailure>((file, identity)) + })? + .map_err(restore_store)?; + let scratch_write = authority_checked(authority, paths, || { + write_and_sync( + &scratch, + next_marker.canonical_bytes(), + &self.directory, + operations, + ) + })?; + if let Err(cause) = scratch_write { + cleanup_with_authority( + authority, + paths, + &self.directory, + MARKER_NEXT_FILE_NAME, + scratch_identity, + )?; + return Err(restore_store(cause)); + } + authority_checked(authority, paths, || { + self.validate_inner(paths, false, ServiceSqliteErrorKind::Restore) + })??; + let scratch_matches = authority_checked(authority, paths, || { + let current = open_marker_file(&self.directory, MARKER_NEXT_FILE_NAME)?; + Ok::<_, StoreFailure>( + file_identity(&scratch)? == scratch_identity + && file_identity(&current)? == scratch_identity, + ) + })? + .map_err(restore_store)?; + if !scratch_matches { + cleanup_with_authority( + authority, + paths, + &self.directory, + MARKER_NEXT_FILE_NAME, + scratch_identity, + )?; + return Err(restore_store(StoreFailure::Conflict)); + } + let replacement = authority_checked(authority, paths, || { + operations + .replace_marker(&self.directory) + .map_err(|_| StoreFailure::Rename) + })?; + if replacement.is_err() { + cleanup_with_authority( + authority, + paths, + &self.directory, + MARKER_NEXT_FILE_NAME, + scratch_identity, + )?; + return Err(restore_store(StoreFailure::Rename)); + } + let parent_sync = authority_checked(authority, paths, || { + operations + .sync_directory(&self.directory) + .map_err(|_| StoreFailure::Sync) + })?; + if parent_sync.is_err() { + return Err(restore_store(StoreFailure::Sync)); + } + let (marker_file, marker_identity, reread) = + authority_checked(authority, paths, || { + let file = open_marker_file(&self.directory, MARKER_FILE_NAME)?; + let identity = file_identity(&file)?; + let marker = read_marker(&file)?; + Ok::<_, StoreFailure>((file, identity, marker)) + })? + .map_err(restore_store)?; + if reread.canonical_bytes() != next_marker.canonical_bytes() { + return Err(restore_store(StoreFailure::Conflict)); + } + let binding = Self { + directory: self.directory, + directory_identity: self.directory_identity, + marker_file, + marker_identity, + marker: next_marker, + }; + authority_checked(authority, paths, || binding.validate_for_restore(paths))??; + Ok(binding) + } + + #[cfg(test)] + pub(crate) fn test_advance_with_failure( + self, + paths: &ServiceSqlitePaths, + authority: &WriterAuthority, + next: RestoreRecoveryPhase, + failure: TestStoreFailure, + ) -> Result<Self, ServiceSqliteError> { + self.advance_with_operations( + paths, + authority, + next, + &FailingStoreOperations { failure }, + ) + } + + pub(crate) const fn marker(&self) -> &RestoreRecoveryMarker { + &self.marker + } + + pub(crate) fn validate( + &self, + paths: &ServiceSqlitePaths, + ) -> Result<(), ServiceSqliteError> { + self.validate_inner(paths, true, ServiceSqliteErrorKind::Recovery) + } + + fn validate_for_restore( + &self, + paths: &ServiceSqlitePaths, + ) -> Result<(), ServiceSqliteError> { + self.validate_inner(paths, true, ServiceSqliteErrorKind::Restore) + } + + fn validate_inner( + &self, + paths: &ServiceSqlitePaths, + require_no_scratch: bool, + operation_kind: ServiceSqliteErrorKind, + ) -> Result<(), ServiceSqliteError> { + let layout = RestoreRecoveryLayout::for_paths(paths) + .map_err(|cause| operation_contract(operation_kind, cause))?; + let current_directory = open( + &layout.state_directory, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|_| authority_store(StoreFailure::Directory))?; + let current_directory = File::from(current_directory); + if validate_directory(&self.directory).map_err(authority_store)? + != self.directory_identity + || validate_directory(&current_directory).map_err(authority_store)? + != self.directory_identity + { + return Err(authority_store(StoreFailure::Conflict)); + } + let current_marker = open_marker_file(&current_directory, MARKER_FILE_NAME) + .map_err(|cause| operation_store(operation_kind, cause))?; + if file_identity(&self.marker_file) + .map_err(|cause| operation_store(operation_kind, cause))? + != self.marker_identity + || file_identity(&current_marker) + .map_err(|cause| operation_store(operation_kind, cause))? + != self.marker_identity + { + return Err(operation_store(operation_kind, StoreFailure::Conflict)); + } + if read_marker(&self.marker_file) + .map_err(|cause| operation_store(operation_kind, cause))? + .canonical_bytes() + != self.marker.canonical_bytes() + { + return Err(operation_store(operation_kind, StoreFailure::Conflict)); + } + if require_no_scratch { + require_absent(&current_directory, MARKER_NEXT_FILE_NAME) + .map_err(|cause| operation_store(operation_kind, cause))?; + } + Ok(()) + } + } + + fn validate_directory(file: &File) -> Result<FileIdentity, StoreFailure> { + let status = fstat(file).map_err(|_| StoreFailure::Directory)?; + if !FileType::from_raw_mode(status.st_mode).is_dir() + || status.st_uid != geteuid().as_raw() + || u32::from(status.st_mode) & 0o022 != 0 + { + return Err(StoreFailure::Directory); + } + identity(status.st_dev, status.st_ino) + } + + fn authority_checked<T, E>( + authority: &WriterAuthority, + paths: &ServiceSqlitePaths, + operation: impl FnOnce() -> Result<T, E>, + ) -> Result<Result<T, E>, ServiceSqliteError> { + authority.validate_for(paths)?; + let result = operation(); + authority.validate_for(paths)?; + Ok(result) + } + + fn cleanup_with_authority( + authority: &WriterAuthority, + paths: &ServiceSqlitePaths, + directory: &File, + name: &str, + expected: FileIdentity, + ) -> Result<(), ServiceSqliteError> { + authority.validate_for(paths)?; + cleanup_exact(directory, name, expected); + authority.validate_for(paths) + } + + fn create_marker_file(directory: &File, name: &str) -> Result<File, StoreFailure> { + let descriptor = openat( + directory, + name, + OFlags::RDWR + | OFlags::CREATE + | OFlags::EXCL + | OFlags::NOFOLLOW + | OFlags::CLOEXEC + | OFlags::NONBLOCK, + Mode::RUSR | Mode::WUSR, + ) + .map_err(|_| StoreFailure::Collision)?; + fchmod(&descriptor, Mode::RUSR | Mode::WUSR).map_err(|_| StoreFailure::Permissions)?; + let file = File::from(descriptor); + let _ = file_identity(&file)?; + Ok(file) + } + + fn open_marker_file(directory: &File, name: &str) -> Result<File, StoreFailure> { + let descriptor = openat( + directory, + name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|error| { + if error == Errno::NOENT { + StoreFailure::Missing + } else { + StoreFailure::Marker + } + })?; + let file = File::from(descriptor); + let _ = file_identity(&file)?; + Ok(file) + } + + fn file_identity(file: &File) -> Result<FileIdentity, StoreFailure> { + let status = fstat(file).map_err(|_| StoreFailure::Marker)?; + if !FileType::from_raw_mode(status.st_mode).is_file() + || u64::from(status.st_nlink) != 1 + || status.st_uid != geteuid().as_raw() + || u32::from(status.st_mode) & 0o777 != 0o600 + { + return Err(StoreFailure::Marker); + } + identity(status.st_dev, status.st_ino) + } + + fn identity(device: impl TryInto<u64>, inode: u64) -> Result<FileIdentity, StoreFailure> { + Ok(FileIdentity { + device: device.try_into().map_err(|_| StoreFailure::Marker)?, + inode, + }) + } + + trait StoreOperations { + fn sync_file(&self, file: &File, directory: &File) -> std::io::Result<()>; + fn sync_directory(&self, directory: &File) -> std::io::Result<()>; + fn replace_marker(&self, directory: &File) -> std::io::Result<()>; + } + + struct SystemStoreOperations; + + impl StoreOperations for SystemStoreOperations { + fn sync_file(&self, file: &File, _directory: &File) -> std::io::Result<()> { + file.sync_all() + } + + fn sync_directory(&self, directory: &File) -> std::io::Result<()> { + directory.sync_all() + } + + fn replace_marker(&self, directory: &File) -> std::io::Result<()> { + renameat( + directory, + MARKER_NEXT_FILE_NAME, + directory, + MARKER_FILE_NAME, + ) + .map_err(std::io::Error::from) + } + } + + #[cfg(test)] + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(crate) enum TestStoreFailure { + ScratchSync, + ParentSyncAfterRename, + AuthorityDriftAndScratchSync, + } + + #[cfg(test)] + struct FailingStoreOperations { + failure: TestStoreFailure, + } + + #[cfg(test)] + impl StoreOperations for FailingStoreOperations { + fn sync_file(&self, file: &File, directory: &File) -> std::io::Result<()> { + match self.failure { + TestStoreFailure::ScratchSync => { + Err(std::io::Error::other("injected scratch sync failure")) + } + TestStoreFailure::AuthorityDriftAndScratchSync => { + fchmod( + directory, + Mode::RUSR | Mode::WUSR | Mode::XUSR | Mode::RGRP | Mode::WGRP | Mode::XGRP, + ) + .map_err(std::io::Error::from)?; + Err(std::io::Error::other( + "injected authority drift and scratch sync failure", + )) + } + TestStoreFailure::ParentSyncAfterRename => file.sync_all(), + } + } + + fn sync_directory(&self, _directory: &File) -> std::io::Result<()> { + Err(std::io::Error::other("injected parent sync failure")) + } + + fn replace_marker(&self, directory: &File) -> std::io::Result<()> { + SystemStoreOperations.replace_marker(directory) + } + } + + fn write_and_sync( + file: &File, + bytes: &[u8], + directory: &File, + operations: &dyn StoreOperations, + ) -> Result<(), StoreFailure> { + let mut file = file.try_clone().map_err(|_| StoreFailure::Write)?; + file.write_all(bytes).map_err(|_| StoreFailure::Write)?; + operations + .sync_file(&file, directory) + .map_err(|_| StoreFailure::Sync) + } + + fn read_marker(file: &File) -> Result<RestoreRecoveryMarker, StoreFailure> { + let mut file = file.try_clone().map_err(|_| StoreFailure::Read)?; + file.seek(SeekFrom::Start(0)) + .map_err(|_| StoreFailure::Read)?; + let mut bytes = Vec::with_capacity(RESTORE_MARKER_MAX_BYTES.min(512)); + file.take(u64::try_from(RESTORE_MARKER_MAX_BYTES + 1).expect("marker bound")) + .read_to_end(&mut bytes) + .map_err(|_| StoreFailure::Read)?; + if bytes.len() > RESTORE_MARKER_MAX_BYTES { + return Err(StoreFailure::Contract( + RestoreMarkerContractError::MarkerTooLarge, + )); + } + RestoreRecoveryMarker::from_canonical_bytes(&bytes).map_err(StoreFailure::Contract) + } + + fn require_absent(directory: &File, name: &str) -> Result<(), StoreFailure> { + match statat(directory, name, AtFlags::SYMLINK_NOFOLLOW) { + Err(error) if error == Errno::NOENT => Ok(()), + _ => Err(StoreFailure::Collision), + } + } + + fn cleanup_exact(directory: &File, name: &str, expected: FileIdentity) { + let Ok(file) = open_marker_file(directory, name) else { + return; + }; + if file_identity(&file).ok() == Some(expected) { + let _ = unlinkat(directory, name, AtFlags::empty()); + let _ = directory.sync_all(); + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum StoreFailure { + Directory, + Marker, + Missing, + Collision, + Permissions, + Read, + Write, + Sync, + Rename, + Conflict, + Contract(RestoreMarkerContractError), + } + + impl fmt::Display for StoreFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Directory => "restore marker directory is invalid", + Self::Marker => "restore marker file is invalid", + Self::Missing => "restore marker file is missing", + Self::Collision => "restore marker artifact already exists", + Self::Permissions => "restore marker permissions are invalid", + Self::Read => "restore marker could not be read", + Self::Write => "restore marker could not be written", + Self::Sync => "restore marker durability could not be proven", + Self::Rename => "restore marker replacement failed", + Self::Conflict => "restore marker binding changed", + Self::Contract(error) => return error.fmt(formatter), + }) + } + } + impl Error for StoreFailure {} + + fn restore_store(cause: StoreFailure) -> ServiceSqliteError { + ServiceSqliteError::with_source(ServiceSqliteErrorKind::Restore, cause) + } + fn recovery_store(cause: StoreFailure) -> ServiceSqliteError { + ServiceSqliteError::with_source(ServiceSqliteErrorKind::Recovery, cause) + } + fn authority_store(cause: StoreFailure) -> ServiceSqliteError { + ServiceSqliteError::with_source(ServiceSqliteErrorKind::Authority, cause) + } + fn restore_contract(cause: RestoreMarkerContractError) -> ServiceSqliteError { + ServiceSqliteError::with_source(ServiceSqliteErrorKind::Restore, cause) + } + fn recovery_contract(cause: RestoreMarkerContractError) -> ServiceSqliteError { + ServiceSqliteError::with_source(ServiceSqliteErrorKind::Recovery, cause) + } + fn operation_store(kind: ServiceSqliteErrorKind, cause: StoreFailure) -> ServiceSqliteError { + debug_assert!(matches!( + kind, + ServiceSqliteErrorKind::Restore | ServiceSqliteErrorKind::Recovery + )); + ServiceSqliteError::with_source(kind, cause) + } + fn operation_contract( + kind: ServiceSqliteErrorKind, + cause: RestoreMarkerContractError, + ) -> ServiceSqliteError { + debug_assert!(matches!( + kind, + ServiceSqliteErrorKind::Restore | ServiceSqliteErrorKind::Recovery + )); + ServiceSqliteError::with_source(kind, cause) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) use store::RestoreMarkerBinding; + +#[cfg(test)] +mod tests { + use super::*; + use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, + }; + + fn paths(root: &Path) -> 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("myc").expect("service"), + InstanceId::new("primary").expect("instance"), + ) + .expect("context"); + ServiceSqlitePaths::from_runtime_context(&context).expect("paths") + } + + fn marker(paths: &ServiceSqlitePaths) -> RestoreRecoveryMarker { + let metadata = ServiceDatabaseMetadata::new( + paths, + SourceGeneration::new([7; 32]).expect("generation"), + NonZeroU32::new(3).expect("schema"), + 1_800_000_000_000, + ServiceSqliteApplicationId::new(0x5244_5254).expect("application"), + ) + .expect("metadata"); + RestoreRecoveryMarker::prepared( + &metadata, + BackupManifestSha256::from_bytes([8; 32]), + RestoreArtifactExpectation::new(1, 2, 4096, [3; 32]).expect("live"), + RestoreArtifactExpectation::new(1, 4, 4096, [5; 32]).expect("staged"), + ) + .expect("marker") + } + + #[test] + fn canonical_vector_and_checksum_are_frozen() { + let root = tempfile::tempdir().expect("root"); + let marker = marker(&paths(root.path())); + let text = std::str::from_utf8(marker.canonical_bytes()).expect("UTF-8"); + assert_eq!(marker.canonical_bytes().len(), 820); + assert!(text.starts_with("{\"schema\":\"radroots.service-sqlite.restore-marker\",\"schema_version\":1,\"phase\":\"prepared\"")); + assert!(text.ends_with("\"marker_sha256\":\"026e975f22d45df3b4f46d4d3958e82e3755f7cd2f17d742714e2b55bf381d0f\"}")); + assert_eq!( + RestoreRecoveryMarker::from_canonical_bytes(marker.canonical_bytes()).expect("parse"), + marker + ); + } + + #[test] + fn prepared_marker_uses_actual_backup_schema_not_a_binary_ceiling() { + let root = tempfile::tempdir().expect("root"); + let paths = paths(root.path()); + let actual = ServiceDatabaseMetadata::new( + &paths, + SourceGeneration::new([7; 32]).expect("generation"), + NonZeroU32::new(1).expect("actual schema"), + 1_800_000_000_000, + ServiceSqliteApplicationId::new(0x5244_5254).expect("application"), + ) + .expect("metadata"); + let marker = RestoreRecoveryMarker::prepared( + &actual, + BackupManifestSha256::from_bytes([8; 32]), + RestoreArtifactExpectation::new(1, 2, 4096, [3; 32]).expect("live"), + RestoreArtifactExpectation::new(1, 4, 4096, [5; 32]).expect("staged"), + ) + .expect("marker"); + let text = std::str::from_utf8(marker.canonical_bytes()).expect("text"); + assert!(text.contains("\"state_schema_version\":1")); + assert!(!text.contains("\"state_schema_version\":3")); + } + + #[test] + fn all_phase_edges_and_idempotent_bytes_are_exact() { + let root = tempfile::tempdir().expect("root"); + let prepared = marker(&paths(root.path())); + let retained = prepared + .transitioned_to(RestoreRecoveryPhase::LiveRetained) + .expect("advance"); + let installed = retained + .transitioned_to(RestoreRecoveryPhase::ReplacementInstalled) + .expect("advance"); + let phases = [prepared, retained, installed]; + for current in &phases { + for next in [ + RestoreRecoveryPhase::Prepared, + RestoreRecoveryPhase::LiveRetained, + RestoreRecoveryPhase::ReplacementInstalled, + ] { + let result = current.transitioned_to(next); + let allowed = current.phase() == next + || matches!( + (current.phase(), next), + ( + RestoreRecoveryPhase::Prepared, + RestoreRecoveryPhase::LiveRetained + ) | ( + RestoreRecoveryPhase::LiveRetained, + RestoreRecoveryPhase::ReplacementInstalled + ) + ); + assert_eq!( + result.is_ok(), + allowed, + "edge {:?}->{next:?}", + current.phase() + ); + if current.phase() == next { + assert_eq!( + result.expect("same").canonical_bytes(), + current.canonical_bytes() + ); + } + } + } + } + + #[test] + fn strict_codec_rejects_tamper_and_noncanonical_inputs() { + let root = tempfile::tempdir().expect("root"); + let marker = marker(&paths(root.path())); + let text = std::str::from_utf8(marker.canonical_bytes()).expect("text"); + for altered in [ + format!(" {text}"), + text.replace("\"schema_version\":1", "\"schema_version\":2"), + text.replace("\"phase\":\"prepared\"", "\"phase\":null"), + text.replace( + "\"service\":\"myc\"", + "\"service\":\"myc\",\"service\":\"myc\"", + ), + text.replace( + "\"schema_version\":1,\"phase\"", + "\"unknown\":1,\"schema_version\":1,\"phase\"", + ), + text.replace("\"device\":1,\"inode\":2", "\"inode\":2,\"device\":1"), + text.replace("\"marker_sha256\":\"0", "\"marker_sha256\":\"A"), + ] { + assert!( + RestoreRecoveryMarker::from_canonical_bytes(altered.as_bytes()).is_err(), + "accepted {altered}" + ); + } + assert_eq!( + RestoreRecoveryMarker::from_canonical_bytes(&vec![b'x'; RESTORE_MARKER_MAX_BYTES + 1]), + Err(RestoreMarkerContractError::MarkerTooLarge) + ); + assert_ne!( + RestoreRecoveryMarker::from_canonical_bytes(&vec![b'x'; RESTORE_MARKER_MAX_BYTES]), + Err(RestoreMarkerContractError::MarkerTooLarge) + ); + } + + #[test] + fn identity_bounds_layout_and_debug_are_closed() { + assert!(RestoreArtifactExpectation::new(0, 0, 1, [0; 32]).is_ok()); + assert!(RestoreArtifactExpectation::new(u64::MAX, u64::MAX, 1, [0; 32]).is_ok()); + assert!(RestoreArtifactExpectation::new(1, 1, 0, [0; 32]).is_err()); + assert!(RestoreArtifactExpectation::new(1, 1, i64::MAX as u64 + 1, [0; 32]).is_err()); + let root = tempfile::tempdir().expect("root"); + let paths = paths(root.path()); + let layout = RestoreRecoveryLayout::for_paths(&paths).expect("layout"); + assert_eq!( + layout.file_names(), + [ + LIVE_FILE_NAME, + STAGED_FILE_NAME, + BACKUP_FILE_NAME, + MARKER_FILE_NAME, + MARKER_NEXT_FILE_NAME + ] + ); + assert!(layout.live.starts_with(&layout.state_directory)); + assert!(layout.staged.starts_with(&layout.state_directory)); + assert!(layout.backup.starts_with(&layout.state_directory)); + assert_eq!(format!("{layout:?}"), "RestoreRecoveryLayout([redacted])"); + assert_eq!( + format!("{:?}", marker(&paths)), + "RestoreRecoveryMarker { schema_version: 1, phase: Prepared, .. }" + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn durable_store_creates_loads_and_advances_owner_only_marker() { + use std::{fs, os::unix::fs::PermissionsExt}; + let root = tempfile::tempdir().expect("root"); + let paths = paths(root.path()); + fs::create_dir_all(paths.state_database().parent().expect("parent")).expect("state dir"); + fs::set_permissions( + paths.state_database().parent().expect("parent"), + fs::Permissions::from_mode(0o700), + ) + .expect("mode"); + let authority = WriterAuthority::acquire(&paths, crate::OpenMode::Initialize) + .expect("authority") + .expect("writer"); + let prepared = marker(&paths); + let binding = RestoreMarkerBinding::create(&paths, &authority, &prepared).expect("create"); + assert_eq!( + fs::metadata(paths.state_database().with_file_name(MARKER_FILE_NAME)) + .expect("metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + binding.validate(&paths).expect("validate"); + drop(binding); + let loaded = RestoreMarkerBinding::load(&paths) + .expect("load") + .expect("present"); + assert_eq!(loaded.marker().phase(), RestoreRecoveryPhase::Prepared); + let loaded = loaded + .advance(&paths, &authority, RestoreRecoveryPhase::LiveRetained) + .expect("advance"); + assert_eq!(loaded.marker().phase(), RestoreRecoveryPhase::LiveRetained); + let same = loaded + .advance(&paths, &authority, RestoreRecoveryPhase::LiveRetained) + .expect("same"); + assert_eq!(same.marker().phase(), RestoreRecoveryPhase::LiveRetained); + assert!( + !paths + .state_database() + .with_file_name(MARKER_NEXT_FILE_NAME) + .exists() + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn atomic_advance_failures_leave_exact_old_or_new_valid_marker() { + use super::store::TestStoreFailure; + use std::{fs, os::unix::fs::PermissionsExt}; + + for (instance, failure, expected_phase) in [ + ( + "pre-rename", + TestStoreFailure::ScratchSync, + RestoreRecoveryPhase::Prepared, + ), + ( + "post-rename", + TestStoreFailure::ParentSyncAfterRename, + RestoreRecoveryPhase::LiveRetained, + ), + ] { + let root = tempfile::tempdir().expect("root"); + let paths = paths(&root.path().join(instance)); + let parent = paths.state_database().parent().expect("parent"); + fs::create_dir_all(parent).expect("state dir"); + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).expect("mode"); + let authority = WriterAuthority::acquire(&paths, crate::OpenMode::Initialize) + .expect("authority") + .expect("writer"); + let binding = + RestoreMarkerBinding::create(&paths, &authority, &marker(&paths)).expect("create"); + assert_eq!( + binding + .test_advance_with_failure( + &paths, + &authority, + RestoreRecoveryPhase::LiveRetained, + failure, + ) + .expect_err("injected failure") + .kind(), + ServiceSqliteErrorKind::Restore + ); + let recovered = RestoreMarkerBinding::load(&paths) + .expect("read valid durable state") + .expect("marker remains"); + assert_eq!(recovered.marker().phase(), expected_phase); + assert!( + !paths + .state_database() + .with_file_name(MARKER_NEXT_FILE_NAME) + .exists() + ); + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn initial_store_requires_prepared_and_authority_wins_combined_failure() { + use super::store::TestStoreFailure; + use std::{fs, os::unix::fs::PermissionsExt}; + + for phase in [ + RestoreRecoveryPhase::LiveRetained, + RestoreRecoveryPhase::ReplacementInstalled, + ] { + let root = tempfile::tempdir().expect("root"); + let paths = paths(root.path()); + let parent = paths.state_database().parent().expect("parent"); + fs::create_dir_all(parent).expect("state dir"); + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).expect("mode"); + let authority = WriterAuthority::acquire(&paths, crate::OpenMode::Initialize) + .expect("authority") + .expect("writer"); + let later = marker(&paths) + .transitioned_to(RestoreRecoveryPhase::LiveRetained) + .expect("retained"); + let later = if phase == RestoreRecoveryPhase::ReplacementInstalled { + later.transitioned_to(phase).expect("installed") + } else { + later + }; + assert_eq!( + RestoreMarkerBinding::create(&paths, &authority, &later) + .expect_err("initial later phase") + .kind(), + ServiceSqliteErrorKind::Restore + ); + assert!(!parent.join(MARKER_FILE_NAME).exists()); + } + + let root = tempfile::tempdir().expect("root"); + let paths = paths(root.path()); + let parent = paths.state_database().parent().expect("parent"); + fs::create_dir_all(parent).expect("state dir"); + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).expect("mode"); + let authority = WriterAuthority::acquire(&paths, crate::OpenMode::Initialize) + .expect("authority") + .expect("writer"); + let binding = + RestoreMarkerBinding::create(&paths, &authority, &marker(&paths)).expect("create"); + assert_eq!( + binding + .test_advance_with_failure( + &paths, + &authority, + RestoreRecoveryPhase::LiveRetained, + TestStoreFailure::AuthorityDriftAndScratchSync, + ) + .expect_err("authority drift") + .kind(), + ServiceSqliteErrorKind::Authority + ); + assert!(parent.join(MARKER_NEXT_FILE_NAME).exists()); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn active_marker_conflict_is_restore_and_directory_replacement_is_authority() { + use std::{fs, os::unix::fs::PermissionsExt}; + + let root = tempfile::tempdir().expect("root"); + let marker_paths = paths(&root.path().join("marker-conflict")); + let parent = marker_paths.state_database().parent().expect("parent"); + fs::create_dir_all(parent).expect("state dir"); + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).expect("mode"); + let authority = WriterAuthority::acquire(&marker_paths, crate::OpenMode::Initialize) + .expect("authority") + .expect("writer"); + let binding = + RestoreMarkerBinding::create(&marker_paths, &authority, &marker(&marker_paths)) + .expect("create"); + let marker_path = parent.join(MARKER_FILE_NAME); + let replacement_bytes = fs::read(&marker_path).expect("marker bytes"); + fs::rename(&marker_path, parent.join("retained-marker")).expect("retain marker"); + fs::write(&marker_path, replacement_bytes).expect("replacement marker"); + fs::set_permissions(&marker_path, fs::Permissions::from_mode(0o600)).expect("mode"); + assert_eq!( + binding + .advance( + &marker_paths, + &authority, + RestoreRecoveryPhase::LiveRetained, + ) + .expect_err("marker replacement") + .kind(), + ServiceSqliteErrorKind::Restore + ); + + let root = tempfile::tempdir().expect("root"); + let paths = paths(&root.path().join("directory-conflict")); + let parent = paths + .state_database() + .parent() + .expect("parent") + .to_path_buf(); + fs::create_dir_all(&parent).expect("state dir"); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).expect("mode"); + let authority = WriterAuthority::acquire(&paths, crate::OpenMode::Initialize) + .expect("authority") + .expect("writer"); + let binding = + RestoreMarkerBinding::create(&paths, &authority, &marker(&paths)).expect("create"); + let retained_parent = parent.with_file_name("state-retained"); + fs::rename(&parent, &retained_parent).expect("retain directory"); + fs::create_dir(&parent).expect("replacement directory"); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).expect("mode"); + assert_eq!( + binding + .advance(&paths, &authority, RestoreRecoveryPhase::LiveRetained,) + .expect_err("directory replacement") + .kind(), + ServiceSqliteErrorKind::Authority + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn load_rejects_stale_scratch_hardlinks_and_wrong_mode() { + use std::{fs, os::unix::fs::PermissionsExt}; + + for shape in ["stale-next", "hardlink", "wrong-mode"] { + let root = tempfile::tempdir().expect("root"); + let paths = paths(root.path()); + let parent = paths.state_database().parent().expect("parent"); + fs::create_dir_all(parent).expect("state dir"); + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).expect("mode"); + let authority = WriterAuthority::acquire(&paths, crate::OpenMode::Initialize) + .expect("authority") + .expect("writer"); + drop( + RestoreMarkerBinding::create(&paths, &authority, &marker(&paths)).expect("create"), + ); + let marker_path = parent.join(MARKER_FILE_NAME); + match shape { + "stale-next" => { + fs::write(parent.join(MARKER_NEXT_FILE_NAME), b"evidence").expect("stale next") + } + "hardlink" => { + fs::hard_link(&marker_path, parent.join("retained-link")).expect("hard link"); + } + "wrong-mode" => { + fs::set_permissions(&marker_path, fs::Permissions::from_mode(0o640)) + .expect("mode"); + } + _ => unreachable!(), + } + assert_eq!( + RestoreMarkerBinding::load(&paths) + .expect_err("invalid artifact") + .kind(), + ServiceSqliteErrorKind::Recovery + ); + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn store_rejects_collisions_shapes_tamper_and_insecure_parent() { + use std::{ + fs, + os::unix::fs::{PermissionsExt, symlink}, + }; + for shape in ["file", "directory", "symlink"] { + let root = tempfile::tempdir().expect("root"); + let paths = paths(root.path()); + let parent = paths.state_database().parent().expect("parent"); + fs::create_dir_all(parent).expect("parent"); + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).expect("mode"); + let marker_path = parent.join(MARKER_FILE_NAME); + match shape { + "file" => fs::write(&marker_path, b"collision").expect("file"), + "directory" => fs::create_dir(&marker_path).expect("directory"), + "symlink" => symlink(parent.join("missing"), &marker_path).expect("symlink"), + _ => unreachable!(), + } + let authority = WriterAuthority::acquire(&paths, crate::OpenMode::Initialize) + .expect("authority") + .expect("writer"); + assert_eq!( + RestoreMarkerBinding::create(&paths, &authority, &marker(&paths)) + .expect_err("collision") + .kind(), + ServiceSqliteErrorKind::Restore + ); + } + + let root = tempfile::tempdir().expect("root"); + let paths = paths(root.path()); + let parent = paths.state_database().parent().expect("parent"); + fs::create_dir_all(parent).expect("parent"); + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).expect("mode"); + let authority = WriterAuthority::acquire(&paths, crate::OpenMode::Initialize) + .expect("authority") + .expect("writer"); + drop(RestoreMarkerBinding::create(&paths, &authority, &marker(&paths)).expect("create")); + fs::write(parent.join(MARKER_FILE_NAME), b"tampered").expect("tamper"); + assert_eq!( + RestoreMarkerBinding::load(&paths) + .expect_err("tamper") + .kind(), + ServiceSqliteErrorKind::Recovery + ); + fs::remove_file(parent.join(MARKER_FILE_NAME)).expect("remove"); + fs::set_permissions(parent, fs::Permissions::from_mode(0o722)).expect("insecure"); + assert_eq!( + RestoreMarkerBinding::load(&paths) + .expect_err("directory") + .kind(), + ServiceSqliteErrorKind::Recovery + ); + } + + #[test] + fn marker_errors_and_debug_are_path_content_and_digest_free() { + let root = tempfile::tempdir().expect("secret-root"); + let paths = paths(root.path()); + let marker = marker(&paths); + let debug = format!("{marker:?}"); + for sensitive in [ + "secret-root", + "state.sqlite", + "myc", + "primary", + "07070707", + "08080808", + ] { + assert!(!debug.contains(sensitive)); + } + for error in [ + RestoreMarkerContractError::MarkerTooLarge, + RestoreMarkerContractError::MalformedEncoding, + RestoreMarkerContractError::NonCanonicalEncoding, + RestoreMarkerContractError::EncodingFailure, + RestoreMarkerContractError::UnsupportedValue, + RestoreMarkerContractError::ChecksumMismatch, + RestoreMarkerContractError::InvalidIdentity, + RestoreMarkerContractError::InvalidLayout, + RestoreMarkerContractError::IllegalTransition, + ] { + let rendered = format!("{error:?} {error}"); + assert!(rendered.is_ascii()); + assert!(!rendered.contains('/')); + assert!(!rendered.contains(".sqlite")); + } + } +} diff --git a/crates/service_sqlite/src/restore/mod.rs b/crates/service_sqlite/src/restore/mod.rs @@ -0,0 +1,13 @@ +//! Private crash-recovery marker mechanics for governed restore. + +mod marker; + +#[allow(unused_imports)] +pub(crate) use marker::{ + RestoreArtifactExpectation, RestoreMarkerContractError, RestoreRecoveryLayout, + RestoreRecoveryMarker, RestoreRecoveryPhase, +}; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[allow(unused_imports)] +pub(crate) use marker::RestoreMarkerBinding; diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs @@ -16,6 +16,8 @@ const INTEGRITY_CATALOG_SOURCE: &str = include_str!("../src/integrity/catalog.rs const METADATA_SOURCE: &str = include_str!("../src/metadata.rs"); const MIGRATION_SOURCE: &str = include_str!("../src/migration.rs"); const OPEN_SOURCE: &str = include_str!("../src/open.rs"); +const RESTORE_MARKER_SOURCE: &str = include_str!("../src/restore/marker.rs"); +const RESTORE_ROOT_SOURCE: &str = include_str!("../src/restore/mod.rs"); const STATUS_SOURCE: &str = include_str!("../src/status.rs"); const TRANSACTION_CONTROL_SOURCE: &str = include_str!("../src/transaction_control.rs"); @@ -67,6 +69,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "metadata", "migration", "open", + "restore", "status", "transaction_control" ]) @@ -134,6 +137,21 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "It is not restore or replacement authority", "copy from the retained member and reverify the staged copy", "pathname verification alone is insufficient", + "Restore crash recovery uses a private sealed v1 marker", + "state.restore-staged.sqlite", + "state.restore-backup.sqlite", + "state.restore-marker.v1.next", + "compact canonical JSON is capped at 2,048 bytes", + "domain-separated checksum binds the canonical fields and detects corruption", + "it is not an authenticity credential", + "only legal durable sequence is `prepared` to `live_retained` to `replacement_installed`", + "repeating the current phase is byte-idempotent", + "descriptor-relative, no-follow, single-link, owner-owned regular files with mode `0600`", + "compare-and-reloads the current bytes", + "create-new scratch, atomically replaces the marker", + "reads do not repair or remove it", + "does not stage, copy, open, rename, replace, or delete a database", + "No marker type, path, raw descriptor, or store operation is public API", ] { assert!( readme_words.contains(required), @@ -167,6 +185,10 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { let backup_verify_production = BACKUP_VERIFY_SOURCE .split_once("#[cfg(test)]") .map_or(BACKUP_VERIFY_SOURCE, |(production, _)| production); + let restore_marker_production = RESTORE_MARKER_SOURCE + .split_once("#[cfg(test)]\nmod tests") + .map(|(production, _)| production) + .expect("restore marker source must keep tests separated"); for required in [ "ServiceSqliteErrorCode", @@ -469,6 +491,61 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { } for required in [ + "radroots.service-sqlite.restore-marker", + "RESTORE_MARKER_SCHEMA_VERSION: u32 = 1", + "RESTORE_MARKER_MAX_BYTES: usize = 2_048", + "radroots.service_sqlite.restore_marker.v1\\0", + "state.restore-staged.sqlite", + "state.restore-backup.sqlite", + "state.restore-marker.v1", + "state.restore-marker.v1.next", + "enum RestoreRecoveryPhase", + "Prepared", + "LiveRetained", + "ReplacementInstalled", + "fn transitioned_to(", + "serde(deny_unknown_fields)", + "OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK", + "OFlags::RDWR", + "OFlags::CREATE", + "OFlags::EXCL", + "Mode::RUSR | Mode::WUSR", + "renameat(", + "sync_all()", + "cleanup_exact", + "ServiceSqliteErrorKind::Restore", + "ServiceSqliteErrorKind::Recovery", + ] { + assert!( + restore_marker_production.contains(required), + "Step 066 restore marker is missing `{required}`" + ); + } + for forbidden in [ + "pub mod restore", + "pub use marker", + "pub struct Restore", + "pub enum Restore", + "pub fn restore", + "pub async fn restore", + "tokio::", + "sqlx::", + "rusqlite::", + "std::fs::copy", + "state_database().rename", + "remove_dir_all", + "SystemTime", + "timeout(", + ] { + assert!( + !restore_marker_production.contains(forbidden) + && !RESTORE_ROOT_SOURCE.contains(forbidden) + && !ROOT.contains(forbidden), + "Step 066 restore marker exposes or implements deferred authority `{forbidden}`" + ); + } + + for required in [ "self.pool.close().await", "PRAGMA wal_checkpoint(TRUNCATE)", "if busy == 0",