use crate::io::cap::{
BrowserStorageIoCap, StorageBackend, StorageConsistencyPolicy, StorageIoCap, StorageOperation,
StoragePolicyError, StorageRequest,
};
#[cfg(target_arch = "wasm32")]
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
#[cfg(target_arch = "wasm32")]
use js_sys::{Array, Promise, Uint8Array};
use std::collections::BTreeMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::{JsCast, JsValue, closure::Closure};
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::JsFuture;
#[cfg(target_arch = "wasm32")]
use web_sys::{
Event, IdbDatabase, IdbFactory, IdbObjectStore, IdbOpenDbRequest, IdbRequest, IdbTransaction,
IdbTransactionMode, Storage, WorkerGlobalScope,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BrowserStorageError {
Policy(StoragePolicyError),
BackendUnavailable(StorageBackend),
HostBackend {
backend: StorageBackend,
operation: StorageOperation,
message: String,
},
}
impl std::fmt::Display for BrowserStorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Policy(error) => write!(f, "{error}"),
Self::BackendUnavailable(backend) => {
write!(f, "storage backend unavailable: {backend:?}")
}
Self::HostBackend {
backend,
operation,
message,
} => write!(
f,
"storage host backend error ({backend:?}, {operation:?}): {message}"
),
}
}
}
impl std::error::Error for BrowserStorageError {}
impl From<StoragePolicyError> for BrowserStorageError {
fn from(error: StoragePolicyError) -> Self {
Self::Policy(error)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StorageEvent {
pub operation: StorageOperation,
pub backend: StorageBackend,
pub namespace_label: String,
pub key_label: Option<String>,
pub value_len: Option<usize>,
pub outcome: StorageEventOutcome,
pub reason_code: StorageEventReasonCode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StorageEventOutcome {
Allowed,
Denied,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StorageEventReasonCode {
Allowed,
InvalidNamespace,
BackendDenied,
NamespaceDenied,
OperationDenied,
MissingKey,
KeyTooLarge,
ValueTooLarge,
NamespaceTooLarge,
EntryCountExceeded,
QuotaExceeded,
BackendUnavailable,
HostBackendError,
}
pub trait StorageHostBackend: std::fmt::Debug + Send + Sync {
fn set(&self, namespace: &str, key: &str, value: &[u8]) -> Result<(), String>;
fn get(&self, namespace: &str, key: &str) -> Result<Option<Vec<u8>>, String>;
fn delete(&self, namespace: &str, key: &str) -> Result<bool, String>;
fn list_keys(&self, namespace: &str) -> Result<Vec<String>, String>;
fn clear_namespace(&self, namespace: &str) -> Result<usize, String>;
}
#[cfg(target_arch = "wasm32")]
pub type StorageHostFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, String>> + 'a>>;
#[cfg(not(target_arch = "wasm32"))]
pub type StorageHostFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, String>> + Send + 'a>>;
pub trait AsyncStorageHostBackend: std::fmt::Debug + Send + Sync {
fn set<'a>(
&'a self,
namespace: &'a str,
key: &'a str,
value: &'a [u8],
) -> StorageHostFuture<'a, ()>;
fn get<'a>(
&'a self,
namespace: &'a str,
key: &'a str,
) -> StorageHostFuture<'a, Option<Vec<u8>>>;
fn delete<'a>(&'a self, namespace: &'a str, key: &'a str) -> StorageHostFuture<'a, bool>;
fn list_keys<'a>(&'a self, namespace: &'a str) -> StorageHostFuture<'a, Vec<String>>;
fn clear_namespace<'a>(&'a self, namespace: &'a str) -> StorageHostFuture<'a, usize>;
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct StorageKey {
backend: StorageBackend,
namespace: String,
key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct StorageNamespaceKey {
backend: StorageBackend,
namespace: String,
}
#[derive(Debug, Clone)]
pub struct BrowserStorageAdapter {
cap: BrowserStorageIoCap,
entries: BTreeMap<StorageKey, Vec<u8>>,
list_snapshot: BTreeMap<StorageNamespaceKey, Vec<String>>,
host_backends: BTreeMap<StorageBackend, Arc<dyn StorageHostBackend>>,
async_host_backends: BTreeMap<StorageBackend, Arc<dyn AsyncStorageHostBackend>>,
unavailable_backends: BTreeMap<StorageBackend, bool>,
used_bytes: usize,
events: Vec<StorageEvent>,
}
impl BrowserStorageAdapter {
#[must_use]
pub fn new(cap: BrowserStorageIoCap) -> Self {
Self {
cap,
entries: BTreeMap::new(),
list_snapshot: BTreeMap::new(),
host_backends: BTreeMap::new(),
async_host_backends: BTreeMap::new(),
unavailable_backends: BTreeMap::new(),
used_bytes: 0,
events: Vec::new(),
}
}
#[must_use]
pub fn cap(&self) -> &BrowserStorageIoCap {
&self.cap
}
#[must_use]
pub fn used_bytes(&self) -> usize {
self.used_bytes
}
#[must_use]
pub fn entry_count(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn events(&self) -> &[StorageEvent] {
&self.events
}
pub fn register_host_backend(
&mut self,
backend: StorageBackend,
host_backend: Arc<dyn StorageHostBackend>,
) {
self.host_backends.insert(backend, host_backend);
}
pub fn register_async_host_backend(
&mut self,
backend: StorageBackend,
host_backend: Arc<dyn AsyncStorageHostBackend>,
) {
self.async_host_backends.insert(backend, host_backend);
}
#[cfg(target_arch = "wasm32")]
pub fn register_wasm_local_storage_backend(&mut self) {
self.register_host_backend(
StorageBackend::LocalStorage,
Arc::new(LocalStorageHostBackend),
);
}
#[cfg(target_arch = "wasm32")]
pub fn register_wasm_indexed_db_backend(&mut self) {
self.register_async_host_backend(StorageBackend::IndexedDb, Arc::new(IndexedDbHostBackend));
}
pub fn unregister_host_backend(
&mut self,
backend: StorageBackend,
) -> Option<Arc<dyn StorageHostBackend>> {
self.host_backends.remove(&backend)
}
pub fn unregister_async_host_backend(
&mut self,
backend: StorageBackend,
) -> Option<Arc<dyn AsyncStorageHostBackend>> {
self.async_host_backends.remove(&backend)
}
#[must_use]
pub fn has_host_backend(&self, backend: StorageBackend) -> bool {
self.host_backends.contains_key(&backend)
}
#[must_use]
pub fn has_async_host_backend(&self, backend: StorageBackend) -> bool {
self.async_host_backends.contains_key(&backend)
}
pub fn set_backend_available(&mut self, backend: StorageBackend, available: bool) {
if available {
self.unavailable_backends.remove(&backend);
} else {
self.unavailable_backends.insert(backend, false);
}
}
#[must_use]
pub fn backend_available(&self, backend: StorageBackend) -> bool {
self.unavailable_backends
.get(&backend)
.copied()
.unwrap_or(true)
}
pub fn flush_namespace_list_view(
&mut self,
backend: StorageBackend,
namespace: impl Into<String>,
) {
let namespace = namespace.into();
self.recompute_list_snapshot(backend, &namespace);
}
pub fn set(
&mut self,
backend: StorageBackend,
namespace: impl Into<String>,
key: impl Into<String>,
value: Vec<u8>,
) -> Result<(), BrowserStorageError> {
let namespace = namespace.into();
let key = key.into();
let request = StorageRequest::set(backend, namespace.clone(), key.clone(), value.len());
self.authorize_request(&request)?;
if self.async_host_backend(backend).is_some() {
return self.sync_backend_requires_async(&request);
}
let storage_key = StorageKey {
backend,
namespace: namespace.clone(),
key: key.clone(),
};
let projected_bytes = self.project_entry_quota(&request, &storage_key, value.len())?;
if let Some(host_backend) = self.host_backend(backend) {
if let Err(message) = host_backend.set(&namespace, &key, &value) {
return self.host_backend_error(&request, message);
}
}
self.used_bytes = projected_bytes;
self.entries.insert(storage_key, value);
Ok(self.complete_allowed(&request, ()))
}
pub fn get(
&mut self,
backend: StorageBackend,
namespace: impl Into<String>,
key: impl Into<String>,
) -> Result<Option<Vec<u8>>, BrowserStorageError> {
let namespace = namespace.into();
let key = key.into();
let request = StorageRequest::get(backend, namespace.clone(), key.clone());
self.authorize_request(&request)?;
if self.async_host_backend(backend).is_some() {
return self.sync_backend_requires_async(&request);
}
let storage_key = StorageKey {
backend,
namespace,
key,
};
if let Some(host_backend) = self.host_backend(backend) {
let value = match host_backend.get(&storage_key.namespace, &storage_key.key) {
Ok(value) => value,
Err(message) => return self.host_backend_error(&request, message),
};
self.sync_host_read_cache(&request, &storage_key, value.as_deref())?;
return Ok(self.complete_allowed(&request, value));
}
let value = self.entries.get(&storage_key).cloned();
Ok(self.complete_allowed(&request, value))
}
pub fn delete(
&mut self,
backend: StorageBackend,
namespace: impl Into<String>,
key: impl Into<String>,
) -> Result<bool, BrowserStorageError> {
let namespace = namespace.into();
let key = key.into();
let request = StorageRequest::delete(backend, namespace.clone(), key.clone());
self.authorize_request(&request)?;
if self.async_host_backend(backend).is_some() {
return self.sync_backend_requires_async(&request);
}
let storage_key = StorageKey {
backend,
namespace: namespace.clone(),
key: key.clone(),
};
if let Some(host_backend) = self.host_backend(backend) {
let deleted = match host_backend.delete(&namespace, &key) {
Ok(deleted) => deleted,
Err(message) => return self.host_backend_error(&request, message),
};
self.remove_cached_entry(&storage_key);
return Ok(self.complete_allowed(&request, deleted));
}
let removed = self.entries.remove(&storage_key);
let deleted = if let Some(old) = removed {
self.used_bytes =
self.used_bytes
.saturating_sub(entry_size(&namespace, &key, old.len()));
true
} else {
false
};
Ok(self.complete_allowed(&request, deleted))
}
pub fn list_keys(
&mut self,
backend: StorageBackend,
namespace: impl Into<String>,
) -> Result<Vec<String>, BrowserStorageError> {
let namespace = namespace.into();
let request = StorageRequest::list_keys(backend, namespace.clone());
self.authorize_request(&request)?;
if self.async_host_backend(backend).is_some() {
return self.sync_backend_requires_async(&request);
}
if let Some(host_backend) = self.host_backend(backend) {
if self.cap.consistency_policy() == StorageConsistencyPolicy::ImmediateReadAfterWrite {
let keys = self.host_backend_list_keys(&request, &*host_backend, &namespace)?;
return Ok(self.complete_allowed(&request, keys));
}
let namespace_key = StorageNamespaceKey {
backend,
namespace: namespace.clone(),
};
let visible = self
.list_snapshot
.get(&namespace_key)
.cloned()
.unwrap_or_default();
let next = self.host_backend_list_keys(&request, &*host_backend, &namespace)?;
self.list_snapshot.insert(namespace_key, next);
return Ok(self.complete_allowed(&request, visible));
}
if self.cap.consistency_policy() == StorageConsistencyPolicy::ImmediateReadAfterWrite {
let keys = self.collect_namespace_keys(backend, &namespace);
return Ok(self.complete_allowed(&request, keys));
}
let namespace_key = StorageNamespaceKey {
backend,
namespace: namespace.clone(),
};
let visible = self
.list_snapshot
.get(&namespace_key)
.cloned()
.unwrap_or_default();
self.recompute_list_snapshot(backend, &namespace);
Ok(self.complete_allowed(&request, visible))
}
pub fn clear_namespace(
&mut self,
backend: StorageBackend,
namespace: impl Into<String>,
) -> Result<usize, BrowserStorageError> {
let namespace = namespace.into();
let request = StorageRequest::clear_namespace(backend, namespace.clone());
self.authorize_request(&request)?;
if self.async_host_backend(backend).is_some() {
return self.sync_backend_requires_async(&request);
}
if let Some(host_backend) = self.host_backend(backend) {
let removed_count = match host_backend.clear_namespace(&namespace) {
Ok(removed_count) => removed_count,
Err(message) => return self.host_backend_error(&request, message),
};
self.remove_cached_namespace(backend, &namespace);
return Ok(self.complete_allowed(&request, removed_count));
}
let keys_to_remove: Vec<StorageKey> = self
.entries
.keys()
.filter(|candidate| candidate.backend == backend && candidate.namespace == namespace)
.cloned()
.collect();
let removed_count = keys_to_remove.len();
for key in keys_to_remove {
if let Some(value) = self.entries.remove(&key) {
self.used_bytes = self.used_bytes.saturating_sub(entry_size(
&key.namespace,
&key.key,
value.len(),
));
}
}
Ok(self.complete_allowed(&request, removed_count))
}
#[allow(clippy::future_not_send)]
pub async fn set_async(
&mut self,
backend: StorageBackend,
namespace: impl Into<String>,
key: impl Into<String>,
value: Vec<u8>,
) -> Result<(), BrowserStorageError> {
let namespace = namespace.into();
let key = key.into();
let request = StorageRequest::set(backend, namespace.clone(), key.clone(), value.len());
let Some(host_backend) = self.async_host_backend(backend) else {
return self.set(backend, namespace, key, value);
};
self.authorize_request(&request)?;
let storage_key = StorageKey {
backend,
namespace: namespace.clone(),
key: key.clone(),
};
let projected_bytes = self.project_entry_quota(&request, &storage_key, value.len())?;
if let Err(message) = host_backend.set(&namespace, &key, &value).await {
return self.host_backend_error(&request, message);
}
self.used_bytes = projected_bytes;
self.entries.insert(storage_key, value);
Ok(self.complete_allowed(&request, ()))
}
#[allow(clippy::future_not_send)]
pub async fn get_async(
&mut self,
backend: StorageBackend,
namespace: impl Into<String>,
key: impl Into<String>,
) -> Result<Option<Vec<u8>>, BrowserStorageError> {
let namespace = namespace.into();
let key = key.into();
let request = StorageRequest::get(backend, namespace.clone(), key.clone());
let Some(host_backend) = self.async_host_backend(backend) else {
return self.get(backend, namespace, key);
};
self.authorize_request(&request)?;
let storage_key = StorageKey {
backend,
namespace,
key,
};
let value = match host_backend
.get(&storage_key.namespace, &storage_key.key)
.await
{
Ok(value) => value,
Err(message) => return self.host_backend_error(&request, message),
};
self.sync_host_read_cache(&request, &storage_key, value.as_deref())?;
Ok(self.complete_allowed(&request, value))
}
#[allow(clippy::future_not_send)]
pub async fn delete_async(
&mut self,
backend: StorageBackend,
namespace: impl Into<String>,
key: impl Into<String>,
) -> Result<bool, BrowserStorageError> {
let namespace = namespace.into();
let key = key.into();
let request = StorageRequest::delete(backend, namespace.clone(), key.clone());
let Some(host_backend) = self.async_host_backend(backend) else {
return self.delete(backend, namespace, key);
};
self.authorize_request(&request)?;
let storage_key = StorageKey {
backend,
namespace: namespace.clone(),
key: key.clone(),
};
let deleted = match host_backend.delete(&namespace, &key).await {
Ok(deleted) => deleted,
Err(message) => return self.host_backend_error(&request, message),
};
self.remove_cached_entry(&storage_key);
Ok(self.complete_allowed(&request, deleted))
}
#[allow(clippy::future_not_send)]
pub async fn list_keys_async(
&mut self,
backend: StorageBackend,
namespace: impl Into<String>,
) -> Result<Vec<String>, BrowserStorageError> {
let namespace = namespace.into();
let request = StorageRequest::list_keys(backend, namespace.clone());
let Some(host_backend) = self.async_host_backend(backend) else {
return self.list_keys(backend, namespace);
};
self.authorize_request(&request)?;
if self.cap.consistency_policy() == StorageConsistencyPolicy::ImmediateReadAfterWrite {
let keys = self
.async_host_backend_list_keys(&request, &*host_backend, &namespace)
.await?;
return Ok(self.complete_allowed(&request, keys));
}
let namespace_key = StorageNamespaceKey {
backend,
namespace: namespace.clone(),
};
let visible = self
.list_snapshot
.get(&namespace_key)
.cloned()
.unwrap_or_default();
let next = self
.async_host_backend_list_keys(&request, &*host_backend, &namespace)
.await?;
self.list_snapshot.insert(namespace_key, next);
Ok(self.complete_allowed(&request, visible))
}
#[allow(clippy::future_not_send)]
pub async fn clear_namespace_async(
&mut self,
backend: StorageBackend,
namespace: impl Into<String>,
) -> Result<usize, BrowserStorageError> {
let namespace = namespace.into();
let request = StorageRequest::clear_namespace(backend, namespace.clone());
let Some(host_backend) = self.async_host_backend(backend) else {
return self.clear_namespace(backend, namespace);
};
self.authorize_request(&request)?;
let removed_count = match host_backend.clear_namespace(&namespace).await {
Ok(removed_count) => removed_count,
Err(message) => return self.host_backend_error(&request, message),
};
self.remove_cached_namespace(backend, &namespace);
Ok(self.complete_allowed(&request, removed_count))
}
fn authorize_request(&mut self, request: &StorageRequest) -> Result<(), BrowserStorageError> {
match self.cap.authorize(request) {
Ok(()) => {
if !self.backend_available(request.backend) {
return self.backend_unavailable(request);
}
Ok(())
}
Err(error) => self.policy_error(request, error),
}
}
fn complete_allowed<T>(&mut self, request: &StorageRequest, value: T) -> T {
self.record_event(
request,
StorageEventOutcome::Allowed,
StorageEventReasonCode::Allowed,
);
value
}
fn policy_error<T>(
&mut self,
request: &StorageRequest,
error: StoragePolicyError,
) -> Result<T, BrowserStorageError> {
self.record_event(
request,
StorageEventOutcome::Denied,
reason_code_for_policy_error(&error),
);
Err(BrowserStorageError::Policy(error))
}
fn backend_unavailable<T>(
&mut self,
request: &StorageRequest,
) -> Result<T, BrowserStorageError> {
self.record_event(
request,
StorageEventOutcome::Denied,
StorageEventReasonCode::BackendUnavailable,
);
Err(BrowserStorageError::BackendUnavailable(request.backend))
}
fn host_backend_error<T>(
&mut self,
request: &StorageRequest,
message: String,
) -> Result<T, BrowserStorageError> {
self.record_event(
request,
StorageEventOutcome::Denied,
StorageEventReasonCode::HostBackendError,
);
Err(BrowserStorageError::HostBackend {
backend: request.backend,
operation: request.operation,
message,
})
}
fn host_backend(&self, backend: StorageBackend) -> Option<Arc<dyn StorageHostBackend>> {
self.host_backends.get(&backend).cloned()
}
fn async_host_backend(
&self,
backend: StorageBackend,
) -> Option<Arc<dyn AsyncStorageHostBackend>> {
self.async_host_backends.get(&backend).cloned()
}
fn sync_backend_requires_async<T>(
&mut self,
request: &StorageRequest,
) -> Result<T, BrowserStorageError> {
self.host_backend_error(
request,
"backend requires async browser storage adapter methods".to_owned(),
)
}
fn normalize_listed_keys(mut keys: Vec<String>) -> Vec<String> {
keys.sort();
keys.dedup();
keys
}
fn host_backend_list_keys(
&mut self,
request: &StorageRequest,
backend: &dyn StorageHostBackend,
namespace: &str,
) -> Result<Vec<String>, BrowserStorageError> {
let keys = match backend.list_keys(namespace) {
Ok(keys) => keys,
Err(message) => return self.host_backend_error(request, message),
};
Ok(Self::normalize_listed_keys(keys))
}
#[allow(clippy::future_not_send)]
async fn async_host_backend_list_keys(
&mut self,
request: &StorageRequest,
backend: &dyn AsyncStorageHostBackend,
namespace: &str,
) -> Result<Vec<String>, BrowserStorageError> {
let keys = match backend.list_keys(namespace).await {
Ok(keys) => keys,
Err(message) => return self.host_backend_error(request, message),
};
Ok(Self::normalize_listed_keys(keys))
}
fn project_entry_quota(
&mut self,
request: &StorageRequest,
storage_key: &StorageKey,
value_len: usize,
) -> Result<usize, BrowserStorageError> {
let quota = self.cap.quota_policy();
if value_len > quota.max_value_bytes {
return self.policy_error(
request,
StoragePolicyError::ValueTooLarge {
len: value_len,
limit: quota.max_value_bytes,
},
);
}
let new_entry_size = entry_size(&storage_key.namespace, &storage_key.key, value_len);
let old_entry_size = self.entries.get(storage_key).map_or(0, |old| {
entry_size(&storage_key.namespace, &storage_key.key, old.len())
});
let projected_entries = if self.entries.contains_key(storage_key) {
self.entries.len()
} else {
self.entries.len() + 1
};
if projected_entries > quota.max_entries {
return self.policy_error(
request,
StoragePolicyError::EntryCountExceeded {
projected: projected_entries,
limit: quota.max_entries,
},
);
}
let projected_bytes = self.used_bytes - old_entry_size + new_entry_size;
if projected_bytes > quota.max_total_bytes {
return self.policy_error(
request,
StoragePolicyError::QuotaExceeded {
projected_bytes,
limit_bytes: quota.max_total_bytes,
},
);
}
Ok(projected_bytes)
}
fn sync_host_read_cache(
&mut self,
request: &StorageRequest,
storage_key: &StorageKey,
value: Option<&[u8]>,
) -> Result<(), BrowserStorageError> {
let Some(value) = value else {
self.remove_cached_entry(storage_key);
return Ok(());
};
let observed_request = request.clone().with_value_len(value.len());
let projected_bytes =
self.project_entry_quota(&observed_request, storage_key, value.len())?;
self.used_bytes = projected_bytes;
self.entries.insert(storage_key.clone(), value.to_vec());
Ok(())
}
fn remove_cached_entry(&mut self, storage_key: &StorageKey) {
if let Some(previous) = self.entries.remove(storage_key) {
self.used_bytes = self.used_bytes.saturating_sub(entry_size(
&storage_key.namespace,
&storage_key.key,
previous.len(),
));
}
}
fn remove_cached_namespace(&mut self, backend: StorageBackend, namespace: &str) {
let keys_to_remove: Vec<StorageKey> = self
.entries
.keys()
.filter(|candidate| candidate.backend == backend && candidate.namespace == namespace)
.cloned()
.collect();
for key in keys_to_remove {
self.remove_cached_entry(&key);
}
}
fn collect_namespace_keys(&self, backend: StorageBackend, namespace: &str) -> Vec<String> {
self.entries
.keys()
.filter(|candidate| candidate.backend == backend && candidate.namespace == namespace)
.map(|candidate| candidate.key.clone())
.collect()
}
fn recompute_list_snapshot(&mut self, backend: StorageBackend, namespace: &str) {
let key = StorageNamespaceKey {
backend,
namespace: namespace.to_owned(),
};
self.list_snapshot
.insert(key, self.collect_namespace_keys(backend, namespace));
}
fn record_event(
&mut self,
request: &StorageRequest,
outcome: StorageEventOutcome,
reason_code: StorageEventReasonCode,
) {
let redaction = self.cap.redaction_policy();
let namespace_label = if redaction.redact_namespaces {
format!("namespace[len:{}]", request.namespace.len())
} else {
request.namespace.clone()
};
let key_label = request.key.as_ref().map(|key| {
if redaction.redact_keys {
format!("key[len:{}]", key.len())
} else {
key.clone()
}
});
let value_len = if redaction.redact_value_lengths {
None
} else {
Some(request.value_len)
};
self.events.push(StorageEvent {
operation: request.operation,
backend: request.backend,
namespace_label,
key_label,
value_len,
outcome,
reason_code,
});
}
}
fn reason_code_for_policy_error(error: &StoragePolicyError) -> StorageEventReasonCode {
match error {
StoragePolicyError::InvalidNamespace(_) => StorageEventReasonCode::InvalidNamespace,
StoragePolicyError::BackendDenied(_) => StorageEventReasonCode::BackendDenied,
StoragePolicyError::NamespaceDenied(_) => StorageEventReasonCode::NamespaceDenied,
StoragePolicyError::OperationDenied(_) => StorageEventReasonCode::OperationDenied,
StoragePolicyError::MissingKey(_) => StorageEventReasonCode::MissingKey,
StoragePolicyError::KeyTooLarge { .. } => StorageEventReasonCode::KeyTooLarge,
StoragePolicyError::ValueTooLarge { .. } => StorageEventReasonCode::ValueTooLarge,
StoragePolicyError::NamespaceTooLarge { .. } => StorageEventReasonCode::NamespaceTooLarge,
StoragePolicyError::EntryCountExceeded { .. } => StorageEventReasonCode::EntryCountExceeded,
StoragePolicyError::QuotaExceeded { .. } => StorageEventReasonCode::QuotaExceeded,
}
}
fn entry_size(namespace: &str, key: &str, value_len: usize) -> usize {
namespace.len() + key.len() + value_len
}
#[cfg(target_arch = "wasm32")]
#[derive(Debug, Default)]
pub struct LocalStorageHostBackend;
#[cfg(target_arch = "wasm32")]
impl LocalStorageHostBackend {
const KEY_PREFIX: &'static str = "asupersync:storage:v1:";
fn with_storage<T>(f: impl FnOnce(Storage) -> Result<T, String>) -> Result<T, String> {
let window = web_sys::window().ok_or_else(|| "window is unavailable".to_owned())?;
let storage = window
.local_storage()
.map_err(|error| format!("failed to access localStorage: {error:?}"))?
.ok_or_else(|| "localStorage is unavailable".to_owned())?;
f(storage)
}
fn key_prefix(namespace: &str) -> String {
let encoded_namespace = URL_SAFE_NO_PAD.encode(namespace.as_bytes());
format!("{}{encoded_namespace}:", Self::KEY_PREFIX)
}
fn encode_storage_key(namespace: &str, key: &str) -> String {
let mut prefixed = Self::key_prefix(namespace);
prefixed.push_str(&URL_SAFE_NO_PAD.encode(key.as_bytes()));
prefixed
}
fn decode_key_segment(encoded: &str) -> Option<String> {
URL_SAFE_NO_PAD
.decode(encoded)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
}
fn decode_storage_key(full_key: &str, namespace: &str) -> Option<String> {
let prefix = Self::key_prefix(namespace);
full_key
.strip_prefix(&prefix)
.and_then(Self::decode_key_segment)
}
}
#[cfg(target_arch = "wasm32")]
impl StorageHostBackend for LocalStorageHostBackend {
fn set(&self, namespace: &str, key: &str, value: &[u8]) -> Result<(), String> {
let storage_key = Self::encode_storage_key(namespace, key);
let encoded_value = URL_SAFE_NO_PAD.encode(value);
Self::with_storage(|storage| {
storage
.set_item(&storage_key, &encoded_value)
.map_err(|error| format!("localStorage set_item failed: {error:?}"))
})
}
fn get(&self, namespace: &str, key: &str) -> Result<Option<Vec<u8>>, String> {
let storage_key = Self::encode_storage_key(namespace, key);
Self::with_storage(|storage| {
let encoded = storage
.get_item(&storage_key)
.map_err(|error| format!("localStorage get_item failed: {error:?}"))?;
encoded
.map(|payload| {
URL_SAFE_NO_PAD
.decode(payload.as_bytes()) .map_err(|error| format!("failed to decode localStorage payload: {error}"))
})
.transpose()
})
}
fn delete(&self, namespace: &str, key: &str) -> Result<bool, String> {
let storage_key = Self::encode_storage_key(namespace, key);
Self::with_storage(|storage| {
let existed = storage
.get_item(&storage_key)
.map_err(|error| format!("localStorage get_item failed: {error:?}"))?
.is_some();
storage
.remove_item(&storage_key)
.map_err(|error| format!("localStorage remove_item failed: {error:?}"))?;
Ok(existed)
})
}
fn list_keys(&self, namespace: &str) -> Result<Vec<String>, String> {
Self::with_storage(|storage| {
let mut keys = Vec::new();
let len = storage
.length()
.map_err(|error| format!("localStorage length failed: {error:?}"))?;
for index in 0..len {
let maybe_key = storage
.key(index)
.map_err(|error| format!("localStorage key({index}) failed: {error:?}"))?;
if let Some(full_key) = maybe_key {
if let Some(decoded) = Self::decode_storage_key(&full_key, namespace) {
keys.push(decoded);
}
}
}
Ok(keys)
})
}
fn clear_namespace(&self, namespace: &str) -> Result<usize, String> {
let keys = self.list_keys(namespace)?;
for key in &keys {
let storage_key = Self::encode_storage_key(namespace, key);
Self::with_storage(|storage| {
storage
.remove_item(&storage_key)
.map_err(|error| format!("localStorage remove_item failed: {error:?}"))
})?;
}
Ok(keys.len())
}
}
#[cfg(target_arch = "wasm32")]
#[derive(Debug, Default)]
pub struct IndexedDbHostBackend;
#[cfg(target_arch = "wasm32")]
impl IndexedDbHostBackend {
const DB_NAME: &'static str = "asupersync_storage_v1";
const STORE_NAME: &'static str = "entries";
const KEY_PREFIX: &'static str = "asupersync:indexeddb:v1:";
const DB_VERSION: u32 = 1;
fn key_prefix(namespace: &str) -> String {
let encoded_namespace = URL_SAFE_NO_PAD.encode(namespace.as_bytes());
format!("{}{encoded_namespace}:", Self::KEY_PREFIX)
}
fn encode_storage_key(namespace: &str, key: &str) -> String {
let mut prefixed = Self::key_prefix(namespace);
prefixed.push_str(&URL_SAFE_NO_PAD.encode(key.as_bytes()));
prefixed
}
fn decode_storage_key(full_key: &str, namespace: &str) -> Option<String> {
let prefix = Self::key_prefix(namespace);
full_key.strip_prefix(&prefix).and_then(|encoded| {
URL_SAFE_NO_PAD
.decode(encoded)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
})
}
fn factory() -> Result<IdbFactory, String> {
if let Some(window) = web_sys::window() {
return window
.indexed_db()
.map_err(|error| format!("failed to access IndexedDB from Window: {error:?}"))?
.ok_or_else(|| "IndexedDB is unavailable on Window".to_owned());
}
if let Ok(worker) = js_sys::global().dyn_into::<WorkerGlobalScope>() {
return worker
.indexed_db()
.map_err(|error| {
format!("failed to access IndexedDB from WorkerGlobalScope: {error:?}")
})?
.ok_or_else(|| "IndexedDB is unavailable in WorkerGlobalScope".to_owned());
}
Err("window or WorkerGlobalScope IndexedDB host is unavailable".to_owned())
}
#[allow(clippy::future_not_send)]
async fn database(&self) -> Result<IdbDatabase, String> {
let request = Self::factory()?
.open_with_u32(Self::DB_NAME, Self::DB_VERSION)
.map_err(|error| format!("failed to open IndexedDB database: {error:?}"))?;
await_open_request(&request, Self::STORE_NAME).await
}
#[allow(clippy::future_not_send)]
async fn store(
&self,
mode: IdbTransactionMode,
) -> Result<(IdbDatabase, IdbTransaction, IdbObjectStore), String> {
let database = self.database().await?;
let transaction = database
.transaction_with_str_and_mode(Self::STORE_NAME, mode)
.map_err(|error| format!("failed to open IndexedDB transaction: {error:?}"))?;
let store = transaction
.object_store(Self::STORE_NAME)
.map_err(|error| format!("failed to open IndexedDB object store: {error:?}"))?;
Ok((database, transaction, store))
}
fn decode_binary_value(value: JsValue) -> Result<Option<Vec<u8>>, String> {
if value.is_undefined() || value.is_null() {
return Ok(None);
}
if value.is_instance_of::<Uint8Array>() || value.is_instance_of::<js_sys::ArrayBuffer>() {
return Ok(Some(Uint8Array::new(&value).to_vec()));
}
Err(format!(
"IndexedDB returned a non-binary payload for browser storage: {value:?}"
))
}
}
#[cfg(target_arch = "wasm32")]
impl AsyncStorageHostBackend for IndexedDbHostBackend {
fn set<'a>(
&'a self,
namespace: &'a str,
key: &'a str,
value: &'a [u8],
) -> StorageHostFuture<'a, ()> {
Box::pin(async move {
let (_database, transaction, store) = self.store(IdbTransactionMode::Readwrite).await?;
let storage_key = Self::encode_storage_key(namespace, key);
let payload = Uint8Array::from(value);
let request = store
.put_with_key(&payload.into(), &JsValue::from_str(&storage_key))
.map_err(|error| format!("IndexedDB put failed to start: {error:?}"))?;
let _ = await_request(&request).await?;
await_transaction(&transaction).await
})
}
fn get<'a>(
&'a self,
namespace: &'a str,
key: &'a str,
) -> StorageHostFuture<'a, Option<Vec<u8>>> {
Box::pin(async move {
let (_database, _transaction, store) = self.store(IdbTransactionMode::Readonly).await?;
let storage_key = Self::encode_storage_key(namespace, key);
let request = store
.get(&JsValue::from_str(&storage_key))
.map_err(|error| format!("IndexedDB get failed to start: {error:?}"))?;
let value = await_request(&request).await?;
Self::decode_binary_value(value)
})
}
fn delete<'a>(&'a self, namespace: &'a str, key: &'a str) -> StorageHostFuture<'a, bool> {
Box::pin(async move {
let (_database, transaction, store) = self.store(IdbTransactionMode::Readwrite).await?;
let storage_key = Self::encode_storage_key(namespace, key);
let existing_request = store
.get(&JsValue::from_str(&storage_key))
.map_err(|error| format!("IndexedDB existence check failed to start: {error:?}"))?;
let existed = !await_request(&existing_request).await?.is_undefined();
let delete_request = store
.delete(&JsValue::from_str(&storage_key))
.map_err(|error| format!("IndexedDB delete failed to start: {error:?}"))?;
let _ = await_request(&delete_request).await?;
await_transaction(&transaction).await?;
Ok(existed)
})
}
fn list_keys<'a>(&'a self, namespace: &'a str) -> StorageHostFuture<'a, Vec<String>> {
Box::pin(async move {
let (_database, _transaction, store) = self.store(IdbTransactionMode::Readonly).await?;
let request = store
.get_all_keys()
.map_err(|error| format!("IndexedDB get_all_keys failed to start: {error:?}"))?;
let result = await_request(&request).await?;
let mut keys = Vec::new();
for value in Array::from(&result).iter() {
if let Some(full_key) = value.as_string() {
if let Some(decoded) = Self::decode_storage_key(&full_key, namespace) {
keys.push(decoded);
}
}
}
keys.sort();
keys.dedup();
Ok(keys)
})
}
fn clear_namespace<'a>(&'a self, namespace: &'a str) -> StorageHostFuture<'a, usize> {
Box::pin(async move {
let keys = self.list_keys(namespace).await?;
if keys.is_empty() {
return Ok(0);
}
let (_database, transaction, store) = self.store(IdbTransactionMode::Readwrite).await?;
for key in &keys {
let storage_key = Self::encode_storage_key(namespace, key);
let request = store
.delete(&JsValue::from_str(&storage_key))
.map_err(|error| {
format!(
"IndexedDB delete failed to start during clear_namespace: {error:?}"
)
})?;
let _ = await_request(&request).await?;
}
await_transaction(&transaction).await?;
Ok(keys.len())
})
}
}
#[cfg(target_arch = "wasm32")]
fn clear_idb_request_handlers(request: &IdbRequest) {
request.set_onsuccess(None);
request.set_onerror(None);
}
#[cfg(target_arch = "wasm32")]
fn clear_idb_open_request_handlers(request: &IdbOpenDbRequest) {
request.set_onsuccess(None);
request.set_onerror(None);
request.set_onblocked(None);
request.set_onupgradeneeded(None);
}
#[cfg(target_arch = "wasm32")]
fn clear_idb_transaction_handlers(transaction: &IdbTransaction) {
transaction.set_oncomplete(None);
transaction.set_onerror(None);
transaction.set_onabort(None);
}
#[cfg(target_arch = "wasm32")]
fn indexed_db_error_message(value: &JsValue) -> String {
value.as_string().unwrap_or_else(|| format!("{value:?}"))
}
#[cfg(target_arch = "wasm32")]
type IdbOpenRequestCallbacks = (
Closure<dyn FnMut(Event)>,
Closure<dyn FnMut(Event)>,
Closure<dyn FnMut(Event)>,
Closure<dyn FnMut(Event)>,
);
#[cfg(target_arch = "wasm32")]
struct IdbOpenRequestHandlerGuard {
cancelled: std::rc::Rc<std::cell::Cell<bool>>,
callbacks: std::rc::Rc<std::cell::RefCell<Option<IdbOpenRequestCallbacks>>>,
pending_database: std::rc::Rc<std::cell::RefCell<Option<IdbDatabase>>>,
}
#[cfg(target_arch = "wasm32")]
impl Drop for IdbOpenRequestHandlerGuard {
fn drop(&mut self) {
if self.callbacks.borrow().is_some() {
self.cancelled.set(true);
}
if let Some(database) = self.pending_database.borrow_mut().take() {
database.close();
}
}
}
#[cfg(target_arch = "wasm32")]
#[cfg(target_arch = "wasm32")]
struct IdbRequestHandlerGuard {
request: IdbRequest,
callbacks: std::rc::Rc<
std::cell::RefCell<Option<(Closure<dyn FnMut(Event)>, Closure<dyn FnMut(Event)>)>>,
>,
}
#[cfg(target_arch = "wasm32")]
impl Drop for IdbRequestHandlerGuard {
fn drop(&mut self) {
if self.callbacks.borrow().is_some() {
clear_idb_request_handlers(&self.request);
self.callbacks.borrow_mut().take();
}
}
}
#[cfg(target_arch = "wasm32")]
#[allow(clippy::future_not_send)]
async fn await_request(request: &IdbRequest) -> Result<JsValue, String> {
let request = request.clone();
let callbacks: std::rc::Rc<
std::cell::RefCell<Option<(Closure<dyn FnMut(Event)>, Closure<dyn FnMut(Event)>)>>,
> = std::rc::Rc::new(std::cell::RefCell::new(None));
let _guard = IdbRequestHandlerGuard {
request: request.clone(),
callbacks: callbacks.clone(),
};
let cb_for_promise = callbacks.clone();
let promise = Promise::new(&mut move |resolve, reject| {
let success_request = request.clone();
let success_cleanup = request.clone();
let resolve_success = resolve.clone();
let reject_success = reject.clone();
let success_callbacks = cb_for_promise.clone();
let on_success: Closure<dyn FnMut(Event)> = Closure::new(move |_event: Event| {
clear_idb_request_handlers(&success_cleanup);
let _ = success_callbacks.borrow_mut().take();
match success_request.result() {
Ok(value) => {
let _ = resolve_success.call1(&JsValue::UNDEFINED, &value);
}
Err(error) => {
let _ = reject_success.call1(&JsValue::UNDEFINED, &error);
}
}
});
let error_request = request.clone();
let error_cleanup = request.clone();
let reject_error = reject.clone();
let error_callbacks = cb_for_promise.clone();
let on_error: Closure<dyn FnMut(Event)> = Closure::new(move |_event: Event| {
clear_idb_request_handlers(&error_cleanup);
let _ = error_callbacks.borrow_mut().take();
let error = error_request.error().map_or_else(
|_| JsValue::from_str("IndexedDB request failed"),
JsValue::from,
);
let _ = reject_error.call1(&JsValue::UNDEFINED, &error);
});
request.set_onsuccess(Some(on_success.as_ref().unchecked_ref()));
request.set_onerror(Some(on_error.as_ref().unchecked_ref()));
*cb_for_promise.borrow_mut() = Some((on_success, on_error));
});
JsFuture::from(promise)
.await
.map_err(|error| indexed_db_error_message(&error))
}
#[cfg(target_arch = "wasm32")]
#[allow(clippy::future_not_send)]
async fn await_open_request(
request: &IdbOpenDbRequest,
store_name: &'static str,
) -> Result<IdbDatabase, String> {
let request = request.clone();
let cancelled = std::rc::Rc::new(std::cell::Cell::new(false));
let callbacks: std::rc::Rc<std::cell::RefCell<Option<IdbOpenRequestCallbacks>>> =
std::rc::Rc::new(std::cell::RefCell::new(None));
let pending_database = std::rc::Rc::new(std::cell::RefCell::new(None));
let _guard = IdbOpenRequestHandlerGuard {
cancelled: cancelled.clone(),
callbacks: callbacks.clone(),
pending_database: pending_database.clone(),
};
let cb_for_promise = callbacks.clone();
let pending_database_for_promise = pending_database.clone();
let promise = Promise::new(&mut move |resolve, reject| {
let success_request = request.clone();
let success_cleanup = request.clone();
let resolve_success = resolve.clone();
let reject_success = reject.clone();
let success_callbacks = cb_for_promise.clone();
let success_cancelled = cancelled.clone();
let success_database = pending_database_for_promise.clone();
let on_success: Closure<dyn FnMut(Event)> = Closure::new(move |_event: Event| {
clear_idb_open_request_handlers(&success_cleanup);
let callbacks = success_callbacks.borrow_mut().take();
match success_request.result() {
Ok(value) => match value.dyn_into::<IdbDatabase>() {
Ok(database) if success_cancelled.get() => database.close(),
Ok(database) => {
let _ = success_database.borrow_mut().replace(database);
let _ = resolve_success.call0(&JsValue::UNDEFINED);
}
Err(value) => {
let _ = reject_success.call1(&JsValue::UNDEFINED, &value);
}
},
Err(error) => {
let _ = reject_success.call1(&JsValue::UNDEFINED, &error);
}
}
drop(callbacks);
});
let error_request = request.clone();
let error_cleanup = request.clone();
let reject_error = reject.clone();
let error_callbacks = cb_for_promise.clone();
let error_cancelled = cancelled.clone();
let on_error: Closure<dyn FnMut(Event)> = Closure::new(move |_event: Event| {
clear_idb_open_request_handlers(&error_cleanup);
let callbacks = error_callbacks.borrow_mut().take();
if !error_cancelled.get() {
let error = error_request.error().map_or_else(
|_| JsValue::from_str("IndexedDB open failed"),
JsValue::from,
);
let _ = reject_error.call1(&JsValue::UNDEFINED, &error);
}
drop(callbacks);
});
let on_blocked: Closure<dyn FnMut(Event)> = Closure::new(move |_event: Event| {
});
let upgrade_request = request.clone();
let upgrade_cancelled = cancelled.clone();
let on_upgrade: Closure<dyn FnMut(Event)> = Closure::new(move |_event: Event| {
if upgrade_cancelled.get() {
if let Some(transaction) = upgrade_request.transaction() {
let _ = transaction.abort();
}
if let Ok(value) = upgrade_request.result() {
if let Ok(database) = value.dyn_into::<IdbDatabase>() {
database.close();
}
}
return;
}
if let Ok(value) = upgrade_request.result() {
if let Ok(database) = value.dyn_into::<IdbDatabase>() {
let _ = database.create_object_store(store_name);
}
}
});
request.set_onsuccess(Some(on_success.as_ref().unchecked_ref()));
request.set_onerror(Some(on_error.as_ref().unchecked_ref()));
request.set_onblocked(Some(on_blocked.as_ref().unchecked_ref()));
request.set_onupgradeneeded(Some(on_upgrade.as_ref().unchecked_ref()));
*cb_for_promise.borrow_mut() = Some((on_success, on_error, on_blocked, on_upgrade));
});
let result = JsFuture::from(promise)
.await
.map_err(|error| indexed_db_error_message(&error));
match result {
Ok(_) => {
let database = pending_database.borrow_mut().take();
database.ok_or_else(|| "IndexedDB open completed without a database".to_owned())
}
Err(error) => Err(error),
}
}
#[cfg(target_arch = "wasm32")]
#[allow(clippy::future_not_send)]
async fn await_transaction(transaction: &IdbTransaction) -> Result<(), String> {
let transaction = transaction.clone();
let promise = Promise::new(&mut move |resolve, reject| {
let callbacks = std::rc::Rc::new(std::cell::RefCell::new(None));
let complete_cleanup = transaction.clone();
let resolve_complete = resolve.clone();
let complete_callbacks = callbacks.clone();
let on_complete: Closure<dyn FnMut(Event)> = Closure::new(move |_event: Event| {
clear_idb_transaction_handlers(&complete_cleanup);
let _ = complete_callbacks.borrow_mut().take();
let _ = resolve_complete.call0(&JsValue::UNDEFINED);
});
let error_cleanup = transaction.clone();
let reject_error = reject.clone();
let error_callbacks = callbacks.clone();
let on_error: Closure<dyn FnMut(Event)> = Closure::new(move |_event: Event| {
clear_idb_transaction_handlers(&error_cleanup);
let _ = error_callbacks.borrow_mut().take();
let _ = reject_error.call1(
&JsValue::UNDEFINED,
&JsValue::from_str("IndexedDB transaction failed"),
);
});
let abort_cleanup = transaction.clone();
let reject_abort = reject.clone();
let abort_callbacks = callbacks.clone();
let on_abort: Closure<dyn FnMut(Event)> = Closure::new(move |_event: Event| {
clear_idb_transaction_handlers(&abort_cleanup);
let _ = abort_callbacks.borrow_mut().take();
let _ = reject_abort.call1(
&JsValue::UNDEFINED,
&JsValue::from_str("IndexedDB transaction aborted"),
);
});
transaction.set_oncomplete(Some(on_complete.as_ref().unchecked_ref()));
transaction.set_onerror(Some(on_error.as_ref().unchecked_ref()));
transaction.set_onabort(Some(on_abort.as_ref().unchecked_ref()));
*callbacks.borrow_mut() = Some((on_complete, on_error, on_abort));
});
JsFuture::from(promise)
.await
.map(|_| ())
.map_err(|error| indexed_db_error_message(&error))
}
#[cfg(test)]
mod tests {
#![allow(
clippy::pedantic,
clippy::nursery,
clippy::expect_fun_call,
clippy::map_unwrap_or,
clippy::cast_possible_wrap,
clippy::future_not_send
)]
use super::*;
use crate::io::cap::{
StorageAuthority, StorageConsistencyPolicy, StorageOperation, StorageQuotaPolicy,
StorageRedactionPolicy,
};
use std::sync::Mutex;
use std::task::{Context, Poll};
#[derive(Debug, Default)]
struct MockHostBackend {
entries: Mutex<BTreeMap<(String, String), Vec<u8>>>,
}
impl StorageHostBackend for MockHostBackend {
fn set(&self, namespace: &str, key: &str, value: &[u8]) -> Result<(), String> {
self.entries
.lock()
.expect("host backend lock poisoned")
.insert((namespace.to_owned(), key.to_owned()), value.to_vec());
Ok(())
}
fn get(&self, namespace: &str, key: &str) -> Result<Option<Vec<u8>>, String> {
Ok(self
.entries
.lock()
.expect("host backend lock poisoned")
.get(&(namespace.to_owned(), key.to_owned()))
.cloned())
}
fn delete(&self, namespace: &str, key: &str) -> Result<bool, String> {
Ok(self
.entries
.lock()
.expect("host backend lock poisoned")
.remove(&(namespace.to_owned(), key.to_owned()))
.is_some())
}
fn list_keys(&self, namespace: &str) -> Result<Vec<String>, String> {
let mut keys: Vec<String> = self
.entries
.lock()
.expect("host backend lock poisoned")
.keys()
.filter(|(candidate_namespace, _)| candidate_namespace == namespace)
.map(|(_, key)| key.clone())
.collect();
keys.sort();
Ok(keys)
}
fn clear_namespace(&self, namespace: &str) -> Result<usize, String> {
let mut entries = self.entries.lock().expect("host backend lock poisoned");
let initial_len = entries.len();
entries.retain(|(candidate_namespace, _), _| candidate_namespace != namespace);
Ok(initial_len.saturating_sub(entries.len()))
}
}
#[derive(Debug)]
struct FailingHostBackend;
impl StorageHostBackend for FailingHostBackend {
fn set(&self, _namespace: &str, _key: &str, _value: &[u8]) -> Result<(), String> {
Err("simulated host backend set failure".to_owned())
}
fn get(&self, _namespace: &str, _key: &str) -> Result<Option<Vec<u8>>, String> {
Err("simulated host backend get failure".to_owned())
}
fn delete(&self, _namespace: &str, _key: &str) -> Result<bool, String> {
Err("simulated host backend delete failure".to_owned())
}
fn list_keys(&self, _namespace: &str) -> Result<Vec<String>, String> {
Err("simulated host backend list failure".to_owned())
}
fn clear_namespace(&self, _namespace: &str) -> Result<usize, String> {
Err("simulated host backend clear failure".to_owned())
}
}
#[derive(Debug, Default)]
struct MockAsyncHostBackend {
entries: Mutex<BTreeMap<(String, String), Vec<u8>>>,
}
impl AsyncStorageHostBackend for MockAsyncHostBackend {
fn set<'a>(
&'a self,
namespace: &'a str,
key: &'a str,
value: &'a [u8],
) -> StorageHostFuture<'a, ()> {
Box::pin(async move {
self.entries
.lock()
.expect("async host backend lock poisoned")
.insert((namespace.to_owned(), key.to_owned()), value.to_vec());
Ok(())
})
}
fn get<'a>(
&'a self,
namespace: &'a str,
key: &'a str,
) -> StorageHostFuture<'a, Option<Vec<u8>>> {
Box::pin(async move {
Ok(self
.entries
.lock()
.expect("async host backend lock poisoned")
.get(&(namespace.to_owned(), key.to_owned()))
.cloned())
})
}
fn delete<'a>(&'a self, namespace: &'a str, key: &'a str) -> StorageHostFuture<'a, bool> {
Box::pin(async move {
Ok(self
.entries
.lock()
.expect("async host backend lock poisoned")
.remove(&(namespace.to_owned(), key.to_owned()))
.is_some())
})
}
fn list_keys<'a>(&'a self, namespace: &'a str) -> StorageHostFuture<'a, Vec<String>> {
Box::pin(async move {
let mut keys: Vec<String> = self
.entries
.lock()
.expect("async host backend lock poisoned")
.keys()
.filter(|(candidate_namespace, _)| candidate_namespace == namespace)
.map(|(_, key)| key.clone())
.collect();
keys.sort();
Ok(keys)
})
}
fn clear_namespace<'a>(&'a self, namespace: &'a str) -> StorageHostFuture<'a, usize> {
Box::pin(async move {
let mut entries = self
.entries
.lock()
.expect("async host backend lock poisoned");
let initial_len = entries.len();
entries.retain(|(candidate_namespace, _), _| candidate_namespace != namespace);
Ok(initial_len.saturating_sub(entries.len()))
})
}
}
#[derive(Debug)]
struct PendingAsyncHostBackend;
impl AsyncStorageHostBackend for PendingAsyncHostBackend {
fn set<'a>(
&'a self,
_namespace: &'a str,
_key: &'a str,
_value: &'a [u8],
) -> StorageHostFuture<'a, ()> {
Box::pin(std::future::pending())
}
fn get<'a>(
&'a self,
_namespace: &'a str,
_key: &'a str,
) -> StorageHostFuture<'a, Option<Vec<u8>>> {
Box::pin(std::future::pending())
}
fn delete<'a>(&'a self, _namespace: &'a str, _key: &'a str) -> StorageHostFuture<'a, bool> {
Box::pin(std::future::pending())
}
fn list_keys<'a>(&'a self, _namespace: &'a str) -> StorageHostFuture<'a, Vec<String>> {
Box::pin(std::future::pending())
}
fn clear_namespace<'a>(&'a self, _namespace: &'a str) -> StorageHostFuture<'a, usize> {
Box::pin(std::future::pending())
}
}
fn storage_cap_with_defaults() -> BrowserStorageIoCap {
BrowserStorageIoCap::new(
StorageAuthority::deny_all()
.grant_backend(StorageBackend::IndexedDb)
.grant_backend(StorageBackend::LocalStorage)
.grant_namespace("cache:*")
.grant_namespace("prefs:*")
.grant_operation(StorageOperation::Get)
.grant_operation(StorageOperation::Set)
.grant_operation(StorageOperation::Delete)
.grant_operation(StorageOperation::ListKeys)
.grant_operation(StorageOperation::ClearNamespace),
StorageQuotaPolicy {
max_total_bytes: 256,
max_key_bytes: 64,
max_value_bytes: 128,
max_namespace_bytes: 32,
max_entries: 16,
},
StorageConsistencyPolicy::ImmediateReadAfterWrite,
StorageRedactionPolicy::default(),
)
}
fn storage_cap_with_quota(quota: StorageQuotaPolicy) -> BrowserStorageIoCap {
BrowserStorageIoCap::new(
StorageAuthority::deny_all()
.grant_backend(StorageBackend::IndexedDb)
.grant_backend(StorageBackend::LocalStorage)
.grant_namespace("cache:*")
.grant_operation(StorageOperation::Get)
.grant_operation(StorageOperation::Set),
quota,
StorageConsistencyPolicy::ImmediateReadAfterWrite,
StorageRedactionPolicy::default(),
)
}
fn quota(
max_total_bytes: usize,
max_value_bytes: usize,
max_entries: usize,
) -> StorageQuotaPolicy {
StorageQuotaPolicy {
max_total_bytes,
max_key_bytes: 16,
max_value_bytes,
max_namespace_bytes: 16,
max_entries,
}
}
fn cached_value<'a>(
adapter: &'a BrowserStorageAdapter,
backend: StorageBackend,
namespace: &str,
key: &str,
) -> Option<&'a [u8]> {
adapter
.entries
.get(&StorageKey {
backend,
namespace: namespace.to_owned(),
key: key.to_owned(),
})
.map(Vec::as_slice)
}
fn assert_one_terminal_event_since(
adapter: &BrowserStorageAdapter,
before: usize,
outcome: StorageEventOutcome,
reason_code: StorageEventReasonCode,
) {
assert_eq!(adapter.events().len(), before + 1);
let event = &adapter.events()[before];
assert_eq!(event.outcome, outcome);
assert_eq!(event.reason_code, reason_code);
}
#[test]
fn sync_operations_emit_one_terminal_success_event_each() {
let mut adapter = BrowserStorageAdapter::new(storage_cap_with_defaults());
const BACKEND: StorageBackend = StorageBackend::LocalStorage;
const NAMESPACE: &str = "prefs:v1";
adapter
.set(BACKEND, NAMESPACE, "theme", b"dark".to_vec())
.expect("set succeeds");
adapter
.get(BACKEND, NAMESPACE, "theme")
.expect("get succeeds");
adapter
.list_keys(BACKEND, NAMESPACE)
.expect("list succeeds");
adapter
.delete(BACKEND, NAMESPACE, "theme")
.expect("delete succeeds");
adapter
.clear_namespace(BACKEND, NAMESPACE)
.expect("clear succeeds");
assert_eq!(adapter.events().len(), 5);
assert!(adapter.events().iter().all(|event| {
event.outcome == StorageEventOutcome::Allowed
&& event.reason_code == StorageEventReasonCode::Allowed
}));
}
#[test]
fn async_fallback_emits_one_terminal_event_per_result() {
futures_lite::future::block_on(async {
let mut adapter = BrowserStorageAdapter::new(storage_cap_with_defaults());
let before = adapter.events().len();
adapter
.set_async(
StorageBackend::LocalStorage,
"prefs:v1",
"theme",
b"dark".to_vec(),
)
.await
.expect("sync fallback succeeds");
assert_one_terminal_event_since(
&adapter,
before,
StorageEventOutcome::Allowed,
StorageEventReasonCode::Allowed,
);
let before = adapter.events().len();
let result = adapter
.set_async(
StorageBackend::LocalStorage,
"prefs:v1",
"oversized",
vec![0; 129],
)
.await;
assert!(matches!(
result,
Err(BrowserStorageError::Policy(
StoragePolicyError::ValueTooLarge { .. }
))
));
assert_one_terminal_event_since(
&adapter,
before,
StorageEventOutcome::Denied,
StorageEventReasonCode::ValueTooLarge,
);
});
}
#[test]
fn dropped_async_operations_emit_no_terminal_event() {
let mut never_polled = BrowserStorageAdapter::new(storage_cap_with_defaults());
let future = never_polled.set_async(
StorageBackend::LocalStorage,
"prefs:v1",
"theme",
b"dark".to_vec(),
);
drop(future);
assert!(never_polled.events().is_empty());
let mut pending = BrowserStorageAdapter::new(storage_cap_with_defaults());
pending.register_async_host_backend(
StorageBackend::IndexedDb,
Arc::new(PendingAsyncHostBackend),
);
let mut future = Box::pin(pending.set_async(
StorageBackend::IndexedDb,
"cache:user:1",
"profile",
b"v1".to_vec(),
));
let mut task_cx = Context::from_waker(std::task::Waker::noop());
assert!(matches!(future.as_mut().poll(&mut task_cx), Poll::Pending));
drop(future);
assert!(pending.events().is_empty());
}
#[test]
fn host_get_quota_contract_sync() {
const BACKEND: StorageBackend = StorageBackend::LocalStorage;
const NAMESPACE: &str = "cache:n";
let oversized_host = Arc::new(MockHostBackend::default());
oversized_host
.set(NAMESPACE, "a", b"12345")
.expect("seed oversized host value");
let mut oversized = BrowserStorageAdapter::new(storage_cap_with_quota(quota(64, 4, 1)));
oversized
.set(BACKEND, NAMESPACE, "a", b"ok".to_vec())
.expect("seed prior cached value");
let prior_bytes = oversized.used_bytes();
oversized.register_host_backend(BACKEND, oversized_host);
assert_eq!(
oversized.get(BACKEND, NAMESPACE, "a"),
Err(BrowserStorageError::Policy(
StoragePolicyError::ValueTooLarge { len: 5, limit: 4 }
))
);
assert_eq!(
cached_value(&oversized, BACKEND, NAMESPACE, "a"),
Some(&b"ok"[..])
);
assert_eq!(oversized.used_bytes(), prior_bytes);
assert_eq!(oversized.entry_count(), 1);
let event = oversized.events().last().expect("oversized denial event");
assert_eq!(event.outcome, StorageEventOutcome::Denied);
assert_eq!(event.reason_code, StorageEventReasonCode::ValueTooLarge);
assert_eq!(event.value_len, Some(5));
let entry_host = Arc::new(MockHostBackend::default());
entry_host
.set(NAMESPACE, "b", b"yy")
.expect("seed second host entry");
let mut entries = BrowserStorageAdapter::new(storage_cap_with_quota(quota(64, 8, 1)));
entries
.set(BACKEND, NAMESPACE, "a", b"x".to_vec())
.expect("seed first cached entry");
let prior_bytes = entries.used_bytes();
entries.register_host_backend(BACKEND, entry_host);
assert_eq!(
entries.get(BACKEND, NAMESPACE, "b"),
Err(BrowserStorageError::Policy(
StoragePolicyError::EntryCountExceeded {
projected: 2,
limit: 1,
}
))
);
assert_eq!(
cached_value(&entries, BACKEND, NAMESPACE, "a"),
Some(&b"x"[..])
);
assert_eq!(cached_value(&entries, BACKEND, NAMESPACE, "b"), None);
assert_eq!(entries.used_bytes(), prior_bytes);
let event = entries.events().last().expect("entry denial event");
assert_eq!(
event.reason_code,
StorageEventReasonCode::EntryCountExceeded
);
assert_eq!(event.value_len, Some(2));
let total_host = Arc::new(MockHostBackend::default());
total_host
.set(NAMESPACE, "b", b"y")
.expect("seed quota host entry");
let mut total = BrowserStorageAdapter::new(storage_cap_with_quota(quota(17, 8, 2)));
total
.set(BACKEND, NAMESPACE, "a", b"x".to_vec())
.expect("seed cached quota entry");
let prior_bytes = total.used_bytes();
total.register_host_backend(BACKEND, total_host);
assert_eq!(
total.get(BACKEND, NAMESPACE, "b"),
Err(BrowserStorageError::Policy(
StoragePolicyError::QuotaExceeded {
projected_bytes: 18,
limit_bytes: 17,
}
))
);
assert_eq!(
cached_value(&total, BACKEND, NAMESPACE, "a"),
Some(&b"x"[..])
);
assert_eq!(cached_value(&total, BACKEND, NAMESPACE, "b"), None);
assert_eq!(total.used_bytes(), prior_bytes);
let event = total.events().last().expect("total quota denial event");
assert_eq!(event.reason_code, StorageEventReasonCode::QuotaExceeded);
assert_eq!(event.value_len, Some(1));
let boundary_host = Arc::new(MockHostBackend::default());
boundary_host
.set(NAMESPACE, "a", b"1234")
.expect("seed boundary host value");
let mut boundary = BrowserStorageAdapter::new(storage_cap_with_quota(quota(12, 4, 1)));
boundary
.set(BACKEND, NAMESPACE, "a", b"12".to_vec())
.expect("seed replacement cache value");
boundary.register_host_backend(
BACKEND,
Arc::clone(&boundary_host) as Arc<dyn StorageHostBackend>,
);
assert_eq!(
boundary
.get(BACKEND, NAMESPACE, "a")
.expect("exact-boundary host read"),
Some(b"1234".to_vec())
);
assert_eq!(boundary.entry_count(), 1);
assert_eq!(boundary.used_bytes(), 12);
assert_eq!(
cached_value(&boundary, BACKEND, NAMESPACE, "a"),
Some(&b"1234"[..])
);
boundary_host
.delete(NAMESPACE, "a")
.expect("delete host value");
assert_eq!(
boundary
.get(BACKEND, NAMESPACE, "a")
.expect("host absence must be observed"),
None
);
assert_eq!(boundary.entry_count(), 0);
assert_eq!(boundary.used_bytes(), 0);
boundary_host
.set(NAMESPACE, "a", b"")
.expect("seed empty host value");
assert_eq!(
boundary
.get(BACKEND, NAMESPACE, "a")
.expect("empty host value must be observed"),
Some(Vec::new())
);
assert_eq!(boundary.entry_count(), 1);
assert_eq!(boundary.used_bytes(), entry_size(NAMESPACE, "a", 0));
assert_eq!(
cached_value(&boundary, BACKEND, NAMESPACE, "a"),
Some(&b""[..])
);
}
#[test]
fn host_get_quota_contract_async() {
const BACKEND: StorageBackend = StorageBackend::IndexedDb;
const NAMESPACE: &str = "cache:n";
futures_lite::future::block_on(async {
let oversized_host = Arc::new(MockAsyncHostBackend::default());
oversized_host
.set(NAMESPACE, "a", b"12345")
.await
.expect("seed oversized async host value");
let mut oversized = BrowserStorageAdapter::new(storage_cap_with_quota(quota(64, 4, 1)));
oversized
.set(BACKEND, NAMESPACE, "a", b"ok".to_vec())
.expect("seed prior cached value");
let prior_bytes = oversized.used_bytes();
oversized.register_async_host_backend(BACKEND, oversized_host);
assert_eq!(
oversized.get_async(BACKEND, NAMESPACE, "a").await,
Err(BrowserStorageError::Policy(
StoragePolicyError::ValueTooLarge { len: 5, limit: 4 }
))
);
assert_eq!(
cached_value(&oversized, BACKEND, NAMESPACE, "a"),
Some(&b"ok"[..])
);
assert_eq!(oversized.used_bytes(), prior_bytes);
assert_eq!(oversized.entry_count(), 1);
let event = oversized.events().last().expect("oversized denial event");
assert_eq!(event.outcome, StorageEventOutcome::Denied);
assert_eq!(event.reason_code, StorageEventReasonCode::ValueTooLarge);
assert_eq!(event.value_len, Some(5));
let entry_host = Arc::new(MockAsyncHostBackend::default());
entry_host
.set(NAMESPACE, "b", b"yy")
.await
.expect("seed second async host entry");
let mut entries = BrowserStorageAdapter::new(storage_cap_with_quota(quota(64, 8, 1)));
entries
.set(BACKEND, NAMESPACE, "a", b"x".to_vec())
.expect("seed first cached entry");
let prior_bytes = entries.used_bytes();
entries.register_async_host_backend(BACKEND, entry_host);
assert_eq!(
entries.get_async(BACKEND, NAMESPACE, "b").await,
Err(BrowserStorageError::Policy(
StoragePolicyError::EntryCountExceeded {
projected: 2,
limit: 1,
}
))
);
assert_eq!(
cached_value(&entries, BACKEND, NAMESPACE, "a"),
Some(&b"x"[..])
);
assert_eq!(cached_value(&entries, BACKEND, NAMESPACE, "b"), None);
assert_eq!(entries.used_bytes(), prior_bytes);
let event = entries.events().last().expect("entry denial event");
assert_eq!(
event.reason_code,
StorageEventReasonCode::EntryCountExceeded
);
assert_eq!(event.value_len, Some(2));
let total_host = Arc::new(MockAsyncHostBackend::default());
total_host
.set(NAMESPACE, "b", b"y")
.await
.expect("seed async quota host entry");
let mut total = BrowserStorageAdapter::new(storage_cap_with_quota(quota(17, 8, 2)));
total
.set(BACKEND, NAMESPACE, "a", b"x".to_vec())
.expect("seed cached quota entry");
let prior_bytes = total.used_bytes();
total.register_async_host_backend(BACKEND, total_host);
assert_eq!(
total.get_async(BACKEND, NAMESPACE, "b").await,
Err(BrowserStorageError::Policy(
StoragePolicyError::QuotaExceeded {
projected_bytes: 18,
limit_bytes: 17,
}
))
);
assert_eq!(
cached_value(&total, BACKEND, NAMESPACE, "a"),
Some(&b"x"[..])
);
assert_eq!(cached_value(&total, BACKEND, NAMESPACE, "b"), None);
assert_eq!(total.used_bytes(), prior_bytes);
let event = total.events().last().expect("total quota denial event");
assert_eq!(event.reason_code, StorageEventReasonCode::QuotaExceeded);
assert_eq!(event.value_len, Some(1));
let boundary_host = Arc::new(MockAsyncHostBackend::default());
boundary_host
.set(NAMESPACE, "a", b"1234")
.await
.expect("seed boundary async host value");
let mut boundary = BrowserStorageAdapter::new(storage_cap_with_quota(quota(12, 4, 1)));
boundary
.set(BACKEND, NAMESPACE, "a", b"12".to_vec())
.expect("seed replacement cache value");
boundary.register_async_host_backend(
BACKEND,
Arc::clone(&boundary_host) as Arc<dyn AsyncStorageHostBackend>,
);
assert_eq!(
boundary
.get_async(BACKEND, NAMESPACE, "a")
.await
.expect("exact-boundary async host read"),
Some(b"1234".to_vec())
);
assert_eq!(boundary.entry_count(), 1);
assert_eq!(boundary.used_bytes(), 12);
assert_eq!(
cached_value(&boundary, BACKEND, NAMESPACE, "a"),
Some(&b"1234"[..])
);
boundary_host
.delete(NAMESPACE, "a")
.await
.expect("delete async host value");
assert_eq!(
boundary
.get_async(BACKEND, NAMESPACE, "a")
.await
.expect("async host absence must be observed"),
None
);
assert_eq!(boundary.entry_count(), 0);
assert_eq!(boundary.used_bytes(), 0);
boundary_host
.set(NAMESPACE, "a", b"")
.await
.expect("seed empty async host value");
assert_eq!(
boundary
.get_async(BACKEND, NAMESPACE, "a")
.await
.expect("empty async host value must be observed"),
Some(Vec::new())
);
assert_eq!(boundary.entry_count(), 1);
assert_eq!(boundary.used_bytes(), entry_size(NAMESPACE, "a", 0));
assert_eq!(
cached_value(&boundary, BACKEND, NAMESPACE, "a"),
Some(&b""[..])
);
});
}
#[test]
fn adapter_round_trip_set_get_delete_is_deterministic() {
let mut adapter = BrowserStorageAdapter::new(storage_cap_with_defaults());
adapter
.set(
StorageBackend::IndexedDb,
"cache:user:42",
"profile",
b"v1".to_vec(),
)
.expect("set should succeed");
adapter
.set(
StorageBackend::IndexedDb,
"cache:user:42",
"access_token",
b"t-1".to_vec(),
)
.expect("set should succeed");
let keys = adapter
.list_keys(StorageBackend::IndexedDb, "cache:user:42")
.expect("list should succeed");
assert_eq!(keys, vec!["access_token".to_owned(), "profile".to_owned()]);
let value = adapter
.get(StorageBackend::IndexedDb, "cache:user:42", "profile")
.expect("get should succeed");
assert_eq!(value, Some(b"v1".to_vec()));
let removed = adapter
.delete(StorageBackend::IndexedDb, "cache:user:42", "profile")
.expect("delete should succeed");
assert!(removed);
assert_eq!(
adapter
.get(StorageBackend::IndexedDb, "cache:user:42", "profile")
.expect("get should succeed"),
None
);
}
#[test]
fn adapter_enforces_total_quota() {
let cap = BrowserStorageIoCap::new(
StorageAuthority::deny_all()
.grant_backend(StorageBackend::LocalStorage)
.grant_namespace("prefs:*")
.grant_operation(StorageOperation::Set),
StorageQuotaPolicy {
max_total_bytes: 16,
max_key_bytes: 16,
max_value_bytes: 16,
max_namespace_bytes: 16,
max_entries: 8,
},
StorageConsistencyPolicy::ImmediateReadAfterWrite,
StorageRedactionPolicy::default(),
);
let mut adapter = BrowserStorageAdapter::new(cap);
adapter
.set(
StorageBackend::LocalStorage,
"prefs:v1",
"a",
b"12".to_vec(),
)
.expect("first set should fit quota");
let result = adapter.set(
StorageBackend::LocalStorage,
"prefs:v1",
"abc",
b"123456789".to_vec(),
);
assert!(matches!(
result,
Err(BrowserStorageError::Policy(
StoragePolicyError::QuotaExceeded { .. }
))
));
}
#[test]
fn adapter_denies_ungranted_namespace() {
let mut adapter = BrowserStorageAdapter::new(storage_cap_with_defaults());
let result = adapter.set(
StorageBackend::IndexedDb,
"session:v1",
"token",
b"x".to_vec(),
);
assert_eq!(
result,
Err(BrowserStorageError::Policy(
StoragePolicyError::NamespaceDenied("session:v1".to_owned())
))
);
}
#[test]
fn adapter_records_redacted_events_when_configured() {
let cap = BrowserStorageIoCap::new(
StorageAuthority::deny_all()
.grant_backend(StorageBackend::IndexedDb)
.grant_namespace("cache:*")
.grant_operation(StorageOperation::Set),
StorageQuotaPolicy::default(),
StorageConsistencyPolicy::ImmediateReadAfterWrite,
StorageRedactionPolicy {
redact_keys: true,
redact_namespaces: true,
redact_value_lengths: true,
},
);
let mut adapter = BrowserStorageAdapter::new(cap);
let result = adapter.set(
StorageBackend::IndexedDb,
"cache:user:9001",
"secret-key",
b"payload".to_vec(),
);
assert!(result.is_ok());
let event = adapter.events().last().expect("event should exist");
assert_eq!(event.outcome, StorageEventOutcome::Allowed);
assert_eq!(event.reason_code, StorageEventReasonCode::Allowed);
assert_eq!(event.namespace_label, "namespace[len:15]");
assert_eq!(event.key_label.as_deref(), Some("key[len:10]"));
assert_eq!(event.value_len, None);
}
#[test]
fn adapter_records_denied_reason_code_for_policy_error() {
let mut adapter = BrowserStorageAdapter::new(storage_cap_with_defaults());
let result = adapter.clear_namespace(StorageBackend::IndexedDb, "session:v1");
assert_eq!(
result,
Err(BrowserStorageError::Policy(
StoragePolicyError::NamespaceDenied("session:v1".to_owned())
))
);
let event = adapter.events().last().expect("event should exist");
assert_eq!(event.outcome, StorageEventOutcome::Denied);
assert_eq!(event.reason_code, StorageEventReasonCode::NamespaceDenied);
}
#[test]
fn adapter_backend_unavailable_is_deterministic_and_traced() {
let mut adapter = BrowserStorageAdapter::new(storage_cap_with_defaults());
adapter.set_backend_available(StorageBackend::IndexedDb, false);
let result = adapter.set(
StorageBackend::IndexedDb,
"cache:user:1",
"token",
b"abc".to_vec(),
);
assert_eq!(
result,
Err(BrowserStorageError::BackendUnavailable(
StorageBackend::IndexedDb
))
);
let event = adapter.events().last().expect("event should exist");
assert_eq!(event.outcome, StorageEventOutcome::Denied);
assert_eq!(
event.reason_code,
StorageEventReasonCode::BackendUnavailable
);
}
#[test]
fn adapter_eventual_list_is_stale_then_converges() {
let cap = BrowserStorageIoCap::new(
StorageAuthority::deny_all()
.grant_backend(StorageBackend::IndexedDb)
.grant_namespace("cache:*")
.grant_operation(StorageOperation::Get)
.grant_operation(StorageOperation::Set)
.grant_operation(StorageOperation::Delete)
.grant_operation(StorageOperation::ListKeys),
StorageQuotaPolicy::default(),
StorageConsistencyPolicy::ReadAfterWriteEventualList,
StorageRedactionPolicy::default(),
);
let mut adapter = BrowserStorageAdapter::new(cap);
adapter
.set(
StorageBackend::IndexedDb,
"cache:user:7",
"profile",
b"v1".to_vec(),
)
.expect("set should succeed");
assert_eq!(
adapter
.get(StorageBackend::IndexedDb, "cache:user:7", "profile")
.expect("get should succeed"),
Some(b"v1".to_vec())
);
assert_eq!(
adapter
.list_keys(StorageBackend::IndexedDb, "cache:user:7")
.expect("first list should succeed"),
Vec::<String>::new()
);
assert_eq!(
adapter
.list_keys(StorageBackend::IndexedDb, "cache:user:7")
.expect("second list should converge"),
vec!["profile".to_owned()]
);
adapter
.delete(StorageBackend::IndexedDb, "cache:user:7", "profile")
.expect("delete should succeed");
assert_eq!(
adapter
.get(StorageBackend::IndexedDb, "cache:user:7", "profile")
.expect("get should succeed"),
None
);
assert_eq!(
adapter
.list_keys(StorageBackend::IndexedDb, "cache:user:7")
.expect("first post-delete list should be stale"),
vec!["profile".to_owned()]
);
assert_eq!(
adapter
.list_keys(StorageBackend::IndexedDb, "cache:user:7")
.expect("second post-delete list should converge"),
Vec::<String>::new()
);
}
#[test]
fn adapter_flush_namespace_list_view_forces_convergence() {
let cap = BrowserStorageIoCap::new(
StorageAuthority::deny_all()
.grant_backend(StorageBackend::IndexedDb)
.grant_namespace("cache:*")
.grant_operation(StorageOperation::ListKeys)
.grant_operation(StorageOperation::Set),
StorageQuotaPolicy::default(),
StorageConsistencyPolicy::ReadAfterWriteEventualList,
StorageRedactionPolicy::default(),
);
let mut adapter = BrowserStorageAdapter::new(cap);
adapter
.set(
StorageBackend::IndexedDb,
"cache:user:9",
"profile",
b"v2".to_vec(),
)
.expect("set should succeed");
adapter.flush_namespace_list_view(StorageBackend::IndexedDb, "cache:user:9");
assert_eq!(
adapter
.list_keys(StorageBackend::IndexedDb, "cache:user:9")
.expect("list should succeed"),
vec!["profile".to_owned()]
);
}
#[test]
fn adapter_clear_namespace_updates_used_bytes() {
let mut adapter = BrowserStorageAdapter::new(storage_cap_with_defaults());
adapter
.set(
StorageBackend::IndexedDb,
"cache:user:42",
"a",
b"12".to_vec(),
)
.expect("set should succeed");
adapter
.set(
StorageBackend::IndexedDb,
"cache:user:42",
"b",
b"123".to_vec(),
)
.expect("set should succeed");
assert!(adapter.used_bytes() > 0);
let removed = adapter
.clear_namespace(StorageBackend::IndexedDb, "cache:user:42")
.expect("clear should succeed");
assert_eq!(removed, 2);
assert_eq!(adapter.entry_count(), 0);
assert_eq!(adapter.used_bytes(), 0);
}
#[test]
fn adapter_routes_local_storage_operations_through_registered_host_backend() {
let host_backend = Arc::new(MockHostBackend::default());
let mut adapter = BrowserStorageAdapter::new(storage_cap_with_defaults());
adapter.register_host_backend(StorageBackend::LocalStorage, host_backend.clone());
adapter
.set(
StorageBackend::LocalStorage,
"prefs:v1",
"theme",
b"dark".to_vec(),
)
.expect("host-backed set should succeed");
assert_eq!(
host_backend
.get("prefs:v1", "theme")
.expect("host-backed get should succeed"),
Some(b"dark".to_vec())
);
let listed = adapter
.list_keys(StorageBackend::LocalStorage, "prefs:v1")
.expect("host-backed list should succeed");
assert_eq!(listed, vec!["theme".to_owned()]);
let removed = adapter
.delete(StorageBackend::LocalStorage, "prefs:v1", "theme")
.expect("host-backed delete should succeed");
assert!(removed);
assert_eq!(
host_backend
.get("prefs:v1", "theme")
.expect("host-backed get should succeed"),
None
);
}
#[test]
fn adapter_records_host_backend_failures_with_deterministic_reason_code() {
let mut adapter = BrowserStorageAdapter::new(storage_cap_with_defaults());
adapter.register_host_backend(StorageBackend::LocalStorage, Arc::new(FailingHostBackend));
let result = adapter.set(
StorageBackend::LocalStorage,
"prefs:v1",
"theme",
b"light".to_vec(),
);
assert!(matches!(
result,
Err(BrowserStorageError::HostBackend {
backend: StorageBackend::LocalStorage,
operation: StorageOperation::Set,
..
})
));
let event = adapter.events().last().expect("event should exist");
assert_eq!(event.outcome, StorageEventOutcome::Denied);
assert_eq!(event.reason_code, StorageEventReasonCode::HostBackendError);
assert_eq!(
adapter.events().len(),
1,
"host failure is one terminal event"
);
}
#[test]
fn sync_methods_fail_closed_for_async_only_backends() {
let mut adapter = BrowserStorageAdapter::new(storage_cap_with_defaults());
adapter.register_async_host_backend(
StorageBackend::IndexedDb,
Arc::new(MockAsyncHostBackend::default()),
);
let result = adapter.set(
StorageBackend::IndexedDb,
"cache:user:42",
"profile",
b"v1".to_vec(),
);
assert!(matches!(
result,
Err(BrowserStorageError::HostBackend {
backend: StorageBackend::IndexedDb,
operation: StorageOperation::Set,
message,
}) if message.contains("requires async browser storage adapter methods")
));
assert_eq!(adapter.entry_count(), 0);
assert_eq!(
adapter.events().len(),
1,
"sync/async mismatch is one terminal event"
);
assert_eq!(adapter.events()[0].outcome, StorageEventOutcome::Denied);
assert_eq!(
adapter.events()[0].reason_code,
StorageEventReasonCode::HostBackendError
);
}
#[test]
fn async_host_backend_round_trip_is_deterministic() {
let async_backend = Arc::new(MockAsyncHostBackend::default());
let mut adapter = BrowserStorageAdapter::new(storage_cap_with_defaults());
adapter.register_async_host_backend(StorageBackend::IndexedDb, async_backend.clone());
futures_lite::future::block_on(async {
adapter
.set_async(
StorageBackend::IndexedDb,
"cache:user:11",
"profile",
b"v1".to_vec(),
)
.await
.expect("async set should succeed");
assert_eq!(
async_backend
.get("cache:user:11", "profile")
.await
.expect("async host get should succeed"),
Some(b"v1".to_vec())
);
assert_eq!(
adapter
.get_async(StorageBackend::IndexedDb, "cache:user:11", "profile")
.await
.expect("async get should succeed"),
Some(b"v1".to_vec())
);
assert_eq!(
adapter
.list_keys_async(StorageBackend::IndexedDb, "cache:user:11")
.await
.expect("async list should succeed"),
vec!["profile".to_owned()]
);
assert!(
adapter
.delete_async(StorageBackend::IndexedDb, "cache:user:11", "profile")
.await
.expect("async delete should succeed")
);
assert_eq!(
adapter
.get_async(StorageBackend::IndexedDb, "cache:user:11", "profile")
.await
.expect("async get should succeed"),
None
);
assert_eq!(
adapter
.clear_namespace_async(StorageBackend::IndexedDb, "cache:user:11")
.await
.expect("async clear should succeed"),
0
);
assert_eq!(adapter.events().len(), 6);
assert!(adapter.events().iter().all(|event| {
event.outcome == StorageEventOutcome::Allowed
&& event.reason_code == StorageEventReasonCode::Allowed
}));
});
}
#[test]
fn async_host_backend_eventual_list_is_stale_then_converges() {
let cap = BrowserStorageIoCap::new(
StorageAuthority::deny_all()
.grant_backend(StorageBackend::IndexedDb)
.grant_namespace("cache:*")
.grant_operation(StorageOperation::Get)
.grant_operation(StorageOperation::Set)
.grant_operation(StorageOperation::Delete)
.grant_operation(StorageOperation::ListKeys),
StorageQuotaPolicy::default(),
StorageConsistencyPolicy::ReadAfterWriteEventualList,
StorageRedactionPolicy::default(),
);
let mut adapter = BrowserStorageAdapter::new(cap);
adapter.register_async_host_backend(
StorageBackend::IndexedDb,
Arc::new(MockAsyncHostBackend::default()),
);
futures_lite::future::block_on(async {
adapter
.set_async(
StorageBackend::IndexedDb,
"cache:user:13",
"profile",
b"v2".to_vec(),
)
.await
.expect("async set should succeed");
assert_eq!(
adapter
.list_keys_async(StorageBackend::IndexedDb, "cache:user:13")
.await
.expect("first async list should succeed"),
Vec::<String>::new()
);
assert_eq!(
adapter
.list_keys_async(StorageBackend::IndexedDb, "cache:user:13")
.await
.expect("second async list should converge"),
vec!["profile".to_owned()]
);
adapter
.delete_async(StorageBackend::IndexedDb, "cache:user:13", "profile")
.await
.expect("async delete should succeed");
assert_eq!(
adapter
.list_keys_async(StorageBackend::IndexedDb, "cache:user:13")
.await
.expect("first post-delete async list should be stale"),
vec!["profile".to_owned()]
);
assert_eq!(
adapter
.list_keys_async(StorageBackend::IndexedDb, "cache:user:13")
.await
.expect("second post-delete async list should converge"),
Vec::<String>::new()
);
});
}
}