use crate::backup::{replace_database_locked, validated_backup, StoreLock};
use crate::{DbError, DbOptions, Result};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const SIDECARS: [&str; 3] = ["-wal", "-shm", "-journal"];
const JOURNAL_MAGIC: &str = "rightkit-db-restore.v1";
const NAME_ATTEMPTS: u32 = 100;
pub type RestoreVerify<'a> = &'a dyn Fn(&DbOptions) -> std::result::Result<(), String>;
#[derive(Clone, Copy)]
pub struct RestoreOptions<'a> {
pub previous_infix: &'a str,
pub failed_infix: &'a str,
pub verify: Option<RestoreVerify<'a>>,
}
impl Default for RestoreOptions<'_> {
fn default() -> Self {
Self {
previous_infix: ".pre-restore-",
failed_infix: ".restore-failed-",
verify: None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RestoreReceipt {
pub previous: Option<PathBuf>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum InterruptedRestore {
PutBack { from: PathBuf },
KeptPlaced { previous: PathBuf },
}
pub fn restore_database(
options: &DbOptions,
replacement: &Path,
restore: &RestoreOptions<'_>,
lock: &rightkit_fs::LockOptions,
) -> Result<RestoreReceipt> {
let _guard = StoreLock::exclusive(&options.path, lock)?;
restore_database_locked(options, replacement, restore)
}
pub fn restore_database_locked(
options: &DbOptions,
replacement: &Path,
restore: &RestoreOptions<'_>,
) -> Result<RestoreReceipt> {
let live = options.path.as_path();
recover_interrupted_restore_locked(options)?;
if same_file(live, replacement) {
return Err(failed(
"the replacement is the live database".into(),
None,
None,
true,
));
}
drop(validated_backup(options, replacement)?);
let journal = journal_path(live);
let previous = if present(live) {
Some(free_name(live, restore.previous_infix)?)
} else {
None
};
write_journal(&journal, "quarantining", previous.as_deref())
.map_err(|e| failed(format!("write the restore journal: {e}"), None, None, true))?;
if let Some(prev) = &previous {
if let Err(e) = move_store(live, prev) {
return Err(rollback(
live,
Some(prev),
restore,
format!("could not move the current store aside: {e}"),
));
}
}
if let Err(e) = replace_database_locked(options, replacement) {
return Err(rollback(
live,
previous.as_deref(),
restore,
format!("could not put the restored store in place: {e}"),
));
}
if let Err(e) = write_journal(&journal, "placed", previous.as_deref()) {
return Err(rollback(
live,
previous.as_deref(),
restore,
format!("update the restore journal: {e}"),
));
}
if let Some(verify) = restore.verify {
if let Err(reason) = verify(options) {
return Err(rollback(
live,
previous.as_deref(),
restore,
format!("the restored store failed its checks: {reason}"),
));
}
}
let _ = std::fs::remove_file(&journal);
rightkit_fs::atomic::sync_dir(parent_of(live));
Ok(RestoreReceipt { previous })
}
pub fn recover_interrupted_restore(
options: &DbOptions,
lock: &rightkit_fs::LockOptions,
) -> Result<Option<InterruptedRestore>> {
let _guard = StoreLock::exclusive(&options.path, lock)?;
recover_interrupted_restore_locked(options)
}
pub fn recover_interrupted_restore_locked(
options: &DbOptions,
) -> Result<Option<InterruptedRestore>> {
let live = options.path.as_path();
let journal = journal_path(live);
let text = match std::fs::read_to_string(&journal) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(failed(
format!("read {}: {e}", journal.display()),
None,
None,
false,
))
}
};
let previous = parse_journal(live, &text).map_err(|m| {
failed(
format!("{}: {m}; nothing was changed", journal.display()),
None,
None,
false,
)
})?;
let done = |outcome: Option<InterruptedRestore>| -> Result<Option<InterruptedRestore>> {
let _ = std::fs::remove_file(&journal);
rightkit_fs::atomic::sync_dir(parent_of(live));
Ok(outcome)
};
let Some(prev) = previous else {
return done(None);
};
let kept_main = present(&prev);
let kept_sidecars = SIDECARS.iter().any(|s| present(&with_suffix(&prev, s)));
match (present(live), kept_main, kept_sidecars) {
(false, true, _) | (_, false, true) => match put_back(&prev, live) {
Ok(()) => done(Some(InterruptedRestore::PutBack { from: prev })),
Err(e) => Err(failed(
format!("the previous store could not be put back: {e}"),
Some(prev),
None,
false,
)),
},
(true, true, _) => done(Some(InterruptedRestore::KeptPlaced { previous: prev })),
(true, false, false) => done(None),
(false, false, false) => Err(failed(
format!(
"the journal names {} but neither it nor the live store exists",
prev.display()
),
None,
None,
false,
)),
}
}
fn rollback(
live: &Path,
previous: Option<&Path>,
restore: &RestoreOptions<'_>,
message: String,
) -> DbError {
let mut failed_copy = None;
if present(live) {
match free_name(live, restore.failed_infix).and_then(|dest| {
move_store(live, &dest)
.map(|()| dest)
.map_err(|e| failed(format!("{e}"), None, None, false))
}) {
Ok(dest) => failed_copy = Some(dest),
Err(e) => {
return failed(
format!(
"{message}; rollback incomplete: the restored copy could not be moved aside ({e})"
),
previous.filter(|p| present(p)).map(Path::to_path_buf),
None,
false,
)
}
}
}
if let Some(prev) = previous {
if let Err(e) = put_back(prev, live) {
return failed(
format!(
"{message}; rollback incomplete: the previous store could not be put back ({e})"
),
Some(prev.to_path_buf()),
failed_copy,
false,
);
}
}
let _ = std::fs::remove_file(journal_path(live));
rightkit_fs::atomic::sync_dir(parent_of(live));
failed(message, None, failed_copy, true)
}
fn failed(
message: String,
previous: Option<PathBuf>,
failed_copy: Option<PathBuf>,
rolled_back: bool,
) -> DbError {
DbError::RestoreFailed {
message,
previous,
failed_copy,
rolled_back,
}
}
fn journal_path(live: &Path) -> PathBuf {
with_suffix(live, ".restore-journal")
}
fn write_journal(path: &Path, state: &str, previous: Option<&Path>) -> Result<()> {
let name = previous
.and_then(Path::file_name)
.map_or_else(|| "none".to_string(), |n| n.to_string_lossy().into_owned());
let body = format!("{JOURNAL_MAGIC}\nstate={state}\nprevious={name}\n");
rightkit_fs::atomic::atomic_write_private(path, body.as_bytes())?;
Ok(())
}
fn parse_journal(live: &Path, text: &str) -> std::result::Result<Option<PathBuf>, String> {
let mut lines = text.lines();
if lines.next() != Some(JOURNAL_MAGIC) {
return Err("not a restore journal".into());
}
let (mut state, mut previous) = (None, None);
for line in lines {
if let Some(v) = line.strip_prefix("state=") {
state = Some(v);
} else if let Some(v) = line.strip_prefix("previous=") {
previous = Some(v);
}
}
if !matches!(state, Some("quarantining" | "placed")) {
return Err("unknown state".into());
}
let name = previous.ok_or("no previous= line")?;
if name == "none" {
return Ok(None);
}
let live_name = live
.file_name()
.ok_or("live path has no file name")?
.to_string_lossy();
if name.is_empty()
|| name.contains(['/', '\\', '\0'])
|| !name.starts_with(&*live_name)
|| name == live_name
{
return Err(format!(
"previous= {name:?} is not a name beside the live store"
));
}
Ok(Some(live.with_file_name(name)))
}
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(suffix);
PathBuf::from(name)
}
fn parent_of(path: &Path) -> &Path {
path.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
fn present(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok()
}
fn same_file(a: &Path, b: &Path) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
fn free_name(live: &Path, infix: &str) -> Result<PathBuf> {
let name = live
.file_name()
.ok_or(DbError::InvalidDatabasePath)?
.to_string_lossy()
.into_owned();
if infix.is_empty() || infix.contains(['/', '\\', '\0']) {
return Err(DbError::InvalidQuarantineSuffix(infix.to_string()));
}
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis());
for attempt in 0..NAME_ATTEMPTS {
let candidate = if attempt == 0 {
format!("{name}{infix}{ms}")
} else {
format!("{name}{infix}{ms}-{attempt}")
};
let dest = live.with_file_name(candidate);
if !present(&dest) && !SIDECARS.iter().any(|s| present(&with_suffix(&dest, s))) {
return Ok(dest);
}
}
Err(DbError::Fs(rightkit_fs::FsError::io(
live,
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"no free name for the kept store",
),
)))
}
fn move_store(from: &Path, to: &Path) -> std::io::Result<()> {
rightkit_fs::rename_exclusive(from, to).map_err(std::io::Error::from)?;
for suffix in SIDECARS {
let (src, dst) = (with_suffix(from, suffix), with_suffix(to, suffix));
match rightkit_fs::rename_exclusive(&src, &dst) {
Ok(()) => {}
Err(e) => {
let e = std::io::Error::from(e);
if e.kind() != std::io::ErrorKind::NotFound {
return Err(e);
}
}
}
}
Ok(())
}
fn put_back(kept: &Path, live: &Path) -> std::io::Result<()> {
if present(kept) {
rightkit_fs::rename_exclusive(kept, live).map_err(std::io::Error::from)?;
}
for suffix in SIDECARS {
let (src, dst) = (with_suffix(kept, suffix), with_suffix(live, suffix));
if present(&src) {
rightkit_fs::rename_exclusive(&src, &dst).map_err(std::io::Error::from)?;
}
}
Ok(())
}