From a9bb9f895b063e5365b3fc629a753bcd62db742b Mon Sep 17 00:00:00 2001 From: Andrew Briscoe Date: Sun, 26 Jul 2026 10:59:38 -0600 Subject: [PATCH 1/2] fix(snapshot): authenticate exact incremental state --- CHANGELOG.md | 12 + src/segment/reader.rs | 23 + src/snapshot/apply.rs | 117 +- src/snapshot/export.rs | 81 +- src/txn/db/segment.rs | 24 +- src/txn/db/snapshot.rs | 526 ++++++-- tests/snapshot_basic.rs | 2629 +++++++++++++++++++++++++++++++++++++-- 7 files changed, 3105 insertions(+), 307 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1afc70..444de2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Snapshot export, restore, and incremental apply now fail closed on malformed, + incomplete, extra, or unreadable artifacts instead of silently skipping + pages or segment files; exported files are synced before success. +- Incremental apply authenticates its exact reachable page and segment sets, + invalidates stale cached plaintext after raw page writes, preserves published + base pages, rejects stale future-page dependencies, and journals both segment + promotion and tombstoning before publishing the target header. + ## [0.1.0] - 2026-06-07 Initial release. diff --git a/src/segment/reader.rs b/src/segment/reader.rs index 0340cc1..db9a6a6 100644 --- a/src/segment/reader.rs +++ b/src/segment/reader.rs @@ -85,6 +85,29 @@ impl SegmentReader { .await } + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn open_internal_at_path( + pager: Arc>, + catalog_meta: SegmentMeta, + path: &str, + mmap_budget_used: std::sync::Arc, + mmap_budget_limit: u64, + ) -> Result { + let page_size = pager.page_size(); + let file = pager.vfs().open(path, OpenMode::Read).await?; + Self::finish_open( + pager, + catalog_meta, + page_size, + file, + MmapBudget { + used: mmap_budget_used, + limit: mmap_budget_limit, + }, + ) + .await + } + /// Open a sealed replacement from either publication location. The /// replacement identity is durable progress, so a missing or malformed /// file must fail closed rather than being regenerated. diff --git a/src/snapshot/apply.rs b/src/snapshot/apply.rs index 0b8fcd4..f76f923 100644 --- a/src/snapshot/apply.rs +++ b/src/snapshot/apply.rs @@ -3,7 +3,7 @@ #![cfg(not(target_arch = "wasm32"))] -use std::path::Path; +use std::{collections::BTreeSet, path::Path}; use tokio::fs; use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; @@ -11,6 +11,14 @@ use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; use crate::Result; use crate::errors::PagedbError; +/// Pages consumed from one `pages.delta` stream during an incremental apply. +pub struct AppliedDeltaPages { + /// Number of page records written to `main.db`. + pub pages_applied: u64, + /// Page IDs supplied by this exact delta stream. + pub page_ids: BTreeSet, +} + /// Apply an incremental snapshot directory (`src_path`) to the Follower's /// `main.db` file at `main_db_path` (absolute filesystem path). Returns stats. /// @@ -20,54 +28,103 @@ pub async fn apply_delta_pages( src_path: &Path, dst_main_db_path: &Path, page_size: usize, -) -> Result { + protected_page_ids: &BTreeSet, + base_allocation_floor: u64, + target_next_page_id: u64, +) -> Result { let delta_path = src_path.join("pages.delta"); let mut delta = match fs::File::open(&delta_path).await { Ok(f) => f, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(AppliedDeltaPages { + pages_applied: 0, + page_ids: BTreeSet::new(), + }); + } Err(e) => return Err(PagedbError::Io(e)), }; + let page_size_u64 = u64::try_from(page_size) + .map_err(|_| PagedbError::snapshot_artifact_invalid("pages.delta.page_size"))?; + let record_size = 8u64 + .checked_add(page_size_u64) + .ok_or_else(|| PagedbError::snapshot_artifact_invalid("pages.delta.record_size"))?; + let delta_len = delta.metadata().await.map_err(PagedbError::Io)?.len(); + if delta_len % record_size != 0 { + return Err(PagedbError::snapshot_artifact_invalid("pages.delta.length")); + } + + // Validate every record before opening main.db for writes. A malformed + // record late in the stream must not leave a valid prefix written into the + // follower's future page range. + let record_count = delta_len / record_size; + let mut page_ids = BTreeSet::new(); + let mut id_buf = [0u8; 8]; + let page_skip = i64::try_from(page_size) + .map_err(|_| PagedbError::snapshot_artifact_invalid("pages.delta.page_size"))?; + for _ in 0..record_count { + delta + .read_exact(&mut id_buf) + .await + .map_err(PagedbError::Io)?; + let page_id = u64::from_be_bytes(id_buf); + if page_id < base_allocation_floor + || page_id >= target_next_page_id + || protected_page_ids.contains(&page_id) + { + return Err(PagedbError::snapshot_artifact_invalid( + "pages.delta.page_id", + )); + } + if !page_ids.insert(page_id) { + return Err(PagedbError::snapshot_artifact_invalid( + "pages.delta.duplicate_page_id", + )); + } + delta + .seek(std::io::SeekFrom::Current(page_skip)) + .await + .map_err(PagedbError::Io)?; + } + delta + .seek(std::io::SeekFrom::Start(0)) + .await + .map_err(PagedbError::Io)?; + let mut dst = fs::OpenOptions::new() .read(true) .write(true) .open(dst_main_db_path) .await .map_err(PagedbError::Io)?; - - let mut pages_applied: u64 = 0; - let mut id_buf = [0u8; 8]; let mut page_buf = vec![0u8; page_size]; - - loop { - // Read page_id (8 bytes BE). - match delta.read_exact(&mut id_buf).await { - Ok(_) => {} - Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, - Err(e) => return Err(PagedbError::Io(e)), - } + for _ in 0..record_count { + delta + .read_exact(&mut id_buf) + .await + .map_err(PagedbError::Io)?; let page_id = u64::from_be_bytes(id_buf); - - // Read page bytes. - match delta.read_exact(&mut page_buf).await { - Ok(_) => {} - Err(e) => return Err(PagedbError::Io(e)), - } + delta + .read_exact(&mut page_buf) + .await + .map_err(PagedbError::Io)?; // Write page to main.db at the correct offset. let offset = page_id - .checked_mul(page_size as u64) - .ok_or_else(|| PagedbError::Io(std::io::Error::other("page offset overflow")))?; + .checked_mul(page_size_u64) + .ok_or_else(|| PagedbError::snapshot_artifact_invalid("pages.delta.page_offset"))?; dst.seek(std::io::SeekFrom::Start(offset)) .await .map_err(PagedbError::Io)?; dst.write_all(&page_buf).await.map_err(PagedbError::Io)?; - pages_applied += 1; } dst.flush().await.map_err(PagedbError::Io)?; dst.sync_all().await.map_err(PagedbError::Io)?; - Ok(pages_applied) + Ok(AppliedDeltaPages { + pages_applied: record_count, + page_ids, + }) } /// Verify that the snapshot's segment directory has exactly the count claimed @@ -90,9 +147,20 @@ pub(crate) async fn validate_snapshot_segment_count(src_path: &Path, expected: u pub async fn stage_snapshot_segments( src_path: &Path, dst_seg_root: &Path, + expected_segment_ids: &BTreeSet<[u8; 16]>, ) -> Result> { let entries = snapshot_segment_entries(src_path).await?; let seg_src = src_path.join("seg"); + let actual_segment_ids: BTreeSet<[u8; 16]> = entries + .iter() + .map(|name| { + crate::hex::parse_hex::<16>(name) + .ok_or_else(|| PagedbError::snapshot_artifact_invalid("segment_file_name")) + }) + .collect::>()?; + if &actual_segment_ids != expected_segment_ids { + return Err(PagedbError::snapshot_artifact_invalid("segments")); + } let staging_dir = dst_seg_root.join(".staging"); fs::create_dir_all(&staging_dir) @@ -103,7 +171,6 @@ pub async fn stage_snapshot_segments( let mut copy_buf = vec![0u8; 64 * 1024]; for name in &entries { - // Each name in seg/ is 32 hex chars encoding the 16-byte segment id. let segment_id = crate::hex::parse_hex::<16>(name) .ok_or_else(|| PagedbError::snapshot_artifact_invalid("segment_file_name"))?; let src_file = seg_src.join(name); diff --git a/src/snapshot/export.rs b/src/snapshot/export.rs index 2b5a36e..53fc0b2 100644 --- a/src/snapshot/export.rs +++ b/src/snapshot/export.rs @@ -155,6 +155,27 @@ fn compute_manifest_mac(data: &[u8], hk_key: &[u8; 32]) -> [u8; 16] { out } +async fn ensure_empty_destination(path: &Path) -> Result<()> { + match fs::read_dir(path).await { + Ok(mut entries) => { + if entries + .next_entry() + .await + .map_err(PagedbError::Io)? + .is_some() + { + return Err(PagedbError::Io(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("snapshot destination is not empty: {}", path.display()), + ))); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(PagedbError::Io(error)), + } + Ok(()) +} + /// Copy all bytes from a tokio file to a destination path, returning bytes written. async fn copy_file_to(src_path: &Path, dst_path: &Path) -> Result { let mut src = fs::File::open(src_path).await.map_err(PagedbError::Io)?; @@ -170,6 +191,7 @@ async fn copy_file_to(src_path: &Path, dst_path: &Path) -> Result { total += n as u64; } dst.flush().await.map_err(PagedbError::Io)?; + dst.sync_all().await.map_err(PagedbError::Io)?; Ok(total) } @@ -184,7 +206,10 @@ pub async fn snapshot_full( manifest: &SnapshotManifest, hk_key: &[u8; 32], segment_ids: &[[u8; 16]], + highest_required_main_page: u64, ) -> Result { + ensure_empty_destination(dst_path).await?; + // Create destination directory layout. fs::create_dir_all(dst_path) .await @@ -204,6 +229,7 @@ pub async fn snapshot_full( .await .map_err(PagedbError::Io)?; mf.flush().await.map_err(PagedbError::Io)?; + mf.sync_all().await.map_err(PagedbError::Io)?; let mut total_bytes: u64 = MANIFEST_RESERVED_SIZE as u64; // Copy main.db. @@ -214,6 +240,20 @@ pub async fn snapshot_full( // Count pages from file size. let page_size = u64::from(manifest.page_size); + let highest_required_page = highest_required_main_page.max(1).max( + manifest + .target_active_root_page_id + .max(manifest.target_catalog_root_page_id), + ); + let required_main_bytes = highest_required_page + .checked_add(1) + .and_then(|page_count| page_count.checked_mul(page_size)) + .ok_or_else(|| PagedbError::snapshot_artifact_invalid("main.db.length"))?; + if main_bytes < required_main_bytes { + return Err(PagedbError::Io(std::io::Error::from( + std::io::ErrorKind::UnexpectedEof, + ))); + } let pages_written = main_bytes.checked_div(page_size).unwrap_or(0); // Copy segment files. @@ -222,11 +262,8 @@ pub async fn snapshot_full( let hex = crate::hex::to_hex_lower(seg_id); let seg_src = src_db_root.join("seg").join(&hex); let seg_dst_file = seg_dst.join(&hex); - if let Ok(n) = copy_file_to(&seg_src, &seg_dst_file).await { - total_bytes += n; - segments_written += 1; - } - // Err(_): file may have been tombstoned between list and copy; skip. + total_bytes += copy_file_to(&seg_src, &seg_dst_file).await?; + segments_written += 1; } Ok(SnapshotStats { @@ -245,16 +282,15 @@ pub async fn snapshot_full( /// The header byte at offset 12 of a data page is the first byte of the /// 6-byte nonce, not the `commit_id`. The specification says: "compare /// `commit_id` stored in each data-page header (offset 12 per Format A)". -/// However, Format A layout has: `cipher_id`[0], `page_kind`[1], flags[2..4], -/// `mk_epoch`[4..12], nonce[12..18]. There is no per-page `commit_id` in the +/// However, Format A layout has: `cipher_id[0]`, `page_kind[1]`, `flags[2..4]`, +/// `mk_epoch[4..12]`, `nonce[12..18]`. There is no per-page `commit_id` in the /// ciphertext header. The correct approach is to emit all pages from the /// current root that are at `page_id` >= `base_next_page_id` (newly allocated /// after base commit), or use the data pages the `BTree` walks. /// -/// We use a practical simplification: emit all pages reachable from the -/// current root whose `page_id` >= `base_next_page_id` (pages allocated after -/// the base snapshot's `next_page_id`). This is conservative but correct: it -/// never emits fewer pages than needed. +/// The caller walks both authenticated snapshots and supplies the exact set of +/// target-reachable pages that were not reachable from the base. Reused page +/// IDs below the base allocation cursor are rejected before this writer runs. pub async fn snapshot_incremental( src_db_root: &Path, dst_path: &Path, @@ -264,6 +300,8 @@ pub async fn snapshot_incremental( base_next_page_id: u64, changed_page_ids: &[u64], ) -> Result { + ensure_empty_destination(dst_path).await?; + fs::create_dir_all(dst_path) .await .map_err(PagedbError::Io)?; @@ -282,6 +320,7 @@ pub async fn snapshot_incremental( .await .map_err(PagedbError::Io)?; mf.flush().await.map_err(PagedbError::Io)?; + mf.sync_all().await.map_err(PagedbError::Io)?; let mut total_bytes: u64 = MANIFEST_RESERVED_SIZE as u64; // Write pages.delta: (page_id u64 BE, page_bytes) for each changed page. @@ -314,13 +353,10 @@ pub async fn snapshot_incremental( .seek(std::io::SeekFrom::Start(offset)) .await .map_err(PagedbError::Io)?; - let n = main_file - .read(&mut page_buf) + main_file + .read_exact(&mut page_buf) .await .map_err(PagedbError::Io)?; - if n < page_size { - continue; // sparse / beyond EOF - } delta_file .write_all(&page_id.to_be_bytes()) .await @@ -333,6 +369,7 @@ pub async fn snapshot_incremental( pages_written += 1; } delta_file.flush().await.map_err(PagedbError::Io)?; + delta_file.sync_all().await.map_err(PagedbError::Io)?; let _ = base_next_page_id; // used by caller to compute changed_page_ids // Copy new/changed segment files. @@ -341,10 +378,8 @@ pub async fn snapshot_incremental( let hex = crate::hex::to_hex_lower(seg_id); let seg_src = src_db_root.join("seg").join(&hex); let seg_dst_file = seg_dst.join(&hex); - if let Ok(n) = copy_file_to(&seg_src, &seg_dst_file).await { - total_bytes += n; - segments_written += 1; - } + total_bytes += copy_file_to(&seg_src, &seg_dst_file).await?; + segments_written += 1; } Ok(SnapshotStats { @@ -373,11 +408,11 @@ pub async fn open_manifest(manifest_path: &Path, kek: &[u8; 32]) -> Result Db { let value = tree.get(&key).await?.ok_or(PagedbError::NotFound)?; Catalog::decode_segment_meta(&value) } - - /// List all segment entries in the catalog. - pub(super) async fn list_all_segments(&self, state: &WriterState) -> Result> { - if state.catalog_root_page_id == 0 { - return Ok(Vec::new()); - } - let tree = BTree::open( - self.pager.clone(), - self.realm_id, - state.catalog_root_page_id, - state.next_page_id, - self.page_size, - ); - let start = vec![CatalogRowKind::Segment as u8]; - let rows = tree.scan_prefix(&start).await?; - let mut out = Vec::with_capacity(rows.len()); - for (_k, v) in rows { - let meta = Catalog::decode_segment_meta(&v)?; - out.push(meta); - } - Ok(out) - } } diff --git a/src/txn/db/snapshot.rs b/src/txn/db/snapshot.rs index bbb2011..d871b25 100644 --- a/src/txn/db/snapshot.rs +++ b/src/txn/db/snapshot.rs @@ -3,6 +3,8 @@ #![cfg(not(target_arch = "wasm32"))] +use std::collections::BTreeSet; + use crate::pager::header::commit_header; use crate::pager::structural_header::MainDbHeaderFields; use crate::recovery::journal::{ @@ -18,11 +20,198 @@ use crate::txn::mode::DbMode; use crate::vfs::Vfs; use crate::vfs::tokio_backend::TokioVfs; use tokio::fs; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::AsyncReadExt; -use super::core::Db; +use super::core::{Db, ReaderSnapshot, decode_commit_meta}; use super::util::{get_vfs_root, page_size_log2}; +async fn collect_tree_page_ids( + db: &Db, + active_root_page_id: u64, + catalog_root_page_id: u64, + next_page_id: u64, +) -> crate::Result> { + let mut page_ids = BTreeSet::new(); + for root_page_id in [active_root_page_id, catalog_root_page_id] { + if root_page_id == 0 { + continue; + } + let tree = crate::btree::BTree::open( + db.pager.clone(), + db.realm_id, + root_page_id, + next_page_id, + db.page_size, + ); + tree.collect_all_page_ids(&mut page_ids).await?; + } + Ok(page_ids) +} + +async fn collect_published_page_ids( + db: &Db, + snapshot: ReaderSnapshot, +) -> crate::Result> { + let mut page_ids = collect_tree_page_ids( + db, + snapshot.root_page_id, + snapshot.catalog_root_page_id, + snapshot.next_page_id, + ) + .await?; + if snapshot.commit_history_root_page_id != 0 { + let history = crate::btree::BTree::open( + db.pager.clone(), + db.realm_id, + snapshot.commit_history_root_page_id, + snapshot.next_page_id, + db.page_size, + ); + history.collect_all_page_ids(&mut page_ids).await?; + } + if snapshot.free_list_root_page_id != 0 { + let (_entries, chain_pages) = crate::pager::freelist::read_chain( + &db.pager, + db.realm_id, + snapshot.free_list_root_page_id, + ) + .await?; + page_ids.extend(chain_pages); + } + Ok(page_ids) +} + +async fn published_allocation_floor( + db: &Db, + snapshot: ReaderSnapshot, +) -> crate::Result { + if snapshot.commit_history_root_page_id == 0 { + return Ok(snapshot.next_page_id); + } + let history = crate::btree::BTree::open( + db.pager.clone(), + db.realm_id, + snapshot.commit_history_root_page_id, + snapshot.next_page_id, + db.page_size, + ); + let key = snapshot.commit_id.to_be_bytes(); + match history.get(&key).await? { + Some(value) => Ok(decode_commit_meta(&value)?.next_page_id), + None => Ok(snapshot.next_page_id), + } +} + +async fn ensure_restore_destination_empty(dst_path: &std::path::Path) -> crate::Result<()> { + match fs::read_dir(dst_path).await { + Ok(mut entries) => { + if entries + .next_entry() + .await + .map_err(crate::errors::PagedbError::Io)? + .is_some() + { + return Err(crate::errors::PagedbError::Io(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("restore destination is not empty: {}", dst_path.display()), + ))); + } + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(crate::errors::PagedbError::Io(error)), + } +} + +async fn cleanup_failed_snapshot(dst_path: &std::path::Path, error: &crate::errors::PagedbError) { + if !matches!( + error, + crate::errors::PagedbError::Io(io) if io.kind() == std::io::ErrorKind::AlreadyExists + ) { + let _ = fs::remove_dir_all(dst_path).await; + } +} + +async fn validate_restored_snapshot( + manifest: &SnapshotManifest, + restored: &Db, +) -> crate::Result<()> { + let snapshot = *restored.snapshot.read(); + if snapshot.commit_id != manifest.target_commit { + return Err(crate::errors::PagedbError::snapshot_incompatible( + "target_commit", + )); + } + if snapshot.next_page_id != manifest.next_page_id_at_target { + return Err(crate::errors::PagedbError::snapshot_incompatible( + "next_page_id_at_target", + )); + } + if snapshot.root_page_id != manifest.target_active_root_page_id { + return Err(crate::errors::PagedbError::snapshot_incompatible( + "target_active_root_page_id", + )); + } + if snapshot.catalog_root_page_id != manifest.target_catalog_root_page_id { + return Err(crate::errors::PagedbError::snapshot_incompatible( + "target_catalog_root_page_id", + )); + } + + collect_tree_page_ids( + restored, + snapshot.root_page_id, + snapshot.catalog_root_page_id, + snapshot.next_page_id, + ) + .await?; + + let expected_segments = restored.list_segments(restored.realm_id, "").await?; + let expected_ids: BTreeSet<[u8; 16]> = expected_segments + .iter() + .map(|meta| meta.segment_id) + .collect(); + let mut actual_ids = BTreeSet::new(); + let mut entries = fs::read_dir(restored.vfs.root_path().join("seg")) + .await + .map_err(crate::errors::PagedbError::Io)?; + while let Some(entry) = entries + .next_entry() + .await + .map_err(crate::errors::PagedbError::Io)? + { + let name = entry + .file_name() + .into_string() + .map_err(|_| crate::errors::PagedbError::snapshot_artifact_invalid("segment.name"))?; + let segment_id = crate::hex::parse_hex::<16>(&name) + .ok_or_else(|| crate::errors::PagedbError::snapshot_artifact_invalid("segment.name"))?; + actual_ids.insert(segment_id); + } + if actual_ids != expected_ids + || u32::try_from(actual_ids.len()).ok() != Some(manifest.segments_count) + { + return Err(crate::errors::PagedbError::snapshot_artifact_invalid( + "segments", + )); + } + + let mmap_limit = u64::try_from(restored.options.mmap_view_scratch_bytes).unwrap_or(u64::MAX); + for meta in expected_segments { + let reader = crate::segment::reader::SegmentReader::open_internal( + restored.pager.clone(), + meta.clone(), + restored.mmap_bytes_in_use.clone(), + mmap_limit, + ) + .await?; + for page_id in 1..meta.page_count.saturating_sub(1) { + let _ = reader.read_page(page_id).await?; + } + } + Ok(()) +} + impl Db { /// Full verbatim snapshot of the database at the current `latest_commit`. /// @@ -74,8 +263,25 @@ impl Db { }; let src_root = get_vfs_root(&*self.vfs)?; - - let stats = snapshot_full(&src_root, dst_path, &manifest, &hk_raw, &segment_ids).await?; + let published_snapshot = *self.snapshot.read(); + let required_page_ids = collect_published_page_ids(self, published_snapshot).await?; + let highest_required_main_page = required_page_ids.iter().next_back().copied().unwrap_or(1); + let stats = match snapshot_full( + &src_root, + dst_path, + &manifest, + &hk_raw, + &segment_ids, + highest_required_main_page, + ) + .await + { + Ok(stats) => stats, + Err(error) => { + cleanup_failed_snapshot(dst_path, &error).await; + return Err(error); + } + }; drop(txn); // unpin Ok(stats) } @@ -97,58 +303,80 @@ impl Db { // Verify and parse manifest. let manifest_src = src_path.join("manifest"); let manifest = open_manifest(&manifest_src, kek.as_bytes()).await?; + if manifest.kind != 0 { + return Err(crate::errors::PagedbError::snapshot_incompatible("kind")); + } + ensure_restore_destination_empty(dst_path).await?; - // Create destination directory. - fs::create_dir_all(dst_path) - .await - .map_err(crate::errors::PagedbError::Io)?; - let seg_dst = dst_path.join("seg"); - fs::create_dir_all(&seg_dst) - .await - .map_err(crate::errors::PagedbError::Io)?; - - // Copy manifest. - let mut manifest_bytes = [0u8; 240]; - { - let mut f = fs::File::open(&manifest_src) + let restore_result = async { + // Create destination directory. + fs::create_dir_all(dst_path) .await .map_err(crate::errors::PagedbError::Io)?; - f.read_exact(&mut manifest_bytes) + let seg_dst = dst_path.join("seg"); + fs::create_dir_all(&seg_dst) .await .map_err(crate::errors::PagedbError::Io)?; - } - { - let mut f = fs::File::create(dst_path.join("manifest")) + + // Copy the already length- and MAC-validated manifest. + fs::copy(&manifest_src, dst_path.join("manifest")) .await .map_err(crate::errors::PagedbError::Io)?; - f.write_all(&manifest_bytes) + + // Copy main.db. + fs::copy(src_path.join("main.db"), dst_path.join("main.db")) .await .map_err(crate::errors::PagedbError::Io)?; - } - // Copy main.db. - fs::copy(src_path.join("main.db"), dst_path.join("main.db")) - .await - .map_err(crate::errors::PagedbError::Io)?; - - // Copy segment files. - let seg_src = src_path.join("seg"); - if let Ok(mut rd) = fs::read_dir(&seg_src).await { - while let Ok(Some(entry)) = rd.next_entry().await { - let name = entry.file_name(); - let src_file = seg_src.join(&name); - let dst_file = seg_dst.join(&name); - fs::copy(&src_file, &dst_file) - .await - .map_err(crate::errors::PagedbError::Io)?; + // Copy segment files without collapsing directory or entry errors. + let seg_src = src_path.join("seg"); + match fs::read_dir(&seg_src).await { + Ok(mut entries) => { + while let Some(entry) = entries + .next_entry() + .await + .map_err(crate::errors::PagedbError::Io)? + { + let name = entry.file_name(); + fs::copy(entry.path(), seg_dst.join(name)) + .await + .map_err(crate::errors::PagedbError::Io)?; + } + } + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && manifest.segments_count == 0 => {} + Err(error) => return Err(crate::errors::PagedbError::Io(error)), } - } - // Open the restored Db in ReadOnly mode. - let page_size = manifest.page_size as usize; - let realm_id = crate::RealmId(manifest.realm_id); - let dst_vfs = TokioVfs::new(dst_path); - Db::::open_read_only(dst_vfs, kek, page_size, realm_id, options).await + // Open and authenticate every manifest-referenced root and segment + // before returning a usable handle. + let page_size = usize::try_from(manifest.page_size) + .map_err(|_| crate::errors::PagedbError::snapshot_incompatible("page_size"))?; + let realm_id = crate::RealmId(manifest.realm_id); + let dst_vfs = TokioVfs::new(dst_path); + let restored = + Db::::open_read_only(dst_vfs, kek, page_size, realm_id, options) + .await + .map_err(|error| match error { + // A syntactically named but malformed undeclared + // sidecar can be discovered by open-time recovery + // before the exact catalog/file-set check below. + // In a restore artifact this is corruption, not an + // unsupported host capability. + crate::errors::PagedbError::Unsupported => { + crate::errors::PagedbError::snapshot_artifact_invalid("segments") + } + other => other, + })?; + validate_restored_snapshot(&manifest, &restored).await?; + Ok(restored) + } + .await; + if let Err(error) = &restore_result { + cleanup_failed_snapshot(dst_path, error).await; + } + restore_result } /// Page-diff snapshot since `base_commit`. Emits only pages changed since @@ -167,27 +395,42 @@ impl Db { let target_active_root_page_id = txn.root_page_id(); let target_catalog_root_page_id = txn.catalog_root_page_id(); - // Get base snapshot's next_page_id by reading the commit history. - let base_txn_result = self.begin_read_at(base_commit).await; - let base_next_page_id = base_txn_result - .as_ref() - .map_or(0u64, crate::txn::ReadTxn::next_page_id); - let base_catalog_root = base_txn_result - .as_ref() - .map_or(0u64, crate::txn::ReadTxn::catalog_root_page_id); + // A missing base is not an empty baseline: without its roots there is + // no sound way to determine which pages the follower must preserve. + let base_txn = self.begin_read_at(base_commit).await?; + let base_next_page_id = base_txn.next_page_id(); + let base_catalog_root = base_txn.catalog_root_page_id(); - // Pages changed = all pages with page_id >= base_next_page_id (allocated after base). - // Also include all pages reachable from current root that have id >= base_next_page_id. - let changed_page_ids: Vec = (base_next_page_id..target_next_page_id).collect(); + let target_page_ids = collect_tree_page_ids( + self, + target_active_root_page_id, + target_catalog_root_page_id, + target_next_page_id, + ) + .await?; + let base_page_ids = collect_tree_page_ids( + self, + base_txn.root_page_id(), + base_catalog_root, + base_next_page_id, + ) + .await?; + let changed_page_ids: Vec = target_page_ids + .difference(&base_page_ids) + .copied() + .collect(); + if changed_page_ids + .iter() + .any(|page_id| *page_id < base_next_page_id) + { + return Err(crate::errors::PagedbError::Unsupported); + } // Current segments. let current_segments = txn.list_segments("").await?; // Base segments (from base catalog). let base_segments: Vec = if base_catalog_root != 0 { - match &base_txn_result { - Ok(bt) => bt.list_segments("").await.unwrap_or_default(), - Err(_) => Vec::new(), - } + base_txn.list_segments("").await? } else { Vec::new() }; @@ -227,7 +470,7 @@ impl Db { let src_root = get_vfs_root(&*self.vfs)?; - let stats = snapshot_incremental( + let stats = match snapshot_incremental( &src_root, dst_path, &manifest, @@ -236,7 +479,14 @@ impl Db { base_next_page_id, &changed_page_ids, ) - .await?; + .await + { + Ok(stats) => stats, + Err(error) => { + cleanup_failed_snapshot(dst_path, &error).await; + return Err(error); + } + }; drop(txn); Ok(stats) @@ -281,6 +531,16 @@ impl Db { let mut f = fs::File::open(&manifest_path) .await .map_err(crate::errors::PagedbError::Io)?; + if f.metadata() + .await + .map_err(crate::errors::PagedbError::Io)? + .len() + != 240 + { + return Err(crate::errors::PagedbError::snapshot_artifact_invalid( + "manifest.length", + )); + } let mut buf = [0u8; 240]; let _ = f .read_exact(&mut buf) @@ -290,6 +550,17 @@ impl Db { }; let manifest = decode_manifest(&manifest_bytes, &hk_raw)?; self.validate_incremental_manifest(&manifest, &manifest_bytes[118..224])?; + let base_snapshot = *self.snapshot.read(); + let protected_page_ids = collect_published_page_ids(self, base_snapshot).await?; + let base_allocation_floor = published_allocation_floor(self, base_snapshot).await?; + let base_segment_ids: BTreeSet<[u8; 16]> = self + .begin_read() + .await? + .list_segments("") + .await? + .into_iter() + .map(|meta| meta.segment_id) + .collect(); let page_size = usize::try_from(manifest.page_size) .map_err(|_| crate::errors::PagedbError::snapshot_incompatible("page_size"))?; @@ -299,54 +570,44 @@ impl Db { let dst_main_db = vfs_root.join("main.db"); // Write delta pages to main.db. - let pages_applied = apply_delta_pages(src_path, &dst_main_db, page_size).await?; - - // Stage new segment files in `.staging/` so they can be promoted - // atomically after the header swap via the apply journal. - let dst_seg_root = vfs_root.join("seg"); - let staging_dir = dst_seg_root.join(".staging"); - let staging_existed = match tokio::fs::metadata(&staging_dir).await { - Ok(_) => true, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, - Err(error) => return Err(crate::errors::PagedbError::Io(error)), - }; - let staged_ids = stage_snapshot_segments(src_path, &dst_seg_root).await?; - let segments_promoted = u32::try_from(staged_ids.len()) - .map_err(|_| crate::errors::PagedbError::snapshot_incompatible("segments_count"))?; - if segments_promoted != manifest.segments_count { - for segment_id in &staged_ids { - let path = staging_dir.join(crate::hex::to_hex_lower(segment_id)); - tokio::fs::remove_file(path) - .await - .map_err(crate::errors::PagedbError::Io)?; - } - if !staging_existed { - tokio::fs::remove_dir(&staging_dir) - .await - .map_err(crate::errors::PagedbError::Io)?; - } - return Err(crate::errors::PagedbError::snapshot_incompatible( - "segments_count", + let applied_pages = apply_delta_pages( + src_path, + &dst_main_db, + page_size, + &protected_page_ids, + base_allocation_floor, + manifest.next_page_id_at_target, + ) + .await?; + // Raw delta writes bypass the Pager. Discard unpinned main-db cache + // entries before authenticating the target so a retry cannot validate + // stale plaintext left by an earlier failed apply. + self.pager.reset_main_pages(); + + let target_page_ids = collect_tree_page_ids( + self, + manifest.target_active_root_page_id, + manifest.target_catalog_root_page_id, + manifest.next_page_id_at_target, + ) + .await?; + let expected_delta_page_ids: BTreeSet = target_page_ids + .difference(&protected_page_ids) + .copied() + .collect(); + if applied_pages.page_ids != expected_delta_page_ids { + return Err(crate::errors::PagedbError::snapshot_artifact_invalid( + "pages.delta.reachability", )); } - let new_commit_id = manifest.target_commit; - let mut state = self.writer.lock().await; - self.ensure_usable()?; - - // Reconcile the target catalog against the currently published one. - // The target pages are already present on disk, so this comparison can - // record both staged promotions and old live segments that must be - // tombstoned after the header becomes durable. - let old_segments = self.list_all_segments(&state).await?; - let target_catalog_root = manifest.target_catalog_root_page_id; - let target_segments = if target_catalog_root == 0 { + let target_segments = if manifest.target_catalog_root_page_id == 0 { Vec::new() } else { let tree = crate::btree::BTree::open( self.pager.clone(), self.realm_id, - target_catalog_root, + manifest.target_catalog_root_page_id, manifest.next_page_id_at_target, self.page_size, ); @@ -359,18 +620,67 @@ impl Db { } entries }; - let target_ids: std::collections::HashSet<[u8; 16]> = + let target_segment_ids: BTreeSet<[u8; 16]> = target_segments.iter().map(|meta| meta.segment_id).collect(); + let expected_promoted_segment_ids: BTreeSet<[u8; 16]> = target_segment_ids + .difference(&base_segment_ids) + .copied() + .collect(); + let expected_tombstoned_segment_ids: BTreeSet<[u8; 16]> = base_segment_ids + .difference(&target_segment_ids) + .copied() + .collect(); + if expected_promoted_segment_ids.len() != manifest.segments_count as usize { + return Err(crate::errors::PagedbError::snapshot_artifact_invalid( + "segments_count", + )); + } + + // Stage new segment files in `.staging/` so they can be promoted + // atomically after the header swap via the apply journal. + let dst_seg_root = vfs_root.join("seg"); + let staged_ids = + stage_snapshot_segments(src_path, &dst_seg_root, &expected_promoted_segment_ids) + .await?; + if !staged_ids.is_empty() { + self.vfs.sync_dir("seg/.staging").await?; + } + let segments_promoted = u32::try_from(staged_ids.len()) + .map_err(|_| crate::errors::PagedbError::snapshot_incompatible("segments_count"))?; + let segments_tombstoned = u32::try_from(expected_tombstoned_segment_ids.len()) + .map_err(|_| crate::errors::PagedbError::snapshot_incompatible("segments_count"))?; + let mmap_limit = u64::try_from(self.options.mmap_view_scratch_bytes).unwrap_or(u64::MAX); + for meta in target_segments + .iter() + .filter(|meta| expected_promoted_segment_ids.contains(&meta.segment_id)) + { + let staged_path = crate::segment::writer::staging_path(&meta.segment_id); + let reader = crate::segment::reader::SegmentReader::open_internal_at_path( + self.pager.clone(), + meta.clone(), + &staged_path, + self.mmap_bytes_in_use.clone(), + mmap_limit, + ) + .await?; + for page_id in 1..meta.page_count.saturating_sub(1) { + let _ = reader.read_page(page_id).await?; + } + } + + let new_commit_id = manifest.target_commit; let mut actions: Vec = staged_ids .iter() .map(|&segment_id| JournalAction::Promote { segment_id }) .collect(); - actions.extend(old_segments.into_iter().filter_map(|meta| { - (!target_ids.contains(&meta.segment_id)).then_some(JournalAction::Tombstone { - segment_id: meta.segment_id, + actions.extend(expected_tombstoned_segment_ids.iter().map(|&segment_id| { + JournalAction::Tombstone { + segment_id, tombstone_commit_id: new_commit_id, - }) + } })); + let mut state = self.writer.lock().await; + self.ensure_usable()?; // Write the journal record to a fresh apply-journal sidecar via the // Pager AEAD path. A fresh, never-reused `journal_id` guarantees the @@ -486,9 +796,9 @@ impl Db { } Ok(crate::snapshot::ApplyStats { - pages_applied, + pages_applied: applied_pages.pages_applied, segments_promoted, - segments_tombstoned: 0, + segments_tombstoned, }) } } diff --git a/tests/snapshot_basic.rs b/tests/snapshot_basic.rs index 397d5fd..cde8ea4 100644 --- a/tests/snapshot_basic.rs +++ b/tests/snapshot_basic.rs @@ -1,17 +1,20 @@ //! Integration tests for snapshot_to / restore_from / promote_to_follower / //! apply_incremental / snapshot_incremental_to. -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use pagedb::options::RetainPolicy; use pagedb::snapshot::export::{ - SnapshotManifest, decode_manifest, derive_snapshot_hk_key, encode_manifest, + SnapshotManifest, decode_manifest, derive_snapshot_hk_key, encode_manifest, open_manifest, }; use pagedb::vfs::tokio_backend::{TokioFile, TokioLockHandle, TokioVfs}; use pagedb::vfs::{OpenMode, Vfs}; use pagedb::{ - ApplyStats, Db, DbMode, OpenOptions, PagedbError, RealmId, SegmentKind, SegmentPageKind, - SnapshotStats, + ApplyStats, CommitId, Db, DbMode, OpenOptions, PagedbError, RealmId, SegmentKind, + SegmentPageKind, SnapshotStats, }; const PAGE: usize = 4096; @@ -19,14 +22,11 @@ const KEK: [u8; 32] = [7u8; 32]; const REALM: RealmId = RealmId::new([1u8; 16]); fn tempdir() -> std::path::PathBuf { - let mut p = std::env::temp_dir(); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - p.push(format!("pagedb-snap-{}-{}", std::process::id(), nanos)); - std::fs::create_dir_all(&p).unwrap(); - p + tempfile::Builder::new() + .prefix("pagedb-snap-") + .tempdir() + .unwrap() + .keep() } async fn make_db(root: &std::path::Path) -> Db { @@ -101,6 +101,109 @@ impl Vfs for RenameFaultVfs { } } +async fn make_db_with_options(root: &std::path::Path, options: OpenOptions) -> Db { + let vfs = TokioVfs::new(root); + Db::open(vfs, KEK, PAGE, REALM, options).await.unwrap() +} + +fn hex_lower(bytes: &[u8; 16]) -> String { + use std::fmt::Write as _; + + let mut out = String::with_capacity(32); + for byte in bytes { + write!(&mut out, "{byte:02x}").unwrap(); + } + out +} + +fn create_stale_snapshot_sidecar(snapshot_dir: &std::path::Path) { + let stale_seg_dir = snapshot_dir.join("seg"); + std::fs::create_dir_all(&stale_seg_dir).unwrap(); + std::fs::write( + stale_seg_dir.join("00000000000000000000000000000001"), + b"stale", + ) + .unwrap(); +} + +#[derive(Clone)] +struct FailStagingSyncTokioVfs { + inner: TokioVfs, + fail_staging_sync: Arc, +} + +impl FailStagingSyncTokioVfs { + fn new(root: impl Into) -> Self { + Self { + inner: TokioVfs::new(root), + fail_staging_sync: Arc::new(AtomicBool::new(false)), + } + } + + fn fail_next_staging_sync(&self) { + self.fail_staging_sync.store(true, Ordering::SeqCst); + } +} + +impl Vfs for FailStagingSyncTokioVfs { + type File = TokioFile; + type LockHandle = TokioLockHandle; + + async fn open(&self, path: &str, mode: OpenMode) -> pagedb::Result { + self.inner.open(path, mode).await + } + + async fn remove(&self, path: &str) -> pagedb::Result<()> { + self.inner.remove(path).await + } + + async fn rename(&self, from: &str, to: &str) -> pagedb::Result<()> { + self.inner.rename(from, to).await + } + + async fn list_dir(&self, path: &str) -> pagedb::Result> { + self.inner.list_dir(path).await + } + + async fn mkdir_all(&self, path: &str) -> pagedb::Result<()> { + self.inner.mkdir_all(path).await + } + + async fn sync_dir(&self, path: &str) -> pagedb::Result<()> { + if path == "seg/.staging" && self.fail_staging_sync.swap(false, Ordering::SeqCst) { + return Err(PagedbError::Io(std::io::Error::other( + "injected staging sync fault", + ))); + } + self.inner.sync_dir(path).await + } + + async fn lock_exclusive(&self, path: &str) -> pagedb::Result { + self.inner.lock_exclusive(path).await + } + + async fn lock_shared(&self, path: &str) -> pagedb::Result { + self.inner.lock_shared(path).await + } + + fn root_path(&self) -> Option<&std::path::Path> { + Some(self.inner.root_path()) + } +} + +#[test] +fn tempdir_helper_allocates_unique_roots() { + let dirs: Vec<_> = (0..128).map(|_| tempdir()).collect(); + let mut paths = dirs.clone(); + paths.sort(); + paths.dedup(); + assert_eq!(paths.len(), dirs.len()); + + for dir in dirs { + std::fs::remove_dir_all(dir).ok(); + } +} + // --------------------------------------------------------------------------- // Test 1: full snapshot then restore reads data back. // --------------------------------------------------------------------------- @@ -148,20 +251,2117 @@ async fn restore_yields_readonly_db() { let snap_dir = tempdir(); let dst_dir = tempdir(); - let db = make_db(&src_dir).await; - db.snapshot_to(&snap_dir).await.unwrap(); + let db = make_db_with_options( + &src_dir, + OpenOptions::default().with_commit_history_retain(RetainPolicy::Unbounded), + ) + .await; + db.snapshot_to(&snap_dir).await.unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + assert_eq!(restored.mode(), DbMode::ReadOnly); + + // begin_write must fail with ReadOnly. + let err = restored.begin_write().await.err().unwrap(); + assert!( + matches!(err, PagedbError::ReadOnly), + "expected ReadOnly, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 3: restore_from rejects corrupt active-root main.db pages. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn restore_rejects_corrupt_active_root_page() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_to(&snap_dir).await.unwrap(); + + let manifest = open_manifest(&snap_dir.join("manifest"), &KEK) + .await + .unwrap(); + assert_ne!( + manifest.target_active_root_page_id, 0, + "test setup must produce a non-empty active tree" + ); + let main_path = snap_dir.join("main.db"); + let mut bytes = std::fs::read(&main_path).unwrap(); + let corrupt_at = manifest.target_active_root_page_id as usize * PAGE + 128; + assert!( + bytes.len() > corrupt_at, + "test setup must include the active root page in full snapshot main.db" + ); + bytes[corrupt_at] ^= 0xFF; + std::fs::write(&main_path, bytes).unwrap(); + drop(db); + + let err = match Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + { + Ok(_) => panic!("restore_from must reject corrupt active-root pages"), + Err(err) => err, + }; + assert!( + matches!( + err, + PagedbError::ChecksumFailure | PagedbError::Corruption(_) + ), + "expected page authentication failure, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 4: failed restore leaves the destination reusable. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn restore_failure_leaves_destination_reusable() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_to(&snap_dir).await.unwrap(); + + let manifest = open_manifest(&snap_dir.join("manifest"), &KEK) + .await + .unwrap(); + let main_path = snap_dir.join("main.db"); + let original_main = std::fs::read(&main_path).unwrap(); + let mut corrupt_main = original_main.clone(); + let corrupt_at = manifest.target_active_root_page_id as usize * PAGE + 128; + assert!( + corrupt_main.len() > corrupt_at, + "test setup must include the active root page in full snapshot main.db" + ); + corrupt_main[corrupt_at] ^= 0xFF; + std::fs::write(&main_path, corrupt_main).unwrap(); + + let err = match Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + { + Ok(_) => panic!("corrupt snapshot must fail restore"), + Err(err) => err, + }; + assert!( + matches!( + err, + PagedbError::ChecksumFailure | PagedbError::Corruption(_) + ), + "expected page authentication failure, got {err:?}" + ); + + std::fs::write(&main_path, original_main).unwrap(); + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .expect("failed restore must leave the destination reusable"); + let rtxn = restored.begin_read().await.unwrap(); + assert_eq!( + rtxn.get(b"base").await.unwrap().as_deref(), + Some(b"data".as_slice()) + ); + drop(rtxn); + drop(restored); + drop(db); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 5: restore_from rejects non-empty destination directories. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn restore_rejects_non_empty_destination() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_to(&snap_dir).await.unwrap(); + drop(db); + + let stale_seg = dst_dir.join("seg"); + std::fs::create_dir_all(&stale_seg).unwrap(); + std::fs::write(stale_seg.join("00000000000000000000000000000000"), b"stale").unwrap(); + + let err = match Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + { + Ok(_) => panic!("restore_from must reject a non-empty destination"), + Err(err) => err, + }; + assert!( + matches!(err, PagedbError::Io(_)), + "expected Io for non-empty destination, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 5: restore_from rejects a manifest whose root fields do not match main.db. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn restore_rejects_manifest_active_root_mismatch() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_to(&snap_dir).await.unwrap(); + + let manifest_path = snap_dir.join("manifest"); + let mut manifest = open_manifest(&manifest_path, &KEK).await.unwrap(); + assert_ne!( + manifest.target_active_root_page_id, 0, + "test setup must produce a non-empty active tree" + ); + let hk_key = derive_snapshot_hk_key(&KEK, &manifest.kek_salt, manifest.mk_epoch).unwrap(); + manifest.target_active_root_page_id = 0; + std::fs::write(manifest_path, encode_manifest(&manifest, &hk_key)).unwrap(); + drop(db); + + let err = match Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + { + Ok(_) => panic!("restore_from must reject manifest/header root mismatch"), + Err(err) => err, + }; + assert!( + matches!( + err, + PagedbError::IdentityForked + | PagedbError::Corruption(_) + | PagedbError::SnapshotIncompatible { + field: "target_active_root_page_id" + } + ), + "expected identity/corruption failure for manifest/header mismatch, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 6: restore_from rejects an incremental snapshot manifest. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn restore_rejects_incremental_snapshot_manifest() { + let src_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"later", b"value").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + drop(db); + + let err = match Db::::restore_from(&delta_dir, &dst_dir, OpenOptions::default(), KEK) + .await + { + Ok(_) => panic!("restore_from must reject an incremental snapshot manifest"), + Err(err) => err, + }; + assert!( + matches!( + err, + PagedbError::Corruption(_) | PagedbError::SnapshotIncompatible { field: "kind" } + ), + "expected Corruption for incremental snapshot manifest, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_rejects_manifest_with_trailing_bytes() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_to(&snap_dir).await.unwrap(); + + let manifest_path = snap_dir.join("manifest"); + let mut bytes = std::fs::read(&manifest_path).unwrap(); + bytes.push(0xAA); + std::fs::write(&manifest_path, bytes).unwrap(); + drop(db); + + let err = match Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + { + Ok(_) => panic!("restore_from must reject non-canonical manifest length"), + Err(err) => err, + }; + assert!( + matches!(err, PagedbError::Corruption(_)), + "expected Corruption for manifest trailing bytes, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 4: promote_to_follower allows applying a real incremental. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn promote_to_follower_allows_apply() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let dst_dir = tempdir(); + let delta_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut txn = db.begin_write().await.unwrap(); + txn.put(b"base", b"before-snapshot").await.unwrap(); + txn.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + // Advance the source after the full snapshot and export c1 -> c2. + { + let mut txn = db.begin_write().await.unwrap(); + txn.put(b"changed", b"after-snapshot").await.unwrap(); + txn.commit().await.unwrap(); + } + let c2 = db.latest_commit(); + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + + let follower = restored.promote_to_follower().await.unwrap(); + assert_eq!(follower.mode(), DbMode::Follower); + assert!(follower.can_apply_incremental()); + + let stats = follower.apply_incremental(&delta_dir).await.unwrap(); + assert!(stats.pages_applied > 0); + assert_eq!(follower.latest_commit(), c2); + + let rtxn = follower.begin_read().await.unwrap(); + assert_eq!( + rtxn.get(b"changed").await.unwrap().as_deref(), + Some(b"after-snapshot".as_slice()) + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); +} + +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_surfaces_staging_dir_sync_failure_then_retry_succeeds() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let dst_dir = tempdir(); + let delta_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"stable").await.unwrap(); + t.commit().await.unwrap(); + } + let base = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + let meta = { + let mut s = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + s.append_page(SegmentPageKind::Data, b"post-base segment") + .await + .unwrap(); + s.seal().await.unwrap() + }; + { + let mut t = db.begin_write().await.unwrap(); + t.link_segment("post-base.seg", &meta).await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_incremental_to(base, &delta_dir).await.unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + drop(restored); + + let vfs = FailStagingSyncTokioVfs::new(&dst_dir); + let restored = Db::open_read_only(vfs.clone(), KEK, PAGE, REALM, OpenOptions::default()) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + vfs.fail_next_staging_sync(); + + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must surface failed staging directory syncs"); + assert!( + matches!(err, PagedbError::Io(_)), + "expected staging sync I/O error, got {err:?}" + ); + assert_eq!( + follower.latest_commit(), + base, + "failed staging sync must leave the follower on the base commit" + ); + { + let rtxn = follower.begin_read().await.unwrap(); + assert_eq!( + rtxn.get(b"base").await.unwrap().as_deref(), + Some(b"stable" as &[u8]) + ); + assert!( + rtxn.open_segment("post-base.seg").await.is_err(), + "failed apply must not expose the target segment" + ); + } + + let stats = follower + .apply_incremental(&delta_dir) + .await + .expect("retry after transient staging sync fault must succeed"); + assert_eq!(stats.segments_promoted, 1); + assert!( + follower.latest_commit() > base, + "successful retry must advance the follower commit" + ); + let rtxn = follower.begin_read().await.unwrap(); + let reader = rtxn.open_segment("post-base.seg").await.unwrap(); + let page = reader.read_page(1).await.unwrap(); + assert!(page.starts_with(b"post-base segment")); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 5: incremental carries only changed pages. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn incremental_carries_only_changed_pages() { + let src_dir = tempdir(); + let snap1_dir = tempdir(); + let snap2_dir = tempdir(); + + let db = make_db_with_options( + &src_dir, + OpenOptions::default().with_commit_history_retain(RetainPolicy::Unbounded), + ) + .await; + // Write a small base that does not create free pages before the base + // cursor; reused below-base pages are covered by the dedicated rejection + // regression below. + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"key000", b"init").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + let full_stats: SnapshotStats = db.snapshot_to(&snap1_dir).await.unwrap(); + + // Write more data to advance the commit. + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"new000", b"added").await.unwrap(); + t.commit().await.unwrap(); + } + + let inc_stats: SnapshotStats = db.snapshot_incremental_to(c1, &snap2_dir).await.unwrap(); + + // Incremental should have fewer pages than the full snapshot. + assert!( + inc_stats.pages_written < full_stats.pages_written, + "incremental pages {} should be < full pages {}", + inc_stats.pages_written, + full_stats.pages_written + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap1_dir).ok(); + std::fs::remove_dir_all(&snap2_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 6: incremental snapshots require a readable base commit. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn incremental_snapshot_rejects_missing_base_commit() { + let src_dir = tempdir(); + let delta_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + + let missing_base = CommitId::new(99); + let err = db + .snapshot_incremental_to(missing_base, &delta_dir) + .await + .expect_err("incremental snapshots must reject an unreadable base commit"); + assert!( + matches!(err, PagedbError::CommitGone { .. }), + "expected CommitGone for missing base commit, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 7: apply_incremental advances commit and data matches. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_advances_commit() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + // Write initial data. + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + // Write more data after c1. + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"new_key", b"new_val").await.unwrap(); + t.commit().await.unwrap(); + } + let c2 = db.latest_commit(); + + // Incremental from c1 to c2. + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + drop(db); + + // Restore and promote. + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + // Apply incremental. + let _stats: ApplyStats = follower.apply_incremental(&delta_dir).await.unwrap(); + + // The follower's latest_commit should equal c2 after applying. + let follower_commit = follower.latest_commit(); + assert_eq!(follower_commit, c2, "follower commit should match c2"); + + // The applied delta must advance the data tree: the key written after the + // base snapshot is now readable, and the base key still resolves. + let rtxn = follower.begin_read().await.unwrap(); + assert_eq!( + rtxn.get(b"new_key").await.unwrap().as_deref(), + Some(b"new_val".as_slice()), + "incrementally-applied key must be readable on the follower" + ); + assert_eq!( + rtxn.get(b"base").await.unwrap().as_deref(), + Some(b"data".as_slice()), + "base key must survive the incremental apply" + ); + drop(rtxn); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 8: apply_incremental rejects a delta when the follower is past its base. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_rejects_delta_when_follower_not_at_base_commit() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"new_key", b"new_val").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + follower.apply_incremental(&delta_dir).await.unwrap(); + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must reject a delta whose base is not the follower commit"); + assert!( + matches!( + err, + PagedbError::IdentityForked + | PagedbError::SnapshotIncompatible { + field: "base_commit" + } + ), + "expected IdentityForked for base-commit mismatch, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +#[tokio::test(flavor = "current_thread")] +async fn incremental_snapshot_rejects_missing_changed_main_page() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"new_key", b"new_val").await.unwrap(); + t.commit().await.unwrap(); + } + + std::fs::OpenOptions::new() + .write(true) + .open(src_dir.join("main.db")) + .unwrap() + .set_len((PAGE * 2) as u64) + .unwrap(); + + let err = db + .snapshot_incremental_to(c1, &delta_dir) + .await + .expect_err("incremental snapshot must reject missing changed main.db pages"); + assert!( + matches!(err, PagedbError::Io(ref io) if io.kind() == std::io::ErrorKind::UnexpectedEof), + "expected UnexpectedEof for missing changed main page, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); +} + +#[tokio::test(flavor = "current_thread")] +async fn incremental_snapshot_rejects_reused_pages_below_base_cursor() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + + let options = OpenOptions::default().with_commit_history_retain(RetainPolicy::Count(2)); + let db = make_db_with_options(&src_dir, options.clone()).await; + { + let mut t = db.begin_write().await.unwrap(); + for i in 0u32..48 { + t.put(format!("old-{i:03}").as_bytes(), &vec![i as u8; PAGE * 2]) + .await + .unwrap(); + } + t.commit().await.unwrap(); + } + { + let mut t = db.begin_write().await.unwrap(); + for i in 0u32..48 { + t.delete(format!("old-{i:03}").as_bytes()).await.unwrap(); + } + t.commit().await.unwrap(); + } + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base-marker", b"retained").await.unwrap(); + t.commit().await.unwrap(); + } + let base = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + let new_value = vec![0xC7; PAGE * 2]; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"reused-after-base", &new_value).await.unwrap(); + t.commit().await.unwrap(); + } + let err = db + .snapshot_incremental_to(base, &delta_dir) + .await + .expect_err("incremental snapshot must reject reused pages below the base cursor"); + assert!( + matches!(err, PagedbError::Unsupported), + "expected Unsupported for reused pages below base cursor, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 9: apply_incremental rejects a truncated delta stream. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_rejects_truncated_delta_stream() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"new_key", b"new_val").await.unwrap(); + t.commit().await.unwrap(); + } + + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + let delta_path = delta_dir.join("pages.delta"); + assert!( + std::fs::metadata(&delta_path).unwrap().len() > 8, + "test setup must produce a non-empty delta stream" + ); + std::fs::write(&delta_path, [0xAA]).unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must reject a truncated delta stream"); + assert!( + matches!(err, PagedbError::Corruption(_)), + "expected Corruption for truncated delta stream, got {err:?}" + ); + + let rtxn = follower.begin_read().await.unwrap(); + assert_eq!( + rtxn.get(b"base").await.unwrap().as_deref(), + Some(b"data".as_slice()) + ); + assert_eq!(rtxn.get(b"new_key").await.unwrap().as_deref(), None); + drop(rtxn); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 9: apply_incremental rejects delta records for header pages. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_rejects_header_page_delta_record() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"new_key", b"new_val").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + + let mut delta = Vec::with_capacity(8 + PAGE); + delta.extend_from_slice(&0u64.to_be_bytes()); + delta.extend_from_slice(&vec![0xAA; PAGE]); + std::fs::write(delta_dir.join("pages.delta"), delta).unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must reject header-page delta records"); + assert!( + matches!(err, PagedbError::Corruption(_)), + "expected Corruption for header-page delta record, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 10: apply_incremental rejects delta records beyond the target page range. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_rejects_delta_record_at_target_next_page_id() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"new_key", b"new_val").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + + let manifest = std::fs::read(delta_dir.join("manifest")).unwrap(); + let target_next_page_id = u64::from_le_bytes(manifest[74..82].try_into().unwrap()); + let mut delta = Vec::with_capacity(8 + PAGE); + delta.extend_from_slice(&target_next_page_id.to_be_bytes()); + delta.extend_from_slice(&vec![0xAA; PAGE]); + std::fs::write(delta_dir.join("pages.delta"), delta).unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must reject out-of-range delta records"); + assert!( + matches!(err, PagedbError::Corruption(_)), + "expected Corruption for out-of-range delta record, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 11: apply_incremental rejects delta records below the base next-page id. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_rejects_delta_record_below_base_next_page_id_without_mutating_base() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + let base_manifest = open_manifest(&snap_dir.join("manifest"), &KEK) + .await + .unwrap(); + + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"new_key", b"new_val").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + + let stale_page_id = base_manifest.target_active_root_page_id; + assert!( + stale_page_id >= 2 && stale_page_id < base_manifest.next_page_id_at_target, + "test setup needs an existing non-header base page" + ); + let delta_path = delta_dir.join("pages.delta"); + let original_delta = std::fs::read(&delta_path).unwrap(); + let mut malicious_delta = Vec::with_capacity(original_delta.len() + 8 + PAGE); + malicious_delta.extend_from_slice(&stale_page_id.to_be_bytes()); + malicious_delta.extend_from_slice(&vec![0xAA; PAGE]); + malicious_delta.extend_from_slice(&original_delta); + std::fs::write(&delta_path, malicious_delta).unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must reject records below the base next-page id"); + assert!( + matches!(err, PagedbError::Corruption(_)), + "expected Corruption for below-base delta record, got {err:?}" + ); + + let rtxn = follower.begin_read().await.unwrap(); + let base = rtxn + .get(b"base") + .await + .expect("failed apply must not corrupt existing base pages"); + assert_eq!(base.as_deref(), Some(b"data".as_slice())); + drop(rtxn); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 12: apply_incremental rejects duplicate delta records. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_rejects_duplicate_delta_page_records() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"new_key", b"new_val").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + + let delta_path = delta_dir.join("pages.delta"); + let original_delta = std::fs::read(&delta_path).unwrap(); + assert!( + original_delta.len() >= 8 + PAGE, + "test setup must produce at least one delta page" + ); + let duplicate_page_id = u64::from_be_bytes(original_delta[..8].try_into().unwrap()); + let mut duplicated_delta = Vec::with_capacity(original_delta.len() + 8 + PAGE); + duplicated_delta.extend_from_slice(&original_delta); + duplicated_delta.extend_from_slice(&duplicate_page_id.to_be_bytes()); + duplicated_delta.extend_from_slice(&vec![0xAA; PAGE]); + std::fs::write(delta_path, duplicated_delta).unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must reject duplicate delta records"); + assert!( + matches!(err, PagedbError::Corruption(_)), + "expected Corruption for duplicate delta record, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 12: apply_incremental rejects corrupt target active-root delta pages. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_rejects_corrupt_target_active_root_delta_page() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"new_key", b"new_val").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + + let manifest = std::fs::read(delta_dir.join("manifest")).unwrap(); + let target_active_root_page_id = u64::from_le_bytes(manifest[102..110].try_into().unwrap()); + let delta_path = delta_dir.join("pages.delta"); + let mut delta = std::fs::read(&delta_path).unwrap(); + let record_len = 8 + PAGE; + let mut corrupted = false; + for record in delta.chunks_exact_mut(record_len) { + let page_id = u64::from_be_bytes(record[..8].try_into().unwrap()); + if page_id == target_active_root_page_id { + record[8 + 128] ^= 0xFF; + corrupted = true; + break; + } + } + assert!( + corrupted, + "test setup must include target active root page {target_active_root_page_id} in pages.delta" + ); + std::fs::write(&delta_path, delta).unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must reject corrupt target active-root delta pages"); + assert!( + matches!( + err, + PagedbError::ChecksumFailure | PagedbError::Corruption(_) + ), + "expected page authentication failure, got {err:?}" + ); + + let rtxn = follower.begin_read().await.unwrap(); + assert_eq!( + rtxn.get(b"base").await.unwrap().as_deref(), + Some(b"data".as_slice()) + ); + assert_eq!( + rtxn.get(b"new_key").await.unwrap().as_deref(), + None, + "failed incremental apply must not advance the active root" + ); + drop(rtxn); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 12: apply_incremental rejects a full snapshot manifest. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_rejects_full_snapshot_manifest() { + let src_dir = tempdir(); + let base_snap_dir = tempdir(); + let full_snap_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_to(&base_snap_dir).await.unwrap(); + + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"later", b"value").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_to(&full_snap_dir).await.unwrap(); + drop(db); + + let restored = + Db::::restore_from(&base_snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + let err = follower + .apply_incremental(&full_snap_dir) + .await + .expect_err("apply_incremental must reject a full snapshot manifest"); + assert!( + matches!( + err, + PagedbError::Corruption(_) | PagedbError::SnapshotIncompatible { field: "kind" } + ), + "expected Corruption for full snapshot manifest, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&base_snap_dir).ok(); + std::fs::remove_dir_all(&full_snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_rejects_manifest_with_trailing_bytes() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"later", b"value").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + + let manifest_path = delta_dir.join("manifest"); + let mut bytes = std::fs::read(&manifest_path).unwrap(); + bytes.push(0xAA); + std::fs::write(&manifest_path, bytes).unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must reject non-canonical manifest length"); + assert!( + matches!(err, PagedbError::Corruption(_)), + "expected Corruption for manifest trailing bytes, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 13: apply_incremental rejects a correctly MACed wrong-realm manifest. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_rejects_wrong_realm_manifest() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"new_key", b"new_val").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + + let manifest_path = delta_dir.join("manifest"); + let mut manifest = open_manifest(&manifest_path, &KEK).await.unwrap(); + let hk_key = derive_snapshot_hk_key(&KEK, &manifest.kek_salt, manifest.mk_epoch).unwrap(); + manifest.realm_id = [2u8; 16]; + std::fs::write(manifest_path, encode_manifest(&manifest, &hk_key)).unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must reject a wrong-realm incremental manifest"); + assert!( + matches!( + err, + PagedbError::IdentityForked + | PagedbError::Corruption(_) + | PagedbError::SnapshotIncompatible { field: "realm_id" } + ), + "expected identity failure for wrong-realm manifest, got {err:?}" + ); + + let rtxn = follower.begin_read().await.unwrap(); + assert_eq!( + rtxn.get(b"base").await.unwrap().as_deref(), + Some(b"data".as_slice()) + ); + assert_eq!( + rtxn.get(b"new_key").await.unwrap().as_deref(), + None, + "failed incremental apply must not advance the active root" + ); + drop(rtxn); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 13: apply_incremental rejects target commits that do not advance. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_rejects_non_advancing_target_commit() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"new_key", b"new_val").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + + let manifest_path = delta_dir.join("manifest"); + let mut manifest = open_manifest(&manifest_path, &KEK).await.unwrap(); + let hk_key = derive_snapshot_hk_key(&KEK, &manifest.kek_salt, manifest.mk_epoch).unwrap(); + manifest.target_commit = manifest.base_commit; + std::fs::write(manifest_path, encode_manifest(&manifest, &hk_key)).unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must reject non-advancing target commits"); + assert!( + matches!( + err, + PagedbError::IdentityForked + | PagedbError::Corruption(_) + | PagedbError::SnapshotIncompatible { + field: "target_commit" + } + ), + "expected identity/corruption failure for non-advancing target commit, got {err:?}" + ); + + let rtxn = follower.begin_read().await.unwrap(); + assert_eq!( + rtxn.get(b"base").await.unwrap().as_deref(), + Some(b"data".as_slice()) + ); + assert_eq!( + rtxn.get(b"new_key").await.unwrap().as_deref(), + None, + "failed incremental apply must not install new content under the base commit id" + ); + drop(rtxn); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 14: standalone db calling apply_incremental returns IdentityForked. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_rejects_on_standalone() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + + let db = make_db(&src_dir).await; + db.snapshot_to(&snap_dir).await.unwrap(); + + let err = db.apply_incremental(&snap_dir).await.err().unwrap(); + assert!( + matches!(err, PagedbError::IdentityForked), + "expected IdentityForked, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 14: snapshot includes segments; restored db can read segment. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn snapshot_includes_segments() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut w = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + w.append_page(SegmentPageKind::Data, b"seg-content") + .await + .unwrap(); + w.set_manifest(b"mf").unwrap(); + let meta = w.seal().await.unwrap(); + let mut t = db.begin_write().await.unwrap(); + t.link_segment("my.seg", &meta).await.unwrap(); + t.commit().await.unwrap(); + } + + let stats = db.snapshot_to(&snap_dir).await.unwrap(); + assert_eq!(stats.segments_written, 1); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let rtxn = restored.begin_read().await.unwrap(); + let reader = rtxn.open_segment("my.seg").await.unwrap(); + let page = reader.read_page(1).await.unwrap(); + assert!(page.starts_with(b"seg-content")); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 15: snapshot_to rejects a catalog segment whose file is missing. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn snapshot_to_rejects_missing_catalog_segment_file() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + + let db = make_db(&src_dir).await; + let meta = { + let mut w = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + w.append_page(SegmentPageKind::Data, b"seg-content") + .await + .unwrap(); + w.set_manifest(b"mf").unwrap(); + w.seal().await.unwrap() + }; + let segment_path = src_dir.join("seg").join(hex_lower(&meta.segment_id)); + { + let mut t = db.begin_write().await.unwrap(); + t.link_segment("missing-source.seg", &meta).await.unwrap(); + t.commit().await.unwrap(); + } + assert!( + segment_path.is_file(), + "test setup must create the linked live segment file" + ); + std::fs::remove_file(&segment_path).unwrap(); + + let err = match db.snapshot_to(&snap_dir).await { + Ok(_) => panic!("snapshot_to must reject a catalog segment whose file is missing"), + Err(err) => err, + }; + assert!( + matches!(err, PagedbError::Io(ref io) if io.kind() == std::io::ErrorKind::NotFound), + "expected NotFound for missing catalog segment file, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 16: snapshot_to rejects non-empty output directories. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn snapshot_to_rejects_non_empty_destination() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + create_stale_snapshot_sidecar(&snap_dir); + + let err = match db.snapshot_to(&snap_dir).await { + Ok(_) => panic!("snapshot_to must reject a non-empty destination"), + Err(err) => err, + }; + assert!( + matches!(err, PagedbError::Io(ref io) if io.kind() == std::io::ErrorKind::AlreadyExists), + "expected AlreadyExists for non-empty snapshot destination, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 17: failed snapshot_to leaves the destination reusable. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn snapshot_to_failure_leaves_destination_reusable() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + + let db = make_db(&src_dir).await; + let meta = { + let mut w = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + w.append_page(SegmentPageKind::Data, b"seg-content") + .await + .unwrap(); + w.set_manifest(b"mf").unwrap(); + w.seal().await.unwrap() + }; + let segment_path = src_dir.join("seg").join(hex_lower(&meta.segment_id)); + let backup_path = segment_path.with_extension("bak"); + { + let mut t = db.begin_write().await.unwrap(); + t.link_segment("retry-full.seg", &meta).await.unwrap(); + t.commit().await.unwrap(); + } + std::fs::rename(&segment_path, &backup_path).unwrap(); + + let err = match db.snapshot_to(&snap_dir).await { + Ok(_) => panic!("snapshot_to must reject a catalog segment whose file is missing"), + Err(err) => err, + }; + assert!( + matches!(err, PagedbError::Io(ref io) if io.kind() == std::io::ErrorKind::NotFound), + "expected NotFound for missing catalog segment file, got {err:?}" + ); + + std::fs::rename(&backup_path, &segment_path).unwrap(); + let stats = db + .snapshot_to(&snap_dir) + .await + .expect("failed snapshot_to must leave the destination reusable"); + assert_eq!(stats.segments_written, 1); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); +} + +#[tokio::test(flavor = "current_thread")] +async fn snapshot_to_rejects_missing_main_page() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"full-missing-page", b"value").await.unwrap(); + t.commit().await.unwrap(); + } + + std::fs::OpenOptions::new() + .write(true) + .open(src_dir.join("main.db")) + .unwrap() + .set_len((PAGE * 2) as u64) + .unwrap(); + + let err = db + .snapshot_to(&snap_dir) + .await + .expect_err("snapshot_to must reject missing main.db pages"); + assert!( + matches!(err, PagedbError::Io(ref io) if io.kind() == std::io::ErrorKind::UnexpectedEof), + "expected UnexpectedEof for missing main.db page, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); +} + +#[tokio::test(flavor = "current_thread")] +async fn snapshot_to_rejects_missing_header_referenced_main_page() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + + let db = make_db(&src_dir).await; + let second_value = vec![0xB2; PAGE * 3]; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"overflow-a", &vec![0xA1; PAGE * 3]).await.unwrap(); + t.put(b"overflow-b", &second_value).await.unwrap(); + t.commit().await.unwrap(); + } + let rtxn = db.begin_read().await.unwrap(); + assert_eq!( + rtxn.get(b"overflow-b").await.unwrap().as_deref(), + Some(second_value.as_slice()), + "test setup must make the committed payload readable before truncation" + ); + drop(rtxn); + + db.snapshot_to(&snap_dir).await.unwrap(); + let manifest = open_manifest(&snap_dir.join("manifest"), &KEK) + .await + .unwrap(); + std::fs::remove_dir_all(&snap_dir).unwrap(); + + let highest_root_page = manifest + .target_active_root_page_id + .max(manifest.target_catalog_root_page_id); + let truncated_len = (highest_root_page + 1) * PAGE as u64; + let main_path = src_dir.join("main.db"); + let original_len = std::fs::metadata(&main_path).unwrap().len(); + assert!( + original_len > truncated_len, + "test setup must allocate header-referenced pages beyond the root/catalog watermark" + ); + std::fs::OpenOptions::new() + .write(true) + .open(&main_path) + .unwrap() + .set_len(truncated_len) + .unwrap(); + + let err = db + .snapshot_to(&snap_dir) + .await + .expect_err("snapshot_to must reject missing header-referenced pages"); + assert!( + matches!(err, PagedbError::Io(ref io) if io.kind() == std::io::ErrorKind::UnexpectedEof), + "expected UnexpectedEof for missing header-referenced page, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 18: snapshot_incremental_to rejects a new segment whose file is missing. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn snapshot_incremental_to_rejects_missing_new_segment_file() { + let src_dir = tempdir(); + let delta_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + + let meta = { + let mut w = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + w.append_page(SegmentPageKind::Data, b"seg-content") + .await + .unwrap(); + w.set_manifest(b"mf").unwrap(); + w.seal().await.unwrap() + }; + let segment_path = src_dir.join("seg").join(hex_lower(&meta.segment_id)); + { + let mut t = db.begin_write().await.unwrap(); + t.link_segment("missing-incremental.seg", &meta) + .await + .unwrap(); + t.commit().await.unwrap(); + } + assert!( + segment_path.is_file(), + "test setup must create the linked live segment file" + ); + std::fs::remove_file(&segment_path).unwrap(); + + let err = match db.snapshot_incremental_to(c1, &delta_dir).await { + Ok(_) => panic!("snapshot_incremental_to must reject a new segment whose file is missing"), + Err(err) => err, + }; + assert!( + matches!(err, PagedbError::Io(ref io) if io.kind() == std::io::ErrorKind::NotFound), + "expected NotFound for missing new segment file, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 19: snapshot_incremental_to rejects non-empty output directories. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn snapshot_incremental_to_rejects_non_empty_destination() { + let src_dir = tempdir(); + let delta_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"new_key", b"new_val").await.unwrap(); + t.commit().await.unwrap(); + } + create_stale_snapshot_sidecar(&delta_dir); + + let err = match db.snapshot_incremental_to(c1, &delta_dir).await { + Ok(_) => panic!("snapshot_incremental_to must reject a non-empty destination"), + Err(err) => err, + }; + assert!( + matches!(err, PagedbError::Io(ref io) if io.kind() == std::io::ErrorKind::AlreadyExists), + "expected AlreadyExists for non-empty incremental destination, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 20: failed snapshot_incremental_to leaves the destination reusable. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn snapshot_incremental_to_failure_leaves_destination_reusable() { + let src_dir = tempdir(); + let delta_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + let meta = { + let mut w = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + w.append_page(SegmentPageKind::Data, b"seg-content") + .await + .unwrap(); + w.set_manifest(b"mf").unwrap(); + w.seal().await.unwrap() + }; + let segment_path = src_dir.join("seg").join(hex_lower(&meta.segment_id)); + let backup_path = segment_path.with_extension("bak"); + { + let mut t = db.begin_write().await.unwrap(); + t.link_segment("retry-incremental.seg", &meta) + .await + .unwrap(); + t.commit().await.unwrap(); + } + std::fs::rename(&segment_path, &backup_path).unwrap(); + + let err = match db.snapshot_incremental_to(c1, &delta_dir).await { + Ok(_) => panic!("snapshot_incremental_to must reject a new segment whose file is missing"), + Err(err) => err, + }; + assert!( + matches!(err, PagedbError::Io(ref io) if io.kind() == std::io::ErrorKind::NotFound), + "expected NotFound for missing new segment file, got {err:?}" + ); + + std::fs::rename(&backup_path, &segment_path).unwrap(); + let stats = db + .snapshot_incremental_to(c1, &delta_dir) + .await + .expect("failed snapshot_incremental_to must leave the destination reusable"); + assert_eq!(stats.segments_written, 1); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 21: apply_incremental rejects renamed segment sidecars. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn apply_incremental_rejects_renamed_manifest_declared_segment_file() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + { + let meta = { + let mut s = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + s.append_page(SegmentPageKind::Data, b"segment-after-base") + .await + .unwrap(); + s.set_manifest(b"mf").unwrap(); + s.seal().await.unwrap() + }; + let mut t = db.begin_write().await.unwrap(); + t.link_segment("renamed.seg", &meta).await.unwrap(); + t.commit().await.unwrap(); + } + + let stats = db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + assert_eq!(stats.segments_written, 1); + let seg_files: Vec<_> = std::fs::read_dir(delta_dir.join("seg")) + .unwrap() + .filter_map(std::result::Result::ok) + .filter(|entry| entry.path().is_file()) + .map(|entry| entry.path()) + .collect(); + assert_eq!( + seg_files.len(), + 1, + "test setup must produce exactly one incremental segment sidecar" + ); + let original_sidecar = &seg_files[0]; + let fake_sidecar = delta_dir + .join("seg") + .join("cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"); + std::fs::rename(original_sidecar, fake_sidecar).unwrap(); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must reject renamed manifest-declared segment files"); + assert!( + matches!( + err, + PagedbError::Corruption(_) + | PagedbError::SnapshotIncompatible { + field: "segments_count" + } + ), + "expected Corruption for renamed segment sidecar, got {err:?}" + ); + + let rtxn = follower.begin_read().await.unwrap(); + assert!( + rtxn.open_segment("renamed.seg").await.is_err(), + "failed incremental apply must not advance the catalog" + ); + drop(rtxn); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// Test 18: restore_from rejects missing manifest-declared segment files. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn restore_rejects_missing_manifest_declared_segment_file() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut w = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + w.append_page(SegmentPageKind::Data, b"seg-content") + .await + .unwrap(); + w.set_manifest(b"mf").unwrap(); + let meta = w.seal().await.unwrap(); + let mut t = db.begin_write().await.unwrap(); + t.link_segment("missing-full.seg", &meta).await.unwrap(); + t.commit().await.unwrap(); + } + + let stats = db.snapshot_to(&snap_dir).await.unwrap(); + assert_eq!(stats.segments_written, 1); + let seg_files: Vec<_> = std::fs::read_dir(snap_dir.join("seg")) + .unwrap() + .filter_map(std::result::Result::ok) + .filter(|entry| entry.path().is_file()) + .map(|entry| entry.path()) + .collect(); + assert_eq!( + seg_files.len(), + 1, + "test setup must produce exactly one full-snapshot segment sidecar" + ); + for file in seg_files { + std::fs::remove_file(file).unwrap(); + } + drop(db); + + let err = match Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + { + Ok(_) => panic!("restore_from must reject missing manifest-declared segment files"), + Err(err) => err, + }; + assert!( + matches!( + err, + PagedbError::Corruption(_) + | PagedbError::SnapshotIncompatible { + field: "segments_count" + } + ), + "expected Corruption for missing full-snapshot segment sidecar, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 19: restore_from rejects renamed manifest-declared segment files. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn restore_rejects_renamed_manifest_declared_segment_file() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut w = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + w.append_page(SegmentPageKind::Data, b"seg-content") + .await + .unwrap(); + w.set_manifest(b"mf").unwrap(); + let meta = w.seal().await.unwrap(); + let mut t = db.begin_write().await.unwrap(); + t.link_segment("renamed-full.seg", &meta).await.unwrap(); + t.commit().await.unwrap(); + } + + let stats = db.snapshot_to(&snap_dir).await.unwrap(); + assert_eq!(stats.segments_written, 1); + let seg_files: Vec<_> = std::fs::read_dir(snap_dir.join("seg")) + .unwrap() + .filter_map(std::result::Result::ok) + .filter(|entry| entry.path().is_file()) + .map(|entry| entry.path()) + .collect(); + assert_eq!( + seg_files.len(), + 1, + "test setup must produce exactly one full-snapshot segment sidecar" + ); + let fake_sidecar = snap_dir + .join("seg") + .join("efefefefefefefefefefefefefefefef"); + std::fs::rename(&seg_files[0], fake_sidecar).unwrap(); + drop(db); + + let err = match Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + { + Ok(_) => panic!("restore_from must reject renamed manifest-declared segment files"), + Err(err) => err, + }; + assert!( + matches!(err, PagedbError::Corruption(_)), + "expected Corruption for renamed full-snapshot segment sidecar, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 20: restore_from rejects corrupt segment data pages. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn restore_rejects_corrupt_segment_data_page() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut w = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + w.append_page(SegmentPageKind::Data, b"seg-content") + .await + .unwrap(); + w.set_manifest(b"mf").unwrap(); + let meta = w.seal().await.unwrap(); + let mut t = db.begin_write().await.unwrap(); + t.link_segment("corrupt.seg", &meta).await.unwrap(); + t.commit().await.unwrap(); + } + + let stats = db.snapshot_to(&snap_dir).await.unwrap(); + assert_eq!(stats.segments_written, 1); + let seg_files: Vec<_> = std::fs::read_dir(snap_dir.join("seg")) + .unwrap() + .filter_map(std::result::Result::ok) + .filter(|entry| entry.path().is_file()) + .map(|entry| entry.path()) + .collect(); + assert_eq!( + seg_files.len(), + 1, + "test setup must produce exactly one full-snapshot segment sidecar" + ); + let mut bytes = std::fs::read(&seg_files[0]).unwrap(); + assert!( + bytes.len() > PAGE + 128, + "test setup must include a data page to corrupt" + ); + bytes[PAGE + 128] ^= 0xFF; + std::fs::write(&seg_files[0], bytes).unwrap(); + drop(db); + + let err = match Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + { + Ok(_) => panic!("restore_from must reject corrupt segment data pages"), + Err(err) => err, + }; + assert!( + matches!( + err, + PagedbError::ChecksumFailure | PagedbError::Corruption(_) + ), + "expected segment authentication failure, got {err:?}" + ); + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Test 21: restore_from rejects extra manifest-undeclared segment files. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "current_thread")] +async fn restore_rejects_extra_manifest_undeclared_segment_file() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let dst_dir = tempdir(); + + let db = make_db(&src_dir).await; + { + let mut w = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + w.append_page(SegmentPageKind::Data, b"seg-content") + .await + .unwrap(); + w.set_manifest(b"mf").unwrap(); + let meta = w.seal().await.unwrap(); + let mut t = db.begin_write().await.unwrap(); + t.link_segment("my.seg", &meta).await.unwrap(); + t.commit().await.unwrap(); + } + + let stats = db.snapshot_to(&snap_dir).await.unwrap(); + assert_eq!(stats.segments_written, 1); + std::fs::write( + snap_dir + .join("seg") + .join("abababababababababababababababab"), + b"manifest-undeclared segment", + ) + .unwrap(); drop(db); - let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + let err = match Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) .await - .unwrap(); - assert_eq!(restored.mode(), DbMode::ReadOnly); - - // begin_write must fail with ReadOnly. - let err = restored.begin_write().await.err().unwrap(); + { + Ok(_) => panic!("restore_from must reject manifest-undeclared segment files"), + Err(err) => err, + }; assert!( - matches!(err, PagedbError::ReadOnly), - "expected ReadOnly, got {err:?}" + matches!(err, PagedbError::Corruption(_)), + "expected Corruption for extra full-snapshot segment sidecar, got {err:?}" ); std::fs::remove_dir_all(&src_dir).ok(); @@ -170,112 +2370,239 @@ async fn restore_yields_readonly_db() { } // --------------------------------------------------------------------------- -// Test 3: promote_to_follower allows applying a real incremental. +// Test 22: apply_incremental rejects missing manifest-declared segment files. // --------------------------------------------------------------------------- #[tokio::test(flavor = "current_thread")] -async fn promote_to_follower_allows_apply() { +async fn apply_incremental_rejects_missing_manifest_declared_segment_file() { let src_dir = tempdir(); let snap_dir = tempdir(); - let dst_dir = tempdir(); let delta_dir = tempdir(); + let dst_dir = tempdir(); let db = make_db(&src_dir).await; + { + let mut t = db.begin_write().await.unwrap(); + t.put(b"base", b"data").await.unwrap(); + t.commit().await.unwrap(); + } let c1 = db.latest_commit(); db.snapshot_to(&snap_dir).await.unwrap(); - // Advance the source after the full snapshot and export c1 -> c2. { - let mut txn = db.begin_write().await.unwrap(); - txn.put(b"changed", b"after-snapshot").await.unwrap(); - txn.commit().await.unwrap(); + let meta = { + let mut s = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + s.append_page(SegmentPageKind::Data, b"segment-after-base") + .await + .unwrap(); + s.set_manifest(b"mf").unwrap(); + s.seal().await.unwrap() + }; + let mut t = db.begin_write().await.unwrap(); + t.link_segment("missing.seg", &meta).await.unwrap(); + t.commit().await.unwrap(); + } + + let stats = db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + assert_eq!(stats.segments_written, 1); + let seg_files: Vec<_> = std::fs::read_dir(delta_dir.join("seg")) + .unwrap() + .filter_map(std::result::Result::ok) + .filter(|entry| entry.path().is_file()) + .map(|entry| entry.path()) + .collect(); + assert_eq!( + seg_files.len(), + 1, + "test setup must produce exactly one incremental segment sidecar" + ); + for file in seg_files { + std::fs::remove_file(file).unwrap(); } - let c2 = db.latest_commit(); - db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); drop(db); let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) .await .unwrap(); - let follower = restored.promote_to_follower().await.unwrap(); - assert_eq!(follower.mode(), DbMode::Follower); - assert!(follower.can_apply_incremental()); - let stats = follower.apply_incremental(&delta_dir).await.unwrap(); - assert!(stats.pages_applied > 0); - assert_eq!(follower.latest_commit(), c2); + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must reject missing manifest-declared segment files"); + assert!( + matches!( + err, + PagedbError::Corruption(_) + | PagedbError::SnapshotIncompatible { + field: "segments_count" + } + ), + "expected Corruption for missing segment sidecar, got {err:?}" + ); let rtxn = follower.begin_read().await.unwrap(); assert_eq!( - rtxn.get(b"changed").await.unwrap().as_deref(), - Some(b"after-snapshot".as_slice()) + rtxn.get(b"base").await.unwrap().as_deref(), + Some(b"data".as_slice()) ); + assert!( + rtxn.open_segment("missing.seg").await.is_err(), + "failed incremental apply must not advance the catalog" + ); + drop(rtxn); std::fs::remove_dir_all(&src_dir).ok(); std::fs::remove_dir_all(&snap_dir).ok(); - std::fs::remove_dir_all(&dst_dir).ok(); std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); } -// --------------------------------------------------------------------------- -// Test 4: incremental carries only changed pages. -// --------------------------------------------------------------------------- #[tokio::test(flavor = "current_thread")] -async fn incremental_carries_only_changed_pages() { +async fn apply_incremental_rejects_delta_depending_on_leftover_future_pages() { let src_dir = tempdir(); - let snap1_dir = tempdir(); - let snap2_dir = tempdir(); + let snap_dir = tempdir(); + let delta_dir = tempdir(); + let dst_dir = tempdir(); let db = make_db(&src_dir).await; - // Write some initial data. { let mut t = db.begin_write().await.unwrap(); - for i in 0u32..50 { - let k = format!("key{i:03}"); - t.put(k.as_bytes(), b"init").await.unwrap(); - } + t.put(b"base", b"data").await.unwrap(); t.commit().await.unwrap(); } let c1 = db.latest_commit(); - let full_stats: SnapshotStats = db.snapshot_to(&snap1_dir).await.unwrap(); + db.snapshot_to(&snap_dir).await.unwrap(); - // Write more data to advance the commit. { + let meta = { + let mut s = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + s.append_page(SegmentPageKind::Data, b"segment-after-base") + .await + .unwrap(); + s.set_manifest(b"mf").unwrap(); + s.seal().await.unwrap() + }; let mut t = db.begin_write().await.unwrap(); - for i in 0u32..10 { - let k = format!("new{i:03}"); - t.put(k.as_bytes(), b"added").await.unwrap(); - } + t.put(b"later", b"value").await.unwrap(); + t.link_segment("leftover.seg", &meta).await.unwrap(); t.commit().await.unwrap(); } + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + drop(db); - let inc_stats: SnapshotStats = db.snapshot_incremental_to(c1, &snap2_dir).await.unwrap(); + let original_delta = std::fs::read(delta_dir.join("pages.delta")).unwrap(); + assert!( + original_delta.len() > PAGE, + "test setup must produce at least one changed main-db page" + ); + let seg_files: Vec<_> = std::fs::read_dir(delta_dir.join("seg")) + .unwrap() + .filter_map(std::result::Result::ok) + .filter(|entry| entry.path().is_file()) + .map(|entry| entry.path()) + .collect(); + assert_eq!( + seg_files.len(), + 1, + "test setup must produce exactly one segment sidecar" + ); + let saved_segments: Vec<_> = seg_files + .iter() + .map(|path| (path.clone(), std::fs::read(path).unwrap())) + .collect(); - // Incremental should have fewer pages than the full snapshot. + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + + for (path, bytes) in &saved_segments { + assert!( + bytes.len() > PAGE + 128, + "test setup must include a segment data page to corrupt" + ); + let mut corrupt = bytes.clone(); + corrupt[PAGE + 128] ^= 0xFF; + std::fs::write(path, corrupt).unwrap(); + } + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("first apply must fail after writing delta pages"); assert!( - inc_stats.pages_written < full_stats.pages_written, - "incremental pages {} should be < full pages {}", - inc_stats.pages_written, - full_stats.pages_written + matches!( + err, + PagedbError::ChecksumFailure | PagedbError::Corruption(_) + ), + "expected corrupt sidecar authentication failure, got {err:?}" + ); + assert_eq!( + follower.latest_commit(), + c1, + "failed apply must not advance the follower header" + ); + + for (path, bytes) in &saved_segments { + std::fs::write(path, bytes).unwrap(); + } + let manifest = std::fs::read(delta_dir.join("manifest")).unwrap(); + let target_active_root_page_id = u64::from_le_bytes(manifest[102..110].try_into().unwrap()); + let mut corrupt_retry_delta = original_delta; + let mut corrupted = false; + for record in corrupt_retry_delta.chunks_exact_mut(8 + PAGE) { + let page_id = u64::from_be_bytes(record[..8].try_into().unwrap()); + if page_id == target_active_root_page_id { + record[8 + 128] ^= 0xFF; + corrupted = true; + break; + } + } + assert!( + corrupted, + "test setup must include the target active root in pages.delta" + ); + std::fs::write(delta_dir.join("pages.delta"), corrupt_retry_delta).unwrap(); + + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("retry must authenticate rewritten pages instead of using cached leftovers"); + assert!( + matches!( + err, + PagedbError::ChecksumFailure | PagedbError::Corruption(_) + ), + "expected authentication failure for a corrupt retry over leftover future pages, got {err:?}" + ); + assert_eq!( + follower.latest_commit(), + c1, + "incomplete retry must not advance the follower header" ); std::fs::remove_dir_all(&src_dir).ok(); - std::fs::remove_dir_all(&snap1_dir).ok(); - std::fs::remove_dir_all(&snap2_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); } // --------------------------------------------------------------------------- -// Test 5: apply_incremental advances commit and data matches. +// Test 23: apply_incremental rejects corrupt new segment sidecars. // --------------------------------------------------------------------------- #[tokio::test(flavor = "current_thread")] -async fn apply_incremental_advances_commit() { +async fn apply_incremental_rejects_corrupt_new_segment_sidecar() { let src_dir = tempdir(); let snap_dir = tempdir(); let delta_dir = tempdir(); let dst_dir = tempdir(); let db = make_db(&src_dir).await; - // Write initial data. { let mut t = db.begin_write().await.unwrap(); t.put(b"base", b"data").await.unwrap(); @@ -284,43 +2611,66 @@ async fn apply_incremental_advances_commit() { let c1 = db.latest_commit(); db.snapshot_to(&snap_dir).await.unwrap(); - // Write more data after c1. { + let meta = { + let mut s = db + .create_segment(REALM, SegmentKind::Unspecified) + .await + .unwrap(); + s.append_page(SegmentPageKind::Data, b"segment-after-base") + .await + .unwrap(); + s.set_manifest(b"mf").unwrap(); + s.seal().await.unwrap() + }; let mut t = db.begin_write().await.unwrap(); - t.put(b"new_key", b"new_val").await.unwrap(); + t.link_segment("corrupt-new.seg", &meta).await.unwrap(); t.commit().await.unwrap(); } - let c2 = db.latest_commit(); - // Incremental from c1 to c2. - db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + let stats = db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); + assert_eq!(stats.segments_written, 1); + let seg_files: Vec<_> = std::fs::read_dir(delta_dir.join("seg")) + .unwrap() + .filter_map(std::result::Result::ok) + .filter(|entry| entry.path().is_file()) + .map(|entry| entry.path()) + .collect(); + assert_eq!( + seg_files.len(), + 1, + "test setup must produce exactly one incremental segment sidecar" + ); + let mut bytes = std::fs::read(&seg_files[0]).unwrap(); + assert!( + bytes.len() > PAGE + 128, + "test setup must include a data page to corrupt" + ); + bytes[PAGE + 128] ^= 0xFF; + std::fs::write(&seg_files[0], bytes).unwrap(); drop(db); - // Restore and promote. let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) .await .unwrap(); let follower = restored.promote_to_follower().await.unwrap(); - // Apply incremental. - let _stats: ApplyStats = follower.apply_incremental(&delta_dir).await.unwrap(); - - // The follower's latest_commit should equal c2 after applying. - let follower_commit = follower.latest_commit(); - assert_eq!(follower_commit, c2, "follower commit should match c2"); + let err = follower + .apply_incremental(&delta_dir) + .await + .expect_err("apply_incremental must reject corrupt new segment sidecars"); + assert!( + matches!( + err, + PagedbError::ChecksumFailure | PagedbError::Corruption(_) + ), + "expected segment authentication failure, got {err:?}" + ); - // The applied delta must advance the data tree: the key written after the - // base snapshot is now readable, and the base key still resolves. let rtxn = follower.begin_read().await.unwrap(); - assert_eq!( - rtxn.get(b"new_key").await.unwrap().as_deref(), - Some(b"new_val".as_slice()), - "incrementally-applied key must be readable on the follower" - ); - assert_eq!( - rtxn.get(b"base").await.unwrap().as_deref(), - Some(b"data".as_slice()), - "base key must survive the incremental apply" + assert!( + rtxn.open_segment("corrupt-new.seg").await.is_err(), + "failed incremental apply must not advance the catalog" ); drop(rtxn); @@ -331,70 +2681,88 @@ async fn apply_incremental_advances_commit() { } // --------------------------------------------------------------------------- -// Test 6: standalone db calling apply_incremental returns IdentityForked. -// --------------------------------------------------------------------------- -#[tokio::test(flavor = "current_thread")] -async fn apply_incremental_rejects_on_standalone() { - let src_dir = tempdir(); - let snap_dir = tempdir(); - - let db = make_db(&src_dir).await; - db.snapshot_to(&snap_dir).await.unwrap(); - - let err = db.apply_incremental(&snap_dir).await.err().unwrap(); - assert!( - matches!(err, PagedbError::IdentityForked), - "expected IdentityForked, got {err:?}" - ); - - std::fs::remove_dir_all(&src_dir).ok(); - std::fs::remove_dir_all(&snap_dir).ok(); -} - -// --------------------------------------------------------------------------- -// Test 7: snapshot includes segments; restored db can read segment. +// Test 24: apply_incremental tombstones segments removed by the target catalog. // --------------------------------------------------------------------------- #[tokio::test(flavor = "current_thread")] -async fn snapshot_includes_segments() { +async fn apply_incremental_tombstones_segment_removed_by_target_catalog() { let src_dir = tempdir(); let snap_dir = tempdir(); + let delta_dir = tempdir(); let dst_dir = tempdir(); let db = make_db(&src_dir).await; - { - let mut w = db + let meta = { + let mut s = db .create_segment(REALM, SegmentKind::Unspecified) .await .unwrap(); - w.append_page(SegmentPageKind::Data, b"seg-content") + s.append_page(SegmentPageKind::Data, b"segment-before-unlink") .await .unwrap(); - w.set_manifest(b"mf").unwrap(); - let meta = w.seal().await.unwrap(); + s.set_manifest(b"mf").unwrap(); + s.seal().await.unwrap() + }; + { let mut t = db.begin_write().await.unwrap(); - t.link_segment("my.seg", &meta).await.unwrap(); + t.link_segment("removed.seg", &meta).await.unwrap(); t.commit().await.unwrap(); } + let c1 = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); - let stats = db.snapshot_to(&snap_dir).await.unwrap(); - assert_eq!(stats.segments_written, 1); + { + let mut t = db.begin_write().await.unwrap(); + t.unlink_segment("removed.seg").await.unwrap(); + t.commit().await.unwrap(); + } + db.snapshot_incremental_to(c1, &delta_dir).await.unwrap(); drop(db); let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) .await .unwrap(); - let rtxn = restored.begin_read().await.unwrap(); - let reader = rtxn.open_segment("my.seg").await.unwrap(); - let page = reader.read_page(1).await.unwrap(); - assert!(page.starts_with(b"seg-content")); + let follower = restored.promote_to_follower().await.unwrap(); + let live_path = dst_dir.join("seg").join(hex_lower(&meta.segment_id)); + assert!( + live_path.is_file(), + "base restore must contain the segment before the unlink delta is applied" + ); + + let stats = follower + .apply_incremental(&delta_dir) + .await + .expect("unlink delta should apply successfully"); + assert_eq!( + stats.segments_tombstoned, 1, + "apply_incremental must report the removed segment tombstone" + ); + assert!( + !live_path.exists(), + "removed segment must not remain at its live path after apply" + ); + let tombstone_dir = dst_dir.join("seg").join(".tombstone"); + let tombstone_count = std::fs::read_dir(&tombstone_dir) + .unwrap() + .filter_map(std::result::Result::ok) + .filter(|entry| entry.path().is_file()) + .count(); + assert_eq!(tombstone_count, 1); + + let rtxn = follower.begin_read().await.unwrap(); + assert!( + rtxn.open_segment("removed.seg").await.is_err(), + "applied target catalog must no longer expose the removed segment" + ); + drop(rtxn); std::fs::remove_dir_all(&src_dir).ok(); std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); std::fs::remove_dir_all(&dst_dir).ok(); } // --------------------------------------------------------------------------- -// Test 8: manifest corruption detected. +// Test 25: manifest corruption detected. // --------------------------------------------------------------------------- #[tokio::test(flavor = "current_thread")] async fn manifest_corruption_detected() { @@ -605,6 +2973,11 @@ async fn failed_apply_promote_poisoned_handle_reopens_and_replays_journal_before let dst_dir = tempdir(); let source = make_db(&src_dir).await; + { + let mut write = source.begin_write().await.unwrap(); + write.put(b"base", b"before-snapshot").await.unwrap(); + write.commit().await.unwrap(); + } let base = source.latest_commit(); source.snapshot_to(&snap_dir).await.unwrap(); let meta = { From 2f265a99dad621249ff328eed864a7653d1177c4 Mon Sep 17 00:00:00 2001 From: Farhan Syah Date: Mon, 27 Jul 2026 05:13:34 +0800 Subject: [PATCH 2/2] fix(snapshot): stop rejecting deltas over follower-local pages Incremental apply distinguished the base's reader-visible page set from its published set only for the write guard, then reused the wider published set to define which pages the delta must contain. That made a producer's legitimate reuse of a page still held by the follower's own free-list chain or commit-history tree look like a malformed artifact instead of the true base/target mismatch it is. Split the two page sets explicitly, report the real collision as a new SnapshotBasePageReused error, restrict segment restore to segment-id filenames, and record destination ownership so failed export/restore/ apply cleanup never deletes a directory the caller pre-created. --- CHANGELOG.md | 26 ++-- src/errors.rs | 17 +++ src/snapshot/apply.rs | 21 ++- src/txn/db/snapshot.rs | 297 +++++++++++++++++++++++++++------------- tests/snapshot_basic.rs | 168 +++++++++++++++++++++-- 5 files changed, 406 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 444de2b..acf49b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,19 +6,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Fixed - -- Snapshot export, restore, and incremental apply now fail closed on malformed, - incomplete, extra, or unreadable artifacts instead of silently skipping - pages or segment files; exported files are synced before success. -- Incremental apply authenticates its exact reachable page and segment sets, - invalidates stale cached plaintext after raw page writes, preserves published - base pages, rejects stale future-page dependencies, and journals both segment - promotion and tombstoning before publishing the target header. - -## [0.1.0] - 2026-06-07 - -Initial release. +The first stable release. Pre-releases are published as `0.1.0-beta.N`; the +entries below describe `0.1.0` as a whole rather than deltas against a shipped +version, since none exists yet. ### Added @@ -40,7 +30,9 @@ Initial release. (Grand Central Dispatch), Android, WASM/OPFS, and WASI backends, plus a tokio thread-pool fallback and an in-memory backend, with format-bit identity across targets. -- **Snapshots** — `snapshot_to`, `restore_from`, and incremental apply. +- **Snapshots** — `snapshot_to`, `restore_from`, and incremental apply, each + authenticated against the exact state its manifest describes. Destinations + must be empty; malformed or incomplete artifacts fail closed. - **Recovery** — open-flow GC, apply-journal replay, deep-walk `fsck`, and the `pagedb-fsck` binary. - **Online rekey** — rekey the database under a new key with mixed-cipher and @@ -53,5 +45,7 @@ Initial release. - Single-writer per database; multi-writer cross-process is not supported. - Writes carry per-page AEAD and copy-on-write overhead; for throughput-bound plaintext KV workloads a generic store may be faster. - -[0.1.0]: https://github.com/nodedb-lab/pagedb/releases/tag/v0.1.0 +- An incremental snapshot cannot always be applied: if the producer recycled a + page the follower's own free-list chain or commit-history tree still hosts, + apply reports `PagedbError::SnapshotBasePageReused` and the follower needs a + full snapshot or a nearer base commit. diff --git a/src/errors.rs b/src/errors.rs index 08a5b33..1ff9316 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -56,6 +56,17 @@ pub enum PagedbError { #[error("incremental snapshot is incompatible: {field}")] SnapshotIncompatible { field: &'static str }, + /// The target state reuses a page the base commit still hosts, so the delta + /// cannot be expressed without overwriting state a follower on that base + /// still depends on. + /// + /// Not a corruption: both states are internally sound. They simply cannot be + /// related by a page delta, and the correct remedy is a full snapshot or a + /// nearer base commit — which is why this is distinct from + /// [`CorruptionDetail::SnapshotArtifactInvalid`]. + #[error("incremental snapshot would overwrite base-live page {page_id}")] + SnapshotBasePageReused { page_id: u64 }, + #[error("commit {commit:?} is durable but unpublished; reopen required")] DurablyCommittedButUnpublished { commit: CommitId }, @@ -433,6 +444,12 @@ impl PagedbError { Self::SnapshotIncompatible { field } } + /// Canonical constructor for a target state that reuses a base-live page. + #[must_use] + pub const fn snapshot_base_page_reused(page_id: u64) -> Self { + Self::SnapshotBasePageReused { page_id } + } + /// Canonical constructor for a handle whose newest durable commit could /// not be reconciled into its reader-visible state. #[must_use] diff --git a/src/snapshot/apply.rs b/src/snapshot/apply.rs index f76f923..092b900 100644 --- a/src/snapshot/apply.rs +++ b/src/snapshot/apply.rs @@ -10,6 +10,7 @@ use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; use crate::Result; use crate::errors::PagedbError; +use crate::pager::page_space::is_reserved; /// Pages consumed from one `pages.delta` stream during an incremental apply. pub struct AppliedDeltaPages { @@ -29,7 +30,6 @@ pub async fn apply_delta_pages( dst_main_db_path: &Path, page_size: usize, protected_page_ids: &BTreeSet, - base_allocation_floor: u64, target_next_page_id: u64, ) -> Result { let delta_path = src_path.join("pages.delta"); @@ -68,14 +68,25 @@ pub async fn apply_delta_pages( .await .map_err(PagedbError::Io)?; let page_id = u64::from_be_bytes(id_buf); - if page_id < base_allocation_floor - || page_id >= target_next_page_id - || protected_page_ids.contains(&page_id) - { + // Reserved pages are the A/B headers and the apply-journal slots. A + // delta record naming one would overwrite the very state that makes the + // apply recoverable, so it is rejected by identity rather than by + // happening to fall under some allocation bound. + if is_reserved(page_id) || page_id >= target_next_page_id { return Err(PagedbError::snapshot_artifact_invalid( "pages.delta.page_id", )); } + // The follower keeps its own free-list chain and commit-history tree + // across an apply, and those pages are invisible to the producer, which + // can neither predict nor avoid them. A collision is therefore not a + // malformed artifact — both states are internally sound, they simply + // cannot be related by a page delta — so it reports as + // `SnapshotBasePageReused` and the remedy is a full snapshot or a nearer + // base. Caught here, before `main.db` is opened for writing. + if protected_page_ids.contains(&page_id) { + return Err(PagedbError::snapshot_base_page_reused(page_id)); + } if !page_ids.insert(page_id) { return Err(PagedbError::snapshot_artifact_invalid( "pages.delta.duplicate_page_id", diff --git a/src/txn/db/snapshot.rs b/src/txn/db/snapshot.rs index d871b25..37e628e 100644 --- a/src/txn/db/snapshot.rs +++ b/src/txn/db/snapshot.rs @@ -22,7 +22,7 @@ use crate::vfs::tokio_backend::TokioVfs; use tokio::fs; use tokio::io::AsyncReadExt; -use super::core::{Db, ReaderSnapshot, decode_commit_meta}; +use super::core::{Db, ReaderSnapshot}; use super::util::{get_vfs_root, page_size_log2}; async fn collect_tree_page_ids( @@ -48,17 +48,38 @@ async fn collect_tree_page_ids( Ok(page_ids) } -async fn collect_published_page_ids( +/// The two page sets a base state contributes to an incremental apply. +/// +/// They are deliberately distinct. `reader_visible` is the data and catalog +/// reachable set — the *only* set both sides of the protocol can compute, so it +/// is what defines which pages a delta must carry. `published` additionally +/// covers the free-list chain and commit-history tree, which +/// [`Db::apply_incremental`] carries over from the follower's own header rather +/// than taking from the producer; those pages stay live across the apply and +/// must never be overwritten, but the producer cannot see them, so they can only +/// ever be a write guard — never part of the delta's definition. +/// +/// Conflating the two is what makes a healthy snapshot look malformed: +/// subtracting `published` when deciding *which pages a delta should contain* +/// turns every page the producer legitimately allocated over a follower-local +/// free-list page into an unexplained set mismatch. +struct BasePageSets { + reader_visible: BTreeSet, + published: BTreeSet, +} + +async fn collect_base_page_sets( db: &Db, snapshot: ReaderSnapshot, -) -> crate::Result> { - let mut page_ids = collect_tree_page_ids( +) -> crate::Result { + let reader_visible = collect_tree_page_ids( db, snapshot.root_page_id, snapshot.catalog_root_page_id, snapshot.next_page_id, ) .await?; + let mut published = reader_visible.clone(); if snapshot.commit_history_root_page_id != 0 { let history = crate::btree::BTree::open( db.pager.clone(), @@ -67,7 +88,7 @@ async fn collect_published_page_ids( snapshot.next_page_id, db.page_size, ); - history.collect_all_page_ids(&mut page_ids).await?; + history.collect_all_page_ids(&mut published).await?; } if snapshot.free_list_root_page_id != 0 { let (_entries, chain_pages) = crate::pager::freelist::read_chain( @@ -76,33 +97,38 @@ async fn collect_published_page_ids( snapshot.free_list_root_page_id, ) .await?; - page_ids.extend(chain_pages); + published.extend(chain_pages); } - Ok(page_ids) + Ok(BasePageSets { + reader_visible, + published, + }) } -async fn published_allocation_floor( - db: &Db, - snapshot: ReaderSnapshot, -) -> crate::Result { - if snapshot.commit_history_root_page_id == 0 { - return Ok(snapshot.next_page_id); - } - let history = crate::btree::BTree::open( - db.pager.clone(), - db.realm_id, - snapshot.commit_history_root_page_id, - snapshot.next_page_id, - db.page_size, - ); - let key = snapshot.commit_id.to_be_bytes(); - match history.get(&key).await? { - Some(value) => Ok(decode_commit_meta(&value)?.next_page_id), - None => Ok(snapshot.next_page_id), - } +/// What a failed export or restore is allowed to delete. +/// +/// Cleanup must undo what the operation created and nothing else. A caller that +/// pre-created an empty output directory — `mkdir -p /backups/snap-1`, then +/// snapshot into it — still owns that directory after a failure, so the +/// distinction is recorded up front rather than inferred afterwards from an +/// `io::ErrorKind` that any number of unrelated operations also produce. +#[derive(Clone, Copy, PartialEq, Eq)] +enum DestinationOwnership { + /// The directory did not exist; a failure removes it entirely. + Created, + /// The directory already existed and was empty; a failure removes only the + /// contents written into it. + PreExisting, } -async fn ensure_restore_destination_empty(dst_path: &std::path::Path) -> crate::Result<()> { +/// Require an empty destination and record whether it already existed. +/// +/// A non-empty destination is refused rather than merged into: a snapshot +/// artifact describes one exact state, and mixing it with unrelated pre-existing +/// pages or segment files produces a directory that authenticates as neither. +async fn claim_empty_destination( + dst_path: &std::path::Path, +) -> crate::Result { match fs::read_dir(dst_path).await { Ok(mut entries) => { if entries @@ -113,22 +139,41 @@ async fn ensure_restore_destination_empty(dst_path: &std::path::Path) -> crate:: { return Err(crate::errors::PagedbError::Io(std::io::Error::new( std::io::ErrorKind::AlreadyExists, - format!("restore destination is not empty: {}", dst_path.display()), + format!("snapshot destination is not empty: {}", dst_path.display()), ))); } - Ok(()) + Ok(DestinationOwnership::PreExisting) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(DestinationOwnership::Created) } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) => Err(crate::errors::PagedbError::Io(error)), } } -async fn cleanup_failed_snapshot(dst_path: &std::path::Path, error: &crate::errors::PagedbError) { - if !matches!( - error, - crate::errors::PagedbError::Io(io) if io.kind() == std::io::ErrorKind::AlreadyExists - ) { - let _ = fs::remove_dir_all(dst_path).await; +/// Roll back a failed export or restore, leaving the destination reusable. +/// +/// Errors are discarded deliberately: the caller is already returning the +/// failure that matters, and a cleanup error must not replace it with something +/// less diagnostic. +async fn cleanup_failed_snapshot(dst_path: &std::path::Path, ownership: DestinationOwnership) { + match ownership { + DestinationOwnership::Created => { + let _ = fs::remove_dir_all(dst_path).await; + } + DestinationOwnership::PreExisting => { + let Ok(mut entries) = fs::read_dir(dst_path).await else { + return; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if entry.file_type().await.is_ok_and(|kind| kind.is_dir()) { + let _ = fs::remove_dir_all(&path).await; + } else { + let _ = fs::remove_file(&path).await; + } + } + } } } @@ -213,6 +258,50 @@ async fn validate_restored_snapshot( } impl Db { + /// Segment rows of the catalog tree rooted at `catalog_root_page_id`. + /// + /// Reads the catalog at an explicit root rather than through a `ReadTxn`, so + /// a caller comparing a base and a target catalog can hold both at once — + /// and so the base side can be drawn from the same published snapshot its + /// page sets came from. + async fn catalog_segment_metas( + &self, + catalog_root_page_id: u64, + next_page_id: u64, + ) -> crate::Result> { + if catalog_root_page_id == 0 { + return Ok(Vec::new()); + } + let tree = crate::btree::BTree::open( + self.pager.clone(), + self.realm_id, + catalog_root_page_id, + next_page_id, + self.page_size, + ); + let rows = tree + .scan_prefix(&[crate::catalog::codec::CatalogRowKind::Segment as u8]) + .await?; + let mut entries = Vec::with_capacity(rows.len()); + for (_, value) in rows { + entries.push(crate::catalog::codec::Catalog::decode_segment_meta(&value)?); + } + Ok(entries) + } + + async fn catalog_segment_ids( + &self, + catalog_root_page_id: u64, + next_page_id: u64, + ) -> crate::Result> { + Ok(self + .catalog_segment_metas(catalog_root_page_id, next_page_id) + .await? + .into_iter() + .map(|meta| meta.segment_id) + .collect()) + } + /// Full verbatim snapshot of the database at the current `latest_commit`. /// /// Not available on `wasm32` targets (requires native file system access). @@ -263,8 +352,16 @@ impl Db { }; let src_root = get_vfs_root(&*self.vfs)?; + let ownership = claim_empty_destination(dst_path).await?; + // Bound before the await: the guard is a `parking_lot` read lock and + // must not be alive across a suspension point. This is a length floor + // for the verbatim `main.db` copy, not a set-membership claim, so the + // currently published snapshot is the right source even though `txn` + // pins a possibly earlier commit — any later commit only grows the file. let published_snapshot = *self.snapshot.read(); - let required_page_ids = collect_published_page_ids(self, published_snapshot).await?; + let required_page_ids = collect_base_page_sets(self, published_snapshot) + .await? + .published; let highest_required_main_page = required_page_ids.iter().next_back().copied().unwrap_or(1); let stats = match snapshot_full( &src_root, @@ -278,7 +375,7 @@ impl Db { { Ok(stats) => stats, Err(error) => { - cleanup_failed_snapshot(dst_path, &error).await; + cleanup_failed_snapshot(dst_path, ownership).await; return Err(error); } }; @@ -306,7 +403,7 @@ impl Db { if manifest.kind != 0 { return Err(crate::errors::PagedbError::snapshot_incompatible("kind")); } - ensure_restore_destination_empty(dst_path).await?; + let ownership = claim_empty_destination(dst_path).await?; let restore_result = async { // Create destination directory. @@ -329,7 +426,12 @@ impl Db { .map_err(crate::errors::PagedbError::Io)?; // Copy segment files without collapsing directory or entry errors. + // Names are screened here, before anything opens the destination: a + // file that is not a segment identity is an undeclared sidecar, and + // letting open-time recovery meet it first reports artifact + // corruption as whatever error that recovery path happens to raise. let seg_src = src_path.join("seg"); + let mut copied_segments: u32 = 0; match fs::read_dir(&seg_src).await { Ok(mut entries) => { while let Some(entry) = entries @@ -337,10 +439,20 @@ impl Db { .await .map_err(crate::errors::PagedbError::Io)? { - let name = entry.file_name(); - fs::copy(entry.path(), seg_dst.join(name)) + let name = entry.file_name().into_string().map_err(|_| { + crate::errors::PagedbError::snapshot_artifact_invalid("segment.name") + })?; + if crate::hex::parse_hex::<16>(&name).is_none() { + return Err(crate::errors::PagedbError::snapshot_artifact_invalid( + "segment.name", + )); + } + fs::copy(entry.path(), seg_dst.join(&name)) .await .map_err(crate::errors::PagedbError::Io)?; + copied_segments = copied_segments.checked_add(1).ok_or_else(|| { + crate::errors::PagedbError::snapshot_artifact_invalid("segments_count") + })?; } } Err(error) @@ -348,6 +460,11 @@ impl Db { && manifest.segments_count == 0 => {} Err(error) => return Err(crate::errors::PagedbError::Io(error)), } + if copied_segments != manifest.segments_count { + return Err(crate::errors::PagedbError::snapshot_artifact_invalid( + "segments", + )); + } // Open and authenticate every manifest-referenced root and segment // before returning a usable handle. @@ -356,25 +473,13 @@ impl Db { let realm_id = crate::RealmId(manifest.realm_id); let dst_vfs = TokioVfs::new(dst_path); let restored = - Db::::open_read_only(dst_vfs, kek, page_size, realm_id, options) - .await - .map_err(|error| match error { - // A syntactically named but malformed undeclared - // sidecar can be discovered by open-time recovery - // before the exact catalog/file-set check below. - // In a restore artifact this is corruption, not an - // unsupported host capability. - crate::errors::PagedbError::Unsupported => { - crate::errors::PagedbError::snapshot_artifact_invalid("segments") - } - other => other, - })?; + Db::::open_read_only(dst_vfs, kek, page_size, realm_id, options).await?; validate_restored_snapshot(&manifest, &restored).await?; Ok(restored) } .await; - if let Err(error) = &restore_result { - cleanup_failed_snapshot(dst_path, error).await; + if restore_result.is_err() { + cleanup_failed_snapshot(dst_path, ownership).await; } restore_result } @@ -415,16 +520,28 @@ impl Db { base_next_page_id, ) .await?; + // Exactly the formula `apply_incremental` re-derives from the manifest. + // Both sides must compute this the same way or a healthy snapshot fails + // the follower's set comparison. let changed_page_ids: Vec = target_page_ids .difference(&base_page_ids) .copied() .collect(); - if changed_page_ids - .iter() - .any(|page_id| *page_id < base_next_page_id) - { - return Err(crate::errors::PagedbError::Unsupported); - } + // Deliberately no producer-side page-reuse check. A page id below the + // base allocation cursor proves nothing: a page that was *free* at the + // base commit is legitimately reallocated for the target, and shipping + // it is safe precisely because no base-reachable state points at it. + // Rejecting on the cursor would fail every database that has ever + // deleted anything. + // + // The one collision that does matter — a page the follower's own + // free-list chain or commit-history tree still hosts — is invisible + // from here. The follower keeps both across an apply, and the base + // commit's recorded free-list root is superseded writer metadata whose + // pages a later commit may already have recycled, so reading it to + // guess would authenticate a page that is no longer a free-list page at + // all. The follower owns that check and runs it before any byte reaches + // `main.db`. // Current segments. let current_segments = txn.list_segments("").await?; @@ -469,6 +586,7 @@ impl Db { }; let src_root = get_vfs_root(&*self.vfs)?; + let ownership = claim_empty_destination(dst_path).await?; let stats = match snapshot_incremental( &src_root, @@ -483,7 +601,7 @@ impl Db { { Ok(stats) => stats, Err(error) => { - cleanup_failed_snapshot(dst_path, &error).await; + cleanup_failed_snapshot(dst_path, ownership).await; return Err(error); } }; @@ -550,17 +668,19 @@ impl Db { }; let manifest = decode_manifest(&manifest_bytes, &hk_raw)?; self.validate_incremental_manifest(&manifest, &manifest_bytes[118..224])?; + // One sample of the published base. Its page sets and its segment set + // are compared against each other below, so drawing them from two + // independent reads would let a concurrent `gc_now` — which Follower + // mode permits — split the base state across the comparison and make a + // valid delta look inconsistent. let base_snapshot = *self.snapshot.read(); - let protected_page_ids = collect_published_page_ids(self, base_snapshot).await?; - let base_allocation_floor = published_allocation_floor(self, base_snapshot).await?; - let base_segment_ids: BTreeSet<[u8; 16]> = self - .begin_read() - .await? - .list_segments("") - .await? - .into_iter() - .map(|meta| meta.segment_id) - .collect(); + let base_pages = collect_base_page_sets(self, base_snapshot).await?; + let base_segment_ids = self + .catalog_segment_ids( + base_snapshot.catalog_root_page_id, + base_snapshot.next_page_id, + ) + .await?; let page_size = usize::try_from(manifest.page_size) .map_err(|_| crate::errors::PagedbError::snapshot_incompatible("page_size"))?; @@ -574,8 +694,7 @@ impl Db { src_path, &dst_main_db, page_size, - &protected_page_ids, - base_allocation_floor, + &base_pages.published, manifest.next_page_id_at_target, ) .await?; @@ -591,8 +710,13 @@ impl Db { manifest.next_page_id_at_target, ) .await?; + // The same formula the producer used to build the delta: + // target-reachable minus base-reader-visible. Subtracting the wider + // `published` set here instead would demand a delta the producer cannot + // construct, because it cannot see this follower's free-list or + // commit-history pages. let expected_delta_page_ids: BTreeSet = target_page_ids - .difference(&protected_page_ids) + .difference(&base_pages.reader_visible) .copied() .collect(); if applied_pages.page_ids != expected_delta_page_ids { @@ -601,25 +725,12 @@ impl Db { )); } - let target_segments = if manifest.target_catalog_root_page_id == 0 { - Vec::new() - } else { - let tree = crate::btree::BTree::open( - self.pager.clone(), - self.realm_id, + let target_segments = self + .catalog_segment_metas( manifest.target_catalog_root_page_id, manifest.next_page_id_at_target, - self.page_size, - ); - let rows = tree - .scan_prefix(&[crate::catalog::codec::CatalogRowKind::Segment as u8]) - .await?; - let mut entries = Vec::with_capacity(rows.len()); - for (_, value) in rows { - entries.push(crate::catalog::codec::Catalog::decode_segment_meta(&value)?); - } - entries - }; + ) + .await?; let target_segment_ids: BTreeSet<[u8; 16]> = target_segments.iter().map(|meta| meta.segment_id).collect(); let expected_promoted_segment_ids: BTreeSet<[u8; 16]> = target_segment_ids diff --git a/tests/snapshot_basic.rs b/tests/snapshot_basic.rs index cde8ea4..8a4cdb6 100644 --- a/tests/snapshot_basic.rs +++ b/tests/snapshot_basic.rs @@ -614,6 +614,116 @@ async fn promote_to_follower_allows_apply() { std::fs::remove_dir_all(&delta_dir).ok(); } +/// A writer that recycles pages between the base and target commits still +/// produces an applicable delta. +/// +/// Page reuse is the steady state of the free-list design, not an edge case: the +/// reclamation floor exists so freed pages come back. A page id below the base +/// commit's allocation cursor therefore proves nothing on its own — what matters +/// is whether the page was *live* at the base. Treating the cursor as a liveness +/// boundary rejects healthy snapshots from any database that has ever deleted +/// anything, which is why this walks a full delete-and-refill cycle rather than +/// only appending. +#[tokio::test(flavor = "current_thread")] +async fn incremental_round_trip_survives_page_reuse_below_the_base_cursor() { + let src_dir = tempdir(); + let snap_dir = tempdir(); + let dst_dir = tempdir(); + let delta_dir = tempdir(); + + let db = make_db(&src_dir).await; + + // Grow the tree well past a single page, so deleting most of it frees + // interior pages rather than just trimming one leaf. + { + let mut txn = db.begin_write().await.unwrap(); + for index in 0u16..512 { + txn.put(format!("reuse-{index:04}").as_bytes(), &[index as u8; 64]) + .await + .unwrap(); + } + txn.commit().await.unwrap(); + } + // Free most of those pages. They stay on the durable free list, below the + // allocation cursor the base commit will record. + { + let mut txn = db.begin_write().await.unwrap(); + for index in 0u16..480 { + txn.delete(format!("reuse-{index:04}").as_bytes()) + .await + .unwrap(); + } + txn.commit().await.unwrap(); + } + let base = db.latest_commit(); + db.snapshot_to(&snap_dir).await.unwrap(); + + // Refill. The allocator draws from the free list, so the target tree is + // reachable through pages whose ids sit below `base`'s cursor. + { + let mut txn = db.begin_write().await.unwrap(); + for index in 0u16..480 { + txn.put(format!("refill-{index:04}").as_bytes(), &[0xC7; 64]) + .await + .unwrap(); + } + txn.commit().await.unwrap(); + } + let target = db.latest_commit(); + let base_next_page_id = { + let txn = db.begin_read_at(base).await.unwrap(); + txn.next_page_id() + }; + + db.snapshot_incremental_to(base, &delta_dir) + .await + .expect("page reuse below the base cursor must still export"); + drop(db); + + let restored = Db::::restore_from(&snap_dir, &dst_dir, OpenOptions::default(), KEK) + .await + .unwrap(); + let follower = restored.promote_to_follower().await.unwrap(); + let stats = follower + .apply_incremental(&delta_dir) + .await + .expect("a delta carrying recycled page ids must still apply"); + assert!(stats.pages_applied > 0); + assert_eq!(follower.latest_commit(), target); + + // The scenario is only meaningful if reuse actually happened; otherwise this + // silently degrades into the append-only case the other tests already cover. + let rtxn = follower.begin_read().await.unwrap(); + assert!( + rtxn.next_page_id() <= base_next_page_id.saturating_add(64), + "expected the refill to recycle freed pages rather than extend the file: \ + base cursor {base_next_page_id}, target cursor {}", + rtxn.next_page_id() + ); + for index in 0u16..480 { + assert_eq!( + rtxn.get(format!("refill-{index:04}").as_bytes()) + .await + .unwrap() + .as_deref(), + Some([0xC7; 64].as_slice()), + "refilled key {index} missing after apply" + ); + assert_eq!( + rtxn.get(format!("reuse-{index:04}").as_bytes()) + .await + .unwrap(), + None, + "deleted key {index} came back after apply" + ); + } + + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&snap_dir).ok(); + std::fs::remove_dir_all(&dst_dir).ok(); + std::fs::remove_dir_all(&delta_dir).ok(); +} + #[tokio::test(flavor = "current_thread")] async fn apply_incremental_surfaces_staging_dir_sync_failure_then_retry_succeeds() { let src_dir = tempdir(); @@ -943,8 +1053,19 @@ async fn incremental_snapshot_rejects_missing_changed_main_page() { std::fs::remove_dir_all(&delta_dir).ok(); } +/// Export succeeds when the target reaches pages below the base allocation +/// cursor, and actually ships them. +/// +/// The cursor is an allocation watermark, not a liveness boundary. A page that +/// was on the free list at the base commit is legitimately reallocated for the +/// target, and shipping it is safe precisely because nothing reachable from the +/// base points at it. Rejecting on the cursor would make incremental snapshots +/// unusable for any database that has ever deleted anything. +/// +/// The complementary end-to-end case — that such a delta also *applies* — is +/// `incremental_round_trip_survives_page_reuse_below_the_base_cursor`. #[tokio::test(flavor = "current_thread")] -async fn incremental_snapshot_rejects_reused_pages_below_base_cursor() { +async fn incremental_snapshot_exports_reused_pages_below_base_cursor() { let src_dir = tempdir(); let snap_dir = tempdir(); let delta_dir = tempdir(); @@ -981,13 +1102,31 @@ async fn incremental_snapshot_rejects_reused_pages_below_base_cursor() { t.put(b"reused-after-base", &new_value).await.unwrap(); t.commit().await.unwrap(); } - let err = db + let base_next_page_id = { + let txn = db.begin_read_at(base).await.unwrap(); + txn.next_page_id() + }; + let stats = db .snapshot_incremental_to(base, &delta_dir) .await - .expect_err("incremental snapshot must reject reused pages below the base cursor"); + .expect("reused pages below the base cursor must still export"); + assert!(stats.pages_written > 0); + + // Prove the scenario is the intended one: at least one shipped record names + // a page id below the base cursor. Without this the test would still pass on + // an implementation that only ever appends. + let delta = std::fs::read(delta_dir.join("pages.delta")).unwrap(); + let record_size = 8 + PAGE; + assert_eq!(delta.len() % record_size, 0, "delta must be whole records"); + let recycled = delta + .chunks_exact(record_size) + .map(|record| u64::from_be_bytes(record[..8].try_into().unwrap())) + .filter(|page_id| *page_id < base_next_page_id) + .count(); assert!( - matches!(err, PagedbError::Unsupported), - "expected Unsupported for reused pages below base cursor, got {err:?}" + recycled > 0, + "expected the refill to recycle freed pages below the base cursor \ + ({base_next_page_id}); the delta shipped none" ); std::fs::remove_dir_all(&src_dir).ok(); @@ -1167,7 +1306,15 @@ async fn apply_incremental_rejects_delta_record_at_target_next_page_id() { // Test 11: apply_incremental rejects delta records below the base next-page id. // --------------------------------------------------------------------------- #[tokio::test(flavor = "current_thread")] -async fn apply_incremental_rejects_delta_record_below_base_next_page_id_without_mutating_base() { +/// A delta record naming a page the base still holds live is refused, and the +/// refusal leaves the base intact. +/// +/// The injected id is the base snapshot's own active root — a page the follower +/// is still reading through. What makes it inadmissible is that it is base-live, +/// not that it sorts below some allocation cursor: recycled ids below that +/// cursor are ordinary and are covered by +/// `incremental_snapshot_exports_reused_pages_below_base_cursor`. +async fn apply_incremental_refuses_to_overwrite_a_base_live_page_without_mutating_base() { let src_dir = tempdir(); let snap_dir = tempdir(); let delta_dir = tempdir(); @@ -1214,10 +1361,13 @@ async fn apply_incremental_rejects_delta_record_below_base_next_page_id_without_ let err = follower .apply_incremental(&delta_dir) .await - .expect_err("apply_incremental must reject records below the base next-page id"); + .expect_err("apply_incremental must refuse to overwrite a base-live page"); assert!( - matches!(err, PagedbError::Corruption(_)), - "expected Corruption for below-base delta record, got {err:?}" + matches!( + err, + PagedbError::SnapshotBasePageReused { page_id } if page_id == stale_page_id + ), + "expected SnapshotBasePageReused naming page {stale_page_id}, got {err:?}" ); let rtxn = follower.begin_read().await.unwrap();