lib

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

recover.rs (47108B)


      1 //! Deterministic writer-authorized reconciliation of interrupted restores.
      2 
      3 #[cfg(any(target_os = "linux", target_os = "macos"))]
      4 use core::fmt;
      5 
      6 #[cfg(any(target_os = "linux", target_os = "macos"))]
      7 use {
      8     super::{
      9         RestoreArtifactExpectation, RestoreMarkerBinding, RestoreRecoveryPhase,
     10         marker::{
     11             BACKUP_FILE_NAME, LIVE_FILE_NAME, MARKER_FILE_NAME, MARKER_NEXT_FILE_NAME,
     12             STAGED_FILE_NAME,
     13         },
     14     },
     15     crate::{
     16         ExistingServiceDatabaseIntent, ServiceDatabaseIdentity, ServiceSqliteError,
     17         ServiceSqliteErrorKind, ServiceSqlitePaths, WriterAuthority,
     18     },
     19     rustix::{
     20         fs::{
     21             AtFlags, FileType, Mode, OFlags, RenameFlags, fstat, openat, renameat_with, statat,
     22             unlinkat,
     23         },
     24         io::Errno,
     25         process::geteuid,
     26     },
     27     sha2::{Digest, Sha256},
     28     std::{error::Error, fs::File, os::unix::fs::FileExt},
     29 };
     30 
     31 #[cfg(any(target_os = "linux", target_os = "macos"))]
     32 const HASH_BUFFER_BYTES: usize = 64 * 1_024;
     33 
     34 #[cfg(any(target_os = "linux", target_os = "macos"))]
     35 pub(crate) fn recover_for_open(
     36     paths: &ServiceSqlitePaths,
     37     identity: &ServiceDatabaseIdentity,
     38     authority: &WriterAuthority,
     39 ) -> Result<(), ServiceSqliteError> {
     40     recover_for_open_expectation(
     41         paths,
     42         RecoveryDatabaseExpectation::Exact(identity),
     43         authority,
     44     )
     45 }
     46 
     47 #[cfg(any(target_os = "linux", target_os = "macos"))]
     48 pub(crate) fn recover_for_open_with_intent(
     49     paths: &ServiceSqlitePaths,
     50     intent: &ExistingServiceDatabaseIntent,
     51     authority: &WriterAuthority,
     52 ) -> Result<(), ServiceSqliteError> {
     53     recover_for_open_expectation(
     54         paths,
     55         RecoveryDatabaseExpectation::Existing(intent),
     56         authority,
     57     )
     58 }
     59 
     60 #[cfg(any(target_os = "linux", target_os = "macos"))]
     61 #[derive(Clone, Copy)]
     62 enum RecoveryDatabaseExpectation<'a> {
     63     Exact(&'a ServiceDatabaseIdentity),
     64     Existing(&'a ExistingServiceDatabaseIntent),
     65 }
     66 
     67 #[cfg(any(target_os = "linux", target_os = "macos"))]
     68 fn recover_for_open_expectation(
     69     paths: &ServiceSqlitePaths,
     70     expectation: RecoveryDatabaseExpectation<'_>,
     71     authority: &WriterAuthority,
     72 ) -> Result<(), ServiceSqliteError> {
     73     authority.validate_for(paths)?;
     74     let Some(mut marker) = RestoreMarkerBinding::load_for_recovery(paths, authority)? else {
     75         authority_checked(authority, paths, || {
     76             refuse_unresolved_recovery(authority.directory())
     77         })?;
     78         return Ok(());
     79     };
     80     let intent_matches = match expectation {
     81         RecoveryDatabaseExpectation::Exact(identity) => marker.marker().matches_identity(identity),
     82         RecoveryDatabaseExpectation::Existing(intent) => {
     83             marker.marker().matches_existing_intent(intent)
     84         }
     85     };
     86     if !intent_matches {
     87         return Err(recovery_error(RecoveryFailureKind::Intent));
     88     }
     89 
     90     let observed = authority_checked(authority, paths, || {
     91         observe_artifacts(authority.directory(), marker.marker())
     92     })?;
     93     if let Some(next) = marker.interrupted_transition(paths, authority)? {
     94         let topology_matches = match (marker.marker().phase(), next) {
     95             (RestoreRecoveryPhase::Prepared, RestoreRecoveryPhase::LiveRetained) => {
     96                 observed.proves_live_retained()
     97             }
     98             (RestoreRecoveryPhase::LiveRetained, RestoreRecoveryPhase::ReplacementInstalled) => {
     99                 observed.proves_replacement_installed()
    100             }
    101             _ => false,
    102         };
    103         if !topology_matches {
    104             return Err(recovery_error(RecoveryFailureKind::Topology));
    105         }
    106         marker = marker.promote_interrupted_transition(paths, authority, next)?;
    107     }
    108 
    109     loop {
    110         let observed = authority_checked(authority, paths, || {
    111             observe_artifacts(authority.directory(), marker.marker())
    112         })?;
    113         match marker.marker().phase() {
    114             RestoreRecoveryPhase::Prepared if observed.can_roll_back_prepared() => {
    115                 if let Some(staged) = observed.staged.as_ref() {
    116                     authority_checked(authority, paths, || {
    117                         remove_exact_artifact(
    118                             authority.directory(),
    119                             STAGED_FILE_NAME,
    120                             staged,
    121                             marker.marker().staged(),
    122                         )
    123                     })?;
    124                 }
    125                 marker.retire(paths, authority)?;
    126                 authority_checked(authority, paths, || {
    127                     refuse_unresolved_recovery(authority.directory())
    128                 })?;
    129                 return Ok(());
    130             }
    131             RestoreRecoveryPhase::Prepared if observed.proves_live_retained() => {
    132                 authority_checked(authority, paths, || {
    133                     authority.directory().sync_all().map_err(|source| {
    134                         recovery_source(RecoveryFailureKind::DirectorySync, source)
    135                     })
    136                 })?;
    137                 marker = marker.advance_for_recovery(
    138                     paths,
    139                     authority,
    140                     RestoreRecoveryPhase::LiveRetained,
    141                 )?;
    142             }
    143             RestoreRecoveryPhase::LiveRetained if observed.needs_replacement_install() => {
    144                 let staged = observed
    145                     .staged
    146                     .as_ref()
    147                     .ok_or_else(|| recovery_error(RecoveryFailureKind::Topology))?;
    148                 authority_checked(authority, paths, || {
    149                     verify_named_artifact(
    150                         authority.directory(),
    151                         STAGED_FILE_NAME,
    152                         staged,
    153                         marker.marker().staged(),
    154                     )
    155                 })?;
    156                 authority_checked(authority, paths, || {
    157                     renameat_with(
    158                         authority.directory(),
    159                         STAGED_FILE_NAME,
    160                         authority.directory(),
    161                         LIVE_FILE_NAME,
    162                         RenameFlags::NOREPLACE,
    163                     )
    164                     .map_err(|source| {
    165                         recovery_source(RecoveryFailureKind::InstallReplacement, source)
    166                     })
    167                 })?;
    168                 authority_checked(authority, paths, || {
    169                     verify_named_artifact(
    170                         authority.directory(),
    171                         LIVE_FILE_NAME,
    172                         staged,
    173                         marker.marker().staged(),
    174                     )
    175                 })?;
    176                 authority_checked(authority, paths, || {
    177                     authority.directory().sync_all().map_err(|source| {
    178                         recovery_source(RecoveryFailureKind::DirectorySync, source)
    179                     })
    180                 })?;
    181                 marker = marker.advance_for_recovery(
    182                     paths,
    183                     authority,
    184                     RestoreRecoveryPhase::ReplacementInstalled,
    185                 )?;
    186             }
    187             RestoreRecoveryPhase::LiveRetained if observed.proves_replacement_installed() => {
    188                 authority_checked(authority, paths, || {
    189                     authority.directory().sync_all().map_err(|source| {
    190                         recovery_source(RecoveryFailureKind::DirectorySync, source)
    191                     })
    192                 })?;
    193                 marker = marker.advance_for_recovery(
    194                     paths,
    195                     authority,
    196                     RestoreRecoveryPhase::ReplacementInstalled,
    197                 )?;
    198             }
    199             RestoreRecoveryPhase::ReplacementInstalled
    200                 if observed.proves_replacement_installed_or_cleanup() =>
    201             {
    202                 let live = match observed.live {
    203                     LiveArtifact::Replacement(ref file) => file,
    204                     _ => return Err(recovery_error(RecoveryFailureKind::Topology)),
    205                 };
    206                 authority_checked(authority, paths, || {
    207                     verify_named_artifact(
    208                         authority.directory(),
    209                         LIVE_FILE_NAME,
    210                         live,
    211                         observed.marker_staged,
    212                     )
    213                 })?;
    214                 if let Some(backup) = observed.backup.as_ref() {
    215                     authority_checked(authority, paths, || {
    216                         remove_exact_artifact(
    217                             authority.directory(),
    218                             BACKUP_FILE_NAME,
    219                             backup,
    220                             observed.marker_live,
    221                         )
    222                     })?;
    223                 }
    224                 marker.retire(paths, authority)?;
    225                 authority_checked(authority, paths, || {
    226                     refuse_unresolved_recovery(authority.directory())
    227                 })?;
    228                 authority_checked(authority, paths, || {
    229                     verify_named_artifact(
    230                         authority.directory(),
    231                         LIVE_FILE_NAME,
    232                         live,
    233                         observed.marker_staged,
    234                     )
    235                 })?;
    236                 return Ok(());
    237             }
    238             _ => return Err(recovery_error(RecoveryFailureKind::Topology)),
    239         }
    240     }
    241 }
    242 
    243 #[cfg(any(target_os = "linux", target_os = "macos"))]
    244 pub(crate) fn refuse_unresolved_recovery(
    245     directory: &impl std::os::fd::AsFd,
    246 ) -> Result<(), ServiceSqliteError> {
    247     for name in [
    248         STAGED_FILE_NAME,
    249         BACKUP_FILE_NAME,
    250         MARKER_FILE_NAME,
    251         MARKER_NEXT_FILE_NAME,
    252     ] {
    253         match statat(directory, name, AtFlags::SYMLINK_NOFOLLOW) {
    254             Err(Errno::NOENT) => {}
    255             Ok(_) | Err(_) => {
    256                 return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Recovery));
    257             }
    258         }
    259     }
    260     Ok(())
    261 }
    262 
    263 #[cfg(any(target_os = "linux", target_os = "macos"))]
    264 struct ObservedArtifacts {
    265     live: LiveArtifact,
    266     staged: Option<File>,
    267     backup: Option<File>,
    268     marker_live: RestoreArtifactExpectation,
    269     marker_staged: RestoreArtifactExpectation,
    270 }
    271 
    272 #[cfg(any(target_os = "linux", target_os = "macos"))]
    273 impl ObservedArtifacts {
    274     fn can_roll_back_prepared(&self) -> bool {
    275         matches!(self.live, LiveArtifact::Original) && self.backup.is_none()
    276     }
    277 
    278     fn proves_live_retained(&self) -> bool {
    279         matches!(self.live, LiveArtifact::Absent) && self.staged.is_some() && self.backup.is_some()
    280     }
    281 
    282     fn needs_replacement_install(&self) -> bool {
    283         self.proves_live_retained()
    284     }
    285 
    286     fn proves_replacement_installed(&self) -> bool {
    287         matches!(self.live, LiveArtifact::Replacement(_))
    288             && self.staged.is_none()
    289             && self.backup.is_some()
    290     }
    291 
    292     fn proves_replacement_installed_or_cleanup(&self) -> bool {
    293         matches!(self.live, LiveArtifact::Replacement(_)) && self.staged.is_none()
    294     }
    295 }
    296 
    297 #[cfg(any(target_os = "linux", target_os = "macos"))]
    298 enum LiveArtifact {
    299     Absent,
    300     Original,
    301     Replacement(File),
    302 }
    303 
    304 #[cfg(any(target_os = "linux", target_os = "macos"))]
    305 fn observe_artifacts(
    306     directory: &File,
    307     marker: &super::RestoreRecoveryMarker,
    308 ) -> Result<ObservedArtifacts, ServiceSqliteError> {
    309     for sidecar in [
    310         "state.sqlite-wal",
    311         "state.sqlite-shm",
    312         "state.sqlite-journal",
    313     ] {
    314         require_absent(directory, sidecar)?;
    315     }
    316     let live = match open_optional(directory, LIVE_FILE_NAME)? {
    317         None => LiveArtifact::Absent,
    318         Some(file) if artifact_has_identity(&file, marker.live())? => {
    319             verify_artifact(&file, marker.live())?;
    320             LiveArtifact::Original
    321         }
    322         Some(file) => {
    323             require_recovery_condition(
    324                 artifact_has_identity(&file, marker.staged())?,
    325                 RecoveryFailureKind::Artifact,
    326             )?;
    327             verify_artifact(&file, marker.staged())?;
    328             LiveArtifact::Replacement(file)
    329         }
    330     };
    331     let staged = observe_expected(directory, STAGED_FILE_NAME, marker.staged())?;
    332     let backup = observe_expected(directory, BACKUP_FILE_NAME, marker.backup())?;
    333     Ok(ObservedArtifacts {
    334         live,
    335         staged,
    336         backup,
    337         marker_live: marker.live(),
    338         marker_staged: marker.staged(),
    339     })
    340 }
    341 
    342 #[cfg(any(target_os = "linux", target_os = "macos"))]
    343 fn observe_expected(
    344     directory: &File,
    345     name: &str,
    346     expected: RestoreArtifactExpectation,
    347 ) -> Result<Option<File>, ServiceSqliteError> {
    348     let Some(file) = open_optional(directory, name)? else {
    349         return Ok(None);
    350     };
    351     verify_artifact(&file, expected)?;
    352     Ok(Some(file))
    353 }
    354 
    355 #[cfg(any(target_os = "linux", target_os = "macos"))]
    356 fn open_optional(directory: &File, name: &str) -> Result<Option<File>, ServiceSqliteError> {
    357     match openat(
    358         directory,
    359         name,
    360         OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
    361         Mode::empty(),
    362     ) {
    363         Ok(file) => Ok(Some(File::from(file))),
    364         Err(Errno::NOENT) => Ok(None),
    365         Err(source) => Err(recovery_source(RecoveryFailureKind::Artifact, source)),
    366     }
    367 }
    368 
    369 #[cfg(any(target_os = "linux", target_os = "macos"))]
    370 fn artifact_has_identity(
    371     file: &File,
    372     expected: RestoreArtifactExpectation,
    373 ) -> Result<bool, ServiceSqliteError> {
    374     let status =
    375         fstat(file).map_err(|source| recovery_source(RecoveryFailureKind::Artifact, source))?;
    376     let device = crate::native_metadata::device(status.st_dev)
    377         .map_err(|_| recovery_error(RecoveryFailureKind::Artifact))?;
    378     Ok((device, status.st_ino) == (expected.device(), expected.inode()))
    379 }
    380 
    381 #[cfg(any(target_os = "linux", target_os = "macos"))]
    382 fn verify_artifact(
    383     file: &File,
    384     expected: RestoreArtifactExpectation,
    385 ) -> Result<(), ServiceSqliteError> {
    386     let status =
    387         fstat(file).map_err(|source| recovery_source(RecoveryFailureKind::Artifact, source))?;
    388     let device = crate::native_metadata::device(status.st_dev)
    389         .map_err(|_| recovery_error(RecoveryFailureKind::Artifact))?;
    390     let length =
    391         u64::try_from(status.st_size).map_err(|_| recovery_error(RecoveryFailureKind::Artifact))?;
    392     require_recovery_condition(
    393         crate::all_constraints([
    394             crate::native_metadata::exact_regular_file(
    395                 FileType::from_raw_mode(status.st_mode).is_file(),
    396                 crate::native_metadata::link_count(status.st_nlink),
    397                 status.st_uid,
    398                 geteuid().as_raw(),
    399                 crate::native_metadata::mode(status.st_mode),
    400             ),
    401             (device, status.st_ino) == (expected.device(), expected.inode()),
    402             crate::native_metadata::valid_artifact_length(length, Some(expected.byte_length())),
    403         ]),
    404         RecoveryFailureKind::Artifact,
    405     )?;
    406     require_recovery_condition(
    407         hash_exact(file, expected.byte_length())? == expected.sha256(),
    408         RecoveryFailureKind::Artifact,
    409     )?;
    410     Ok(())
    411 }
    412 
    413 #[cfg(any(target_os = "linux", target_os = "macos"))]
    414 fn verify_named_artifact(
    415     directory: &File,
    416     name: &str,
    417     held: &File,
    418     expected: RestoreArtifactExpectation,
    419 ) -> Result<(), ServiceSqliteError> {
    420     verify_artifact(held, expected)?;
    421     let current = open_optional(directory, name)?
    422         .ok_or_else(|| recovery_error(RecoveryFailureKind::Artifact))?;
    423     verify_artifact(&current, expected)
    424 }
    425 
    426 #[cfg(any(target_os = "linux", target_os = "macos"))]
    427 fn remove_exact_artifact(
    428     directory: &File,
    429     name: &str,
    430     held: &File,
    431     expected: RestoreArtifactExpectation,
    432 ) -> Result<(), ServiceSqliteError> {
    433     verify_named_artifact(directory, name, held, expected)?;
    434     unlinkat(directory, name, AtFlags::empty())
    435         .map_err(|source| recovery_source(RecoveryFailureKind::Cleanup, source))?;
    436     directory
    437         .sync_all()
    438         .map_err(|source| recovery_source(RecoveryFailureKind::DirectorySync, source))?;
    439     require_absent(directory, name)
    440 }
    441 
    442 #[cfg(any(target_os = "linux", target_os = "macos"))]
    443 fn require_absent(directory: &File, name: &str) -> Result<(), ServiceSqliteError> {
    444     match statat(directory, name, AtFlags::SYMLINK_NOFOLLOW) {
    445         Err(Errno::NOENT) => Ok(()),
    446         Ok(_) | Err(_) => Err(recovery_error(RecoveryFailureKind::Topology)),
    447     }
    448 }
    449 
    450 #[cfg(any(target_os = "linux", target_os = "macos"))]
    451 fn require_recovery_condition(
    452     condition: bool,
    453     kind: RecoveryFailureKind,
    454 ) -> Result<(), ServiceSqliteError> {
    455     if condition {
    456         Ok(())
    457     } else {
    458         Err(recovery_error(kind))
    459     }
    460 }
    461 
    462 #[cfg(any(target_os = "linux", target_os = "macos"))]
    463 fn hash_exact(file: &File, expected_length: u64) -> Result<[u8; 32], ServiceSqliteError> {
    464     let mut hasher = Sha256::new();
    465     let mut buffer = [0_u8; HASH_BUFFER_BYTES];
    466     let mut offset = 0_u64;
    467     while offset < expected_length {
    468         let requested = usize::try_from((expected_length - offset).min(HASH_BUFFER_BYTES as u64))
    469             .map_err(|_| recovery_error(RecoveryFailureKind::Hash))?;
    470         let read = file
    471             .read_at(&mut buffer[..requested], offset)
    472             .map_err(|source| recovery_source(RecoveryFailureKind::Hash, source))?;
    473         require_recovery_condition(read != 0, RecoveryFailureKind::Hash)?;
    474         hasher.update(&buffer[..read]);
    475         offset = offset
    476             .checked_add(
    477                 u64::try_from(read).map_err(|_| recovery_error(RecoveryFailureKind::Hash))?,
    478             )
    479             .ok_or_else(|| recovery_error(RecoveryFailureKind::Hash))?;
    480     }
    481     let mut extra = [0_u8; 1];
    482     require_recovery_condition(
    483         file.read_at(&mut extra, expected_length)
    484             .map_err(|source| recovery_source(RecoveryFailureKind::Hash, source))?
    485             == 0,
    486         RecoveryFailureKind::Hash,
    487     )?;
    488     Ok(hasher.finalize().into())
    489 }
    490 
    491 #[cfg(any(target_os = "linux", target_os = "macos"))]
    492 fn authority_checked<T>(
    493     authority: &WriterAuthority,
    494     paths: &ServiceSqlitePaths,
    495     operation: impl FnOnce() -> Result<T, ServiceSqliteError>,
    496 ) -> Result<T, ServiceSqliteError> {
    497     authority.validate_for(paths)?;
    498     let result = operation();
    499     authority.validate_for(paths)?;
    500     result
    501 }
    502 
    503 #[cfg(any(target_os = "linux", target_os = "macos"))]
    504 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    505 enum RecoveryFailureKind {
    506     Intent,
    507     Topology,
    508     Artifact,
    509     Hash,
    510     InstallReplacement,
    511     DirectorySync,
    512     Cleanup,
    513 }
    514 
    515 #[cfg(any(target_os = "linux", target_os = "macos"))]
    516 struct RecoveryFailure {
    517     kind: RecoveryFailureKind,
    518     source: Option<Box<dyn Error + Send + Sync + 'static>>,
    519 }
    520 
    521 #[cfg(any(target_os = "linux", target_os = "macos"))]
    522 impl fmt::Debug for RecoveryFailure {
    523     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    524         formatter
    525             .debug_struct("RecoveryFailure")
    526             .field("kind", &self.kind)
    527             .field("source", &self.source.as_ref().map(|_| "[redacted]"))
    528             .finish()
    529     }
    530 }
    531 
    532 #[cfg(any(target_os = "linux", target_os = "macos"))]
    533 impl fmt::Display for RecoveryFailure {
    534     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    535         formatter.write_str(match self.kind {
    536             RecoveryFailureKind::Intent => "restore recovery intent does not match",
    537             RecoveryFailureKind::Topology => "restore recovery topology is ambiguous",
    538             RecoveryFailureKind::Artifact => "restore recovery artifact is invalid",
    539             RecoveryFailureKind::Hash => "restore recovery artifact hash failed",
    540             RecoveryFailureKind::InstallReplacement => {
    541                 "restore recovery replacement installation failed"
    542             }
    543             RecoveryFailureKind::DirectorySync => "restore recovery directory durability failed",
    544             RecoveryFailureKind::Cleanup => "restore recovery cleanup failed",
    545         })
    546     }
    547 }
    548 
    549 #[cfg(any(target_os = "linux", target_os = "macos"))]
    550 impl Error for RecoveryFailure {
    551     fn source(&self) -> Option<&(dyn Error + 'static)> {
    552         self.source
    553             .as_deref()
    554             .map(|source| source as &(dyn Error + 'static))
    555     }
    556 }
    557 
    558 #[cfg(any(target_os = "linux", target_os = "macos"))]
    559 fn recovery_error(kind: RecoveryFailureKind) -> ServiceSqliteError {
    560     ServiceSqliteError::with_source(
    561         ServiceSqliteErrorKind::Recovery,
    562         RecoveryFailure { kind, source: None },
    563     )
    564 }
    565 
    566 #[cfg(any(target_os = "linux", target_os = "macos"))]
    567 fn recovery_source(
    568     kind: RecoveryFailureKind,
    569     source: impl Error + Send + Sync + 'static,
    570 ) -> ServiceSqliteError {
    571     ServiceSqliteError::with_source(
    572         ServiceSqliteErrorKind::Recovery,
    573         RecoveryFailure {
    574             kind,
    575             source: Some(Box::new(source)),
    576         },
    577     )
    578 }
    579 
    580 #[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
    581 mod tests {
    582     use std::{
    583         fs::{self, File, OpenOptions},
    584         io::Write,
    585         num::NonZeroU32,
    586         os::unix::fs::{FileTypeExt, OpenOptionsExt, PermissionsExt},
    587         path::{Path, PathBuf},
    588         process::Command,
    589     };
    590 
    591     use radroots_runtime_paths::{
    592         InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver,
    593         RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId,
    594     };
    595     use radroots_storage::event::SourceGeneration;
    596 
    597     use super::*;
    598     use crate::restore::RestoreRecoveryMarker;
    599     use crate::{
    600         BackupManifestSha256, OpenMode, ServiceDatabaseMetadata, ServiceSqliteApplicationId,
    601     };
    602 
    603     const OLD_BYTES: &[u8] = b"old-live-state";
    604     const NEW_BYTES: &[u8] = b"new-restored-state";
    605 
    606     #[test]
    607     fn recovery_failure_inventory_is_complete_and_source_aware() {
    608         let cases = [
    609             (
    610                 RecoveryFailureKind::Intent,
    611                 "restore recovery intent does not match",
    612             ),
    613             (
    614                 RecoveryFailureKind::Topology,
    615                 "restore recovery topology is ambiguous",
    616             ),
    617             (
    618                 RecoveryFailureKind::Artifact,
    619                 "restore recovery artifact is invalid",
    620             ),
    621             (
    622                 RecoveryFailureKind::Hash,
    623                 "restore recovery artifact hash failed",
    624             ),
    625             (
    626                 RecoveryFailureKind::InstallReplacement,
    627                 "restore recovery replacement installation failed",
    628             ),
    629             (
    630                 RecoveryFailureKind::DirectorySync,
    631                 "restore recovery directory durability failed",
    632             ),
    633             (
    634                 RecoveryFailureKind::Cleanup,
    635                 "restore recovery cleanup failed",
    636             ),
    637         ];
    638         for (kind, message) in cases {
    639             let plain = RecoveryFailure { kind, source: None };
    640             assert_eq!(plain.to_string(), message);
    641             assert!(plain.source().is_none());
    642             let sourced = RecoveryFailure {
    643                 kind,
    644                 source: Some(Box::new(std::io::Error::other("private-cause"))),
    645             };
    646             assert_eq!(sourced.to_string(), message);
    647             assert!(sourced.source().is_some());
    648             assert!(format!("{sourced:?}").contains("[redacted]"));
    649             assert!(require_recovery_condition(true, kind).is_ok());
    650             assert_eq!(
    651                 require_recovery_condition(false, kind)
    652                     .expect_err("false condition")
    653                     .kind(),
    654                 ServiceSqliteErrorKind::Recovery
    655             );
    656         }
    657     }
    658 
    659     #[test]
    660     fn observed_artifact_topology_predicates_cover_every_boolean_combination() {
    661         fn observed(live: u8, staged: bool, backup: bool) -> ObservedArtifacts {
    662             let live = match live {
    663                 0 => LiveArtifact::Absent,
    664                 1 => LiveArtifact::Original,
    665                 2 => LiveArtifact::Replacement(File::open("/dev/null").expect("replacement")),
    666                 _ => unreachable!("test topology is closed"),
    667             };
    668             let artifact =
    669                 RestoreArtifactExpectation::new(1, 2, 1, [3; 32]).expect("artifact expectation");
    670             ObservedArtifacts {
    671                 live,
    672                 staged: staged.then(|| File::open("/dev/null").expect("staged")),
    673                 backup: backup.then(|| File::open("/dev/null").expect("backup")),
    674                 marker_live: artifact,
    675                 marker_staged: artifact,
    676             }
    677         }
    678 
    679         for live in 0..=2 {
    680             for staged in [false, true] {
    681                 for backup in [false, true] {
    682                     let observed = observed(live, staged, backup);
    683                     assert_eq!(observed.can_roll_back_prepared(), live == 1 && !backup);
    684                     assert_eq!(
    685                         observed.proves_live_retained(),
    686                         live == 0 && staged && backup
    687                     );
    688                     assert_eq!(
    689                         observed.needs_replacement_install(),
    690                         live == 0 && staged && backup
    691                     );
    692                     assert_eq!(
    693                         observed.proves_replacement_installed(),
    694                         live == 2 && !staged && backup
    695                     );
    696                     assert_eq!(
    697                         observed.proves_replacement_installed_or_cleanup(),
    698                         live == 2 && !staged
    699                     );
    700                 }
    701             }
    702         }
    703     }
    704 
    705     struct Fixture {
    706         _root: tempfile::TempDir,
    707         paths: ServiceSqlitePaths,
    708         identity: ServiceDatabaseIdentity,
    709         authority: WriterAuthority,
    710     }
    711 
    712     impl Fixture {
    713         fn new() -> Self {
    714             let root = tempfile::tempdir().expect("temporary root");
    715             let paths = service_paths(root.path());
    716             let state_directory = paths.state_database().parent().expect("state directory");
    717             fs::create_dir_all(state_directory).expect("create state directory");
    718             fs::set_permissions(state_directory, fs::Permissions::from_mode(0o700))
    719                 .expect("restrict state directory");
    720             write_new(paths.state_database(), OLD_BYTES);
    721             write_new(&artifact_path(&paths, STAGED_FILE_NAME), NEW_BYTES);
    722             let authority = WriterAuthority::acquire(&paths, OpenMode::ReadWriteExisting)
    723                 .expect("writer authority")
    724                 .expect("writable mode retains authority");
    725             let metadata = database_metadata(&paths);
    726             let marker = RestoreRecoveryMarker::prepared(
    727                 &metadata,
    728                 BackupManifestSha256::from_bytes([19; 32]),
    729                 expectation(paths.state_database()),
    730                 expectation(&artifact_path(&paths, STAGED_FILE_NAME)),
    731             )
    732             .expect("prepared marker");
    733             RestoreMarkerBinding::create(&paths, &authority, &marker)
    734                 .expect("persist prepared marker");
    735             Self {
    736                 _root: root,
    737                 paths,
    738                 identity: metadata.identity(),
    739                 authority,
    740             }
    741         }
    742 
    743         fn load_marker(&self) -> RestoreMarkerBinding {
    744             RestoreMarkerBinding::load_for_recovery(&self.paths, &self.authority)
    745                 .expect("load marker")
    746                 .expect("marker exists")
    747         }
    748 
    749         fn retain_live(&self, advance: bool) {
    750             fs::rename(
    751                 self.paths.state_database(),
    752                 artifact_path(&self.paths, BACKUP_FILE_NAME),
    753             )
    754             .expect("retain live");
    755             sync_state_directory(&self.paths);
    756             if advance {
    757                 self.load_marker()
    758                     .advance(
    759                         &self.paths,
    760                         &self.authority,
    761                         RestoreRecoveryPhase::LiveRetained,
    762                     )
    763                     .expect("advance live-retained marker");
    764             }
    765         }
    766 
    767         fn install_stage(&self, advance: bool) {
    768             fs::rename(
    769                 artifact_path(&self.paths, STAGED_FILE_NAME),
    770                 self.paths.state_database(),
    771             )
    772             .expect("install stage");
    773             sync_state_directory(&self.paths);
    774             if advance {
    775                 self.load_marker()
    776                     .advance(
    777                         &self.paths,
    778                         &self.authority,
    779                         RestoreRecoveryPhase::ReplacementInstalled,
    780                     )
    781                     .expect("advance replacement marker");
    782             }
    783         }
    784 
    785         fn write_scratch(&self, next: RestoreRecoveryPhase) {
    786             let marker = self.load_marker();
    787             let next = marker
    788                 .marker()
    789                 .transitioned_to(next)
    790                 .expect("legal next phase");
    791             write_new(
    792                 &artifact_path(&self.paths, MARKER_NEXT_FILE_NAME),
    793                 next.canonical_bytes(),
    794             );
    795             sync_state_directory(&self.paths);
    796         }
    797 
    798         fn recover(&self) -> Result<(), ServiceSqliteError> {
    799             recover_for_open(&self.paths, &self.identity, &self.authority)
    800         }
    801 
    802         fn recover_with_intent(&self) -> Result<(), ServiceSqliteError> {
    803             let intent = ExistingServiceDatabaseIntent::new(
    804                 &self.paths,
    805                 self.identity.supported_state_schema_version(),
    806                 self.identity.application_id(),
    807             );
    808             recover_for_open_with_intent(&self.paths, &intent, &self.authority)
    809         }
    810     }
    811 
    812     #[test]
    813     fn prepared_with_old_live_rolls_back_and_retries_idempotently() {
    814         let fixture = Fixture::new();
    815         fixture.recover().expect("rollback prepared restore");
    816         assert_eq!(fs::read(fixture.paths.state_database()).unwrap(), OLD_BYTES);
    817         assert_no_recovery_evidence(&fixture.paths);
    818         fixture.recover().expect("repeated recovery is a no-op");
    819     }
    820 
    821     #[test]
    822     fn interrupted_prepared_rollback_without_stage_finishes_marker_cleanup() {
    823         let fixture = Fixture::new();
    824         fs::remove_file(artifact_path(&fixture.paths, STAGED_FILE_NAME)).expect("remove stage");
    825         sync_state_directory(&fixture.paths);
    826         fixture.recover().expect("finish interrupted rollback");
    827         assert_eq!(fs::read(fixture.paths.state_database()).unwrap(), OLD_BYTES);
    828         assert_no_recovery_evidence(&fixture.paths);
    829     }
    830 
    831     #[test]
    832     fn prepared_with_proven_first_rename_rolls_forward() {
    833         let fixture = Fixture::new();
    834         fixture.retain_live(false);
    835         fixture.recover().expect("recover after first rename");
    836         assert_eq!(fs::read(fixture.paths.state_database()).unwrap(), NEW_BYTES);
    837         assert_no_recovery_evidence(&fixture.paths);
    838     }
    839 
    840     #[test]
    841     fn generation_discovering_intent_recovers_when_live_database_is_retained() {
    842         let fixture = Fixture::new();
    843         fixture.retain_live(false);
    844         fixture
    845             .recover_with_intent()
    846             .expect("recover from marker-bound existing intent");
    847         assert_eq!(fs::read(fixture.paths.state_database()).unwrap(), NEW_BYTES);
    848         assert_no_recovery_evidence(&fixture.paths);
    849     }
    850 
    851     #[test]
    852     fn live_retained_with_proven_second_rename_rolls_forward() {
    853         let fixture = Fixture::new();
    854         fixture.retain_live(true);
    855         fixture.install_stage(false);
    856         fixture.recover().expect("recover after second rename");
    857         assert_eq!(fs::read(fixture.paths.state_database()).unwrap(), NEW_BYTES);
    858         assert_no_recovery_evidence(&fixture.paths);
    859     }
    860 
    861     #[test]
    862     fn replacement_installed_retires_backup_and_marker() {
    863         let fixture = Fixture::new();
    864         fixture.retain_live(true);
    865         fixture.install_stage(true);
    866         fixture.recover().expect("retire completed recovery");
    867         assert_eq!(fs::read(fixture.paths.state_database()).unwrap(), NEW_BYTES);
    868         assert_no_recovery_evidence(&fixture.paths);
    869     }
    870 
    871     #[test]
    872     fn replacement_cleanup_without_backup_finishes_idempotently() {
    873         let fixture = Fixture::new();
    874         fixture.retain_live(true);
    875         fixture.install_stage(true);
    876         fs::remove_file(artifact_path(&fixture.paths, BACKUP_FILE_NAME)).expect("remove backup");
    877         sync_state_directory(&fixture.paths);
    878         fixture.recover().expect("finish interrupted cleanup");
    879         assert_eq!(fs::read(fixture.paths.state_database()).unwrap(), NEW_BYTES);
    880         assert_no_recovery_evidence(&fixture.paths);
    881         fixture.recover().expect("repeated cleanup is a no-op");
    882     }
    883 
    884     #[test]
    885     fn topology_consistent_marker_scratch_is_promoted_at_both_edges() {
    886         let first = Fixture::new();
    887         first.retain_live(false);
    888         first.write_scratch(RestoreRecoveryPhase::LiveRetained);
    889         first.recover().expect("promote first scratch");
    890         assert_eq!(fs::read(first.paths.state_database()).unwrap(), NEW_BYTES);
    891         assert_no_recovery_evidence(&first.paths);
    892 
    893         let second = Fixture::new();
    894         second.retain_live(true);
    895         second.install_stage(false);
    896         second.write_scratch(RestoreRecoveryPhase::ReplacementInstalled);
    897         second.recover().expect("promote second scratch");
    898         assert_eq!(fs::read(second.paths.state_database()).unwrap(), NEW_BYTES);
    899         assert_no_recovery_evidence(&second.paths);
    900     }
    901 
    902     #[test]
    903     fn inferred_advance_failures_are_recovery_and_authority_keeps_precedence() {
    904         let recovery = Fixture::new();
    905         let error = recovery
    906             .load_marker()
    907             .test_advance_for_recovery_with_failure(
    908                 &recovery.paths,
    909                 &recovery.authority,
    910                 RestoreRecoveryPhase::LiveRetained,
    911                 crate::restore::marker::TestStoreFailure::ScratchSync,
    912             )
    913             .expect_err("recovery marker sync failure must be classified");
    914         assert_eq!(error.kind(), ServiceSqliteErrorKind::Recovery);
    915         assert!(artifact_path(&recovery.paths, MARKER_FILE_NAME).exists());
    916         assert!(!artifact_path(&recovery.paths, MARKER_NEXT_FILE_NAME).exists());
    917 
    918         let authority = Fixture::new();
    919         let state_directory = authority.paths.state_database().parent().unwrap();
    920         let error = authority
    921             .load_marker()
    922             .test_advance_for_recovery_with_failure(
    923                 &authority.paths,
    924                 &authority.authority,
    925                 RestoreRecoveryPhase::LiveRetained,
    926                 crate::restore::marker::TestStoreFailure::AuthorityDriftAndScratchSync,
    927             )
    928             .expect_err("authority drift must dominate the marker failure");
    929         assert_eq!(error.kind(), ServiceSqliteErrorKind::Authority);
    930         assert!(artifact_path(&authority.paths, MARKER_FILE_NAME).exists());
    931         fs::set_permissions(state_directory, fs::Permissions::from_mode(0o700))
    932             .expect("restore state directory mode");
    933     }
    934 
    935     #[test]
    936     fn replaced_interrupted_scratch_never_overwrites_the_valid_marker() {
    937         let fixture = Fixture::new();
    938         fixture.retain_live(false);
    939         fixture.write_scratch(RestoreRecoveryPhase::LiveRetained);
    940         let marker = fixture.load_marker();
    941         let scratch = artifact_path(&fixture.paths, MARKER_NEXT_FILE_NAME);
    942         let retained = scratch.with_file_name("retained-valid-marker-next");
    943         let replacement_bytes = b"foreign-marker-next";
    944         let scratch_for_hook = scratch.clone();
    945         let error = marker
    946             .test_promote_interrupted_transition_after_hook(
    947                 &fixture.paths,
    948                 &fixture.authority,
    949                 RestoreRecoveryPhase::LiveRetained,
    950                 move || {
    951                     fs::rename(&scratch_for_hook, &retained).expect("retain exact scratch");
    952                     write_new(&scratch_for_hook, replacement_bytes);
    953                 },
    954             )
    955             .expect_err("replaced scratch must fail without marker replacement");
    956         assert_eq!(error.kind(), ServiceSqliteErrorKind::Recovery);
    957         assert_eq!(fs::read(&scratch).unwrap(), replacement_bytes);
    958         assert!(
    959             scratch
    960                 .with_file_name("retained-valid-marker-next")
    961                 .exists()
    962         );
    963         let current = fixture.load_marker();
    964         assert_eq!(current.marker().phase(), RestoreRecoveryPhase::Prepared);
    965     }
    966 
    967     #[test]
    968     fn topology_inconsistent_scratch_and_tampered_artifacts_fail_without_cleanup() {
    969         let scratch = Fixture::new();
    970         scratch.write_scratch(RestoreRecoveryPhase::LiveRetained);
    971         let error = scratch
    972             .recover()
    973             .expect_err("scratch before the first rename is ambiguous");
    974         assert_eq!(error.kind(), ServiceSqliteErrorKind::Recovery);
    975         assert!(artifact_path(&scratch.paths, MARKER_FILE_NAME).exists());
    976         assert!(artifact_path(&scratch.paths, MARKER_NEXT_FILE_NAME).exists());
    977         assert!(artifact_path(&scratch.paths, STAGED_FILE_NAME).exists());
    978 
    979         let tampered = Fixture::new();
    980         fs::write(
    981             artifact_path(&tampered.paths, STAGED_FILE_NAME),
    982             b"tampered-restored",
    983         )
    984         .expect("tamper stage");
    985         let before = fs::read_dir(
    986             tampered
    987                 .paths
    988                 .state_database()
    989                 .parent()
    990                 .expect("state directory"),
    991         )
    992         .unwrap()
    993         .count();
    994         let error = tampered
    995             .recover()
    996             .expect_err("tampered stage must fail closed");
    997         assert_eq!(error.kind(), ServiceSqliteErrorKind::Recovery);
    998         assert_eq!(
    999             fs::read_dir(
   1000                 tampered
   1001                     .paths
   1002                     .state_database()
   1003                     .parent()
   1004                     .expect("state directory")
   1005             )
   1006             .unwrap()
   1007             .count(),
   1008             before
   1009         );
   1010         assert!(artifact_path(&tampered.paths, MARKER_FILE_NAME).exists());
   1011     }
   1012 
   1013     #[test]
   1014     fn sidecar_mode_and_identity_mismatch_preserve_recovery_evidence() {
   1015         let sidecar = Fixture::new();
   1016         write_new(&artifact_path(&sidecar.paths, "state.sqlite-wal"), b"wal");
   1017         let error = sidecar
   1018             .recover()
   1019             .expect_err("restore recovery rejects sidecars");
   1020         assert_eq!(error.kind(), ServiceSqliteErrorKind::Recovery);
   1021         assert!(artifact_path(&sidecar.paths, MARKER_FILE_NAME).exists());
   1022 
   1023         let mode = Fixture::new();
   1024         fs::set_permissions(
   1025             artifact_path(&mode.paths, STAGED_FILE_NAME),
   1026             fs::Permissions::from_mode(0o640),
   1027         )
   1028         .expect("change stage mode");
   1029         let error = mode
   1030             .recover()
   1031             .expect_err("insecure artifact mode fails closed");
   1032         assert_eq!(error.kind(), ServiceSqliteErrorKind::Recovery);
   1033         assert!(artifact_path(&mode.paths, MARKER_FILE_NAME).exists());
   1034 
   1035         let mismatch = Fixture::new();
   1036         let wrong = ServiceDatabaseIdentity::new(
   1037             &mismatch.paths,
   1038             SourceGeneration::new([99; 32]).expect("different generation"),
   1039             NonZeroU32::new(1).expect("schema version"),
   1040             mismatch.identity.application_id(),
   1041         );
   1042         let error = recover_for_open(&mismatch.paths, &wrong, &mismatch.authority)
   1043             .expect_err("marker intent mismatch fails closed");
   1044         assert_eq!(error.kind(), ServiceSqliteErrorKind::Recovery);
   1045         assert!(artifact_path(&mismatch.paths, MARKER_FILE_NAME).exists());
   1046     }
   1047 
   1048     #[test]
   1049     fn symlink_hardlink_fifo_and_foreign_replacement_fail_without_deletion() {
   1050         use std::os::unix::fs::symlink;
   1051 
   1052         let symlinked = Fixture::new();
   1053         let staged = artifact_path(&symlinked.paths, STAGED_FILE_NAME);
   1054         let held = staged.with_file_name("held-stage");
   1055         fs::rename(&staged, &held).expect("retain original stage");
   1056         symlink(symlinked.paths.state_database(), &staged).expect("replace with symlink");
   1057         let error = symlinked
   1058             .recover()
   1059             .expect_err("symlink replacement fails closed");
   1060         assert_eq!(error.kind(), ServiceSqliteErrorKind::Recovery);
   1061         assert!(
   1062             fs::symlink_metadata(&staged)
   1063                 .unwrap()
   1064                 .file_type()
   1065                 .is_symlink()
   1066         );
   1067         assert!(held.exists());
   1068 
   1069         let hardlinked = Fixture::new();
   1070         let staged = artifact_path(&hardlinked.paths, STAGED_FILE_NAME);
   1071         let link = staged.with_file_name("stage-hardlink");
   1072         fs::hard_link(&staged, &link).expect("create hard link");
   1073         let error = hardlinked
   1074             .recover()
   1075             .expect_err("multiple-link stage fails closed");
   1076         assert_eq!(error.kind(), ServiceSqliteErrorKind::Recovery);
   1077         assert!(staged.exists());
   1078         assert!(link.exists());
   1079 
   1080         let fifo = Fixture::new();
   1081         let staged = artifact_path(&fifo.paths, STAGED_FILE_NAME);
   1082         fs::remove_file(&staged).expect("remove original stage");
   1083         assert!(
   1084             Command::new("mkfifo")
   1085                 .arg(&staged)
   1086                 .status()
   1087                 .expect("run mkfifo")
   1088                 .success()
   1089         );
   1090         fs::set_permissions(&staged, fs::Permissions::from_mode(0o600)).expect("restrict FIFO");
   1091         let error = fifo
   1092             .recover()
   1093             .expect_err("nonblocking FIFO admission fails closed");
   1094         assert_eq!(error.kind(), ServiceSqliteErrorKind::Recovery);
   1095         assert!(fs::symlink_metadata(&staged).unwrap().file_type().is_fifo());
   1096 
   1097         let replaced = Fixture::new();
   1098         let staged = artifact_path(&replaced.paths, STAGED_FILE_NAME);
   1099         let original = staged.with_file_name("original-stage");
   1100         fs::rename(&staged, &original).expect("retain original stage");
   1101         write_new(&staged, b"foreign-stage");
   1102         let foreign = fs::read(&staged).unwrap();
   1103         let error = replaced
   1104             .recover()
   1105             .expect_err("foreign replacement fails closed");
   1106         assert_eq!(error.kind(), ServiceSqliteErrorKind::Recovery);
   1107         assert_eq!(fs::read(&staged).unwrap(), foreign);
   1108         assert_eq!(fs::read(&original).unwrap(), NEW_BYTES);
   1109         assert!(artifact_path(&replaced.paths, MARKER_FILE_NAME).exists());
   1110     }
   1111 
   1112     #[test]
   1113     fn authority_drift_precedes_recovery_and_keeps_evidence() {
   1114         let fixture = Fixture::new();
   1115         let state_directory = fixture.paths.state_database().parent().unwrap();
   1116         fs::set_permissions(state_directory, fs::Permissions::from_mode(0o770))
   1117             .expect("drift state directory mode");
   1118         let error = fixture
   1119             .recover()
   1120             .expect_err("authority drift must stop recovery");
   1121         assert_eq!(error.kind(), ServiceSqliteErrorKind::Authority);
   1122         assert!(artifact_path(&fixture.paths, MARKER_FILE_NAME).exists());
   1123         assert!(artifact_path(&fixture.paths, STAGED_FILE_NAME).exists());
   1124         fs::set_permissions(state_directory, fs::Permissions::from_mode(0o700))
   1125             .expect("restore state directory mode");
   1126     }
   1127 
   1128     #[test]
   1129     fn orphan_stage_backup_or_scratch_without_marker_is_never_repaired() {
   1130         for name in [STAGED_FILE_NAME, BACKUP_FILE_NAME, MARKER_NEXT_FILE_NAME] {
   1131             let fixture = Fixture::new();
   1132             fs::remove_file(artifact_path(&fixture.paths, MARKER_FILE_NAME))
   1133                 .expect("remove marker");
   1134             if name != STAGED_FILE_NAME {
   1135                 fs::remove_file(artifact_path(&fixture.paths, STAGED_FILE_NAME))
   1136                     .expect("remove default stage");
   1137                 write_new(&artifact_path(&fixture.paths, name), b"orphan");
   1138             }
   1139             sync_state_directory(&fixture.paths);
   1140             let error = fixture
   1141                 .recover()
   1142                 .expect_err("orphan recovery evidence must fail closed");
   1143             assert_eq!(error.kind(), ServiceSqliteErrorKind::Recovery);
   1144             assert!(artifact_path(&fixture.paths, name).exists());
   1145         }
   1146     }
   1147 
   1148     fn service_paths(root: &Path) -> ServiceSqlitePaths {
   1149         let context = RuntimeContext::resolve(
   1150             &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
   1151             RuntimeContextBootstrap::new(
   1152                 RadrootsPathProfile::RepoLocal,
   1153                 Some(root.to_path_buf()),
   1154                 RuntimeContextSource::BootstrapCli,
   1155                 RuntimeContextSource::BootstrapCli,
   1156             )
   1157             .expect("bootstrap"),
   1158             ServiceId::new("myc").expect("service"),
   1159             InstanceId::new("recovery").expect("instance"),
   1160         )
   1161         .expect("runtime context");
   1162         ServiceSqlitePaths::from_runtime_context(&context).expect("SQLite paths")
   1163     }
   1164 
   1165     fn database_metadata(paths: &ServiceSqlitePaths) -> ServiceDatabaseMetadata {
   1166         ServiceDatabaseMetadata::new(
   1167             paths,
   1168             SourceGeneration::new([7; 32]).expect("source generation"),
   1169             NonZeroU32::new(1).expect("schema version"),
   1170             1_700_000_000_000,
   1171             ServiceSqliteApplicationId::new(0x5244_5351).expect("application ID"),
   1172         )
   1173         .expect("metadata")
   1174     }
   1175 
   1176     fn write_new(path: &Path, bytes: &[u8]) -> File {
   1177         let mut file = OpenOptions::new()
   1178             .read(true)
   1179             .write(true)
   1180             .create_new(true)
   1181             .mode(0o600)
   1182             .open(path)
   1183             .expect("create artifact");
   1184         file.set_permissions(fs::Permissions::from_mode(0o600))
   1185             .expect("set artifact mode");
   1186         file.write_all(bytes).expect("write artifact");
   1187         file.sync_all().expect("sync artifact");
   1188         file
   1189     }
   1190 
   1191     fn expectation(path: &Path) -> RestoreArtifactExpectation {
   1192         let file = File::open(path).expect("open artifact");
   1193         let status = fstat(&file).expect("artifact status");
   1194         RestoreArtifactExpectation::new(
   1195             crate::native_metadata::device(status.st_dev).expect("device"),
   1196             status.st_ino,
   1197             u64::try_from(status.st_size).expect("length"),
   1198             hash_exact(
   1199                 &file,
   1200                 u64::try_from(status.st_size).expect("positive artifact length"),
   1201             )
   1202             .expect("artifact digest"),
   1203         )
   1204         .expect("artifact expectation")
   1205     }
   1206 
   1207     fn artifact_path(paths: &ServiceSqlitePaths, name: &str) -> PathBuf {
   1208         paths.state_database().with_file_name(name)
   1209     }
   1210 
   1211     fn sync_state_directory(paths: &ServiceSqlitePaths) {
   1212         File::open(paths.state_database().parent().expect("state directory"))
   1213             .expect("open state directory")
   1214             .sync_all()
   1215             .expect("sync state directory");
   1216     }
   1217 
   1218     fn assert_no_recovery_evidence(paths: &ServiceSqlitePaths) {
   1219         for name in [
   1220             STAGED_FILE_NAME,
   1221             BACKUP_FILE_NAME,
   1222             MARKER_FILE_NAME,
   1223             MARKER_NEXT_FILE_NAME,
   1224         ] {
   1225             assert!(!artifact_path(paths, name).exists(), "unexpected {name}");
   1226         }
   1227     }
   1228 }