lib

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

commit 1d205a28895c148969ae55d4050d143a4cb49ef3
parent f1c987644d85bdea2cd2bffcc33a7e1ce2db4833
Author: triesap <tyson@radroots.org>
Date:   Sat,  1 Aug 2026 21:24:09 +0000

storage: define backup, restore, integrity, and status contracts

- validate versioned manifests paths hashes and secret policy
- require verified staged operations before atomic finalization
- expose integrity locking WAL and shutdown status contracts
- prove failure terminal revision and member verification behavior

Diffstat:
Mcrates/storage/src/backup.rs | 598++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/storage/src/error.rs | 40++++++++++++++++++++++++++++++++++++++++
Mcrates/storage/src/lib.rs | 1+
Mcrates/storage/src/status.rs | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/storage/tests/backup.rs | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 987 insertions(+), 1 deletion(-)

diff --git a/crates/storage/src/backup.rs b/crates/storage/src/backup.rs @@ -1 +1,597 @@ -//! Backup, restore, and integrity contracts. +//! Versioned backup, staged restore, and member-integrity contracts. + +use radroots_transport::BoxFuture; +use std::collections::BTreeSet; + +use crate::{ + Error, + status::{IntegrityStatus, StorageStatus}, +}; + +pub const BACKUP_MEMBER_PATH_MAX_BYTES: usize = 512; +pub const BACKUP_MEMBER_MAX: usize = 1_024; + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct BackupId([u8; 16]); + +impl BackupId { + pub const fn new(bytes: [u8; 16]) -> Result<Self, Error> { + if bytes_are_zero(&bytes) { + return Err(Error::InvalidBackupId); + } + Ok(Self(bytes)) + } + pub const fn as_bytes(&self) -> &[u8; 16] { + &self.0 + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct BackupFormatVersion(u16); + +impl BackupFormatVersion { + pub const V1: Self = Self(1); + pub const fn new(value: u16) -> Result<Self, Error> { + if value == 0 { + Err(Error::InvalidBackupVersion) + } else { + Ok(Self(value)) + } + } + pub const fn get(self) -> u16 { + self.0 + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BackupSecretPolicy { + ExcludeProtectedStorage, + IncludeProtectedStorage, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BackupMemberKind { + Runtime, + Protected, + Metadata, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct MemberDigest([u8; 32]); + +impl MemberDigest { + pub const fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackupMember { + relative_path: String, + kind: BackupMemberKind, + byte_length: u64, + sha256: MemberDigest, +} + +impl BackupMember { + pub fn new( + relative_path: impl Into<String>, + kind: BackupMemberKind, + byte_length: u64, + sha256: MemberDigest, + ) -> Result<Self, Error> { + let relative_path = relative_path.into(); + if !valid_member_path(relative_path.as_str()) { + return Err(Error::InvalidBackupMemberPath); + } + if byte_length == 0 { + return Err(Error::InvalidBackupMemberLength); + } + Ok(Self { + relative_path, + kind, + byte_length, + sha256, + }) + } + pub fn relative_path(&self) -> &str { + self.relative_path.as_str() + } + pub const fn kind(&self) -> BackupMemberKind { + self.kind + } + pub const fn byte_length(&self) -> u64 { + self.byte_length + } + pub const fn sha256(&self) -> MemberDigest { + self.sha256 + } +} + +/// Self-contained immutable inventory of one backup bundle. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackupManifest { + format_version: BackupFormatVersion, + backup_id: BackupId, + created_at_unix_ms: u64, + secret_policy: BackupSecretPolicy, + total_bytes: u64, + members: Vec<BackupMember>, +} + +impl BackupManifest { + pub fn new( + format_version: BackupFormatVersion, + backup_id: BackupId, + created_at_unix_ms: u64, + secret_policy: BackupSecretPolicy, + members: Vec<BackupMember>, + ) -> Result<Self, Error> { + if created_at_unix_ms == 0 || members.is_empty() || members.len() > BACKUP_MEMBER_MAX { + return Err(Error::InvalidBackupManifest); + } + let mut paths = BTreeSet::new(); + let mut total_bytes = 0_u64; + for member in &members { + if !paths.insert(member.relative_path()) { + return Err(Error::DuplicateBackupMember); + } + if member.kind() == BackupMemberKind::Protected + && secret_policy == BackupSecretPolicy::ExcludeProtectedStorage + { + return Err(Error::BackupSecretPolicyViolation); + } + total_bytes = total_bytes + .checked_add(member.byte_length()) + .ok_or(Error::InvalidBackupManifest)?; + } + Ok(Self { + format_version, + backup_id, + created_at_unix_ms, + secret_policy, + total_bytes, + members, + }) + } + pub const fn format_version(&self) -> BackupFormatVersion { + self.format_version + } + pub const fn backup_id(&self) -> BackupId { + self.backup_id + } + pub const fn created_at_unix_ms(&self) -> u64 { + self.created_at_unix_ms + } + pub const fn secret_policy(&self) -> BackupSecretPolicy { + self.secret_policy + } + pub const fn total_bytes(&self) -> u64 { + self.total_bytes + } + pub fn members(&self) -> &[BackupMember] { + self.members.as_slice() + } + pub fn member(&self, path: &str) -> Option<&BackupMember> { + self.members + .iter() + .find(|member| member.relative_path() == path) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackupPlan { + backup_id: BackupId, + format_version: BackupFormatVersion, + secret_policy: BackupSecretPolicy, + requested_at_unix_ms: u64, +} + +impl BackupPlan { + pub const fn new( + backup_id: BackupId, + format_version: BackupFormatVersion, + secret_policy: BackupSecretPolicy, + requested_at_unix_ms: u64, + ) -> Result<Self, Error> { + if requested_at_unix_ms == 0 { + return Err(Error::InvalidBackupTimestamp); + } + Ok(Self { + backup_id, + format_version, + secret_policy, + requested_at_unix_ms, + }) + } + pub const fn backup_id(&self) -> BackupId { + self.backup_id + } + pub const fn format_version(&self) -> BackupFormatVersion { + self.format_version + } + pub const fn secret_policy(&self) -> BackupSecretPolicy { + self.secret_policy + } + pub const fn requested_at_unix_ms(&self) -> u64 { + self.requested_at_unix_ms + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct ReliabilityRevision(u64); + +impl ReliabilityRevision { + pub const INITIAL: Self = Self(1); + pub const fn new(value: u64) -> Result<Self, Error> { + if value == 0 { + Err(Error::InvalidReliabilityRevision) + } else { + Ok(Self(value)) + } + } + pub const fn get(self) -> u64 { + self.0 + } + fn next(self) -> Result<Self, Error> { + self.0 + .checked_add(1) + .map(Self) + .ok_or(Error::CorruptReliabilityOperation) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BackupStage { + Planned, + Captured, + Verified, + Finalized, + Failed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackupOperation { + plan: BackupPlan, + revision: ReliabilityRevision, + stage: BackupStage, + manifest: Option<BackupManifest>, + updated_at_unix_ms: u64, +} + +impl BackupOperation { + pub const fn planned(plan: BackupPlan) -> Self { + let at = plan.requested_at_unix_ms; + Self { + plan, + revision: ReliabilityRevision::INITIAL, + stage: BackupStage::Planned, + manifest: None, + updated_at_unix_ms: at, + } + } + pub const fn plan(&self) -> &BackupPlan { + &self.plan + } + pub const fn revision(&self) -> ReliabilityRevision { + self.revision + } + pub const fn stage(&self) -> BackupStage { + self.stage + } + pub const fn manifest(&self) -> Option<&BackupManifest> { + self.manifest.as_ref() + } + pub const fn updated_at_unix_ms(&self) -> u64 { + self.updated_at_unix_ms + } + + pub fn transition( + &self, + expected_revision: ReliabilityRevision, + transition: BackupTransition, + at_unix_ms: u64, + ) -> Result<Self, Error> { + if expected_revision != self.revision { + return Err(Error::ReliabilityRevisionConflict); + } + if at_unix_ms < self.updated_at_unix_ms { + return Err(Error::InvalidBackupTimestamp); + } + let (stage, manifest) = match (self.stage, transition) { + (BackupStage::Planned, BackupTransition::Captured(manifest)) => { + if manifest.backup_id() != self.plan.backup_id() + || manifest.format_version() != self.plan.format_version() + || manifest.secret_policy() != self.plan.secret_policy() + { + return Err(Error::BackupManifestPlanMismatch); + } + (BackupStage::Captured, Some(manifest)) + } + (BackupStage::Captured, BackupTransition::Verified) => { + (BackupStage::Verified, self.manifest.clone()) + } + (BackupStage::Verified, BackupTransition::Finalize) => { + (BackupStage::Finalized, self.manifest.clone()) + } + ( + BackupStage::Planned | BackupStage::Captured | BackupStage::Verified, + BackupTransition::Fail, + ) => (BackupStage::Failed, self.manifest.clone()), + (BackupStage::Finalized | BackupStage::Failed, _) => { + return Err(Error::ReliabilityOperationTerminal); + } + _ => return Err(Error::InvalidBackupTransition), + }; + Ok(Self { + plan: self.plan.clone(), + revision: self.revision.next()?, + stage, + manifest, + updated_at_unix_ms: at_unix_ms, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BackupTransition { + Captured(BackupManifest), + Verified, + Finalize, + Fail, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RestorePlan { + manifest: BackupManifest, + accepted_secret_policy: BackupSecretPolicy, + requested_at_unix_ms: u64, +} + +impl RestorePlan { + pub fn new( + manifest: BackupManifest, + accepted_secret_policy: BackupSecretPolicy, + requested_at_unix_ms: u64, + ) -> Result<Self, Error> { + if requested_at_unix_ms == 0 { + return Err(Error::InvalidRestoreTimestamp); + } + if manifest.secret_policy() == BackupSecretPolicy::IncludeProtectedStorage + && accepted_secret_policy != BackupSecretPolicy::IncludeProtectedStorage + { + return Err(Error::BackupSecretPolicyViolation); + } + Ok(Self { + manifest, + accepted_secret_policy, + requested_at_unix_ms, + }) + } + pub const fn manifest(&self) -> &BackupManifest { + &self.manifest + } + pub const fn accepted_secret_policy(&self) -> BackupSecretPolicy { + self.accepted_secret_policy + } + pub const fn requested_at_unix_ms(&self) -> u64 { + self.requested_at_unix_ms + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MemberVerification { + Verified, + Missing, + HashMismatch, + LengthMismatch, + UnsafePath, + Unexpected, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RestoreMemberStatus { + relative_path: String, + verification: MemberVerification, +} + +impl RestoreMemberStatus { + pub fn new( + relative_path: impl Into<String>, + verification: MemberVerification, + ) -> Result<Self, Error> { + let relative_path = relative_path.into(); + if !valid_member_path(relative_path.as_str()) { + return Err(Error::InvalidBackupMemberPath); + } + Ok(Self { + relative_path, + verification, + }) + } + pub fn relative_path(&self) -> &str { + self.relative_path.as_str() + } + pub const fn verification(&self) -> MemberVerification { + self.verification + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RestoreStage { + Staging, + Verifying, + Finalizing, + Finalized, + Failed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RestoreOperation { + plan: RestorePlan, + revision: ReliabilityRevision, + stage: RestoreStage, + member_status: Vec<RestoreMemberStatus>, + updated_at_unix_ms: u64, +} + +impl RestoreOperation { + pub const fn staging(plan: RestorePlan) -> Self { + let at = plan.requested_at_unix_ms; + Self { + plan, + revision: ReliabilityRevision::INITIAL, + stage: RestoreStage::Staging, + member_status: Vec::new(), + updated_at_unix_ms: at, + } + } + pub const fn plan(&self) -> &RestorePlan { + &self.plan + } + pub const fn revision(&self) -> ReliabilityRevision { + self.revision + } + pub const fn stage(&self) -> RestoreStage { + self.stage + } + pub fn member_status(&self) -> &[RestoreMemberStatus] { + self.member_status.as_slice() + } + + pub fn transition( + &self, + expected_revision: ReliabilityRevision, + transition: RestoreTransition, + at_unix_ms: u64, + ) -> Result<Self, Error> { + if expected_revision != self.revision { + return Err(Error::ReliabilityRevisionConflict); + } + if at_unix_ms < self.updated_at_unix_ms { + return Err(Error::InvalidRestoreTimestamp); + } + let (stage, member_status) = match (self.stage, transition) { + (RestoreStage::Staging, RestoreTransition::Staged) => { + (RestoreStage::Verifying, Vec::new()) + } + (RestoreStage::Verifying, RestoreTransition::Verified(statuses)) => { + validate_restore_members(self.plan.manifest(), &statuses)?; + (RestoreStage::Finalizing, statuses) + } + (RestoreStage::Finalizing, RestoreTransition::Finalize) => { + (RestoreStage::Finalized, self.member_status.clone()) + } + ( + RestoreStage::Staging | RestoreStage::Verifying | RestoreStage::Finalizing, + RestoreTransition::Fail, + ) => (RestoreStage::Failed, self.member_status.clone()), + (RestoreStage::Finalized | RestoreStage::Failed, _) => { + return Err(Error::ReliabilityOperationTerminal); + } + _ => return Err(Error::InvalidRestoreTransition), + }; + Ok(Self { + plan: self.plan.clone(), + revision: self.revision.next()?, + stage, + member_status, + updated_at_unix_ms: at_unix_ms, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RestoreTransition { + Staged, + Verified(Vec<RestoreMemberStatus>), + Finalize, + Fail, +} + +/// Backend-neutral reliability operations. Implementations own staging and +/// atomic filesystem replacement; callers receive only typed state. +pub trait StorageReliability: Send + Sync { + fn begin_backup(&self, plan: BackupPlan) -> BoxFuture<'_, Result<BackupOperation, Error>>; + fn transition_backup( + &self, + backup_id: BackupId, + expected_revision: ReliabilityRevision, + transition: BackupTransition, + at_unix_ms: u64, + ) -> BoxFuture<'_, Result<BackupOperation, Error>>; + fn begin_restore(&self, plan: RestorePlan) -> BoxFuture<'_, Result<RestoreOperation, Error>>; + fn transition_restore( + &self, + backup_id: BackupId, + expected_revision: ReliabilityRevision, + transition: RestoreTransition, + at_unix_ms: u64, + ) -> BoxFuture<'_, Result<RestoreOperation, Error>>; + fn integrity(&self) -> BoxFuture<'_, Result<IntegrityStatus, Error>>; + fn status(&self) -> BoxFuture<'_, Result<StorageStatus, Error>>; + fn close(&self) -> BoxFuture<'_, Result<StorageStatus, Error>>; +} + +fn validate_restore_members( + manifest: &BackupManifest, + statuses: &[RestoreMemberStatus], +) -> Result<(), Error> { + if statuses.len() != manifest.members().len() { + return Err(Error::RestoreMemberVerificationFailed); + } + let mut paths = BTreeSet::new(); + for status in statuses { + if status.verification() != MemberVerification::Verified + || !paths.insert(status.relative_path()) + || manifest.member(status.relative_path()).is_none() + { + return Err(Error::RestoreMemberVerificationFailed); + } + } + Ok(()) +} + +fn valid_member_path(value: &str) -> bool { + !value.is_empty() + && value.len() <= BACKUP_MEMBER_PATH_MAX_BYTES + && value == value.trim() + && !value.starts_with('/') + && !value.contains('\\') + && value.split('/').all(|part| { + !part.is_empty() && part != "." && part != ".." && !part.chars().any(char::is_control) + }) +} + +const fn bytes_are_zero(bytes: &[u8; 16]) -> bool { + let mut index = 0; + while index < bytes.len() { + if bytes[index] != 0 { + return false; + } + index += 1; + } + true +} diff --git a/crates/storage/src/error.rs b/crates/storage/src/error.rs @@ -88,6 +88,25 @@ pub enum Error { PrivateArtifactTombstoned, InvalidExpiredArtifactQueryLimit, CorruptPrivateArtifactMetadata, + InvalidBackupId, + InvalidBackupVersion, + InvalidBackupTimestamp, + InvalidBackupMemberPath, + InvalidBackupMemberLength, + InvalidBackupManifest, + DuplicateBackupMember, + BackupSecretPolicyViolation, + BackupManifestPlanMismatch, + InvalidBackupTransition, + InvalidRestoreTimestamp, + InvalidRestoreTransition, + RestoreMemberVerificationFailed, + InvalidReliabilityRevision, + ReliabilityRevisionConflict, + ReliabilityOperationTerminal, + CorruptReliabilityOperation, + InvalidIntegrityStatus, + InvalidStorageStatus, } impl fmt::Display for Error { @@ -193,6 +212,27 @@ impl fmt::Display for Error { "storage expired-artifact query limit is invalid" } Self::CorruptPrivateArtifactMetadata => "storage private-artifact metadata is corrupt", + Self::InvalidBackupId => "storage backup id is invalid", + Self::InvalidBackupVersion => "storage backup version is invalid", + Self::InvalidBackupTimestamp => "storage backup timestamp is invalid", + Self::InvalidBackupMemberPath => "storage backup member path is invalid", + Self::InvalidBackupMemberLength => "storage backup member length is invalid", + Self::InvalidBackupManifest => "storage backup manifest is invalid", + Self::DuplicateBackupMember => "storage backup member is duplicated", + Self::BackupSecretPolicyViolation => "storage backup secret policy was violated", + Self::BackupManifestPlanMismatch => "storage backup manifest does not match its plan", + Self::InvalidBackupTransition => "storage backup transition is invalid", + Self::InvalidRestoreTimestamp => "storage restore timestamp is invalid", + Self::InvalidRestoreTransition => "storage restore transition is invalid", + Self::RestoreMemberVerificationFailed => "storage restore member verification failed", + Self::InvalidReliabilityRevision => "storage reliability revision is invalid", + Self::ReliabilityRevisionConflict => { + "storage reliability revision conflicts with durable state" + } + Self::ReliabilityOperationTerminal => "storage reliability operation is terminal", + Self::CorruptReliabilityOperation => "storage reliability operation is corrupt", + Self::InvalidIntegrityStatus => "storage integrity status is invalid", + Self::InvalidStorageStatus => "storage status is invalid", }) } } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs @@ -14,6 +14,7 @@ pub mod private_artifact; pub mod projection; pub mod status; +pub use backup::StorageReliability; pub use error::Error; pub use event::EventStore; pub use journal::Journal; diff --git a/crates/storage/src/status.rs b/crates/storage/src/status.rs @@ -2,6 +2,142 @@ use crate::{Error, event::SourceGeneration}; +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StorageOpenMode { + ReadOnly, + ReadWriteExisting, + Create, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WriterPolicy { + NoWriter, + AdvisoryProcessLock, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ShutdownState { + Open, + Closing, + Closed, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum IntegrityHealth { + Healthy, + Degraded, + Corrupt, + Unknown, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct IntegrityStatus { + health: IntegrityHealth, + checked_at_unix_ms: Option<u64>, + verified_members: u32, + failed_members: u32, +} + +impl IntegrityStatus { + pub fn new( + health: IntegrityHealth, + checked_at_unix_ms: Option<u64>, + verified_members: u32, + failed_members: u32, + ) -> Result<Self, Error> { + if matches!(checked_at_unix_ms, Some(0)) + || (health == IntegrityHealth::Healthy && failed_members != 0) + || (health == IntegrityHealth::Corrupt && failed_members == 0) + || (health == IntegrityHealth::Unknown && checked_at_unix_ms.is_some()) + { + return Err(Error::InvalidIntegrityStatus); + } + Ok(Self { + health, + checked_at_unix_ms, + verified_members, + failed_members, + }) + } + pub const fn health(self) -> IntegrityHealth { + self.health + } + pub const fn checked_at_unix_ms(self) -> Option<u64> { + self.checked_at_unix_ms + } + pub const fn verified_members(self) -> u32 { + self.verified_members + } + pub const fn failed_members(self) -> u32 { + self.failed_members + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StorageStatus { + open_mode: StorageOpenMode, + writer_policy: WriterPolicy, + shutdown: ShutdownState, + integrity: IntegrityStatus, + wal_enabled: bool, + busy_timeout_ms: u32, +} + +impl StorageStatus { + pub fn new( + open_mode: StorageOpenMode, + writer_policy: WriterPolicy, + shutdown: ShutdownState, + integrity: IntegrityStatus, + wal_enabled: bool, + busy_timeout_ms: u32, + ) -> Result<Self, Error> { + if (open_mode == StorageOpenMode::ReadOnly && writer_policy != WriterPolicy::NoWriter) + || (open_mode != StorageOpenMode::ReadOnly + && writer_policy != WriterPolicy::AdvisoryProcessLock) + || (open_mode != StorageOpenMode::ReadOnly && (!wal_enabled || busy_timeout_ms == 0)) + { + return Err(Error::InvalidStorageStatus); + } + Ok(Self { + open_mode, + writer_policy, + shutdown, + integrity, + wal_enabled, + busy_timeout_ms, + }) + } + pub const fn open_mode(self) -> StorageOpenMode { + self.open_mode + } + pub const fn writer_policy(self) -> WriterPolicy { + self.writer_policy + } + pub const fn shutdown(self) -> ShutdownState { + self.shutdown + } + pub const fn integrity(self) -> IntegrityStatus { + self.integrity + } + pub const fn wal_enabled(self) -> bool { + self.wal_enabled + } + pub const fn busy_timeout_ms(self) -> u32 { + self.busy_timeout_ms + } +} + /// Current event-store operating mode. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] diff --git a/crates/storage/tests/backup.rs b/crates/storage/tests/backup.rs @@ -0,0 +1,213 @@ +use radroots_storage::{ + Error, StorageReliability, + backup::{ + BackupFormatVersion, BackupId, BackupManifest, BackupMember, BackupMemberKind, + BackupOperation, BackupPlan, BackupSecretPolicy, BackupStage, BackupTransition, + MemberDigest, MemberVerification, ReliabilityRevision, RestoreMemberStatus, + RestoreOperation, RestorePlan, RestoreStage, RestoreTransition, + }, + status::{ + IntegrityHealth, IntegrityStatus, ShutdownState, StorageOpenMode, StorageStatus, + WriterPolicy, + }, +}; + +fn member(path: &str, kind: BackupMemberKind, byte: u8) -> BackupMember { + BackupMember::new(path, kind, 100, MemberDigest::new([byte; 32])).expect("backup member") +} + +fn manifest(policy: BackupSecretPolicy) -> BackupManifest { + let mut members = vec![member( + "runtime/runtime.sqlite", + BackupMemberKind::Runtime, + 1, + )]; + if policy == BackupSecretPolicy::IncludeProtectedStorage { + members.push(member( + "private/private.sqlite", + BackupMemberKind::Protected, + 2, + )); + } + BackupManifest::new( + BackupFormatVersion::V1, + BackupId::new([7; 16]).expect("backup id"), + 100, + policy, + members, + ) + .expect("backup manifest") +} + +#[test] +fn manifests_reject_unsafe_duplicate_and_policy_violating_members() { + assert_eq!( + BackupMember::new( + "../runtime.sqlite", + BackupMemberKind::Runtime, + 1, + MemberDigest::new([1; 32]), + ), + Err(Error::InvalidBackupMemberPath) + ); + let runtime = member("runtime.sqlite", BackupMemberKind::Runtime, 1); + assert_eq!( + BackupManifest::new( + BackupFormatVersion::V1, + BackupId::new([1; 16]).expect("id"), + 1, + BackupSecretPolicy::ExcludeProtectedStorage, + vec![runtime.clone(), runtime], + ), + Err(Error::DuplicateBackupMember) + ); + assert_eq!( + BackupManifest::new( + BackupFormatVersion::V1, + BackupId::new([1; 16]).expect("id"), + 1, + BackupSecretPolicy::ExcludeProtectedStorage, + vec![member("private.sqlite", BackupMemberKind::Protected, 2)], + ), + Err(Error::BackupSecretPolicyViolation) + ); +} + +#[test] +fn backup_requires_capture_verification_before_atomic_finalization() { + let plan = BackupPlan::new( + BackupId::new([7; 16]).expect("backup id"), + BackupFormatVersion::V1, + BackupSecretPolicy::IncludeProtectedStorage, + 90, + ) + .expect("backup plan"); + let planned = BackupOperation::planned(plan); + assert_eq!( + planned.transition( + ReliabilityRevision::INITIAL, + BackupTransition::Finalize, + 100, + ), + Err(Error::InvalidBackupTransition) + ); + let captured = planned + .transition( + ReliabilityRevision::INITIAL, + BackupTransition::Captured(manifest(BackupSecretPolicy::IncludeProtectedStorage)), + 100, + ) + .expect("captured backup"); + let verified = captured + .transition(captured.revision(), BackupTransition::Verified, 110) + .expect("verified backup"); + let finalized = verified + .transition(verified.revision(), BackupTransition::Finalize, 120) + .expect("finalized backup"); + assert_eq!(finalized.stage(), BackupStage::Finalized); + assert_eq!(finalized.manifest().expect("manifest").total_bytes(), 200); + assert_eq!( + finalized.transition(finalized.revision(), BackupTransition::Fail, 130), + Err(Error::ReliabilityOperationTerminal) + ); +} + +#[test] +fn restore_stages_and_verifies_every_member_before_replacement() { + let manifest = manifest(BackupSecretPolicy::IncludeProtectedStorage); + assert_eq!( + RestorePlan::new( + manifest.clone(), + BackupSecretPolicy::ExcludeProtectedStorage, + 200, + ), + Err(Error::BackupSecretPolicyViolation) + ); + let plan = RestorePlan::new( + manifest.clone(), + BackupSecretPolicy::IncludeProtectedStorage, + 200, + ) + .expect("restore plan"); + let staging = RestoreOperation::staging(plan); + let verifying = staging + .transition(ReliabilityRevision::INITIAL, RestoreTransition::Staged, 210) + .expect("staged restore"); + let failed_evidence = vec![ + RestoreMemberStatus::new("runtime/runtime.sqlite", MemberVerification::Verified) + .expect("status"), + RestoreMemberStatus::new("private/private.sqlite", MemberVerification::HashMismatch) + .expect("status"), + ]; + assert_eq!( + verifying.transition( + verifying.revision(), + RestoreTransition::Verified(failed_evidence), + 220, + ), + Err(Error::RestoreMemberVerificationFailed) + ); + let verified = manifest + .members() + .iter() + .map(|member| { + RestoreMemberStatus::new(member.relative_path(), MemberVerification::Verified) + .expect("verified status") + }) + .collect(); + let finalizing = verifying + .transition( + verifying.revision(), + RestoreTransition::Verified(verified), + 220, + ) + .expect("verified restore"); + let finalized = finalizing + .transition(finalizing.revision(), RestoreTransition::Finalize, 230) + .expect("finalized restore"); + assert_eq!(finalized.stage(), RestoreStage::Finalized); +} + +#[test] +fn integrity_and_storage_status_reject_inconsistent_runtime_claims() { + let integrity = + IntegrityStatus::new(IntegrityHealth::Healthy, Some(100), 2, 0).expect("integrity status"); + let status = StorageStatus::new( + StorageOpenMode::ReadWriteExisting, + WriterPolicy::AdvisoryProcessLock, + ShutdownState::Open, + integrity, + true, + 5_000, + ) + .expect("storage status"); + assert!(status.wal_enabled()); + assert_eq!(status.busy_timeout_ms(), 5_000); + assert_eq!( + IntegrityStatus::new(IntegrityHealth::Healthy, Some(100), 1, 1), + Err(Error::InvalidIntegrityStatus) + ); + assert_eq!( + StorageStatus::new( + StorageOpenMode::ReadWriteExisting, + WriterPolicy::NoWriter, + ShutdownState::Open, + integrity, + true, + 5_000, + ), + Err(Error::InvalidStorageStatus) + ); +} + +#[test] +fn reliability_spi_is_dyn_compatible_and_versions_are_independent() { + fn accepts_dyn(_: Option<&dyn StorageReliability>) {} + accepts_dyn(None); + assert_eq!(BackupFormatVersion::V1.get(), 1); + assert_eq!( + BackupFormatVersion::new(0), + Err(Error::InvalidBackupVersion) + ); + assert_eq!(BackupId::new([0; 16]), Err(Error::InvalidBackupId)); +}