use std::collections::BTreeSet;
use std::fmt;
use std::fs;
use std::io::{self, Read};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::{Component, Path, PathBuf};
use rightkit_fs::atomic::try_sync_dir;
use rightkit_fs::{
recover_transactions, FileKey, FileTransaction, FsError, TransactionEntry, TransactionOptions,
TransactionReport,
};
use serde_json::{json, Value};
use crate::app_root::{
normalize_roots, roots_overlap, same_content, write_json_atomic, AppRoot, AppRootError,
MigrateOptions, MigrationReport,
};
use crate::discovery::try_lock_exclusive;
pub const CUTOVER_RECORD: &str = ".rightkit-root-cutover.json";
const CUTOVER_LOCK: &str = ".rightkit-cutover.lock";
const TXN_AREA: &str = ".rktxn";
const RECORD_VERSION: u64 = 1;
const SELECTED_RECORD_VERSION: u64 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParticipantError {
message: String,
}
impl ParticipantError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn from_error(error: &dyn std::error::Error) -> Self {
Self::new(error.to_string())
}
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for ParticipantError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ParticipantError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CutoverStep {
Prepare,
Quiesce,
Relocate,
Reopen,
Settle,
}
impl CutoverStep {
pub fn as_str(self) -> &'static str {
match self {
CutoverStep::Prepare => "prepare",
CutoverStep::Quiesce => "quiesce",
CutoverStep::Relocate => "relocate",
CutoverStep::Reopen => "reopen",
CutoverStep::Settle => "settle",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Settlement {
Committed,
Aborted,
}
#[derive(Debug, Clone)]
pub struct CutoverContext {
id: String,
generation: u64,
old_root: PathBuf,
new_root: PathBuf,
resumed: bool,
}
impl CutoverContext {
pub fn id(&self) -> &str {
&self.id
}
pub fn generation(&self) -> u64 {
self.generation
}
pub fn old_root(&self) -> &Path {
&self.old_root
}
pub fn new_root(&self) -> &Path {
&self.new_root
}
pub fn resumed(&self) -> bool {
self.resumed
}
pub fn authoritative(&self, settlement: Settlement) -> &Path {
match settlement {
Settlement::Committed => &self.new_root,
Settlement::Aborted => &self.old_root,
}
}
}
pub trait CutoverParticipant: Send {
fn name(&self) -> &str;
fn owned_paths(&self) -> Vec<PathBuf> {
Vec::new()
}
fn prepare(&mut self, _cx: &CutoverContext) -> Result<(), ParticipantError> {
Ok(())
}
fn quiesce(&mut self, _cx: &CutoverContext) -> Result<(), ParticipantError> {
Ok(())
}
fn relocate(&mut self, _cx: &CutoverContext) -> Result<(), ParticipantError> {
Ok(())
}
fn reopen(&mut self, _cx: &CutoverContext) -> Result<(), ParticipantError> {
Ok(())
}
fn settle(
&mut self,
_cx: &CutoverContext,
_settlement: Settlement,
) -> Result<(), ParticipantError> {
Ok(())
}
fn recover_completed(&mut self, _cx: &CutoverContext) -> Result<(), ParticipantError> {
Ok(())
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub enum FsMove {
#[default]
None,
Tree(MigrateOptions),
Companions(Vec<PathBuf>),
}
impl FsMove {
fn label(&self) -> &'static str {
match self {
FsMove::None => "none",
FsMove::Tree(_) => "tree",
FsMove::Companions(_) => "companions",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CutoverPhase {
Copying,
Switched,
Completed,
Aborted,
}
impl CutoverPhase {
pub fn as_str(self) -> &'static str {
match self {
CutoverPhase::Copying => "copying",
CutoverPhase::Switched => "switched",
CutoverPhase::Completed => "completed",
CutoverPhase::Aborted => "aborted",
}
}
fn parse(text: &str) -> Option<Self> {
Some(match text {
"copying" => CutoverPhase::Copying,
"switched" => CutoverPhase::Switched,
"completed" => CutoverPhase::Completed,
"aborted" => CutoverPhase::Aborted,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RootSelection {
Old,
New,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RootStatus {
pub id: String,
pub generation: u64,
pub phase: CutoverPhase,
pub selected: RootSelection,
pub old_root: PathBuf,
pub new_root: PathBuf,
pub participants: Vec<String>,
pub error: Option<String>,
pub retired: Option<PathBuf>,
pub settlement_complete: bool,
}
impl RootStatus {
pub fn selected_root(&self) -> &Path {
match self.selected {
RootSelection::Old => &self.old_root,
RootSelection::New => &self.new_root,
}
}
pub fn needs_recovery(&self) -> bool {
self.phase == CutoverPhase::Switched
|| (self.phase == CutoverPhase::Completed && !self.settlement_complete)
}
}
#[derive(Debug, Clone)]
struct Record {
id: String,
generation: u64,
phase: CutoverPhase,
old_root: PathBuf,
new_root: PathBuf,
old_key: FileKey,
new_key: FileKey,
participants: Vec<String>,
fs_move: String,
error: Option<String>,
retired: Option<PathBuf>,
settlement_complete: bool,
tree_selection: TreeSelection,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum TreeSelection {
All,
Pending,
Roots(Vec<PathBuf>),
}
impl TreeSelection {
fn json(&self) -> Value {
match self {
Self::All => json!({"mode": "all"}),
Self::Pending => json!({"mode": "pending"}),
Self::Roots(roots) => json!({"mode": "roots", "roots": roots}),
}
}
fn parse(value: Option<&Value>) -> Result<Self, CutoverError> {
let Some(value) = value else {
return Ok(Self::All);
};
let bad = || CutoverError::RecordUnreadable("invalid tree selection".into());
match value.get("mode").and_then(Value::as_str) {
Some("all") => Ok(Self::All),
Some("pending") => Ok(Self::Pending),
Some("roots") => {
let roots: Vec<PathBuf> =
serde_json::from_value(value.get("roots").cloned().ok_or_else(bad)?)
.map_err(|_| bad())?;
if normalize_roots(roots.clone()).map_err(CutoverError::Migrate)? != roots {
return Err(bad());
}
Ok(Self::Roots(roots))
}
_ => Err(bad()),
}
}
}
fn key_json(key: FileKey) -> Value {
json!({ "volume": key.volume, "index": key.index })
}
fn key_from(value: &Value) -> Option<FileKey> {
Some(FileKey {
volume: value.get("volume")?.as_u64()?,
index: value.get("index")?.as_u64()?,
})
}
impl Record {
fn to_json(&self) -> Value {
json!({
"version": if matches!(self.tree_selection, TreeSelection::All) { RECORD_VERSION } else { SELECTED_RECORD_VERSION },
"id": self.id,
"generation": self.generation,
"phase": self.phase.as_str(),
"old_root": self.old_root.to_str(),
"new_root": self.new_root.to_str(),
"old_key": key_json(self.old_key),
"new_key": key_json(self.new_key),
"participants": self.participants,
"fs_move": self.fs_move,
"error": self.error,
"retired": self.retired.as_ref().and_then(|p| p.to_str()),
"settlement_complete": self.settlement_complete,
"tree_selection": self.tree_selection.json(),
})
}
fn parse(text: &str) -> Result<Self, CutoverError> {
let bad = |why: &str| CutoverError::RecordUnreadable(why.to_string());
let v: Value = serde_json::from_str(text).map_err(|e| bad(&e.to_string()))?;
let version = v.get("version").and_then(Value::as_u64);
match version {
Some(RECORD_VERSION) => {}
Some(SELECTED_RECORD_VERSION) => {}
Some(n) if n > SELECTED_RECORD_VERSION => {
return Err(bad("the record was written by a newer version"))
}
_ => return Err(bad("missing or unknown record version")),
}
let text_of = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
let record = Record {
id: text_of("id").ok_or_else(|| bad("no id"))?,
generation: v
.get("generation")
.and_then(Value::as_u64)
.ok_or_else(|| bad("no generation"))?,
phase: text_of("phase")
.and_then(|p| CutoverPhase::parse(&p))
.ok_or_else(|| bad("unknown phase"))?,
old_root: text_of("old_root")
.map(PathBuf::from)
.ok_or_else(|| bad("no old_root"))?,
new_root: text_of("new_root")
.map(PathBuf::from)
.ok_or_else(|| bad("no new_root"))?,
old_key: v
.get("old_key")
.and_then(key_from)
.ok_or_else(|| bad("no old_key"))?,
new_key: v
.get("new_key")
.and_then(key_from)
.ok_or_else(|| bad("no new_key"))?,
participants: v
.get("participants")
.and_then(Value::as_array)
.and_then(|a| {
a.iter()
.map(|n| n.as_str().map(str::to_string))
.collect::<Option<Vec<_>>>()
})
.ok_or_else(|| bad("invalid participant list"))?,
fs_move: text_of("fs_move").ok_or_else(|| bad("no fs_move"))?,
error: text_of("error"),
retired: text_of("retired").map(PathBuf::from),
settlement_complete: match v.get("settlement_complete") {
None => false,
Some(value) => value
.as_bool()
.ok_or_else(|| bad("invalid settlement_complete"))?,
},
tree_selection: TreeSelection::parse(v.get("tree_selection"))?,
};
if version == Some(SELECTED_RECORD_VERSION)
&& matches!(record.tree_selection, TreeSelection::All)
{
return Err(bad("selected record requires explicit scope"));
}
if version == Some(RECORD_VERSION) && !matches!(record.tree_selection, TreeSelection::All) {
return Err(bad("legacy record cannot carry selected scope"));
}
if let TreeSelection::Roots(roots) = &record.tree_selection {
validate_cutover_roots(roots)?;
}
let mut names = BTreeSet::new();
if record.id.len() != 16
|| !record.id.bytes().all(|b| b.is_ascii_hexdigit())
|| record.generation == 0
|| !matches!(record.fs_move.as_str(), "none" | "tree" | "companions")
|| record
.participants
.iter()
.any(|name| !valid_participant_name(name) || !names.insert(name))
{
return Err(bad("invalid cutover identity, generation or participants"));
}
if (record.fs_move != "tree" && !matches!(record.tree_selection, TreeSelection::All))
|| (matches!(
record.phase,
CutoverPhase::Switched | CutoverPhase::Completed
) && matches!(record.tree_selection, TreeSelection::Pending))
{
return Err(bad("tree selection inconsistent with phase or file move"));
}
for root in [&record.old_root, &record.new_root] {
if !root.is_absolute() || root.components().any(|c| c == Component::ParentDir) {
return Err(bad("invalid root path"));
}
}
if record.old_root.starts_with(&record.new_root)
|| record.new_root.starts_with(&record.old_root)
{
return Err(bad("overlapping roots"));
}
Ok(record)
}
fn status(&self) -> RootStatus {
RootStatus {
id: self.id.clone(),
generation: self.generation,
phase: self.phase,
selected: match self.phase {
CutoverPhase::Switched | CutoverPhase::Completed => RootSelection::New,
CutoverPhase::Copying | CutoverPhase::Aborted => RootSelection::Old,
},
old_root: self.old_root.clone(),
new_root: self.new_root.clone(),
participants: self.participants.clone(),
error: self.error.clone(),
retired: self.retired.clone(),
settlement_complete: self.settlement_complete,
}
}
}
fn read_record(new_root: &Path) -> Result<Option<Record>, CutoverError> {
let path = new_root.join(CUTOVER_RECORD);
match fs::symlink_metadata(&path) {
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
Ok(_) => {}
}
let mut file = rightkit_fs::open_read_nofollow(&path, &rightkit_fs::ReadOptions::new())?;
let mut text = String::new();
file.read_to_string(&mut text)?;
let record = Record::parse(&text)?;
let actual = fs::canonicalize(new_root)?;
if record.new_root != actual {
return Err(CutoverError::RecordMismatch(
"record names another destination".into(),
));
}
check_directory(&actual, record.new_key)?;
Ok(Some(record))
}
fn write_record(new_root: &Path, record: &Record) -> io::Result<()> {
write_json_atomic(&new_root.join(CUTOVER_RECORD), &record.to_json())?;
try_sync_dir(new_root)
}
#[derive(Debug)]
#[non_exhaustive]
pub enum CutoverError {
InvalidRoot {
path: PathBuf,
reason: &'static str,
},
BadParticipant(String),
BadCompanion(PathBuf),
OwnedPathConflict {
participant: String,
path: PathBuf,
},
Busy(PathBuf),
Collision(PathBuf),
PathSubstituted(PathBuf),
RecordMismatch(String),
RecordUnreadable(String),
PendingTransaction(String),
Participant {
name: String,
step: CutoverStep,
error: ParticipantError,
},
AbortIncomplete {
cause: Box<CutoverError>,
failures: Vec<(String, ParticipantError)>,
},
ReopenPending {
name: String,
error: ParticipantError,
generation: u64,
},
SettlementPending {
generation: u64,
failures: Vec<(String, ParticipantError)>,
},
Migrate(AppRootError),
Fs(FsError),
FsRecoveryRequired(String),
Undecided(io::Error),
NotCompleted(CutoverPhase),
Io(io::Error),
}
impl fmt::Display for CutoverError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CutoverError::InvalidRoot { path, reason } => {
write!(f, "root cutover: {}: {reason}", path.display())
}
CutoverError::BadParticipant(n) => {
write!(f, "root cutover: participant {n:?} is not usable")
}
CutoverError::BadCompanion(p) => write!(
f,
"root cutover: companion {} is not a plain relative path to a regular file",
p.display()
),
CutoverError::OwnedPathConflict { participant, path } => write!(
f,
"root cutover: {} lies on a path participant {participant} owns",
path.display()
),
CutoverError::Busy(p) => {
write!(f, "root cutover: another cutover holds {}", p.display())
}
CutoverError::Collision(p) => write!(
f,
"root cutover: {} holds different content; nothing was overwritten",
p.display()
),
CutoverError::PathSubstituted(p) => write!(
f,
"root cutover: {} is no longer the directory that was recorded",
p.display()
),
CutoverError::RecordMismatch(why) => write!(f, "root cutover: record mismatch: {why}"),
CutoverError::RecordUnreadable(why) => {
write!(f, "root cutover: cutover record unreadable: {why}")
}
CutoverError::PendingTransaction(why) => {
write!(f, "root cutover: an earlier file transaction is pending: {why}")
}
CutoverError::Participant { name, step, error } => write!(
f,
"root cutover: participant {name} failed to {}: {error}",
step.as_str()
),
CutoverError::AbortIncomplete { cause, failures } => {
write!(f, "{cause}; {} participant(s) did not resume", failures.len())
}
CutoverError::ReopenPending {
name,
error,
generation,
} => write!(
f,
"root cutover: generation {generation} is switched but participant {name} could not reopen: {error}; run recover"
),
CutoverError::Migrate(e) => write!(f, "root cutover: tree migration: {e}"),
CutoverError::SettlementPending { generation, failures } => write!(f,
"root cutover: generation {generation} selected new root; {} participant cleanup failures; run recover", failures.len()),
CutoverError::Fs(e) => write!(f, "root cutover: {e}"),
CutoverError::FsRecoveryRequired(why) => {
write!(f, "root cutover: file transaction needs recovery: {why}")
}
CutoverError::Undecided(e) => write!(
f,
"root cutover: the switch record could not be confirmed ({e}); run recover"
),
CutoverError::NotCompleted(p) => {
write!(f, "root cutover: the cutover is {}, not completed", p.as_str())
}
CutoverError::Io(e) => write!(f, "root cutover: {e}"),
}
}
}
impl std::error::Error for CutoverError {}
impl From<io::Error> for CutoverError {
fn from(e: io::Error) -> Self {
CutoverError::Io(e)
}
}
impl From<FsError> for CutoverError {
fn from(e: FsError) -> Self {
CutoverError::Fs(e)
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct CutoverReport {
pub id: String,
pub generation: u64,
pub selected: RootSelection,
pub migration: Option<MigrationReport>,
pub companions: Vec<PathBuf>,
pub resumed: bool,
pub already_completed: bool,
pub settle_warnings: Vec<(String, ParticipantError)>,
}
impl CutoverReport {
fn new(record: &Record) -> Self {
Self {
id: record.id.clone(),
generation: record.generation,
selected: RootSelection::New,
migration: None,
companions: Vec::new(),
resumed: false,
already_completed: false,
settle_warnings: Vec::new(),
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum RecoverOutcome {
Nothing,
Resumed(Box<CutoverReport>),
Completed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RetireMode {
RenameAside,
Trash,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Retired {
pub path: PathBuf,
pub already: bool,
}
struct Plan {
old: PathBuf,
new: PathBuf,
old_key: FileKey,
new_key: FileKey,
}
type TreeRootsResolver = dyn Fn(&CutoverContext) -> Result<Vec<PathBuf>, ParticipantError> + Send;
pub struct RootCutover {
app: AppRoot,
old_root: PathBuf,
fs_move: FsMove,
participants: Vec<Box<dyn CutoverParticipant>>,
tree_roots: Option<Box<TreeRootsResolver>>,
}
fn valid_participant_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
fn plain_relative(path: &Path) -> bool {
path.components().count() > 0 && path.components().all(|c| matches!(c, Component::Normal(_)))
}
fn validate_cutover_roots(roots: &[PathBuf]) -> Result<(), CutoverError> {
for root in roots {
if [CUTOVER_RECORD, CUTOVER_LOCK, TXN_AREA]
.iter()
.any(|reserved| roots_overlap(root, Path::new(reserved)))
{
return Err(CutoverError::BadCompanion(root.clone()));
}
}
Ok(())
}
fn new_id() -> Result<String, CutoverError> {
let mut raw = [0u8; 8];
getrandom::fill(&mut raw).map_err(|e| CutoverError::Io(io::Error::other(e.to_string())))?;
Ok(raw.iter().map(|b| format!("{b:02x}")).collect())
}
fn take_lock(new_root: &Path) -> Result<fs::File, CutoverError> {
let path = new_root.join(CUTOVER_LOCK);
match try_lock_exclusive(&path) {
Ok(Some(file)) => Ok(file),
Ok(None) => Err(CutoverError::Busy(path)),
Err(e) => Err(CutoverError::Io(e)),
}
}
fn resolve_transactions(new_root: &Path) -> Result<(), CutoverError> {
let report = recover_transactions(new_root).map_err(CutoverError::Fs)?;
if report.is_clean() {
Ok(())
} else {
Err(CutoverError::PendingTransaction(format!(
"{} transaction(s) retained under {}",
report.transactions.len(),
new_root.join(TXN_AREA).display()
)))
}
}
fn check_directory(path: &Path, key: FileKey) -> Result<(), CutoverError> {
let substituted = || CutoverError::PathSubstituted(path.to_path_buf());
let md = fs::symlink_metadata(path).map_err(|_| substituted())?;
if !real_directory(&md) {
return Err(substituted());
}
match FileKey::of_path(path) {
Ok(now) if now == key => Ok(()),
_ => Err(substituted()),
}
}
fn real_directory(metadata: &fs::Metadata) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
if metadata.file_attributes() & 0x400 != 0 {
return false;
}
}
metadata.file_type().is_dir()
}
fn panic_text(payload: Box<dyn std::any::Any + Send>) -> String {
payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".to_string())
}
impl RootCutover {
pub fn new(app: AppRoot, old_root: impl Into<PathBuf>) -> Self {
Self {
app,
old_root: old_root.into(),
fs_move: FsMove::None,
participants: Vec::new(),
tree_roots: None,
}
}
pub fn fs_move(mut self, fs_move: FsMove) -> Self {
self.fs_move = fs_move;
self
}
pub fn tree_roots(
mut self,
resolver: impl Fn(&CutoverContext) -> Result<Vec<PathBuf>, ParticipantError> + Send + 'static,
) -> Self {
self.tree_roots = Some(Box::new(resolver));
self
}
pub fn participant(mut self, participant: impl CutoverParticipant + 'static) -> Self {
self.participants.push(Box::new(participant));
self
}
pub fn status(app: &AppRoot) -> Result<Option<RootStatus>, CutoverError> {
let Some(record) = read_record(app.root())? else {
return Ok(None);
};
if matches!(record.phase, CutoverPhase::Copying | CutoverPhase::Aborted) {
check_directory(&record.old_root, record.old_key)?;
}
Ok(Some(record.status()))
}
pub fn selected_root(app: &AppRoot, legacy: &Path) -> Result<PathBuf, CutoverError> {
Ok(match read_record(app.root())? {
None => legacy.to_path_buf(),
Some(record) => {
let (path, key) = match record.phase {
CutoverPhase::Switched | CutoverPhase::Completed => {
(&record.new_root, record.new_key)
}
CutoverPhase::Copying | CutoverPhase::Aborted => {
(&record.old_root, record.old_key)
}
};
check_directory(path, key)?;
path.clone()
}
})
}
fn validate_resume(&self, record: &Record) -> Result<(), CutoverError> {
let names: Vec<_> = self
.participants
.iter()
.map(|p| p.name().to_string())
.collect();
if names != record.participants
|| (matches!(record.phase, CutoverPhase::Copying | CutoverPhase::Aborted)
&& self.fs_move.label() != record.fs_move)
{
return Err(CutoverError::RecordMismatch(
"participant order or file-move mode changed".into(),
));
}
if matches!(record.phase, CutoverPhase::Copying | CutoverPhase::Aborted)
&& matches!(self.requested_selection()?, TreeSelection::All)
!= matches!(record.tree_selection, TreeSelection::All)
{
return Err(CutoverError::RecordMismatch(
"tree selection mode changed".into(),
));
}
if record.retired.is_none() {
let old = fs::canonicalize(&self.old_root)?;
if old != record.old_root {
return Err(CutoverError::RecordMismatch("old root changed".into()));
}
check_directory(&old, record.old_key)?;
}
Ok(())
}
fn requested_selection(&self) -> Result<TreeSelection, CutoverError> {
match (&self.fs_move, &self.tree_roots) {
(FsMove::Tree(options), Some(_)) if options.included.is_none() => {
Ok(TreeSelection::Pending)
}
(_, Some(_)) => Err(CutoverError::RecordMismatch(
"tree_roots requires Tree without static include_roots".into(),
)),
(FsMove::Tree(options), None) => {
if let Some(roots) = &options.included {
self.validate_selected_roots(roots)?;
Ok(TreeSelection::Roots(roots.clone()))
} else {
Ok(TreeSelection::All)
}
}
_ => Ok(TreeSelection::All),
}
}
fn validate_selected_roots(&self, roots: &[PathBuf]) -> Result<(), CutoverError> {
validate_cutover_roots(roots)?;
for root in roots {
for participant in &self.participants {
if participant
.owned_paths()
.iter()
.any(|owned| roots_overlap(root, owned))
{
return Err(CutoverError::OwnedPathConflict {
participant: participant.name().into(),
path: root.clone(),
});
}
}
}
Ok(())
}
fn plan_new(&self) -> Result<(PathBuf, FileKey), CutoverError> {
if !real_directory(&fs::symlink_metadata(self.app.root())?) {
return Err(CutoverError::InvalidRoot {
path: self.app.root().to_path_buf(),
reason: "the new root must be a real directory, not a link or reparse point",
});
}
let new = fs::canonicalize(self.app.root()).map_err(CutoverError::Io)?;
if new.to_str().is_none() {
return Err(CutoverError::InvalidRoot {
path: new,
reason: "the new root path is not UTF-8",
});
}
let key = FileKey::of_path(&new).map_err(CutoverError::Fs)?;
Ok((new, key))
}
fn plan_old(&self, new: PathBuf, new_key: FileKey) -> Result<Plan, CutoverError> {
let mut seen = BTreeSet::new();
for p in &self.participants {
let name = p.name();
if !valid_participant_name(name) || !seen.insert(name.to_string()) {
return Err(CutoverError::BadParticipant(name.to_string()));
}
if p.owned_paths().iter().any(|o| !plain_relative(o)) {
return Err(CutoverError::BadParticipant(name.to_string()));
}
}
if let FsMove::Companions(rels) = &self.fs_move {
for rel in rels {
if !plain_relative(rel) {
return Err(CutoverError::BadCompanion(rel.clone()));
}
for p in &self.participants {
if p.owned_paths()
.iter()
.any(|o| rel.starts_with(o) || o.starts_with(rel))
{
return Err(CutoverError::OwnedPathConflict {
participant: p.name().to_string(),
path: rel.clone(),
});
}
}
}
}
let raw = &self.old_root;
let invalid = |reason: &'static str| CutoverError::InvalidRoot {
path: raw.clone(),
reason,
};
if !raw.is_absolute() || raw.components().any(|c| c == Component::ParentDir) {
return Err(invalid(
"the old root must be an absolute path without '..'",
));
}
let md = fs::symlink_metadata(raw).map_err(|_| invalid("the old root does not exist"))?;
if !real_directory(&md) {
return Err(invalid("the old root must be a real directory, not a link"));
}
let old = fs::canonicalize(raw).map_err(CutoverError::Io)?;
if old.to_str().is_none() {
return Err(invalid("the old root path is not UTF-8"));
}
if old.starts_with(&new) || new.starts_with(&old) {
return Err(invalid("the old and new roots are the same or nested"));
}
let old_key = FileKey::of_path(&old).map_err(CutoverError::Fs)?;
Ok(Plan {
old,
new,
old_key,
new_key,
})
}
pub fn run(&mut self) -> Result<CutoverReport, CutoverError> {
let (new, new_key) = self.plan_new()?;
let _lock = take_lock(&new)?;
resolve_transactions(&new)?;
let mut generation = 1;
let mut resumed = false;
let mut selection = self.requested_selection()?;
if let Some(record) = read_record(&new)? {
self.validate_resume(&record)?;
if record.new_root != new {
return Err(CutoverError::RecordMismatch(format!(
"the record names {} as the new root",
record.new_root.display()
)));
}
match record.phase {
CutoverPhase::Completed => {
check_directory(&new, record.new_key)?;
let mut report = CutoverReport::new(&record);
report.already_completed = true;
self.complete_settlement(&new, record, true)?;
return Ok(report);
}
CutoverPhase::Switched => {
check_directory(&new, record.new_key)?;
let mut report = CutoverReport::new(&record);
report.resumed = true;
return self.finish(&new, record, true, report);
}
CutoverPhase::Copying | CutoverPhase::Aborted => {
check_directory(&new, record.new_key)?;
if let TreeSelection::Roots(roots) = record.tree_selection.clone() {
selection = TreeSelection::Roots(roots);
}
generation = record.generation.checked_add(1).ok_or_else(|| {
CutoverError::RecordUnreadable("generation exhausted".into())
})?;
resumed = true;
}
}
}
let plan = self.plan_old(new, new_key)?;
let mut record = Record {
id: new_id()?,
generation,
phase: CutoverPhase::Copying,
old_root: plan.old.clone(),
new_root: plan.new.clone(),
old_key: plan.old_key,
new_key: plan.new_key,
participants: self
.participants
.iter()
.map(|p| p.name().to_string())
.collect(),
fs_move: self.fs_move.label().to_string(),
error: None,
retired: None,
settlement_complete: false,
tree_selection: selection,
};
write_record(&plan.new, &record)?;
let cx = CutoverContext {
id: record.id.clone(),
generation,
old_root: plan.old.clone(),
new_root: plan.new.clone(),
resumed,
};
let mut report = CutoverReport::new(&record);
if let Err((prepared, cause)) = self.before_switch(&plan, &cx, &mut record, &mut report) {
return Err(self.abort(&plan, record, &cx, prepared, cause));
}
let copying = record.clone();
record.phase = CutoverPhase::Switched;
if let Err(e) = write_record(&plan.new, &record) {
match read_record(&plan.new) {
Ok(Some(on_disk)) if on_disk.id == record.id => match on_disk.phase {
CutoverPhase::Switched => {}
CutoverPhase::Copying => {
let prepared = self.participants.len();
return Err(self.abort(&plan, copying, &cx, prepared, CutoverError::Io(e)));
}
_ => return Err(CutoverError::Undecided(e)),
},
_ => return Err(CutoverError::Undecided(e)),
}
}
self.finish(&plan.new, record, false, report)
}
pub fn recover(&mut self) -> Result<RecoverOutcome, CutoverError> {
let (new, _) = self.plan_new()?;
let _lock = take_lock(&new)?;
resolve_transactions(&new)?;
let Some(record) = read_record(&new)? else {
return Ok(RecoverOutcome::Nothing);
};
self.validate_resume(&record)?;
if record.new_root != new {
return Err(CutoverError::RecordMismatch(format!(
"the record names {} as the new root",
record.new_root.display()
)));
}
match record.phase {
CutoverPhase::Switched => {
check_directory(&new, record.new_key)?;
let mut report = CutoverReport::new(&record);
report.resumed = true;
self.finish(&new, record, true, report)
.map(|done| RecoverOutcome::Resumed(Box::new(done)))
}
CutoverPhase::Completed => {
check_directory(&new, record.new_key)?;
self.complete_settlement(&new, record, true)?;
Ok(RecoverOutcome::Completed)
}
CutoverPhase::Copying | CutoverPhase::Aborted => Ok(RecoverOutcome::Nothing),
}
}
fn before_switch(
&mut self,
plan: &Plan,
cx: &CutoverContext,
record: &mut Record,
report: &mut CutoverReport,
) -> Result<(), (usize, CutoverError)> {
let n = self.participants.len();
let mut prepared = 0;
for i in 0..n {
self.hook(i, CutoverStep::Prepare, cx)
.map_err(|e| (prepared, e))?;
prepared += 1;
}
for i in 0..n {
self.hook(i, CutoverStep::Quiesce, cx)
.map_err(|e| (prepared, e))?;
}
self.resolve_tree_roots(plan, cx, record)
.map_err(|e| (prepared, e))?;
for i in 0..n {
self.hook(i, CutoverStep::Relocate, cx)
.map_err(|e| (prepared, e))?;
}
check_directory(&plan.old, plan.old_key).map_err(|e| (prepared, e))?;
check_directory(&plan.new, plan.new_key).map_err(|e| (prepared, e))?;
self.move_files(plan, &record.tree_selection, report)
.map_err(|e| (prepared, e))?;
check_directory(&plan.old, plan.old_key).map_err(|e| (prepared, e))?;
check_directory(&plan.new, plan.new_key).map_err(|e| (prepared, e))?;
Ok(())
}
fn resolve_tree_roots(
&self,
plan: &Plan,
cx: &CutoverContext,
record: &mut Record,
) -> Result<(), CutoverError> {
let current = if let Some(resolve) = &self.tree_roots {
match catch_unwind(AssertUnwindSafe(|| resolve(cx))) {
Ok(Ok(roots)) => Some(roots),
Ok(Err(error)) => {
return Err(CutoverError::Participant {
name: "tree-roots".into(),
step: CutoverStep::Quiesce,
error,
})
}
Err(payload) => {
return Err(CutoverError::Participant {
name: "tree-roots".into(),
step: CutoverStep::Quiesce,
error: ParticipantError::new(format!("panicked: {}", panic_text(payload))),
})
}
}
} else if let FsMove::Tree(options) = &self.fs_move {
options.included.clone()
} else {
None
};
if let Some(mut roots) = current {
if let TreeSelection::Roots(previous) = &record.tree_selection {
roots.extend(previous.iter().cloned());
}
let roots = normalize_roots(roots).map_err(CutoverError::Migrate)?;
self.validate_selected_roots(&roots)?;
check_directory(&plan.old, plan.old_key)?;
check_directory(&plan.new, plan.new_key)?;
record.tree_selection = TreeSelection::Roots(roots);
write_record(&plan.new, record)?;
}
Ok(())
}
fn abort(
&mut self,
plan: &Plan,
mut record: Record,
cx: &CutoverContext,
prepared: usize,
cause: CutoverError,
) -> CutoverError {
let mut failures = Vec::new();
for i in (0..prepared).rev() {
if let Err(CutoverError::Participant { name, error, .. }) =
self.settle_hook(i, cx, Settlement::Aborted)
{
failures.push((name, error));
}
}
record.phase = CutoverPhase::Aborted;
record.error = Some(cause.to_string());
if check_directory(&plan.new, plan.new_key).is_ok() {
let _ = write_record(&plan.new, &record);
}
if failures.is_empty() {
cause
} else {
CutoverError::AbortIncomplete {
cause: Box::new(cause),
failures,
}
}
}
fn finish(
&mut self,
new: &Path,
mut record: Record,
resumed: bool,
mut report: CutoverReport,
) -> Result<CutoverReport, CutoverError> {
let cx = CutoverContext {
id: record.id.clone(),
generation: record.generation,
old_root: record.old_root.clone(),
new_root: record.new_root.clone(),
resumed,
};
for i in 0..self.participants.len() {
if let Err(e) = self.hook(i, CutoverStep::Reopen, &cx) {
return Err(match e {
CutoverError::Participant { name, error, .. } => CutoverError::ReopenPending {
name,
error,
generation: record.generation,
},
other => other,
});
}
}
record.phase = CutoverPhase::Completed;
record.error = None;
record.settlement_complete = false;
write_record(new, &record)?;
self.complete_settlement(new, record, false)?;
report.resumed = report.resumed || resumed;
Ok(report)
}
fn complete_settlement(
&mut self,
new: &Path,
mut record: Record,
replay: bool,
) -> Result<(), CutoverError> {
if record.settlement_complete {
return Ok(());
}
let cx = CutoverContext {
id: record.id.clone(),
generation: record.generation,
old_root: record.old_root.clone(),
new_root: record.new_root.clone(),
resumed: replay,
};
let mut failures = Vec::new();
for i in 0..self.participants.len() {
let result = if replay {
let participant = &mut self.participants[i];
let name = participant.name().to_string();
let result = catch_unwind(AssertUnwindSafe(|| participant.recover_completed(&cx)));
Self::hook_result(name, CutoverStep::Settle, result)
} else {
self.settle_hook(i, &cx, Settlement::Committed)
};
if let Err(CutoverError::Participant { name, error, .. }) = result {
failures.push((name, error));
}
}
if !failures.is_empty() {
return Err(CutoverError::SettlementPending {
generation: record.generation,
failures,
});
}
record.settlement_complete = true;
write_record(new, &record).map_err(|error| CutoverError::SettlementPending {
generation: record.generation,
failures: vec![(
"settlement-record".into(),
ParticipantError::new(format!(
"participant cleanup finished but durable acknowledgement write failed: {error}"
)),
)],
})?;
Ok(())
}
fn hook(
&mut self,
index: usize,
step: CutoverStep,
cx: &CutoverContext,
) -> Result<(), CutoverError> {
let participant = &mut self.participants[index];
let name = participant.name().to_string();
let result = catch_unwind(AssertUnwindSafe(|| match step {
CutoverStep::Prepare => participant.prepare(cx),
CutoverStep::Quiesce => participant.quiesce(cx),
CutoverStep::Relocate => participant.relocate(cx),
CutoverStep::Reopen => participant.reopen(cx),
CutoverStep::Settle => Ok(()),
}));
Self::hook_result(name, step, result)
}
fn settle_hook(
&mut self,
index: usize,
cx: &CutoverContext,
settlement: Settlement,
) -> Result<(), CutoverError> {
let participant = &mut self.participants[index];
let name = participant.name().to_string();
let result = catch_unwind(AssertUnwindSafe(|| participant.settle(cx, settlement)));
Self::hook_result(name, CutoverStep::Settle, result)
}
fn hook_result(
name: String,
step: CutoverStep,
result: std::thread::Result<Result<(), ParticipantError>>,
) -> Result<(), CutoverError> {
match result {
Ok(Ok(())) => Ok(()),
Ok(Err(error)) => Err(CutoverError::Participant { name, step, error }),
Err(payload) => {
let error = ParticipantError::new(format!("panicked: {}", panic_text(payload)));
Err(CutoverError::Participant { name, step, error })
}
}
}
fn move_files(
&self,
plan: &Plan,
selection: &TreeSelection,
report: &mut CutoverReport,
) -> Result<(), CutoverError> {
match &self.fs_move {
FsMove::None => Ok(()),
FsMove::Tree(options) => {
let mut options = options
.clone()
.exclude(CUTOVER_RECORD)
.exclude(CUTOVER_LOCK)
.exclude(TXN_AREA);
if let TreeSelection::Roots(roots) = selection {
options = options
.include_roots(roots.clone())
.map_err(CutoverError::Migrate)?;
} else if matches!(selection, TreeSelection::Pending) {
return Err(CutoverError::RecordMismatch(
"tree selection unresolved".into(),
));
}
for participant in &self.participants {
for owned in participant.owned_paths() {
options = options.exclude(owned);
}
}
let migrated = self
.app
.migrate_for_cutover(&plan.old, &options)
.map_err(CutoverError::Migrate)?;
if !migrated.state().is_usable() {
return Err(CutoverError::Migrate(AppRootError::VerifyFailed(
plan.old.clone(),
)));
}
report.migration = Some(migrated);
Ok(())
}
FsMove::Companions(rels) => self.move_companions(plan, rels, report),
}
}
fn move_companions(
&self,
plan: &Plan,
rels: &[PathBuf],
report: &mut CutoverReport,
) -> Result<(), CutoverError> {
let mut entries = Vec::new();
let mut pairs = Vec::new();
for rel in rels {
let src = plan.old.join(rel);
let md =
fs::symlink_metadata(&src).map_err(|_| CutoverError::BadCompanion(rel.clone()))?;
if !md.is_file() {
return Err(CutoverError::BadCompanion(rel.clone()));
}
let dst = self.app.path(rel).map_err(CutoverError::Migrate)?;
match fs::symlink_metadata(&dst) {
Ok(existing) if existing.is_file() => {
if !same_content(&src, &dst).map_err(CutoverError::Migrate)? {
return Err(CutoverError::Collision(dst));
}
}
Ok(_) => return Err(CutoverError::Collision(dst)),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
entries.push(TransactionEntry::create_new(rel.clone(), src.clone()));
}
Err(e) => return Err(CutoverError::Io(e)),
}
pairs.push((src, dst));
}
if !entries.is_empty() {
let prepared =
FileTransaction::prepare(&plan.new, entries, TransactionOptions::default())
.map_err(CutoverError::Fs)?;
match prepared.commit().map_err(CutoverError::Fs)? {
TransactionReport::Committed(_) => {}
TransactionReport::RolledBack(undone) => {
return Err(CutoverError::Fs(undone.cause));
}
TransactionReport::RecoveryRequired(stuck) => {
return Err(CutoverError::FsRecoveryRequired(format!(
"transaction {} retained {} item(s): {}",
stuck.id,
stuck.retained.len(),
stuck.cause
)));
}
_ => {
return Err(CutoverError::FsRecoveryRequired(
"unrecognised transaction report".to_string(),
));
}
}
}
for (src, dst) in &pairs {
if !same_content(src, dst).map_err(CutoverError::Migrate)? {
return Err(CutoverError::Collision(dst.clone()));
}
}
report.companions = pairs.into_iter().map(|(_, dst)| dst).collect();
Ok(())
}
pub fn retire_old_root(app: &AppRoot, mode: RetireMode) -> Result<Retired, CutoverError> {
let new = fs::canonicalize(app.root()).map_err(CutoverError::Io)?;
let _lock = take_lock(&new)?;
let Some(mut record) = read_record(&new)? else {
return Err(CutoverError::RecordUnreadable(
"no cutover record at this root".to_string(),
));
};
if record.phase != CutoverPhase::Completed {
return Err(CutoverError::NotCompleted(record.phase));
}
if !record.settlement_complete {
return Err(CutoverError::SettlementPending {
generation: record.generation,
failures: vec![(
"settlement".into(),
ParticipantError::new("participant cleanup is not durably complete"),
)],
});
}
if record.new_root != new {
return Err(CutoverError::RecordMismatch(format!(
"the record names {} as the new root",
record.new_root.display()
)));
}
check_directory(&new, record.new_key)?;
if let Some(path) = record.retired.clone() {
check_directory(&path, record.old_key)?;
return Ok(Retired {
path,
already: true,
});
}
let old = record.old_root.clone();
if old.starts_with(&new) || new.starts_with(&old) {
return Err(CutoverError::InvalidRoot {
path: old,
reason: "the old and new roots are the same or nested",
});
}
let aside = {
let mut name = old.as_os_str().to_os_string();
name.push(format!(".rk-retired-{}", record.id));
PathBuf::from(name)
};
match fs::symlink_metadata(&old) {
Err(e) if e.kind() == io::ErrorKind::NotFound => {
check_directory(&aside, record.old_key)?;
record.retired = Some(aside.clone());
write_record(&new, &record)?;
return Ok(Retired {
path: aside,
already: true,
});
}
Err(e) => return Err(CutoverError::Io(e)),
Ok(_) => {}
}
check_directory(&old, record.old_key)?;
let landed = match mode {
RetireMode::RenameAside => {
if fs::symlink_metadata(&aside).is_ok() {
return Err(CutoverError::Collision(aside));
}
rightkit_fs::rename_exclusive(&old, &aside).map_err(|error| match error {
FsError::AlreadyExists(_) => CutoverError::Collision(aside.clone()),
other => CutoverError::Fs(other),
})?;
aside
}
RetireMode::Trash => {
let trashed = rightkit_fs::move_to_trash(&old).map_err(CutoverError::Fs)?;
trashed.trashed_as.unwrap_or(trashed.original)
}
};
record.retired = Some(landed.clone());
write_record(&new, &record)?;
Ok(Retired {
path: landed,
already: false,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Suite;
#[test]
fn abort_does_not_write_into_substituted_destination() {
let temporary = tempfile::tempdir().unwrap();
let old = temporary.path().join("old");
fs::create_dir(&old).unwrap();
fs::write(old.join("startup.log"), b"source").unwrap();
let app = Suite::at(temporary.path().join("suite"))
.unwrap()
.app_root("AbortIdentity")
.unwrap();
let mut cutover = RootCutover::new(app.clone(), &old)
.fs_move(FsMove::Tree(MigrateOptions::strict()))
.tree_roots(|_| Err(ParticipantError::new("stop before copy")));
assert!(matches!(
cutover.run(),
Err(CutoverError::Participant { name, error, .. })
if name == "tree-roots" && error.message() == "stop before copy"
));
let (new, new_key) = cutover.plan_new().unwrap();
let plan = cutover.plan_old(new, new_key).unwrap();
let mut record = read_record(&plan.new).unwrap().unwrap();
assert_eq!(record.phase, CutoverPhase::Aborted);
record.phase = CutoverPhase::Copying;
record.error = None;
write_record(&plan.new, &record).unwrap();
let original = fs::read(plan.new.join(CUTOVER_RECORD)).unwrap();
let cx = CutoverContext {
id: record.id.clone(),
generation: record.generation,
old_root: plan.old.clone(),
new_root: plan.new.clone(),
resumed: false,
};
let saved = plan.new.with_extension("saved");
fs::rename(&plan.new, &saved).unwrap();
fs::create_dir(&plan.new).unwrap();
fs::write(plan.new.join("sentinel"), b"replacement").unwrap();
assert!(matches!(
check_directory(&plan.new, plan.new_key),
Err(CutoverError::PathSubstituted(_))
));
let cause = cutover.abort(
&plan,
record,
&cx,
0,
CutoverError::Io(io::Error::other("resolver failed after replacement")),
);
assert!(matches!(
cause,
CutoverError::Io(error) if error.to_string() == "resolver failed after replacement"
));
assert!(!plan.new.join(CUTOVER_RECORD).exists());
assert_eq!(fs::read(plan.new.join("sentinel")).unwrap(), b"replacement");
assert_eq!(fs::read_dir(&plan.new).unwrap().count(), 1);
assert_eq!(fs::read(saved.join(CUTOVER_RECORD)).unwrap(), original);
assert_eq!(fs::read(plan.old.join("startup.log")).unwrap(), b"source");
assert_eq!(RootCutover::selected_root(&app, &old).unwrap(), old);
}
}