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