myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit c4e7d8624e927bc7a4f7699755a0e74c0881b9b6
parent c45fd07cb6e920c71dabd2d90a85f490d30e7647
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 14:57:55 +0000

state: integrate Myc backup and recovery

Diffstat:
MREADME | 10++++++++++
Msrc/lib.rs | 6++++++
Msrc/state_host.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++--------
Asrc/state_maintenance.rs | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/nip46_e2e.rs | 5+++--
Atests/services_hardening_state_resilience.rs | 409+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 782 insertions(+), 10 deletions(-)

diff --git a/README b/README @@ -60,6 +60,16 @@ that transaction boundary. The v2 binding table and its update/delete guards are checksum-pinned service-owned schema objects; later workflow tables remain owned by their ordered repository steps. +Writable hosts expose Myc-bound online-backup and active-integrity operations. +Backup verification retains the exact admitted member inode, and +offline staging derives the same runtime paths, database identity, migration +catalog, and schema catalog from sealed Myc evidence. Finalization returns no +open host; the next writable open alone reconciles durable recovery evidence. +Read-only hosts cannot capture backups, and any live host prevents offline +restore authority. Cancellation, explicit close, and cleanup behavior remain +owned by the source-locked shared SQLx host; Myc maps every public failure to a +stable source-free classification. + ## NIP-46 runtime contract Myc listens for encrypted kind-24133 requests on the exact configured relay diff --git a/src/lib.rs b/src/lib.rs @@ -27,6 +27,7 @@ mod signing_adapter; pub mod sql; mod state_catalog; mod state_host; +mod state_maintenance; mod state_metadata; mod state_repository; pub mod transport; @@ -120,6 +121,11 @@ pub use state_host::{ MycStateHost, MycStateHostError, MycStateHostErrorKind, MycStateHostMode, initialize_myc_state, open_myc_state_inspection, open_myc_state_read_write, }; +pub use state_maintenance::{ + MycStagedStateRestore, MycStateMaintenanceError, MycStateMaintenanceErrorKind, + MycVerifiedStateBackup, finalize_myc_state_restore, stage_myc_state_restore, + verify_myc_state_backup, +}; pub use state_metadata::{ MYC_OPERATOR_CONTRACT_VERSION, MYC_STATE_APPLICATION_ID, MycExpectedIdentities, MycExpectedPublicIdentity, MycNormalizedConfigDigest, MycStateMetadata, MycStateMetadataError, diff --git a/src/state_host.rs b/src/state_host.rs @@ -1,17 +1,23 @@ //! Sealed lifecycle boundary for the canonical Myc SQLite state catalog. use core::fmt; -use std::{error::Error, path::PathBuf}; +use std::{ + error::Error, + path::{Path, PathBuf}, +}; use radroots_service_sqlite::{ - MigrationApplicationOutcome, MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, - ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqlitePaths, initialize_database, + BackupCreatedAtUnixMs, IntegrityCheckedAtUnixMs, MigrationApplicationOutcome, + MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, ServiceBackupManifest, + ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqliteIntegrityReport, + ServiceSqlitePaths, initialize_database, }; use sqlx::{ConnectOptions, Connection, SqliteConnection, sqlite::SqliteConnectOptions}; use crate::{ - MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION, MycRuntimeContext, MycStateMetadata, - MycStateRepository, myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs, + MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION, MycRuntimeContext, + MycStateMaintenanceError, MycStateMaintenanceErrorKind, MycStateMetadata, MycStateRepository, + myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs, }; /// Stable lifecycle mode of one opened Myc state host. @@ -148,6 +154,37 @@ impl MycStateHost { MycStateRepository::new(&self.host, &self.metadata) } + /// Captures one governed point-in-time backup from a writable Myc host. + /// + /// The staging directory must be a new absolute path. The returned + /// manifest remains in memory and contains no protected identity material. + pub async fn capture_online_backup( + &self, + staging_directory: &Path, + created_at: BackupCreatedAtUnixMs, + ) -> Result<ServiceBackupManifest, MycStateMaintenanceError> { + if self.mode != MycStateHostMode::ReadWriteExisting { + return Err(MycStateMaintenanceError::new( + MycStateMaintenanceErrorKind::InvalidMode, + )); + } + self.host + .capture_online_backup(staging_directory, created_at) + .await + .map_err(MycStateMaintenanceError::from_sqlite) + } + + /// Runs one explicit bounded integrity inspection over this host. + pub async fn inspect_integrity( + &self, + checked_at: IntegrityCheckedAtUnixMs, + ) -> Result<ServiceSqliteIntegrityReport, MycStateMaintenanceError> { + self.host + .inspect_integrity(checked_at) + .await + .map_err(MycStateMaintenanceError::from_sqlite) + } + /// Drains the shared host and explicitly releases retained authority. pub async fn close(&self) -> Result<(), MycStateHostError> { self.host @@ -298,12 +335,14 @@ pub async fn open_myc_state_inspection( Ok(state) } -fn state_paths(runtime: &MycRuntimeContext) -> Result<ServiceSqlitePaths, MycStateHostError> { +pub(crate) fn state_paths( + runtime: &MycRuntimeContext, +) -> Result<ServiceSqlitePaths, MycStateHostError> { ServiceSqlitePaths::from_runtime_context(runtime.context()) .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InvalidPaths)) } -fn require_metadata( +pub(crate) fn require_metadata( runtime: &MycRuntimeContext, metadata: &MycStateMetadata, ) -> Result<(), MycStateHostError> { @@ -349,7 +388,7 @@ fn exact_existing_outcome(outcome: MigrationApplicationOutcome) -> bool { ) } -fn catalogs() -> Result< +pub(crate) fn catalogs() -> Result< ( radroots_service_sqlite::MigrationCatalog, radroots_service_sqlite::SchemaCatalog, diff --git a/src/state_maintenance.rs b/src/state_maintenance.rs @@ -0,0 +1,307 @@ +//! Myc-bound integrity, backup, and offline restore integration. + +use core::{fmt, num::NonZeroU64}; +use std::{error::Error, path::Path}; + +use radroots_service_sqlite::{ + BackupManifestSha256, ServiceBackupManifest, ServiceDatabaseMetadata, ServiceSqliteError, + ServiceSqliteErrorKind, StagedServiceRestore, VerifiedServiceBackup, finalize_staged_restore, + stage_verified_restore, verify_backup_bundle, +}; + +use crate::{MycRuntimeContext, MycStateMetadata, state_host}; + +/// Stable source-free class for a Myc state-maintenance failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycStateMaintenanceErrorKind { + InvalidEvidence, + InvalidMode, + Catalog, + Authority, + Open, + Metadata, + Migration, + Backup, + Restore, + Integrity, + Recovery, +} + +impl MycStateMaintenanceErrorKind { + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidEvidence => "state_maintenance_evidence_invalid", + Self::InvalidMode => "state_maintenance_mode_invalid", + Self::Catalog => "state_maintenance_catalog_invalid", + Self::Authority => "state_maintenance_authority_failed", + Self::Open => "state_maintenance_open_failed", + Self::Metadata => "state_maintenance_metadata_invalid", + Self::Migration => "state_maintenance_migration_invalid", + Self::Backup => "state_backup_failed", + Self::Restore => "state_restore_failed", + Self::Integrity => "state_integrity_failed", + Self::Recovery => "state_recovery_failed", + } + } +} + +/// Redacted Myc state-maintenance failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycStateMaintenanceError { + kind: MycStateMaintenanceErrorKind, +} + +impl MycStateMaintenanceError { + pub(crate) const fn new(kind: MycStateMaintenanceErrorKind) -> Self { + Self { kind } + } + + pub(crate) fn from_sqlite(error: ServiceSqliteError) -> Self { + let kind = match error.kind() { + ServiceSqliteErrorKind::Authority => MycStateMaintenanceErrorKind::Authority, + ServiceSqliteErrorKind::Open + | ServiceSqliteErrorKind::Create + | ServiceSqliteErrorKind::Pragma => MycStateMaintenanceErrorKind::Open, + ServiceSqliteErrorKind::Metadata => MycStateMaintenanceErrorKind::Metadata, + ServiceSqliteErrorKind::Migration => MycStateMaintenanceErrorKind::Migration, + ServiceSqliteErrorKind::Backup => MycStateMaintenanceErrorKind::Backup, + ServiceSqliteErrorKind::Restore => MycStateMaintenanceErrorKind::Restore, + ServiceSqliteErrorKind::Integrity => MycStateMaintenanceErrorKind::Integrity, + ServiceSqliteErrorKind::Recovery => MycStateMaintenanceErrorKind::Recovery, + }; + Self::new(kind) + } + + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> MycStateMaintenanceErrorKind { + self.kind + } + + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Display for MycStateMaintenanceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + MycStateMaintenanceErrorKind::InvalidEvidence => { + "Myc state maintenance evidence is invalid" + } + MycStateMaintenanceErrorKind::InvalidMode => { + "Myc state maintenance is unavailable in this host mode" + } + MycStateMaintenanceErrorKind::Catalog => "Myc state catalogs are invalid", + MycStateMaintenanceErrorKind::Authority => { + "Myc state maintenance authority could not be established" + } + MycStateMaintenanceErrorKind::Open => "Myc state maintenance could not open state", + MycStateMaintenanceErrorKind::Metadata => "Myc state metadata is invalid", + MycStateMaintenanceErrorKind::Migration => "Myc state migration history is invalid", + MycStateMaintenanceErrorKind::Backup => "Myc state backup failed", + MycStateMaintenanceErrorKind::Restore => "Myc state restore failed", + MycStateMaintenanceErrorKind::Integrity => "Myc state integrity check failed", + MycStateMaintenanceErrorKind::Recovery => "Myc state recovery failed", + }) + } +} + +impl fmt::Debug for MycStateMaintenanceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycStateMaintenanceError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for MycStateMaintenanceError {} + +/// Retained exact-inode proof of one verified Myc backup. +/// +/// Construction is sealed to [`verify_myc_state_backup`]. No raw descriptor or +/// pathname is exposed. +/// +/// ```compile_fail +/// use myc::MycVerifiedStateBackup; +/// let _ = MycVerifiedStateBackup { inner: todo!() }; +/// ``` +pub struct MycVerifiedStateBackup { + inner: VerifiedServiceBackup, +} + +impl MycVerifiedStateBackup { + /// Returns the admitted canonical manifest. + #[must_use] + pub const fn manifest(&self) -> &ServiceBackupManifest { + self.inner.manifest() + } + + /// Returns the actual immutable database metadata read from the retained member. + #[must_use] + pub const fn database_metadata(&self) -> &ServiceDatabaseMetadata { + self.inner.database_metadata() + } +} + +impl fmt::Debug for MycVerifiedStateBackup { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycVerifiedStateBackup([redacted])") + } +} + +/// Offline staged Myc replacement that retains exclusive writer authority. +/// +/// Construction is sealed to [`stage_myc_state_restore`]. Dropping this value +/// preserves the shared exact-inode cleanup and fail-closed evidence contract. +/// +/// ```compile_fail +/// use myc::MycStagedStateRestore; +/// let _ = MycStagedStateRestore { inner: todo!() }; +/// ``` +pub struct MycStagedStateRestore { + inner: StagedServiceRestore, +} + +impl fmt::Debug for MycStagedStateRestore { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycStagedStateRestore([redacted])") + } +} + +/// Verifies an untrusted backup bundle against one sealed Myc state identity. +pub fn verify_myc_state_backup( + manifest_bytes: &[u8], + expected_manifest_digest: BackupManifestSha256, + bundle_directory: &Path, + expected: &MycStateMetadata, + maximum_state_bytes: NonZeroU64, +) -> Result<MycVerifiedStateBackup, MycStateMaintenanceError> { + verify_backup_bundle( + manifest_bytes, + expected_manifest_digest, + bundle_directory, + &expected.database_identity(), + maximum_state_bytes, + ) + .map(|inner| MycVerifiedStateBackup { inner }) + .map_err(MycStateMaintenanceError::from_sqlite) +} + +/// Copies and fully reverifies a verified backup beside closed Myc state. +/// +/// This operation acquires exclusive writer authority. It never creates a +/// recovery marker or replaces the live database. +pub async fn stage_myc_state_restore( + runtime: &MycRuntimeContext, + expected: &MycStateMetadata, + verified: MycVerifiedStateBackup, +) -> Result<MycStagedStateRestore, MycStateMaintenanceError> { + state_host::require_metadata(runtime, expected).map_err(|_| { + MycStateMaintenanceError::new(MycStateMaintenanceErrorKind::InvalidEvidence) + })?; + let paths = state_host::state_paths(runtime).map_err(|_| { + MycStateMaintenanceError::new(MycStateMaintenanceErrorKind::InvalidEvidence) + })?; + let (migrations, schema) = state_host::catalogs() + .map_err(|_| MycStateMaintenanceError::new(MycStateMaintenanceErrorKind::Catalog))?; + stage_verified_restore( + &paths, + &expected.database_identity(), + &migrations, + &schema, + verified.inner, + ) + .await + .map(|inner| MycStagedStateRestore { inner }) + .map_err(MycStateMaintenanceError::from_sqlite) +} + +/// Atomically installs a completely verified staged Myc restore. +/// +/// Success intentionally returns no open host. The next writable open owns +/// exact recovery evidence reconciliation before SQLite is exposed again. +pub async fn finalize_myc_state_restore( + staged: MycStagedStateRestore, +) -> Result<(), MycStateMaintenanceError> { + finalize_staged_restore(staged.inner) + .await + .map_err(MycStateMaintenanceError::from_sqlite) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_failures_map_to_the_closed_source_free_myc_vocabulary() { + for (source, expected) in [ + ( + ServiceSqliteErrorKind::Authority, + MycStateMaintenanceErrorKind::Authority, + ), + ( + ServiceSqliteErrorKind::Open, + MycStateMaintenanceErrorKind::Open, + ), + ( + ServiceSqliteErrorKind::Create, + MycStateMaintenanceErrorKind::Open, + ), + ( + ServiceSqliteErrorKind::Pragma, + MycStateMaintenanceErrorKind::Open, + ), + ( + ServiceSqliteErrorKind::Metadata, + MycStateMaintenanceErrorKind::Metadata, + ), + ( + ServiceSqliteErrorKind::Migration, + MycStateMaintenanceErrorKind::Migration, + ), + ( + ServiceSqliteErrorKind::Backup, + MycStateMaintenanceErrorKind::Backup, + ), + ( + ServiceSqliteErrorKind::Restore, + MycStateMaintenanceErrorKind::Restore, + ), + ( + ServiceSqliteErrorKind::Integrity, + MycStateMaintenanceErrorKind::Integrity, + ), + ( + ServiceSqliteErrorKind::Recovery, + MycStateMaintenanceErrorKind::Recovery, + ), + ] { + let mapped = MycStateMaintenanceError::from_sqlite(ServiceSqliteError::with_source( + source, + SensitiveSource, + )); + assert_eq!(mapped.kind(), expected); + assert!(Error::source(&mapped).is_none()); + let rendered = format!("{mapped} {mapped:?}"); + assert!(!rendered.contains("sensitive")); + assert!(!mapped.code().is_empty()); + } + } + + #[derive(Debug)] + struct SensitiveSource; + + impl fmt::Display for SensitiveSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("sensitive /tmp/state.sqlite") + } + } + + impl Error for SensitiveSource {} +} diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -2432,6 +2432,9 @@ async fn live_listener_works_with_sqlite_signer_state_and_runtime_audit() -> Tes assert_eq!(outbox_records[0].status, MycDeliveryOutboxStatus::Failed); assert_eq!(outbox_records[1].status, MycDeliveryOutboxStatus::Finalized); + let _ = shutdown_tx.send(()); + listener_task.await??; + let restarted_runtime = MycRuntime::bootstrap(runtime.config().clone())?; assert_eq!( restarted_runtime @@ -2482,8 +2485,6 @@ async fn live_listener_works_with_sqlite_signer_state_and_runtime_audit() -> Tes Some("SQLite Client") ); - let _ = shutdown_tx.send(()); - listener_task.await??; Ok(()) } diff --git a/tests/services_hardening_state_resilience.rs b/tests/services_hardening_state_resilience.rs @@ -0,0 +1,409 @@ +#![forbid(unsafe_code)] +#![cfg(any(target_os = "linux", target_os = "macos"))] + +use std::{ + error::Error, + fs, + num::NonZeroU64, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + time::Duration, +}; + +use myc::{ + MycConfigProfile, MycStateHostErrorKind, MycStateMaintenanceErrorKind, MycStateMetadata, + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, finalize_myc_state_restore, + initialize_myc_state, open_myc_state_inspection, open_myc_state_read_write, + parse_myc_cli_v1_from, parse_myc_config_v1, resolve_myc_runtime_context, + stage_myc_state_restore, verify_myc_state_backup, +}; +use radroots_service_sqlite::{ + BackupCreatedAtUnixMs, IntegrityCheckOutcome, IntegrityCheckedAtUnixMs, + MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, +}; +use radroots_storage::event::SourceGeneration; +use sqlx::{ConnectOptions, Connection, SqliteConnection, sqlite::SqliteConnectOptions}; + +const CONFIG_EXAMPLE: &[u8] = + include_bytes!("../contracts/services_hardening/config.v1.example.toml"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const HOST_SOURCE: &str = include_str!("../src/state_host.rs"); +const MAINTENANCE_SOURCE: &str = include_str!("../src/state_maintenance.rs"); + +fn runtime(root: &Path, instance: &str) -> myc::MycRuntimeContext { + let invocation = parse_myc_cli_v1_from([ + "myc", + "--profile", + "repo-local", + "--instance", + instance, + "--repo-local-root", + root.to_str().expect("UTF-8 temporary root"), + "run", + ]) + .expect("valid invocation"); + resolve_myc_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + &invocation, + ) + .expect("runtime context") +} + +fn prepare_state_directory(runtime: &myc::MycRuntimeContext) { + let directory = runtime.context().paths().state(); + fs::create_dir_all(directory).expect("state directory"); + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("state mode"); +} + +fn metadata(runtime: &myc::MycRuntimeContext) -> MycStateMetadata { + let configuration = + parse_myc_config_v1(CONFIG_EXAMPLE, MycConfigProfile::RepoLocal).expect("configuration"); + MycStateMetadata::new( + runtime, + &configuration, + SourceGeneration::new([0x5a; 32]).expect("generation"), + 1_725_000_000_000, + ) + .expect("metadata") +} + +fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) { + let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("migration time"); + let build = MigrationBuildIdentity::new( + env!("CARGO_PKG_VERSION"), + "1111111111111111111111111111111111111111", + "b44119fbac5985be8127ad1bf56d2950e6399427", + "rustc-test", + "test-target", + "service-host", + 1, + myc::MYC_STATE_SCHEMA_VERSION, + 1, + 1, + 1, + ) + .expect("build identity"); + (applied_at, build) +} + +fn recovery_paths(runtime: &myc::MycRuntimeContext) -> [PathBuf; 4] { + let state = runtime.context().paths().state(); + [ + state.join("state.restore-staged.sqlite"), + state.join("state.restore-backup.sqlite"), + state.join("state.restore-marker.v1"), + state.join("state.restore-marker.v1.next"), + ] +} + +fn directory_inventory(directory: &Path) -> Vec<String> { + let mut entries = fs::read_dir(directory) + .expect("state directory") + .map(|entry| { + entry + .expect("state entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect::<Vec<_>>(); + entries.sort(); + entries +} + +#[tokio::test] +async fn backup_integrity_and_offline_restore_obey_one_exact_myc_authority() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "primary"); + prepare_state_directory(&runtime); + let metadata = metadata(&runtime); + let (applied_at, build) = migration_evidence(); + initialize_myc_state(&runtime, &metadata, applied_at, &build) + .await + .expect("initialization"); + + let writer = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("writable host"); + let cancelled_bundle = directory.path().join("cancelled-backup"); + let cancelled = tokio::time::timeout( + Duration::from_nanos(1), + writer.capture_online_backup( + &cancelled_bundle, + BackupCreatedAtUnixMs::new(1_725_000_000_100).expect("capture time"), + ), + ) + .await; + assert!(cancelled.is_err(), "capture future must be cancellable"); + writer + .close() + .await + .expect("close drains cancelled capture cleanup"); + assert!(!cancelled_bundle.exists()); + + let writer = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("writer reacquisition after cancelled capture"); + let cancelled_integrity = tokio::time::timeout( + Duration::from_nanos(1), + writer.inspect_integrity( + IntegrityCheckedAtUnixMs::new(1_725_000_000_200).expect("inspection time"), + ), + ) + .await; + assert!( + cancelled_integrity.is_err(), + "integrity future must be cancellable" + ); + let report = writer + .inspect_integrity( + IntegrityCheckedAtUnixMs::new(1_725_000_000_201).expect("retry inspection time"), + ) + .await + .expect("integrity retry after cancellation"); + assert_eq!(report.sqlite(), IntegrityCheckOutcome::Verified); + assert_eq!(report.foreign_keys(), IntegrityCheckOutcome::Verified); + assert!(report.diagnostics().is_empty()); + + let bundle = directory.path().join("backup"); + let manifest = writer + .capture_online_backup( + &bundle, + BackupCreatedAtUnixMs::new(1_725_000_000_300).expect("capture time"), + ) + .await + .expect("online backup"); + assert_eq!(manifest.service().as_str(), "myc"); + assert_eq!(manifest.instance().as_str(), "primary"); + assert_eq!(manifest.state_schema_version().get(), 2); + assert!(!manifest.protected_material_included()); + let members = fs::read_dir(&bundle) + .expect("backup directory") + .map(|entry| entry.expect("entry").file_name()) + .collect::<Vec<_>>(); + assert_eq!(members, ["state.sqlite"]); + let manifest_bytes = manifest.canonical_bytes().to_vec(); + let manifest_digest = manifest.digest(); + let maximum_state_bytes = + NonZeroU64::new(manifest.members()[0].byte_length()).expect("nonzero captured member"); + writer.close().await.expect("writer close"); + + let live_path = runtime.artifacts().state_database(); + let state_directory = runtime.context().paths().state(); + let live_bytes_before_inspection = fs::read(live_path).expect("live bytes"); + let live_modified_before_inspection = fs::metadata(live_path) + .expect("live metadata") + .modified() + .expect("live modified time"); + let inventory_before_inspection = directory_inventory(state_directory); + let inspection = open_myc_state_inspection(&runtime, &metadata) + .await + .expect("read-only inspection"); + let inspection_report = inspection + .inspect_integrity( + IntegrityCheckedAtUnixMs::new(1_725_000_000_350).expect("inspection time"), + ) + .await + .expect("read-only integrity inspection"); + assert_eq!(inspection_report.sqlite(), IntegrityCheckOutcome::Verified); + assert_eq!( + inspection_report.foreign_keys(), + IntegrityCheckOutcome::Verified + ); + let forbidden_bundle = directory.path().join("inspection-backup"); + let error = inspection + .capture_online_backup( + &forbidden_bundle, + BackupCreatedAtUnixMs::new(1_725_000_000_400).expect("capture time"), + ) + .await + .expect_err("read-only capture"); + assert_eq!(error.kind(), MycStateMaintenanceErrorKind::InvalidMode); + assert!(!forbidden_bundle.exists()); + + let verified = verify_myc_state_backup( + &manifest_bytes, + manifest_digest, + &bundle, + &metadata, + maximum_state_bytes, + ) + .expect("verified retained backup"); + let contended = stage_myc_state_restore(&runtime, &metadata, verified) + .await + .expect_err("offline staging must reject a live inspection host"); + assert_eq!(contended.kind(), MycStateMaintenanceErrorKind::Authority); + inspection.close().await.expect("inspection close"); + assert_eq!( + fs::read(live_path).expect("live bytes after inspection"), + live_bytes_before_inspection + ); + assert_eq!( + fs::metadata(live_path) + .expect("live metadata after inspection") + .modified() + .expect("live modified time after inspection"), + live_modified_before_inspection + ); + assert_eq!( + directory_inventory(state_directory), + inventory_before_inspection + ); + + let verified = verify_myc_state_backup( + &manifest_bytes, + manifest_digest, + &bundle, + &metadata, + maximum_state_bytes, + ) + .expect("reverified backup for runtime mismatch"); + let secondary = self::runtime(directory.path(), "secondary"); + let mismatch = stage_myc_state_restore(&secondary, &metadata, verified) + .await + .expect_err("runtime and metadata must remain cross-bound"); + assert_eq!( + mismatch.kind(), + MycStateMaintenanceErrorKind::InvalidEvidence + ); + assert!(!secondary.artifacts().state_database().exists()); + assert!(recovery_paths(&secondary).iter().all(|path| !path.exists())); + + let verified = verify_myc_state_backup( + &manifest_bytes, + manifest_digest, + &bundle, + &metadata, + maximum_state_bytes, + ) + .expect("reverified backup"); + assert_eq!( + format!("{verified:?}"), + "MycVerifiedStateBackup([redacted])" + ); + assert_eq!(verified.database_metadata().state_schema_version().get(), 2); + let staged = stage_myc_state_restore(&runtime, &metadata, verified) + .await + .expect("offline staging"); + assert_eq!(format!("{staged:?}"), "MycStagedStateRestore([redacted])"); + finalize_myc_state_restore(staged) + .await + .expect("atomic finalization"); + + let unavailable = open_myc_state_inspection(&runtime, &metadata) + .await + .expect_err("inspection never performs restore recovery"); + assert_eq!(unavailable.kind(), MycStateHostErrorKind::InspectionOpen); + let recovered = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("writable open reconciles exact recovery evidence"); + recovered + .repository() + .verify_binding() + .await + .expect("restored Myc binding"); + recovered.close().await.expect("recovered writer close"); + for path in recovery_paths(&runtime) { + assert!(!path.exists(), "recovery evidence must be retired"); + } +} + +#[tokio::test] +async fn exact_open_rejects_migration_history_drift_without_repair() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "primary"); + prepare_state_directory(&runtime); + let metadata = metadata(&runtime); + let (applied_at, build) = migration_evidence(); + initialize_myc_state(&runtime, &metadata, applied_at, &build) + .await + .expect("initialization"); + + let options = SqliteConnectOptions::new() + .filename(runtime.artifacts().state_database()) + .create_if_missing(false) + .disable_statement_logging(); + let mut connection = SqliteConnection::connect_with(&options) + .await + .expect("test-only offline connection"); + sqlx::query("DROP TRIGGER schema_migrations_no_delete") + .execute(&mut connection) + .await + .expect("remove immutable test guard"); + sqlx::query("DELETE FROM schema_migrations WHERE version = 2") + .execute(&mut connection) + .await + .expect("create invalid migration prefix"); + connection.close().await.expect("test connection close"); + + let error = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect_err("migration drift must fail closed"); + assert_eq!(error.kind(), MycStateHostErrorKind::ReadWriteOpen); + let inspection = open_myc_state_inspection(&runtime, &metadata) + .await + .expect_err("inspection must reject migration drift"); + assert_eq!(inspection.kind(), MycStateHostErrorKind::InspectionOpen); +} + +#[test] +fn maintenance_boundary_is_sealed_source_free_and_sqlx_owned() { + assert!(LIB_SOURCE.contains("mod state_maintenance;")); + assert!(!LIB_SOURCE.contains("pub mod state_maintenance;")); + assert!(HOST_SOURCE.contains(".capture_online_backup(staging_directory, created_at)")); + assert!(HOST_SOURCE.contains(".inspect_integrity(checked_at)")); + assert!(MAINTENANCE_SOURCE.contains("verify_backup_bundle(")); + assert!(MAINTENANCE_SOURCE.contains("stage_verified_restore(")); + assert!(MAINTENANCE_SOURCE.contains("finalize_staged_restore(")); + for forbidden in [ + "sqlx::", + "SqliteConnection", + "SqlitePool", + "raw_sql", + "BEGIN ", + "COMMIT", + "ROLLBACK", + "std::fs", + "std::env", + "std::time", + "provider", + "relay", + "tokio::spawn", + "spawn_blocking", + ] { + assert!( + !MAINTENANCE_SOURCE.contains(forbidden), + "found forbidden maintenance authority `{forbidden}`" + ); + } + + for kind in [ + MycStateMaintenanceErrorKind::InvalidEvidence, + MycStateMaintenanceErrorKind::InvalidMode, + MycStateMaintenanceErrorKind::Catalog, + MycStateMaintenanceErrorKind::Authority, + MycStateMaintenanceErrorKind::Open, + MycStateMaintenanceErrorKind::Metadata, + MycStateMaintenanceErrorKind::Migration, + MycStateMaintenanceErrorKind::Backup, + MycStateMaintenanceErrorKind::Restore, + MycStateMaintenanceErrorKind::Integrity, + MycStateMaintenanceErrorKind::Recovery, + ] { + assert!(!kind.code().is_empty()); + } + + let error = verify_myc_state_backup( + b"/tmp/secret-state.sqlite", + radroots_service_sqlite::BackupManifestSha256::from_bytes([0x11; 32]), + Path::new("/tmp/secret-bundle"), + &metadata(&runtime(Path::new("/tmp/secret-root"), "primary")), + NonZeroU64::new(1).expect("limit"), + ) + .expect_err("invalid manifest"); + assert!(Error::source(&error).is_none()); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains("secret")); + assert!(!rendered.contains("/tmp")); + assert!(!rendered.contains("sqlite")); +}