Skip to main content

mongreldb_core/
pitr.rs

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