use std::collections::HashMap;
use std::ops::Bound;
use std::time::Duration;
use crate::config::{FjallTuning, StoreConfig};
use crate::error::AppError;
use fjall::{KeyspaceCreateOptions, PersistMode, Readable};
use serde::Serialize;
use serde::de::DeserializeOwned;
use tracing::info;
pub mod counter;
#[cfg(feature = "encryption")]
pub(crate) mod encryption;
pub(crate) mod generation;
#[cfg(any(feature = "vsock-store", test))]
mod key_locks;
#[cfg(feature = "vsock-store")]
pub mod vsock;
#[cfg(any(feature = "vsock-store", test))]
mod vsock_pool;
#[cfg(any(test, feature = "test-support"))]
pub mod test_fixture;
const STORE_OP_TIMEOUT: Duration = Duration::from_secs(30);
async fn blocking_with_timeout<F, T>(f: F) -> Result<T, AppError>
where
F: FnOnce() -> Result<T, AppError> + Send + 'static,
T: Send + 'static,
{
match tokio::time::timeout(STORE_OP_TIMEOUT, tokio::task::spawn_blocking(f)).await {
Ok(Ok(result)) => result,
Ok(Err(e)) => Err(AppError::Internal(format!("blocking task panicked: {e}"))),
Err(_) => Err(AppError::Internal(format!(
"store operation timed out after {}s",
STORE_OP_TIMEOUT.as_secs()
))),
}
}
pub type RawKvPair = (Vec<u8>, Vec<u8>);
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ScanOpts {
pub prefix: Vec<u8>,
pub after: Option<Vec<u8>>,
pub limit: usize,
pub reverse: bool,
}
impl ScanOpts {
pub fn new(prefix: impl Into<Vec<u8>>, limit: usize) -> Self {
Self {
prefix: prefix.into(),
after: None,
limit,
reverse: false,
}
}
pub fn newest_first(prefix: impl Into<Vec<u8>>, limit: usize) -> Self {
Self {
reverse: true,
..Self::new(prefix, limit)
}
}
#[must_use]
pub fn after(mut self, key: impl Into<Vec<u8>>) -> Self {
self.after = Some(key.into());
self
}
}
pub(crate) fn scan_bounds(opts: &ScanOpts) -> (Bound<Vec<u8>>, Bound<Vec<u8>>) {
let mut lower = if opts.prefix.is_empty() {
Bound::Unbounded
} else {
Bound::Included(opts.prefix.clone())
};
let mut upper = prefix_upper_bound(&opts.prefix);
if let Some(after) = &opts.after {
if opts.reverse {
let within = match &upper {
Bound::Excluded(end) => after < end,
_ => true,
};
if within {
upper = Bound::Excluded(after.clone());
}
} else if after.as_slice() >= opts.prefix.as_slice() {
lower = Bound::Excluded(after.clone());
}
}
(lower, upper)
}
fn prefix_upper_bound(prefix: &[u8]) -> Bound<Vec<u8>> {
let mut end = prefix.to_vec();
while let Some(last) = end.pop() {
if last < u8::MAX {
end.push(last + 1);
return Bound::Excluded(end);
}
}
Bound::Unbounded
}
pub(crate) fn apply_scan_locally(mut pairs: Vec<RawKvPair>, opts: &ScanOpts) -> Vec<RawKvPair> {
let bounds = scan_bounds(opts);
pairs.retain(|(key, _)| key_in_bounds(key, &bounds));
pairs.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
if opts.reverse {
pairs.reverse();
}
pairs.truncate(opts.limit);
pairs
}
pub(crate) fn key_in_bounds(key: &[u8], bounds: &(Bound<Vec<u8>>, Bound<Vec<u8>>)) -> bool {
let (lower, upper) = bounds;
let above = match lower {
Bound::Included(bound) => key >= bound.as_slice(),
Bound::Excluded(bound) => key > bound.as_slice(),
Bound::Unbounded => true,
};
let below = match upper {
Bound::Included(bound) => key <= bound.as_slice(),
Bound::Excluded(bound) => key < bound.as_slice(),
Bound::Unbounded => true,
};
above && below
}
pub(crate) const OVERSIZED_RESPONSE_PREFIX: &str = "vsock response too large:";
pub(crate) fn is_oversized_response(error: &AppError) -> bool {
matches!(error, AppError::Internal(message) if message.starts_with(OVERSIZED_RESPONSE_PREFIX))
}
pub(crate) fn map_oversized_bulk_read(error: AppError) -> AppError {
if is_oversized_response(&error) {
AppError::Internal(
"bounded vsock scan is unavailable and the legacy response exceeds 16 MiB; \
upgrade the enclave proxy"
.into(),
)
} else {
error
}
}
pub struct ScanPager {
handle: KeyspaceHandle,
opts: ScanOpts,
cursor: Option<Vec<u8>>,
buffered: Option<std::vec::IntoIter<RawKvPair>>,
done: bool,
}
impl ScanPager {
fn new(handle: KeyspaceHandle, opts: ScanOpts) -> Self {
Self {
cursor: opts.after.clone(),
done: opts.limit == 0,
handle,
opts,
buffered: None,
}
}
pub async fn next_page(&mut self) -> Result<Vec<RawKvPair>, AppError> {
if self.done {
return Ok(Vec::new());
}
if self.buffered.is_some() {
return Ok(self.take_buffered_page());
}
if self.handle.has_native_scan().await {
let mut opts = self.opts.clone();
opts.after = self.cursor.clone();
let page = self.handle.scan_raw(opts).await?;
if page.len() < self.opts.limit {
self.done = true;
}
if let Some((key, _)) = page.last() {
self.cursor = Some(key.clone());
} else {
self.done = true;
}
return Ok(page);
}
self.buffer_remaining_range().await?;
Ok(self.take_buffered_page())
}
async fn buffer_remaining_range(&mut self) -> Result<(), AppError> {
let pairs = self
.handle
.prefix_iter_raw(self.opts.prefix.clone())
.await
.map_err(map_oversized_bulk_read)?;
let mut remaining = self.opts.clone();
remaining.after = self.cursor.clone();
remaining.limit = usize::MAX;
self.buffered = Some(apply_scan_locally(pairs, &remaining).into_iter());
Ok(())
}
fn take_buffered_page(&mut self) -> Vec<RawKvPair> {
let buffered = self.buffered.as_mut().expect("buffer filled");
let page: Vec<RawKvPair> = buffered.by_ref().take(self.opts.limit).collect();
if page.len() < self.opts.limit {
self.done = true;
}
if let Some((key, _)) = page.last() {
self.cursor = Some(key.clone());
}
page
}
}
const FJALL_VERSION_MARKER: &str = "version";
pub fn local_store_exists(data_dir: &std::path::Path) -> bool {
data_dir.join(FJALL_VERSION_MARKER).is_file()
}
#[derive(Clone)]
pub enum Store {
Local(LocalStore),
#[cfg(feature = "vsock-store")]
Vsock(vsock::VsockStore),
}
impl Store {
pub fn open(config: &StoreConfig) -> Result<Self, AppError> {
Self::open_with(config, &FjallTuning::default())
}
pub fn open_with(config: &StoreConfig, tuning: &FjallTuning) -> Result<Self, AppError> {
Ok(Store::Local(LocalStore::open_with(config, tuning)?))
}
#[cfg(feature = "vsock-store")]
pub async fn connect_vsock(port: Option<u32>) -> Result<Self, AppError> {
Ok(Store::Vsock(vsock::VsockStore::connect(port).await?))
}
pub fn keyspace(&self, name: &str) -> Result<KeyspaceHandle, AppError> {
match self {
Store::Local(s) => Ok(KeyspaceHandle::Local(s.keyspace(name)?)),
#[cfg(feature = "vsock-store")]
Store::Vsock(s) => Ok(KeyspaceHandle::Vsock(s.keyspace(name)?)),
}
}
pub async fn persist(&self) -> Result<(), AppError> {
match self {
Store::Local(s) => s.persist().await,
#[cfg(feature = "vsock-store")]
Store::Vsock(s) => s.persist().await,
}
}
}
#[derive(Clone)]
pub enum KeyspaceHandle {
Local(LocalKeyspaceHandle),
#[cfg(feature = "vsock-store")]
Vsock(vsock::VsockKeyspaceHandle),
}
impl KeyspaceHandle {
#[cfg(feature = "encryption")]
pub fn with_encryption(self, key: [u8; 32]) -> Self {
match self {
KeyspaceHandle::Local(h) => KeyspaceHandle::Local(h.with_encryption(key)),
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => KeyspaceHandle::Vsock(h.with_encryption(key)),
}
}
pub fn is_encrypted(&self) -> bool {
match self {
KeyspaceHandle::Local(h) => h.is_encrypted(),
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.is_encrypted(),
}
}
pub fn cache_ticket(&self) -> Option<u64> {
self.writes().ticket()
}
pub fn ticket_is_current(&self, ticket: u64) -> bool {
self.writes().ticket() == Some(ticket)
}
fn writes(&self) -> &generation::KeyspaceWrites {
match self {
KeyspaceHandle::Local(h) => &h.writes,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.writes(),
}
}
#[cfg(test)]
pub(crate) fn begin_local_write_for_test(&self) -> generation::WriteGuard {
match self {
KeyspaceHandle::Local(h) => h.writes.begin().known_on_drop(),
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(_) => unimplemented!("local-store tests only"),
}
}
#[cfg(test)]
pub(crate) async fn remove_untracked(&self, key: impl Into<Vec<u8>>) -> Result<(), AppError> {
match self {
KeyspaceHandle::Local(h) => {
let key = key.into();
let ks = h.keyspace.clone();
blocking_with_timeout(move || Ok(ks.remove(key)?)).await
}
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(_) => unimplemented!("local-store tests only"),
}
}
#[cfg(feature = "encryption")]
pub async fn migrate_to_encrypted(&self, key: [u8; 32]) -> Result<usize, AppError> {
if self.is_encrypted() {
return Err(AppError::Internal(
"migrate_to_encrypted must be called on a bare (unencrypted) keyspace handle"
.into(),
));
}
let rows = self.prefix_iter_raw(Vec::<u8>::new()).await?;
let encrypted = self.clone().with_encryption(key);
let mut migrated = 0usize;
for (k, v) in rows {
if encryption::is_v1_encrypted(&v) {
continue;
}
encrypted.insert_raw(k, v).await?;
migrated += 1;
}
Ok(migrated)
}
pub async fn persist(&self) -> Result<(), AppError> {
match self {
KeyspaceHandle::Local(h) => h.persist().await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.persist().await,
}
}
pub async fn insert<V: Serialize>(
&self,
key: impl Into<Vec<u8>>,
value: &V,
) -> Result<(), AppError> {
match self {
KeyspaceHandle::Local(h) => h.insert(key, value).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.insert(key, value).await,
}
}
pub async fn insert_if_absent<V: Serialize>(
&self,
key: impl Into<Vec<u8>>,
value: &V,
) -> Result<bool, AppError> {
match self {
KeyspaceHandle::Local(h) => h.insert_if_absent(key, value).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.insert_if_absent(key, value).await,
}
}
pub async fn insert_raw_if_absent(
&self,
key: impl Into<Vec<u8>>,
value: impl Into<Vec<u8>>,
) -> Result<bool, AppError> {
match self {
KeyspaceHandle::Local(h) => h.insert_raw_if_absent(key, value).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.insert_raw_if_absent(key, value).await,
}
}
pub async fn get<V: DeserializeOwned + Send + 'static>(
&self,
key: impl Into<Vec<u8>>,
) -> Result<Option<V>, AppError> {
match self {
KeyspaceHandle::Local(h) => h.get(key).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.get(key).await,
}
}
pub async fn remove(&self, key: impl Into<Vec<u8>>) -> Result<(), AppError> {
match self {
KeyspaceHandle::Local(h) => h.remove(key).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.remove(key).await,
}
}
pub async fn take_raw(&self, key: impl Into<Vec<u8>>) -> Result<Option<Vec<u8>>, AppError> {
match self {
KeyspaceHandle::Local(h) => h.take_raw(key).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.take_raw(key).await,
}
}
pub async fn insert_raw(
&self,
key: impl Into<Vec<u8>>,
value: impl Into<Vec<u8>>,
) -> Result<(), AppError> {
match self {
KeyspaceHandle::Local(h) => h.insert_raw(key, value).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.insert_raw(key, value).await,
}
}
pub async fn get_raw(&self, key: impl Into<Vec<u8>>) -> Result<Option<Vec<u8>>, AppError> {
match self {
KeyspaceHandle::Local(h) => h.get_raw(key).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.get_raw(key).await,
}
}
pub async fn prefix_iter_raw(
&self,
prefix: impl Into<Vec<u8>>,
) -> Result<Vec<RawKvPair>, AppError> {
match self {
KeyspaceHandle::Local(h) => h.prefix_iter_raw(prefix).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.prefix_iter_raw(prefix).await,
}
}
pub async fn range_from_raw(
&self,
from: impl Into<Vec<u8>>,
) -> Result<Vec<RawKvPair>, AppError> {
match self {
KeyspaceHandle::Local(h) => h.range_from_raw(from).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.range_from_raw(from).await,
}
}
pub async fn scan_raw(&self, opts: ScanOpts) -> Result<Vec<RawKvPair>, AppError> {
match self {
KeyspaceHandle::Local(h) => h.scan_raw(opts).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.scan_raw(opts).await,
}
}
pub fn scan_pager(&self, opts: ScanOpts) -> ScanPager {
ScanPager::new(self.clone(), opts)
}
async fn has_native_scan(&self) -> bool {
match self {
KeyspaceHandle::Local(_) => true,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.has_native_scan().await,
}
}
pub async fn prefix_keys(&self, prefix: impl Into<Vec<u8>>) -> Result<Vec<Vec<u8>>, AppError> {
match self {
KeyspaceHandle::Local(h) => h.prefix_keys(prefix).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.prefix_keys(prefix).await,
}
}
pub async fn approximate_len(&self) -> Result<usize, AppError> {
match self {
KeyspaceHandle::Local(h) => h.approximate_len().await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.approximate_len().await,
}
}
pub async fn swap<V: Serialize>(
&self,
old_key: impl Into<Vec<u8>>,
new_key: impl Into<Vec<u8>>,
value: &V,
) -> Result<bool, AppError> {
match self {
KeyspaceHandle::Local(h) => h.swap(old_key, new_key, value).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.swap(old_key, new_key, value).await,
}
}
pub async fn move_if_unchanged<V: Serialize>(
&self,
old_key: impl Into<Vec<u8>>,
expected: Vec<u8>,
new_key: impl Into<Vec<u8>>,
value: &V,
) -> Result<MoveOutcome, AppError> {
match self {
KeyspaceHandle::Local(h) => {
h.move_if_unchanged(old_key, expected, new_key, value).await
}
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => {
h.move_if_unchanged(old_key, expected, new_key, value).await
}
}
}
#[doc(hidden)]
pub fn journal_seqno(&self) -> Option<u64> {
match self {
KeyspaceHandle::Local(h) => Some(h.db.seqno()),
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(_) => None,
}
}
pub async fn apply_batch(&self, batch: Batch) -> Result<BatchOutcome, AppError> {
batch.validate()?;
match self {
KeyspaceHandle::Local(h) => h.apply_batch(batch).await,
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(h) => h.apply_batch(batch).await,
}
}
}
#[derive(Clone, Debug)]
pub(crate) enum Check {
Absent(Vec<u8>),
Present(Vec<u8>),
Unchanged(Vec<u8>, Vec<u8>),
}
impl Check {
#[cfg(feature = "vsock-store")]
pub(crate) fn key(&self) -> &[u8] {
match self {
Check::Absent(k) | Check::Present(k) | Check::Unchanged(k, _) => k,
}
}
}
#[derive(Clone, Debug)]
pub(crate) enum Write {
Put(Vec<u8>, Vec<u8>),
Delete(Vec<u8>),
}
impl Write {
pub(crate) fn key(&self) -> &[u8] {
match self {
Write::Put(k, _) | Write::Delete(k) => k,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct Batch {
pub(crate) checks: Vec<Check>,
pub(crate) writes: Vec<Write>,
}
impl Batch {
pub fn new() -> Self {
Self::default()
}
pub fn require_absent(&mut self, key: impl Into<Vec<u8>>) -> &mut Self {
self.checks.push(Check::Absent(key.into()));
self
}
pub fn require_present(&mut self, key: impl Into<Vec<u8>>) -> &mut Self {
self.checks.push(Check::Present(key.into()));
self
}
pub fn require_unchanged(
&mut self,
key: impl Into<Vec<u8>>,
expected: impl Into<Vec<u8>>,
) -> &mut Self {
self.checks
.push(Check::Unchanged(key.into(), expected.into()));
self
}
pub fn insert<V: Serialize>(
&mut self,
key: impl Into<Vec<u8>>,
value: &V,
) -> Result<&mut Self, AppError> {
self.writes
.push(Write::Put(key.into(), serde_json::to_vec(value)?));
Ok(self)
}
pub fn insert_raw(&mut self, key: impl Into<Vec<u8>>, value: impl Into<Vec<u8>>) -> &mut Self {
self.writes.push(Write::Put(key.into(), value.into()));
self
}
pub fn remove(&mut self, key: impl Into<Vec<u8>>) -> &mut Self {
self.writes.push(Write::Delete(key.into()));
self
}
pub fn is_empty(&self) -> bool {
self.checks.is_empty() && self.writes.is_empty()
}
pub(crate) fn validate(&self) -> Result<(), AppError> {
let mut seen = std::collections::HashSet::new();
for w in &self.writes {
if !seen.insert(w.key()) {
return Err(AppError::Internal(
"store batch writes the same key twice".into(),
));
}
}
Ok(())
}
#[cfg(feature = "vsock-store")]
pub(crate) fn keys(&self) -> Vec<&[u8]> {
self.checks
.iter()
.map(Check::key)
.chain(self.writes.iter().map(Write::key))
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchOutcome {
Applied,
Refused { check: usize },
}
impl BatchOutcome {
pub fn is_applied(self) -> bool {
self == BatchOutcome::Applied
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MoveOutcome {
Moved,
SourceMissing,
SourceChanged,
TargetExists,
}
type WriteLocks =
std::sync::Arc<std::sync::Mutex<HashMap<String, std::sync::Arc<std::sync::Mutex<()>>>>>;
#[derive(Clone)]
pub struct LocalStore {
db: fjall::Database,
write_locks: WriteLocks,
writes: generation::Generations,
}
#[derive(Clone)]
pub struct LocalKeyspaceHandle {
keyspace: fjall::Keyspace,
#[cfg(feature = "encryption")]
name: String,
db: fjall::Database,
write_lock: std::sync::Arc<std::sync::Mutex<()>>,
writes: std::sync::Arc<generation::KeyspaceWrites>,
#[cfg(feature = "encryption")]
encryption_key: Option<std::sync::Arc<zeroize::Zeroizing<[u8; 32]>>>,
}
fn lock_writes(lock: &std::sync::Mutex<()>) -> std::sync::MutexGuard<'_, ()> {
lock.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn read_snapshot(db: &fjall::Database) -> fjall::Snapshot {
db.snapshot()
}
fn commit_move(
db: &fjall::Database,
ks: &fjall::Keyspace,
old: &[u8],
new: &[u8],
value: Vec<u8>,
) -> Result<(), AppError> {
let mut batch = db.batch();
batch.insert(ks, new, value);
batch.remove(ks, old);
batch.commit()?;
Ok(())
}
impl LocalStore {
pub fn open(config: &StoreConfig) -> Result<Self, AppError> {
Self::open_with(config, &FjallTuning::default())
}
pub fn open_with(config: &StoreConfig, tuning: &FjallTuning) -> Result<Self, AppError> {
tuning.validate().map_err(AppError::Config)?;
std::fs::create_dir_all(&config.data_dir).map_err(AppError::Io)?;
let mut builder = fjall::Database::builder(&config.data_dir);
if let Some(bytes) = tuning.block_cache {
builder = builder.cache_size(bytes);
}
if let Some(bytes) = tuning.max_journal {
builder = builder.max_journaling_size(bytes);
}
if let Some(bytes) = tuning.write_buffer {
#[allow(deprecated)]
{
builder = builder.max_write_buffer_size(Some(bytes));
}
}
info!(
path = %config.data_dir.display(),
block_cache = %tuning.block_cache.map(crate::config::human_bytes).unwrap_or_else(|| "default".to_string()),
write_buffer = %tuning.write_buffer.map(crate::config::human_bytes).unwrap_or_else(|| "default".to_string()),
max_journal = %tuning.max_journal.map(crate::config::human_bytes).unwrap_or_else(|| "default".to_string()),
"opening store"
);
let db = builder.open()?;
Ok(Self {
db,
write_locks: WriteLocks::default(),
writes: generation::Generations::default(),
})
}
pub fn keyspace(&self, name: &str) -> Result<LocalKeyspaceHandle, AppError> {
let keyspace = self.db.keyspace(name, KeyspaceCreateOptions::default)?;
let write_lock = self
.write_locks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.entry(name.to_string())
.or_default()
.clone();
Ok(LocalKeyspaceHandle {
keyspace,
#[cfg(feature = "encryption")]
name: name.to_string(),
db: self.db.clone(),
write_lock,
writes: self.writes.for_keyspace(name),
#[cfg(feature = "encryption")]
encryption_key: None,
})
}
pub async fn persist(&self) -> Result<(), AppError> {
let db = self.db.clone();
tokio::task::spawn_blocking(move || db.persist(PersistMode::SyncAll))
.await
.map_err(|e| AppError::Internal(format!("blocking task panicked: {e}")))??;
Ok(())
}
}
impl LocalKeyspaceHandle {
#[cfg(feature = "encryption")]
pub fn with_encryption(mut self, key: [u8; 32]) -> Self {
self.encryption_key = Some(std::sync::Arc::new(zeroize::Zeroizing::new(key)));
self
}
pub fn is_encrypted(&self) -> bool {
#[cfg(feature = "encryption")]
{
self.encryption_key.is_some()
}
#[cfg(not(feature = "encryption"))]
{
false
}
}
pub async fn persist(&self) -> Result<(), AppError> {
let db = self.db.clone();
blocking_with_timeout(move || Ok(db.persist(PersistMode::SyncAll)?)).await
}
pub async fn insert<V: Serialize>(
&self,
key: impl Into<Vec<u8>>,
value: &V,
) -> Result<(), AppError> {
let key = key.into();
let bytes = serde_json::to_vec(value)?;
let bytes = self.maybe_encrypt(&key, bytes)?;
self.locked_write(move |ks| Ok(ks.insert(key, bytes)?))
.await
}
pub async fn get<V: DeserializeOwned + Send + 'static>(
&self,
key: impl Into<Vec<u8>>,
) -> Result<Option<V>, AppError> {
let key = key.into();
let ks = self.keyspace.clone();
#[cfg(feature = "encryption")]
let enc_key = self.encryption_key.clone();
#[cfg(feature = "encryption")]
let name = self.name.clone();
let db = self.db.clone();
blocking_with_timeout(move || match read_snapshot(&db).get(&ks, &key)? {
Some(bytes) => {
#[cfg(feature = "encryption")]
let bytes = {
let k = enc_key.as_ref().map(|arc| &***arc);
encryption::maybe_decrypt_bytes(k, &name, &key, &bytes)?
};
#[cfg(not(feature = "encryption"))]
let bytes = bytes.to_vec();
Ok(Some(serde_json::from_slice(&bytes)?))
}
None => Ok(None),
})
.await
}
pub async fn remove(&self, key: impl Into<Vec<u8>>) -> Result<(), AppError> {
let key = key.into();
self.locked_write(move |ks| Ok(ks.remove(key)?)).await
}
async fn locked_write<F>(&self, f: F) -> Result<(), AppError>
where
F: FnOnce(&fjall::Keyspace) -> Result<(), AppError> + Send + 'static,
{
let ks = self.keyspace.clone();
let lock = self.write_lock.clone();
let write = self.writes.begin().known_on_drop();
blocking_with_timeout(move || {
let _guard = lock_writes(&lock);
let _write = write;
f(&ks)
})
.await
}
pub async fn take_raw(&self, key: impl Into<Vec<u8>>) -> Result<Option<Vec<u8>>, AppError> {
let key = key.into();
let ks = self.keyspace.clone();
let lock = self.write_lock.clone();
let write = self.writes.begin().known_on_drop();
#[cfg(feature = "encryption")]
let enc_key = self.encryption_key.clone();
#[cfg(feature = "encryption")]
let name = self.name.clone();
blocking_with_timeout(move || {
let _guard = lock_writes(&lock);
let _write = write;
match ks.get(&key)? {
Some(bytes) => {
ks.remove(&key)?;
#[cfg(feature = "encryption")]
let bytes = {
let k = enc_key.as_ref().map(|arc| &***arc);
encryption::maybe_decrypt_bytes(k, &name, &key, &bytes)?
};
#[cfg(not(feature = "encryption"))]
let bytes = bytes.to_vec();
Ok(Some(bytes))
}
None => Ok(None),
}
})
.await
}
pub async fn insert_raw(
&self,
key: impl Into<Vec<u8>>,
value: impl Into<Vec<u8>>,
) -> Result<(), AppError> {
let key = key.into();
let value = self.maybe_encrypt(&key, value.into())?;
self.locked_write(move |ks| Ok(ks.insert(key, value)?))
.await
}
pub async fn get_raw(&self, key: impl Into<Vec<u8>>) -> Result<Option<Vec<u8>>, AppError> {
let key = key.into();
let ks = self.keyspace.clone();
#[cfg(feature = "encryption")]
let enc_key = self.encryption_key.clone();
#[cfg(feature = "encryption")]
let name = self.name.clone();
let db = self.db.clone();
blocking_with_timeout(move || match read_snapshot(&db).get(&ks, &key)? {
Some(bytes) => {
#[cfg(feature = "encryption")]
let bytes = {
let k = enc_key.as_ref().map(|arc| &***arc);
encryption::maybe_decrypt_bytes(k, &name, &key, &bytes)?
};
#[cfg(not(feature = "encryption"))]
let bytes = bytes.to_vec();
Ok(Some(bytes))
}
None => Ok(None),
})
.await
}
pub async fn prefix_iter_raw(
&self,
prefix: impl Into<Vec<u8>>,
) -> Result<Vec<RawKvPair>, AppError> {
let prefix = prefix.into();
let ks = self.keyspace.clone();
let db = self.db.clone();
#[cfg(feature = "encryption")]
let enc_key = self.encryption_key.clone();
#[cfg(feature = "encryption")]
let name = self.name.clone();
blocking_with_timeout(move || {
let mut results = Vec::new();
for guard in read_snapshot(&db).prefix(&ks, &prefix) {
let (key, value) = guard.into_inner()?;
#[cfg(feature = "encryption")]
let value = {
let k = enc_key.as_ref().map(|arc| &***arc);
encryption::maybe_decrypt_bytes(k, &name, &key, &value)?
};
#[cfg(not(feature = "encryption"))]
let value = value.to_vec();
results.push((key.to_vec(), value));
}
Ok(results)
})
.await
}
pub async fn range_from_raw(
&self,
from: impl Into<Vec<u8>>,
) -> Result<Vec<RawKvPair>, AppError> {
let from = from.into();
let ks = self.keyspace.clone();
let db = self.db.clone();
#[cfg(feature = "encryption")]
let enc_key = self.encryption_key.clone();
#[cfg(feature = "encryption")]
let name = self.name.clone();
blocking_with_timeout(move || {
let mut results = Vec::new();
for guard in read_snapshot(&db).range(&ks, from..) {
let (key, value) = guard.into_inner()?;
#[cfg(feature = "encryption")]
let value = {
let k = enc_key.as_ref().map(|arc| &***arc);
encryption::maybe_decrypt_bytes(k, &name, &key, &value)?
};
#[cfg(not(feature = "encryption"))]
let value = value.to_vec();
results.push((key.to_vec(), value));
}
Ok(results)
})
.await
}
pub async fn prefix_keys(&self, prefix: impl Into<Vec<u8>>) -> Result<Vec<Vec<u8>>, AppError> {
let prefix = prefix.into();
let ks = self.keyspace.clone();
let db = self.db.clone();
blocking_with_timeout(move || {
let mut results = Vec::new();
for guard in read_snapshot(&db).prefix(&ks, &prefix) {
let (key, _value) = guard.into_inner()?;
results.push(key.to_vec());
}
Ok(results)
})
.await
}
pub async fn scan_raw(&self, opts: ScanOpts) -> Result<Vec<RawKvPair>, AppError> {
let ks = self.keyspace.clone();
let db = self.db.clone();
#[cfg(feature = "encryption")]
let enc_key = self.encryption_key.clone();
#[cfg(feature = "encryption")]
let name = self.name.clone();
blocking_with_timeout(move || {
let mut results = Vec::with_capacity(opts.limit.min(1024));
if opts.limit == 0 {
return Ok(results);
}
let snapshot = read_snapshot(&db);
let iter = snapshot.range(&ks, scan_bounds(&opts));
let guards: Box<dyn Iterator<Item = _>> = if opts.reverse {
Box::new(iter.rev())
} else {
Box::new(iter)
};
for guard in guards.take(opts.limit) {
let (key, value) = guard.into_inner()?;
#[cfg(feature = "encryption")]
let value = {
let k = enc_key.as_ref().map(|arc| &***arc);
encryption::maybe_decrypt_bytes(k, &name, &key, &value)?
};
#[cfg(not(feature = "encryption"))]
let value = value.to_vec();
results.push((key.to_vec(), value));
}
Ok(results)
})
.await
}
pub async fn approximate_len(&self) -> Result<usize, AppError> {
let ks = self.keyspace.clone();
blocking_with_timeout(move || Ok(ks.approximate_len())).await
}
pub async fn swap<V: Serialize>(
&self,
old_key: impl Into<Vec<u8>>,
new_key: impl Into<Vec<u8>>,
value: &V,
) -> Result<bool, AppError> {
let old_key = old_key.into();
let new_key = new_key.into();
let bytes = serde_json::to_vec(value)?;
let bytes = self.maybe_encrypt(&new_key, bytes)?;
let db = self.db.clone();
let ks = self.keyspace.clone();
let lock = self.write_lock.clone();
let write = self.writes.begin().known_on_drop();
blocking_with_timeout(move || {
let _guard = lock_writes(&lock);
let _write = write;
if ks.contains_key(&new_key)? {
return Ok(false);
}
commit_move(&db, &ks, &old_key, &new_key, bytes)?;
Ok(true)
})
.await
}
pub async fn move_if_unchanged<V: Serialize>(
&self,
old_key: impl Into<Vec<u8>>,
expected: Vec<u8>,
new_key: impl Into<Vec<u8>>,
value: &V,
) -> Result<MoveOutcome, AppError> {
let old_key = old_key.into();
let new_key = new_key.into();
let bytes = serde_json::to_vec(value)?;
let bytes = self.maybe_encrypt(&new_key, bytes)?;
let db = self.db.clone();
let ks = self.keyspace.clone();
let lock = self.write_lock.clone();
let write = self.writes.begin().known_on_drop();
#[cfg(feature = "encryption")]
let enc_key = self.encryption_key.clone();
#[cfg(feature = "encryption")]
let name = self.name.clone();
blocking_with_timeout(move || {
let _guard = lock_writes(&lock);
let _write = write;
let Some(current) = ks.get(&old_key)? else {
return Ok(MoveOutcome::SourceMissing);
};
#[cfg(feature = "encryption")]
let current = {
let k = enc_key.as_ref().map(|arc| &***arc);
encryption::maybe_decrypt_bytes(k, &name, &old_key, ¤t)?
};
#[cfg(not(feature = "encryption"))]
let current = current.to_vec();
if current != expected {
return Ok(MoveOutcome::SourceChanged);
}
if ks.contains_key(&new_key)? {
return Ok(MoveOutcome::TargetExists);
}
commit_move(&db, &ks, &old_key, &new_key, bytes)?;
Ok(MoveOutcome::Moved)
})
.await
}
pub async fn insert_if_absent<V: Serialize>(
&self,
key: impl Into<Vec<u8>>,
value: &V,
) -> Result<bool, AppError> {
let key = key.into();
let bytes = serde_json::to_vec(value)?;
self.insert_bytes_if_absent(key, bytes).await
}
pub async fn insert_raw_if_absent(
&self,
key: impl Into<Vec<u8>>,
value: impl Into<Vec<u8>>,
) -> Result<bool, AppError> {
self.insert_bytes_if_absent(key.into(), value.into()).await
}
async fn insert_bytes_if_absent(&self, key: Vec<u8>, bytes: Vec<u8>) -> Result<bool, AppError> {
let bytes = self.maybe_encrypt(&key, bytes)?;
let ks = self.keyspace.clone();
let lock = self.write_lock.clone();
let write = self.writes.begin().known_on_drop();
blocking_with_timeout(move || {
let _guard = lock_writes(&lock);
let _write = write;
if ks.contains_key(&key)? {
return Ok(false);
}
ks.insert(&key, bytes)?;
Ok(true)
})
.await
}
pub async fn apply_batch(&self, batch: Batch) -> Result<BatchOutcome, AppError> {
let Batch { checks, writes } = batch;
let writes = writes
.into_iter()
.map(|w| match w {
Write::Put(k, v) => {
let v = self.maybe_encrypt(&k, v)?;
Ok(Write::Put(k, v))
}
d => Ok(d),
})
.collect::<Result<Vec<_>, AppError>>()?;
let db = self.db.clone();
let ks = self.keyspace.clone();
let lock = self.write_lock.clone();
let write = self.writes.begin().known_on_drop();
#[cfg(feature = "encryption")]
let enc_key = self.encryption_key.clone();
#[cfg(feature = "encryption")]
let name = self.name.clone();
blocking_with_timeout(move || {
let _guard = lock_writes(&lock);
let _write = write;
for (i, check) in checks.iter().enumerate() {
let holds = match check {
Check::Absent(k) => !ks.contains_key(k)?,
Check::Present(k) => ks.contains_key(k)?,
Check::Unchanged(k, expected) => match ks.get(k)? {
None => false,
Some(stored) => {
#[cfg(feature = "encryption")]
let current = {
let key = enc_key.as_ref().map(|arc| &***arc);
encryption::maybe_decrypt_bytes(key, &name, k, &stored)?
};
#[cfg(not(feature = "encryption"))]
let current = stored.to_vec();
current == *expected
}
},
};
if !holds {
return Ok(BatchOutcome::Refused { check: i });
}
}
if writes.is_empty() {
return Ok(BatchOutcome::Applied);
}
let mut batch = db.batch();
for w in writes {
match w {
Write::Put(k, v) => batch.insert(&ks, k, v),
Write::Delete(k) => batch.remove(&ks, k),
}
}
batch.commit()?;
Ok(BatchOutcome::Applied)
})
.await
}
fn maybe_encrypt(&self, store_key: &[u8], plaintext: Vec<u8>) -> Result<Vec<u8>, AppError> {
#[cfg(feature = "encryption")]
{
match self.encryption_key.as_ref().map(|arc| &***arc) {
Some(key) => encryption::encrypt_value(key, &self.name, store_key, &plaintext),
None => Ok(plaintext),
}
}
#[cfg(not(feature = "encryption"))]
{
let _ = store_key;
Ok(plaintext)
}
}
}
#[cfg(test)]
mod oversized_response_tests {
use super::*;
fn as_production_raises_it() -> AppError {
AppError::Internal(format!("{OVERSIZED_RESPONSE_PREFIX} 36869978 > 16777216"))
}
#[test]
fn the_rendered_error_does_not_start_with_its_own_message() {
let rendered = as_production_raises_it().to_string();
assert!(
!rendered.starts_with(OVERSIZED_RESPONSE_PREFIX),
"if this ever passes, matching on `to_string()` would be safe — it is not: {rendered}"
);
assert!(rendered.contains(OVERSIZED_RESPONSE_PREFIX));
}
#[test]
fn an_oversized_response_is_recognised() {
assert!(is_oversized_response(&as_production_raises_it()));
}
#[test]
fn other_errors_are_not_mistaken_for_it() {
for other in [
AppError::Internal("something else entirely".into()),
AppError::NotFound("row".into()),
] {
assert!(!is_oversized_response(&other), "{other}");
}
}
#[test]
fn a_bulk_read_fallback_reports_the_upgrade_instruction() {
let mapped = map_oversized_bulk_read(as_production_raises_it()).to_string();
assert!(
mapped.contains("upgrade the enclave proxy"),
"an operator needs to be told what to do, got: {mapped}"
);
assert!(
!mapped.contains("36869978"),
"the raw frame size is not the operator's problem: {mapped}"
);
}
#[test]
fn an_unrelated_error_passes_through_the_mapping_unchanged() {
let untouched = map_oversized_bulk_read(AppError::NotFound("row".into()));
assert_eq!(untouched.to_string(), "not found: row");
}
}
#[cfg(test)]
mod scan_range_vectors {
use super::*;
#[derive(serde::Deserialize)]
pub(crate) struct Case {
pub(crate) name: String,
#[serde(default)]
pub(crate) encoding: Option<String>,
pub(crate) keys: Vec<String>,
pub(crate) prefix: String,
pub(crate) after: Option<String>,
pub(crate) reverse: bool,
pub(crate) expect: Vec<String>,
}
#[derive(serde::Deserialize)]
struct Vectors {
cases: Vec<Case>,
}
pub(crate) fn bytes(value: &str, hex_encoded: bool) -> Vec<u8> {
if hex_encoded {
assert!(value.len().is_multiple_of(2), "hex field has odd length");
(0..value.len())
.step_by(2)
.map(|i| u8::from_str_radix(&value[i..i + 2], 16).expect("hex byte"))
.collect()
} else {
value.as_bytes().to_vec()
}
}
pub(crate) fn render(keys: &[Vec<u8>], hex_encoded: bool) -> Vec<String> {
keys.iter()
.map(|key| {
if hex_encoded {
key.iter().map(|b| format!("{b:02x}")).collect()
} else {
String::from_utf8_lossy(key).into_owned()
}
})
.collect()
}
pub(crate) fn load() -> Vec<Case> {
serde_json::from_str::<Vectors>(include_str!("scan_range_vectors.json"))
.expect("scan range vectors parse")
.cases
}
#[tokio::test]
async fn local_and_in_memory_paths_both_match_the_shared_vectors() {
for case in load() {
let hex_encoded = case.encoding.as_deref() == Some("hex");
let opts = ScanOpts {
prefix: bytes(&case.prefix, hex_encoded),
after: case.after.as_deref().map(|a| bytes(a, hex_encoded)),
limit: usize::MAX,
reverse: case.reverse,
};
let expect: Vec<Vec<u8>> = case
.expect
.iter()
.map(|key| bytes(key, hex_encoded))
.collect();
let seeded: Vec<Vec<u8>> = case
.keys
.iter()
.map(|key| bytes(key, hex_encoded))
.collect();
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("store");
let ks = store.keyspace("vectors").expect("keyspace");
for key in &seeded {
ks.insert_raw(key.clone(), b"v".to_vec())
.await
.expect("seed");
}
let scanned: Vec<Vec<u8>> = ks
.scan_raw(opts.clone())
.await
.expect("scan")
.into_iter()
.map(|(key, _)| key)
.collect();
assert_eq!(
render(&scanned, hex_encoded),
render(&expect, hex_encoded),
"local scan_raw disagrees: {}",
case.name
);
let bulk: Vec<RawKvPair> = seeded
.iter()
.rev()
.map(|key| (key.clone(), b"v".to_vec()))
.collect();
let filtered: Vec<Vec<u8>> = apply_scan_locally(bulk, &opts)
.into_iter()
.map(|(key, _)| key)
.collect();
assert_eq!(
render(&filtered, hex_encoded),
render(&expect, hex_encoded),
"apply_scan_locally disagrees: {}",
case.name
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_store() -> (Store, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("failed to create temp dir");
let config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = Store::open(&config).expect("failed to open store");
(store, dir)
}
#[test]
fn local_store_exists_ignores_a_bare_directory() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(
!local_store_exists(dir.path()),
"an empty directory holds no store"
);
std::fs::write(dir.path().join("did.jsonl"), b"{}").expect("write stray file");
assert!(
!local_store_exists(dir.path()),
"a non-empty directory without fjall's marker still holds no store"
);
assert!(
!local_store_exists(&dir.path().join("does-not-exist")),
"an absent directory holds no store"
);
}
#[test]
fn local_store_exists_sees_an_opened_store() {
let (_store, dir) = temp_store();
assert!(
local_store_exists(dir.path()),
"opening a store must make the probe report it"
);
}
#[tokio::test]
async fn persist_survives_store_reopen() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().to_path_buf();
{
let store = Store::open(&StoreConfig {
data_dir: path.clone(),
})
.expect("open store");
let ks = store.keyspace("keys").unwrap();
ks.insert_raw("carveout:closed", b"admin-did".to_vec())
.await
.unwrap();
ks.persist().await.unwrap();
}
let store = Store::open(&StoreConfig { data_dir: path }).expect("reopen store");
let ks = store.keyspace("keys").unwrap();
assert_eq!(
ks.get_raw("carveout:closed").await.unwrap().as_deref(),
Some(b"admin-did".as_slice()),
"a persisted write must survive a store reopen"
);
}
#[tokio::test]
async fn insert_if_absent_claims_only_once() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
assert!(
ks.insert_if_absent("k", &"first".to_string())
.await
.unwrap(),
"first claim must succeed"
);
assert!(
!ks.insert_if_absent("k", &"second".to_string())
.await
.unwrap(),
"second claim must be refused"
);
let got: String = ks.get("k").await.unwrap().unwrap();
assert_eq!(got, "first", "loser must not overwrite the stored value");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn insert_if_absent_under_concurrency_admits_exactly_one() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
let mut handles = Vec::new();
for i in 0..16u32 {
let ks = ks.clone();
handles.push(tokio::spawn(async move {
ks.insert_if_absent("contested", &format!("writer-{i}"))
.await
.unwrap()
}));
}
let mut winners = 0;
for h in handles {
if h.await.unwrap() {
winners += 1;
}
}
assert_eq!(winners, 1, "exactly one racing claim may win");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn take_raw_under_concurrency_admits_exactly_one() {
let (store, _dir) = temp_store();
store
.keyspace("test")
.unwrap()
.insert("token", &"refresh".to_string())
.await
.unwrap();
let mut handles = Vec::new();
for _ in 0..16 {
let ks = store.keyspace("test").unwrap();
handles.push(tokio::spawn(
async move { ks.take_raw("token").await.unwrap() },
));
}
let mut claimed = 0;
for h in handles {
if h.await.unwrap().is_some() {
claimed += 1;
}
}
assert_eq!(claimed, 1, "exactly one concurrent take_raw may claim");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn move_if_unchanged_under_concurrency_admits_exactly_one() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
ks.insert("src", &"row".to_string()).await.unwrap();
let expected = ks.get_raw("src").await.unwrap().unwrap();
let mut handles = Vec::new();
for n in 0..16 {
let ks = store.keyspace("test").unwrap();
let expected = expected.clone();
handles.push(tokio::spawn(async move {
ks.move_if_unchanged("src", expected, format!("dst{n}"), &"row".to_string())
.await
.unwrap()
}));
}
let mut moved = 0;
for h in handles {
if h.await.unwrap() == MoveOutcome::Moved {
moved += 1;
}
}
assert_eq!(moved, 1, "exactly one concurrent move may land");
assert!(ks.get_raw("src").await.unwrap().is_none());
}
fn journal_seqno(store: &Store) -> fjall::SeqNo {
match store {
Store::Local(s) => s.db.seqno(),
#[cfg(feature = "vsock-store")]
Store::Vsock(_) => unreachable!("local-store tests only"),
}
}
#[tokio::test]
async fn moves_commit_as_one_journal_record() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
ks.insert("a", &"row".to_string()).await.unwrap();
let before = journal_seqno(&store);
assert!(ks.swap("a", "b", &"row".to_string()).await.unwrap());
assert_eq!(journal_seqno(&store) - before, 1, "swap is one record");
let expected = ks.get_raw("b").await.unwrap().unwrap();
let before = journal_seqno(&store);
assert_eq!(
ks.move_if_unchanged("b", expected, "c", &"row".to_string())
.await
.unwrap(),
MoveOutcome::Moved
);
assert_eq!(journal_seqno(&store) - before, 1, "move is one record");
assert!(ks.get_raw("b").await.unwrap().is_none());
assert!(ks.get_raw("c").await.unwrap().is_some());
ks.insert("d", &"taken".to_string()).await.unwrap();
let before = journal_seqno(&store);
assert!(!ks.swap("c", "d", &"row".to_string()).await.unwrap());
assert_eq!(journal_seqno(&store), before, "refusal writes nothing");
}
#[tokio::test]
async fn apply_batch_commits_one_journal_record_or_nothing() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
ks.insert("old", &"v1".to_string()).await.unwrap();
let v1 = ks.get_raw("old").await.unwrap().unwrap();
let mut b = Batch::new();
b.require_absent("new").require_unchanged("old", v1.clone());
b.insert("new", &"v2".to_string()).unwrap();
b.insert_raw("idx:a", "1")
.insert_raw("idx:b", "1")
.remove("old");
let before = journal_seqno(&store);
assert_eq!(ks.apply_batch(b).await.unwrap(), BatchOutcome::Applied);
assert_eq!(journal_seqno(&store) - before, 1, "one journal record");
assert_eq!(
ks.get::<String>("new").await.unwrap().as_deref(),
Some("v2")
);
assert!(ks.get_raw("idx:a").await.unwrap().is_some());
assert!(ks.get_raw("idx:b").await.unwrap().is_some());
assert!(ks.get_raw("old").await.unwrap().is_none());
let mut b = Batch::new();
b.require_absent("other").require_unchanged("new", v1);
b.insert_raw("x", "y");
let before = journal_seqno(&store);
assert_eq!(
ks.apply_batch(b).await.unwrap(),
BatchOutcome::Refused { check: 1 }
);
assert_eq!(journal_seqno(&store), before, "refusal writes nothing");
assert!(ks.get_raw("x").await.unwrap().is_none());
}
#[tokio::test]
async fn apply_batch_reports_the_first_failing_precondition() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
ks.insert("a", &"v".to_string()).await.unwrap();
let v = ks.get_raw("a").await.unwrap().unwrap();
let outcome = |b: Batch| {
let ks = ks.clone();
async move { ks.apply_batch(b).await.unwrap() }
};
let mut b = Batch::new();
b.require_present("a")
.require_absent("a")
.require_present("missing");
assert_eq!(outcome(b).await, BatchOutcome::Refused { check: 1 });
let mut b = Batch::new();
b.require_unchanged("missing", v.clone());
assert_eq!(outcome(b).await, BatchOutcome::Refused { check: 0 });
let mut b = Batch::new();
b.require_present("a").require_unchanged("a", v);
let before = journal_seqno(&store);
assert_eq!(outcome(b).await, BatchOutcome::Applied);
assert_eq!(journal_seqno(&store), before);
}
#[tokio::test]
async fn apply_batch_refuses_a_key_written_twice() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
let mut b = Batch::new();
b.insert_raw("k", "1").remove("k");
assert!(ks.apply_batch(b).await.is_err());
assert!(ks.get_raw("k").await.unwrap().is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn apply_batch_under_concurrency_admits_exactly_one() {
let (store, _dir) = temp_store();
let mut handles = Vec::new();
for n in 0..16 {
let ks = store.keyspace("test").unwrap();
handles.push(tokio::spawn(async move {
let mut b = Batch::new();
b.require_absent("claim")
.insert_raw("claim", format!("{n}"))
.insert_raw(format!("by:{n}"), "1");
ks.apply_batch(b).await.unwrap().is_applied()
}));
}
let mut applied = 0;
for h in handles {
if h.await.unwrap() {
applied += 1;
}
}
assert_eq!(applied, 1, "exactly one batch may claim the key");
let ks = store.keyspace("test").unwrap();
assert_eq!(ks.prefix_keys("by:").await.unwrap().len(), 1);
}
#[cfg(feature = "encryption")]
#[tokio::test]
async fn apply_batch_compares_plaintext_on_an_encrypted_keyspace() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap().with_encryption([9u8; 32]);
ks.insert("a", &"secret".to_string()).await.unwrap();
let plain = ks.get_raw("a").await.unwrap().unwrap();
let mut b = Batch::new();
b.require_unchanged("a", plain);
b.insert("a", &"secret-2".to_string()).unwrap();
assert!(ks.apply_batch(b).await.unwrap().is_applied());
assert_eq!(
ks.get::<String>("a").await.unwrap().as_deref(),
Some("secret-2")
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plain_writes_wait_for_the_keyspace_write_lock() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
let lock = match &ks {
KeyspaceHandle::Local(h) => h.write_lock.clone(),
#[cfg(feature = "vsock-store")]
KeyspaceHandle::Vsock(_) => unreachable!("local store"),
};
let (held_tx, held_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let holder = std::thread::spawn(move || {
let _guard = lock_writes(&lock);
held_tx.send(()).unwrap();
release_rx.recv().unwrap();
});
held_rx.recv().unwrap();
let writer = ks.clone();
let insert = tokio::spawn(async move { writer.insert_raw("k", "v").await });
let remover = ks.clone();
let remove = tokio::spawn(async move { remover.remove("other").await });
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(!insert.is_finished(), "insert ran under a held write lock");
assert!(!remove.is_finished(), "remove ran under a held write lock");
release_tx.send(()).unwrap();
holder.join().unwrap();
insert.await.unwrap().unwrap();
remove.await.unwrap().unwrap();
assert!(ks.get_raw("k").await.unwrap().is_some());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_plain_remove_never_lands_inside_a_conditional_batch() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
for i in 0..500 {
ks.insert_raw("row", "v1").await.unwrap();
let writer = ks.clone();
let rewrite = tokio::spawn(async move {
let mut b = Batch::new();
b.require_unchanged("row", "v1").insert_raw("row", "v2");
writer.apply_batch(b).await.unwrap()
});
let remover = ks.clone();
let remove = tokio::spawn(async move { remover.remove("row").await.unwrap() });
rewrite.await.unwrap();
remove.await.unwrap();
assert!(
ks.get_raw("row").await.unwrap().is_none(),
"iteration {i}: a removed row was written back"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_reader_never_sees_a_batch_half_applied() {
const MOVES: usize = 2_000;
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
ks.insert_raw("mv:a", "row").await.unwrap();
let writer = {
let ks = ks.clone();
tokio::spawn(async move {
for n in 0..MOVES {
let (from, to) = if n % 2 == 0 {
("mv:a", "mv:b")
} else {
("mv:b", "mv:a")
};
let mut b = Batch::new();
b.require_present(from).remove(from).insert_raw(to, "row");
assert!(ks.apply_batch(b).await.unwrap().is_applied());
}
})
};
let mut reads = 0usize;
while !writer.is_finished() {
let rows = ks.prefix_iter_raw("mv:").await.unwrap();
assert_eq!(
rows.len(),
1,
"read {reads} saw a half-applied move: {rows:?}"
);
reads += 1;
}
writer.await.unwrap();
assert!(reads > 0, "the reader never overlapped the writer");
}
#[tokio::test]
async fn move_if_unchanged_refuses_a_changed_source_and_an_occupied_target() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
ks.insert("src", &"v1".to_string()).await.unwrap();
let stale = ks.get_raw("src").await.unwrap().unwrap();
ks.insert("src", &"v2".to_string()).await.unwrap();
assert_eq!(
ks.move_if_unchanged("src", stale, "dst", &"x".to_string())
.await
.unwrap(),
MoveOutcome::SourceChanged
);
let current = ks.get_raw("src").await.unwrap().unwrap();
ks.insert("dst", &"taken".to_string()).await.unwrap();
assert_eq!(
ks.move_if_unchanged("src", current, "dst", &"x".to_string())
.await
.unwrap(),
MoveOutcome::TargetExists
);
assert!(ks.get_raw("src").await.unwrap().is_some(), "nothing moved");
}
#[tokio::test]
async fn test_basic_roundtrip() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
struct TestRecord {
id: String,
value: u64,
}
let record = TestRecord {
id: "test-1".into(),
value: 42,
};
ks.insert("key:test-1", &record).await.unwrap();
let got: TestRecord = ks.get("key:test-1").await.unwrap().unwrap();
assert_eq!(got, record);
}
#[tokio::test]
async fn test_prefix_iter() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
for i in 0..5 {
ks.insert_raw(format!("prefix:{i}"), format!("value-{i}").into_bytes())
.await
.unwrap();
}
let raw = ks.prefix_iter_raw("prefix:").await.unwrap();
assert_eq!(raw.len(), 5);
}
#[tokio::test]
async fn test_range_from_raw_seeks_to_lower_bound() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
for k in ["2026-01:a", "2026-02:b", "2026-03:c", "2026-04:d"] {
ks.insert_raw(k.as_bytes().to_vec(), b"v".to_vec())
.await
.unwrap();
}
let rows = ks.range_from_raw(b"2026-03:".to_vec()).await.unwrap();
let keys: Vec<String> = rows
.iter()
.map(|(k, _)| String::from_utf8(k.clone()).unwrap())
.collect();
assert_eq!(keys, vec!["2026-03:c", "2026-04:d"]);
assert_eq!(ks.range_from_raw(Vec::new()).await.unwrap().len(), 4);
assert!(
ks.range_from_raw(b"2026-99:".to_vec())
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn test_remove() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
ks.insert_raw("key", b"value".to_vec()).await.unwrap();
assert!(ks.get_raw("key").await.unwrap().is_some());
ks.remove("key").await.unwrap();
assert!(ks.get_raw("key").await.unwrap().is_none());
}
#[tokio::test]
async fn test_swap() {
let (store, _dir) = temp_store();
let ks = store.keyspace("test").unwrap();
ks.insert("old", &"value").await.unwrap();
let swapped = ks.swap("old", "new", &"value").await.unwrap();
assert!(swapped);
assert!(ks.get::<String>("old").await.unwrap().is_none());
assert!(ks.get::<String>("new").await.unwrap().is_some());
}
#[tokio::test]
async fn test_passthrough_mode_no_encryption() {
let (store, _dir) = temp_store();
let ks = store.keyspace("plain").unwrap();
assert!(!ks.is_encrypted());
ks.insert_raw("test", b"visible".to_vec()).await.unwrap();
let raw = ks.get_raw("test").await.unwrap().unwrap();
assert_eq!(raw, b"visible");
}
#[cfg(feature = "encryption")]
#[tokio::test]
async fn test_encrypted_roundtrip() {
let (store, _dir) = temp_store();
let ks = store
.keyspace("encrypted")
.unwrap()
.with_encryption([0xAB; 32]);
assert!(ks.is_encrypted());
ks.insert_raw("raw:test", b"hello world".to_vec())
.await
.unwrap();
let raw = ks.get_raw("raw:test").await.unwrap().unwrap();
assert_eq!(raw, b"hello world");
ks.insert("json:test", &"encrypted value").await.unwrap();
let got: String = ks.get("json:test").await.unwrap().unwrap();
assert_eq!(got, "encrypted value");
}
#[cfg(feature = "encryption")]
#[tokio::test]
async fn encrypted_value_cannot_be_pasted_to_another_key() {
let (store, _dir) = temp_store();
let key = [0x55; 32];
let ks = store.keyspace("acl").unwrap().with_encryption(key);
ks.insert_raw("acl:victim", b"admin-row".to_vec())
.await
.unwrap();
let raw = store.keyspace("acl").unwrap();
let stolen = raw.get_raw("acl:victim").await.unwrap().unwrap();
raw.insert_raw("acl:attacker", stolen).await.unwrap();
let err = ks.get_raw("acl:attacker").await;
assert!(
err.is_err(),
"a ciphertext pasted to a different key must fail AAD authentication"
);
assert_eq!(
ks.get_raw("acl:victim").await.unwrap().unwrap(),
b"admin-row"
);
}
#[cfg(feature = "encryption")]
#[tokio::test]
async fn test_encrypted_data_is_actually_encrypted_on_disk() {
let (store, _dir) = temp_store();
let enc_key = [0x42; 32];
let ks_enc = store.keyspace("secrets").unwrap().with_encryption(enc_key);
ks_enc
.insert_raw("test", b"plaintext secret".to_vec())
.await
.unwrap();
let ks_raw = store.keyspace("secrets").unwrap();
let on_disk = ks_raw.get_raw("test").await.unwrap().unwrap();
assert_ne!(on_disk, b"plaintext secret");
assert!(on_disk.len() >= 12 + 16 + 16);
let decrypted = ks_enc.get_raw("test").await.unwrap().unwrap();
assert_eq!(decrypted, b"plaintext secret");
}
#[cfg(feature = "encryption")]
#[tokio::test]
async fn migrate_to_encrypted_converts_legacy_plaintext() {
let (store, _dir) = temp_store();
let key = [0x33; 32];
let bare = store.keyspace("install").unwrap();
bare.insert_raw("token:a", b"ephemeral-key-bytes".to_vec())
.await
.unwrap();
bare.insert("token:b", &"json-state".to_string())
.await
.unwrap();
let migrated = bare.migrate_to_encrypted(key).await.unwrap();
assert_eq!(migrated, 2, "both legacy rows must be encrypted");
let on_disk = bare.get_raw("token:a").await.unwrap().unwrap();
assert_ne!(on_disk, b"ephemeral-key-bytes");
assert!(
on_disk.starts_with(b"VAE1"),
"migrated row must carry the v1 encryption magic"
);
let enc = store.keyspace("install").unwrap().with_encryption(key);
assert_eq!(
enc.get_raw("token:a").await.unwrap().unwrap(),
b"ephemeral-key-bytes"
);
let b: String = enc.get("token:b").await.unwrap().unwrap();
assert_eq!(b, "json-state");
}
#[cfg(feature = "encryption")]
#[tokio::test]
async fn migrate_to_encrypted_is_idempotent_and_crash_safe() {
let (store, _dir) = temp_store();
let key = [0x44; 32];
let bare = store.keyspace("passkey").unwrap();
bare.insert_raw("row:1", b"plaintext-one".to_vec())
.await
.unwrap();
assert_eq!(bare.migrate_to_encrypted(key).await.unwrap(), 1);
bare.insert_raw("row:2", b"plaintext-two".to_vec())
.await
.unwrap();
assert_eq!(bare.migrate_to_encrypted(key).await.unwrap(), 1);
assert_eq!(bare.migrate_to_encrypted(key).await.unwrap(), 0);
let enc = store.keyspace("passkey").unwrap().with_encryption(key);
assert_eq!(
enc.get_raw("row:1").await.unwrap().unwrap(),
b"plaintext-one"
);
assert_eq!(
enc.get_raw("row:2").await.unwrap().unwrap(),
b"plaintext-two"
);
}
#[cfg(feature = "encryption")]
#[tokio::test]
async fn migrate_to_encrypted_rejects_encrypted_handle() {
let (store, _dir) = temp_store();
let enc = store
.keyspace("install")
.unwrap()
.with_encryption([0x55; 32]);
assert!(enc.migrate_to_encrypted([0x55; 32]).await.is_err());
}
#[test]
fn open_leaves_fjalls_own_default_cache_capacity() {
let dir = tempfile::tempdir().expect("tempdir");
let config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = LocalStore::open(&config).expect("open");
assert_eq!(store.db.cache_capacity(), 32 * 1024 * 1024);
}
#[test]
fn open_with_default_tuning_matches_open() {
let dir = tempfile::tempdir().expect("tempdir");
let config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = LocalStore::open_with(&config, &FjallTuning::default())
.expect("open_with default tuning");
assert_eq!(store.db.cache_capacity(), 32 * 1024 * 1024);
}
#[test]
fn configured_tuning_opens_and_the_block_cache_takes_effect() {
let dir = tempfile::tempdir().expect("tempdir");
let config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let tuning = FjallTuning {
block_cache: Some(8 * 1024 * 1024),
write_buffer: Some(crate::config::MIN_WRITE_BUFFER_BYTES),
max_journal: Some(crate::config::MIN_MAX_JOURNAL_BYTES),
};
let store = LocalStore::open_with(&config, &tuning).expect("open_with configured tuning");
assert_eq!(store.db.cache_capacity(), 8 * 1024 * 1024);
}
#[test]
fn open_with_refuses_a_below_floor_setting_naming_it() {
let dir = tempfile::tempdir().expect("tempdir");
let config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let tuning = FjallTuning {
block_cache: None,
write_buffer: None,
max_journal: Some(1024), };
let Err(err) = LocalStore::open_with(&config, &tuning) else {
panic!("a below-floor max_journal must be refused");
};
let msg = err.to_string();
assert!(
msg.contains("fjall.max_journal"),
"error must name the setting, got: {msg}"
);
}
#[test]
fn open_with_refuses_a_zero_setting_naming_it() {
let dir = tempfile::tempdir().expect("tempdir");
let config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let tuning = FjallTuning {
block_cache: Some(0),
write_buffer: None,
max_journal: None,
};
let Err(err) = LocalStore::open_with(&config, &tuning) else {
panic!("a zero block_cache must be refused");
};
let msg = err.to_string();
assert!(msg.contains("fjall.block_cache"), "got: {msg}");
assert!(msg.contains("zero"), "got: {msg}");
}
}