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