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