1use std::collections::BTreeSet;
54use std::fmt;
55use std::fs;
56use std::io::{self, Read};
57use std::panic::{catch_unwind, AssertUnwindSafe};
58use std::path::{Component, Path, PathBuf};
59
60use rightkit_fs::atomic::try_sync_dir;
61use rightkit_fs::{
62 recover_transactions, FileKey, FileTransaction, FsError, TransactionEntry, TransactionOptions,
63 TransactionReport,
64};
65use serde_json::{json, Value};
66
67use crate::app_root::{
68 normalize_roots, roots_overlap, same_content, write_json_atomic, AppRoot, AppRootError,
69 MigrateOptions, MigrationReport,
70};
71use crate::discovery::try_lock_exclusive;
72
73pub const CUTOVER_RECORD: &str = ".rightkit-root-cutover.json";
75const CUTOVER_LOCK: &str = ".rightkit-cutover.lock";
77const TXN_AREA: &str = ".rktxn";
79const RECORD_VERSION: u64 = 1;
80const SELECTED_RECORD_VERSION: u64 = 2;
81
82#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ParticipantError {
89 message: String,
90}
91
92impl ParticipantError {
93 pub fn new(message: impl Into<String>) -> Self {
94 Self {
95 message: message.into(),
96 }
97 }
98
99 pub fn from_error(error: &dyn std::error::Error) -> Self {
101 Self::new(error.to_string())
102 }
103
104 pub fn message(&self) -> &str {
105 &self.message
106 }
107}
108
109impl fmt::Display for ParticipantError {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 f.write_str(&self.message)
112 }
113}
114
115impl std::error::Error for ParticipantError {}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119#[non_exhaustive]
120pub enum CutoverStep {
121 Prepare,
122 Quiesce,
123 Relocate,
124 Reopen,
125 Settle,
126}
127
128impl CutoverStep {
129 pub fn as_str(self) -> &'static str {
130 match self {
131 CutoverStep::Prepare => "prepare",
132 CutoverStep::Quiesce => "quiesce",
133 CutoverStep::Relocate => "relocate",
134 CutoverStep::Reopen => "reopen",
135 CutoverStep::Settle => "settle",
136 }
137 }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142#[non_exhaustive]
143pub enum Settlement {
144 Committed,
146 Aborted,
148}
149
150#[derive(Debug, Clone)]
152pub struct CutoverContext {
153 id: String,
154 generation: u64,
155 old_root: PathBuf,
156 new_root: PathBuf,
157 resumed: bool,
158}
159
160impl CutoverContext {
161 pub fn id(&self) -> &str {
163 &self.id
164 }
165
166 pub fn generation(&self) -> u64 {
169 self.generation
170 }
171
172 pub fn old_root(&self) -> &Path {
174 &self.old_root
175 }
176
177 pub fn new_root(&self) -> &Path {
179 &self.new_root
180 }
181
182 pub fn resumed(&self) -> bool {
186 self.resumed
187 }
188
189 pub fn authoritative(&self, settlement: Settlement) -> &Path {
191 match settlement {
192 Settlement::Committed => &self.new_root,
193 Settlement::Aborted => &self.old_root,
194 }
195 }
196}
197
198pub trait CutoverParticipant: Send {
234 fn name(&self) -> &str;
236
237 fn owned_paths(&self) -> Vec<PathBuf> {
240 Vec::new()
241 }
242
243 fn prepare(&mut self, _cx: &CutoverContext) -> Result<(), ParticipantError> {
244 Ok(())
245 }
246
247 fn quiesce(&mut self, _cx: &CutoverContext) -> Result<(), ParticipantError> {
248 Ok(())
249 }
250
251 fn relocate(&mut self, _cx: &CutoverContext) -> Result<(), ParticipantError> {
252 Ok(())
253 }
254
255 fn reopen(&mut self, _cx: &CutoverContext) -> Result<(), ParticipantError> {
256 Ok(())
257 }
258
259 fn settle(
260 &mut self,
261 _cx: &CutoverContext,
262 _settlement: Settlement,
263 ) -> Result<(), ParticipantError> {
264 Ok(())
265 }
266
267 fn recover_completed(&mut self, _cx: &CutoverContext) -> Result<(), ParticipantError> {
272 Ok(())
273 }
274}
275
276#[derive(Debug, Clone, Default)]
282#[non_exhaustive]
283pub enum FsMove {
284 #[default]
286 None,
287 Tree(MigrateOptions),
290 Companions(Vec<PathBuf>),
295}
296
297impl FsMove {
298 fn label(&self) -> &'static str {
299 match self {
300 FsMove::None => "none",
301 FsMove::Tree(_) => "tree",
302 FsMove::Companions(_) => "companions",
303 }
304 }
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309#[non_exhaustive]
310pub enum CutoverPhase {
311 Copying,
313 Switched,
316 Completed,
318 Aborted,
320}
321
322impl CutoverPhase {
323 pub fn as_str(self) -> &'static str {
324 match self {
325 CutoverPhase::Copying => "copying",
326 CutoverPhase::Switched => "switched",
327 CutoverPhase::Completed => "completed",
328 CutoverPhase::Aborted => "aborted",
329 }
330 }
331
332 fn parse(text: &str) -> Option<Self> {
333 Some(match text {
334 "copying" => CutoverPhase::Copying,
335 "switched" => CutoverPhase::Switched,
336 "completed" => CutoverPhase::Completed,
337 "aborted" => CutoverPhase::Aborted,
338 _ => return None,
339 })
340 }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum RootSelection {
346 Old,
347 New,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq)]
352#[non_exhaustive]
353pub struct RootStatus {
354 pub id: String,
355 pub generation: u64,
356 pub phase: CutoverPhase,
357 pub selected: RootSelection,
358 pub old_root: PathBuf,
359 pub new_root: PathBuf,
360 pub participants: Vec<String>,
361 pub error: Option<String>,
363 pub retired: Option<PathBuf>,
365 pub settlement_complete: bool,
368}
369
370impl RootStatus {
371 pub fn selected_root(&self) -> &Path {
373 match self.selected {
374 RootSelection::Old => &self.old_root,
375 RootSelection::New => &self.new_root,
376 }
377 }
378
379 pub fn needs_recovery(&self) -> bool {
382 self.phase == CutoverPhase::Switched
383 || (self.phase == CutoverPhase::Completed && !self.settlement_complete)
384 }
385}
386
387#[derive(Debug, Clone)]
388struct Record {
389 id: String,
390 generation: u64,
391 phase: CutoverPhase,
392 old_root: PathBuf,
393 new_root: PathBuf,
394 old_key: FileKey,
395 new_key: FileKey,
396 participants: Vec<String>,
397 fs_move: String,
398 error: Option<String>,
399 retired: Option<PathBuf>,
400 settlement_complete: bool,
401 tree_selection: TreeSelection,
402}
403
404#[derive(Clone, Debug, PartialEq, Eq)]
405enum TreeSelection {
406 All,
407 Pending,
408 Roots(Vec<PathBuf>),
409}
410
411impl TreeSelection {
412 fn json(&self) -> Value {
413 match self {
414 Self::All => json!({"mode": "all"}),
415 Self::Pending => json!({"mode": "pending"}),
416 Self::Roots(roots) => json!({"mode": "roots", "roots": roots}),
417 }
418 }
419 fn parse(value: Option<&Value>) -> Result<Self, CutoverError> {
420 let Some(value) = value else {
421 return Ok(Self::All);
422 };
423 let bad = || CutoverError::RecordUnreadable("invalid tree selection".into());
424 match value.get("mode").and_then(Value::as_str) {
425 Some("all") => Ok(Self::All),
426 Some("pending") => Ok(Self::Pending),
427 Some("roots") => {
428 let roots: Vec<PathBuf> =
429 serde_json::from_value(value.get("roots").cloned().ok_or_else(bad)?)
430 .map_err(|_| bad())?;
431 if normalize_roots(roots.clone()).map_err(CutoverError::Migrate)? != roots {
432 return Err(bad());
433 }
434 Ok(Self::Roots(roots))
435 }
436 _ => Err(bad()),
437 }
438 }
439}
440
441fn key_json(key: FileKey) -> Value {
442 json!({ "volume": key.volume, "index": key.index })
443}
444
445fn key_from(value: &Value) -> Option<FileKey> {
446 Some(FileKey {
447 volume: value.get("volume")?.as_u64()?,
448 index: value.get("index")?.as_u64()?,
449 })
450}
451
452impl Record {
453 fn to_json(&self) -> Value {
454 json!({
455 "version": if matches!(self.tree_selection, TreeSelection::All) { RECORD_VERSION } else { SELECTED_RECORD_VERSION },
456 "id": self.id,
457 "generation": self.generation,
458 "phase": self.phase.as_str(),
459 "old_root": self.old_root.to_str(),
460 "new_root": self.new_root.to_str(),
461 "old_key": key_json(self.old_key),
462 "new_key": key_json(self.new_key),
463 "participants": self.participants,
464 "fs_move": self.fs_move,
465 "error": self.error,
466 "retired": self.retired.as_ref().and_then(|p| p.to_str()),
467 "settlement_complete": self.settlement_complete,
468 "tree_selection": self.tree_selection.json(),
469 })
470 }
471
472 fn parse(text: &str) -> Result<Self, CutoverError> {
473 let bad = |why: &str| CutoverError::RecordUnreadable(why.to_string());
474 let v: Value = serde_json::from_str(text).map_err(|e| bad(&e.to_string()))?;
475 let version = v.get("version").and_then(Value::as_u64);
476 match version {
477 Some(RECORD_VERSION) => {}
478 Some(SELECTED_RECORD_VERSION) => {}
479 Some(n) if n > SELECTED_RECORD_VERSION => {
480 return Err(bad("the record was written by a newer version"))
481 }
482 _ => return Err(bad("missing or unknown record version")),
483 }
484 let text_of = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
485 let record = Record {
486 id: text_of("id").ok_or_else(|| bad("no id"))?,
487 generation: v
488 .get("generation")
489 .and_then(Value::as_u64)
490 .ok_or_else(|| bad("no generation"))?,
491 phase: text_of("phase")
492 .and_then(|p| CutoverPhase::parse(&p))
493 .ok_or_else(|| bad("unknown phase"))?,
494 old_root: text_of("old_root")
495 .map(PathBuf::from)
496 .ok_or_else(|| bad("no old_root"))?,
497 new_root: text_of("new_root")
498 .map(PathBuf::from)
499 .ok_or_else(|| bad("no new_root"))?,
500 old_key: v
501 .get("old_key")
502 .and_then(key_from)
503 .ok_or_else(|| bad("no old_key"))?,
504 new_key: v
505 .get("new_key")
506 .and_then(key_from)
507 .ok_or_else(|| bad("no new_key"))?,
508 participants: v
509 .get("participants")
510 .and_then(Value::as_array)
511 .and_then(|a| {
512 a.iter()
513 .map(|n| n.as_str().map(str::to_string))
514 .collect::<Option<Vec<_>>>()
515 })
516 .ok_or_else(|| bad("invalid participant list"))?,
517 fs_move: text_of("fs_move").ok_or_else(|| bad("no fs_move"))?,
518 error: text_of("error"),
519 retired: text_of("retired").map(PathBuf::from),
520 settlement_complete: match v.get("settlement_complete") {
521 None => false,
522 Some(value) => value
523 .as_bool()
524 .ok_or_else(|| bad("invalid settlement_complete"))?,
525 },
526 tree_selection: TreeSelection::parse(v.get("tree_selection"))?,
527 };
528 if version == Some(SELECTED_RECORD_VERSION)
529 && matches!(record.tree_selection, TreeSelection::All)
530 {
531 return Err(bad("selected record requires explicit scope"));
532 }
533 if version == Some(RECORD_VERSION) && !matches!(record.tree_selection, TreeSelection::All) {
534 return Err(bad("legacy record cannot carry selected scope"));
535 }
536 if let TreeSelection::Roots(roots) = &record.tree_selection {
537 validate_cutover_roots(roots)?;
538 }
539 let mut names = BTreeSet::new();
540 if record.id.len() != 16
541 || !record.id.bytes().all(|b| b.is_ascii_hexdigit())
542 || record.generation == 0
543 || !matches!(record.fs_move.as_str(), "none" | "tree" | "companions")
544 || record
545 .participants
546 .iter()
547 .any(|name| !valid_participant_name(name) || !names.insert(name))
548 {
549 return Err(bad("invalid cutover identity, generation or participants"));
550 }
551 if (record.fs_move != "tree" && !matches!(record.tree_selection, TreeSelection::All))
552 || (matches!(
553 record.phase,
554 CutoverPhase::Switched | CutoverPhase::Completed
555 ) && matches!(record.tree_selection, TreeSelection::Pending))
556 {
557 return Err(bad("tree selection inconsistent with phase or file move"));
558 }
559 for root in [&record.old_root, &record.new_root] {
560 if !root.is_absolute() || root.components().any(|c| c == Component::ParentDir) {
561 return Err(bad("invalid root path"));
562 }
563 }
564 if record.old_root.starts_with(&record.new_root)
565 || record.new_root.starts_with(&record.old_root)
566 {
567 return Err(bad("overlapping roots"));
568 }
569 Ok(record)
570 }
571
572 fn status(&self) -> RootStatus {
573 RootStatus {
574 id: self.id.clone(),
575 generation: self.generation,
576 phase: self.phase,
577 selected: match self.phase {
578 CutoverPhase::Switched | CutoverPhase::Completed => RootSelection::New,
579 CutoverPhase::Copying | CutoverPhase::Aborted => RootSelection::Old,
580 },
581 old_root: self.old_root.clone(),
582 new_root: self.new_root.clone(),
583 participants: self.participants.clone(),
584 error: self.error.clone(),
585 retired: self.retired.clone(),
586 settlement_complete: self.settlement_complete,
587 }
588 }
589}
590
591fn read_record(new_root: &Path) -> Result<Option<Record>, CutoverError> {
592 let path = new_root.join(CUTOVER_RECORD);
593 match fs::symlink_metadata(&path) {
594 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
595 Err(e) => return Err(e.into()),
596 Ok(_) => {}
597 }
598 let mut file = rightkit_fs::open_read_nofollow(&path, &rightkit_fs::ReadOptions::new())?;
599 let mut text = String::new();
600 file.read_to_string(&mut text)?;
601 let record = Record::parse(&text)?;
602 let actual = fs::canonicalize(new_root)?;
603 if record.new_root != actual {
604 return Err(CutoverError::RecordMismatch(
605 "record names another destination".into(),
606 ));
607 }
608 check_directory(&actual, record.new_key)?;
609 Ok(Some(record))
610}
611
612fn write_record(new_root: &Path, record: &Record) -> io::Result<()> {
614 write_json_atomic(&new_root.join(CUTOVER_RECORD), &record.to_json())?;
615 try_sync_dir(new_root)
616}
617
618#[derive(Debug)]
624#[non_exhaustive]
625pub enum CutoverError {
626 InvalidRoot {
629 path: PathBuf,
630 reason: &'static str,
631 },
632 BadParticipant(String),
635 BadCompanion(PathBuf),
639 OwnedPathConflict {
641 participant: String,
642 path: PathBuf,
643 },
644 Busy(PathBuf),
646 Collision(PathBuf),
648 PathSubstituted(PathBuf),
651 RecordMismatch(String),
653 RecordUnreadable(String),
656 PendingTransaction(String),
658 Participant {
661 name: String,
662 step: CutoverStep,
663 error: ParticipantError,
664 },
665 AbortIncomplete {
668 cause: Box<CutoverError>,
669 failures: Vec<(String, ParticipantError)>,
670 },
671 ReopenPending {
674 name: String,
675 error: ParticipantError,
676 generation: u64,
677 },
678 SettlementPending {
680 generation: u64,
681 failures: Vec<(String, ParticipantError)>,
682 },
683 Migrate(AppRootError),
685 Fs(FsError),
687 FsRecoveryRequired(String),
689 Undecided(io::Error),
692 NotCompleted(CutoverPhase),
694 Io(io::Error),
695}
696
697impl fmt::Display for CutoverError {
698 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
699 match self {
700 CutoverError::InvalidRoot { path, reason } => {
701 write!(f, "root cutover: {}: {reason}", path.display())
702 }
703 CutoverError::BadParticipant(n) => {
704 write!(f, "root cutover: participant {n:?} is not usable")
705 }
706 CutoverError::BadCompanion(p) => write!(
707 f,
708 "root cutover: companion {} is not a plain relative path to a regular file",
709 p.display()
710 ),
711 CutoverError::OwnedPathConflict { participant, path } => write!(
712 f,
713 "root cutover: {} lies on a path participant {participant} owns",
714 path.display()
715 ),
716 CutoverError::Busy(p) => {
717 write!(f, "root cutover: another cutover holds {}", p.display())
718 }
719 CutoverError::Collision(p) => write!(
720 f,
721 "root cutover: {} holds different content; nothing was overwritten",
722 p.display()
723 ),
724 CutoverError::PathSubstituted(p) => write!(
725 f,
726 "root cutover: {} is no longer the directory that was recorded",
727 p.display()
728 ),
729 CutoverError::RecordMismatch(why) => write!(f, "root cutover: record mismatch: {why}"),
730 CutoverError::RecordUnreadable(why) => {
731 write!(f, "root cutover: cutover record unreadable: {why}")
732 }
733 CutoverError::PendingTransaction(why) => {
734 write!(f, "root cutover: an earlier file transaction is pending: {why}")
735 }
736 CutoverError::Participant { name, step, error } => write!(
737 f,
738 "root cutover: participant {name} failed to {}: {error}",
739 step.as_str()
740 ),
741 CutoverError::AbortIncomplete { cause, failures } => {
742 write!(f, "{cause}; {} participant(s) did not resume", failures.len())
743 }
744 CutoverError::ReopenPending {
745 name,
746 error,
747 generation,
748 } => write!(
749 f,
750 "root cutover: generation {generation} is switched but participant {name} could not reopen: {error}; run recover"
751 ),
752 CutoverError::Migrate(e) => write!(f, "root cutover: tree migration: {e}"),
753 CutoverError::SettlementPending { generation, failures } => write!(f,
754 "root cutover: generation {generation} selected new root; {} participant cleanup failures; run recover", failures.len()),
755 CutoverError::Fs(e) => write!(f, "root cutover: {e}"),
756 CutoverError::FsRecoveryRequired(why) => {
757 write!(f, "root cutover: file transaction needs recovery: {why}")
758 }
759 CutoverError::Undecided(e) => write!(
760 f,
761 "root cutover: the switch record could not be confirmed ({e}); run recover"
762 ),
763 CutoverError::NotCompleted(p) => {
764 write!(f, "root cutover: the cutover is {}, not completed", p.as_str())
765 }
766 CutoverError::Io(e) => write!(f, "root cutover: {e}"),
767 }
768 }
769}
770
771impl std::error::Error for CutoverError {}
772
773impl From<io::Error> for CutoverError {
774 fn from(e: io::Error) -> Self {
775 CutoverError::Io(e)
776 }
777}
778
779impl From<FsError> for CutoverError {
780 fn from(e: FsError) -> Self {
781 CutoverError::Fs(e)
782 }
783}
784
785#[derive(Debug)]
787#[non_exhaustive]
788pub struct CutoverReport {
789 pub id: String,
790 pub generation: u64,
791 pub selected: RootSelection,
793 pub migration: Option<MigrationReport>,
795 pub companions: Vec<PathBuf>,
798 pub resumed: bool,
800 pub already_completed: bool,
802 pub settle_warnings: Vec<(String, ParticipantError)>,
805}
806
807impl CutoverReport {
808 fn new(record: &Record) -> Self {
809 Self {
810 id: record.id.clone(),
811 generation: record.generation,
812 selected: RootSelection::New,
813 migration: None,
814 companions: Vec::new(),
815 resumed: false,
816 already_completed: false,
817 settle_warnings: Vec::new(),
818 }
819 }
820}
821
822#[derive(Debug)]
824#[non_exhaustive]
825pub enum RecoverOutcome {
826 Nothing,
829 Resumed(Box<CutoverReport>),
831 Completed,
833}
834
835#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837#[non_exhaustive]
838pub enum RetireMode {
839 RenameAside,
842 Trash,
845}
846
847#[derive(Debug, Clone, PartialEq, Eq)]
849#[non_exhaustive]
850pub struct Retired {
851 pub path: PathBuf,
853 pub already: bool,
855}
856
857struct Plan {
862 old: PathBuf,
863 new: PathBuf,
864 old_key: FileKey,
865 new_key: FileKey,
866}
867
868type TreeRootsResolver = dyn Fn(&CutoverContext) -> Result<Vec<PathBuf>, ParticipantError> + Send;
869
870pub struct RootCutover {
872 app: AppRoot,
873 old_root: PathBuf,
874 fs_move: FsMove,
875 participants: Vec<Box<dyn CutoverParticipant>>,
876 tree_roots: Option<Box<TreeRootsResolver>>,
877}
878
879fn valid_participant_name(name: &str) -> bool {
880 !name.is_empty()
881 && name.len() <= 64
882 && name
883 .chars()
884 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
885}
886
887fn plain_relative(path: &Path) -> bool {
888 path.components().count() > 0 && path.components().all(|c| matches!(c, Component::Normal(_)))
889}
890
891fn validate_cutover_roots(roots: &[PathBuf]) -> Result<(), CutoverError> {
892 for root in roots {
893 if [CUTOVER_RECORD, CUTOVER_LOCK, TXN_AREA]
894 .iter()
895 .any(|reserved| roots_overlap(root, Path::new(reserved)))
896 {
897 return Err(CutoverError::BadCompanion(root.clone()));
898 }
899 }
900 Ok(())
901}
902
903fn new_id() -> Result<String, CutoverError> {
904 let mut raw = [0u8; 8];
905 getrandom::fill(&mut raw).map_err(|e| CutoverError::Io(io::Error::other(e.to_string())))?;
906 Ok(raw.iter().map(|b| format!("{b:02x}")).collect())
907}
908
909fn take_lock(new_root: &Path) -> Result<fs::File, CutoverError> {
910 let path = new_root.join(CUTOVER_LOCK);
911 match try_lock_exclusive(&path) {
912 Ok(Some(file)) => Ok(file),
913 Ok(None) => Err(CutoverError::Busy(path)),
914 Err(e) => Err(CutoverError::Io(e)),
915 }
916}
917
918fn resolve_transactions(new_root: &Path) -> Result<(), CutoverError> {
921 let report = recover_transactions(new_root).map_err(CutoverError::Fs)?;
922 if report.is_clean() {
923 Ok(())
924 } else {
925 Err(CutoverError::PendingTransaction(format!(
926 "{} transaction(s) retained under {}",
927 report.transactions.len(),
928 new_root.join(TXN_AREA).display()
929 )))
930 }
931}
932
933fn check_directory(path: &Path, key: FileKey) -> Result<(), CutoverError> {
935 let substituted = || CutoverError::PathSubstituted(path.to_path_buf());
936 let md = fs::symlink_metadata(path).map_err(|_| substituted())?;
937 if !real_directory(&md) {
938 return Err(substituted());
939 }
940 match FileKey::of_path(path) {
941 Ok(now) if now == key => Ok(()),
942 _ => Err(substituted()),
943 }
944}
945
946fn real_directory(metadata: &fs::Metadata) -> bool {
947 #[cfg(windows)]
948 {
949 use std::os::windows::fs::MetadataExt;
950 if metadata.file_attributes() & 0x400 != 0 {
951 return false;
952 }
953 }
954 metadata.file_type().is_dir()
955}
956
957fn panic_text(payload: Box<dyn std::any::Any + Send>) -> String {
958 payload
959 .downcast_ref::<&str>()
960 .map(|s| s.to_string())
961 .or_else(|| payload.downcast_ref::<String>().cloned())
962 .unwrap_or_else(|| "panic".to_string())
963}
964
965impl RootCutover {
966 pub fn new(app: AppRoot, old_root: impl Into<PathBuf>) -> Self {
969 Self {
970 app,
971 old_root: old_root.into(),
972 fs_move: FsMove::None,
973 participants: Vec::new(),
974 tree_roots: None,
975 }
976 }
977
978 pub fn fs_move(mut self, fs_move: FsMove) -> Self {
980 self.fs_move = fs_move;
981 self
982 }
983
984 pub fn tree_roots(
987 mut self,
988 resolver: impl Fn(&CutoverContext) -> Result<Vec<PathBuf>, ParticipantError> + Send + 'static,
989 ) -> Self {
990 self.tree_roots = Some(Box::new(resolver));
991 self
992 }
993
994 pub fn participant(mut self, participant: impl CutoverParticipant + 'static) -> Self {
996 self.participants.push(Box::new(participant));
997 self
998 }
999
1000 pub fn status(app: &AppRoot) -> Result<Option<RootStatus>, CutoverError> {
1006 let Some(record) = read_record(app.root())? else {
1007 return Ok(None);
1008 };
1009 if matches!(record.phase, CutoverPhase::Copying | CutoverPhase::Aborted) {
1010 check_directory(&record.old_root, record.old_key)?;
1011 }
1012 Ok(Some(record.status()))
1013 }
1014
1015 pub fn selected_root(app: &AppRoot, legacy: &Path) -> Result<PathBuf, CutoverError> {
1018 Ok(match read_record(app.root())? {
1019 None => legacy.to_path_buf(),
1020 Some(record) => {
1021 let (path, key) = match record.phase {
1022 CutoverPhase::Switched | CutoverPhase::Completed => {
1023 (&record.new_root, record.new_key)
1024 }
1025 CutoverPhase::Copying | CutoverPhase::Aborted => {
1026 (&record.old_root, record.old_key)
1027 }
1028 };
1029 check_directory(path, key)?;
1030 path.clone()
1031 }
1032 })
1033 }
1034
1035 fn validate_resume(&self, record: &Record) -> Result<(), CutoverError> {
1036 let names: Vec<_> = self
1037 .participants
1038 .iter()
1039 .map(|p| p.name().to_string())
1040 .collect();
1041 if names != record.participants
1042 || (matches!(record.phase, CutoverPhase::Copying | CutoverPhase::Aborted)
1043 && self.fs_move.label() != record.fs_move)
1044 {
1045 return Err(CutoverError::RecordMismatch(
1046 "participant order or file-move mode changed".into(),
1047 ));
1048 }
1049 if matches!(record.phase, CutoverPhase::Copying | CutoverPhase::Aborted)
1050 && matches!(self.requested_selection()?, TreeSelection::All)
1051 != matches!(record.tree_selection, TreeSelection::All)
1052 {
1053 return Err(CutoverError::RecordMismatch(
1054 "tree selection mode changed".into(),
1055 ));
1056 }
1057 if record.retired.is_none() {
1058 let old = fs::canonicalize(&self.old_root)?;
1059 if old != record.old_root {
1060 return Err(CutoverError::RecordMismatch("old root changed".into()));
1061 }
1062 check_directory(&old, record.old_key)?;
1063 }
1064 Ok(())
1065 }
1066
1067 fn requested_selection(&self) -> Result<TreeSelection, CutoverError> {
1068 match (&self.fs_move, &self.tree_roots) {
1069 (FsMove::Tree(options), Some(_)) if options.included.is_none() => {
1070 Ok(TreeSelection::Pending)
1071 }
1072 (_, Some(_)) => Err(CutoverError::RecordMismatch(
1073 "tree_roots requires Tree without static include_roots".into(),
1074 )),
1075 (FsMove::Tree(options), None) => {
1076 if let Some(roots) = &options.included {
1077 self.validate_selected_roots(roots)?;
1078 Ok(TreeSelection::Roots(roots.clone()))
1079 } else {
1080 Ok(TreeSelection::All)
1081 }
1082 }
1083 _ => Ok(TreeSelection::All),
1084 }
1085 }
1086
1087 fn validate_selected_roots(&self, roots: &[PathBuf]) -> Result<(), CutoverError> {
1088 validate_cutover_roots(roots)?;
1089 for root in roots {
1090 for participant in &self.participants {
1091 if participant
1092 .owned_paths()
1093 .iter()
1094 .any(|owned| roots_overlap(root, owned))
1095 {
1096 return Err(CutoverError::OwnedPathConflict {
1097 participant: participant.name().into(),
1098 path: root.clone(),
1099 });
1100 }
1101 }
1102 }
1103 Ok(())
1104 }
1105
1106 fn plan_new(&self) -> Result<(PathBuf, FileKey), CutoverError> {
1109 if !real_directory(&fs::symlink_metadata(self.app.root())?) {
1110 return Err(CutoverError::InvalidRoot {
1111 path: self.app.root().to_path_buf(),
1112 reason: "the new root must be a real directory, not a link or reparse point",
1113 });
1114 }
1115 let new = fs::canonicalize(self.app.root()).map_err(CutoverError::Io)?;
1116 if new.to_str().is_none() {
1117 return Err(CutoverError::InvalidRoot {
1118 path: new,
1119 reason: "the new root path is not UTF-8",
1120 });
1121 }
1122 let key = FileKey::of_path(&new).map_err(CutoverError::Fs)?;
1123 Ok((new, key))
1124 }
1125
1126 fn plan_old(&self, new: PathBuf, new_key: FileKey) -> Result<Plan, CutoverError> {
1127 let mut seen = BTreeSet::new();
1128 for p in &self.participants {
1129 let name = p.name();
1130 if !valid_participant_name(name) || !seen.insert(name.to_string()) {
1131 return Err(CutoverError::BadParticipant(name.to_string()));
1132 }
1133 if p.owned_paths().iter().any(|o| !plain_relative(o)) {
1134 return Err(CutoverError::BadParticipant(name.to_string()));
1135 }
1136 }
1137 if let FsMove::Companions(rels) = &self.fs_move {
1138 for rel in rels {
1139 if !plain_relative(rel) {
1140 return Err(CutoverError::BadCompanion(rel.clone()));
1141 }
1142 for p in &self.participants {
1143 if p.owned_paths()
1144 .iter()
1145 .any(|o| rel.starts_with(o) || o.starts_with(rel))
1146 {
1147 return Err(CutoverError::OwnedPathConflict {
1148 participant: p.name().to_string(),
1149 path: rel.clone(),
1150 });
1151 }
1152 }
1153 }
1154 }
1155 let raw = &self.old_root;
1156 let invalid = |reason: &'static str| CutoverError::InvalidRoot {
1157 path: raw.clone(),
1158 reason,
1159 };
1160 if !raw.is_absolute() || raw.components().any(|c| c == Component::ParentDir) {
1161 return Err(invalid(
1162 "the old root must be an absolute path without '..'",
1163 ));
1164 }
1165 let md = fs::symlink_metadata(raw).map_err(|_| invalid("the old root does not exist"))?;
1166 if !real_directory(&md) {
1167 return Err(invalid("the old root must be a real directory, not a link"));
1168 }
1169 let old = fs::canonicalize(raw).map_err(CutoverError::Io)?;
1170 if old.to_str().is_none() {
1171 return Err(invalid("the old root path is not UTF-8"));
1172 }
1173 if old.starts_with(&new) || new.starts_with(&old) {
1174 return Err(invalid("the old and new roots are the same or nested"));
1175 }
1176 let old_key = FileKey::of_path(&old).map_err(CutoverError::Fs)?;
1177 Ok(Plan {
1178 old,
1179 new,
1180 old_key,
1181 new_key,
1182 })
1183 }
1184
1185 pub fn run(&mut self) -> Result<CutoverReport, CutoverError> {
1195 let (new, new_key) = self.plan_new()?;
1196 let _lock = take_lock(&new)?;
1197 resolve_transactions(&new)?;
1198 let mut generation = 1;
1199 let mut resumed = false;
1200 let mut selection = self.requested_selection()?;
1201 if let Some(record) = read_record(&new)? {
1202 self.validate_resume(&record)?;
1203 if record.new_root != new {
1204 return Err(CutoverError::RecordMismatch(format!(
1205 "the record names {} as the new root",
1206 record.new_root.display()
1207 )));
1208 }
1209 match record.phase {
1210 CutoverPhase::Completed => {
1211 check_directory(&new, record.new_key)?;
1212 let mut report = CutoverReport::new(&record);
1213 report.already_completed = true;
1214 self.complete_settlement(&new, record, true)?;
1215 return Ok(report);
1216 }
1217 CutoverPhase::Switched => {
1218 check_directory(&new, record.new_key)?;
1219 let mut report = CutoverReport::new(&record);
1220 report.resumed = true;
1221 return self.finish(&new, record, true, report);
1222 }
1223 CutoverPhase::Copying | CutoverPhase::Aborted => {
1224 check_directory(&new, record.new_key)?;
1225 if let TreeSelection::Roots(roots) = record.tree_selection.clone() {
1226 selection = TreeSelection::Roots(roots);
1227 }
1228 generation = record.generation.checked_add(1).ok_or_else(|| {
1229 CutoverError::RecordUnreadable("generation exhausted".into())
1230 })?;
1231 resumed = true;
1232 }
1233 }
1234 }
1235 let plan = self.plan_old(new, new_key)?;
1236 let mut record = Record {
1237 id: new_id()?,
1238 generation,
1239 phase: CutoverPhase::Copying,
1240 old_root: plan.old.clone(),
1241 new_root: plan.new.clone(),
1242 old_key: plan.old_key,
1243 new_key: plan.new_key,
1244 participants: self
1245 .participants
1246 .iter()
1247 .map(|p| p.name().to_string())
1248 .collect(),
1249 fs_move: self.fs_move.label().to_string(),
1250 error: None,
1251 retired: None,
1252 settlement_complete: false,
1253 tree_selection: selection,
1254 };
1255 write_record(&plan.new, &record)?;
1256 let cx = CutoverContext {
1257 id: record.id.clone(),
1258 generation,
1259 old_root: plan.old.clone(),
1260 new_root: plan.new.clone(),
1261 resumed,
1262 };
1263 let mut report = CutoverReport::new(&record);
1264 if let Err((prepared, cause)) = self.before_switch(&plan, &cx, &mut record, &mut report) {
1265 return Err(self.abort(&plan, record, &cx, prepared, cause));
1266 }
1267
1268 let copying = record.clone();
1270 record.phase = CutoverPhase::Switched;
1271 if let Err(e) = write_record(&plan.new, &record) {
1272 match read_record(&plan.new) {
1275 Ok(Some(on_disk)) if on_disk.id == record.id => match on_disk.phase {
1276 CutoverPhase::Switched => {}
1277 CutoverPhase::Copying => {
1278 let prepared = self.participants.len();
1279 return Err(self.abort(&plan, copying, &cx, prepared, CutoverError::Io(e)));
1280 }
1281 _ => return Err(CutoverError::Undecided(e)),
1282 },
1283 _ => return Err(CutoverError::Undecided(e)),
1284 }
1285 }
1286 self.finish(&plan.new, record, false, report)
1287 }
1288
1289 pub fn recover(&mut self) -> Result<RecoverOutcome, CutoverError> {
1297 let (new, _) = self.plan_new()?;
1298 let _lock = take_lock(&new)?;
1299 resolve_transactions(&new)?;
1300 let Some(record) = read_record(&new)? else {
1301 return Ok(RecoverOutcome::Nothing);
1302 };
1303 self.validate_resume(&record)?;
1304 if record.new_root != new {
1305 return Err(CutoverError::RecordMismatch(format!(
1306 "the record names {} as the new root",
1307 record.new_root.display()
1308 )));
1309 }
1310 match record.phase {
1311 CutoverPhase::Switched => {
1312 check_directory(&new, record.new_key)?;
1313 let mut report = CutoverReport::new(&record);
1314 report.resumed = true;
1315 self.finish(&new, record, true, report)
1316 .map(|done| RecoverOutcome::Resumed(Box::new(done)))
1317 }
1318 CutoverPhase::Completed => {
1319 check_directory(&new, record.new_key)?;
1320 self.complete_settlement(&new, record, true)?;
1321 Ok(RecoverOutcome::Completed)
1322 }
1323 CutoverPhase::Copying | CutoverPhase::Aborted => Ok(RecoverOutcome::Nothing),
1324 }
1325 }
1326
1327 fn before_switch(
1330 &mut self,
1331 plan: &Plan,
1332 cx: &CutoverContext,
1333 record: &mut Record,
1334 report: &mut CutoverReport,
1335 ) -> Result<(), (usize, CutoverError)> {
1336 let n = self.participants.len();
1337 let mut prepared = 0;
1338 for i in 0..n {
1339 self.hook(i, CutoverStep::Prepare, cx)
1340 .map_err(|e| (prepared, e))?;
1341 prepared += 1;
1342 }
1343 for i in 0..n {
1344 self.hook(i, CutoverStep::Quiesce, cx)
1345 .map_err(|e| (prepared, e))?;
1346 }
1347 self.resolve_tree_roots(plan, cx, record)
1348 .map_err(|e| (prepared, e))?;
1349 for i in 0..n {
1350 self.hook(i, CutoverStep::Relocate, cx)
1351 .map_err(|e| (prepared, e))?;
1352 }
1353 check_directory(&plan.old, plan.old_key).map_err(|e| (prepared, e))?;
1354 check_directory(&plan.new, plan.new_key).map_err(|e| (prepared, e))?;
1355 self.move_files(plan, &record.tree_selection, report)
1356 .map_err(|e| (prepared, e))?;
1357 check_directory(&plan.old, plan.old_key).map_err(|e| (prepared, e))?;
1359 check_directory(&plan.new, plan.new_key).map_err(|e| (prepared, e))?;
1360 Ok(())
1361 }
1362
1363 fn resolve_tree_roots(
1364 &self,
1365 plan: &Plan,
1366 cx: &CutoverContext,
1367 record: &mut Record,
1368 ) -> Result<(), CutoverError> {
1369 let current = if let Some(resolve) = &self.tree_roots {
1370 match catch_unwind(AssertUnwindSafe(|| resolve(cx))) {
1371 Ok(Ok(roots)) => Some(roots),
1372 Ok(Err(error)) => {
1373 return Err(CutoverError::Participant {
1374 name: "tree-roots".into(),
1375 step: CutoverStep::Quiesce,
1376 error,
1377 })
1378 }
1379 Err(payload) => {
1380 return Err(CutoverError::Participant {
1381 name: "tree-roots".into(),
1382 step: CutoverStep::Quiesce,
1383 error: ParticipantError::new(format!("panicked: {}", panic_text(payload))),
1384 })
1385 }
1386 }
1387 } else if let FsMove::Tree(options) = &self.fs_move {
1388 options.included.clone()
1389 } else {
1390 None
1391 };
1392 if let Some(mut roots) = current {
1393 if let TreeSelection::Roots(previous) = &record.tree_selection {
1394 roots.extend(previous.iter().cloned());
1395 }
1396 let roots = normalize_roots(roots).map_err(CutoverError::Migrate)?;
1397 self.validate_selected_roots(&roots)?;
1398 check_directory(&plan.old, plan.old_key)?;
1399 check_directory(&plan.new, plan.new_key)?;
1400 record.tree_selection = TreeSelection::Roots(roots);
1401 write_record(&plan.new, record)?;
1402 }
1403 Ok(())
1404 }
1405
1406 fn abort(
1409 &mut self,
1410 plan: &Plan,
1411 mut record: Record,
1412 cx: &CutoverContext,
1413 prepared: usize,
1414 cause: CutoverError,
1415 ) -> CutoverError {
1416 let mut failures = Vec::new();
1417 for i in (0..prepared).rev() {
1418 if let Err(CutoverError::Participant { name, error, .. }) =
1419 self.settle_hook(i, cx, Settlement::Aborted)
1420 {
1421 failures.push((name, error));
1422 }
1423 }
1424 record.phase = CutoverPhase::Aborted;
1425 record.error = Some(cause.to_string());
1426 if check_directory(&plan.new, plan.new_key).is_ok() {
1428 let _ = write_record(&plan.new, &record);
1429 }
1430 if failures.is_empty() {
1431 cause
1432 } else {
1433 CutoverError::AbortIncomplete {
1434 cause: Box::new(cause),
1435 failures,
1436 }
1437 }
1438 }
1439
1440 fn finish(
1442 &mut self,
1443 new: &Path,
1444 mut record: Record,
1445 resumed: bool,
1446 mut report: CutoverReport,
1447 ) -> Result<CutoverReport, CutoverError> {
1448 let cx = CutoverContext {
1449 id: record.id.clone(),
1450 generation: record.generation,
1451 old_root: record.old_root.clone(),
1452 new_root: record.new_root.clone(),
1453 resumed,
1454 };
1455 for i in 0..self.participants.len() {
1456 if let Err(e) = self.hook(i, CutoverStep::Reopen, &cx) {
1457 return Err(match e {
1458 CutoverError::Participant { name, error, .. } => CutoverError::ReopenPending {
1459 name,
1460 error,
1461 generation: record.generation,
1462 },
1463 other => other,
1464 });
1465 }
1466 }
1467 record.phase = CutoverPhase::Completed;
1468 record.error = None;
1469 record.settlement_complete = false;
1470 write_record(new, &record)?;
1471 self.complete_settlement(new, record, false)?;
1472 report.resumed = report.resumed || resumed;
1473 Ok(report)
1474 }
1475
1476 fn complete_settlement(
1477 &mut self,
1478 new: &Path,
1479 mut record: Record,
1480 replay: bool,
1481 ) -> Result<(), CutoverError> {
1482 if record.settlement_complete {
1483 return Ok(());
1484 }
1485 let cx = CutoverContext {
1486 id: record.id.clone(),
1487 generation: record.generation,
1488 old_root: record.old_root.clone(),
1489 new_root: record.new_root.clone(),
1490 resumed: replay,
1491 };
1492 let mut failures = Vec::new();
1493 for i in 0..self.participants.len() {
1494 let result = if replay {
1495 let participant = &mut self.participants[i];
1496 let name = participant.name().to_string();
1497 let result = catch_unwind(AssertUnwindSafe(|| participant.recover_completed(&cx)));
1498 Self::hook_result(name, CutoverStep::Settle, result)
1499 } else {
1500 self.settle_hook(i, &cx, Settlement::Committed)
1501 };
1502 if let Err(CutoverError::Participant { name, error, .. }) = result {
1503 failures.push((name, error));
1504 }
1505 }
1506 if !failures.is_empty() {
1507 return Err(CutoverError::SettlementPending {
1508 generation: record.generation,
1509 failures,
1510 });
1511 }
1512 record.settlement_complete = true;
1513 write_record(new, &record).map_err(|error| CutoverError::SettlementPending {
1514 generation: record.generation,
1515 failures: vec![(
1516 "settlement-record".into(),
1517 ParticipantError::new(format!(
1518 "participant cleanup finished but durable acknowledgement write failed: {error}"
1519 )),
1520 )],
1521 })?;
1522 Ok(())
1523 }
1524
1525 fn hook(
1526 &mut self,
1527 index: usize,
1528 step: CutoverStep,
1529 cx: &CutoverContext,
1530 ) -> Result<(), CutoverError> {
1531 let participant = &mut self.participants[index];
1532 let name = participant.name().to_string();
1533 let result = catch_unwind(AssertUnwindSafe(|| match step {
1534 CutoverStep::Prepare => participant.prepare(cx),
1535 CutoverStep::Quiesce => participant.quiesce(cx),
1536 CutoverStep::Relocate => participant.relocate(cx),
1537 CutoverStep::Reopen => participant.reopen(cx),
1538 CutoverStep::Settle => Ok(()),
1540 }));
1541 Self::hook_result(name, step, result)
1542 }
1543
1544 fn settle_hook(
1545 &mut self,
1546 index: usize,
1547 cx: &CutoverContext,
1548 settlement: Settlement,
1549 ) -> Result<(), CutoverError> {
1550 let participant = &mut self.participants[index];
1551 let name = participant.name().to_string();
1552 let result = catch_unwind(AssertUnwindSafe(|| participant.settle(cx, settlement)));
1553 Self::hook_result(name, CutoverStep::Settle, result)
1554 }
1555
1556 fn hook_result(
1557 name: String,
1558 step: CutoverStep,
1559 result: std::thread::Result<Result<(), ParticipantError>>,
1560 ) -> Result<(), CutoverError> {
1561 match result {
1562 Ok(Ok(())) => Ok(()),
1563 Ok(Err(error)) => Err(CutoverError::Participant { name, step, error }),
1564 Err(payload) => {
1565 let error = ParticipantError::new(format!("panicked: {}", panic_text(payload)));
1566 Err(CutoverError::Participant { name, step, error })
1567 }
1568 }
1569 }
1570
1571 fn move_files(
1574 &self,
1575 plan: &Plan,
1576 selection: &TreeSelection,
1577 report: &mut CutoverReport,
1578 ) -> Result<(), CutoverError> {
1579 match &self.fs_move {
1580 FsMove::None => Ok(()),
1581 FsMove::Tree(options) => {
1582 let mut options = options
1585 .clone()
1586 .exclude(CUTOVER_RECORD)
1587 .exclude(CUTOVER_LOCK)
1588 .exclude(TXN_AREA);
1589 if let TreeSelection::Roots(roots) = selection {
1590 options = options
1591 .include_roots(roots.clone())
1592 .map_err(CutoverError::Migrate)?;
1593 } else if matches!(selection, TreeSelection::Pending) {
1594 return Err(CutoverError::RecordMismatch(
1595 "tree selection unresolved".into(),
1596 ));
1597 }
1598 for participant in &self.participants {
1599 for owned in participant.owned_paths() {
1600 options = options.exclude(owned);
1601 }
1602 }
1603 let migrated = self
1604 .app
1605 .migrate_for_cutover(&plan.old, &options)
1606 .map_err(CutoverError::Migrate)?;
1607 if !migrated.state().is_usable() {
1608 return Err(CutoverError::Migrate(AppRootError::VerifyFailed(
1609 plan.old.clone(),
1610 )));
1611 }
1612 report.migration = Some(migrated);
1613 Ok(())
1614 }
1615 FsMove::Companions(rels) => self.move_companions(plan, rels, report),
1616 }
1617 }
1618
1619 fn move_companions(
1620 &self,
1621 plan: &Plan,
1622 rels: &[PathBuf],
1623 report: &mut CutoverReport,
1624 ) -> Result<(), CutoverError> {
1625 let mut entries = Vec::new();
1626 let mut pairs = Vec::new();
1627 for rel in rels {
1628 let src = plan.old.join(rel);
1629 let md =
1630 fs::symlink_metadata(&src).map_err(|_| CutoverError::BadCompanion(rel.clone()))?;
1631 if !md.is_file() {
1632 return Err(CutoverError::BadCompanion(rel.clone()));
1633 }
1634 let dst = self.app.path(rel).map_err(CutoverError::Migrate)?;
1636 match fs::symlink_metadata(&dst) {
1637 Ok(existing) if existing.is_file() => {
1638 if !same_content(&src, &dst).map_err(CutoverError::Migrate)? {
1639 return Err(CutoverError::Collision(dst));
1640 }
1641 }
1642 Ok(_) => return Err(CutoverError::Collision(dst)),
1643 Err(e) if e.kind() == io::ErrorKind::NotFound => {
1644 entries.push(TransactionEntry::create_new(rel.clone(), src.clone()));
1645 }
1646 Err(e) => return Err(CutoverError::Io(e)),
1647 }
1648 pairs.push((src, dst));
1649 }
1650 if !entries.is_empty() {
1651 let prepared =
1652 FileTransaction::prepare(&plan.new, entries, TransactionOptions::default())
1653 .map_err(CutoverError::Fs)?;
1654 match prepared.commit().map_err(CutoverError::Fs)? {
1655 TransactionReport::Committed(_) => {}
1656 TransactionReport::RolledBack(undone) => {
1657 return Err(CutoverError::Fs(undone.cause));
1658 }
1659 TransactionReport::RecoveryRequired(stuck) => {
1660 return Err(CutoverError::FsRecoveryRequired(format!(
1661 "transaction {} retained {} item(s): {}",
1662 stuck.id,
1663 stuck.retained.len(),
1664 stuck.cause
1665 )));
1666 }
1667 _ => {
1668 return Err(CutoverError::FsRecoveryRequired(
1669 "unrecognised transaction report".to_string(),
1670 ));
1671 }
1672 }
1673 }
1674 for (src, dst) in &pairs {
1676 if !same_content(src, dst).map_err(CutoverError::Migrate)? {
1677 return Err(CutoverError::Collision(dst.clone()));
1678 }
1679 }
1680 report.companions = pairs.into_iter().map(|(_, dst)| dst).collect();
1681 Ok(())
1682 }
1683
1684 pub fn retire_old_root(app: &AppRoot, mode: RetireMode) -> Result<Retired, CutoverError> {
1696 let new = fs::canonicalize(app.root()).map_err(CutoverError::Io)?;
1697 let _lock = take_lock(&new)?;
1698 let Some(mut record) = read_record(&new)? else {
1699 return Err(CutoverError::RecordUnreadable(
1700 "no cutover record at this root".to_string(),
1701 ));
1702 };
1703 if record.phase != CutoverPhase::Completed {
1704 return Err(CutoverError::NotCompleted(record.phase));
1705 }
1706 if !record.settlement_complete {
1707 return Err(CutoverError::SettlementPending {
1708 generation: record.generation,
1709 failures: vec![(
1710 "settlement".into(),
1711 ParticipantError::new("participant cleanup is not durably complete"),
1712 )],
1713 });
1714 }
1715 if record.new_root != new {
1716 return Err(CutoverError::RecordMismatch(format!(
1717 "the record names {} as the new root",
1718 record.new_root.display()
1719 )));
1720 }
1721 check_directory(&new, record.new_key)?;
1722 if let Some(path) = record.retired.clone() {
1723 check_directory(&path, record.old_key)?;
1724 return Ok(Retired {
1725 path,
1726 already: true,
1727 });
1728 }
1729 let old = record.old_root.clone();
1730 if old.starts_with(&new) || new.starts_with(&old) {
1731 return Err(CutoverError::InvalidRoot {
1732 path: old,
1733 reason: "the old and new roots are the same or nested",
1734 });
1735 }
1736 let aside = {
1737 let mut name = old.as_os_str().to_os_string();
1738 name.push(format!(".rk-retired-{}", record.id));
1739 PathBuf::from(name)
1740 };
1741 match fs::symlink_metadata(&old) {
1742 Err(e) if e.kind() == io::ErrorKind::NotFound => {
1743 check_directory(&aside, record.old_key)?;
1745 record.retired = Some(aside.clone());
1746 write_record(&new, &record)?;
1747 return Ok(Retired {
1748 path: aside,
1749 already: true,
1750 });
1751 }
1752 Err(e) => return Err(CutoverError::Io(e)),
1753 Ok(_) => {}
1754 }
1755 check_directory(&old, record.old_key)?;
1756 let landed = match mode {
1757 RetireMode::RenameAside => {
1758 if fs::symlink_metadata(&aside).is_ok() {
1759 return Err(CutoverError::Collision(aside));
1760 }
1761 rightkit_fs::rename_exclusive(&old, &aside).map_err(|error| match error {
1762 FsError::AlreadyExists(_) => CutoverError::Collision(aside.clone()),
1763 other => CutoverError::Fs(other),
1764 })?;
1765 aside
1766 }
1767 RetireMode::Trash => {
1768 let trashed = rightkit_fs::move_to_trash(&old).map_err(CutoverError::Fs)?;
1769 trashed.trashed_as.unwrap_or(trashed.original)
1770 }
1771 };
1772 record.retired = Some(landed.clone());
1773 write_record(&new, &record)?;
1774 Ok(Retired {
1775 path: landed,
1776 already: false,
1777 })
1778 }
1779}
1780
1781#[cfg(test)]
1782mod tests {
1783 use super::*;
1784 use crate::Suite;
1785
1786 #[test]
1787 fn abort_does_not_write_into_substituted_destination() {
1788 let temporary = tempfile::tempdir().unwrap();
1789 let old = temporary.path().join("old");
1790 fs::create_dir(&old).unwrap();
1791 fs::write(old.join("startup.log"), b"source").unwrap();
1792 let app = Suite::at(temporary.path().join("suite"))
1793 .unwrap()
1794 .app_root("AbortIdentity")
1795 .unwrap();
1796 let mut cutover = RootCutover::new(app.clone(), &old)
1797 .fs_move(FsMove::Tree(MigrateOptions::strict()))
1798 .tree_roots(|_| Err(ParticipantError::new("stop before copy")));
1799 assert!(matches!(
1800 cutover.run(),
1801 Err(CutoverError::Participant { name, error, .. })
1802 if name == "tree-roots" && error.message() == "stop before copy"
1803 ));
1804 let (new, new_key) = cutover.plan_new().unwrap();
1807 let plan = cutover.plan_old(new, new_key).unwrap();
1808 let mut record = read_record(&plan.new).unwrap().unwrap();
1809 assert_eq!(record.phase, CutoverPhase::Aborted);
1810 record.phase = CutoverPhase::Copying;
1811 record.error = None;
1812 write_record(&plan.new, &record).unwrap();
1813 let original = fs::read(plan.new.join(CUTOVER_RECORD)).unwrap();
1814 let cx = CutoverContext {
1815 id: record.id.clone(),
1816 generation: record.generation,
1817 old_root: plan.old.clone(),
1818 new_root: plan.new.clone(),
1819 resumed: false,
1820 };
1821 let saved = plan.new.with_extension("saved");
1822 fs::rename(&plan.new, &saved).unwrap();
1823 fs::create_dir(&plan.new).unwrap();
1824 fs::write(plan.new.join("sentinel"), b"replacement").unwrap();
1825 assert!(matches!(
1826 check_directory(&plan.new, plan.new_key),
1827 Err(CutoverError::PathSubstituted(_))
1828 ));
1829 let cause = cutover.abort(
1830 &plan,
1831 record,
1832 &cx,
1833 0,
1834 CutoverError::Io(io::Error::other("resolver failed after replacement")),
1835 );
1836 assert!(matches!(
1837 cause,
1838 CutoverError::Io(error) if error.to_string() == "resolver failed after replacement"
1839 ));
1840 assert!(!plan.new.join(CUTOVER_RECORD).exists());
1841 assert_eq!(fs::read(plan.new.join("sentinel")).unwrap(), b"replacement");
1842 assert_eq!(fs::read_dir(&plan.new).unwrap().count(), 1);
1843 assert_eq!(fs::read(saved.join(CUTOVER_RECORD)).unwrap(), original);
1844 assert_eq!(fs::read(plan.old.join("startup.log")).unwrap(), b"source");
1845 assert_eq!(RootCutover::selected_root(&app, &old).unwrap(), old);
1846 }
1847}