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:
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));
+}