diff --git a/rust/revault_lockbox_api/Cargo.toml b/rust/revault_lockbox_api/Cargo.toml
index 6eb12b4..c10b30e 100644
--- a/rust/revault_lockbox_api/Cargo.toml
+++ b/rust/revault_lockbox_api/Cargo.toml
@@ -14,6 +14,7 @@ name = "revault_lockbox_api"
path = "src/lib.rs"
[features]
+range-experiment = []
default = ["issue303-protocol"]
vault-integration = []
bindings = []
diff --git a/rust/revault_lockbox_api/src/lib.rs b/rust/revault_lockbox_api/src/lib.rs
index 9d5007b..041553a 100644
--- a/rust/revault_lockbox_api/src/lib.rs
+++ b/rust/revault_lockbox_api/src/lib.rs
@@ -53,6 +53,8 @@ mod paths;
mod scan;
mod security;
mod storage;
+#[cfg(feature = "range-experiment")]
+pub use storage::sparse::SparseArchive;
mod toc;
#[cfg(feature = "vault-integration")]
pub mod vault_integration;
diff --git a/rust/revault_lockbox_api/src/lockbox/key_management.rs b/rust/revault_lockbox_api/src/lockbox/key_management.rs
index 5ccf917..113cc69 100644
--- a/rust/revault_lockbox_api/src/lockbox/key_management.rs
+++ b/rust/revault_lockbox_api/src/lockbox/key_management.rs
@@ -649,6 +649,13 @@ impl Lockbox {
)
}
+ /// Experimental read-only sparse storage; caller must satisfy missing ranges
+ /// and retry before accepting even a successful result.
+ #[cfg(feature = "range-experiment")]
+ pub fn open_sparse_unencrypted(source: crate::SparseArchive) -> Result<Lockbox<ReadOnly>> {
+ Ok(Self::open_unencrypted_storage(StorageBackend::Sparse(source), false)?.into_state())
+ }
+
fn attach_signing_choice(&mut self, signing: crate::Signing<'_>) -> Result<()> {
match signing {
crate::Signing::None if self.format_mode.signed() => Err(Error::InvalidInput(
diff --git a/rust/revault_lockbox_api/src/storage/mod.rs b/rust/revault_lockbox_api/src/storage/mod.rs
index a717fbe..29bb86f 100644
--- a/rust/revault_lockbox_api/src/storage/mod.rs
+++ b/rust/revault_lockbox_api/src/storage/mod.rs
@@ -5,6 +5,8 @@ pub(crate) mod file_lock;
pub(crate) mod free_index;
pub(crate) mod free_slot;
pub(crate) mod page_cache;
+#[cfg(feature = "range-experiment")]
+pub mod sparse;
use crate::secret_vec::SecureVec;
use crate::{Error, Result};
@@ -45,6 +47,8 @@ pub(crate) trait Storage: Clone + std::fmt::Debug {
pub(crate) enum StorageBackend {
Memory(MemoryStore),
File(FileStore),
+ #[cfg(feature = "range-experiment")]
+ Sparse(sparse::SparseArchive),
}
impl StorageBackend {
@@ -97,6 +101,8 @@ impl StorageBackend {
}
pub(crate) fn is_read_only(&self) -> bool {
+ #[cfg(feature = "range-experiment")]
+ if matches!(self, Self::Sparse(_)) { return true; }
matches!(self, Self::File(store) if !store.writable)
}
@@ -145,6 +151,8 @@ impl StorageBackend {
pub(crate) fn path(&self) -> Option<&Path> {
match self {
Self::Memory(_) => None,
+ #[cfg(feature = "range-experiment")]
+ Self::Sparse(_) => None,
Self::File(store) => Some(store.path()),
}
}
@@ -154,6 +162,8 @@ impl Storage for StorageBackend {
fn len(&self) -> Result<u64> {
match self {
Self::Memory(store) => store.len(),
+ #[cfg(feature = "range-experiment")]
+ Self::Sparse(store) => store.len(),
Self::File(store) => store.len(),
}
}
@@ -161,6 +171,8 @@ impl Storage for StorageBackend {
fn read_at(&self, offset: u64, len: usize) -> Result<Vec<u8>> {
match self {
Self::Memory(store) => store.read_at(offset, len),
+ #[cfg(feature = "range-experiment")]
+ Self::Sparse(store) => store.read_at(offset, len),
Self::File(store) => store.read_at(offset, len),
}
}
@@ -168,6 +180,8 @@ impl Storage for StorageBackend {
fn read_at_into(&self, offset: u64, out: &mut [u8]) -> Result<()> {
match self {
Self::Memory(store) => store.read_at_into(offset, out),
+ #[cfg(feature = "range-experiment")]
+ Self::Sparse(store) => store.read_at_into(offset, out),
Self::File(store) => store.read_at_into(offset, out),
}
}
@@ -175,6 +189,8 @@ impl Storage for StorageBackend {
fn append(&mut self, bytes: &[u8]) -> Result<u64> {
match self {
Self::Memory(store) => store.append(bytes),
+ #[cfg(feature = "range-experiment")]
+ Self::Sparse(_) => Err(Error::Io("sparse archive is read-only".into())),
Self::File(store) => store.append(bytes),
}
}
@@ -182,6 +198,8 @@ impl Storage for StorageBackend {
fn write_at(&mut self, offset: u64, bytes: &[u8]) -> Result<()> {
match self {
Self::Memory(store) => store.write_at(offset, bytes),
+ #[cfg(feature = "range-experiment")]
+ Self::Sparse(_) => Err(Error::Io("sparse archive is read-only".into())),
Self::File(store) => store.write_at(offset, bytes),
}
}
@@ -189,6 +207,8 @@ impl Storage for StorageBackend {
fn sync(&self) -> Result<()> {
match self {
Self::Memory(store) => store.sync(),
+ #[cfg(feature = "range-experiment")]
+ Self::Sparse(_) => Ok(()),
Self::File(store) => store.sync(),
}
}
@@ -392,42 +412,42 @@ impl StorageBackend {
pub(crate) fn fail_memory_operation_after_successes(&mut self, successes: usize) {
match self {
Self::Memory(store) => store.fail_operation_after_successes(successes),
- Self::File(_) => panic!("failure injection is only available for memory storage"),
+ _ => panic!("failure injection is only available for memory storage"),
}
}
pub(crate) fn memory_operation_count(&self) -> usize {
match self {
Self::Memory(store) => store.operation_count(),
- Self::File(_) => panic!("operation counting is only available for memory storage"),
+ _ => panic!("operation counting is only available for memory storage"),
}
}
pub(crate) fn reset_memory_operation_count(&self) {
match self {
Self::Memory(store) => store.reset_operation_count(),
- Self::File(_) => panic!("operation counting is only available for memory storage"),
+ _ => panic!("operation counting is only available for memory storage"),
}
}
pub(crate) fn fail_memory_append_after_successes(&mut self, successes: usize) {
match self {
Self::Memory(store) => store.fail_append_after_successes(successes),
- Self::File(_) => panic!("failure injection is only available for memory storage"),
+ _ => panic!("failure injection is only available for memory storage"),
}
}
pub(crate) fn fail_memory_next_write_at(&mut self, offset: u64) {
match self {
Self::Memory(store) => store.fail_next_write_at(offset),
- Self::File(_) => panic!("failure injection is only available for memory storage"),
+ _ => panic!("failure injection is only available for memory storage"),
}
}
pub(crate) fn fail_memory_sync_after_successes(&mut self, successes: usize) {
match self {
Self::Memory(store) => store.fail_sync_after_successes(successes),
- Self::File(_) => panic!("failure injection is only available for memory storage"),
+ _ => panic!("failure injection is only available for memory storage"),
}
}
}
@@ -437,7 +457,7 @@ impl StorageBackend {
pub(crate) fn inject_test_write_failure_at(&mut self, offset: u64) {
match self {
Self::Memory(store) => store.fail_next_write_at(offset),
- Self::File(_) => panic!("test write failure is only available for memory storage"),
+ _ => panic!("test write failure is only available for memory storage"),
}
}
}
diff --git a/rust/revault_lockbox_api/src/storage/sparse.rs b/rust/revault_lockbox_api/src/storage/sparse.rs
new file mode 100644
index 0000000..3be54f1
--- /dev/null
+++ b/rust/revault_lockbox_api/src/storage/sparse.rs
@@ -0,0 +1,105 @@
+//! Experiment only: immutable sparse bytes, never fabricated zero-filled holes.
+use super::Storage;
+use crate::{Error, Result};
+use std::collections::BTreeMap;
+use std::sync::{Arc, Mutex};
+
+#[derive(Clone, Debug)]
+/// Shared read-only archive bytes supplied incrementally by an external reader.
+pub struct SparseArchive {
+ length: u64,
+ state: Arc<Mutex<State>>,
+}
+#[derive(Debug, Default)]
+struct State {
+ segments: BTreeMap<u64, Vec<u8>>,
+ missing: Option<(u64, usize)>,
+}
+impl SparseArchive {
+ /// Creates an empty cache with the archive's immutable logical length.
+ pub fn new(length: u64) -> Self {
+ Self {
+ length,
+ state: Arc::new(Mutex::new(State::default())),
+ }
+ }
+ /// Supplies an immutable, non-overlapping range (exact repeats are allowed).
+ pub fn supply(&self, offset: u64, bytes: Vec<u8>) -> Result<()> {
+ let end = offset
+ .checked_add(bytes.len() as u64)
+ .ok_or(Error::Truncated)?;
+ if end > self.length || bytes.is_empty() {
+ return Err(Error::Truncated);
+ }
+ let mut state = self.state.lock().unwrap();
+ // Never replace previously supplied bytes with a different revision.
+ for (&start, previous) in &state.segments {
+ let left = offset.max(start);
+ let right = end.min(start + previous.len() as u64);
+ if left < right {
+ if start == offset && *previous == bytes {
+ return Ok(());
+ }
+ return Err(Error::Io("overlapping sparse archive ranges".into()));
+ }
+ }
+ state.segments.insert(offset, bytes);
+ Ok(())
+ }
+ /// Clears the missing-range signal before beginning a synchronous read.
+ pub fn clear_missing(&self) {
+ self.state.lock().unwrap().missing = None;
+ }
+ /// Returns the first unsatisfied read; inspect even after a successful open.
+ pub fn missing(&self) -> Option<(u64, usize)> {
+ self.state.lock().unwrap().missing
+ }
+}
+impl Storage for SparseArchive {
+ fn len(&self) -> Result<u64> {
+ Ok(self.length)
+ }
+ fn read_at(&self, offset: u64, len: usize) -> Result<Vec<u8>> {
+ let mut bytes = vec![0; len];
+ self.read_at_into(offset, &mut bytes)?;
+ Ok(bytes)
+ }
+ fn read_at_into(&self, offset: u64, out: &mut [u8]) -> Result<()> {
+ let end = offset
+ .checked_add(out.len() as u64)
+ .ok_or(Error::Truncated)?;
+ if end > self.length {
+ return Err(Error::Truncated);
+ }
+ let mut state = self.state.lock().unwrap();
+ let mut position = offset;
+ while position < end {
+ if let Some((&start, bytes)) = state.segments.range(..=position).next_back() {
+ let available_end = start + bytes.len() as u64;
+ if available_end > position {
+ let take_end = end.min(available_end);
+ out[(position - offset) as usize..(take_end - offset) as usize]
+ .copy_from_slice(
+ &bytes[(position - start) as usize..(take_end - start) as usize],
+ );
+ position = take_end;
+ continue;
+ }
+ }
+ if state.missing.is_none() {
+ state.missing = Some((position, (end - position) as usize));
+ }
+ return Err(Error::Io("range not loaded".into()));
+ }
+ Ok(())
+ }
+ fn append(&mut self, _: &[u8]) -> Result<u64> {
+ Err(Error::Io("read-only".into()))
+ }
+ fn write_at(&mut self, _: u64, _: &[u8]) -> Result<()> {
+ Err(Error::Io("read-only".into()))
+ }
+ fn sync(&self) -> Result<()> {
+ Ok(())
+ }
+}
Request
Add a supported read-only random-access/sparse backing-store API so reVault can read a remote lockbox using HTTP byte ranges, particularly from browser WASM, without downloading or extracting the whole archive first.
This is a feature proposal with a working prototype, not a request to merge the experimental patch unchanged. Related: #311 concerns cold-read performance; this proposal concerns supplying bytes from an external range source.
Working experiment
A native documentation generator creates unencrypted, unsigned lockboxes containing gzip-compressed JSON bundles and viewer assets. A browser service worker uses reVault compiled to WASM to read those files. The server serves only raw archive bytes; it does not parse reVault.
The prototype adds a feature-gated
SparseArchivestorage variant and a read-onlyopen_sparse_unencryptedentrypoint. The cache contains only supplied segments, not a zero-filled full-size archive. A synchronous core read reports missing bytes; JavaScript fetches them asynchronously and retries. Existing format, page decoders and cryptography are unchanged.The base revision is
301c34efad3f5ef3ae5e34ea47bfa8000e0c619c. The full minimal core patch below has paths normalized for the reVault repository. WASM adapter and native regression tests follow, so this issue does not depend on unpublished branches or temporary files.HTTP/client contract used
Accept-Ranges: bytes, and a strong ETag.RangeandIf-Match. Require exact HTTP 206, matching Content-Range/total length, unchanged ETag, exact body length and no HTTP content encoding.Results
Same archive files, viewer and WASM artifact for full/range modes. First library view, excluding shared WASM:
Opening metadata itself needed 92,480 B / 85,312 B. With runtime already loaded, a server model of 25 ms per request plus aggregate 10 Mbit/s response-body bandwidth gave median first-page times (3 runs): dcli full 683 ms / range 623 ms; hugeicons full 5,033 ms / range 1,102 ms. This is a modeled connection, not production/WAN measurement. Local cold hugeicons was slower with ranges (188 vs 246 ms); extra round trips matter.
48 browser loads covered two packages, full/range, cold/already-loaded runtime, local/modeled network, three repetitions. All 16 dcli + 49 hugeicons files read through WASM matched directory counterparts by SHA-256. Search, navigation, lazy source, mobile drawer, reload/no repeated downloads and missing-file behavior passed. Changed ETag/412, ignored Range/200, wrong Content-Range and truncated-body cases were rejected. Two native tests below passed. After source/search, dcli had eventually fetched all bytes; hugeicons had fetched 937,280 B. No generation-speed claim. Browsing every file eventually caches the entire archive.
Suggested upstream design/acceptance
Prototype core patch
Apply against the base revision, then review/adapt to current upstream:
Complete core patch
WASM adapter
Build as cdylib with wasm-bindgen 0.2.121, reVault core feature
range-experiment, and revault_page_api; wasm32-unknown-unknown, wasm-bindgen --target web.Native regression tests
Complete client implementation from the experiment
Service worker (HTTP transport and sparse-read retries)