Skip to content

Commit 230e95d

Browse files
committed
feat(db): enforce mode-aware opens and durable identities
1 parent b87672e commit 230e95d

27 files changed

Lines changed: 1478 additions & 915 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,4 @@ dmypy.json
9595
.gradle/
9696

9797
.cargo/
98-
98+
.pi

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ bytes = "1"
3131
tracing = "0.1"
3232
parking_lot = "0.12"
3333
rayon = "1"
34+
getrandom = { version = "0.2", features = ["js"] }
3435

3536
# tokio's `rt-multi-thread` and `fs` features are not supported on
3637
# `wasm32-unknown-unknown`. Split per target so OPFS builds compile.
@@ -67,7 +68,6 @@ lz4_flex = { version = "0.13", optional = true, default-features = false, featur
6768
] }
6869

6970
[target.'cfg(target_arch = "wasm32")'.dependencies]
70-
getrandom = { version = "0.2", features = ["js"] }
7171
wasm-bindgen = { version = "0.2", optional = true }
7272
wasm-bindgen-futures = { version = "0.4", optional = true }
7373
js-sys = { version = "0.3", optional = true }

src/compaction/full.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ async fn compact_now_inner<V: Vfs + Clone>(db: &Db<V>) -> Result<CompactStats> {
106106
)
107107
.await?;
108108
db.vfs.mkdir_all("seg/.staging").await?;
109-
let new_segment_id = db.next_segment_id();
109+
let new_segment_id = crate::crypto::random::segment_id()?;
110110
let mut writer = SegmentWriter::create_internal(
111111
db.pager.clone(),
112112
meta.realm_id,

src/crypto/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod kdf;
66
pub mod key_manager;
77
pub mod keys;
88
pub mod nonce;
9+
pub(crate) mod random;
910

1011
pub use aad::{Aad, AadFields};
1112
pub use cipher::{Cipher, CipherId};

src/crypto/random/identity.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//! Purpose-specific CSPRNG helpers for nonce-space partitioning identities.
2+
3+
use crate::Result;
4+
5+
fn random_bytes() -> Result<[u8; 16]> {
6+
let mut bytes = [0u8; 16];
7+
getrandom::getrandom(&mut bytes)?;
8+
Ok(bytes)
9+
}
10+
11+
fn random_nonzero_identity() -> Result<[u8; 16]> {
12+
loop {
13+
let bytes = random_bytes()?;
14+
if bytes != [0u8; 16] {
15+
return Ok(bytes);
16+
}
17+
}
18+
}
19+
20+
pub(crate) fn database_identity() -> Result<([u8; 16], [u8; 16])> {
21+
Ok((random_nonzero_identity()?, random_bytes()?))
22+
}
23+
24+
pub(crate) fn segment_id() -> Result<[u8; 16]> {
25+
random_nonzero_identity()
26+
}
27+
28+
pub(crate) fn journal_id() -> Result<[u8; 16]> {
29+
random_nonzero_identity()
30+
}
31+
32+
#[cfg(test)]
33+
mod tests {
34+
use super::*;
35+
36+
#[test]
37+
fn purpose_specific_identities_reserve_zero() {
38+
assert_ne!(database_identity().unwrap().0, [0u8; 16]);
39+
assert_ne!(segment_id().unwrap(), [0u8; 16]);
40+
assert_ne!(journal_id().unwrap(), [0u8; 16]);
41+
}
42+
}

src/crypto/random/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
//! Cryptographically secure generation of persistent identities and salts.
2+
3+
mod identity;
4+
5+
pub(crate) use identity::{database_identity, journal_id, segment_id};

src/errors.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ pub enum PagedbError {
2626
#[error("nonce counter exhausted (per-file 2^48 limit reached); rekey required")]
2727
NonceCounterExhausted,
2828

29+
#[error("arithmetic overflow while computing {operation}")]
30+
ArithmeticOverflow { operation: &'static str },
31+
2932
#[error("read-only handle")]
3033
ReadOnly,
3134

@@ -117,6 +120,9 @@ pub enum PagedbError {
117120
#[error("unsupported by backend")]
118121
Unsupported,
119122

123+
#[error("cryptographically secure randomness unavailable: {0}")]
124+
Randomness(#[from] getrandom::Error),
125+
120126
#[error("io: {0}")]
121127
Io(#[from] std::io::Error),
122128
}
@@ -196,6 +202,12 @@ impl PagedbError {
196202
Self::Corruption(detail)
197203
}
198204

205+
/// Canonical constructor for arithmetic-overflow errors.
206+
#[must_use]
207+
pub const fn arithmetic_overflow(operation: &'static str) -> Self {
208+
Self::ArithmeticOverflow { operation }
209+
}
210+
199211
/// Canonical constructor for deferred-free backlog errors.
200212
#[must_use]
201213
pub fn deferred_free_backlog(pages_pending: u64, oldest_pinning_commit: u64) -> Self {

src/pager/core.rs

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@
44
55
use std::collections::BTreeMap;
66
use std::sync::Arc;
7-
use std::sync::atomic::{AtomicU64, Ordering as AtomOrd};
7+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomOrd};
88

99
use bytes::Bytes;
1010
use tokio::sync::Mutex as AsyncMutex;
11-
use tracing;
1211

1312
use crate::crypto::aad::{AadFields, MAIN_DB_SEGMENT_ID};
1413
use crate::crypto::key_manager::DekLru;
@@ -41,7 +40,7 @@ pub struct PagerConfig {
4140
pub anchor_budget: u64,
4241
pub dek_lru_capacity: usize,
4342
/// Number of AEAD-verification retries on a cache miss before surfacing
44-
/// a `ChecksumFailure`. Set to > 0 only in Observer mode to absorb torn
43+
/// a `ChecksumFailure`. Set to > 0 only in `Observer` mode to absorb torn
4544
/// reads; all other modes keep this at 0 so that AEAD failures remain
4645
/// hard corruption signals.
4746
pub observer_retry_count: u32,
@@ -162,6 +161,7 @@ pub struct Pager<V: Vfs> {
162161
/// in the cache. Reads use per-page epoch routing; writes always use this
163162
/// atomic to pick the DEK.
164163
active_epoch: AtomicU64,
164+
read_only: AtomicBool,
165165
files: AsyncMutex<BTreeMap<FileKey, Arc<AsyncMutex<V::File>>>>,
166166
dek_lru: parking_lot::Mutex<DekLru>,
167167
main_nonce: parking_lot::Mutex<MainDbNonceGen>,
@@ -172,7 +172,7 @@ pub struct Pager<V: Vfs> {
172172
journal_nonces: parking_lot::Mutex<BTreeMap<[u8; 16], SegmentNonceGen>>,
173173
pub(crate) inner: Arc<PagerInner>,
174174
/// Retries on AEAD failure before surfacing `ChecksumFailure`. Non-zero
175-
/// only in Observer mode to absorb torn reads from a concurrent writer.
175+
/// only in `Observer` mode to absorb torn reads from a concurrent writer.
176176
observer_retry_count: u32,
177177
}
178178

@@ -236,13 +236,28 @@ impl<V: Vfs> Pager<V> {
236236
files: AsyncMutex::new(BTreeMap::new()),
237237
inner,
238238
active_epoch: AtomicU64::new(initial_epoch),
239+
read_only: AtomicBool::new(false),
239240
mk: parking_lot::RwLock::new(mk),
240241
observer_retry_count,
241242
vfs,
242243
cfg,
243244
})
244245
}
245246

247+
/// Restrict all lazily-opened persistent files to read access and reject
248+
/// pager flushes. This only changes in-memory state.
249+
pub(crate) fn set_read_only(&self) {
250+
self.read_only.store(true, AtomOrd::SeqCst);
251+
}
252+
253+
/// Enable persistent writes after a frozen read-only handle transitions
254+
/// to Follower mode. Cached read-only file handles are discarded so later
255+
/// pager operations reopen them with read/write access.
256+
pub(crate) async fn enable_write_access(&self) {
257+
self.read_only.store(false, AtomOrd::SeqCst);
258+
self.files.lock().await.clear();
259+
}
260+
246261
/// Atomically advance the active epoch used for flush (write) operations.
247262
/// Also installs a new master key. Called by `Db::rekey_db` immediately
248263
/// before the final page flush so all dirty pages are re-sealed under the
@@ -602,14 +617,22 @@ impl<V: Vfs> Pager<V> {
602617
// mk_epoch before constructing AAD and selecting the DEK.
603618
self.inner.record_miss(file);
604619
let page_size = self.cfg.page_size;
620+
let page_size_u64 =
621+
u64::try_from(page_size).map_err(|_| PagedbError::arithmetic_overflow("page size"))?;
622+
let page_offset = page_id
623+
.checked_mul(page_size_u64)
624+
.ok_or_else(|| PagedbError::arithmetic_overflow("page read offset"))?;
605625
let file_handle = self.open_file_handle(file).await?;
606626

607627
// Observer-mode retry loop: on AEAD failure retry up to
608628
// `observer_retry_count` times (10 ms backoff) to absorb torn reads
609629
// from a concurrent writer. In non-observer mode (retry_count == 0)
610630
// the loop body executes exactly once and any AEAD failure is a hard
611631
// corruption signal.
612-
let max_attempts = self.observer_retry_count + 1;
632+
let max_attempts = self
633+
.observer_retry_count
634+
.checked_add(1)
635+
.ok_or_else(|| PagedbError::arithmetic_overflow("observer retry attempts"))?;
613636
let mut last_err: Option<PagedbError> = None;
614637
for attempt in 0..max_attempts {
615638
if attempt > 0 {
@@ -618,7 +641,7 @@ impl<V: Vfs> Pager<V> {
618641
let mut buf = vec![0u8; page_size];
619642
{
620643
let f = file_handle.lock().await;
621-
let n = f.read_at(page_id * page_size as u64, &mut buf).await?;
644+
let n = f.read_at(page_offset, &mut buf).await?;
622645
if n < page_size {
623646
for b in &mut buf[n..] {
624647
*b = 0;
@@ -690,12 +713,17 @@ impl<V: Vfs> Pager<V> {
690713
segment_id: [u8; 16],
691714
dest_path: Option<&str>,
692715
) -> Result<()> {
716+
if self.read_only.load(AtomOrd::SeqCst) {
717+
return Err(PagedbError::ReadOnly);
718+
}
693719
let dirty_ids = self.inner.cache_for_key(file).lock().dirty_for_file(file);
694720
if dirty_ids.is_empty() {
695721
return Ok(());
696722
}
697723

698724
let page_size = self.cfg.page_size;
725+
let page_size_u64 =
726+
u64::try_from(page_size).map_err(|_| PagedbError::arithmetic_overflow("page size"))?;
699727
let flush_epoch = self.active_epoch.load(AtomOrd::SeqCst);
700728

701729
// Serial gather: snapshot each dirty page's plaintext + kind under the
@@ -723,6 +751,15 @@ impl<V: Vfs> Pager<V> {
723751
prepared.push((*pid, kind, wire));
724752
}
725753

754+
// Validate every physical offset before consuming any nonce.
755+
let offsets: Vec<u64> = prepared
756+
.iter()
757+
.map(|(pid, _, _)| {
758+
pid.checked_mul(page_size_u64)
759+
.ok_or_else(|| PagedbError::arithmetic_overflow("page write offset"))
760+
})
761+
.collect::<Result<_>>()?;
762+
726763
// Pre-allocate a nonce per page (counter increments — single-threaded
727764
// by design; cheap).
728765
let mut nonces: Vec<Nonce> = Vec::with_capacity(prepared.len());
@@ -773,11 +810,8 @@ impl<V: Vfs> Pager<V> {
773810

774811
// Issue physical-id-order vectored writes.
775812
let mut reqs: Vec<WriteReq<'_>> = Vec::with_capacity(prepared.len());
776-
for (pid, _kind, wire) in &prepared {
777-
reqs.push(WriteReq {
778-
offset: *pid * page_size as u64,
779-
buf: wire,
780-
});
813+
for ((_, _kind, wire), offset) in prepared.iter().zip(offsets) {
814+
reqs.push(WriteReq { offset, buf: wire });
781815
}
782816
if let Some(path) = dest_path {
783817
// Alternate destination (compaction's compacted copy): open it
@@ -801,21 +835,33 @@ impl<V: Vfs> Pager<V> {
801835
}
802836

803837
async fn open_file_handle(&self, file: FileKey) -> Result<Arc<AsyncMutex<V::File>>> {
804-
let mut files = self.files.lock().await;
805-
if let Some(h) = files.get(&file) {
806-
return Ok(h.clone());
838+
let cached = {
839+
let files = self.files.lock().await;
840+
files.get(&file).cloned()
841+
};
842+
if let Some(handle) = cached {
843+
return Ok(handle);
807844
}
845+
808846
let path = match file {
809847
FileKey::Main => self.cfg.main_db_path.clone(),
810848
FileKey::Segment(id) => format!("seg/{}", crate::hex::to_hex_lower(&id)),
811849
FileKey::ApplyJournal(id) => {
812850
format!("applyjournal/{}", crate::hex::to_hex_lower(&id))
813851
}
814852
};
815-
let f = self.vfs.open(&path, OpenMode::CreateOrOpen).await?;
816-
let arc = Arc::new(AsyncMutex::new(f));
817-
files.insert(file, arc.clone());
818-
Ok(arc)
853+
let mode = if self.read_only.load(AtomOrd::SeqCst) {
854+
OpenMode::Read
855+
} else {
856+
OpenMode::CreateOrOpen
857+
};
858+
let opened = Arc::new(AsyncMutex::new(self.vfs.open(&path, mode).await?));
859+
let mut files = self.files.lock().await;
860+
if let Some(handle) = files.get(&file) {
861+
return Ok(handle.clone());
862+
}
863+
files.insert(file, opened.clone());
864+
Ok(opened)
819865
}
820866

821867
fn next_nonce_for_flush(&self, file: FileKey) -> Result<Nonce> {

src/recovery/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ pub use deep_walk::{DeepWalkReport, run_deep_walk};
1010
pub use journal::{
1111
ApplyJournalRecord, JournalAction, execute_journal_actions, replay_apply_journal,
1212
};
13-
pub use reconcile::reconcile_catalog;
13+
pub use reconcile::{repair_catalog, verify_catalog};

0 commit comments

Comments
 (0)