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