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(¤t, 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 }