Skip to content

Commit 71d1894

Browse files
presempathy-awbfarhan-syah
authored andcommitted
fix(snapshot): authenticate exact incremental state
1 parent a6c3e18 commit 71d1894

7 files changed

Lines changed: 3105 additions & 307 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ All notable changes to this project are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
55
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
9+
### Fixed
10+
11+
- Snapshot export, restore, and incremental apply now fail closed on malformed,
12+
incomplete, extra, or unreadable artifacts instead of silently skipping
13+
pages or segment files; exported files are synced before success.
14+
- Incremental apply authenticates its exact reachable page and segment sets,
15+
invalidates stale cached plaintext after raw page writes, preserves published
16+
base pages, rejects stale future-page dependencies, and journals both segment
17+
promotion and tombstoning before publishing the target header.
18+
719
## [0.1.0] - 2026-06-07
820

921
Initial release.

src/segment/reader.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,29 @@ impl<V: Vfs + Clone> SegmentReader<V> {
8585
.await
8686
}
8787

88+
#[cfg(not(target_arch = "wasm32"))]
89+
pub(crate) async fn open_internal_at_path(
90+
pager: Arc<Pager<V>>,
91+
catalog_meta: SegmentMeta,
92+
path: &str,
93+
mmap_budget_used: std::sync::Arc<std::sync::atomic::AtomicU64>,
94+
mmap_budget_limit: u64,
95+
) -> Result<Self> {
96+
let page_size = pager.page_size();
97+
let file = pager.vfs().open(path, OpenMode::Read).await?;
98+
Self::finish_open(
99+
pager,
100+
catalog_meta,
101+
page_size,
102+
file,
103+
MmapBudget {
104+
used: mmap_budget_used,
105+
limit: mmap_budget_limit,
106+
},
107+
)
108+
.await
109+
}
110+
88111
/// Open a sealed replacement from either publication location. The
89112
/// replacement identity is durable progress, so a missing or malformed
90113
/// file must fail closed rather than being regenerated.

src/snapshot/apply.rs

Lines changed: 92 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,22 @@
33
44
#![cfg(not(target_arch = "wasm32"))]
55

6-
use std::path::Path;
6+
use std::{collections::BTreeSet, path::Path};
77

88
use tokio::fs;
99
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
1010

1111
use crate::Result;
1212
use crate::errors::PagedbError;
1313

14+
/// Pages consumed from one `pages.delta` stream during an incremental apply.
15+
pub struct AppliedDeltaPages {
16+
/// Number of page records written to `main.db`.
17+
pub pages_applied: u64,
18+
/// Page IDs supplied by this exact delta stream.
19+
pub page_ids: BTreeSet<u64>,
20+
}
21+
1422
/// Apply an incremental snapshot directory (`src_path`) to the Follower's
1523
/// `main.db` file at `main_db_path` (absolute filesystem path). Returns stats.
1624
///
@@ -20,54 +28,103 @@ pub async fn apply_delta_pages(
2028
src_path: &Path,
2129
dst_main_db_path: &Path,
2230
page_size: usize,
23-
) -> Result<u64> {
31+
protected_page_ids: &BTreeSet<u64>,
32+
base_allocation_floor: u64,
33+
target_next_page_id: u64,
34+
) -> Result<AppliedDeltaPages> {
2435
let delta_path = src_path.join("pages.delta");
2536
let mut delta = match fs::File::open(&delta_path).await {
2637
Ok(f) => f,
27-
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
38+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
39+
return Ok(AppliedDeltaPages {
40+
pages_applied: 0,
41+
page_ids: BTreeSet::new(),
42+
});
43+
}
2844
Err(e) => return Err(PagedbError::Io(e)),
2945
};
3046

