iroh_db/
operations.rs

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/// Controls whether a closed-directory backup includes the separately encrypted local key vault.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub struct BackupOptions {
23    pub include_keys: bool,
24}
25
26/// Summary of a verified filesystem backup or restore.
27#[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    /// Device identity that authenticated the canonical backup catalog.
44    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/// Evidence from immutable-object and derived-state verification.
81#[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/// Low-cardinality operational counters that never contain record or capability values.
91#[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    /// Immutable commits read while maintaining the incremental CRDT materialization index.
160    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    /// Returns whether registered Rust record adapters were sufficient for a full replay.
223    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    /// Returns a low-cardinality operational snapshot safe for metrics exporters.
238    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    /// Verifies content hashes, signatures, encryption, dependency presence, and rebuild equality.
290    #[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    /// Rebuilds typed materialized state in memory and swaps it in one metadata transaction.
365    #[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    /// Rewrites existing materialized/index tables atomically when application record types are absent.
391    #[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/// Creates an authenticated backup from a closed database using its default file key provider.
418#[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/// Creates a closed-store backup authenticated by an application-managed key provider.
430#[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/// Verifies the embedded self-signature and every file before publishing a restore directory.
520///
521/// Use [`restore_backup_from`] when the source device identity is part of the
522/// trust decision; an unpinned self-signature proves integrity, not provenance.
523#[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/// Restores only when the authenticated catalog signer matches the expected device.
532#[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(&current).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}