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