47+
let page_size_u64 = u64::try_from(page_size)
48+
.map_err(|_| PagedbError::snapshot_artifact_invalid("pages.delta.page_size"))?;
49+
let record_size = 8u64
50+
.checked_add(page_size_u64)
51+
.ok_or_else(|| PagedbError::snapshot_artifact_invalid("pages.delta.record_size"))?;
52+
let delta_len = delta.metadata().await.map_err(PagedbError::Io)?.len();
53+
if delta_len % record_size != 0 {
54+
return Err(PagedbError::snapshot_artifact_invalid("pages.delta.length"));
55+
}
56+
57+
// Validate every record before opening main.db for writes. A malformed
58+
// record late in the stream must not leave a valid prefix written into the
59+
// follower's future page range.
60+
let record_count = delta_len / record_size;
61+
let mut page_ids = BTreeSet::new();
62+
let mut id_buf = [0u8; 8];
63+
let page_skip = i64::try_from(page_size)
64+
.map_err(|_| PagedbError::snapshot_artifact_invalid("pages.delta.page_size"))?;
65+
for _ in 0..record_count {
66+
delta
67+
.read_exact(&mut id_buf)
68+
.await
69+
.map_err(PagedbError::Io)?;
70+
let page_id = u64::from_be_bytes(id_buf);
71+
if page_id < base_allocation_floor
72+
|| page_id >= target_next_page_id
73+
|| protected_page_ids.contains(&page_id)
74+
{
75+
return Err(PagedbError::snapshot_artifact_invalid(
76+
"pages.delta.page_id",
77+
));
78+
}
79+
if !page_ids.insert(page_id) {
80+
return Err(PagedbError::snapshot_artifact_invalid(
81+
"pages.delta.duplicate_page_id",
82+
));
83+
}
84+
delta
85+
.seek(std::io::SeekFrom::Current(page_skip))
86+
.await
87+
.map_err(PagedbError::Io)?;
88+
}
89+
delta
90+
.seek(std::io::SeekFrom::Start(0))
91+
.await
92+
.map_err(PagedbError::Io)?;
93+
3194
let mut dst = fs::OpenOptions::new()
3295
.read(true)
3396
.write(true)
3497
.open(dst_main_db_path)
3598
.await
3699
.map_err(PagedbError::Io)?;
37-
38-
let mut pages_applied: u64 = 0;
39-
let mut id_buf = [0u8; 8];
40100
let mut page_buf = vec![0u8; page_size];
41-
42-
loop {
43-
// Read page_id (8 bytes BE).
44-
match delta.read_exact(&mut id_buf).await {
45-
Ok(_) => {}
46-
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
47-
Err(e) => return Err(PagedbError::Io(e)),
48-
}
101+
for _ in 0..record_count {
102+
delta
103+
.read_exact(&mut id_buf)
104+
.await
105+
.map_err(PagedbError::Io)?;
49106
let page_id = u64::from_be_bytes(id_buf);
50-
51-
// Read page bytes.
52-
match delta.read_exact(&mut page_buf).await {
53-
Ok(_) => {}
54-
Err(e) => return Err(PagedbError::Io(e)),
55-
}
107+
delta
108+
.read_exact(&mut page_buf)
109+
.await
110+
.map_err(PagedbError::Io)?;
56111

57112
// Write page to main.db at the correct offset.
58113
let offset = page_id
59-
.checked_mul(page_size as u64)
60-
.ok_or_else(|| PagedbError::Io(std::io::Error::other("page offset overflow")))?;
114+
.checked_mul(page_size_u64)
115+
.ok_or_else(|| PagedbError::snapshot_artifact_invalid("pages.delta.page_offset"))?;
61116
dst.seek(std::io::SeekFrom::Start(offset))
62117
.await
63118
.map_err(PagedbError::Io)?;
64119
dst.write_all(&page_buf).await.map_err(PagedbError::Io)?;
65-
pages_applied += 1;
66120
}
67121

68122
dst.flush().await.map_err(PagedbError::Io)?;
69123
dst.sync_all().await.map_err(PagedbError::Io)?;
70-
Ok(pages_applied)
124+
Ok(AppliedDeltaPages {
125+
pages_applied: record_count,
126+
page_ids,
127+
})
71128
}
72129

73130
/// 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
90147
pub async fn stage_snapshot_segments(
91148
src_path: &Path,
92149
dst_seg_root: &Path,
150+
expected_segment_ids: &BTreeSet<[u8; 16]>,
93151
) -> Result<Vec<[u8; 16]>> {
94152
let entries = snapshot_segment_entries(src_path).await?;
95153
let seg_src = src_path.join("seg");
154+
let actual_segment_ids: BTreeSet<[u8; 16]> = entries
155+
.iter()
156+
.map(|name| {
157+
crate::hex::parse_hex::<16>(name)
158+
.ok_or_else(|| PagedbError::snapshot_artifact_invalid("segment_file_name"))
159+
})
160+
.collect::<Result<_>>()?;
161+
if &actual_segment_ids != expected_segment_ids {
162+
return Err(PagedbError::snapshot_artifact_invalid("segments"));
163+
}
96164

97165
let staging_dir = dst_seg_root.join(".staging");
98166
fs::create_dir_all(&staging_dir)
@@ -103,7 +171,6 @@ pub async fn stage_snapshot_segments(
103171
let mut copy_buf = vec![0u8; 64 * 1024];
104172

105173
for name in &entries {
106-
// Each name in seg/ is 32 hex chars encoding the 16-byte segment id.
107174
let segment_id = crate::hex::parse_hex::<16>(name)
108175
.ok_or_else(|| PagedbError::snapshot_artifact_invalid("segment_file_name"))?;
109176
let src_file = seg_src.join(name);

0 commit comments

Comments
 (0)