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