1use crate::backup::{replace_database_locked, validated_backup, StoreLock};
31use crate::{DbError, DbOptions, Result};
32use std::path::{Path, PathBuf};
33use std::time::{SystemTime, UNIX_EPOCH};
34
35const SIDECARS: [&str; 3] = ["-wal", "-shm", "-journal"];
36const JOURNAL_MAGIC: &str = "rightkit-db-restore.v1";
37const NAME_ATTEMPTS: u32 = 100;
38
39pub type RestoreVerify<'a> = &'a dyn Fn(&DbOptions) -> std::result::Result<(), String>;
42
43#[derive(Clone, Copy)]
45pub struct RestoreOptions<'a> {
46 pub previous_infix: &'a str,
48 pub failed_infix: &'a str,
51 pub verify: Option<RestoreVerify<'a>>,
53}
54
55impl Default for RestoreOptions<'_> {
56 fn default() -> Self {
57 Self {
58 previous_infix: ".pre-restore-",
59 failed_infix: ".restore-failed-",
60 verify: None,
61 }
62 }
63}
64
65#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct RestoreReceipt {
68 pub previous: Option<PathBuf>,
71}
72
73#[derive(Clone, Debug, Eq, PartialEq)]
75pub enum InterruptedRestore {
76 PutBack { from: PathBuf },
79 KeptPlaced { previous: PathBuf },
82}
83
84pub fn restore_database(
86 options: &DbOptions,
87 replacement: &Path,
88 restore: &RestoreOptions<'_>,
89 lock: &rightkit_fs::LockOptions,
90) -> Result<RestoreReceipt> {
91 let _guard = StoreLock::exclusive(&options.path, lock)?;
92 restore_database_locked(options, replacement, restore)
93}
94
95pub fn restore_database_locked(
97 options: &DbOptions,
98 replacement: &Path,
99 restore: &RestoreOptions<'_>,
100) -> Result<RestoreReceipt> {
101 let live = options.path.as_path();
102 recover_interrupted_restore_locked(options)?;
103 if same_file(live, replacement) {
104 return Err(failed(
105 "the replacement is the live database".into(),
106 None,
107 None,
108 true,
109 ));
110 }
111 drop(validated_backup(options, replacement)?);
113
114 let journal = journal_path(live);
115 let previous = if present(live) {
116 Some(free_name(live, restore.previous_infix)?)
117 } else {
118 None
119 };
120 write_journal(&journal, "quarantining", previous.as_deref())
121 .map_err(|e| failed(format!("write the restore journal: {e}"), None, None, true))?;
122
123 if let Some(prev) = &previous {
124 if let Err(e) = move_store(live, prev) {
125 return Err(rollback(
127 live,
128 Some(prev),
129 restore,
130 format!("could not move the current store aside: {e}"),
131 ));
132 }
133 }
134 if let Err(e) = replace_database_locked(options, replacement) {
135 return Err(rollback(
136 live,
137 previous.as_deref(),
138 restore,
139 format!("could not put the restored store in place: {e}"),
140 ));
141 }
142 if let Err(e) = write_journal(&journal, "placed", previous.as_deref()) {
143 return Err(rollback(
144 live,
145 previous.as_deref(),
146 restore,
147 format!("update the restore journal: {e}"),
148 ));
149 }
150 if let Some(verify) = restore.verify {
151 if let Err(reason) = verify(options) {
152 return Err(rollback(
153 live,
154 previous.as_deref(),
155 restore,
156 format!("the restored store failed its checks: {reason}"),
157 ));
158 }
159 }
160 let _ = std::fs::remove_file(&journal);
161 rightkit_fs::atomic::sync_dir(parent_of(live));
162 Ok(RestoreReceipt { previous })
163}
164
165pub fn recover_interrupted_restore(
169 options: &DbOptions,
170 lock: &rightkit_fs::LockOptions,
171) -> Result<Option<InterruptedRestore>> {
172 let _guard = StoreLock::exclusive(&options.path, lock)?;
173 recover_interrupted_restore_locked(options)
174}
175
176pub fn recover_interrupted_restore_locked(
192 options: &DbOptions,
193) -> Result<Option<InterruptedRestore>> {
194 let live = options.path.as_path();
195 let journal = journal_path(live);
196 let text = match std::fs::read_to_string(&journal) {
197 Ok(text) => text,
198 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
199 Err(e) => {
200 return Err(failed(
201 format!("read {}: {e}", journal.display()),
202 None,
203 None,
204 false,
205 ))
206 }
207 };
208 let previous = parse_journal(live, &text).map_err(|m| {
209 failed(
210 format!("{}: {m}; nothing was changed", journal.display()),
211 None,
212 None,
213 false,
214 )
215 })?;
216 let done = |outcome: Option<InterruptedRestore>| -> Result<Option<InterruptedRestore>> {
217 let _ = std::fs::remove_file(&journal);
218 rightkit_fs::atomic::sync_dir(parent_of(live));
219 Ok(outcome)
220 };
221 let Some(prev) = previous else {
222 return done(None);
223 };
224 let kept_main = present(&prev);
225 let kept_sidecars = SIDECARS.iter().any(|s| present(&with_suffix(&prev, s)));
226 match (present(live), kept_main, kept_sidecars) {
227 (false, true, _) | (_, false, true) => match put_back(&prev, live) {
231 Ok(()) => done(Some(InterruptedRestore::PutBack { from: prev })),
232 Err(e) => Err(failed(
233 format!("the previous store could not be put back: {e}"),
234 Some(prev),
235 None,
236 false,
237 )),
238 },
239 (true, true, _) => done(Some(InterruptedRestore::KeptPlaced { previous: prev })),
240 (true, false, false) => done(None),
241 (false, false, false) => Err(failed(
242 format!(
243 "the journal names {} but neither it nor the live store exists",
244 prev.display()
245 ),
246 None,
247 None,
248 false,
249 )),
250 }
251}
252
253fn rollback(
256 live: &Path,
257 previous: Option<&Path>,
258 restore: &RestoreOptions<'_>,
259 message: String,
260) -> DbError {
261 let mut failed_copy = None;
262 if present(live) {
263 match free_name(live, restore.failed_infix).and_then(|dest| {
264 move_store(live, &dest)
265 .map(|()| dest)
266 .map_err(|e| failed(format!("{e}"), None, None, false))
267 }) {
268 Ok(dest) => failed_copy = Some(dest),
269 Err(e) => {
270 return failed(
271 format!(
272 "{message}; rollback incomplete: the restored copy could not be moved aside ({e})"
273 ),
274 previous.filter(|p| present(p)).map(Path::to_path_buf),
275 None,
276 false,
277 )
278 }
279 }
280 }
281 if let Some(prev) = previous {
282 if let Err(e) = put_back(prev, live) {
283 return failed(
284 format!(
285 "{message}; rollback incomplete: the previous store could not be put back ({e})"
286 ),
287 Some(prev.to_path_buf()),
288 failed_copy,
289 false,
290 );
291 }
292 }
293 let _ = std::fs::remove_file(journal_path(live));
294 rightkit_fs::atomic::sync_dir(parent_of(live));
295 failed(message, None, failed_copy, true)
296}
297
298fn failed(
299 message: String,
300 previous: Option<PathBuf>,
301 failed_copy: Option<PathBuf>,
302 rolled_back: bool,
303) -> DbError {
304 DbError::RestoreFailed {
305 message,
306 previous,
307 failed_copy,
308 rolled_back,
309 }
310}
311
312fn journal_path(live: &Path) -> PathBuf {
313 with_suffix(live, ".restore-journal")
314}
315
316fn write_journal(path: &Path, state: &str, previous: Option<&Path>) -> Result<()> {
317 let name = previous
318 .and_then(Path::file_name)
319 .map_or_else(|| "none".to_string(), |n| n.to_string_lossy().into_owned());
320 let body = format!("{JOURNAL_MAGIC}\nstate={state}\nprevious={name}\n");
321 rightkit_fs::atomic::atomic_write_private(path, body.as_bytes())?;
322 Ok(())
323}
324
325fn parse_journal(live: &Path, text: &str) -> std::result::Result<Option<PathBuf>, String> {
328 let mut lines = text.lines();
329 if lines.next() != Some(JOURNAL_MAGIC) {
330 return Err("not a restore journal".into());
331 }
332 let (mut state, mut previous) = (None, None);
333 for line in lines {
334 if let Some(v) = line.strip_prefix("state=") {
335 state = Some(v);
336 } else if let Some(v) = line.strip_prefix("previous=") {
337 previous = Some(v);
338 }
339 }
340 if !matches!(state, Some("quarantining" | "placed")) {
341 return Err("unknown state".into());
342 }
343 let name = previous.ok_or("no previous= line")?;
344 if name == "none" {
345 return Ok(None);
346 }
347 let live_name = live
348 .file_name()
349 .ok_or("live path has no file name")?
350 .to_string_lossy();
351 if name.is_empty()
352 || name.contains(['/', '\\', '\0'])
353 || !name.starts_with(&*live_name)
354 || name == live_name
355 {
356 return Err(format!(
357 "previous= {name:?} is not a name beside the live store"
358 ));
359 }
360 Ok(Some(live.with_file_name(name)))
361}
362
363fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
364 let mut name = path.as_os_str().to_os_string();
365 name.push(suffix);
366 PathBuf::from(name)
367}
368
369fn parent_of(path: &Path) -> &Path {
370 path.parent()
371 .filter(|p| !p.as_os_str().is_empty())
372 .unwrap_or_else(|| Path::new("."))
373}
374
375fn present(path: &Path) -> bool {
376 std::fs::symlink_metadata(path).is_ok()
377}
378
379fn same_file(a: &Path, b: &Path) -> bool {
380 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
381 (Ok(a), Ok(b)) => a == b,
382 _ => a == b,
383 }
384}
385
386fn free_name(live: &Path, infix: &str) -> Result<PathBuf> {
389 let name = live
390 .file_name()
391 .ok_or(DbError::InvalidDatabasePath)?
392 .to_string_lossy()
393 .into_owned();
394 if infix.is_empty() || infix.contains(['/', '\\', '\0']) {
395 return Err(DbError::InvalidQuarantineSuffix(infix.to_string()));
396 }
397 let ms = SystemTime::now()
398 .duration_since(UNIX_EPOCH)
399 .map_or(0, |d| d.as_millis());
400 for attempt in 0..NAME_ATTEMPTS {
401 let candidate = if attempt == 0 {
402 format!("{name}{infix}{ms}")
403 } else {
404 format!("{name}{infix}{ms}-{attempt}")
405 };
406 let dest = live.with_file_name(candidate);
407 if !present(&dest) && !SIDECARS.iter().any(|s| present(&with_suffix(&dest, s))) {
408 return Ok(dest);
409 }
410 }
411 Err(DbError::Fs(rightkit_fs::FsError::io(
412 live,
413 std::io::Error::new(
414 std::io::ErrorKind::AlreadyExists,
415 "no free name for the kept store",
416 ),
417 )))
418}
419
420fn move_store(from: &Path, to: &Path) -> std::io::Result<()> {
423 rightkit_fs::rename_exclusive(from, to).map_err(std::io::Error::from)?;
424 for suffix in SIDECARS {
425 let (src, dst) = (with_suffix(from, suffix), with_suffix(to, suffix));
426 match rightkit_fs::rename_exclusive(&src, &dst) {
427 Ok(()) => {}
428 Err(e) => {
429 let e = std::io::Error::from(e);
430 if e.kind() != std::io::ErrorKind::NotFound {
431 return Err(e);
432 }
433 }
434 }
435 }
436 Ok(())
437}
438
439fn put_back(kept: &Path, live: &Path) -> std::io::Result<()> {
442 if present(kept) {
443 rightkit_fs::rename_exclusive(kept, live).map_err(std::io::Error::from)?;
444 }
445 for suffix in SIDECARS {
446 let (src, dst) = (with_suffix(kept, suffix), with_suffix(live, suffix));
447 if present(&src) {
448 rightkit_fs::rename_exclusive(&src, &dst).map_err(std::io::Error::from)?;
449 }
450 }
451 Ok(())
452}