Skip to main content

rightkit_daemon/
root_cutover.rs

1//! `RootCutover`: move an app's data root from an old location to a new one with the writers
2//! stopped, the copy verified, the selection persisted and every reader reopened, and with the old
3//! root left complete and recoverable (PK07-3).
4//!
5//! The cutover composes three things and owns none of their internals:
6//!
7//! * the **file move**: either the restart-safe tree copy of [`AppRoot::migrate_from_with`]
8//!   ([`FsMove::Tree`]) or a bounded list of companion files published through one
9//!   `rightkit-fs` `FileTransaction` rooted at the new root ([`FsMove::Companions`]);
10//! * **lifecycle participants** the app registers ([`CutoverParticipant`]): opaque objects with
11//!   lifecycle hooks (prepare, quiesce, relocate, reopen, settle & completed recovery).
12//!   The encrypted-database relocation of
13//!   `rightkit-db` is one of them: the app wraps its phased `RelocationSession` in a participant. This
14//!   crate depends on neither `rightkit-db` nor any credential crate, and `rightkit-db` takes no
15//!   daemon dependency;
16//! * a **persisted selection**, the record [`CUTOVER_RECORD`] at the new root, which says which
17//!   root readers and services must open.
18//!
19//! # Protocol
20//!
21//! | # | Step | Selection a reader sees | A crash here leaves |
22//! |---|---|---|---|
23//! | 1 | take the cutover lock at the new root; resolve pending `FileTransaction`s | old | nothing |
24//! | 2 | record `copying` (generation, both roots, both directory identities) | old | the old root, untouched |
25//! | 3 | every participant `prepare`, then `quiesce` (writers stopped) | old | quiesced writers die with the process |
26//! | 4 | every participant `relocate`; then the file move | old | a partial copy at the new root, never selected |
27//! | 5 | re-check both directory identities (path substitution) | old | as 4 |
28//! | 6 | record `switched`: **the single decision** | **new** | roll forward: [`RootCutover::recover`] |
29//! | 7 | every participant `reopen` at the new root and verify | new | `switched`: reopen again (hooks are idempotent) |
30//! | 8 | record `completed` with settlement pending; every participant `settle(Committed)`; record settlement complete | new | `completed`: retry cleanup only |
31//!
32//! Before step 6 any failure aborts: every participant that was prepared gets
33//! `settle(Aborted)` (in reverse order) and resumes at the old root, the record becomes `aborted`
34//! with the reason and the old root is exactly as it was. A rerun resumes: the tree copy and the
35//! companion step are restart-safe and never overwrite a differing file. After step 6 there is no
36//! rollback in this version: the new root is the authority and a failed reopen is reported as
37//! [`CutoverError::ReopenPending`]; [`RootCutover::recover`] (run it at startup, before opening
38//! any root) finishes it. A completed cutover whose cleanup failed returns
39//! [`CutoverError::SettlementPending`]; fresh recovery calls only
40//! [`CutoverParticipant::recover_completed`] until all cleanup is durably acknowledged.
41//! A participant whose files must not travel with the tree names them in
42//! [`CutoverParticipant::owned_paths`]; the tree move leaves those paths out.
43//!
44//! The old root is never modified or deleted by the cutover. Cleanup is a separate step,
45//! [`RootCutover::retire_old_root`], that refuses unless the cutover is `completed`, settlement
46//! is durably complete and the old
47//! directory is still the directory (by file identity, not by path) that was cut over, and then
48//! only renames it aside or sends it to the trash.
49//!
50//! Readers decide the root with [`RootCutover::status`] / [`RootCutover::selected_root`]: no
51//! record or `copying`/`aborted` selects the old root, `switched`/`completed` the new one.
52
53use 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
73/// The persisted selection: written at the new root, atomically, at every phase change.
74pub const CUTOVER_RECORD: &str = ".rightkit-root-cutover.json";
75/// The cross-process cutover lock at the new root (an OS lock, released on process death).
76const CUTOVER_LOCK: &str = ".rightkit-cutover.lock";
77/// The `rightkit-fs` transaction area; never copied from an old root.
78const TXN_AREA: &str = ".rktxn";
79const RECORD_VERSION: u64 = 1;
80const SELECTED_RECORD_VERSION: u64 = 2;
81
82// ---------------------------------------------------------------------------------------------
83// Participants
84// ---------------------------------------------------------------------------------------------
85
86/// A failure reported by a participant hook.
87#[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    /// Wrap any error by its message.
100    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/// The hook that was running.
118#[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/// How the cutover ended, as told to [`CutoverParticipant::settle`].
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142#[non_exhaustive]
143pub enum Settlement {
144    /// The new root is the authority and every participant reopened there.
145    Committed,
146    /// The cutover failed before the switch: resume at the old root.
147    Aborted,
148}
149
150/// What a participant hook is told about the cutover it is part of.
151#[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    /// The cutover id (random, fresh for each attempt, persisted in the record).
162    pub fn id(&self) -> &str {
163        &self.id
164    }
165
166    /// The generation number: 1 for the first attempt at this new root, one more for each rerun
167    /// after an abort or an interruption. A service can fence stale replies with it.
168    pub fn generation(&self) -> u64 {
169        self.generation
170    }
171
172    /// The canonical old root. Participants must treat it as read-only.
173    pub fn old_root(&self) -> &Path {
174        &self.old_root
175    }
176
177    /// The canonical new root.
178    pub fn new_root(&self) -> &Path {
179        &self.new_root
180    }
181
182    /// True when this attempt follows an earlier one that did not complete, or finishes a
183    /// switched cutover after a restart. Hooks must be idempotent: work an earlier attempt
184    /// already finished is verified, not redone or refused.
185    pub fn resumed(&self) -> bool {
186        self.resumed
187    }
188
189    /// The root a participant must be using after `settlement`.
190    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
198/// An opaque lifecycle participant: something the app owns that holds state under the root and
199/// must be stopped, moved, reopened or released as part of the cutover (an encrypted database and
200/// its key, a settings store, a resident service, a watcher).
201///
202/// Every hook has a default that does nothing, so a participant implements the ones it needs.
203/// Hooks run in registration order (`settle(Aborted)` in reverse), on the caller's thread, one at
204/// a time. A panic in a hook is caught and reported as that hook failing.
205///
206/// Contract: `prepare` validates and acquires what the cutover needs and changes nothing the old
207/// root depends on; `quiesce` stops this participant's writers (after it returns `Ok` nothing of
208/// this participant writes to the old root, and a read-only `relocate` may rely on it);
209/// `relocate` copies this participant's state to the new root, never modifies or removes the old
210/// state, and must be idempotent (state already verified at the new root from an earlier attempt
211/// is `Ok`); `reopen` opens the participant at the new root and verifies it; `settle` releases
212/// what `prepare` acquired and, for [`Settlement::Aborted`], resumes at the old root. `settle` is
213/// called for every participant whose `prepare` returned `Ok`, whichever later hook failed, so it
214/// must be safe in any state (a participant that quiesced and one that did not alike). A
215/// participant whose own `prepare` failed is not settled: it cleans up after itself.
216///
217/// A phased database adapter maps `prepare` to `prepare_relocation`, `quiesce` to the session's
218/// `quiesce`, and `relocate` to `copy`. A committed copy does not activate the new database:
219/// only this coordinator's durable `Switched` record authorizes the session's `reopen`.
220/// `settle(Committed)` maps to `RelocationSettlement::Committed` cleanup; `settle(Aborted)` maps
221/// to `RelocationSettlement::Aborted`, resumes the old database, and retains a committed copy.
222/// A failed reopen never authorizes old-root resumption, even when no new connection was opened.
223///
224/// Recovery constructs fresh participants from durable root, file and transaction identities.
225/// A `Switched` recovery calls `reopen` directly, without `prepare`, `quiesce` or `relocate`:
226/// database adapters reconstruct a session with `recover_relocation_session` and retain it in
227/// participant state. They must not use terminal `recover_relocation`, which clears replay
228/// journals before this coordinator records `Completed`. Only `settle(Committed)`, called after
229/// that durable record, settles the retained session. Dropping it after a failed reopen or failed
230/// completion-record write preserves recovery authority for another process.
231/// An unknown copy/transaction outcome must preserve its journal
232/// and both byte identities, return an error, and keep writers stopped until recovery resolves it.
233pub trait CutoverParticipant: Send {
234    /// A short stable name: ASCII letters, digits, `.`, `_` or `-`, unique within the cutover.
235    fn name(&self) -> &str;
236
237    /// Paths, relative to the root, this participant moves by its own means. A tree move leaves
238    /// them out ([`MigrateOptions::exclude`]) and a companion list may not touch them.
239    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    /// Retry selected-new cleanup after durable Completed, including in a fresh process.
268    /// Idempotent: absent already-clean state succeeds; retained unknown/mismatched authority
269    /// fails. Never resumes old root. DB adapters use DB-owned completed recovery, rather
270    /// than blindly reopening or treating a missing in-memory session as already settled.
271    fn recover_completed(&mut self, _cx: &CutoverContext) -> Result<(), ParticipantError> {
272        Ok(())
273    }
274}
275
276// ---------------------------------------------------------------------------------------------
277// Plan, phases, record, status
278// ---------------------------------------------------------------------------------------------
279
280/// How the files under the root travel.
281#[derive(Debug, Clone, Default)]
282#[non_exhaustive]
283pub enum FsMove {
284    /// No file move: the participants carry everything.
285    #[default]
286    None,
287    /// Copy the whole old tree with [`AppRoot::migrate_from_with`] under these options, minus the
288    /// participants' [`owned_paths`](CutoverParticipant::owned_paths) and the cutover's own files.
289    Tree(MigrateOptions),
290    /// Publish these files (paths relative to both roots, regular files, at most 256) through one
291    /// `rightkit-fs` `FileTransaction` rooted at the new root. A destination that already holds
292    /// identical bytes is accepted (a rerun); one that holds anything else is a
293    /// [`CutoverError::Collision`] and nothing is overwritten.
294    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/// The persisted phase of a cutover.
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309#[non_exhaustive]
310pub enum CutoverPhase {
311    /// Started, not switched. The old root is the authority.
312    Copying,
313    /// The switch is durable. The new root is the authority; participants may still have to
314    /// reopen ([`RootCutover::recover`]).
315    Switched,
316    /// Switched and every participant reopened.
317    Completed,
318    /// Failed before the switch. The old root is the authority; a rerun is allowed.
319    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/// Which root readers must open.
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum RootSelection {
346    Old,
347    New,
348}
349
350/// What the persisted record says (see [`RootCutover::status`]).
351#[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    /// Why an `aborted` cutover failed.
362    pub error: Option<String>,
363    /// Where [`RootCutover::retire_old_root`] put the old root, once it did.
364    pub retired: Option<PathBuf>,
365    /// Every participant's committed cleanup was durably acknowledged. Missing legacy markers
366    /// are false: a completed cutover still needs cleanup recovery before old-root retirement.
367    pub settlement_complete: bool,
368}
369
370impl RootStatus {
371    /// The root to open.
372    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    /// True when the switch is durable but reopen or committed cleanup remains pending: run
380    /// [`RootCutover::recover`] before opening anything.
381    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
612/// Atomic replace of the record, then a directory flush where the platform has one.
613fn 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// ---------------------------------------------------------------------------------------------
619// Errors and reports
620// ---------------------------------------------------------------------------------------------
621
622/// Why a cutover step failed.
623#[derive(Debug)]
624#[non_exhaustive]
625pub enum CutoverError {
626    /// The old or new root cannot be used as given (missing, not a real directory, a symlink,
627    /// not UTF-8, relative, or the two nest). Nothing was changed.
628    InvalidRoot {
629        path: PathBuf,
630        reason: &'static str,
631    },
632    /// A participant name is empty, not plain, or used twice, or an owned path is not a plain
633    /// relative path.
634    BadParticipant(String),
635    /// A companion path is not a plain relative path to a regular file in the old root.
636    /// Path syntax is checked before participant hooks; file existence and type are checked
637    /// at move time, after `relocate`. A failure then settles prepared participants as aborted.
638    BadCompanion(PathBuf),
639    /// A companion lies on a path a participant owns.
640    OwnedPathConflict {
641        participant: String,
642        path: PathBuf,
643    },
644    /// Another process or thread is running a cutover for this root.
645    Busy(PathBuf),
646    /// A destination holds something other than what would be written. Nothing was overwritten.
647    Collision(PathBuf),
648    /// A root is no longer the directory (by file identity) that was recorded: it was replaced
649    /// or moved while the cutover ran.
650    PathSubstituted(PathBuf),
651    /// The persisted record names other roots than this cutover.
652    RecordMismatch(String),
653    /// The persisted record cannot be read or is of a newer version. Fails closed: the caller
654    /// must not guess which root to open.
655    RecordUnreadable(String),
656    /// A `FileTransaction` of an earlier run is retained and could not be resolved.
657    PendingTransaction(String),
658    /// A participant hook failed before the switch; every prepared participant was settled as
659    /// aborted and the old root is the authority.
660    Participant {
661        name: String,
662        step: CutoverStep,
663        error: ParticipantError,
664    },
665    /// The cutover failed before the switch and at least one participant also failed to resume
666    /// (`settle(Aborted)`); those participants must be restarted by the app.
667    AbortIncomplete {
668        cause: Box<CutoverError>,
669        failures: Vec<(String, ParticipantError)>,
670    },
671    /// The switch is durable (the new root is the authority) but a participant could not reopen.
672    /// Run [`RootCutover::recover`]; hooks are idempotent.
673    ReopenPending {
674        name: String,
675        error: ParticipantError,
676        generation: u64,
677    },
678    /// New root remains authoritative; durable participant cleanup must be retried.
679    SettlementPending {
680        generation: u64,
681        failures: Vec<(String, ParticipantError)>,
682    },
683    /// The tree migration failed or ended unusable.
684    Migrate(AppRootError),
685    /// A file-system operation failed.
686    Fs(FsError),
687    /// The companion transaction could not be decided in this process.
688    FsRecoveryRequired(String),
689    /// The record write at the switch failed and what is on disk could not be read back. The
690    /// decision is unknown; participants stay quiesced. Run [`RootCutover::recover`].
691    Undecided(io::Error),
692    /// The cutover has not reached `completed`; old-root retirement remains forbidden.
693    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/// What a finished cutover did.
786#[derive(Debug)]
787#[non_exhaustive]
788pub struct CutoverReport {
789    pub id: String,
790    pub generation: u64,
791    /// Always [`RootSelection::New`] for a returned report.
792    pub selected: RootSelection,
793    /// The tree migration report ([`FsMove::Tree`]).
794    pub migration: Option<MigrationReport>,
795    /// Absolute destinations of the companions, published now or already in place
796    /// ([`FsMove::Companions`]).
797    pub companions: Vec<PathBuf>,
798    /// This call finished a switched cutover begun earlier (a restart, or a reopen that failed).
799    pub resumed: bool,
800    /// The cutover was already completed; only pending cleanup recovery may have run.
801    pub already_completed: bool,
802    /// Reserved compatibility field, empty on successful reports. Committed cleanup failures
803    /// return [`CutoverError::SettlementPending`] while preserving new-root authority.
804    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/// What [`RootCutover::recover`] found.
823#[derive(Debug)]
824#[non_exhaustive]
825pub enum RecoverOutcome {
826    /// No cutover, or one that never reached the switch (`copying`, `aborted`): the old root is
827    /// still the authority and nothing was run.
828    Nothing,
829    /// A switched cutover was finished.
830    Resumed(Box<CutoverReport>),
831    /// The cutover was already completed; any pending committed cleanup has been acknowledged.
832    Completed,
833}
834
835/// What [`RootCutover::retire_old_root`] does with the old root.
836#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837#[non_exhaustive]
838pub enum RetireMode {
839    /// Rename the old root to `<name>.rk-retired-<id>` beside it. Recoverable by renaming back;
840    /// frees no space.
841    RenameAside,
842    /// Send the old root to the platform trash (macOS only today: `rightkit_fs::move_to_trash`
843    /// reports `Unavailable` on Windows and the call changes nothing there).
844    Trash,
845}
846
847/// What [`RootCutover::retire_old_root`] did.
848#[derive(Debug, Clone, PartialEq, Eq)]
849#[non_exhaustive]
850pub struct Retired {
851    /// Where the old root is now.
852    pub path: PathBuf,
853    /// An earlier call had already retired it (or finished retiring it after a crash).
854    pub already: bool,
855}
856
857// ---------------------------------------------------------------------------------------------
858// RootCutover
859// ---------------------------------------------------------------------------------------------
860
861struct 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
870/// The cutover of one app root. Build it with [`RootCutover::new`], register participants, run it.
871pub 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
918/// Resolve any `FileTransaction` an earlier run left at the new root. Retained material is a
919/// refusal, never a guess.
920fn 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
933/// The directory at `path` must still be a real directory with identity `key`.
934fn 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    /// A cutover of `app`'s root (the new root) from `old_root`. `old_root` must be an absolute
967    /// path to a real directory that does not contain, and is not inside, the new root.
968    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    /// How the files travel (default [`FsMove::None`]).
979    pub fn fs_move(mut self, fs_move: FsMove) -> Self {
980        self.fs_move = fs_move;
981        self
982    }
983
984    /// Resolve a bounded tree-root descriptor after every writer has quiesced.
985    /// Legal only for `FsMove::Tree` without static `include_roots`.
986    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    /// Register a participant. Order matters: hooks run in registration order.
995    pub fn participant(mut self, participant: impl CutoverParticipant + 'static) -> Self {
996        self.participants.push(Box::new(participant));
997        self
998    }
999
1000    // ---- reading the selection -------------------------------------------------------------
1001
1002    /// The persisted selection of `app`'s root, or `None` when no cutover ever started there (the
1003    /// old root is the only root). Takes no lock and changes nothing. An unreadable record is an
1004    /// error, never a guess.
1005    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    /// The root to open: the new root once the switch is durable, otherwise `legacy` (the old
1016    /// root the app used before any cutover).
1017    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    // ---- running ---------------------------------------------------------------------------
1107
1108    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    /// Run the cutover. Idempotent: a completed cutover returns
1186    /// [`already_completed`](CutoverReport::already_completed); a switched one is finished (the
1187    /// roll-forward of [`recover`](Self::recover)); an aborted or interrupted one is attempted
1188    /// again with the next generation.
1189    ///
1190    /// On `Err` before the switch the old root is the authority and every prepared participant
1191    /// was settled as aborted. [`CutoverError::ReopenPending`] and [`CutoverError::Undecided`]
1192    /// mean the opposite or unknown: call [`recover`](Self::recover).
1193    /// [`CutoverError::SettlementPending`] preserves new-root authority & retries cleanup only.
1194    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        // The decision. Before the write lands the old root is the authority, after it the new.
1269        let copying = record.clone();
1270        record.phase = CutoverPhase::Switched;
1271        if let Err(e) = write_record(&plan.new, &record) {
1272            // The rename may have landed even though the write reported failure (a failed
1273            // directory flush). Read back before deciding anything.
1274            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    /// Finish a switched cutover after a restart or a failed reopen, without starting a new one.
1290    /// Run it at startup, before opening any root: it takes the cutover lock, resolves pending
1291    /// file transactions, and, when the record says `switched`, reopens every participant at the
1292    /// new root and completes. `completed` with pending settlement invokes only
1293    /// [`CutoverParticipant::recover_completed`], never reopen or old-root resumption.
1294    /// A cutover that never reached the switch is left alone
1295    /// ([`RecoverOutcome::Nothing`]: the old root is still the authority).
1296    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    /// Steps 3 to 5. `Err` carries how many participants were prepared (they get
1328    /// `settle(Aborted)`).
1329    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        // Path substitution: both roots must still be the directories this run recorded.
1358        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    /// Settle every prepared participant as aborted (reverse order), record `aborted`, and return
1407    /// the cause (with the participants that did not resume attached).
1408    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        // Best effort: `copying` already selects the old root, so a failed write loses nothing.
1427        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    /// Steps 7 and 8: reopen everything at the new root, record `completed`, settle.
1441    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            // `settle` carries a settlement and goes through `settle_hook`.
1539            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    // ---- the file move ---------------------------------------------------------------------
1572
1573    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                // The cutover's own files never travel (an old root can itself be an earlier
1583                // cutover's new root), and neither do the participants' own paths.
1584                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            // `AppRoot::path` refuses a destination whose ancestors resolve outside the root.
1635            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        // Verify every companion byte for byte, including those already in place.
1675        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    // ---- cleanup ---------------------------------------------------------------------------
1685
1686    /// Retire the old root of a completed cutover: a separate, identity-checked step that never
1687    /// runs as part of [`run`](Self::run). Refuses unless the record is `completed` and the
1688    /// directory at the recorded old path still has the file identity recorded when the cutover
1689    /// started (a path that was replaced is [`CutoverError::PathSubstituted`], never touched), and
1690    /// the new root still has its recorded identity. Takes the cutover lock.
1691    ///
1692    /// It never deletes: [`RetireMode::RenameAside`] renames the old root beside itself,
1693    /// [`RetireMode::Trash`] uses the platform trash. A crash between the rename and the record
1694    /// is repaired by calling it again (the aside directory is found by name and identity).
1695    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                // Either a previous call renamed it and crashed before the record, or it is gone.
1744                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        // Capture a pending attempt after run released its lock, so replacement is possible
1805        // on Windows too. Exercise abort's identity guard without weakening that lock.
1806        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}