Skip to content

Commit 0368cd5

Browse files
committed
fix(pager): treat a truncated header slot as absent, not fatal
A file cut short mid-slot is the same recoverable condition as a slot that fails its HK-MAC check: the A/B protocol is designed to survive exactly one unusable slot. Add read_header_slot, which maps a short/EOF read to an absent slot so the caller's usual decode rejects it like any other damaged copy, and route both header-reading call sites through it so the surviving slot still opens the database.
1 parent ccb819c commit 0368cd5

4 files changed

Lines changed: 84 additions & 8 deletions

File tree

src/pager/header.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,28 @@ pub async fn bootstrap_header<V: Vfs>(
8282
/// Open an existing main.db, return the active header fields and which slot
8383
/// they came from.
8484
///
85+
/// Read one header slot into `buf`, treating an incomplete slot as an
86+
/// unverifiable one rather than as a fatal error.
87+
///
88+
/// The A/B protocol survives one unusable slot by design, and a file truncated
89+
/// mid-slot is exactly that case: the surviving copy must still open the
90+
/// database. `buf` keeps whatever prefix was transferred, so the caller's
91+
/// ordinary decode rejects it on the magic or HK-MAC check like any other
92+
/// damaged slot. Every other backend error propagates unchanged — only
93+
/// end-of-file is absence.
94+
pub(crate) async fn read_header_slot<F: VfsFile + ?Sized>(
95+
file: &mut F,
96+
offset: u64,
97+
buf: &mut [u8],
98+
) -> Result<()> {
99+
match read_exact_at(file, offset, buf).await {
100+
Err(PagedbError::Io(ref error)) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
101+
Ok(())
102+
}
103+
other => other,
104+
}
105+
}
106+
85107
/// Reads both slots; verifies each via HK-MAC; picks the one with the
86108
/// greater `seq`. If only one verifies, it wins. If neither verifies, returns
87109
/// `Corruption(HeaderUnverifiable)` — unrecoverable from inside the header
@@ -95,10 +117,10 @@ pub async fn open_header<V: Vfs>(
95117
let mut f = vfs.open(path, OpenMode::ReadWrite).await?;
96118
let mut buf_a = vec![0u8; page_size];
97119
let mut buf_b = vec![0u8; page_size];
98-
read_exact_at(&mut f, 0, &mut buf_a).await?;
120+
read_header_slot(&mut f, 0, &mut buf_a).await?;
99121
let page_size_u64 = u64::try_from(page_size)
100122
.map_err(|_| PagedbError::Io(std::io::Error::other("page_size > u64")))?;
101-
read_exact_at(&mut f, page_size_u64, &mut buf_b).await?;
123+
read_header_slot(&mut f, page_size_u64, &mut buf_b).await?;
102124
let a = decode_main_db_header(&buf_a, hk, page_size).ok();
103125
let b = decode_main_db_header(&buf_b, hk, page_size).ok();
104126
match (a, b) {

src/txn/db/open/existing.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ use crate::crypto::{CipherId, SecretKey};
1010
use crate::errors::PagedbError;
1111
use crate::options::OpenOptions;
1212
use crate::pager::header::ActiveSlot;
13+
use crate::pager::header::read_header_slot;
1314
use crate::pager::structural_header::MainDbHeaderFields;
1415
use crate::pager::{Pager, PagerConfig};
15-
use crate::vfs::{Vfs, read_exact_at};
16+
use crate::vfs::Vfs;
1617
use crate::{RealmId, Result};
1718

1819
use super::super::super::mode::DbMode;
@@ -107,10 +108,10 @@ impl<V: Vfs + Clone> Db<V> {
107108
let mut f = vfs.open(&main_db_path, file_mode).await?;
108109
let mut buf_a = vec![0u8; page_size];
109110
let mut buf_b = vec![0u8; page_size];
110-
read_exact_at(&mut f, 0, &mut buf_a).await?;
111+
read_header_slot(&mut f, 0, &mut buf_a).await?;
111112
let page_size_u64 = u64::try_from(page_size)
112113
.map_err(|_| PagedbError::Io(std::io::Error::other("page_size > u64")))?;
113-
read_exact_at(&mut f, page_size_u64, &mut buf_b).await?;
114+
read_header_slot(&mut f, page_size_u64, &mut buf_b).await?;
114115
drop(f);
115116

116117
let try_decode = |buf: &[u8]| -> Option<(MainDbHeaderFields, bool)> {

src/txn/db/util.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
use crate::Result;
55
use crate::crypto::kdf::{derive_hk, derive_mk};
66
use crate::errors::PagedbError;
7-
use crate::vfs::{Vfs, read_exact_at};
7+
use crate::pager::header::read_header_slot;
8+
use crate::vfs::Vfs;
89

910
pub(super) fn page_size_log2(page_size: usize) -> Result<u8> {
1011
match page_size {
@@ -42,10 +43,10 @@ pub(super) async fn peek_restore_mode<V: Vfs + Clone>(
4243
let mut f = vfs.open("/main.db", OpenMode::Read).await?;
4344
let mut buf_a = vec![0u8; page_size];
4445
let mut buf_b = vec![0u8; page_size];
45-
read_exact_at(&mut f, 0, &mut buf_a).await?;
46+
read_header_slot(&mut f, 0, &mut buf_a).await?;
4647
let page_size_u64 = u64::try_from(page_size)
4748
.map_err(|_| PagedbError::Io(std::io::Error::other("page_size > u64")))?;
48-
read_exact_at(&mut f, page_size_u64, &mut buf_b).await?;
49+
read_header_slot(&mut f, page_size_u64, &mut buf_b).await?;
4950
drop(f);
5051

5152
for buf in [&buf_a, &buf_b] {

tests/header.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,55 @@ async fn open_picks_higher_seq_when_both_verify() {
259259
assert_eq!(slot, ActiveSlot::B);
260260
assert_eq!(got.counter_anchor, 9000);
261261
}
262+
263+
/// A file truncated mid-slot must still open on the surviving copy.
264+
///
265+
/// The A/B protocol exists so that one unusable slot is survivable. Reading a
266+
/// slot short is the same condition as a slot that fails its HK-MAC: absent,
267+
/// not fatal. Rejecting the open outright would throw away the intact header
268+
/// sitting in the other slot.
269+
#[tokio::test(flavor = "current_thread")]
270+
async fn open_survives_a_file_truncated_inside_the_inactive_slot() {
271+
let vfs = MemVfs::new();
272+
let hk = hk();
273+
bootstrap_header(&vfs, "/main.db", &hk, &sample(1, 12, 4242), 4096)
274+
.await
275+
.unwrap();
276+
277+
// Slot A is intact; slot B is cut in half.
278+
let mut f = vfs.open("/main.db", OpenMode::ReadWrite).await.unwrap();
279+
f.truncate(4096 + 2048).await.unwrap();
280+
f.sync().await.unwrap();
281+
drop(f);
282+
283+
let (got, slot) = open_header(&vfs, "/main.db", &hk, 4096)
284+
.await
285+
.expect("the intact slot must still open the database");
286+
assert_eq!(slot, ActiveSlot::A);
287+
assert_eq!(got.seq, 1);
288+
assert_eq!(got.counter_anchor, 4242);
289+
}
290+
291+
/// With neither slot readable, the header layer still reports corruption
292+
/// rather than leaking a raw end-of-file error.
293+
#[tokio::test(flavor = "current_thread")]
294+
async fn open_reports_corruption_when_truncation_takes_both_slots() {
295+
let vfs = MemVfs::new();
296+
let hk = hk();
297+
bootstrap_header(&vfs, "/main.db", &hk, &sample(1, 12, 0), 4096)
298+
.await
299+
.unwrap();
300+
301+
let mut f = vfs.open("/main.db", OpenMode::ReadWrite).await.unwrap();
302+
f.truncate(0).await.unwrap();
303+
f.sync().await.unwrap();
304+
drop(f);
305+
306+
let err = open_header(&vfs, "/main.db", &hk, 4096)
307+
.await
308+
.expect_err("a file with no readable slot must not open");
309+
assert!(
310+
matches!(err, PagedbError::Corruption(_)),
311+
"expected Corruption, got {err:?}"
312+
);
313+
}

0 commit comments

Comments
 (0)