Skip to main content

mongreldb_core/
pitr.rs

1//! Point-in-time recovery archives built from an online base backup plus
2//! checksummed, transaction-complete logical WAL chunks.
3
4use crate::backup::verify_backup_durable_with_manifest_sha256;
5use crate::durable_file::DurableRoot;
6use crate::epoch::Epoch;
7use crate::wal::{Op, Record};
8use crate::{Database, MongrelError, Result};
9use bincode::Options as _;
10use fs2::FileExt;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::collections::{HashMap, HashSet};
14use std::io::Read;
15use std::path::{Component, Path, PathBuf};
16
17const LEGACY_FORMAT_VERSION: u16 = 1;
18const FORMAT_VERSION: u16 = 2;
19const MANIFEST_FILE: &str = "pitr.json";
20const MAX_MANIFEST_BYTES: u64 = 16 * 1024 * 1024;
21const MAX_CHUNK_BYTES: u64 = 1024 * 1024 * 1024;
22#[cfg(feature = "encryption")]
23const MANIFEST_AUTH_DOMAIN: &[u8] = b"mongreldb/pitr/manifest-auth/v2\0";
24const CHAIN_DOMAIN: &[u8] = b"mongreldb/pitr/chunk-chain/v2\0";
25const GENESIS_DOMAIN: &[u8] = b"mongreldb/pitr/genesis/v2\0";
26#[cfg(feature = "encryption")]
27const CHUNK_KEY_DOMAIN: &[u8] = b"mongreldb/pitr/chunk/v2";
28#[cfg(feature = "encryption")]
29const MANIFEST_KEY_DOMAIN: &[u8] = b"mongreldb/pitr/manifest/v2";
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum PitrTarget {
33    Latest,
34    Epoch(u64),
35    TimestampNanos(u64),
36}
37
38#[derive(Clone, Copy)]
39pub enum PitrCredentials<'a> {
40    /// Filesystem-owner recovery. Authenticated target catalogs are restored
41    /// without validating a database user.
42    None,
43    Encryption(&'a str),
44    /// Filesystem-owner recovery plus validation against the final target
45    /// catalog. Supplying bad credentials fails before publication.
46    User {
47        username: &'a str,
48        password: &'a str,
49    },
50    EncryptionAndUser {
51        passphrase: &'a str,
52        username: &'a str,
53        password: &'a str,
54    },
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58#[serde(deny_unknown_fields)]
59pub struct PitrCommitPoint {
60    pub epoch: u64,
61    pub unix_nanos: u64,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65#[serde(deny_unknown_fields)]
66pub struct PitrChunkRef {
67    pub file: String,
68    pub from_epoch: u64,
69    pub through_epoch: u64,
70    pub records: usize,
71    pub bytes: u64,
72    pub sha256: String,
73    pub commits: Vec<PitrCommitPoint>,
74    #[serde(default, skip_serializing_if = "is_zero")]
75    pub first_sequence: u64,
76    #[serde(default, skip_serializing_if = "is_zero")]
77    pub last_sequence: u64,
78    #[serde(default, skip_serializing_if = "String::is_empty")]
79    pub previous_chain_sha256: String,
80    #[serde(default, skip_serializing_if = "String::is_empty")]
81    pub chain_sha256: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(deny_unknown_fields)]
86pub struct PitrArchiveManifest {
87    pub format_version: u16,
88    pub base_epoch: u64,
89    pub base_unix_nanos: u64,
90    /// SHA-256 of the exact bounded base-backup manifest bytes. Version 2
91    /// authentication and chain genesis bind the base file set through this.
92    #[serde(default, skip_serializing_if = "String::is_empty")]
93    pub base_backup_sha256: String,
94    pub archived_through_epoch: u64,
95    pub last_commit_unix_nanos: u64,
96    pub chunks: Vec<PitrChunkRef>,
97    /// Version 2 chunks are AEAD-encrypted when the source database is encrypted.
98    #[serde(default)]
99    pub encrypted: bool,
100    /// Hash-chain head. The genesis value binds the base backup boundary.
101    #[serde(default, skip_serializing_if = "String::is_empty")]
102    pub chain_sha256: String,
103    /// HMAC-SHA256 over the canonical manifest, present for encrypted v2 archives.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub authentication: Option<String>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct PitrArchiveReport {
110    pub archive: PathBuf,
111    pub from_epoch: u64,
112    pub through_epoch: u64,
113    pub records: usize,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118struct LegacyPitrChunk {
119    format_version: u16,
120    from_epoch: u64,
121    through_epoch: u64,
122    records: Vec<Record>,
123    commits: Vec<PitrCommitPoint>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128struct PitrChunkV2 {
129    format_version: u16,
130    from_epoch: u64,
131    through_epoch: u64,
132    records: Vec<Record>,
133    commits: Vec<PitrCommitPoint>,
134    first_sequence: u64,
135    last_sequence: u64,
136    previous_chain_sha256: String,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141struct PitrChunkEnvelopeV2 {
142    format_version: u16,
143    encrypted: bool,
144    nonce: Option<[u8; 12]>,
145    payload: Vec<u8>,
146}
147
148struct DecodedPitrChunk {
149    from_epoch: u64,
150    through_epoch: u64,
151    records: Vec<Record>,
152    commits: Vec<PitrCommitPoint>,
153    first_sequence: Option<u64>,
154    last_sequence: Option<u64>,
155    previous_chain_sha256: Option<String>,
156}
157
158impl Database {
159    /// Initialize a PITR archive with a consistent online base backup.
160    pub fn create_pitr_archive(&self, destination: impl AsRef<Path>) -> Result<PitrArchiveReport> {
161        self.create_pitr_archive_inner(destination.as_ref(), || Ok(()))
162    }
163
164    fn create_pitr_archive_inner<F>(
165        &self,
166        destination: &Path,
167        before_publish: F,
168    ) -> Result<PitrArchiveReport>
169    where
170        F: FnOnce() -> Result<()>,
171    {
172        let admin = crate::auth::Permission::Admin;
173        self.require(&admin)?;
174        let operation_principal = self.principal_snapshot();
175        let prepared = prepare_destination(destination, "pitr-stage")?;
176        let stage = prepared.parent.open_directory(&prepared.stage_name)?;
177        let mut before_publish = Some(before_publish);
178        let outcome = (|| {
179            let backup = self.hot_backup_to_durable_child(
180                &stage,
181                Path::new("base"),
182                &crate::ExecutionControl::new(None),
183            )?;
184            let base = stage.open_directory("base")?;
185            let (verified_backup, base_backup_sha256) =
186                verify_backup_durable_with_manifest_sha256(&base)?;
187            if verified_backup.epoch != backup.epoch {
188                return Err(MongrelError::Other(format!(
189                    "PITR base backup epoch changed during creation: expected {}, got {}",
190                    backup.epoch, verified_backup.epoch
191                )));
192            }
193            drop(base);
194            let manifest = PitrArchiveManifest {
195                format_version: FORMAT_VERSION,
196                base_epoch: backup.epoch,
197                base_unix_nanos: backup.boundary_unix_nanos,
198                base_backup_sha256: base_backup_sha256.clone(),
199                archived_through_epoch: backup.epoch,
200                last_commit_unix_nanos: backup.boundary_unix_nanos,
201                chunks: Vec::new(),
202                encrypted: self.kek().is_some(),
203                chain_sha256: genesis_chain(
204                    backup.epoch,
205                    backup.boundary_unix_nanos,
206                    &base_backup_sha256,
207                )?,
208                authentication: None,
209            };
210            write_manifest(&stage, &manifest, self.kek().map(AsRef::as_ref))?;
211            let publish = before_publish
212                .take()
213                .ok_or_else(|| MongrelError::Other("PITR publish hook already consumed".into()))?;
214            publish()?;
215            drop(stage);
216            self.with_exact_principal_current(operation_principal.as_ref(), &admin, || {
217                let published = std::cell::Cell::new(false);
218                if let Err(error) = prepared.parent.rename_directory_new_with_after(
219                    &prepared.stage_name,
220                    &prepared.parent,
221                    &prepared.destination_name,
222                    || published.set(true),
223                ) {
224                    if published.get() {
225                        return Err(MongrelError::CommitOutcomeUnknown {
226                            epoch: backup.epoch,
227                            message: format!("PITR archive publication was not durable: {error}"),
228                        });
229                    }
230                    if error.kind() == std::io::ErrorKind::AlreadyExists {
231                        return Err(MongrelError::Conflict(format!(
232                            "PITR archive already exists: {}",
233                            prepared.destination.display()
234                        )));
235                    }
236                    return Err(error.into());
237                }
238                // Keep a practical WAL window for callers that archive periodically.
239                self.set_replication_wal_retention_segments(64);
240                Ok(PitrArchiveReport {
241                    archive: prepared.destination.clone(),
242                    from_epoch: backup.epoch,
243                    through_epoch: backup.epoch,
244                    records: 0,
245                })
246            })
247        })();
248        if outcome.is_err() {
249            let _ = prepared.parent.remove_directory_all(&prepared.stage_name);
250        }
251        outcome
252    }
253
254    /// Append all complete commits since the archive watermark. Spilled-run
255    /// commits are converted to ordinary logical Put records while their run
256    /// payload remains available. A retention gap fails closed.
257    pub fn archive_pitr(&self, archive: impl AsRef<Path>) -> Result<PitrArchiveReport> {
258        self.archive_pitr_inner(archive.as_ref(), || Ok(()))
259    }
260
261    fn archive_pitr_inner<F>(&self, archive: &Path, before_publish: F) -> Result<PitrArchiveReport>
262    where
263        F: FnOnce() -> Result<()>,
264    {
265        let admin = crate::auth::Permission::Admin;
266        self.require(&admin)?;
267        let operation_principal = self.principal_snapshot();
268        let archive = DurableRoot::open(archive)?;
269        let archive_path = archive.canonical_path().to_path_buf();
270        let lock = archive.open_lock_file(".archive.lock")?;
271        lock.lock_exclusive()?;
272        let mut manifest = read_pitr_manifest_from_root(&archive)?;
273        validate_archive_key(&manifest, self.kek().map(AsRef::as_ref))?;
274        verify_manifest_authentication(&manifest, self.kek().map(AsRef::as_ref))?;
275        if manifest.format_version == LEGACY_FORMAT_VERSION {
276            return Err(MongrelError::Conflict(
277                "legacy PITR archives are restore-only; create a new version 2 archive".into(),
278            ));
279        }
280        let from_epoch = manifest.archived_through_epoch;
281        let batch = self.replication_batch_since(from_epoch)?;
282        if batch.current_epoch == from_epoch {
283            return Ok(PitrArchiveReport {
284                archive: archive_path,
285                from_epoch,
286                through_epoch: from_epoch,
287                records: 0,
288            });
289        }
290        if batch.retention_gap {
291            return Err(MongrelError::Conflict(format!(
292                "PITR WAL retention gap after epoch {from_epoch}; create a new base archive"
293            )));
294        }
295
296        let mut records = materialize_spilled_records(self, batch.records)?;
297        if records.is_empty() {
298            return Err(MongrelError::Conflict(
299                "PITR source advanced but no complete WAL transactions remain".into(),
300            ));
301        }
302        let minimum_sequence = manifest
303            .chunks
304            .last()
305            .map(|reference| {
306                reference.last_sequence.checked_add(1).ok_or_else(|| {
307                    MongrelError::Conflict("PITR record sequence space exhausted".into())
308                })
309            })
310            .transpose()?;
311        normalize_record_sequences(&mut records, minimum_sequence)?;
312        let first_sequence = records
313            .first()
314            .map(|record| record.seq.0)
315            .ok_or_else(|| MongrelError::Conflict("PITR batch has no records".into()))?;
316        let last_sequence = records
317            .last()
318            .map(|record| record.seq.0)
319            .ok_or_else(|| MongrelError::Conflict("PITR batch has no records".into()))?;
320        let mut timestamps = HashMap::new();
321        let mut commit_epochs = Vec::new();
322        for record in &records {
323            match record.op {
324                Op::CommitTimestamp { unix_nanos } => {
325                    timestamps.insert(record.txn_id, unix_nanos);
326                }
327                Op::TxnCommit { epoch, .. } => commit_epochs.push((record.txn_id, epoch)),
328                _ => {}
329            }
330        }
331        commit_epochs.sort_by_key(|(_, epoch)| *epoch);
332        let archive_time = unix_nanos();
333        let mut last_timestamp = manifest.last_commit_unix_nanos;
334        let commits = commit_epochs
335            .into_iter()
336            .map(|(txn_id, epoch)| {
337                let timestamp = timestamps
338                    .get(&txn_id)
339                    .copied()
340                    .unwrap_or(archive_time)
341                    .max(last_timestamp);
342                last_timestamp = timestamp;
343                PitrCommitPoint {
344                    epoch,
345                    unix_nanos: timestamp,
346                }
347            })
348            .collect::<Vec<_>>();
349        let through_epoch = commits
350            .last()
351            .map(|commit| commit.epoch)
352            .ok_or_else(|| MongrelError::Conflict("PITR batch has no commit marker".into()))?;
353        let previous_chain_sha256 = manifest.chain_sha256.clone();
354        let chunk = PitrChunkV2 {
355            format_version: FORMAT_VERSION,
356            from_epoch,
357            through_epoch,
358            records,
359            commits: commits.clone(),
360            first_sequence,
361            last_sequence,
362            previous_chain_sha256: previous_chain_sha256.clone(),
363        };
364        let file = chunk_file_name(from_epoch, through_epoch);
365        let bytes = encode_or_reuse_chunk_v2(
366            &archive,
367            Path::new(&file),
368            &chunk,
369            self.kek().map(AsRef::as_ref),
370        )?;
371        let chunk_sha256 = sha256_bytes(&bytes);
372        let chain_sha256 = next_chain(
373            &previous_chain_sha256,
374            &chunk_sha256,
375            from_epoch,
376            through_epoch,
377            chunk.records.len(),
378            first_sequence,
379            last_sequence,
380        )?;
381        before_publish()?;
382        self.with_exact_principal_current(operation_principal.as_ref(), &admin, || {
383            publish_chunk(&archive, Path::new(&file), &bytes, &chunk_sha256)?;
384            manifest.chunks.push(PitrChunkRef {
385                file,
386                from_epoch,
387                through_epoch,
388                records: chunk.records.len(),
389                bytes: bytes.len() as u64,
390                sha256: chunk_sha256,
391                commits,
392                first_sequence,
393                last_sequence,
394                previous_chain_sha256,
395                chain_sha256: chain_sha256.clone(),
396            });
397            manifest.archived_through_epoch = through_epoch;
398            manifest.last_commit_unix_nanos = last_timestamp;
399            manifest.chain_sha256 = chain_sha256;
400            let manifest_published = std::cell::Cell::new(false);
401            let publication = write_manifest_with_after(
402                &archive,
403                &manifest,
404                self.kek().map(AsRef::as_ref),
405                || manifest_published.set(true),
406            );
407            finish_manifest_publication(publication, manifest_published.get(), through_epoch)?;
408            Ok(PitrArchiveReport {
409                archive: archive_path,
410                from_epoch,
411                through_epoch,
412                records: chunk.records.len(),
413            })
414        })
415    }
416}
417
418pub fn read_pitr_manifest(archive: impl AsRef<Path>) -> Result<PitrArchiveManifest> {
419    let archive = DurableRoot::open(archive)?;
420    read_pitr_manifest_from_root(&archive)
421}
422
423fn read_pitr_manifest_from_root(archive: &DurableRoot) -> Result<PitrArchiveManifest> {
424    let source = archive.open_regular(MANIFEST_FILE)?;
425    let length = source.metadata()?.len();
426    if length > MAX_MANIFEST_BYTES {
427        return Err(MongrelError::InvalidArgument(format!(
428            "PITR manifest exceeds {MAX_MANIFEST_BYTES} bytes"
429        )));
430    }
431    let mut bytes = Vec::with_capacity(length as usize);
432    source
433        .take(MAX_MANIFEST_BYTES.saturating_add(1))
434        .read_to_end(&mut bytes)?;
435    if bytes.len() as u64 > MAX_MANIFEST_BYTES {
436        return Err(MongrelError::InvalidArgument(format!(
437            "PITR manifest exceeds {MAX_MANIFEST_BYTES} bytes"
438        )));
439    }
440    let manifest: PitrArchiveManifest = serde_json::from_slice(&bytes)
441        .map_err(|error| MongrelError::InvalidArgument(format!("PITR manifest: {error}")))?;
442    validate_manifest_structure(&manifest)?;
443    Ok(manifest)
444}
445
446/// Restore an archive to a new directory at an epoch or timestamp cutoff.
447pub fn restore_pitr(
448    archive: impl AsRef<Path>,
449    destination: impl AsRef<Path>,
450    target: PitrTarget,
451    credentials: PitrCredentials<'_>,
452) -> Result<u64> {
453    restore_pitr_inner(
454        archive.as_ref(),
455        destination.as_ref(),
456        target,
457        credentials,
458        |_| Ok(()),
459    )
460}
461
462fn restore_pitr_inner<F>(
463    archive: &Path,
464    destination: &Path,
465    target: PitrTarget,
466    credentials: PitrCredentials<'_>,
467    after_stage_created: F,
468) -> Result<u64>
469where
470    F: FnOnce(&Path) -> Result<()>,
471{
472    let archive = DurableRoot::open(archive)?;
473    let manifest = read_pitr_manifest_from_root(&archive)?;
474    let base = archive.open_directory("base")?;
475    let (backup_manifest, base_backup_sha256) = verify_backup_durable_with_manifest_sha256(&base)?;
476    let archive_kek = derive_archive_kek(&archive, &manifest, credentials)?;
477    verify_manifest_authentication(&manifest, archive_kek.as_ref())?;
478    if backup_manifest.epoch != manifest.base_epoch {
479        return invalid_pitr(format!(
480            "PITR base backup epoch mismatch: expected {}, got {}",
481            manifest.base_epoch, backup_manifest.epoch
482        ));
483    }
484    if manifest.format_version == FORMAT_VERSION
485        && base_backup_sha256 != manifest.base_backup_sha256
486    {
487        return invalid_pitr("PITR base backup manifest does not match the archive manifest");
488    }
489    let target_epoch = resolve_target_epoch(&manifest, target)?;
490    let records = load_records_through(&archive, &manifest, target_epoch, archive_kek.as_ref())?;
491    let prepared = prepare_destination(destination, "pitr-restore")?;
492    if prepared
493        .parent
494        .canonical_path()
495        .starts_with(archive.canonical_path())
496    {
497        prepared.parent.remove_directory_all(&prepared.stage_name)?;
498        return Err(MongrelError::InvalidArgument(
499            "PITR restore destination must not be inside the archive".into(),
500        ));
501    }
502    let stage = prepared.parent.open_directory(&prepared.stage_name)?;
503    let stage_path = prepared.parent.canonical_path().join(&prepared.stage_name);
504    let mut after_stage_created = Some(after_stage_created);
505    let outcome = (|| {
506        let hook = after_stage_created
507            .take()
508            .ok_or_else(|| MongrelError::Other("PITR restore hook already consumed".into()))?;
509        hook(&stage_path)?;
510        copy_tree(&base, &stage)?;
511        // The archive may be updated concurrently after the base was verified
512        // above. Verify the exact copied tree before replaying anything so a
513        // mixed-generation copy can never become the published destination.
514        let (staged_backup_manifest, staged_backup_sha256) =
515            verify_backup_durable_with_manifest_sha256(&stage)?;
516        if staged_backup_manifest != backup_manifest || staged_backup_sha256 != base_backup_sha256 {
517            return invalid_pitr(
518                "PITR base backup changed while the restore staging copy was created",
519            );
520        }
521        let meta = stage.create_directory_all_pinned("_meta")?;
522        meta.write_atomic("replica", b"PITR restore staging\n")?;
523        meta.write_atomic("repl_epoch", manifest.base_epoch.to_string().as_bytes())?;
524
525        if !records.is_empty() {
526            let earliest_epoch = records.iter().filter_map(|record| match record.op {
527                Op::TxnCommit { epoch, .. } => Some(epoch),
528                _ => None,
529            });
530            let batch = crate::replication::ReplicationBatch::complete(
531                manifest.base_epoch,
532                target_epoch,
533                earliest_epoch.min(),
534                false,
535                false,
536                records,
537            )?;
538            let replica = open_recovery_staging(&stage, credentials)?;
539            replica.append_replication_batch(&batch)?;
540            drop(replica);
541        }
542        let recovered = open_recovery_staging(&stage, credentials)?;
543        if recovered.visible_epoch().0 < target_epoch {
544            return Err(MongrelError::Other(format!(
545                "PITR recovery stopped at epoch {}, expected {target_epoch}",
546                recovered.visible_epoch().0
547            )));
548        }
549        validate_target_user_credentials(&recovered, credentials)?;
550        drop(recovered);
551        meta.remove_file("replica")?;
552        meta.remove_file("repl_epoch")?;
553        drop(meta);
554        drop(stage);
555        let published = std::cell::Cell::new(false);
556        if let Err(error) = prepared.parent.rename_directory_new_with_after(
557            &prepared.stage_name,
558            &prepared.parent,
559            &prepared.destination_name,
560            || published.set(true),
561        ) {
562            if published.get() {
563                return Err(MongrelError::CommitOutcomeUnknown {
564                    epoch: target_epoch,
565                    message: format!("PITR restore publication was not durable: {error}"),
566                });
567            }
568            if error.kind() == std::io::ErrorKind::AlreadyExists {
569                return Err(MongrelError::Conflict(format!(
570                    "PITR destination already exists: {}",
571                    prepared.destination.display()
572                )));
573            }
574            return Err(error.into());
575        }
576        Ok(target_epoch)
577    })();
578    if outcome.is_err() {
579        let _ = prepared.parent.remove_directory_all(&prepared.stage_name);
580    }
581    outcome
582}
583
584fn validate_manifest_structure(manifest: &PitrArchiveManifest) -> Result<()> {
585    if !matches!(
586        manifest.format_version,
587        LEGACY_FORMAT_VERSION | FORMAT_VERSION
588    ) {
589        return invalid_pitr(format!(
590            "unsupported PITR archive version {}",
591            manifest.format_version
592        ));
593    }
594    if manifest.archived_through_epoch < manifest.base_epoch {
595        return invalid_pitr("PITR archive watermark predates its base backup");
596    }
597    if manifest.last_commit_unix_nanos < manifest.base_unix_nanos {
598        return invalid_pitr("PITR archive timestamp predates its base backup");
599    }
600
601    let v2 = manifest.format_version == FORMAT_VERSION;
602    let genesis = if v2 {
603        validate_sha256(
604            &manifest.base_backup_sha256,
605            "PITR base backup manifest checksum",
606        )?;
607        genesis_chain(
608            manifest.base_epoch,
609            manifest.base_unix_nanos,
610            &manifest.base_backup_sha256,
611        )?
612    } else {
613        String::new()
614    };
615    if v2 {
616        validate_sha256(&manifest.chain_sha256, "PITR chain head")?;
617        match (&manifest.authentication, manifest.encrypted) {
618            (Some(authentication), true) => {
619                validate_sha256(authentication, "PITR manifest authentication")?;
620            }
621            (None, false) => {}
622            (None, true) => {
623                return invalid_pitr("encrypted PITR manifest lacks authentication");
624            }
625            (Some(_), false) => {
626                return invalid_pitr("plaintext PITR manifest has unexpected authentication");
627            }
628        }
629    } else if manifest.encrypted
630        || !manifest.base_backup_sha256.is_empty()
631        || !manifest.chain_sha256.is_empty()
632        || manifest.authentication.is_some()
633    {
634        return invalid_pitr("legacy PITR manifest contains version 2 fields");
635    }
636
637    let mut expected_from = manifest.base_epoch;
638    let mut previous_commit_epoch = manifest.base_epoch;
639    let mut previous_timestamp = manifest.base_unix_nanos;
640    let mut previous_chain = genesis;
641    let mut previous_sequence = None;
642    for reference in &manifest.chunks {
643        validate_chunk_reference_path(reference)?;
644        if reference.from_epoch != expected_from {
645            return invalid_pitr(format!(
646                "PITR chunk {} is not contiguous: expected from_epoch {expected_from}, got {}",
647                reference.file, reference.from_epoch
648            ));
649        }
650        if reference.through_epoch <= reference.from_epoch {
651            return invalid_pitr(format!(
652                "PITR chunk {} has an empty or reversed epoch range",
653                reference.file
654            ));
655        }
656        if reference.records == 0 || reference.bytes == 0 || reference.bytes > MAX_CHUNK_BYTES {
657            return invalid_pitr(format!(
658                "PITR chunk {} has invalid record or byte counts",
659                reference.file
660            ));
661        }
662        validate_sha256(&reference.sha256, "PITR chunk checksum")?;
663        if reference.commits.is_empty() {
664            return invalid_pitr(format!(
665                "PITR chunk {} has no commit points",
666                reference.file
667            ));
668        }
669        for commit in &reference.commits {
670            if commit.epoch <= previous_commit_epoch
671                || commit.epoch <= reference.from_epoch
672                || commit.epoch > reference.through_epoch
673            {
674                return invalid_pitr(format!(
675                    "PITR chunk {} has an invalid commit epoch {}",
676                    reference.file, commit.epoch
677                ));
678            }
679            if commit.unix_nanos < previous_timestamp {
680                return invalid_pitr(format!(
681                    "PITR chunk {} has a decreasing commit timestamp",
682                    reference.file
683                ));
684            }
685            previous_commit_epoch = commit.epoch;
686            previous_timestamp = commit.unix_nanos;
687        }
688        if previous_commit_epoch != reference.through_epoch {
689            return invalid_pitr(format!(
690                "PITR chunk {} does not end at its final commit",
691                reference.file
692            ));
693        }
694        if v2 {
695            if reference.first_sequence > reference.last_sequence
696                || reference
697                    .last_sequence
698                    .checked_sub(reference.first_sequence)
699                    .and_then(|span| span.checked_add(1))
700                    != u64::try_from(reference.records).ok()
701                || previous_sequence.is_some_and(|previous| reference.first_sequence <= previous)
702            {
703                return invalid_pitr(format!(
704                    "PITR chunk {} has an invalid record sequence range",
705                    reference.file
706                ));
707            }
708            validate_sha256(&reference.previous_chain_sha256, "PITR previous chain hash")?;
709            validate_sha256(&reference.chain_sha256, "PITR chain hash")?;
710            if reference.previous_chain_sha256 != previous_chain {
711                return invalid_pitr(format!(
712                    "PITR chunk {} breaks the previous-chain link",
713                    reference.file
714                ));
715            }
716            let expected_chain = next_chain(
717                &previous_chain,
718                &reference.sha256,
719                reference.from_epoch,
720                reference.through_epoch,
721                reference.records,
722                reference.first_sequence,
723                reference.last_sequence,
724            )?;
725            if reference.chain_sha256 != expected_chain {
726                return invalid_pitr(format!(
727                    "PITR chunk {} has an invalid chain hash",
728                    reference.file
729                ));
730            }
731            previous_chain = expected_chain;
732            previous_sequence = Some(reference.last_sequence);
733        } else if !reference.previous_chain_sha256.is_empty()
734            || !reference.chain_sha256.is_empty()
735            || reference.first_sequence != 0
736            || reference.last_sequence != 0
737        {
738            return invalid_pitr(format!(
739                "legacy PITR chunk {} contains version 2 chain fields",
740                reference.file
741            ));
742        }
743        expected_from = reference.through_epoch;
744    }
745
746    if expected_from != manifest.archived_through_epoch {
747        return invalid_pitr("PITR archive watermark does not match its final chunk");
748    }
749    if previous_timestamp != manifest.last_commit_unix_nanos {
750        return invalid_pitr("PITR archive timestamp does not match its final commit");
751    }
752    if v2 && manifest.chain_sha256 != previous_chain {
753        return invalid_pitr("PITR manifest chain head does not match its chunks");
754    }
755    Ok(())
756}
757
758fn validate_chunk_reference_path(reference: &PitrChunkRef) -> Result<()> {
759    let path = Path::new(&reference.file);
760    if path.components().count() != 1
761        || !matches!(path.components().next(), Some(Component::Normal(_)))
762        || reference.file != chunk_file_name(reference.from_epoch, reference.through_epoch)
763    {
764        return invalid_pitr(format!("invalid PITR chunk path {:?}", reference.file));
765    }
766    Ok(())
767}
768
769fn chunk_file_name(from_epoch: u64, through_epoch: u64) -> String {
770    format!("wal-{from_epoch:020}-{through_epoch:020}.bin")
771}
772
773fn genesis_chain(
774    base_epoch: u64,
775    base_unix_nanos: u64,
776    base_backup_sha256: &str,
777) -> Result<String> {
778    let base_backup = decode_sha256(base_backup_sha256, "PITR base backup manifest checksum")?;
779    let mut hasher = Sha256::new();
780    hasher.update(GENESIS_DOMAIN);
781    hasher.update(base_epoch.to_be_bytes());
782    hasher.update(base_unix_nanos.to_be_bytes());
783    hasher.update(base_backup);
784    Ok(hex_bytes(&hasher.finalize()))
785}
786
787fn next_chain(
788    previous_chain_sha256: &str,
789    chunk_sha256: &str,
790    from_epoch: u64,
791    through_epoch: u64,
792    records: usize,
793    first_sequence: u64,
794    last_sequence: u64,
795) -> Result<String> {
796    let previous = decode_sha256(previous_chain_sha256, "PITR previous chain hash")?;
797    let chunk = decode_sha256(chunk_sha256, "PITR chunk checksum")?;
798    let records = u64::try_from(records)
799        .map_err(|_| MongrelError::InvalidArgument("PITR record count is too large".into()))?;
800    let mut hasher = Sha256::new();
801    hasher.update(CHAIN_DOMAIN);
802    hasher.update(previous);
803    hasher.update(chunk);
804    hasher.update(from_epoch.to_be_bytes());
805    hasher.update(through_epoch.to_be_bytes());
806    hasher.update(records.to_be_bytes());
807    hasher.update(first_sequence.to_be_bytes());
808    hasher.update(last_sequence.to_be_bytes());
809    Ok(hex_bytes(&hasher.finalize()))
810}
811
812fn encode_chunk_v2(chunk: &PitrChunkV2, kek: Option<&crate::encryption::Kek>) -> Result<Vec<u8>> {
813    let plaintext = bincode::DefaultOptions::new()
814        .with_fixint_encoding()
815        .reject_trailing_bytes()
816        .with_limit(MAX_CHUNK_BYTES)
817        .serialize(chunk)?;
818    let (encrypted, nonce, payload) = match kek {
819        Some(kek) => encrypt_chunk_payload(kek, &plaintext)?,
820        None => (false, None, plaintext),
821    };
822    let envelope = PitrChunkEnvelopeV2 {
823        format_version: FORMAT_VERSION,
824        encrypted,
825        nonce,
826        payload,
827    };
828    let bytes = bincode::DefaultOptions::new()
829        .with_fixint_encoding()
830        .reject_trailing_bytes()
831        .with_limit(MAX_CHUNK_BYTES)
832        .serialize(&envelope)?;
833    if bytes.len() as u64 > MAX_CHUNK_BYTES {
834        return invalid_pitr("PITR chunk exceeds maximum size");
835    }
836    Ok(bytes)
837}
838
839fn encode_or_reuse_chunk_v2(
840    root: &DurableRoot,
841    path: &Path,
842    expected: &PitrChunkV2,
843    kek: Option<&crate::encryption::Kek>,
844) -> Result<Vec<u8>> {
845    let source = match root.open_regular(path) {
846        Ok(source) => source,
847        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
848            return encode_chunk_v2(expected, kek)
849        }
850        Err(error) => return Err(error.into()),
851    };
852    let length = source.metadata()?.len();
853    if length == 0 || length > MAX_CHUNK_BYTES {
854        return Err(MongrelError::Conflict(format!(
855            "PITR orphan chunk {} has an invalid length",
856            path.display()
857        )));
858    }
859    let mut bytes = Vec::with_capacity(length as usize);
860    source
861        .take(MAX_CHUNK_BYTES.saturating_add(1))
862        .read_to_end(&mut bytes)?;
863    if bytes.len() as u64 > MAX_CHUNK_BYTES {
864        return Err(MongrelError::Conflict(format!(
865            "PITR orphan chunk {} exceeds maximum size",
866            path.display()
867        )));
868    }
869    let decoded = decode_chunk(FORMAT_VERSION, kek.is_some(), &bytes, kek).map_err(|error| {
870        MongrelError::Conflict(format!(
871            "PITR orphan chunk {} cannot be reused: {error}",
872            path.display()
873        ))
874    })?;
875    let expected_records = bincode::DefaultOptions::new()
876        .with_fixint_encoding()
877        .serialize(&expected.records)?;
878    let actual_records = bincode::DefaultOptions::new()
879        .with_fixint_encoding()
880        .serialize(&decoded.records)?;
881    if decoded.from_epoch != expected.from_epoch
882        || decoded.through_epoch != expected.through_epoch
883        || decoded.commits != expected.commits
884        || decoded.first_sequence != Some(expected.first_sequence)
885        || decoded.last_sequence != Some(expected.last_sequence)
886        || decoded.previous_chain_sha256.as_deref() != Some(expected.previous_chain_sha256.as_str())
887        || actual_records != expected_records
888    {
889        return Err(MongrelError::Conflict(format!(
890            "PITR orphan chunk {} does not match retry payload",
891            path.display()
892        )));
893    }
894    Ok(bytes)
895}
896
897#[cfg(feature = "encryption")]
898fn encrypt_chunk_payload(
899    kek: &crate::encryption::Kek,
900    plaintext: &[u8],
901) -> Result<(bool, Option<[u8; 12]>, Vec<u8>)> {
902    use crate::encryption::Cipher as _;
903
904    let mut nonce = [0u8; 12];
905    crate::encryption::fill_random(&mut nonce)?;
906    let key = kek.derive_subkey(CHUNK_KEY_DOMAIN);
907    let cipher = crate::encryption::AesCipher::new(key.as_ref())?;
908    Ok((true, Some(nonce), cipher.encrypt_page(&nonce, plaintext)?))
909}
910
911#[cfg(not(feature = "encryption"))]
912fn encrypt_chunk_payload(
913    _kek: &crate::encryption::Kek,
914    _plaintext: &[u8],
915) -> Result<(bool, Option<[u8; 12]>, Vec<u8>)> {
916    unreachable!("Kek is unconstructable without the encryption feature")
917}
918
919fn decode_chunk(
920    format_version: u16,
921    encrypted: bool,
922    bytes: &[u8],
923    kek: Option<&crate::encryption::Kek>,
924) -> Result<DecodedPitrChunk> {
925    if format_version == LEGACY_FORMAT_VERSION {
926        let chunk: LegacyPitrChunk = bincode::DefaultOptions::new()
927            .with_fixint_encoding()
928            .reject_trailing_bytes()
929            .with_limit(MAX_CHUNK_BYTES)
930            .deserialize(bytes)?;
931        if chunk.format_version != LEGACY_FORMAT_VERSION {
932            return invalid_pitr(format!(
933                "unsupported legacy PITR chunk version {}",
934                chunk.format_version
935            ));
936        }
937        return Ok(DecodedPitrChunk {
938            from_epoch: chunk.from_epoch,
939            through_epoch: chunk.through_epoch,
940            records: chunk.records,
941            commits: chunk.commits,
942            first_sequence: None,
943            last_sequence: None,
944            previous_chain_sha256: None,
945        });
946    }
947
948    let envelope: PitrChunkEnvelopeV2 = bincode::DefaultOptions::new()
949        .with_fixint_encoding()
950        .reject_trailing_bytes()
951        .with_limit(MAX_CHUNK_BYTES)
952        .deserialize(bytes)?;
953    if envelope.format_version != FORMAT_VERSION || envelope.encrypted != encrypted {
954        return invalid_pitr("PITR chunk envelope does not match its manifest");
955    }
956    let plaintext = match (envelope.encrypted, envelope.nonce, kek) {
957        (false, None, None) => envelope.payload,
958        (true, Some(nonce), Some(kek)) => decrypt_chunk_payload(kek, &nonce, &envelope.payload)?,
959        (true, None, _) => return invalid_pitr("encrypted PITR chunk lacks a nonce"),
960        (true, Some(_), None) => {
961            return Err(MongrelError::Encryption(
962                "encrypted PITR chunk requires its database passphrase".into(),
963            ));
964        }
965        (false, Some(_), _) => return invalid_pitr("plaintext PITR chunk has a nonce"),
966        (false, None, Some(_)) => {
967            return invalid_pitr("plaintext PITR chunk was opened with an encryption key");
968        }
969    };
970    let chunk: PitrChunkV2 = bincode::DefaultOptions::new()
971        .with_fixint_encoding()
972        .reject_trailing_bytes()
973        .with_limit(MAX_CHUNK_BYTES)
974        .deserialize(&plaintext)?;
975    if chunk.format_version != FORMAT_VERSION {
976        return invalid_pitr(format!(
977            "unsupported PITR chunk version {}",
978            chunk.format_version
979        ));
980    }
981    Ok(DecodedPitrChunk {
982        from_epoch: chunk.from_epoch,
983        through_epoch: chunk.through_epoch,
984        records: chunk.records,
985        commits: chunk.commits,
986        first_sequence: Some(chunk.first_sequence),
987        last_sequence: Some(chunk.last_sequence),
988        previous_chain_sha256: Some(chunk.previous_chain_sha256),
989    })
990}
991
992#[cfg(feature = "encryption")]
993fn decrypt_chunk_payload(
994    kek: &crate::encryption::Kek,
995    nonce: &[u8; 12],
996    ciphertext: &[u8],
997) -> Result<Vec<u8>> {
998    use crate::encryption::Cipher as _;
999
1000    let key = kek.derive_subkey(CHUNK_KEY_DOMAIN);
1001    crate::encryption::AesCipher::new(key.as_ref())?.decrypt_page(nonce, ciphertext)
1002}
1003
1004#[cfg(not(feature = "encryption"))]
1005fn decrypt_chunk_payload(
1006    _kek: &crate::encryption::Kek,
1007    _nonce: &[u8; 12],
1008    _ciphertext: &[u8],
1009) -> Result<Vec<u8>> {
1010    unreachable!("Kek is unconstructable without the encryption feature")
1011}
1012
1013fn validate_chunk(
1014    reference: &PitrChunkRef,
1015    chunk: &DecodedPitrChunk,
1016    format_version: u16,
1017    preceding_sequence: Option<u64>,
1018) -> Result<Option<u64>> {
1019    if chunk.from_epoch != reference.from_epoch
1020        || chunk.through_epoch != reference.through_epoch
1021        || chunk.records.len() != reference.records
1022        || chunk.commits != reference.commits
1023        || (format_version == FORMAT_VERSION
1024            && (chunk.first_sequence != Some(reference.first_sequence)
1025                || chunk.last_sequence != Some(reference.last_sequence)))
1026    {
1027        return invalid_pitr(format!(
1028            "PITR chunk {} body does not match its manifest reference",
1029            reference.file
1030        ));
1031    }
1032    match (format_version, &chunk.previous_chain_sha256) {
1033        (FORMAT_VERSION, Some(previous)) if previous == &reference.previous_chain_sha256 => {}
1034        (LEGACY_FORMAT_VERSION, None) => {}
1035        _ => {
1036            return invalid_pitr(format!(
1037                "PITR chunk {} body has an invalid previous-chain link",
1038                reference.file
1039            ));
1040        }
1041    }
1042
1043    let mut seen = HashSet::new();
1044    let mut committed = HashSet::new();
1045    let mut commit_epochs = Vec::new();
1046    let mut commit_txns = Vec::new();
1047    let mut commit_timestamps = HashMap::new();
1048    let mut previous_sequence = preceding_sequence.map(Epoch);
1049    for record in &chunk.records {
1050        if record.txn_id == crate::wal::SYSTEM_TXN_ID {
1051            return invalid_pitr(format!(
1052                "PITR chunk {} contains a system transaction",
1053                reference.file
1054            ));
1055        }
1056        let invalid_sequence = previous_sequence.is_some_and(|previous: Epoch| {
1057            if format_version == FORMAT_VERSION {
1058                record.seq <= previous
1059            } else {
1060                record.seq < previous
1061            }
1062        });
1063        if invalid_sequence {
1064            return invalid_pitr(format!(
1065                "PITR chunk {} has duplicate or decreasing record sequence numbers",
1066                reference.file
1067            ));
1068        }
1069        previous_sequence = Some(record.seq);
1070        if committed.contains(&record.txn_id) {
1071            return invalid_pitr(format!(
1072                "PITR chunk {} contains records after a transaction commit",
1073                reference.file
1074            ));
1075        }
1076        seen.insert(record.txn_id);
1077        if let Op::CommitTimestamp { unix_nanos } = record.op {
1078            if commit_timestamps
1079                .insert(record.txn_id, unix_nanos)
1080                .is_some()
1081            {
1082                return invalid_pitr(format!(
1083                    "PITR chunk {} contains duplicate commit timestamps",
1084                    reference.file
1085                ));
1086            }
1087        }
1088        if let Op::TxnCommit { epoch, .. } = record.op {
1089            if !committed.insert(record.txn_id) {
1090                return invalid_pitr(format!(
1091                    "PITR chunk {} contains a duplicate transaction commit",
1092                    reference.file
1093                ));
1094            }
1095            commit_epochs.push(epoch);
1096            commit_txns.push(record.txn_id);
1097        }
1098    }
1099    if seen != committed {
1100        return invalid_pitr(format!(
1101            "PITR chunk {} contains an incomplete transaction",
1102            reference.file
1103        ));
1104    }
1105    let expected_epochs = reference
1106        .commits
1107        .iter()
1108        .map(|commit| commit.epoch)
1109        .collect::<Vec<_>>();
1110    if commit_epochs != expected_epochs {
1111        return invalid_pitr(format!(
1112            "PITR chunk {} commit markers do not match its manifest",
1113            reference.file
1114        ));
1115    }
1116    for (index, txn_id) in commit_txns.into_iter().enumerate() {
1117        if commit_timestamps
1118            .get(&txn_id)
1119            .is_some_and(|timestamp| *timestamp != reference.commits[index].unix_nanos)
1120        {
1121            return invalid_pitr(format!(
1122                "PITR chunk {} commit timestamp does not match its body",
1123                reference.file
1124            ));
1125        }
1126    }
1127    if format_version == FORMAT_VERSION
1128        && (chunk.records.first().map(|record| record.seq.0) != Some(reference.first_sequence)
1129            || chunk.records.last().map(|record| record.seq.0) != Some(reference.last_sequence))
1130    {
1131        return invalid_pitr(format!(
1132            "PITR chunk {} record sequence bounds do not match its body",
1133            reference.file
1134        ));
1135    }
1136    Ok(previous_sequence.map(|sequence| sequence.0))
1137}
1138
1139fn validate_archive_key(
1140    manifest: &PitrArchiveManifest,
1141    kek: Option<&crate::encryption::Kek>,
1142) -> Result<()> {
1143    if manifest.format_version == LEGACY_FORMAT_VERSION && kek.is_some() {
1144        return Err(MongrelError::Conflict(
1145            "encrypted legacy PITR archives are unsupported; create a version 2 archive".into(),
1146        ));
1147    }
1148    if manifest.encrypted != kek.is_some() {
1149        return Err(MongrelError::Conflict(
1150            "PITR archive encryption does not match the source database".into(),
1151        ));
1152    }
1153    Ok(())
1154}
1155
1156fn derive_archive_kek(
1157    archive: &DurableRoot,
1158    manifest: &PitrArchiveManifest,
1159    credentials: PitrCredentials<'_>,
1160) -> Result<Option<crate::encryption::Kek>> {
1161    let salt_path = Path::new("base").join("_meta").join("keys");
1162    let mut salt_file = match archive.open_regular(&salt_path) {
1163        Ok(file) => Some(file),
1164        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1165        Err(error) => return Err(error.into()),
1166    };
1167    let encrypted_base = salt_file.is_some();
1168    if manifest.format_version == LEGACY_FORMAT_VERSION && encrypted_base {
1169        return Err(MongrelError::Conflict(
1170            "encrypted legacy PITR archives cannot be restored safely; create a version 2 archive"
1171                .into(),
1172        ));
1173    }
1174    if manifest.format_version == FORMAT_VERSION && manifest.encrypted != encrypted_base {
1175        return invalid_pitr("PITR manifest encryption does not match its base backup");
1176    }
1177    if !encrypted_base {
1178        if matches!(
1179            credentials,
1180            PitrCredentials::Encryption(_) | PitrCredentials::EncryptionAndUser { .. }
1181        ) {
1182            return invalid_pitr("plaintext PITR archive does not accept encryption credentials");
1183        }
1184        return Ok(None);
1185    }
1186    let passphrase = match credentials {
1187        PitrCredentials::Encryption(passphrase)
1188        | PitrCredentials::EncryptionAndUser { passphrase, .. } => passphrase,
1189        PitrCredentials::None | PitrCredentials::User { .. } => {
1190            return Err(MongrelError::Encryption(
1191                "encrypted PITR archive requires its database passphrase".into(),
1192            ));
1193        }
1194    };
1195    derive_kek_from_salt(
1196        salt_file
1197            .as_mut()
1198            .ok_or_else(|| MongrelError::Encryption("missing PITR encryption salt".into()))?,
1199        passphrase,
1200    )
1201    .map(Some)
1202}
1203
1204#[cfg(feature = "encryption")]
1205fn derive_kek_from_salt(
1206    source: &mut std::fs::File,
1207    passphrase: &str,
1208) -> Result<crate::encryption::Kek> {
1209    let mut salt = [0u8; crate::encryption::SALT_LEN];
1210    source.read_exact(&mut salt)?;
1211    let mut extra = [0u8; 1];
1212    if source.read(&mut extra)? != 0 {
1213        return Err(MongrelError::Encryption(
1214            "invalid PITR base encryption salt length".into(),
1215        ));
1216    }
1217    crate::encryption::Kek::derive(passphrase, &salt)
1218}
1219
1220#[cfg(not(feature = "encryption"))]
1221fn derive_kek_from_salt(
1222    _source: &mut std::fs::File,
1223    _passphrase: &str,
1224) -> Result<crate::encryption::Kek> {
1225    Err(MongrelError::Encryption(
1226        "encryption feature is disabled".into(),
1227    ))
1228}
1229
1230fn verify_manifest_authentication(
1231    manifest: &PitrArchiveManifest,
1232    kek: Option<&crate::encryption::Kek>,
1233) -> Result<()> {
1234    if manifest.format_version == LEGACY_FORMAT_VERSION || !manifest.encrypted {
1235        return Ok(());
1236    }
1237    verify_manifest_mac(
1238        manifest,
1239        kek.ok_or_else(|| {
1240            MongrelError::Encryption("encrypted PITR archive requires an encryption key".into())
1241        })?,
1242    )
1243}
1244
1245fn manifest_authentication(
1246    manifest: &PitrArchiveManifest,
1247    kek: Option<&crate::encryption::Kek>,
1248) -> Result<Option<String>> {
1249    if manifest.format_version == LEGACY_FORMAT_VERSION || !manifest.encrypted {
1250        return Ok(None);
1251    }
1252    sign_manifest_mac(
1253        manifest,
1254        kek.ok_or_else(|| {
1255            MongrelError::Encryption("encrypted PITR archive requires an encryption key".into())
1256        })?,
1257    )
1258    .map(Some)
1259}
1260
1261#[cfg(feature = "encryption")]
1262fn manifest_auth_bytes(manifest: &PitrArchiveManifest) -> Result<Vec<u8>> {
1263    let mut unsigned = manifest.clone();
1264    unsigned.authentication = None;
1265    serde_json::to_vec(&unsigned)
1266        .map_err(|error| MongrelError::Other(format!("PITR manifest encode: {error}")))
1267}
1268
1269#[cfg(feature = "encryption")]
1270fn sign_manifest_mac(
1271    manifest: &PitrArchiveManifest,
1272    kek: &crate::encryption::Kek,
1273) -> Result<String> {
1274    use hmac::Mac as _;
1275
1276    let key = kek.derive_subkey(MANIFEST_KEY_DOMAIN);
1277    let mut mac = <hmac::Hmac<Sha256> as hmac::Mac>::new_from_slice(key.as_ref())
1278        .map_err(|error| MongrelError::Encryption(format!("PITR HMAC key: {error}")))?;
1279    mac.update(MANIFEST_AUTH_DOMAIN);
1280    mac.update(&manifest_auth_bytes(manifest)?);
1281    Ok(hex_bytes(&mac.finalize().into_bytes()))
1282}
1283
1284#[cfg(not(feature = "encryption"))]
1285fn sign_manifest_mac(
1286    _manifest: &PitrArchiveManifest,
1287    _kek: &crate::encryption::Kek,
1288) -> Result<String> {
1289    unreachable!("Kek is unconstructable without the encryption feature")
1290}
1291
1292#[cfg(feature = "encryption")]
1293fn verify_manifest_mac(manifest: &PitrArchiveManifest, kek: &crate::encryption::Kek) -> Result<()> {
1294    use hmac::Mac as _;
1295
1296    let authentication = manifest
1297        .authentication
1298        .as_deref()
1299        .ok_or_else(|| MongrelError::InvalidArgument("missing PITR authentication".into()))?;
1300    let expected = decode_sha256(authentication, "PITR manifest authentication")?;
1301    let key = kek.derive_subkey(MANIFEST_KEY_DOMAIN);
1302    let mut mac = <hmac::Hmac<Sha256> as hmac::Mac>::new_from_slice(key.as_ref())
1303        .map_err(|error| MongrelError::Encryption(format!("PITR HMAC key: {error}")))?;
1304    mac.update(MANIFEST_AUTH_DOMAIN);
1305    mac.update(&manifest_auth_bytes(manifest)?);
1306    mac.verify_slice(&expected)
1307        .map_err(|_| MongrelError::Decryption("PITR manifest authentication failed".into()))
1308}
1309
1310#[cfg(not(feature = "encryption"))]
1311fn verify_manifest_mac(
1312    _manifest: &PitrArchiveManifest,
1313    _kek: &crate::encryption::Kek,
1314) -> Result<()> {
1315    unreachable!("Kek is unconstructable without the encryption feature")
1316}
1317
1318fn validate_sha256(value: &str, label: &str) -> Result<()> {
1319    decode_sha256(value, label).map(|_| ())
1320}
1321
1322fn decode_sha256(value: &str, label: &str) -> Result<[u8; 32]> {
1323    if value.len() != 64
1324        || !value
1325            .bytes()
1326            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1327    {
1328        return invalid_pitr(format!("{label} is not lowercase SHA-256 hex"));
1329    }
1330    let mut bytes = [0u8; 32];
1331    for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
1332        bytes[index] = (hex_nibble(pair[0]) << 4) | hex_nibble(pair[1]);
1333    }
1334    Ok(bytes)
1335}
1336
1337fn hex_nibble(byte: u8) -> u8 {
1338    match byte {
1339        b'0'..=b'9' => byte - b'0',
1340        b'a'..=b'f' => byte - b'a' + 10,
1341        _ => unreachable!("validated hex digit"),
1342    }
1343}
1344
1345fn hex_bytes(bytes: &[u8]) -> String {
1346    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
1347}
1348
1349fn is_zero(value: &u64) -> bool {
1350    *value == 0
1351}
1352
1353fn invalid_pitr<T>(message: impl Into<String>) -> Result<T> {
1354    Err(MongrelError::InvalidArgument(message.into()))
1355}
1356
1357fn materialize_spilled_records(db: &Database, records: Vec<Record>) -> Result<Vec<Record>> {
1358    let table_schemas = db
1359        .catalog_snapshot()
1360        .tables
1361        .into_iter()
1362        .map(|entry| (entry.table_id, entry.schema))
1363        .collect::<HashMap<_, _>>();
1364    let commit_epochs: HashMap<u64, u64> = records
1365        .iter()
1366        .filter_map(|record| match record.op {
1367            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
1368            _ => None,
1369        })
1370        .collect();
1371    let logical_spills = records
1372        .iter()
1373        .filter_map(|record| match &record.op {
1374            Op::SpilledRows { table_id, .. } => Some((record.txn_id, *table_id)),
1375            _ => None,
1376        })
1377        .collect::<HashSet<_>>();
1378    let mut output = Vec::with_capacity(records.len());
1379    for record in records {
1380        match record.op {
1381            Op::SpilledRows { table_id, rows } => output.push(Record::new(
1382                record.seq,
1383                record.txn_id,
1384                Op::Put { table_id, rows },
1385            )),
1386            Op::TxnCommit {
1387                epoch,
1388                mut added_runs,
1389            } => {
1390                for run in &added_runs {
1391                    if logical_spills.contains(&(record.txn_id, run.table_id)) {
1392                        continue;
1393                    }
1394                    let schema = table_schemas.get(&run.table_id).ok_or_else(|| {
1395                        MongrelError::Conflict(format!(
1396                            "PITR cannot materialize spilled run {} for unavailable table {}",
1397                            run.run_id, run.table_id
1398                        ))
1399                    })?;
1400                    let run_path = db
1401                        .root()
1402                        .join(crate::database::TABLES_DIR)
1403                        .join(run.table_id.to_string())
1404                        .join(crate::engine::RUNS_DIR)
1405                        .join(format!("r-{}.sr", run.run_id));
1406                    let mut reader = crate::sorted_run::RunReader::open(
1407                        run_path,
1408                        schema.clone(),
1409                        db.kek().cloned(),
1410                    )?;
1411                    let mut rows = reader.all_rows()?;
1412                    for row in &mut rows {
1413                        row.committed_epoch = Epoch(epoch);
1414                    }
1415                    output.push(Record::new(
1416                        record.seq,
1417                        record.txn_id,
1418                        Op::Put {
1419                            table_id: run.table_id,
1420                            rows: bincode::serialize(&rows)?,
1421                        },
1422                    ));
1423                }
1424                added_runs.clear();
1425                output.push(Record::new(
1426                    record.seq,
1427                    record.txn_id,
1428                    Op::TxnCommit { epoch, added_runs },
1429                ));
1430            }
1431            op => output.push(Record::new(record.seq, record.txn_id, op)),
1432        }
1433    }
1434    let complete: HashSet<u64> = output
1435        .iter()
1436        .filter_map(|record| match record.op {
1437            Op::TxnCommit { .. } => Some(record.txn_id),
1438            _ => None,
1439        })
1440        .collect();
1441    if commit_epochs
1442        .keys()
1443        .any(|txn_id| !complete.contains(txn_id))
1444    {
1445        return Err(MongrelError::Conflict(
1446            "PITR conversion lost a transaction commit".into(),
1447        ));
1448    }
1449    Ok(output)
1450}
1451
1452fn normalize_record_sequences(records: &mut [Record], minimum: Option<u64>) -> Result<()> {
1453    let original = records
1454        .first()
1455        .map(|record| record.seq.0)
1456        .ok_or_else(|| MongrelError::Conflict("PITR batch has no records".into()))?;
1457    let start = minimum.map_or(original, |minimum| minimum.max(original));
1458    for (offset, record) in records.iter_mut().enumerate() {
1459        record.seq = Epoch(
1460            start
1461                .checked_add(u64::try_from(offset).map_err(|_| {
1462                    MongrelError::Conflict("PITR record sequence space exhausted".into())
1463                })?)
1464                .ok_or_else(|| {
1465                    MongrelError::Conflict("PITR record sequence space exhausted".into())
1466                })?,
1467        );
1468    }
1469    Ok(())
1470}
1471
1472fn resolve_target_epoch(manifest: &PitrArchiveManifest, target: PitrTarget) -> Result<u64> {
1473    match target {
1474        PitrTarget::Latest => Ok(manifest.archived_through_epoch),
1475        PitrTarget::Epoch(epoch)
1476            if epoch >= manifest.base_epoch && epoch <= manifest.archived_through_epoch =>
1477        {
1478            Ok(manifest
1479                .chunks
1480                .iter()
1481                .flat_map(|chunk| &chunk.commits)
1482                .filter(|commit| commit.epoch <= epoch)
1483                .map(|commit| commit.epoch)
1484                .max()
1485                .unwrap_or(manifest.base_epoch))
1486        }
1487        PitrTarget::Epoch(epoch) => Err(MongrelError::InvalidArgument(format!(
1488            "PITR epoch {epoch} outside archive range {}..={}",
1489            manifest.base_epoch, manifest.archived_through_epoch
1490        ))),
1491        PitrTarget::TimestampNanos(timestamp) => {
1492            if timestamp < manifest.base_unix_nanos {
1493                return Err(MongrelError::InvalidArgument(
1494                    "PITR timestamp predates base backup".into(),
1495                ));
1496            }
1497            let mut epoch = manifest.base_epoch;
1498            for commit in manifest.chunks.iter().flat_map(|chunk| &chunk.commits) {
1499                if commit.unix_nanos > timestamp {
1500                    break;
1501                }
1502                epoch = commit.epoch;
1503            }
1504            Ok(epoch)
1505        }
1506    }
1507}
1508
1509fn load_records_through(
1510    archive: &DurableRoot,
1511    manifest: &PitrArchiveManifest,
1512    target_epoch: u64,
1513    kek: Option<&crate::encryption::Kek>,
1514) -> Result<Vec<Record>> {
1515    let mut records = Vec::new();
1516    let mut previous_sequence = None;
1517    for reference in &manifest.chunks {
1518        let source = archive.open_regular(&reference.file)?;
1519        let length = source.metadata()?.len();
1520        if length != reference.bytes || length > MAX_CHUNK_BYTES {
1521            return Err(MongrelError::Other(format!(
1522                "PITR chunk {} length mismatch",
1523                reference.file
1524            )));
1525        }
1526        let mut bytes = Vec::with_capacity(length as usize);
1527        source
1528            .take(reference.bytes.saturating_add(1))
1529            .read_to_end(&mut bytes)?;
1530        if bytes.len() as u64 != reference.bytes || sha256_bytes(&bytes) != reference.sha256 {
1531            return Err(MongrelError::Other(format!(
1532                "PITR chunk {} checksum mismatch",
1533                reference.file
1534            )));
1535        }
1536        let chunk = decode_chunk(manifest.format_version, manifest.encrypted, &bytes, kek)?;
1537        previous_sequence = validate_chunk(
1538            reference,
1539            &chunk,
1540            manifest.format_version,
1541            previous_sequence,
1542        )?;
1543        if reference.from_epoch < target_epoch {
1544            let selected: HashSet<u64> = chunk
1545                .records
1546                .iter()
1547                .filter_map(|record| match record.op {
1548                    Op::TxnCommit { epoch, .. } if epoch <= target_epoch => Some(record.txn_id),
1549                    _ => None,
1550                })
1551                .collect();
1552            records.extend(
1553                chunk
1554                    .records
1555                    .into_iter()
1556                    .filter(|record| selected.contains(&record.txn_id)),
1557            );
1558        }
1559    }
1560    Ok(records)
1561}
1562
1563fn open_recovery_staging(root: &DurableRoot, credentials: PitrCredentials<'_>) -> Result<Database> {
1564    match credentials {
1565        PitrCredentials::None | PitrCredentials::User { .. } => {
1566            Database::open_replica_recovery_durable(root)
1567        }
1568        #[cfg(feature = "encryption")]
1569        PitrCredentials::Encryption(passphrase) => {
1570            Database::open_encrypted_replica_recovery_durable(root, passphrase)
1571        }
1572        #[cfg(not(feature = "encryption"))]
1573        PitrCredentials::Encryption(_) => Err(MongrelError::Encryption(
1574            "encryption feature is disabled".into(),
1575        )),
1576        #[cfg(feature = "encryption")]
1577        PitrCredentials::EncryptionAndUser { passphrase, .. } => {
1578            Database::open_encrypted_replica_recovery_durable(root, passphrase)
1579        }
1580        #[cfg(not(feature = "encryption"))]
1581        PitrCredentials::EncryptionAndUser { .. } => Err(MongrelError::Encryption(
1582            "encryption feature is disabled".into(),
1583        )),
1584    }
1585}
1586
1587fn validate_target_user_credentials(
1588    database: &Database,
1589    credentials: PitrCredentials<'_>,
1590) -> Result<()> {
1591    let (username, password) = match credentials {
1592        PitrCredentials::User { username, password }
1593        | PitrCredentials::EncryptionAndUser {
1594            username, password, ..
1595        } => (username, password),
1596        PitrCredentials::None | PitrCredentials::Encryption(_) => return Ok(()),
1597    };
1598    if !database.require_auth_enabled() {
1599        return Err(MongrelError::AuthNotRequired);
1600    }
1601    if database.verify_user(username, password)?.is_some() {
1602        Ok(())
1603    } else {
1604        Err(MongrelError::InvalidCredentials {
1605            username: username.to_string(),
1606        })
1607    }
1608}
1609
1610struct PreparedDestination {
1611    destination: PathBuf,
1612    parent: DurableRoot,
1613    destination_name: PathBuf,
1614    stage_name: PathBuf,
1615}
1616
1617fn prepare_destination(path: &Path, label: &str) -> Result<PreparedDestination> {
1618    let name = path
1619        .file_name()
1620        .ok_or_else(|| MongrelError::InvalidArgument("invalid destination".into()))?;
1621    let requested_parent = path
1622        .parent()
1623        .filter(|parent| !parent.as_os_str().is_empty())
1624        .unwrap_or_else(|| Path::new("."));
1625    crate::durable_file::create_directory_all(requested_parent)?;
1626    let parent = DurableRoot::open(requested_parent)?;
1627    let destination_name = PathBuf::from(name);
1628    if parent.entry_exists(&destination_name)? {
1629        return Err(MongrelError::Conflict(format!(
1630            "destination already exists: {}",
1631            path.display()
1632        )));
1633    }
1634    for _ in 0..128 {
1635        let mut nonce = [0u8; 12];
1636        crate::encryption::fill_random(&mut nonce)?;
1637        let suffix = hex_bytes(&nonce);
1638        let stage_name = PathBuf::from(format!(
1639            ".{}.{}-{}-{suffix}",
1640            name.to_string_lossy(),
1641            label,
1642            std::process::id(),
1643        ));
1644        match parent.create_directory_new(&stage_name) {
1645            Ok(()) => {
1646                return Ok(PreparedDestination {
1647                    destination: parent.canonical_path().join(&destination_name),
1648                    parent,
1649                    destination_name,
1650                    stage_name,
1651                });
1652            }
1653            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1654            Err(error) => return Err(error.into()),
1655        }
1656    }
1657    Err(MongrelError::Conflict(
1658        "could not allocate PITR staging directory".into(),
1659    ))
1660}
1661
1662fn copy_tree(source: &DurableRoot, destination: &DurableRoot) -> Result<()> {
1663    source.walk_regular_files(
1664        |_, _| Ok(true),
1665        |relative| {
1666            destination.create_directory_all(relative)?;
1667            Ok(())
1668        },
1669        |relative, source| {
1670            destination.copy_new_from(relative, source)?;
1671            Ok(())
1672        },
1673    )
1674}
1675
1676fn write_manifest(
1677    root: &DurableRoot,
1678    manifest: &PitrArchiveManifest,
1679    kek: Option<&crate::encryption::Kek>,
1680) -> Result<()> {
1681    write_manifest_with_after(root, manifest, kek, || {})
1682}
1683
1684fn write_manifest_with_after<F>(
1685    root: &DurableRoot,
1686    manifest: &PitrArchiveManifest,
1687    kek: Option<&crate::encryption::Kek>,
1688    after_publish: F,
1689) -> Result<()>
1690where
1691    F: FnOnce(),
1692{
1693    let mut manifest = manifest.clone();
1694    manifest.authentication = manifest_authentication(&manifest, kek)?;
1695    validate_manifest_structure(&manifest)?;
1696    let bytes = serde_json::to_vec_pretty(&manifest)
1697        .map_err(|error| MongrelError::Other(format!("PITR manifest encode: {error}")))?;
1698    root.write_atomic_with_after(MANIFEST_FILE, &bytes, after_publish)?;
1699    Ok(())
1700}
1701
1702fn finish_manifest_publication(
1703    result: Result<()>,
1704    published: bool,
1705    through_epoch: u64,
1706) -> Result<()> {
1707    match result {
1708        Err(error) if published => Err(MongrelError::CommitOutcomeUnknown {
1709            epoch: through_epoch,
1710            message: format!("PITR manifest publication was not durable: {error}"),
1711        }),
1712        result => result,
1713    }
1714}
1715
1716fn publish_chunk(root: &DurableRoot, path: &Path, bytes: &[u8], sha256: &str) -> Result<()> {
1717    match root.write_new(path, bytes) {
1718        Ok(()) => Ok(()),
1719        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1720            verify_existing_chunk(root, path, bytes.len() as u64, sha256)
1721        }
1722        Err(error) => Err(error.into()),
1723    }
1724}
1725
1726fn verify_existing_chunk(
1727    root: &DurableRoot,
1728    path: &Path,
1729    expected_bytes: u64,
1730    expected_sha256: &str,
1731) -> Result<()> {
1732    let file = root.open_regular(path)?;
1733    if file.metadata()?.len() != expected_bytes {
1734        return Err(MongrelError::Conflict(format!(
1735            "PITR orphan chunk {} does not match retry payload",
1736            path.display()
1737        )));
1738    }
1739    let mut hasher = Sha256::new();
1740    let mut buffer = [0u8; 64 * 1024];
1741    let mut total = 0u64;
1742    let mut file = file.take(expected_bytes.saturating_add(1));
1743    loop {
1744        let read = file.read(&mut buffer)?;
1745        if read == 0 {
1746            break;
1747        }
1748        total = total.saturating_add(read as u64);
1749        hasher.update(&buffer[..read]);
1750    }
1751    if total != expected_bytes {
1752        return Err(MongrelError::Conflict(format!(
1753            "PITR orphan chunk {} changed while being verified",
1754            path.display()
1755        )));
1756    }
1757    let actual = hasher
1758        .finalize()
1759        .iter()
1760        .map(|byte| format!("{byte:02x}"))
1761        .collect::<String>();
1762    if actual != expected_sha256 {
1763        return Err(MongrelError::Conflict(format!(
1764            "PITR orphan chunk {} checksum does not match retry payload",
1765            path.display()
1766        )));
1767    }
1768    Ok(())
1769}
1770
1771fn sha256_bytes(bytes: &[u8]) -> String {
1772    Sha256::digest(bytes)
1773        .iter()
1774        .map(|byte| format!("{byte:02x}"))
1775        .collect()
1776}
1777
1778fn unix_nanos() -> u64 {
1779    std::time::SystemTime::now()
1780        .duration_since(std::time::UNIX_EPOCH)
1781        .unwrap_or_default()
1782        .as_nanos() as u64
1783}
1784
1785#[cfg(test)]
1786mod tests {
1787    use super::*;
1788    use crate::auth::Principal;
1789    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
1790
1791    #[test]
1792    fn visible_manifest_sync_failure_has_unknown_commit_outcome() {
1793        let error = finish_manifest_publication(
1794            Err(MongrelError::Other("parent fsync failed".into())),
1795            true,
1796            42,
1797        )
1798        .unwrap_err();
1799
1800        assert!(matches!(
1801            error,
1802            MongrelError::CommitOutcomeUnknown { epoch: 42, .. }
1803        ));
1804
1805        let error = finish_manifest_publication(
1806            Err(MongrelError::Other("rename failed".into())),
1807            false,
1808            42,
1809        )
1810        .unwrap_err();
1811        assert!(matches!(error, MongrelError::Other(message) if message == "rename failed"));
1812    }
1813
1814    fn schema() -> Schema {
1815        Schema {
1816            schema_id: 0,
1817            columns: vec![ColumnDef {
1818                id: 1,
1819                name: "id".into(),
1820                ty: TypeId::Int64,
1821                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
1822                default_value: None,
1823            }],
1824            indexes: Vec::new(),
1825            colocation: Vec::new(),
1826            constraints: Default::default(),
1827            clustered: false,
1828        }
1829    }
1830
1831    fn sample_manifest() -> PitrArchiveManifest {
1832        let base_epoch = 10;
1833        let base_unix_nanos = 100;
1834        let base_backup_sha256 = "aa".repeat(32);
1835        let genesis = genesis_chain(base_epoch, base_unix_nanos, &base_backup_sha256).unwrap();
1836        let first_sha = "11".repeat(32);
1837        let first_chain = next_chain(&genesis, &first_sha, 10, 12, 4, 20, 23).unwrap();
1838        let second_sha = "22".repeat(32);
1839        let second_chain = next_chain(&first_chain, &second_sha, 12, 13, 2, 30, 31).unwrap();
1840        PitrArchiveManifest {
1841            format_version: FORMAT_VERSION,
1842            base_epoch,
1843            base_unix_nanos,
1844            base_backup_sha256,
1845            archived_through_epoch: 13,
1846            last_commit_unix_nanos: 130,
1847            chunks: vec![
1848                PitrChunkRef {
1849                    file: chunk_file_name(10, 12),
1850                    from_epoch: 10,
1851                    through_epoch: 12,
1852                    records: 4,
1853                    bytes: 100,
1854                    sha256: first_sha,
1855                    commits: vec![
1856                        PitrCommitPoint {
1857                            epoch: 11,
1858                            unix_nanos: 110,
1859                        },
1860                        PitrCommitPoint {
1861                            epoch: 12,
1862                            unix_nanos: 120,
1863                        },
1864                    ],
1865                    first_sequence: 20,
1866                    last_sequence: 23,
1867                    previous_chain_sha256: genesis,
1868                    chain_sha256: first_chain.clone(),
1869                },
1870                PitrChunkRef {
1871                    file: chunk_file_name(12, 13),
1872                    from_epoch: 12,
1873                    through_epoch: 13,
1874                    records: 2,
1875                    bytes: 100,
1876                    sha256: second_sha,
1877                    commits: vec![PitrCommitPoint {
1878                        epoch: 13,
1879                        unix_nanos: 130,
1880                    }],
1881                    first_sequence: 30,
1882                    last_sequence: 31,
1883                    previous_chain_sha256: first_chain,
1884                    chain_sha256: second_chain.clone(),
1885                },
1886            ],
1887            encrypted: false,
1888            chain_sha256: second_chain,
1889            authentication: None,
1890        }
1891    }
1892
1893    fn sample_chunk() -> PitrChunkV2 {
1894        PitrChunkV2 {
1895            format_version: FORMAT_VERSION,
1896            from_epoch: 10,
1897            through_epoch: 11,
1898            records: vec![
1899                Record::new(Epoch(20), 7, Op::CommitTimestamp { unix_nanos: 110 }),
1900                Record::new(
1901                    Epoch(21),
1902                    7,
1903                    Op::TxnCommit {
1904                        epoch: 11,
1905                        added_runs: Vec::new(),
1906                    },
1907                ),
1908            ],
1909            commits: vec![PitrCommitPoint {
1910                epoch: 11,
1911                unix_nanos: 110,
1912            }],
1913            first_sequence: 20,
1914            last_sequence: 21,
1915            previous_chain_sha256: "11".repeat(32),
1916        }
1917    }
1918
1919    #[test]
1920    fn matching_plaintext_orphan_chunk_is_reused_exactly() {
1921        let directory = tempfile::tempdir().unwrap();
1922        let root = DurableRoot::open(directory.path()).unwrap();
1923        let chunk = sample_chunk();
1924        let file = Path::new("wal-10-11.bin");
1925        let encoded = encode_or_reuse_chunk_v2(&root, file, &chunk, None).unwrap();
1926        root.write_new(file, &encoded).unwrap();
1927
1928        let reused = encode_or_reuse_chunk_v2(&root, file, &chunk, None).unwrap();
1929
1930        assert_eq!(reused, encoded);
1931    }
1932
1933    #[cfg(feature = "encryption")]
1934    #[test]
1935    fn matching_encrypted_orphan_chunk_is_reused_exactly() {
1936        let directory = tempfile::tempdir().unwrap();
1937        let root = DurableRoot::open(directory.path()).unwrap();
1938        let chunk = sample_chunk();
1939        let file = Path::new("wal-10-11.bin");
1940        let kek = crate::encryption::Kek::derive("secret", &[7; 16]).unwrap();
1941        let encoded = encode_or_reuse_chunk_v2(&root, file, &chunk, Some(&kek)).unwrap();
1942        root.write_new(file, &encoded).unwrap();
1943
1944        let reused = encode_or_reuse_chunk_v2(&root, file, &chunk, Some(&kek)).unwrap();
1945
1946        assert_eq!(reused, encoded);
1947    }
1948
1949    fn root_and_alice(database: &Database) -> (Principal, Principal) {
1950        let root = database.principal_snapshot().unwrap();
1951        database.create_user("alice", "alice-password").unwrap();
1952        database.set_user_admin("alice", true).unwrap();
1953        let alice = database.resolve_principal("alice").unwrap();
1954        (root, alice)
1955    }
1956
1957    #[test]
1958    fn base_archive_rechecks_exact_admin_at_outer_publication() {
1959        let source = tempfile::tempdir().unwrap();
1960        let destination_parent = tempfile::tempdir().unwrap();
1961        let destination = destination_parent.path().join("archive");
1962        let database =
1963            Database::create_with_credentials(source.path(), "admin", "admin-password").unwrap();
1964        database.create_user("rescue", "rescue-password").unwrap();
1965        database.set_user_admin("rescue", true).unwrap();
1966
1967        let result = database.create_pitr_archive_inner(&destination, || {
1968            database.drop_user("admin")?;
1969            Ok(())
1970        });
1971
1972        assert!(
1973            matches!(result, Err(MongrelError::AuthRequired)),
1974            "unexpected result: {result:?}"
1975        );
1976        assert!(!destination.exists());
1977    }
1978
1979    #[test]
1980    fn incremental_archive_rechecks_exact_admin_at_outer_publication() {
1981        let source = tempfile::tempdir().unwrap();
1982        let archive_parent = tempfile::tempdir().unwrap();
1983        let archive = archive_parent.path().join("archive");
1984        let database =
1985            Database::create_with_credentials(source.path(), "admin", "admin-password").unwrap();
1986        database.create_user("rescue", "rescue-password").unwrap();
1987        database.set_user_admin("rescue", true).unwrap();
1988        let base = database.create_pitr_archive(&archive).unwrap();
1989        database.create_table("items", schema()).unwrap();
1990
1991        let result = database.archive_pitr_inner(&archive, || {
1992            database.drop_user("admin")?;
1993            Ok(())
1994        });
1995
1996        assert!(
1997            matches!(result, Err(MongrelError::AuthRequired)),
1998            "unexpected result: {result:?}"
1999        );
2000        let manifest = read_pitr_manifest(&archive).unwrap();
2001        assert_eq!(manifest.archived_through_epoch, base.through_epoch);
2002        assert!(manifest.chunks.is_empty());
2003    }
2004
2005    #[test]
2006    fn manifest_rejects_paths_gaps_duplicates_reordering_and_bad_timestamps() {
2007        let manifest = sample_manifest();
2008        validate_manifest_structure(&manifest).unwrap();
2009
2010        let mut path = manifest.clone();
2011        path.chunks[0].file = "../escape.bin".into();
2012        assert!(validate_manifest_structure(&path).is_err());
2013
2014        let mut missing_base_digest = manifest.clone();
2015        missing_base_digest.base_backup_sha256.clear();
2016        assert!(validate_manifest_structure(&missing_base_digest).is_err());
2017
2018        let mut absolute = manifest.clone();
2019        absolute.chunks[0].file = "/tmp/escape.bin".into();
2020        assert!(validate_manifest_structure(&absolute).is_err());
2021
2022        let mut gap = manifest.clone();
2023        gap.chunks[1].from_epoch = 11;
2024        gap.chunks[1].file = chunk_file_name(11, 13);
2025        assert!(validate_manifest_structure(&gap).is_err());
2026
2027        let mut duplicate = manifest.clone();
2028        duplicate.chunks.insert(1, duplicate.chunks[0].clone());
2029        assert!(validate_manifest_structure(&duplicate).is_err());
2030
2031        let mut reordered = manifest.clone();
2032        reordered.chunks.swap(0, 1);
2033        assert!(validate_manifest_structure(&reordered).is_err());
2034
2035        let mut duplicate_sequence = manifest.clone();
2036        duplicate_sequence.chunks[1].first_sequence = duplicate_sequence.chunks[0].last_sequence;
2037        duplicate_sequence.chunks[1].last_sequence =
2038            duplicate_sequence.chunks[1].first_sequence + 1;
2039        assert!(validate_manifest_structure(&duplicate_sequence).is_err());
2040
2041        let mut timestamp = manifest;
2042        timestamp.chunks[1].commits[0].unix_nanos = 119;
2043        timestamp.last_commit_unix_nanos = 119;
2044        assert!(validate_manifest_structure(&timestamp).is_err());
2045    }
2046
2047    #[test]
2048    fn chunk_body_rejects_count_range_commit_and_sequence_mismatches() {
2049        let manifest = sample_manifest();
2050        let reference = &manifest.chunks[1];
2051        let records = vec![
2052            Record::new(Epoch(30), 7, Op::CommitTimestamp { unix_nanos: 130 }),
2053            Record::new(
2054                Epoch(31),
2055                7,
2056                Op::TxnCommit {
2057                    epoch: 13,
2058                    added_runs: Vec::new(),
2059                },
2060            ),
2061        ];
2062        let chunk = DecodedPitrChunk {
2063            from_epoch: 12,
2064            through_epoch: 13,
2065            records,
2066            commits: reference.commits.clone(),
2067            first_sequence: Some(reference.first_sequence),
2068            last_sequence: Some(reference.last_sequence),
2069            previous_chain_sha256: Some(reference.previous_chain_sha256.clone()),
2070        };
2071        validate_chunk(reference, &chunk, FORMAT_VERSION, None).unwrap();
2072
2073        let mut wrong_count = reference.clone();
2074        wrong_count.records = 3;
2075        assert!(validate_chunk(&wrong_count, &chunk, FORMAT_VERSION, None).is_err());
2076
2077        let mut wrong_range = DecodedPitrChunk {
2078            from_epoch: 11,
2079            through_epoch: chunk.through_epoch,
2080            records: chunk.records.clone(),
2081            commits: chunk.commits.clone(),
2082            first_sequence: chunk.first_sequence,
2083            last_sequence: chunk.last_sequence,
2084            previous_chain_sha256: chunk.previous_chain_sha256.clone(),
2085        };
2086        assert!(validate_chunk(reference, &wrong_range, FORMAT_VERSION, None).is_err());
2087        wrong_range.from_epoch = chunk.from_epoch;
2088        wrong_range.records.swap(0, 1);
2089        assert!(validate_chunk(reference, &wrong_range, FORMAT_VERSION, None).is_err());
2090
2091        let mut duplicate_sequence = DecodedPitrChunk {
2092            from_epoch: chunk.from_epoch,
2093            through_epoch: chunk.through_epoch,
2094            records: chunk.records.clone(),
2095            commits: chunk.commits.clone(),
2096            first_sequence: chunk.first_sequence,
2097            last_sequence: chunk.last_sequence,
2098            previous_chain_sha256: chunk.previous_chain_sha256.clone(),
2099        };
2100        duplicate_sequence.records[1].seq = duplicate_sequence.records[0].seq;
2101        assert!(validate_chunk(reference, &duplicate_sequence, FORMAT_VERSION, None).is_err());
2102        assert!(validate_chunk(reference, &chunk, FORMAT_VERSION, Some(30)).is_err());
2103    }
2104
2105    #[test]
2106    fn base_archive_rejects_drop_and_recreate_of_same_admin_username() {
2107        let source = tempfile::tempdir().unwrap();
2108        let destination_parent = tempfile::tempdir().unwrap();
2109        let destination = destination_parent.path().join("archive");
2110        let database =
2111            Database::create_with_credentials(source.path(), "root", "root-password").unwrap();
2112        let (root, stale_alice) = root_and_alice(&database);
2113        database.set_cached_principal_for_test(Some(stale_alice.clone()));
2114
2115        let result = database.create_pitr_archive_inner(&destination, || {
2116            database.set_cached_principal_for_test(Some(root.clone()));
2117            database.drop_user("alice")?;
2118            database.create_user("alice", "replacement-password")?;
2119            database.set_user_admin("alice", true)?;
2120            database.set_cached_principal_for_test(Some(stale_alice.clone()));
2121            Ok(())
2122        });
2123
2124        assert!(matches!(result, Err(MongrelError::AuthRequired)));
2125        assert!(!destination.exists());
2126    }
2127
2128    #[test]
2129    fn incremental_archive_rejects_admin_demotion_at_final_publication() {
2130        let source = tempfile::tempdir().unwrap();
2131        let archive_parent = tempfile::tempdir().unwrap();
2132        let archive = archive_parent.path().join("archive");
2133        let database =
2134            Database::create_with_credentials(source.path(), "root", "root-password").unwrap();
2135        let (root, stale_alice) = root_and_alice(&database);
2136        database.create_table("items", schema()).unwrap();
2137        database.create_pitr_archive(&archive).unwrap();
2138        let mut transaction = database.begin();
2139        transaction
2140            .put("items", vec![(1, crate::Value::Int64(1))])
2141            .unwrap();
2142        transaction.commit().unwrap();
2143        let before = read_pitr_manifest(&archive).unwrap();
2144        database.set_cached_principal_for_test(Some(stale_alice.clone()));
2145
2146        let result = database.archive_pitr_inner(&archive, || {
2147            database.set_cached_principal_for_test(Some(root.clone()));
2148            database.set_user_admin("alice", false)?;
2149            database.set_cached_principal_for_test(Some(stale_alice.clone()));
2150            Ok(())
2151        });
2152
2153        assert!(matches!(result, Err(MongrelError::PermissionDenied { .. })));
2154        let after = read_pitr_manifest(&archive).unwrap();
2155        assert_eq!(after, before);
2156    }
2157
2158    #[cfg(unix)]
2159    #[test]
2160    fn archive_lock_symlink_is_rejected_without_touching_target() {
2161        use std::os::unix::fs::symlink;
2162
2163        let source = tempfile::tempdir().unwrap();
2164        let archive_parent = tempfile::tempdir().unwrap();
2165        let outside = tempfile::tempdir().unwrap();
2166        let archive = archive_parent.path().join("archive");
2167        let database = Database::create(source.path()).unwrap();
2168        database.create_table("items", schema()).unwrap();
2169        database.create_pitr_archive(&archive).unwrap();
2170        let mut transaction = database.begin();
2171        transaction
2172            .put("items", vec![(1, crate::Value::Int64(1))])
2173            .unwrap();
2174        transaction.commit().unwrap();
2175        let outside_lock = outside.path().join("lock");
2176        std::fs::write(&outside_lock, b"outside").unwrap();
2177        symlink(&outside_lock, archive.join(".archive.lock")).unwrap();
2178
2179        assert!(database.archive_pitr(&archive).is_err());
2180        assert_eq!(std::fs::read(outside_lock).unwrap(), b"outside");
2181        assert!(read_pitr_manifest(&archive).unwrap().chunks.is_empty());
2182    }
2183
2184    #[cfg(unix)]
2185    #[test]
2186    fn symlinked_chunk_is_not_followed_during_restore() {
2187        use std::os::unix::fs::symlink;
2188
2189        let source = tempfile::tempdir().unwrap();
2190        let archive_parent = tempfile::tempdir().unwrap();
2191        let restore_parent = tempfile::tempdir().unwrap();
2192        let outside = tempfile::tempdir().unwrap();
2193        let archive = archive_parent.path().join("archive");
2194        let destination = restore_parent.path().join("restored");
2195        let database = Database::create(source.path()).unwrap();
2196        database.create_table("items", schema()).unwrap();
2197        database.create_pitr_archive(&archive).unwrap();
2198        let mut transaction = database.begin();
2199        transaction
2200            .put("items", vec![(1, crate::Value::Int64(1))])
2201            .unwrap();
2202        transaction.commit().unwrap();
2203        database.archive_pitr(&archive).unwrap();
2204        let manifest = read_pitr_manifest(&archive).unwrap();
2205        let chunk_path = archive.join(&manifest.chunks[0].file);
2206        let outside_chunk = outside.path().join("chunk.bin");
2207        std::fs::rename(&chunk_path, &outside_chunk).unwrap();
2208        let before = std::fs::read(&outside_chunk).unwrap();
2209        symlink(&outside_chunk, &chunk_path).unwrap();
2210
2211        assert!(restore_pitr(
2212            &archive,
2213            &destination,
2214            PitrTarget::Latest,
2215            PitrCredentials::None,
2216        )
2217        .is_err());
2218        assert!(!destination.exists());
2219        assert_eq!(std::fs::read(outside_chunk).unwrap(), before);
2220    }
2221
2222    #[test]
2223    fn restore_rejects_base_changed_after_initial_verification() {
2224        let source = tempfile::tempdir().unwrap();
2225        let archive_parent = tempfile::tempdir().unwrap();
2226        let restore_parent = tempfile::tempdir().unwrap();
2227        let archive = archive_parent.path().join("archive");
2228        let destination = restore_parent.path().join("restored");
2229        let database = Database::create(source.path()).unwrap();
2230        database.create_table("items", schema()).unwrap();
2231        database.create_pitr_archive(&archive).unwrap();
2232        let base_manifest = crate::backup::verify_backup(archive.join("base")).unwrap();
2233        let victim = archive.join("base").join(&base_manifest.files[0].path);
2234
2235        let result = restore_pitr_inner(
2236            &archive,
2237            &destination,
2238            PitrTarget::Latest,
2239            PitrCredentials::None,
2240            |_| {
2241                std::fs::write(&victim, b"changed after verification")?;
2242                Ok(())
2243            },
2244        );
2245
2246        assert!(result.is_err());
2247        assert!(!destination.exists());
2248    }
2249
2250    #[cfg(unix)]
2251    #[test]
2252    fn restore_stage_nested_symlink_cannot_escape() {
2253        use std::os::unix::fs::symlink;
2254
2255        let source = tempfile::tempdir().unwrap();
2256        let archive_parent = tempfile::tempdir().unwrap();
2257        let restore_parent = tempfile::tempdir().unwrap();
2258        let outside = tempfile::tempdir().unwrap();
2259        let archive = archive_parent.path().join("archive");
2260        let destination = restore_parent.path().join("restored");
2261        let database = Database::create(source.path()).unwrap();
2262        database.create_table("items", schema()).unwrap();
2263        database.create_pitr_archive(&archive).unwrap();
2264        let guard = outside.path().join("guard");
2265        std::fs::write(&guard, b"unchanged").unwrap();
2266
2267        let result = restore_pitr_inner(
2268            &archive,
2269            &destination,
2270            PitrTarget::Latest,
2271            PitrCredentials::None,
2272            |stage| {
2273                symlink(outside.path(), stage.join("_meta"))?;
2274                Ok(())
2275            },
2276        );
2277
2278        assert!(result.is_err());
2279        assert!(!destination.exists());
2280        assert_eq!(std::fs::read(&guard).unwrap(), b"unchanged");
2281        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 1);
2282    }
2283
2284    #[test]
2285    fn restore_destination_inside_archive_is_rejected_without_staging_debris() {
2286        let source = tempfile::tempdir().unwrap();
2287        let archive_parent = tempfile::tempdir().unwrap();
2288        let archive = archive_parent.path().join("archive");
2289        let database = Database::create(source.path()).unwrap();
2290        database.create_table("items", schema()).unwrap();
2291        database.create_pitr_archive(&archive).unwrap();
2292        let base = archive.join("base");
2293        let mut before = std::fs::read_dir(&base)
2294            .unwrap()
2295            .map(|entry| entry.unwrap().file_name())
2296            .collect::<Vec<_>>();
2297        before.sort();
2298
2299        let result = restore_pitr(
2300            &archive,
2301            base.join("restored"),
2302            PitrTarget::Latest,
2303            PitrCredentials::None,
2304        );
2305
2306        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
2307        let mut after = std::fs::read_dir(&base)
2308            .unwrap()
2309            .map(|entry| entry.unwrap().file_name())
2310            .collect::<Vec<_>>();
2311        after.sort();
2312        assert_eq!(after, before);
2313    }
2314
2315    #[cfg(unix)]
2316    #[test]
2317    fn base_backup_stays_in_pinned_stage_after_parent_rename() {
2318        let source = tempfile::tempdir().unwrap();
2319        let parent_root = tempfile::tempdir().unwrap();
2320        let requested_parent = parent_root.path().join("requested");
2321        let moved_parent = parent_root.path().join("moved");
2322        std::fs::create_dir(&requested_parent).unwrap();
2323        let destination = requested_parent.join("archive");
2324        let database = Database::create(source.path()).unwrap();
2325        database.create_table("items", schema()).unwrap();
2326        let mut transaction = database.begin();
2327        transaction
2328            .put("items", vec![(1, crate::Value::Int64(1))])
2329            .unwrap();
2330        transaction.commit().unwrap();
2331        database.checkpoint().unwrap();
2332        let requested_for_hook = requested_parent.clone();
2333        let moved_for_hook = moved_parent.clone();
2334        database.__set_backup_hook(move || {
2335            std::fs::rename(&requested_for_hook, &moved_for_hook).unwrap();
2336            std::fs::create_dir(&requested_for_hook).unwrap();
2337        });
2338
2339        database.create_pitr_archive(&destination).unwrap();
2340
2341        assert!(moved_parent.join("archive/base").is_dir());
2342        assert!(!requested_parent.join("archive").exists());
2343        read_pitr_manifest(moved_parent.join("archive")).unwrap();
2344    }
2345
2346    #[cfg(unix)]
2347    #[test]
2348    fn archive_publication_stays_in_pinned_parent_after_rename() {
2349        let source = tempfile::tempdir().unwrap();
2350        let parent_root = tempfile::tempdir().unwrap();
2351        let requested_parent = parent_root.path().join("requested");
2352        let moved_parent = parent_root.path().join("moved");
2353        std::fs::create_dir(&requested_parent).unwrap();
2354        let destination = requested_parent.join("archive");
2355        let database = Database::create(source.path()).unwrap();
2356
2357        let report = database
2358            .create_pitr_archive_inner(&destination, || {
2359                std::fs::rename(&requested_parent, &moved_parent)?;
2360                std::fs::create_dir(&requested_parent)?;
2361                Ok(())
2362            })
2363            .unwrap();
2364
2365        assert_eq!(report.archive, destination);
2366        assert!(moved_parent.join("archive").is_dir());
2367        assert!(!requested_parent.join("archive").exists());
2368    }
2369
2370    #[cfg(unix)]
2371    #[test]
2372    fn restore_recovery_stays_in_pinned_stage_after_parent_rename() {
2373        let source = tempfile::tempdir().unwrap();
2374        let archive_parent = tempfile::tempdir().unwrap();
2375        let restore_root = tempfile::tempdir().unwrap();
2376        let archive = archive_parent.path().join("archive");
2377        let requested_parent = restore_root.path().join("requested");
2378        let moved_parent = restore_root.path().join("moved");
2379        std::fs::create_dir(&requested_parent).unwrap();
2380        let destination = requested_parent.join("restored");
2381        let database = Database::create(source.path()).unwrap();
2382        database.create_table("items", schema()).unwrap();
2383        database.create_pitr_archive(&archive).unwrap();
2384        let mut transaction = database.begin();
2385        transaction
2386            .put("items", vec![(1, crate::Value::Int64(7))])
2387            .unwrap();
2388        transaction.commit().unwrap();
2389        database.archive_pitr(&archive).unwrap();
2390
2391        restore_pitr_inner(
2392            &archive,
2393            &destination,
2394            PitrTarget::Latest,
2395            PitrCredentials::None,
2396            |_| {
2397                std::fs::rename(&requested_parent, &moved_parent)?;
2398                std::fs::create_dir(&requested_parent)?;
2399                Ok(())
2400            },
2401        )
2402        .unwrap();
2403
2404        assert!(!destination.exists());
2405        let restored = Database::open(moved_parent.join("restored")).unwrap();
2406        assert_eq!(
2407            restored
2408                .table("items")
2409                .unwrap()
2410                .lock()
2411                .visible_rows(restored.snapshot().0)
2412                .unwrap()
2413                .len(),
2414            1
2415        );
2416    }
2417
2418    #[test]
2419    fn plaintext_legacy_archive_is_restore_only_but_remains_restorable() {
2420        let source = tempfile::tempdir().unwrap();
2421        let archive_parent = tempfile::tempdir().unwrap();
2422        let restore_parent = tempfile::tempdir().unwrap();
2423        let archive = archive_parent.path().join("archive");
2424        let destination = restore_parent.path().join("restored");
2425        let database = Database::create(source.path()).unwrap();
2426        database.create_table("items", schema()).unwrap();
2427        database.create_pitr_archive(&archive).unwrap();
2428        let mut transaction = database.begin();
2429        transaction
2430            .put("items", vec![(1, crate::Value::Int64(7))])
2431            .unwrap();
2432        transaction.commit().unwrap();
2433        database.archive_pitr(&archive).unwrap();
2434
2435        let mut manifest = read_pitr_manifest(&archive).unwrap();
2436        let reference = &mut manifest.chunks[0];
2437        let current_bytes = std::fs::read(archive.join(&reference.file)).unwrap();
2438        let decoded = decode_chunk(FORMAT_VERSION, false, &current_bytes, None).unwrap();
2439        let legacy = LegacyPitrChunk {
2440            format_version: LEGACY_FORMAT_VERSION,
2441            from_epoch: decoded.from_epoch,
2442            through_epoch: decoded.through_epoch,
2443            records: decoded.records,
2444            commits: decoded.commits,
2445        };
2446        let legacy_bytes = bincode::DefaultOptions::new()
2447            .with_fixint_encoding()
2448            .serialize(&legacy)
2449            .unwrap();
2450        std::fs::write(archive.join(&reference.file), &legacy_bytes).unwrap();
2451        reference.bytes = legacy_bytes.len() as u64;
2452        reference.sha256 = sha256_bytes(&legacy_bytes);
2453        reference.first_sequence = 0;
2454        reference.last_sequence = 0;
2455        reference.previous_chain_sha256.clear();
2456        reference.chain_sha256.clear();
2457        manifest.format_version = LEGACY_FORMAT_VERSION;
2458        manifest.base_backup_sha256.clear();
2459        manifest.chain_sha256.clear();
2460        manifest.authentication = None;
2461        std::fs::write(
2462            archive.join(MANIFEST_FILE),
2463            serde_json::to_vec_pretty(&manifest).unwrap(),
2464        )
2465        .unwrap();
2466
2467        restore_pitr(
2468            &archive,
2469            &destination,
2470            PitrTarget::Latest,
2471            PitrCredentials::None,
2472        )
2473        .unwrap();
2474        let restored = Database::open(destination).unwrap();
2475        assert_eq!(
2476            restored
2477                .table("items")
2478                .unwrap()
2479                .lock()
2480                .visible_rows(restored.snapshot().0)
2481                .unwrap()
2482                .len(),
2483            1
2484        );
2485        assert!(matches!(
2486            database.archive_pitr(&archive),
2487            Err(MongrelError::Conflict(_))
2488        ));
2489    }
2490
2491    #[cfg(feature = "encryption")]
2492    #[test]
2493    fn encrypted_chunks_use_distinct_random_nonces() {
2494        let source = tempfile::tempdir().unwrap();
2495        let archive_parent = tempfile::tempdir().unwrap();
2496        let archive = archive_parent.path().join("archive");
2497        let database = Database::create_encrypted(source.path(), "secret passphrase").unwrap();
2498        database.create_table("items", schema()).unwrap();
2499        database.create_pitr_archive(&archive).unwrap();
2500        for id in [1, 2] {
2501            let mut transaction = database.begin();
2502            transaction
2503                .put("items", vec![(1, crate::Value::Int64(id))])
2504                .unwrap();
2505            transaction.commit().unwrap();
2506            database.archive_pitr(&archive).unwrap();
2507        }
2508        let manifest = read_pitr_manifest(&archive).unwrap();
2509        assert_eq!(manifest.chunks.len(), 2);
2510        let nonces = manifest
2511            .chunks
2512            .iter()
2513            .map(|reference| {
2514                let bytes = std::fs::read(archive.join(&reference.file)).unwrap();
2515                bincode::DefaultOptions::new()
2516                    .with_fixint_encoding()
2517                    .reject_trailing_bytes()
2518                    .deserialize::<PitrChunkEnvelopeV2>(&bytes)
2519                    .unwrap()
2520                    .nonce
2521                    .unwrap()
2522            })
2523            .collect::<Vec<_>>();
2524        assert_ne!(nonces[0], nonces[1]);
2525    }
2526
2527    #[cfg(feature = "encryption")]
2528    #[test]
2529    fn encrypted_legacy_archive_is_refused() {
2530        let source = tempfile::tempdir().unwrap();
2531        let archive_parent = tempfile::tempdir().unwrap();
2532        let restore_parent = tempfile::tempdir().unwrap();
2533        let archive = archive_parent.path().join("archive");
2534        let destination = restore_parent.path().join("restored");
2535        let database = Database::create_encrypted(source.path(), "secret passphrase").unwrap();
2536        database.create_pitr_archive(&archive).unwrap();
2537        let mut manifest = read_pitr_manifest(&archive).unwrap();
2538        manifest.format_version = LEGACY_FORMAT_VERSION;
2539        manifest.encrypted = false;
2540        manifest.base_backup_sha256.clear();
2541        manifest.chain_sha256.clear();
2542        manifest.authentication = None;
2543        std::fs::write(
2544            archive.join(MANIFEST_FILE),
2545            serde_json::to_vec_pretty(&manifest).unwrap(),
2546        )
2547        .unwrap();
2548
2549        assert!(matches!(
2550            restore_pitr(
2551                &archive,
2552                &destination,
2553                PitrTarget::Latest,
2554                PitrCredentials::Encryption("secret passphrase"),
2555            ),
2556            Err(MongrelError::Conflict(_))
2557        ));
2558        assert!(!destination.exists());
2559    }
2560}