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