1use std::{
2 fs::OpenOptions,
3 io::{Read as _, Write as _},
4 path::{Component, Path, PathBuf},
5 time::{Duration, SystemTime, UNIX_EPOCH},
6};
7
8use fs2::FileExt as _;
9use iroh_db_core::AuthorId;
10use iroh_db_security::{LocalCredentials, open_commit, verify_backup_signature};
11
12use crate::{DbError, IrohDb, LatencyMetrics};
13
14const BACKUP_FORMAT_VERSION: u16 = 2;
15const CATALOG_FILE: &str = "BACKUP-CATALOG.v2";
16const MAX_CATALOG_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_CATALOG_ENTRIES: usize = 1_000_000;
18const COPY_BUFFER_BYTES: usize = 64 * 1024;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub struct BackupOptions {
23 pub include_keys: bool,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct BackupReport {
29 files: usize,
30 bytes: u64,
31 signer: AuthorId,
32}
33
34impl BackupReport {
35 pub const fn files(self) -> usize {
36 self.files
37 }
38
39 pub const fn bytes(self) -> u64 {
40 self.bytes
41 }
42
43 pub const fn signer(self) -> AuthorId {
45 self.signer
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, minicbor::Encode, minicbor::Decode)]
50#[cbor(array)]
51struct BackupCatalogContent {
52 #[n(0)]
53 version: u16,
54 #[n(1)]
55 entries: Vec<BackupCatalogEntry>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, minicbor::Encode, minicbor::Decode)]
59#[cbor(array)]
60struct BackupCatalogEntry {
61 #[n(0)]
62 hash: [u8; 32],
63 #[n(1)]
64 size: u64,
65 #[n(2)]
66 path: String,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, minicbor::Encode, minicbor::Decode)]
70#[cbor(array)]
71struct SignedBackupCatalog {
72 #[n(0)]
73 content: Vec<u8>,
74 #[n(1)]
75 signer: AuthorId,
76 #[n(2)]
77 signature: [u8; 64],
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct VerificationReport {
83 commits: usize,
84 orphan_commits: usize,
85 typed_rebuild_performed: bool,
86 derived_consistent: bool,
87 state_root: [u8; 32],
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub struct OperationalMetrics {
93 accepted_commits: usize,
94 frontier_size: usize,
95 materialized_records: usize,
96 local_domains: usize,
97 active_migrations: usize,
98 staged_migration_records: usize,
99 frozen_migrations: usize,
100 local_apply_latency: LatencyMetrics,
101 query_latency: LatencyMetrics,
102 index_records_scanned: u64,
103 reconciliation_commits_scanned: u64,
104 equivocation_quarantines: u64,
105 schema_rebases: u64,
106 sync_rounds: u64,
107 sync_failures: u64,
108 sync_commits_received: u64,
109 sync_commits_sent: u64,
110 snapshots_created: u64,
111 snapshots_installed: u64,
112 snapshot_age: Option<Duration>,
113 repairs_succeeded: u64,
114 repairs_failed: u64,
115 last_repair_derived_consistent: Option<bool>,
116}
117
118impl OperationalMetrics {
119 pub const fn accepted_commits(self) -> usize {
120 self.accepted_commits
121 }
122
123 pub const fn frontier_size(self) -> usize {
124 self.frontier_size
125 }
126
127 pub const fn materialized_records(self) -> usize {
128 self.materialized_records
129 }
130
131 pub const fn local_domains(self) -> usize {
132 self.local_domains
133 }
134
135 pub const fn active_migrations(self) -> usize {
136 self.active_migrations
137 }
138
139 pub const fn staged_migration_records(self) -> usize {
140 self.staged_migration_records
141 }
142
143 pub const fn frozen_migrations(self) -> usize {
144 self.frozen_migrations
145 }
146
147 pub const fn local_apply_latency(self) -> LatencyMetrics {
148 self.local_apply_latency
149 }
150
151 pub const fn query_latency(self) -> LatencyMetrics {
152 self.query_latency
153 }
154
155 pub const fn index_records_scanned(self) -> u64 {
156 self.index_records_scanned
157 }
158
159 pub const fn reconciliation_commits_scanned(self) -> u64 {
161 self.reconciliation_commits_scanned
162 }
163
164 pub const fn equivocation_quarantines(self) -> u64 {
165 self.equivocation_quarantines
166 }
167
168 pub const fn schema_rebases(self) -> u64 {
169 self.schema_rebases
170 }
171
172 pub const fn sync_rounds(self) -> u64 {
173 self.sync_rounds
174 }
175
176 pub const fn sync_failures(self) -> u64 {
177 self.sync_failures
178 }
179
180 pub const fn sync_commits_received(self) -> u64 {
181 self.sync_commits_received
182 }
183
184 pub const fn sync_commits_sent(self) -> u64 {
185 self.sync_commits_sent
186 }
187
188 pub const fn snapshots_created(self) -> u64 {
189 self.snapshots_created
190 }
191
192 pub const fn snapshots_installed(self) -> u64 {
193 self.snapshots_installed
194 }
195
196 pub const fn snapshot_age(self) -> Option<Duration> {
197 self.snapshot_age
198 }
199
200 pub const fn repairs_succeeded(self) -> u64 {
201 self.repairs_succeeded
202 }
203
204 pub const fn repairs_failed(self) -> u64 {
205 self.repairs_failed
206 }
207
208 pub const fn last_repair_derived_consistent(self) -> Option<bool> {
209 self.last_repair_derived_consistent
210 }
211}
212
213impl VerificationReport {
214 pub const fn commits(&self) -> usize {
215 self.commits
216 }
217
218 pub const fn orphan_commits(&self) -> usize {
219 self.orphan_commits
220 }
221
222 pub const fn typed_rebuild_performed(&self) -> bool {
224 self.typed_rebuild_performed
225 }
226
227 pub const fn derived_consistent(&self) -> bool {
228 self.derived_consistent
229 }
230
231 pub const fn state_root(&self) -> [u8; 32] {
232 self.state_root
233 }
234}
235
236impl IrohDb {
237 pub fn operational_metrics(&self) -> Result<OperationalMetrics, DbError> {
239 let runtime = self.inner.telemetry.snapshot();
240 let (active_migrations, staged_migration_records, frozen_migrations) =
241 self.inner.store.migration_counts(self.domain_id())?;
242 let snapshot_age = self
243 .inner
244 .store
245 .snapshot_observed_at(self.domain_id())?
246 .map(|observed| {
247 SystemTime::now()
248 .duration_since(UNIX_EPOCH)
249 .unwrap_or_default()
250 .as_secs()
251 .saturating_sub(observed)
252 })
253 .map(Duration::from_secs);
254 Ok(OperationalMetrics {
255 accepted_commits: self
256 .inner
257 .store
258 .list_stored_commit_ids(self.domain_id())?
259 .len(),
260 frontier_size: self.inner.store.frontier(self.domain_id())?.len(),
261 materialized_records: self
262 .inner
263 .store
264 .export_materialized(self.domain_id())?
265 .len(),
266 local_domains: self.domains()?.len(),
267 active_migrations,
268 staged_migration_records,
269 frozen_migrations,
270 local_apply_latency: runtime.local_apply,
271 query_latency: runtime.query,
272 index_records_scanned: runtime.index_records_scanned,
273 reconciliation_commits_scanned: runtime.reconciliation_commits_scanned,
274 equivocation_quarantines: runtime.equivocation_quarantines,
275 schema_rebases: runtime.schema_rebases,
276 sync_rounds: runtime.sync_rounds,
277 sync_failures: runtime.sync_failures,
278 sync_commits_received: runtime.sync_received,
279 sync_commits_sent: runtime.sync_sent,
280 snapshots_created: runtime.snapshots_created,
281 snapshots_installed: runtime.snapshots_installed,
282 snapshot_age,
283 repairs_succeeded: runtime.repairs_succeeded,
284 repairs_failed: runtime.repairs_failed,
285 last_repair_derived_consistent: runtime.last_repair_derived_consistent,
286 })
287 }
288
289 #[tracing::instrument(name = "iroh_db.verify", skip_all)]
291 pub async fn verify(&self) -> Result<VerificationReport, DbError> {
292 let _control_reader = self.inner.control_gate.read().await;
293 let _writer = self.inner.writer.lock().await;
294 self.verify_unlocked().await
295 }
296
297 async fn verify_unlocked(&self) -> Result<VerificationReport, DbError> {
298 if !self
299 .inner
300 .store
301 .authority_checkpoint_consistent(self.domain_id())?
302 {
303 return Err(DbError::Operation(
304 "authority checkpoint evidence is incomplete".into(),
305 ));
306 }
307 if !self
308 .inner
309 .store
310 .subject_revocations_consistent(self.domain_id())?
311 {
312 return Err(DbError::Operation(
313 "subject revocation index differs from signed control".into(),
314 ));
315 }
316 let commit_ids = self.inner.store.list_stored_commit_ids(self.domain_id())?;
317 for commit_id in &commit_ids {
318 let envelope = self.inner.store.load_commit(*commit_id).await?;
319 if envelope.header().domain_id() != self.domain_id() {
320 return Err(DbError::Operation("commit domain mismatch".into()));
321 }
322 let key = self
323 .inner
324 .credentials
325 .load_domain_epoch(self.domain_id(), envelope.header().epoch())?
326 .domain_key();
327 open_commit(&envelope, &key)?;
328 for dependency in envelope.header().dependencies() {
329 if !self
330 .inner
331 .store
332 .contains_commit(self.domain_id(), *dependency)?
333 {
334 return Err(DbError::Operation(format!(
335 "commit dependency is absent: {dependency}"
336 )));
337 }
338 }
339 }
340 let orphans = self.inner.store.scan_commit_orphans().await?;
341 let current = canonical_records(self.inner.store.export_materialized(self.domain_id())?)?;
342 let adapters_available = self.inner.has_record_adapters();
343 let no_replay_input = commit_ids.is_empty()
344 && self
345 .inner
346 .store
347 .snapshot_baseline(self.domain_id())?
348 .is_none();
349 let typed_rebuild_performed = adapters_available || no_replay_input;
350 let derived_consistent = if typed_rebuild_performed {
351 canonical_records(self.rebuild_mutations().await?)? == current
352 } else {
353 false
354 };
355 Ok(VerificationReport {
356 commits: commit_ids.len(),
357 orphan_commits: orphans.len(),
358 typed_rebuild_performed,
359 derived_consistent,
360 state_root: self.state_root()?,
361 })
362 }
363
364 #[tracing::instrument(name = "iroh_db.repair", skip_all)]
366 pub async fn repair(&self) -> Result<VerificationReport, DbError> {
367 let result = self.repair_inner().await;
368 self.inner.telemetry.record_repair(
369 result
370 .as_ref()
371 .ok()
372 .map(VerificationReport::derived_consistent),
373 );
374 result
375 }
376
377 async fn repair_inner(&self) -> Result<VerificationReport, DbError> {
378 let _control_reader = self.inner.control_gate.read().await;
379 let _writer = self.inner.writer.lock().await;
380 let rebuilt = self.rebuild_mutations().await?;
381 self.inner
382 .store
383 .repair_subject_revocations(self.domain_id())?;
384 self.inner
385 .store
386 .replace_materialized(self.domain_id(), &rebuilt)?;
387 self.verify_unlocked().await
388 }
389
390 #[tracing::instrument(name = "iroh_db.repair_indexes", skip_all)]
392 pub async fn repair_indexes(&self) -> Result<VerificationReport, DbError> {
393 let result = self.repair_indexes_inner().await;
394 self.inner.telemetry.record_repair(
395 result
396 .as_ref()
397 .ok()
398 .map(VerificationReport::derived_consistent),
399 );
400 result
401 }
402
403 async fn repair_indexes_inner(&self) -> Result<VerificationReport, DbError> {
404 let _control_reader = self.inner.control_gate.read().await;
405 let _writer = self.inner.writer.lock().await;
406 let exported = self.inner.store.export_materialized(self.domain_id())?;
407 self.inner
408 .store
409 .repair_subject_revocations(self.domain_id())?;
410 self.inner
411 .store
412 .replace_materialized(self.domain_id(), &exported)?;
413 self.verify_unlocked().await
414 }
415}
416
417#[tracing::instrument(name = "iroh_db.backup", skip_all)]
419pub fn backup_closed(
420 source: impl AsRef<Path>,
421 destination: impl AsRef<Path>,
422 options: BackupOptions,
423) -> Result<BackupReport, DbError> {
424 let source = source.as_ref();
425 let signer = LocalCredentials::load(source.join("keys"))?.signer();
426 backup_closed_with_signer(source, destination, options, &signer)
427}
428
429#[tracing::instrument(name = "iroh_db.backup", skip_all)]
431pub fn backup_closed_with_signer(
432 source: impl AsRef<Path>,
433 destination: impl AsRef<Path>,
434 options: BackupOptions,
435 signer: &iroh_db_security::IrohSigner,
436) -> Result<BackupReport, DbError> {
437 let source = source.as_ref();
438 let destination = destination.as_ref();
439 if !source.join("FORMAT").is_file() || destination.exists() {
440 return Err(operation_error(
441 "source is not a database or destination exists",
442 ));
443 }
444 let lock = OpenOptions::new()
445 .read(true)
446 .write(true)
447 .open(source.join("locks/writer.lock"))
448 .map_err(io_operation)?;
449 lock.try_lock_exclusive()
450 .map_err(|_| operation_error("database must be closed before backup"))?;
451 let mut files = Vec::new();
452 collect_files(source, source, options, &mut files)?;
453 files.sort();
454 if files.len() > MAX_CATALOG_ENTRIES {
455 return Err(operation_error("backup contains too many files"));
456 }
457 let staging = create_staging_directory(destination, "backup")?;
458 let result = build_backup(source, &staging, &files, signer);
459 let report = match result {
460 Ok(report) => {
461 publish_staging_directory(&staging, destination)?;
462 report
463 }
464 Err(error) => {
465 discard_staging_directory(&staging);
466 return Err(error);
467 }
468 };
469 drop(lock);
470 Ok(report)
471}
472
473fn build_backup(
474 source: &Path,
475 staging: &Path,
476 files: &[PathBuf],
477 signer: &iroh_db_security::IrohSigner,
478) -> Result<BackupReport, DbError> {
479 let mut entries = Vec::with_capacity(files.len());
480 let mut total = 0_u64;
481 for relative in files {
482 let target = staging.join(relative);
483 if let Some(parent) = target.parent() {
484 std::fs::create_dir_all(parent).map_err(io_operation)?;
485 }
486 let (hash, size) = copy_and_hash(&source.join(relative), &target)?;
487 total = total
488 .checked_add(size)
489 .ok_or_else(|| operation_error("backup byte count overflow"))?;
490 entries.push(BackupCatalogEntry {
491 hash,
492 size,
493 path: canonical_relative(relative)?,
494 });
495 }
496 entries.sort_by(|left, right| left.path.cmp(&right.path));
497 let content = BackupCatalogContent {
498 version: BACKUP_FORMAT_VERSION,
499 entries,
500 };
501 let content = minicbor::to_vec(content).map_err(backup_codec)?;
502 if u64::try_from(content.len()).unwrap_or(u64::MAX) > MAX_CATALOG_BYTES {
503 return Err(operation_error("backup catalog is too large"));
504 }
505 let catalog = SignedBackupCatalog {
506 signature: signer.sign_backup_catalog(&content),
507 signer: signer.author(),
508 content,
509 };
510 let catalog = minicbor::to_vec(catalog).map_err(backup_codec)?;
511 write_new_file(&staging.join(CATALOG_FILE), &catalog)?;
512 Ok(BackupReport {
513 files: files.len(),
514 bytes: total,
515 signer: signer.author(),
516 })
517}
518
519#[tracing::instrument(name = "iroh_db.restore", skip_all)]
524pub fn restore_backup(
525 backup: impl AsRef<Path>,
526 destination: impl AsRef<Path>,
527) -> Result<BackupReport, DbError> {
528 restore_backup_impl(backup.as_ref(), destination.as_ref(), None)
529}
530
531#[tracing::instrument(name = "iroh_db.restore", skip_all)]
533pub fn restore_backup_from(
534 backup: impl AsRef<Path>,
535 destination: impl AsRef<Path>,
536 expected_signer: AuthorId,
537) -> Result<BackupReport, DbError> {
538 restore_backup_impl(backup.as_ref(), destination.as_ref(), Some(expected_signer))
539}
540
541fn restore_backup_impl(
542 backup: &Path,
543 destination: &Path,
544 expected_signer: Option<AuthorId>,
545) -> Result<BackupReport, DbError> {
546 if destination.exists() {
547 return Err(operation_error("restore destination already exists"));
548 }
549 require_directory_without_symlink(backup)?;
550 let catalog_bytes =
551 read_bounded_regular_file(backup, Path::new(CATALOG_FILE), MAX_CATALOG_BYTES)?;
552 let catalog: SignedBackupCatalog = decode_backup_canonical(&catalog_bytes)?;
553 verify_backup_signature(catalog.signer, &catalog.content, &catalog.signature)?;
554 if expected_signer.is_some_and(|expected| expected != catalog.signer) {
555 return Err(operation_error(
556 "backup signer does not match the expected device",
557 ));
558 }
559 let content: BackupCatalogContent = decode_backup_canonical(&catalog.content)?;
560 validate_catalog(&content)?;
561 let mut total = 0_u64;
562 for entry in &content.entries {
563 let relative = Path::new(&entry.path);
564 let (hash, size) = hash_backup_file(backup, relative)?;
565 if size != entry.size || hash != entry.hash {
566 return Err(operation_error("backup checksum mismatch"));
567 }
568 total = total
569 .checked_add(size)
570 .ok_or_else(|| operation_error("restore byte count overflow"))?;
571 }
572 let staging = create_staging_directory(destination, "restore")?;
573 let result = restore_into_staging(backup, &staging, &content.entries);
574 if let Err(error) = result {
575 discard_staging_directory(&staging);
576 return Err(error);
577 }
578 publish_staging_directory(&staging, destination)?;
579 Ok(BackupReport {
580 files: content.entries.len(),
581 bytes: total,
582 signer: catalog.signer,
583 })
584}
585
586fn restore_into_staging(
587 backup: &Path,
588 staging: &Path,
589 entries: &[BackupCatalogEntry],
590) -> Result<(), DbError> {
591 for entry in entries {
592 let relative = Path::new(&entry.path);
593 let target = staging.join(relative);
594 if let Some(parent) = target.parent() {
595 std::fs::create_dir_all(parent).map_err(io_operation)?;
596 }
597 let (hash, size) = copy_backup_file(backup, relative, &target)?;
598 if size != entry.size || hash != entry.hash {
599 return Err(operation_error("backup changed during restore"));
600 }
601 set_restored_permissions(relative, &target)?;
602 }
603 Ok(())
604}
605
606fn create_staging_directory(destination: &Path, purpose: &str) -> Result<PathBuf, DbError> {
607 let parent = destination_parent(destination);
608 if !parent.is_dir() {
609 return Err(operation_error("destination parent is not a directory"));
610 }
611 let name = destination
612 .file_name()
613 .and_then(|value| value.to_str())
614 .ok_or_else(|| operation_error("destination name must be valid UTF-8"))?;
615 for _ in 0..16 {
616 let mut random = [0_u8; 16];
617 getrandom::fill(&mut random).map_err(|_| DbError::RandomnessUnavailable)?;
618 let candidate = parent.join(format!(
619 ".{name}.iroh-db-{purpose}-{}",
620 blake3::hash(&random).to_hex()
621 ));
622 match std::fs::create_dir(&candidate) {
623 Ok(()) => return Ok(candidate),
624 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
625 Err(error) => return Err(io_operation(error)),
626 }
627 }
628 Err(operation_error("could not allocate a staging directory"))
629}
630
631fn publish_staging_directory(staging: &Path, destination: &Path) -> Result<(), DbError> {
632 if destination.exists() {
633 discard_staging_directory(staging);
634 return Err(operation_error("destination appeared during publication"));
635 }
636 sync_tree_directories(staging)?;
637 if let Err(error) = std::fs::rename(staging, destination) {
638 discard_staging_directory(staging);
639 return Err(io_operation(error));
640 }
641 sync_directory(destination_parent(destination))
642}
643
644fn destination_parent(destination: &Path) -> &Path {
645 destination
646 .parent()
647 .filter(|parent| !parent.as_os_str().is_empty())
648 .unwrap_or_else(|| Path::new("."))
649}
650
651fn sync_tree_directories(root: &Path) -> Result<(), DbError> {
652 let mut directories = vec![root.to_path_buf()];
653 collect_directories(root, &mut directories)?;
654 directories.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
655 for directory in directories {
656 sync_directory(&directory)?;
657 }
658 Ok(())
659}
660
661fn collect_directories(directory: &Path, directories: &mut Vec<PathBuf>) -> Result<(), DbError> {
662 for entry in std::fs::read_dir(directory).map_err(io_operation)? {
663 let entry = entry.map_err(io_operation)?;
664 if entry.file_type().map_err(io_operation)?.is_dir() {
665 let path = entry.path();
666 directories.push(path.clone());
667 collect_directories(&path, directories)?;
668 }
669 }
670 Ok(())
671}
672
673#[cfg(unix)]
674fn sync_directory(directory: &Path) -> Result<(), DbError> {
675 std::fs::File::open(directory)
676 .and_then(|file| file.sync_all())
677 .map_err(io_operation)
678}
679
680#[cfg(not(unix))]
681fn sync_directory(_directory: &Path) -> Result<(), DbError> {
682 Ok(())
683}
684
685fn discard_staging_directory(staging: &Path) {
686 let _ignored = std::fs::remove_dir_all(staging);
687}
688
689fn write_new_file(path: &Path, bytes: &[u8]) -> Result<(), DbError> {
690 let mut file = OpenOptions::new()
691 .write(true)
692 .create_new(true)
693 .open(path)
694 .map_err(io_operation)?;
695 file.write_all(bytes).map_err(io_operation)?;
696 file.sync_all().map_err(io_operation)
697}
698
699fn copy_and_hash(source: &Path, target: &Path) -> Result<([u8; 32], u64), DbError> {
700 let metadata = std::fs::symlink_metadata(source).map_err(io_operation)?;
701 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
702 return Err(operation_error("backup source changed during collection"));
703 }
704 let mut reader = std::fs::File::open(source).map_err(io_operation)?;
705 let mut writer = OpenOptions::new()
706 .write(true)
707 .create_new(true)
708 .open(target)
709 .map_err(io_operation)?;
710 let result = copy_reader_and_hash(&mut reader, &mut writer)?;
711 writer.sync_all().map_err(io_operation)?;
712 Ok(result)
713}
714
715fn copy_backup_file(
716 backup: &Path,
717 relative: &Path,
718 target: &Path,
719) -> Result<([u8; 32], u64), DbError> {
720 let mut reader = open_backup_file(backup, relative)?;
721 let mut writer = OpenOptions::new()
722 .write(true)
723 .create_new(true)
724 .open(target)
725 .map_err(io_operation)?;
726 let result = copy_reader_and_hash(&mut reader, &mut writer)?;
727 writer.sync_all().map_err(io_operation)?;
728 Ok(result)
729}
730
731fn copy_reader_and_hash(
732 reader: &mut std::fs::File,
733 writer: &mut std::fs::File,
734) -> Result<([u8; 32], u64), DbError> {
735 let mut buffer = vec![0_u8; COPY_BUFFER_BYTES];
736 let mut hasher = blake3::Hasher::new();
737 let mut size = 0_u64;
738 loop {
739 let read = reader.read(&mut buffer).map_err(io_operation)?;
740 if read == 0 {
741 break;
742 }
743 hasher.update(&buffer[..read]);
744 writer.write_all(&buffer[..read]).map_err(io_operation)?;
745 size = size
746 .checked_add(u64::try_from(read).unwrap_or(u64::MAX))
747 .ok_or_else(|| operation_error("backup byte count overflow"))?;
748 }
749 Ok((*hasher.finalize().as_bytes(), size))
750}
751
752fn hash_backup_file(backup: &Path, relative: &Path) -> Result<([u8; 32], u64), DbError> {
753 let mut reader = open_backup_file(backup, relative)?;
754 let mut buffer = vec![0_u8; COPY_BUFFER_BYTES];
755 let mut hasher = blake3::Hasher::new();
756 let mut size = 0_u64;
757 loop {
758 let read = reader.read(&mut buffer).map_err(io_operation)?;
759 if read == 0 {
760 break;
761 }
762 hasher.update(&buffer[..read]);
763 size = size
764 .checked_add(u64::try_from(read).unwrap_or(u64::MAX))
765 .ok_or_else(|| operation_error("restore byte count overflow"))?;
766 }
767 Ok((*hasher.finalize().as_bytes(), size))
768}
769
770fn open_backup_file(backup: &Path, relative: &Path) -> Result<std::fs::File, DbError> {
771 validate_relative(relative)?;
772 let components: Vec<_> = relative.components().collect();
773 let mut current = backup.to_path_buf();
774 for (index, component) in components.iter().enumerate() {
775 let Component::Normal(component) = component else {
776 return Err(operation_error("backup contains an unsafe path"));
777 };
778 current.push(component);
779 let metadata = std::fs::symlink_metadata(¤t).map_err(io_operation)?;
780 if metadata.file_type().is_symlink()
781 || (index + 1 < components.len() && !metadata.is_dir())
782 || (index + 1 == components.len() && !metadata.is_file())
783 {
784 return Err(operation_error("backup contains a non-regular path"));
785 }
786 }
787 std::fs::File::open(current).map_err(io_operation)
788}
789
790fn read_bounded_regular_file(
791 root: &Path,
792 relative: &Path,
793 maximum: u64,
794) -> Result<Vec<u8>, DbError> {
795 let mut file = open_backup_file(root, relative)?;
796 let length = file.metadata().map_err(io_operation)?.len();
797 if length > maximum {
798 return Err(operation_error("backup catalog is too large"));
799 }
800 let capacity = usize::try_from(length)
801 .map_err(|_| operation_error("backup catalog exceeds platform limits"))?;
802 let mut bytes = Vec::with_capacity(capacity);
803 file.read_to_end(&mut bytes).map_err(io_operation)?;
804 Ok(bytes)
805}
806
807fn require_directory_without_symlink(path: &Path) -> Result<(), DbError> {
808 let metadata = std::fs::symlink_metadata(path).map_err(io_operation)?;
809 if metadata.file_type().is_symlink() || !metadata.is_dir() {
810 return Err(operation_error("backup root must be a regular directory"));
811 }
812 Ok(())
813}
814
815fn validate_catalog(content: &BackupCatalogContent) -> Result<(), DbError> {
816 if content.version != BACKUP_FORMAT_VERSION {
817 return Err(operation_error("unsupported backup format"));
818 }
819 if content.entries.len() > MAX_CATALOG_ENTRIES {
820 return Err(operation_error("backup catalog contains too many files"));
821 }
822 let mut previous: Option<&str> = None;
823 let mut total = 0_u64;
824 let mut has_format = false;
825 for entry in &content.entries {
826 if entry.path.len() > 4096 || entry.path == CATALOG_FILE {
827 return Err(operation_error("backup catalog contains an invalid path"));
828 }
829 let relative = Path::new(&entry.path);
830 validate_relative(relative)?;
831 if canonical_relative(relative)? != entry.path
832 || previous.is_some_and(|value| value >= entry.path.as_str())
833 {
834 return Err(operation_error(
835 "backup catalog paths are non-canonical or unordered",
836 ));
837 }
838 previous = Some(&entry.path);
839 has_format |= entry.path == "FORMAT";
840 total = total
841 .checked_add(entry.size)
842 .ok_or_else(|| operation_error("backup catalog byte count overflow"))?;
843 }
844 if !has_format {
845 return Err(operation_error("backup catalog does not contain FORMAT"));
846 }
847 Ok(())
848}
849
850fn canonical_relative(path: &Path) -> Result<String, DbError> {
851 validate_relative(path)?;
852 path.components()
853 .map(|component| match component {
854 Component::Normal(value) => value
855 .to_str()
856 .map(ToOwned::to_owned)
857 .ok_or_else(|| operation_error("backup paths must be valid UTF-8")),
858 _ => Err(operation_error("backup contains an unsafe path")),
859 })
860 .collect::<Result<Vec<_>, _>>()
861 .map(|components| components.join("/"))
862}
863
864fn decode_backup_canonical<T>(bytes: &[u8]) -> Result<T, DbError>
865where
866 T: minicbor::Encode<()> + for<'value> minicbor::Decode<'value, ()>,
867{
868 let mut decoder = minicbor::Decoder::new(bytes);
869 let value = decoder.decode().map_err(backup_codec)?;
870 if decoder.position() != bytes.len() || minicbor::to_vec(&value).map_err(backup_codec)? != bytes
871 {
872 return Err(operation_error("backup catalog is not canonical"));
873 }
874 Ok(value)
875}
876
877#[allow(clippy::needless_pass_by_value)]
878fn backup_codec(error: impl std::fmt::Display) -> DbError {
879 DbError::Operation(format!("invalid backup catalog: {error}"))
880}
881
882#[cfg(unix)]
883fn set_restored_permissions(relative: &Path, target: &Path) -> Result<(), DbError> {
884 use std::os::unix::fs::PermissionsExt as _;
885
886 if relative.starts_with("keys") {
887 std::fs::set_permissions(target, std::fs::Permissions::from_mode(0o600))
888 .map_err(io_operation)?;
889 }
890 Ok(())
891}
892
893#[cfg(not(unix))]
894fn set_restored_permissions(_relative: &Path, _target: &Path) -> Result<(), DbError> {
895 Ok(())
896}
897
898fn canonical_records(
899 records: Vec<iroh_db_store::MaterializedRecord>,
900) -> Result<Vec<Vec<u8>>, DbError> {
901 let mut records = records
902 .into_iter()
903 .map(|record| record.encode_canonical().map_err(DbError::from))
904 .collect::<Result<Vec<_>, _>>()?;
905 records.sort();
906 Ok(records)
907}
908
909fn collect_files(
910 root: &Path,
911 directory: &Path,
912 options: BackupOptions,
913 files: &mut Vec<PathBuf>,
914) -> Result<(), DbError> {
915 for entry in std::fs::read_dir(directory).map_err(io_operation)? {
916 let entry = entry.map_err(io_operation)?;
917 let path = entry.path();
918 let relative = path
919 .strip_prefix(root)
920 .map_err(|_| operation_error("backup path escaped source"))?;
921 if relative.starts_with("tmp")
922 || relative == Path::new("locks/writer.lock")
923 || (!options.include_keys && relative.starts_with("keys"))
924 {
925 continue;
926 }
927 let file_type = entry.file_type().map_err(io_operation)?;
928 if file_type.is_symlink() {
929 return Err(operation_error("backup refuses symbolic links"));
930 }
931 if file_type.is_dir() {
932 collect_files(root, &path, options, files)?;
933 } else if file_type.is_file() {
934 files.push(relative.to_path_buf());
935 }
936 }
937 Ok(())
938}
939
940fn validate_relative(path: &Path) -> Result<(), DbError> {
941 if path.as_os_str().is_empty()
942 || path
943 .components()
944 .any(|component| !matches!(component, Component::Normal(_)))
945 {
946 return Err(operation_error("backup contains an unsafe path"));
947 }
948 Ok(())
949}
950
951fn operation_error(message: &str) -> DbError {
952 DbError::Operation(message.into())
953}
954
955#[allow(clippy::needless_pass_by_value)]
956fn io_operation(error: std::io::Error) -> DbError {
957 DbError::Operation(error.to_string())
958}
959
960#[cfg(test)]
961mod tests {
962 use std::fmt::Write as _;
963
964 use iroh_db_security::IrohSigner;
965
966 use super::*;
967
968 #[test]
969 fn signed_backup_catalog_has_permanent_golden_bytes() {
970 let content = minicbor::to_vec(BackupCatalogContent {
971 version: BACKUP_FORMAT_VERSION,
972 entries: vec![BackupCatalogEntry {
973 hash: [1; 32],
974 size: 3,
975 path: "FORMAT".into(),
976 }],
977 })
978 .unwrap();
979 let signer = IrohSigner::from_secret_bytes([7; 32]);
980 let catalog = minicbor::to_vec(SignedBackupCatalog {
981 signature: signer.sign_backup_catalog(&content),
982 signer: signer.author(),
983 content,
984 })
985 .unwrap();
986 let mut actual = String::with_capacity(catalog.len() * 2);
987 for byte in catalog {
988 write!(&mut actual, "{byte:02x}").expect("writing to a string cannot fail");
989 }
990
991 assert_eq!(
992 actual,
993 "83982e188202188118831898182001010101010101010101010101010101010101010101010101010101010101010318661846184f1852184d18411854982018ea184a186c186318e2189c18520a18be18f51850187b13182e18c518f918951847187618ae18be18be187b18921842181e18ea186914184618d2182c98401871188718a31826189e189a18b81873183e184218c118ea0618fa1852185918e0189c1823182b18b01832187a18241837182d18b6184b182718af18a618fd185a186f18d618741518f4184618dc1840189618331841182c18d51833185d121846186b18471881189a1018cc182b18dd1118501865186e189b04"
994 );
995 }
996}