Skip to main content

corium_transactor/
backup.rs

1//! Online, storage-native database backup and offline restore.
2//!
3//! A backup is one versioned binary archive. The transactor fixes the upper
4//! transaction basis and hands the backup client an independent storage
5//! connection; [`backup`] replays only through that basis. Re-running it
6//! against the same file appends one binary checkpoint containing only the
7//! records after the archive's previous checkpoint.
8
9use std::fs::{self, File, OpenOptions};
10use std::future::Future;
11use std::io::{self, Read, Seek, SeekFrom, Write};
12use std::path::{Path, PathBuf};
13use std::pin::Pin;
14use std::sync::Arc;
15
16use corium_log::{
17    FileLog, LogError, TransactionLog, TxRecord, append_framed_record, decode_framed_records,
18};
19use corium_protocol::pb;
20use corium_store::{
21    BlobId, BlobStore, DbRoot, FORMAT_VERSION, FsStore, RootStore, StoreError, db_root_name,
22};
23use thiserror::Error;
24
25use crate::{LogBackend, NodeStore, StorageConnectionError, StoreSpec};
26
27/// Binary backup container version written by this release.
28pub const BACKUP_FORMAT_VERSION: u32 = 1;
29
30const BACKUP_MAGIC: &[u8; 16] = b"CORIUM_BACKUP\0\0\0";
31const BLOB_TAG: [u8; 4] = *b"BLOB";
32const CHECKPOINT_TAG: [u8; 4] = *b"CKPT";
33const CHECKPOINT_END: &[u8; 4] = b"DONE";
34const WRITER_VERSION: &str = env!("CARGO_PKG_VERSION");
35
36/// Counters and snapshot identity returned by a backup.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct BackupReport {
39    /// Binary backup container version.
40    pub backup_format_version: u32,
41    /// Corium version that wrote the latest checkpoint.
42    pub writer_version: String,
43    /// Transaction basis preserved by the snapshot.
44    pub basis_t: u64,
45    /// Index basis named by the copied root.
46    pub index_basis_t: u64,
47    /// Immutable blobs newly copied during this run.
48    pub copied_blobs: usize,
49    /// Immutable blobs already present in an incremental destination.
50    pub reused_blobs: usize,
51    /// Transaction records appended during this run.
52    pub replayed_transactions: usize,
53}
54
55/// Counters and identity returned by a restore.
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct RestoreReport {
58    /// Binary backup container version.
59    pub backup_format_version: u32,
60    /// Corium version that wrote the latest checkpoint.
61    pub writer_version: String,
62    /// Source database name recorded in the backup.
63    pub source_db: String,
64    /// Restored database name (which may differ for clone restores).
65    pub target_db: String,
66    /// Restored transaction basis.
67    pub basis_t: u64,
68    /// Immutable blobs newly copied into the target store.
69    pub copied_blobs: usize,
70    /// Immutable blobs already shared by the target store.
71    pub reused_blobs: usize,
72}
73
74/// Backup or restore failure.
75#[derive(Debug, Error)]
76pub enum BackupError {
77    /// Filesystem operation failed.
78    #[error("backup I/O failed: {0}")]
79    Io(#[from] io::Error),
80    /// Blob/root store operation failed.
81    #[error(transparent)]
82    Store(#[from] StoreError),
83    /// Transaction log could not be read.
84    #[error(transparent)]
85    Log(#[from] LogError),
86    /// The requested database does not exist.
87    #[error("database {0:?} does not exist")]
88    MissingDatabase(String),
89    /// A required archive section, root, blob, or log record is malformed.
90    #[error("invalid backup: {0}")]
91    Invalid(String),
92    /// Restore refuses to replace an existing database.
93    #[error("database {0:?} already exists in the target")]
94    TargetExists(String),
95    /// The stored format cannot be read by this binary.
96    #[error("storage format {found} is newer than supported format {supported}")]
97    UnsupportedFormat {
98        /// Version found in the backup.
99        found: u32,
100        /// Newest version understood by this binary.
101        supported: u32,
102    },
103    /// The binary backup container is newer than this binary understands.
104    #[error(
105        "backup file format {found} (created by Corium {writer}) is newer than supported format {supported}"
106    )]
107    UnsupportedBackupFormat {
108        /// Version found in the archive header.
109        found: u32,
110        /// Newest version understood by this binary.
111        supported: u32,
112        /// Corium version recorded by the archive creator.
113        writer: String,
114    },
115    /// The advertised backend cannot be opened independently.
116    #[error("storage backend cannot be backed up independently: {0}")]
117    UnsupportedSource(String),
118}
119
120/// An independently accessible storage service plus the transaction basis
121/// fixed by the running transactor.
122#[derive(Clone, Debug)]
123pub struct BackupSource {
124    store: StoreSpec,
125    data_dir: PathBuf,
126    basis_t: u64,
127}
128
129impl BackupSource {
130    /// Decodes the transactor's one-shot backup discovery response.
131    ///
132    /// # Errors
133    /// Returns an error for missing connection details, an in-memory backend,
134    /// or a backend omitted from this build.
135    pub fn from_info(info: pb::GetStorageInfoResponse) -> Result<Self, BackupError> {
136        let storage = info
137            .storage
138            .ok_or_else(|| BackupError::Invalid("transactor returned no storage backend".into()))?;
139        let (store, data_dir) =
140            StoreSpec::from_connection(storage).map_err(|error| match error {
141                StorageConnectionError::Missing => {
142                    BackupError::Invalid("transactor returned no storage backend".into())
143                }
144                StorageConnectionError::Unsupported(detail) => {
145                    BackupError::UnsupportedSource(detail)
146                }
147            })?;
148        Ok(Self {
149            store,
150            data_dir,
151            basis_t: info.basis_t,
152        })
153    }
154
155    /// Fixed upper transaction basis for this run.
156    #[must_use]
157    pub const fn basis_t(&self) -> u64 {
158        self.basis_t
159    }
160}
161
162#[derive(Clone, Debug, Eq, PartialEq)]
163struct ArchiveHeader {
164    creator_version: String,
165    source_db: String,
166    index_basis_t: u64,
167    storage_format_version: u32,
168    root: Vec<u8>,
169}
170
171#[derive(Debug)]
172struct Archive {
173    header: ArchiveHeader,
174    writer_version: String,
175    basis_t: u64,
176    metadata: Vec<u8>,
177    records: Vec<TxRecord>,
178    blob_offsets: Vec<(u64, u64)>,
179    durable_len: u64,
180}
181
182fn valid_db_name(name: &str) -> bool {
183    !name.is_empty()
184        && name.len() <= 128
185        && name
186            .bytes()
187            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
188}
189
190fn meta_root_name(db: &str) -> String {
191    format!("meta:{db}")
192}
193
194fn log_path(data_dir: &Path, db: &str) -> PathBuf {
195    data_dir.join("logs").join(format!("{db}.log"))
196}
197
198fn invalid(detail: impl Into<String>) -> BackupError {
199    BackupError::Invalid(detail.into())
200}
201
202fn write_u32(writer: &mut impl Write, value: u32) -> Result<(), BackupError> {
203    writer.write_all(&value.to_be_bytes())?;
204    Ok(())
205}
206
207fn write_u64(writer: &mut impl Write, value: u64) -> Result<(), BackupError> {
208    writer.write_all(&value.to_be_bytes())?;
209    Ok(())
210}
211
212fn read_u32(reader: &mut impl Read) -> Result<u32, BackupError> {
213    let mut bytes = [0; 4];
214    reader.read_exact(&mut bytes)?;
215    Ok(u32::from_be_bytes(bytes))
216}
217
218fn read_u64(reader: &mut impl Read) -> Result<u64, BackupError> {
219    let mut bytes = [0; 8];
220    reader.read_exact(&mut bytes)?;
221    Ok(u64::from_be_bytes(bytes))
222}
223
224fn write_bytes(writer: &mut impl Write, bytes: &[u8]) -> Result<(), BackupError> {
225    write_u64(
226        writer,
227        u64::try_from(bytes.len()).map_err(|_| invalid("backup field is too large"))?,
228    )?;
229    writer.write_all(bytes)?;
230    Ok(())
231}
232
233fn read_bytes(reader: &mut impl Read, field: &str) -> Result<Vec<u8>, BackupError> {
234    let len = usize::try_from(read_u64(reader)?)
235        .map_err(|_| invalid(format!("{field} is too large for this platform")))?;
236    let mut bytes = vec![0; len];
237    reader.read_exact(&mut bytes)?;
238    Ok(bytes)
239}
240
241fn read_string(reader: &mut impl Read, field: &str) -> Result<String, BackupError> {
242    String::from_utf8(read_bytes(reader, field)?)
243        .map_err(|_| invalid(format!("{field} is not UTF-8")))
244}
245
246fn write_frame(writer: &mut impl Write, tag: [u8; 4], payload: &[u8]) -> Result<(), BackupError> {
247    writer.write_all(&tag)?;
248    write_u64(
249        writer,
250        u64::try_from(payload.len()).map_err(|_| invalid("backup frame is too large"))?,
251    )?;
252    writer.write_all(payload)?;
253    Ok(())
254}
255
256fn write_header(writer: &mut impl Write, header: &ArchiveHeader) -> Result<(), BackupError> {
257    writer.write_all(BACKUP_MAGIC)?;
258    write_u32(writer, BACKUP_FORMAT_VERSION)?;
259    write_bytes(writer, header.creator_version.as_bytes())?;
260    write_bytes(writer, header.source_db.as_bytes())?;
261    write_u32(writer, header.storage_format_version)?;
262    write_u64(writer, header.index_basis_t)?;
263    write_bytes(writer, &header.root)
264}
265
266fn encode_checkpoint(
267    basis_t: u64,
268    previous_basis_t: u64,
269    metadata: &[u8],
270    records: &[TxRecord],
271) -> Result<Vec<u8>, BackupError> {
272    let mut framed = Vec::new();
273    for record in records {
274        append_framed_record(&mut framed, record)?;
275    }
276    let mut payload = Vec::new();
277    write_bytes(&mut payload, WRITER_VERSION.as_bytes())?;
278    write_u64(&mut payload, basis_t)?;
279    write_u64(
280        &mut payload,
281        if records.is_empty() {
282            0
283        } else {
284            previous_basis_t.saturating_add(1)
285        },
286    )?;
287    write_u64(
288        &mut payload,
289        u64::try_from(records.len()).map_err(|_| invalid("too many transaction records"))?,
290    )?;
291    write_bytes(&mut payload, metadata)?;
292    write_bytes(&mut payload, &framed)?;
293    payload.extend_from_slice(CHECKPOINT_END);
294    Ok(payload)
295}
296
297fn read_frame_bytes(file: &mut File, frame_end: u64, field: &str) -> Result<Vec<u8>, BackupError> {
298    let len = read_u64(file)?;
299    let start = file.stream_position()?;
300    let end = start
301        .checked_add(len)
302        .ok_or_else(|| invalid(format!("{field} length overflowed")))?;
303    if end > frame_end {
304        return Err(invalid(format!("{field} extends past its checkpoint")));
305    }
306    let len = usize::try_from(len)
307        .map_err(|_| invalid(format!("{field} is too large for this platform")))?;
308    let mut bytes = vec![0; len];
309    file.read_exact(&mut bytes)?;
310    Ok(bytes)
311}
312
313fn decode_checkpoint(
314    file: &mut File,
315    frame_end: u64,
316    previous_basis_t: u64,
317    include_records: bool,
318) -> Result<(String, u64, Vec<u8>, Vec<TxRecord>), BackupError> {
319    let writer_version = String::from_utf8(read_frame_bytes(
320        file,
321        frame_end,
322        "checkpoint writer version",
323    )?)
324    .map_err(|_| invalid("checkpoint writer version is not UTF-8"))?;
325    let basis_t = read_u64(file)?;
326    let start_t = read_u64(file)?;
327    let record_count = read_u64(file)?;
328    let metadata = read_frame_bytes(file, frame_end, "checkpoint metadata")?;
329    let framed_len = read_u64(file)?;
330    let framed_start = file.stream_position()?;
331    let records_end = framed_start
332        .checked_add(framed_len)
333        .ok_or_else(|| invalid("checkpoint transaction-record length overflowed"))?;
334    if records_end
335        .checked_add(4)
336        .is_none_or(|checkpoint_end| checkpoint_end != frame_end)
337    {
338        return Err(invalid(
339            "checkpoint transaction records do not fill their frame",
340        ));
341    }
342    let framed = if include_records {
343        let len = usize::try_from(framed_len)
344            .map_err(|_| invalid("transaction records are too large for this platform"))?;
345        let mut bytes = vec![0; len];
346        file.read_exact(&mut bytes)?;
347        bytes
348    } else {
349        file.seek(SeekFrom::Start(records_end))?;
350        Vec::new()
351    };
352    let mut end = [0; 4];
353    file.read_exact(&mut end)?;
354    if &end != CHECKPOINT_END || file.stream_position()? != frame_end {
355        return Err(invalid("checkpoint has invalid trailing bytes"));
356    }
357    let expected_count = basis_t
358        .checked_sub(previous_basis_t)
359        .ok_or_else(|| invalid("checkpoint basis regressed"))?;
360    let expected_start = if expected_count == 0 {
361        0
362    } else {
363        previous_basis_t
364            .checked_add(1)
365            .ok_or_else(|| invalid("checkpoint basis overflowed"))?
366    };
367    if record_count != expected_count || start_t != expected_start {
368        return Err(invalid("checkpoint transaction range is not contiguous"));
369    }
370    let records = if include_records {
371        let records = decode_framed_records(&framed)?;
372        if records.len() as u64 != record_count {
373            return Err(invalid(
374                "checkpoint record count does not match its payload",
375            ));
376        }
377        if record_count > 0 {
378            validate_records(&records, start_t, basis_t)?;
379        }
380        records
381    } else {
382        Vec::new()
383    };
384    Ok((writer_version, basis_t, metadata, records))
385}
386
387fn read_header(file: &mut File) -> Result<ArchiveHeader, BackupError> {
388    let mut magic = [0; 16];
389    file.read_exact(&mut magic)?;
390    if &magic != BACKUP_MAGIC {
391        return Err(invalid("unknown backup file magic"));
392    }
393    let format_version = read_u32(file)?;
394    let creator_version = read_string(file, "archive creator version")?;
395    if format_version > BACKUP_FORMAT_VERSION {
396        return Err(BackupError::UnsupportedBackupFormat {
397            found: format_version,
398            supported: BACKUP_FORMAT_VERSION,
399            writer: creator_version,
400        });
401    }
402    if format_version != BACKUP_FORMAT_VERSION {
403        return Err(invalid(format!(
404            "unsupported backup file format {format_version}"
405        )));
406    }
407    let source_db = read_string(file, "source database name")?;
408    let storage_format_version = read_u32(file)?;
409    if storage_format_version > FORMAT_VERSION {
410        return Err(BackupError::UnsupportedFormat {
411            found: storage_format_version,
412            supported: FORMAT_VERSION,
413        });
414    }
415    Ok(ArchiveHeader {
416        creator_version,
417        source_db,
418        storage_format_version,
419        index_basis_t: read_u64(file)?,
420        root: read_bytes(file, "database root")?,
421    })
422}
423
424fn read_archive(path: &Path, include_records: bool) -> Result<Archive, BackupError> {
425    if !path.is_file() {
426        return Err(invalid(format!(
427            "{} is not a binary backup file",
428            path.display()
429        )));
430    }
431    let mut file = File::open(path)?;
432    let file_len = file.metadata()?.len();
433    let header = read_header(&mut file)?;
434    let mut writer_version = header.creator_version.clone();
435    let mut basis_t = 0;
436    let mut metadata = Vec::new();
437    let mut records = Vec::new();
438    let mut blob_offsets = Vec::new();
439    let mut durable_len = file.stream_position()?;
440    let mut saw_checkpoint = false;
441    loop {
442        let frame_start = file.stream_position()?;
443        let mut tag = [0; 4];
444        match file.read_exact(&mut tag) {
445            Ok(()) => {}
446            Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break,
447            Err(error) => return Err(error.into()),
448        }
449        let frame_len = match read_u64(&mut file) {
450            Ok(len) => len,
451            Err(BackupError::Io(error)) if error.kind() == io::ErrorKind::UnexpectedEof => break,
452            Err(error) => return Err(error),
453        };
454        let payload_offset = file.stream_position()?;
455        let frame_end = payload_offset
456            .checked_add(frame_len)
457            .ok_or_else(|| invalid("backup frame length overflowed"))?;
458        if frame_end > file_len {
459            break;
460        }
461        if tag == BLOB_TAG {
462            if saw_checkpoint {
463                return Err(invalid("blob frame appears after a checkpoint"));
464            }
465            blob_offsets.push((payload_offset, frame_len));
466            file.seek(SeekFrom::Start(frame_end))?;
467        } else if tag == CHECKPOINT_TAG {
468            saw_checkpoint = true;
469            let (checkpoint_writer, checkpoint_basis, checkpoint_meta, checkpoint_records) =
470                decode_checkpoint(&mut file, frame_end, basis_t, include_records)?;
471            writer_version = checkpoint_writer;
472            basis_t = checkpoint_basis;
473            metadata = checkpoint_meta;
474            records.extend(checkpoint_records);
475        } else {
476            return Err(invalid(format!(
477                "unknown backup frame tag at offset {frame_start}"
478            )));
479        }
480        durable_len = frame_end;
481    }
482    if !saw_checkpoint {
483        return Err(invalid("backup contains no complete checkpoint"));
484    }
485    Ok(Archive {
486        header,
487        writer_version,
488        basis_t,
489        metadata,
490        records,
491        blob_offsets,
492        durable_len,
493    })
494}
495
496/// Writes one blob and everything it references into the binary archive,
497/// children first.
498fn write_blob_tree<'a>(
499    source: &'a dyn BlobStore,
500    id: &'a BlobId,
501    seen: &'a mut std::collections::HashSet<BlobId>,
502    writer: &'a mut File,
503    copied: &'a mut usize,
504) -> Pin<Box<dyn Future<Output = Result<(), BackupError>> + Send + 'a>> {
505    Box::pin(async move {
506        if !seen.insert(id.clone()) {
507            return Ok(());
508        }
509        let bytes = source
510            .get(id)
511            .await?
512            .ok_or_else(|| BackupError::Invalid(format!("root references missing blob {id}")))?;
513        for child in corium_store::index_blob_children(&bytes)? {
514            write_blob_tree(source, &child, seen, writer, copied).await?;
515        }
516        write_frame(writer, BLOB_TAG, &bytes)?;
517        *copied += 1;
518        Ok(())
519    })
520}
521
522fn validate_blob_tree<'a>(
523    store: &'a dyn BlobStore,
524    id: &'a BlobId,
525    seen: &'a mut std::collections::HashSet<BlobId>,
526) -> Pin<Box<dyn Future<Output = Result<(), BackupError>> + Send + 'a>> {
527    Box::pin(async move {
528        if !seen.insert(id.clone()) {
529            return Ok(());
530        }
531        let bytes = store
532            .get(id)
533            .await?
534            .ok_or_else(|| invalid(format!("archive is missing reachable blob {id}")))?;
535        for child in corium_store::index_blob_children(&bytes)? {
536            validate_blob_tree(store, &child, seen).await?;
537        }
538        Ok(())
539    })
540}
541
542fn write_log_atomically(records: &[TxRecord], target: &Path) -> Result<(), BackupError> {
543    if let Some(parent) = target.parent() {
544        fs::create_dir_all(parent)?;
545    }
546    let temporary = target.with_extension("tmp");
547    match fs::remove_file(&temporary) {
548        Ok(()) => {}
549        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
550        Err(error) => return Err(error.into()),
551    }
552    let log = FileLog::open(&temporary)?;
553    for record in records {
554        log.append(record)?;
555    }
556    fs::rename(temporary, target)?;
557    Ok(())
558}
559
560fn bare_root() -> DbRoot {
561    DbRoot {
562        format_version: FORMAT_VERSION,
563        lease_version: 0,
564        owner: String::new(),
565        lease_expires_unix_ms: 0,
566        owner_endpoint: String::new(),
567        index_basis_t: 0,
568        roots: None,
569        next_entity_id: 0,
570        last_tx_instant: i64::MIN,
571    }
572}
573
574fn sanitize_root(mut root: DbRoot) -> DbRoot {
575    root.lease_version = 0;
576    root.owner.clear();
577    root.lease_expires_unix_ms = 0;
578    root.owner_endpoint.clear();
579    root
580}
581
582fn validate_records(
583    records: &[corium_log::TxRecord],
584    start: u64,
585    end_inclusive: u64,
586) -> Result<(), BackupError> {
587    if start > end_inclusive {
588        return Ok(());
589    }
590    let expected = end_inclusive - start + 1;
591    if records.len() as u64 != expected
592        || records.first().map(|record| record.t) != Some(start)
593        || records.last().map(|record| record.t) != Some(end_inclusive)
594        || records.windows(2).any(|pair| pair[1].t != pair[0].t + 1)
595    {
596        return Err(BackupError::Invalid(format!(
597            "source log does not contain a contiguous range {start}..={end_inclusive}"
598        )));
599    }
600    Ok(())
601}
602
603fn append_archive_checkpoint(
604    destination: &Path,
605    archive: &Archive,
606    checkpoint: &[u8],
607    changed: bool,
608) -> Result<String, BackupError> {
609    if !changed {
610        return Ok(archive.writer_version.clone());
611    }
612    let mut file = OpenOptions::new().write(true).open(destination)?;
613    file.set_len(archive.durable_len)?;
614    file.seek(SeekFrom::Start(archive.durable_len))?;
615    write_frame(&mut file, CHECKPOINT_TAG, checkpoint)?;
616    file.sync_all()?;
617    Ok(WRITER_VERSION.to_owned())
618}
619
620async fn create_archive(
621    destination: &Path,
622    db: &str,
623    root: &DbRoot,
624    source_store: &dyn BlobStore,
625    checkpoint: &[u8],
626) -> Result<usize, BackupError> {
627    if let Some(parent) = destination.parent()
628        && !parent.as_os_str().is_empty()
629    {
630        fs::create_dir_all(parent)?;
631    }
632    let mut temporary = destination.as_os_str().to_os_string();
633    temporary.push(".tmp");
634    let temporary = PathBuf::from(temporary);
635    match fs::remove_file(&temporary) {
636        Ok(()) => {}
637        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
638        Err(error) => return Err(error.into()),
639    }
640    let mut file = File::create(&temporary)?;
641    write_header(
642        &mut file,
643        &ArchiveHeader {
644            creator_version: WRITER_VERSION.to_owned(),
645            source_db: db.to_owned(),
646            index_basis_t: root.index_basis_t,
647            storage_format_version: root.format_version,
648            root: root.encode(),
649        },
650    )?;
651    let mut seen = std::collections::HashSet::new();
652    let mut copied = 0;
653    for id in root.roots.iter().flatten() {
654        write_blob_tree(source_store, id, &mut seen, &mut file, &mut copied).await?;
655    }
656    write_frame(&mut file, CHECKPOINT_TAG, checkpoint)?;
657    file.sync_all()?;
658    fs::rename(temporary, destination)?;
659    Ok(copied)
660}
661
662/// Copies one live database into `destination` through a fixed transaction
663/// checkpoint.
664///
665/// `source` comes from [`BackupSource::from_info`], after the transactor fixes
666/// its current `t`. The source log may continue growing; this function reads
667/// only `(old_backup_t, source_t]`. Reusing a destination retains its index
668/// snapshot and appends only missing transaction records.
669///
670/// # Errors
671/// Returns an error if the database is missing, the checkpoint cannot be read
672/// contiguously, the destination belongs to another database, or storage
673/// cannot be opened.
674pub async fn backup(
675    source: &BackupSource,
676    db: &str,
677    destination: impl AsRef<Path>,
678) -> Result<BackupReport, BackupError> {
679    if !valid_db_name(db) {
680        return Err(BackupError::MissingDatabase(db.to_owned()));
681    }
682    let destination = destination.as_ref();
683    let source_store = Arc::new(NodeStore::open_existing(&source.store, &source.data_dir).await?);
684    let source_logs =
685        LogBackend::for_spec(&source.store, &source.data_dir, Arc::clone(&source_store));
686    let db_name = db_root_name(db);
687    let meta_name = meta_root_name(db);
688    let source_db_bytes = source_store
689        .get_root(&db_name)
690        .await?
691        .ok_or_else(|| BackupError::MissingDatabase(db.to_owned()))?;
692    let meta = source_store
693        .get_root(&meta_name)
694        .await?
695        .ok_or_else(|| BackupError::MissingDatabase(db.to_owned()))?;
696    let source_root = DbRoot::decode(&source_db_bytes)
697        .ok_or_else(|| BackupError::Invalid("database root cannot be decoded".into()))?;
698    if source_root.format_version > FORMAT_VERSION {
699        return Err(BackupError::UnsupportedFormat {
700            found: source_root.format_version,
701            supported: FORMAT_VERSION,
702        });
703    }
704    let previous = destination
705        .exists()
706        .then(|| read_archive(destination, false))
707        .transpose()?;
708    if let Some(archive) = &previous {
709        if archive.header.source_db != db {
710            return Err(invalid(format!(
711                "backup belongs to database {:?}, not {db:?}",
712                archive.header.source_db
713            )));
714        }
715        if archive.basis_t > source.basis_t {
716            return Err(invalid(format!(
717                "backup basis {} is ahead of source checkpoint {}",
718                archive.basis_t, source.basis_t
719            )));
720        }
721    }
722    let existing_basis = previous.as_ref().map_or(0, |archive| archive.basis_t);
723    let start = existing_basis.saturating_add(1);
724    let records = if start <= source.basis_t {
725        let end = source
726            .basis_t
727            .checked_add(1)
728            .ok_or_else(|| BackupError::Invalid("source basis is too large".into()))?;
729        let log = source_logs.open_read_only(db).await?;
730        log.tx_range_async(start, Some(end)).await?
731    } else {
732        Vec::new()
733    };
734    validate_records(&records, start, source.basis_t)?;
735
736    // An existing archive's snapshot is its stable replay base. On a first
737    // backup, opportunistically retain the source snapshot only when it is
738    // not ahead of the fixed log checkpoint; otherwise a bare root forces a
739    // correct full-log replay on restore.
740    let root = if let Some(archive) = &previous {
741        let root = DbRoot::decode(&archive.header.root)
742            .ok_or_else(|| invalid("archive database root cannot be decoded"))?;
743        if root.index_basis_t != archive.header.index_basis_t
744            || root.format_version != archive.header.storage_format_version
745        {
746            return Err(invalid("archive header does not match its database root"));
747        }
748        sanitize_root(root)
749    } else if source_root.index_basis_t <= source.basis_t {
750        sanitize_root(source_root)
751    } else {
752        bare_root()
753    };
754    let checkpoint = encode_checkpoint(source.basis_t, existing_basis, &meta, &records)?;
755    let (copied_blobs, writer_version) = if let Some(archive) = &previous {
756        (
757            0,
758            append_archive_checkpoint(destination, archive, &checkpoint, !records.is_empty())?,
759        )
760    } else {
761        (
762            create_archive(destination, db, &root, source_store.as_ref(), &checkpoint).await?,
763            WRITER_VERSION.to_owned(),
764        )
765    };
766    Ok(BackupReport {
767        backup_format_version: BACKUP_FORMAT_VERSION,
768        writer_version,
769        basis_t: source.basis_t,
770        index_basis_t: root.index_basis_t,
771        copied_blobs,
772        reused_blobs: 0,
773        replayed_transactions: records.len(),
774    })
775}
776
777/// Restores a backup under `target_db`, allowing restore-as-clone by choosing
778/// a name different from the archive's source database.
779///
780/// Existing target databases are never overwritten. Immutable blobs already
781/// present in the target store are shared rather than recopied.
782///
783/// # Errors
784/// Returns an error for malformed/incomplete backups, unsupported formats,
785/// existing targets, or I/O failures.
786pub async fn restore(
787    source: impl AsRef<Path>,
788    target_data_dir: impl AsRef<Path>,
789    target_db: &str,
790) -> Result<RestoreReport, BackupError> {
791    if !valid_db_name(target_db) {
792        return Err(BackupError::Invalid(format!(
793            "invalid target database name {target_db:?}"
794        )));
795    }
796    let source = source.as_ref();
797    let target_data_dir = target_data_dir.as_ref();
798    let archive = read_archive(source, true)?;
799    let root = DbRoot::decode(&archive.header.root)
800        .ok_or_else(|| invalid("archive database root cannot be decoded"))?;
801    if root.index_basis_t != archive.header.index_basis_t
802        || root.format_version != archive.header.storage_format_version
803    {
804        return Err(invalid("archive header does not match its database root"));
805    }
806
807    let target_store = FsStore::open(target_data_dir.join("store"))?;
808    let target_db_name = db_root_name(target_db);
809    let target_meta_name = meta_root_name(target_db);
810    if target_store.get_root(&target_db_name).await?.is_some()
811        || target_store.get_root(&target_meta_name).await?.is_some()
812        || log_path(target_data_dir, target_db).exists()
813    {
814        return Err(BackupError::TargetExists(target_db.to_owned()));
815    }
816    let mut backup_file = File::open(source)?;
817    let mut copied_blobs = 0;
818    let mut reused_blobs = 0;
819    for (offset, len) in &archive.blob_offsets {
820        backup_file.seek(SeekFrom::Start(*offset))?;
821        let len =
822            usize::try_from(*len).map_err(|_| invalid("blob is too large for this platform"))?;
823        let mut bytes = vec![0; len];
824        backup_file.read_exact(&mut bytes)?;
825        let id = corium_store::digest(&bytes);
826        if target_store.contains(&id).await? {
827            reused_blobs += 1;
828        } else {
829            let stored = target_store.put(&bytes).await?;
830            if stored != id {
831                return Err(invalid(format!("blob digest changed while restoring {id}")));
832            }
833            copied_blobs += 1;
834        }
835    }
836    let mut seen = std::collections::HashSet::new();
837    for id in root.roots.iter().flatten() {
838        validate_blob_tree(&target_store, id, &mut seen).await?;
839    }
840    let target_log = log_path(target_data_dir, target_db);
841    write_log_atomically(&archive.records, &target_log)?;
842    let db_bytes = archive.header.root;
843    // Metadata is the catalog entry, so publish it last. A node can never
844    // discover a partially restored database.
845    let publish = match target_store
846        .cas_root(&target_db_name, None, &db_bytes)
847        .await
848    {
849        Ok(()) => {
850            target_store
851                .cas_root(&target_meta_name, None, &archive.metadata)
852                .await
853        }
854        Err(error) => Err(error),
855    };
856    if let Err(error) = publish {
857        let _ = target_store.delete_root(&target_db_name).await;
858        let _ = target_store.delete_root(&target_meta_name).await;
859        let _ = fs::remove_file(&target_log);
860        return Err(error.into());
861    }
862    Ok(RestoreReport {
863        backup_format_version: BACKUP_FORMAT_VERSION,
864        writer_version: archive.writer_version,
865        source_db: archive.header.source_db,
866        target_db: target_db.to_owned(),
867        basis_t: archive.basis_t,
868        copied_blobs,
869        reused_blobs,
870    })
871}