1use std::collections::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, Catalog};
10use crate::errors::HaysonSchemaError;
11use crate::lock::DirLock;
12use crate::storage::{partition_names_in, LOG_SUFFIX};
13
14use super::database::{Database, DatabaseError, HeldLock, LockMode};
15use super::layout::{Layout, LayoutOptions};
16use super::verify::{verify, VerifyOptions};
17
18pub const BACKUP_MANIFEST: &str = "hayson-backup.json";
20const BACKUP_FORMAT: u64 = 1;
21const RESTORE_OLD: &str = ".restore-old";
22const RESTORE_NEW: &str = ".restore-new";
23
24#[derive(Debug, Clone)]
25pub struct BackupOptions {
26 pub lock: LockMode,
30 pub indexes: bool,
33 pub exclude_tables: Vec<String>,
37}
38
39impl Default for BackupOptions {
40 fn default() -> Self {
41 BackupOptions {
42 lock: LockMode::default(),
43 indexes: false,
44 exclude_tables: Vec::new(),
45 }
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct BackupResult {
51 pub dest: PathBuf,
52 pub files: usize,
53 pub bytes: u64,
54}
55
56#[derive(Debug, Clone, Default)]
57pub struct RestoreOptions {
58 pub lock: LockMode,
60 pub max_image_bytes: Option<u64>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct RestoreResult {
66 pub dir: PathBuf,
67 pub files: usize,
68 pub bytes: u64,
69 pub replaced: usize,
71}
72
73pub(super) fn refuse(message: String) -> DatabaseError {
74 DatabaseError::Schema(HaysonSchemaError(message))
75}
76
77pub(super) fn read_catalog_of(layout: &Layout) -> Result<Catalog, DatabaseError> {
78 let path = layout.metadata_path();
79 let raw = fs::read_to_string(path).map_err(|error| {
80 if error.kind() == io::ErrorKind::NotFound {
81 refuse(format!(
82 "\"{}\" does not exist — there is no database to back up",
83 path.display()
84 ))
85 } else {
86 DatabaseError::Io(error)
87 }
88 })?;
89 let value: Value = serde_json::from_str(&raw)
90 .map_err(|error| DatabaseError::Catalog(format!("\"{}\" is not valid JSON: {error}", path.display())))?;
91 Ok(parse_catalog(&value)?)
92}
93
94fn relative_safely(root: &Path, path: &Path) -> Result<PathBuf, DatabaseError> {
95 let relative = path
96 .strip_prefix(root)
97 .map_err(|_| refuse(format!("\"{}\" is outside the database directory", path.display())))?;
98 check_relative(relative)?;
99 Ok(relative.to_path_buf())
100}
101
102pub(super) fn check_relative(path: &Path) -> Result<(), DatabaseError> {
104 let sane = path.components().all(|part| matches!(part, Component::Normal(_)));
105 if sane && path.components().next().is_some() {
106 Ok(())
107 } else {
108 Err(refuse(format!(
109 "\"{}\" is not a path inside the database directory",
110 path.display()
111 )))
112 }
113}
114
115fn files_in(dir: &Path) -> Result<Vec<PathBuf>, DatabaseError> {
116 let mut found = Vec::new();
117 let Ok(entries) = fs::read_dir(dir) else {
118 return Ok(found);
119 };
120 for entry in entries {
121 let entry = entry?;
122 let kind = entry.file_type()?;
123 if kind.is_symlink() {
124 return Err(refuse(format!(
125 "\"{}\" is a symbolic link — refusing to follow it",
126 entry.path().display()
127 )));
128 }
129 if kind.is_file() {
130 found.push(entry.path());
131 }
132 }
133 found.sort();
134 Ok(found)
135}
136
137pub(super) fn owned_files(
142 root: &Path,
143 layout: &Layout,
144 catalog: &Catalog,
145 indexes: bool,
146 exclude: &[String],
147) -> Result<Vec<PathBuf>, DatabaseError> {
148 if let Some(unknown) = exclude.iter().find(|name| !catalog.tables.contains_key(*name)) {
149 return Err(refuse(format!(
150 "cannot leave out table \"{unknown}\" — the database declares no such table"
151 )));
152 }
153 let mut owned: BTreeSet<PathBuf> = BTreeSet::new();
154 let mut add = |path: &Path| -> Result<(), DatabaseError> {
155 owned.insert(relative_safely(root, path)?);
156 Ok(())
157 };
158 add(layout.metadata_path())?;
159 for (name, entry) in &catalog.tables {
160 if exclude.contains(name) {
161 continue;
162 }
163 let flat = layout.log_path(name);
164 match fs::symlink_metadata(&flat) {
165 Ok(meta) if meta.file_type().is_symlink() => {
166 return Err(refuse(format!(
167 "\"{}\" is a symbolic link — refusing to follow it",
168 flat.display()
169 )))
170 }
171 Ok(meta) if meta.is_file() => add(&flat)?,
172 _ => {}
173 }
174 let partitions = layout.tables().join(name);
175 for part in partition_names_in(&partitions) {
176 add(&partitions.join(format!("{part}{LOG_SUFFIX}")))?;
177 }
178 if indexes && layout.index_path(name).is_file() {
179 add(&layout.index_path(name))?;
180 }
181 let image_dir = entry
182 .options
183 .as_ref()
184 .and_then(|options| options.image_dir.clone())
185 .or_else(|| catalog.defaults.as_ref().and_then(|d| d.image_dir.clone()))
186 .map(|dir| root.join(dir))
187 .unwrap_or_else(|| layout.tables().join(format!("{name}.images")));
188 for file in files_in(&image_dir)? {
189 add(&file)?;
190 }
191 }
192 Ok(owned.into_iter().collect())
193}
194
195pub(super) fn emptied_logs(root: &Path, layout: &Layout, catalog: &Catalog, exclude: &[String]) -> Vec<PathBuf> {
199 exclude
200 .iter()
201 .filter(|name| {
202 catalog
203 .tables
204 .get(*name)
205 .is_some_and(|entry| entry.partition_by.is_none())
206 })
207 .filter_map(|name| relative_safely(root, &layout.log_path(name)).ok())
208 .collect()
209}
210
211pub(super) fn hold(root: &Path, layout: &Layout, lock: &LockMode) -> Result<HeldLock, DatabaseError> {
212 let LockMode::Enabled(options) = lock else {
213 return Ok(HeldLock(None));
214 };
215 if root.is_dir() {
216 fs::create_dir_all(layout.runtime())?;
217 }
218 Ok(HeldLock(Some(DirLock::acquire_as(
219 layout.lock_path(),
220 options.clone(),
221 root.to_path_buf(),
222 )?)))
223}
224
225fn copy_synced(from: &Path, to: &Path) -> io::Result<u64> {
226 if let Some(parent) = to.parent() {
227 fs::create_dir_all(parent)?;
228 }
229 let bytes = fs::copy(from, to)?;
230 fs::File::open(to)?.sync_all()?;
231 Ok(bytes)
232}
233
234pub fn backup_database(dir: &Path, dest: &Path, options: &BackupOptions) -> Result<BackupResult, DatabaseError> {
245 let layout = Database::resolve_layout(dir, &LayoutOptions::default())?;
246 let catalog = read_catalog_of(&layout)?;
247 if dest.exists()
248 && fs::read_dir(dest)
249 .map(|mut entries| entries.next().is_some())
250 .unwrap_or(true)
251 {
252 return Err(refuse(format!(
253 "\"{}\" already exists and is not an empty directory",
254 dest.display()
255 )));
256 }
257 let partial = sibling(dest, ".partial");
258 if partial.exists() {
259 fs::remove_dir_all(&partial)?;
260 }
261
262 let held = hold(dir, &layout, &options.lock)?;
263 let outcome = copy_database(dir, &layout, &catalog, &partial, options);
264 drop(held);
265 match outcome {
266 Ok(result) => {
267 if dest.exists() {
268 fs::remove_dir(dest)?;
269 }
270 fs::rename(&partial, dest)?;
271 Ok(BackupResult {
272 dest: dest.to_path_buf(),
273 ..result
274 })
275 }
276 Err(error) => {
277 let _ = fs::remove_dir_all(&partial);
278 Err(error)
279 }
280 }
281}
282
283pub(super) fn sibling(path: &Path, suffix: &str) -> PathBuf {
284 let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
285 name.push(suffix);
286 path.with_file_name(name)
287}
288
289fn copy_database(
290 root: &Path,
291 layout: &Layout,
292 catalog: &Catalog,
293 partial: &Path,
294 options: &BackupOptions,
295) -> Result<BackupResult, DatabaseError> {
296 if layout.journal_path().exists() {
297 return Err(refuse(format!(
298 "\"{}\" holds an unfinished unit of work — open the database for writing once so it is finished, then back it up",
299 layout.journal_path().display()
300 )));
301 }
302 let mut files = owned_files(root, layout, catalog, options.indexes, &options.exclude_tables)?;
303 let emptied = emptied_logs(root, layout, catalog, &options.exclude_tables);
304 files.extend(emptied.iter().cloned());
305 files.sort();
306 fs::create_dir_all(partial)?;
307 let mut listed = Vec::new();
308 let mut total = 0u64;
309 for relative in &files {
310 let bytes = if emptied.contains(relative) {
311 let target = partial.join(relative);
312 if let Some(parent) = target.parent() {
313 fs::create_dir_all(parent)?;
314 }
315 fs::File::create(&target)?.sync_all()?;
316 0
317 } else {
318 copy_synced(&root.join(relative), &partial.join(relative))?
319 };
320 total += bytes;
321 listed.push(json!({ "path": relative.to_string_lossy().replace('\\', "/"), "bytes": bytes }));
322 }
323 let manifest = json!({
324 "format": BACKUP_FORMAT,
325 "createdAt": chrono::Utc::now().timestamp_millis(),
326 "engine": env!("CARGO_PKG_VERSION"),
327 "metadataFile": layout.metadata_file(),
328 "files": listed,
329 });
330 let mut text = serde_json::to_string_pretty(&manifest).map_err(|e| DatabaseError::Catalog(e.to_string()))?;
331 text.push('\n');
332 write_atomic(&partial.join(BACKUP_MANIFEST), text.as_bytes(), None)?;
333 Ok(BackupResult {
334 dest: partial.to_path_buf(),
335 files: files.len(),
336 bytes: total,
337 })
338}
339
340#[derive(Clone)]
341pub(super) struct ManifestEntry {
342 pub(super) relative: PathBuf,
343 pub(super) bytes: u64,
344}
345
346fn read_manifest(backup: &Path) -> Result<(String, Vec<ManifestEntry>), DatabaseError> {
347 let path = backup.join(BACKUP_MANIFEST);
348 let raw = fs::read_to_string(&path).map_err(|error| {
349 refuse(format!(
350 "\"{}\" is not a readable backup manifest: {error}",
351 path.display()
352 ))
353 })?;
354 parse_manifest(&raw, &path.display().to_string())
355}
356
357pub(super) fn parse_manifest(raw: &str, label: &str) -> Result<(String, Vec<ManifestEntry>), DatabaseError> {
358 let manifest: Value =
359 serde_json::from_str(raw).map_err(|error| refuse(format!("\"{label}\" is not valid JSON: {error}")))?;
360 if manifest.get("format").and_then(Value::as_u64) != Some(BACKUP_FORMAT) {
361 return Err(refuse(format!("\"{label}\" is not a backup format this engine reads")));
362 }
363 let metadata_file = manifest
364 .get("metadataFile")
365 .and_then(Value::as_str)
366 .ok_or_else(|| refuse("backup manifest names no \"metadataFile\"".to_string()))?
367 .to_string();
368 let listed = manifest
369 .get("files")
370 .and_then(Value::as_array)
371 .ok_or_else(|| refuse("backup manifest lists no \"files\"".to_string()))?;
372 let mut entries = Vec::new();
373 for item in listed {
374 let relative = PathBuf::from(
375 item.get("path")
376 .and_then(Value::as_str)
377 .ok_or_else(|| refuse("a backup manifest entry has no \"path\"".to_string()))?,
378 );
379 check_relative(&relative)?;
380 let bytes = item
381 .get("bytes")
382 .and_then(Value::as_u64)
383 .ok_or_else(|| refuse(format!("manifest entry \"{}\" has no \"bytes\"", relative.display())))?;
384 entries.push(ManifestEntry { relative, bytes });
385 }
386 Ok((metadata_file, entries))
387}
388
389pub fn restore_database(backup: &Path, dir: &Path, options: &RestoreOptions) -> Result<RestoreResult, DatabaseError> {
401 let (metadata_file, entries) = read_manifest(backup)?;
402 for entry in &entries {
403 let path = backup.join(&entry.relative);
404 let meta = fs::symlink_metadata(&path).map_err(|error| {
405 refuse(format!(
406 "backup file \"{}\" is missing: {error}",
407 entry.relative.display()
408 ))
409 })?;
410 if !meta.is_file() || meta.len() != entry.bytes {
411 return Err(refuse(format!(
412 "backup file \"{}\" does not match its manifest entry ({} bytes expected)",
413 entry.relative.display(),
414 entry.bytes
415 )));
416 }
417 }
418 let part = RestorePart {
419 sub: PathBuf::new(),
420 metadata_file,
421 entries: entries.clone(),
422 };
423 restore_staged(dir, &[part], &options.lock, options.max_image_bytes, |staged| {
424 let mut total = 0u64;
425 for entry in &entries {
426 total += copy_synced(&backup.join(&entry.relative), &staged.join(&entry.relative))?;
427 }
428 Ok(total)
429 })
430}
431
432pub(super) struct RestorePart {
436 pub(super) sub: PathBuf,
437 pub(super) metadata_file: String,
438 pub(super) entries: Vec<ManifestEntry>,
439}
440
441pub(super) fn restore_staged(
446 dir: &Path,
447 parts: &[RestorePart],
448 lock: &LockMode,
449 max_image_bytes: Option<u64>,
450 stage: impl FnOnce(&Path) -> Result<u64, DatabaseError>,
451) -> Result<RestoreResult, DatabaseError> {
452 let old = dir.join(RESTORE_OLD);
453 if old.exists() {
454 return Err(refuse(format!(
455 "\"{}\" 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",
456 old.display()
457 )));
458 }
459 let created = !dir.exists();
460 let mut layouts = Vec::new();
461 let mut held = Vec::new();
462 for part in parts {
463 let root = dir.join(&part.sub);
464 fs::create_dir_all(&root)?;
465 let layout = Database::resolve_layout(
466 &root,
467 &LayoutOptions {
468 metadata_file: Some(part.metadata_file.clone()),
469 ..LayoutOptions::default()
470 },
471 )?;
472 held.push(hold(&root, &layout, lock)?);
473 layouts.push(layout);
474 }
475 let staged = dir.join(RESTORE_NEW);
476 let outcome = install(dir, &layouts, parts, &staged, &old, max_image_bytes, stage);
477 let _ = fs::remove_dir_all(&staged);
478 drop(held);
479 if created && outcome.is_err() {
480 for layout in &layouts {
482 let _ = fs::remove_dir_all(layout.runtime());
483 }
484 let _ = fs::remove_dir(dir);
485 }
486 outcome
487}
488
489fn install(
490 root: &Path,
491 layouts: &[Layout],
492 parts: &[RestorePart],
493 staged: &Path,
494 old: &Path,
495 max_image_bytes: Option<u64>,
496 stage: impl FnOnce(&Path) -> Result<u64, DatabaseError>,
497) -> Result<RestoreResult, DatabaseError> {
498 if staged.exists() {
499 fs::remove_dir_all(staged)?;
500 }
501 let total = stage(staged)?;
502 for part in parts {
503 let report = verify(
504 &staged.join(&part.sub),
505 &VerifyOptions {
506 relations: false,
507 max_image_bytes,
508 ..VerifyOptions::default()
509 },
510 );
511 let which = if part.sub.as_os_str().is_empty() {
512 String::new()
513 } else {
514 format!(" (database \"{}\")", part.sub.display())
515 };
516 if let Some(problem) = report.problems.iter().find(|p| p.code == "image-oversize") {
517 return Err(refuse(format!(
518 "the backup holds an image over the size limit{which} ({}) — nothing was changed",
519 problem.message
520 )));
521 }
522 if let Some(problem) = report
523 .problems
524 .iter()
525 .find(|p| p.severity == super::verify::Severity::Error)
526 {
527 return Err(refuse(format!(
528 "the backup does not verify{which} ({}: {}) — nothing was changed",
529 problem.code, problem.message
530 )));
531 }
532 }
533
534 let entries: Vec<&ManifestEntry> = parts.iter().flat_map(|part| part.entries.iter()).collect();
537 let mut replaced: BTreeSet<PathBuf> = entries.iter().map(|e| e.relative.clone()).collect();
538 for (part, layout) in parts.iter().zip(layouts) {
539 let part_root = root.join(&part.sub);
540 if let Ok(catalog) = read_catalog_of(layout) {
541 if let Ok(current) = owned_files(&part_root, layout, &catalog, true, &[]) {
542 replaced.extend(current.into_iter().map(|relative| part.sub.join(relative)));
543 }
544 }
545 }
546 replaced.retain(|relative| root.join(relative).is_file());
547
548 fs::create_dir_all(old)?;
549 let mut moved_out: Vec<PathBuf> = Vec::new();
550 let mut moved_in: Vec<PathBuf> = Vec::new();
551 let swap = (|| -> io::Result<()> {
552 for relative in &replaced {
553 move_file(&root.join(relative), &old.join(relative))?;
554 moved_out.push(relative.clone());
555 }
556 for entry in &entries {
557 move_file(&staged.join(&entry.relative), &root.join(&entry.relative))?;
558 moved_in.push(entry.relative.clone());
559 }
560 Ok(())
561 })();
562 match swap {
563 Ok(()) => {
564 fs::remove_dir_all(old)?;
565 Ok(RestoreResult {
566 dir: root.to_path_buf(),
567 files: entries.len(),
568 bytes: total,
569 replaced: replaced.len(),
570 })
571 }
572 Err(error) => {
573 let rollback = (|| -> io::Result<()> {
574 for relative in &moved_in {
575 fs::remove_file(root.join(relative))?;
576 }
577 for relative in &moved_out {
578 move_file(&old.join(relative), &root.join(relative))?;
579 }
580 Ok(())
581 })();
582 match rollback {
583 Ok(()) => {
584 let _ = fs::remove_dir_all(old);
585 Err(DatabaseError::Io(error))
586 }
587 Err(rollback_error) => Err(refuse(format!(
588 "restore failed ({error}) and putting the previous files back failed too ({rollback_error}) — the previous database is in \"{}\"",
589 old.display()
590 ))),
591 }
592 }
593 }
594}
595
596fn move_file(from: &Path, to: &Path) -> io::Result<()> {
597 if let Some(parent) = to.parent() {
598 fs::create_dir_all(parent)?;
599 }
600 fs::rename(from, to)
601}