1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::io;
4use std::path::{Component, Path, PathBuf};
5
6use serde_json::{json, Value};
7
8use crate::atomic::write_atomic;
9use crate::catalog::{parse_catalog, read_catalog_text, Catalog, ReadCatalogError};
10use crate::crypto::{CipherOptions, Sealer};
11use crate::errors::{HaysonCipherError, HaysonSchemaError};
12use crate::lock::DirLock;
13use crate::storage::{partition_names_in, LOG_SUFFIX};
14
15use super::database::{Database, DatabaseError, HeldLock, LockMode};
16use super::layout::{Layout, LayoutOptions};
17use super::verify::{verify, VerifyOptions};
18
19pub const BACKUP_MANIFEST: &str = "hayson-backup.json";
21const BACKUP_FORMAT: u64 = 1;
22const RESTORE_OLD: &str = ".restore-old";
23const RESTORE_NEW: &str = ".restore-new";
24
25#[derive(Debug, Clone)]
26pub struct BackupOptions {
27 pub lock: LockMode,
31 pub indexes: bool,
34 pub exclude_tables: Vec<String>,
38 pub encryption: Option<CipherOptions>,
41}
42
43impl Default for BackupOptions {
44 fn default() -> Self {
45 BackupOptions {
46 lock: LockMode::default(),
47 indexes: false,
48 exclude_tables: Vec::new(),
49 encryption: None,
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct BackupResult {
56 pub dest: PathBuf,
57 pub files: usize,
58 pub bytes: u64,
59}
60
61#[derive(Debug, Clone, Default)]
62pub struct RestoreOptions {
63 pub lock: LockMode,
65 pub max_image_bytes: Option<u64>,
67 pub encryption: Option<CipherOptions>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct RestoreResult {
73 pub dir: PathBuf,
74 pub files: usize,
75 pub bytes: u64,
76 pub replaced: usize,
78}
79
80pub(super) fn sealer_of(encryption: Option<&CipherOptions>) -> Result<Option<std::sync::Arc<Sealer>>, DatabaseError> {
82 Ok(encryption.map(Sealer::new).transpose()?.map(std::sync::Arc::new))
83}
84
85pub(super) fn refuse(message: String) -> DatabaseError {
86 DatabaseError::Schema(HaysonSchemaError(message))
87}
88
89pub(super) fn read_catalog_of(layout: &Layout, sealer: Option<&Sealer>) -> Result<Catalog, DatabaseError> {
90 let path = layout.metadata_path();
91 let raw = match read_catalog_text(path, sealer) {
92 Ok(Some(raw)) => raw,
93 Ok(None) => {
94 return Err(refuse(format!(
95 "\"{}\" does not exist — there is no database to back up",
96 path.display()
97 )))
98 }
99 Err(ReadCatalogError::Io(error)) => return Err(DatabaseError::Io(error)),
100 Err(
101 ReadCatalogError::Cipher(error)
102 | ReadCatalogError::Encrypted(error)
103 | ReadCatalogError::NotEncrypted(error),
104 ) => return Err(DatabaseError::Cipher(Box::new(error))),
105 };
106 let value: Value = serde_json::from_str(&raw)
107 .map_err(|error| DatabaseError::Catalog(format!("\"{}\" is not valid JSON: {error}", path.display())))?;
108 Ok(parse_catalog(&value)?)
109}
110
111fn relative_safely(root: &Path, path: &Path) -> Result<PathBuf, DatabaseError> {
112 let relative = path
113 .strip_prefix(root)
114 .map_err(|_| refuse(format!("\"{}\" is outside the database directory", path.display())))?;
115 check_relative(relative)?;
116 Ok(relative.to_path_buf())
117}
118
119pub(super) fn check_relative(path: &Path) -> Result<(), DatabaseError> {
121 let sane = path.components().all(|part| matches!(part, Component::Normal(_)));
122 if sane && path.components().next().is_some() {
123 Ok(())
124 } else {
125 Err(refuse(format!(
126 "\"{}\" is not a path inside the database directory",
127 path.display()
128 )))
129 }
130}
131
132fn files_in(dir: &Path) -> Result<Vec<PathBuf>, DatabaseError> {
133 let mut found = Vec::new();
134 let Ok(entries) = fs::read_dir(dir) else {
135 return Ok(found);
136 };
137 for entry in entries {
138 let entry = entry?;
139 let kind = entry.file_type()?;
140 if kind.is_symlink() {
141 return Err(refuse(format!(
142 "\"{}\" is a symbolic link — refusing to follow it",
143 entry.path().display()
144 )));
145 }
146 if kind.is_file() {
147 found.push(entry.path());
148 }
149 }
150 found.sort();
151 Ok(found)
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
156pub(super) enum Owned {
157 Metadata,
158 Log(String),
160 Image(String),
162 Index(String),
163}
164
165pub(super) fn owned_files(
170 root: &Path,
171 layout: &Layout,
172 catalog: &Catalog,
173 indexes: bool,
174 exclude: &[String],
175) -> Result<Vec<PathBuf>, DatabaseError> {
176 Ok(owned_entries(root, layout, catalog, indexes, exclude)?
177 .into_iter()
178 .map(|(path, _)| path)
179 .collect())
180}
181
182pub(super) fn owned_entries(
184 root: &Path,
185 layout: &Layout,
186 catalog: &Catalog,
187 indexes: bool,
188 exclude: &[String],
189) -> Result<Vec<(PathBuf, Owned)>, DatabaseError> {
190 if let Some(unknown) = exclude.iter().find(|name| !catalog.tables.contains_key(*name)) {
191 return Err(refuse(format!(
192 "cannot leave out table \"{unknown}\" — the database declares no such table"
193 )));
194 }
195 let mut owned: BTreeMap<PathBuf, Owned> = BTreeMap::new();
196 let mut add = |path: &Path, kind: Owned| -> Result<(), DatabaseError> {
197 owned.insert(relative_safely(root, path)?, kind);
198 Ok(())
199 };
200 add(layout.metadata_path(), Owned::Metadata)?;
201 for (name, entry) in &catalog.tables {
202 if exclude.contains(name) {
203 continue;
204 }
205 let flat = layout.log_path(name);
206 match fs::symlink_metadata(&flat) {
207 Ok(meta) if meta.file_type().is_symlink() => {
208 return Err(refuse(format!(
209 "\"{}\" is a symbolic link — refusing to follow it",
210 flat.display()
211 )))
212 }
213 Ok(meta) if meta.is_file() => add(&flat, Owned::Log(name.clone()))?,
214 _ => {}
215 }
216 let partitions = layout.tables().join(name);
217 for part in partition_names_in(&partitions) {
218 add(
219 &partitions.join(format!("{part}{LOG_SUFFIX}")),
220 Owned::Log(name.clone()),
221 )?;
222 }
223 if indexes && layout.index_path(name).is_file() {
224 add(&layout.index_path(name), Owned::Index(name.clone()))?;
225 }
226 let image_dir = entry
227 .options
228 .as_ref()
229 .and_then(|options| options.image_dir.clone())
230 .or_else(|| catalog.defaults.as_ref().and_then(|d| d.image_dir.clone()))
231 .map(|dir| root.join(dir))
232 .unwrap_or_else(|| layout.tables().join(format!("{name}.images")));
233 for file in files_in(&image_dir)? {
234 add(&file, Owned::Image(name.clone()))?;
235 }
236 }
237 Ok(owned.into_iter().collect())
238}
239
240pub(super) fn emptied_logs(root: &Path, layout: &Layout, catalog: &Catalog, exclude: &[String]) -> Vec<PathBuf> {
244 exclude
245 .iter()
246 .filter(|name| {
247 catalog
248 .tables
249 .get(*name)
250 .is_some_and(|entry| entry.partition_by.is_none())
251 })
252 .filter_map(|name| relative_safely(root, &layout.log_path(name)).ok())
253 .collect()
254}
255
256pub(super) fn hold(root: &Path, layout: &Layout, lock: &LockMode) -> Result<HeldLock, DatabaseError> {
257 let LockMode::Enabled(options) = lock else {
258 return Ok(HeldLock(None));
259 };
260 if root.is_dir() {
261 fs::create_dir_all(layout.runtime())?;
262 }
263 Ok(HeldLock(Some(DirLock::acquire_as(
264 layout.lock_path(),
265 options.clone(),
266 root.to_path_buf(),
267 )?)))
268}
269
270fn copy_synced(from: &Path, to: &Path) -> io::Result<u64> {
271 if let Some(parent) = to.parent() {
272 fs::create_dir_all(parent)?;
273 }
274 let bytes = fs::copy(from, to)?;
275 fs::File::open(to)?.sync_all()?;
276 Ok(bytes)
277}
278
279pub fn backup_database(dir: &Path, dest: &Path, options: &BackupOptions) -> Result<BackupResult, DatabaseError> {
290 let sealer = sealer_of(options.encryption.as_ref())?;
291 let layout = Database::resolve_layout_sealed(dir, &LayoutOptions::default(), sealer.as_deref())?;
292 let catalog = read_catalog_of(&layout, sealer.as_deref())?;
293 if dest.exists()
294 && fs::read_dir(dest)
295 .map(|mut entries| entries.next().is_some())
296 .unwrap_or(true)
297 {
298 return Err(refuse(format!(
299 "\"{}\" already exists and is not an empty directory",
300 dest.display()
301 )));
302 }
303 let partial = sibling(dest, ".partial");
304 if partial.exists() {
305 fs::remove_dir_all(&partial)?;
306 }
307
308 let held = hold(dir, &layout, &options.lock)?;
309 let outcome = copy_database(dir, &layout, &catalog, &partial, options);
310 drop(held);
311 match outcome {
312 Ok(result) => {
313 if dest.exists() {
314 fs::remove_dir(dest)?;
315 }
316 fs::rename(&partial, dest)?;
317 Ok(BackupResult {
318 dest: dest.to_path_buf(),
319 ..result
320 })
321 }
322 Err(error) => {
323 let _ = fs::remove_dir_all(&partial);
324 Err(error)
325 }
326 }
327}
328
329pub(super) fn sibling(path: &Path, suffix: &str) -> PathBuf {
330 let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
331 name.push(suffix);
332 path.with_file_name(name)
333}
334
335fn copy_database(
336 root: &Path,
337 layout: &Layout,
338 catalog: &Catalog,
339 partial: &Path,
340 options: &BackupOptions,
341) -> Result<BackupResult, DatabaseError> {
342 if layout.journal_path().exists() {
343 return Err(refuse(format!(
344 "\"{}\" holds an unfinished unit of work — open the database for writing once so it is finished, then back it up",
345 layout.journal_path().display()
346 )));
347 }
348 let mut files = owned_files(root, layout, catalog, options.indexes, &options.exclude_tables)?;
349 let emptied = emptied_logs(root, layout, catalog, &options.exclude_tables);
350 files.extend(emptied.iter().cloned());
351 files.sort();
352 fs::create_dir_all(partial)?;
353 let mut listed = Vec::new();
354 let mut total = 0u64;
355 for relative in &files {
356 let bytes = if emptied.contains(relative) {
357 let target = partial.join(relative);
358 if let Some(parent) = target.parent() {
359 fs::create_dir_all(parent)?;
360 }
361 fs::File::create(&target)?.sync_all()?;
362 0
363 } else {
364 copy_synced(&root.join(relative), &partial.join(relative))?
365 };
366 total += bytes;
367 listed.push(json!({ "path": relative.to_string_lossy().replace('\\', "/"), "bytes": bytes }));
368 }
369 let manifest = json!({
370 "format": BACKUP_FORMAT,
371 "createdAt": chrono::Utc::now().timestamp_millis(),
372 "engine": env!("CARGO_PKG_VERSION"),
373 "metadataFile": layout.metadata_file(),
374 "files": listed,
375 });
376 let mut text = serde_json::to_string_pretty(&manifest).map_err(|e| DatabaseError::Catalog(e.to_string()))?;
377 text.push('\n');
378 write_atomic(&partial.join(BACKUP_MANIFEST), text.as_bytes(), None)?;
379 Ok(BackupResult {
380 dest: partial.to_path_buf(),
381 files: files.len(),
382 bytes: total,
383 })
384}
385
386#[derive(Clone)]
387pub(super) struct ManifestEntry {
388 pub(super) relative: PathBuf,
389 pub(super) bytes: u64,
390}
391
392fn read_manifest(backup: &Path) -> Result<(String, Vec<ManifestEntry>), DatabaseError> {
393 let path = backup.join(BACKUP_MANIFEST);
394 let raw = fs::read_to_string(&path).map_err(|error| {
395 refuse(format!(
396 "\"{}\" is not a readable backup manifest: {error}",
397 path.display()
398 ))
399 })?;
400 parse_manifest(&raw, &path.display().to_string())
401}
402
403pub(super) fn parse_manifest(raw: &str, label: &str) -> Result<(String, Vec<ManifestEntry>), DatabaseError> {
404 let manifest: Value =
405 serde_json::from_str(raw).map_err(|error| refuse(format!("\"{label}\" is not valid JSON: {error}")))?;
406 if manifest.get("format").and_then(Value::as_u64) != Some(BACKUP_FORMAT) {
407 return Err(refuse(format!("\"{label}\" is not a backup format this engine reads")));
408 }
409 let metadata_file = manifest
410 .get("metadataFile")
411 .and_then(Value::as_str)
412 .ok_or_else(|| refuse("backup manifest names no \"metadataFile\"".to_string()))?
413 .to_string();
414 let listed = manifest
415 .get("files")
416 .and_then(Value::as_array)
417 .ok_or_else(|| refuse("backup manifest lists no \"files\"".to_string()))?;
418 let mut entries = Vec::new();
419 for item in listed {
420 let relative = PathBuf::from(
421 item.get("path")
422 .and_then(Value::as_str)
423 .ok_or_else(|| refuse("a backup manifest entry has no \"path\"".to_string()))?,
424 );
425 check_relative(&relative)?;
426 let bytes = item
427 .get("bytes")
428 .and_then(Value::as_u64)
429 .ok_or_else(|| refuse(format!("manifest entry \"{}\" has no \"bytes\"", relative.display())))?;
430 entries.push(ManifestEntry { relative, bytes });
431 }
432 Ok((metadata_file, entries))
433}
434
435pub fn restore_database(backup: &Path, dir: &Path, options: &RestoreOptions) -> Result<RestoreResult, DatabaseError> {
447 let (metadata_file, entries) = read_manifest(backup)?;
448 for entry in &entries {
449 let path = backup.join(&entry.relative);
450 let meta = fs::symlink_metadata(&path).map_err(|error| {
451 refuse(format!(
452 "backup file \"{}\" is missing: {error}",
453 entry.relative.display()
454 ))
455 })?;
456 if !meta.is_file() || meta.len() != entry.bytes {
457 return Err(refuse(format!(
458 "backup file \"{}\" does not match its manifest entry ({} bytes expected)",
459 entry.relative.display(),
460 entry.bytes
461 )));
462 }
463 }
464 let part = RestorePart {
465 sub: PathBuf::new(),
466 encryption: options.encryption.clone(),
467 current_encryption: None,
468 metadata_file,
469 entries: entries.clone(),
470 };
471 restore_staged(dir, vec![part], &options.lock, options.max_image_bytes, |staged, _| {
472 let mut total = 0u64;
473 for entry in &entries {
474 total += copy_synced(&backup.join(&entry.relative), &staged.join(&entry.relative))?;
475 }
476 Ok(total)
477 })
478}
479
480pub(super) struct RestorePart {
484 pub(super) sub: PathBuf,
485 pub(super) encryption: Option<CipherOptions>,
487 pub(super) current_encryption: Option<Option<CipherOptions>>,
490 pub(super) metadata_file: String,
491 pub(super) entries: Vec<ManifestEntry>,
492}
493
494pub(super) fn restore_staged(
499 dir: &Path,
500 mut parts: Vec<RestorePart>,
501 lock: &LockMode,
502 max_image_bytes: Option<u64>,
503 stage: impl FnOnce(&Path, &mut [RestorePart]) -> Result<u64, DatabaseError>,
504) -> Result<RestoreResult, DatabaseError> {
505 let old = dir.join(RESTORE_OLD);
506 if old.exists() {
507 return Err(refuse(format!(
508 "\"{}\" exists — an earlier restore was interrupted and it may hold the only good copy of the database; move its files back (or delete it) before restoring again",
509 old.display()
510 )));
511 }
512 let mut created: Vec<PathBuf> = Vec::new();
513 let mut made_roots: Vec<bool> = Vec::new();
514 let mut layouts = Vec::new();
515 let mut held = Vec::new();
516 for part in &parts {
517 let root = dir.join(&part.sub);
518 let first_new = root.ancestors().take_while(|path| !path.exists()).last();
520 made_roots.push(first_new.is_some());
521 if let Some(first_new) = first_new {
522 created.push(first_new.to_path_buf());
523 }
524 fs::create_dir_all(&root)?;
525 let sealer = sealer_of(part.current_encryption.as_ref().unwrap_or(&part.encryption).as_ref())?;
526 let layout = Database::resolve_layout_sealed(
527 &root,
528 &LayoutOptions {
529 metadata_file: Some(part.metadata_file.clone()),
530 ..LayoutOptions::default()
531 },
532 sealer.as_deref(),
533 )?;
534 held.push(hold(&root, &layout, lock)?);
535 layouts.push(layout);
536 }
537 let staged = dir.join(RESTORE_NEW);
538 let outcome = install(dir, &layouts, &mut parts, &staged, &old, max_image_bytes, stage);
539 let _ = fs::remove_dir_all(&staged);
540 drop(held);
541 if outcome.is_err() {
542 for (layout, made) in layouts.iter().zip(&made_roots) {
544 if *made {
545 let _ = fs::remove_dir_all(layout.runtime());
546 }
547 }
548 for path in created.iter().rev() {
549 let _ = fs::remove_dir(path);
550 let mut parent = path.parent();
551 while let Some(up) = parent {
552 if up == dir || fs::remove_dir(up).is_err() {
553 break;
554 }
555 parent = up.parent();
556 }
557 }
558 if dir.exists() && created.iter().any(|path| path == dir) {
559 let _ = fs::remove_dir(dir);
560 }
561 }
562 outcome
563}
564
565fn install(
566 root: &Path,
567 layouts: &[Layout],
568 parts: &mut [RestorePart],
569 staged: &Path,
570 old: &Path,
571 max_image_bytes: Option<u64>,
572 stage: impl FnOnce(&Path, &mut [RestorePart]) -> Result<u64, DatabaseError>,
573) -> Result<RestoreResult, DatabaseError> {
574 if staged.exists() {
575 fs::remove_dir_all(staged)?;
576 }
577 let total = stage(staged, parts)?;
578 for part in parts.iter() {
579 let report = verify(
580 &staged.join(&part.sub),
581 &VerifyOptions {
582 relations: false,
583 max_image_bytes,
584 encryption: part.encryption.clone(),
585 ..VerifyOptions::default()
586 },
587 );
588 let which = if part.sub.as_os_str().is_empty() {
589 String::new()
590 } else {
591 format!(" (database \"{}\")", part.sub.display())
592 };
593 if let Some(problem) = report.problems.iter().find(|p| {
594 matches!(
595 p.code,
596 "catalog-encrypted" | "catalog-key-rejected" | "catalog-not-encrypted"
597 )
598 }) {
599 return Err(DatabaseError::Cipher(Box::new(HaysonCipherError::new(
600 "",
601 crate::crypto::STORAGE_FIELD,
602 format!("{which}: {}", problem.message),
603 ))));
604 }
605 if let Some(problem) = report.problems.iter().find(|p| p.code == "image-oversize") {
606 return Err(refuse(format!(
607 "the backup holds an image over the size limit{which} ({}) — nothing was changed",
608 problem.message
609 )));
610 }
611 if let Some(problem) = report
612 .problems
613 .iter()
614 .find(|p| p.severity == super::verify::Severity::Error)
615 {
616 return Err(refuse(format!(
617 "the backup does not verify{which} ({}: {}) — nothing was changed",
618 problem.code, problem.message
619 )));
620 }
621 }
622
623 let entries: Vec<&ManifestEntry> = parts.iter().flat_map(|part| part.entries.iter()).collect();
626 let mut replaced: BTreeSet<PathBuf> = entries.iter().map(|e| e.relative.clone()).collect();
627 for (part, layout) in parts.iter().zip(layouts.iter()) {
628 let part_root = root.join(&part.sub);
629 let current = part.current_encryption.as_ref().unwrap_or(&part.encryption);
630 let sealer = sealer_of(current.as_ref()).ok().flatten();
631 if let Ok(catalog) = read_catalog_of(layout, sealer.as_deref()) {
632 if let Ok(current) = owned_files(&part_root, layout, &catalog, true, &[]) {
633 replaced.extend(current.into_iter().map(|relative| part.sub.join(relative)));
634 }
635 }
636 }
637 replaced.retain(|relative| root.join(relative).is_file());
638
639 fs::create_dir_all(old)?;
640 let mut moved_out: Vec<PathBuf> = Vec::new();
641 let mut moved_in: Vec<PathBuf> = Vec::new();
642 let swap = (|| -> io::Result<()> {
643 for relative in &replaced {
644 move_file(&root.join(relative), &old.join(relative))?;
645 moved_out.push(relative.clone());
646 }
647 for entry in &entries {
648 move_file(&staged.join(&entry.relative), &root.join(&entry.relative))?;
649 moved_in.push(entry.relative.clone());
650 }
651 Ok(())
652 })();
653 match swap {
654 Ok(()) => {
655 fs::remove_dir_all(old)?;
656 Ok(RestoreResult {
657 dir: root.to_path_buf(),
658 files: entries.len(),
659 bytes: total,
660 replaced: replaced.len(),
661 })
662 }
663 Err(error) => {
664 let rollback = (|| -> io::Result<()> {
665 for relative in &moved_in {
666 fs::remove_file(root.join(relative))?;
667 }
668 for relative in &moved_out {
669 move_file(&old.join(relative), &root.join(relative))?;
670 }
671 Ok(())
672 })();
673 match rollback {
674 Ok(()) => {
675 let _ = fs::remove_dir_all(old);
676 Err(DatabaseError::Io(error))
677 }
678 Err(rollback_error) => Err(refuse(format!(
679 "restore failed ({error}) and putting the previous files back failed too ({rollback_error}) — the previous database is in \"{}\"",
680 old.display()
681 ))),
682 }
683 }
684 }
685}
686
687fn move_file(from: &Path, to: &Path) -> io::Result<()> {
688 if let Some(parent) = to.parent() {
689 fs::create_dir_all(parent)?;
690 }
691 fs::rename(from, to)
692}