use std::collections::HashMap;
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>);
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 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 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 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}");
}
}