use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use super::{CallResult, CallValue, CapabilityCall, CapabilityRuntime, DenialReason, PluginValue};
pub const KV_PREFIX: &str = "plugin-kv";
pub trait KvStore: Send + Sync + 'static {
fn get(&self, key: &str) -> Result<Option<PluginValue>, String>;
fn set(&self, key: &str, value: PluginValue) -> Result<(), String>;
fn delete(&self, key: &str) -> Result<(), String>;
}
#[must_use]
pub fn escape_segment(segment: &str) -> String {
let mut out = String::with_capacity(segment.len());
for ch in segment.chars() {
match ch {
'%' => out.push_str("%25"),
':' => out.push_str("%3A"),
other => out.push(other),
}
}
out
}
#[must_use]
pub fn namespaced_key(plugin: &str, tenant: Option<&str>, key: &str) -> String {
format!(
"{KV_PREFIX}:{plugin}:{tenant}:{key}",
plugin = escape_segment(plugin),
tenant = escape_segment(&super::tenant_segment(tenant)),
key = escape_segment(key)
)
}
pub(super) fn validate(
runtime: &CapabilityRuntime,
call: &CapabilityCall,
) -> Result<(), CallResult> {
if let CapabilityCall::KvSet { id, value, .. } = call {
let ceiling = runtime.quotas().kv_value_bytes as usize;
if value.weight() > ceiling {
return Err(CallResult::denied(
*id,
DenialReason::QuotaExceeded,
format!("a kv value may hold at most {ceiling} bytes"),
));
}
}
Ok(())
}
pub(super) fn perform(runtime: &CapabilityRuntime, call: &CapabilityCall, key: &str) -> CallResult {
let id = call.id();
let Some(store) = runtime.services.kv.clone() else {
return CallResult::denied(
id,
DenialReason::Unavailable,
"this host has no key/value backend wired for sandboxed plugins",
);
};
let physical = namespaced_key(&runtime.plugin, runtime.tenant(), key);
match call {
CapabilityCall::KvGet { .. } => {
let ceiling = runtime.quotas().kv_value_bytes as usize;
match store.get(&physical) {
Err(detail) => CallResult::denied(id, DenialReason::BackendError, detail),
Ok(Some(value)) if value.weight() > ceiling => CallResult::denied(
id,
DenialReason::QuotaExceeded,
format!(
"the stored value is {} bytes, over this plugin's current {ceiling}-byte \
`kv_value_bytes` ceiling",
value.weight()
),
),
Ok(Some(value)) => CallResult::Ok {
id,
value: CallValue::Value { value, found: true },
},
Ok(None) => CallResult::Ok {
id,
value: CallValue::Value {
value: PluginValue::Null,
found: false,
},
},
}
}
CapabilityCall::KvSet { value, .. } => {
let ceiling = runtime.quotas().kv_value_bytes as usize;
if value.weight() > ceiling {
return CallResult::denied(
id,
DenialReason::QuotaExceeded,
format!("a kv value may hold at most {ceiling} bytes"),
);
}
match store.set(&physical, value.clone()) {
Ok(()) => CallResult::Ok {
id,
value: CallValue::Done,
},
Err(detail) => CallResult::denied(id, DenialReason::BackendError, detail),
}
}
CapabilityCall::KvDelete { .. } => match store.delete(&physical) {
Ok(()) => CallResult::Ok {
id,
value: CallValue::Done,
},
Err(detail) => CallResult::denied(id, DenialReason::BackendError, detail),
},
_ => CallResult::denied(id, DenialReason::Malformed, "not a kv call"),
}
}
#[derive(Debug)]
pub struct MemoryKvStore {
entries: Mutex<KvEntries>,
capacity: usize,
byte_capacity: usize,
}
#[derive(Debug, Default)]
struct KvEntries {
map: HashMap<String, PluginValue>,
bytes: usize,
}
pub const DEFAULT_KV_CAPACITY: usize = 10_000;
pub const DEFAULT_KV_BYTE_CAPACITY: usize = 32 * 1024 * 1024;
impl Default for MemoryKvStore {
fn default() -> Self {
Self {
entries: Mutex::new(KvEntries::default()),
capacity: DEFAULT_KV_CAPACITY,
byte_capacity: DEFAULT_KV_BYTE_CAPACITY,
}
}
}
impl MemoryKvStore {
#[must_use]
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
#[must_use]
pub fn with_capacity(capacity: usize) -> Arc<Self> {
Arc::new(Self {
entries: Mutex::new(KvEntries::default()),
capacity,
byte_capacity: DEFAULT_KV_BYTE_CAPACITY,
})
}
#[must_use]
pub fn with_capacities(capacity: usize, byte_capacity: usize) -> Arc<Self> {
Arc::new(Self {
entries: Mutex::new(KvEntries::default()),
capacity,
byte_capacity,
})
}
#[must_use]
pub const fn capacity(&self) -> usize {
self.capacity
}
#[must_use]
pub const fn byte_capacity(&self) -> usize {
self.byte_capacity
}
fn entry_weight(key: &str, value: &PluginValue) -> usize {
const PER_ENTRY: usize = 96;
key.len()
.saturating_add(value.weight())
.saturating_add(PER_ENTRY)
}
#[must_use]
#[allow(
clippy::significant_drop_tightening,
reason = "the body is one critical section over the shared map; releasing early would \
either split a ceiling check from the write it guards or let the snapshot \
this returns be torn"
)]
pub fn keys(&self) -> Vec<String> {
let entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
let mut keys: Vec<String> = entries.map.keys().cloned().collect();
keys.sort();
keys
}
}
impl KvStore for MemoryKvStore {
fn get(&self, key: &str) -> Result<Option<PluginValue>, String> {
Ok(self
.entries
.lock()
.unwrap_or_else(PoisonError::into_inner)
.map
.get(key)
.cloned())
}
#[allow(
clippy::significant_drop_tightening,
reason = "the body is one critical section over the shared map; releasing early would \
either split a ceiling check from the write it guards or let the snapshot \
this returns be torn"
)]
fn set(&self, key: &str, value: PluginValue) -> Result<(), String> {
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
if entries.map.len() >= self.capacity && !entries.map.contains_key(key) {
return Err(format!(
"this host's plugin key/value store is full at {} keys",
self.capacity
));
}
let incoming = Self::entry_weight(key, &value);
let outgoing = entries
.map
.get(key)
.map_or(0, |existing| Self::entry_weight(key, existing));
if entries
.bytes
.saturating_sub(outgoing)
.saturating_add(incoming)
> self.byte_capacity
{
return Err(format!(
"this host's plugin key/value store is full at {} bytes",
self.byte_capacity
));
}
entries.bytes = entries
.bytes
.saturating_sub(outgoing)
.saturating_add(incoming);
entries.map.insert(key.to_owned(), value);
Ok(())
}
fn delete(&self, key: &str) -> Result<(), String> {
{
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(removed) = entries.map.remove(key) {
let freed = Self::entry_weight(key, &removed);
entries.bytes = entries.bytes.saturating_sub(freed);
}
}
Ok(())
}
}
#[derive(Clone)]
pub struct CacheKvStore(pub std::sync::Arc<dyn crate::cache::Cache>);
impl std::fmt::Debug for CacheKvStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("CacheKvStore")
}
}
impl KvStore for CacheKvStore {
fn get(&self, key: &str) -> Result<Option<PluginValue>, String> {
crate::cache::try_get_cached::<PluginValue>(self.0.as_ref(), key)
.map_err(|error| error.to_string())
}
fn set(&self, key: &str, value: PluginValue) -> Result<(), String> {
crate::cache::try_insert_cached(self.0.as_ref(), key, value, None)
.map_err(|error| error.to_string())
}
fn delete(&self, key: &str) -> Result<(), String> {
self.0
.try_invalidate(key)
.map_err(|error| error.to_string())
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
#[derive(Debug, Default)]
struct SerializingCache {
bytes: Mutex<std::collections::HashMap<String, Vec<u8>>>,
}
impl crate::cache::Cache for SerializingCache {
fn get_value(&self, key: &str) -> Option<Arc<dyn std::any::Any + Send + Sync>> {
let bytes = self
.bytes
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(key)
.cloned()?;
Some(Arc::new(crate::cache::RawCacheBytes(bytes)))
}
fn insert_value(&self, _key: &str, _value: Arc<dyn std::any::Any + Send + Sync>) {}
fn insert_raw_bytes(&self, key: &str, bytes: Vec<u8>, _ttl: Option<Duration>) {
self.bytes
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(key.to_owned(), bytes);
}
fn invalidate(&self, key: &str) {
self.bytes
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(key);
}
fn clear(&self) {
self.bytes
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clear();
}
}
#[test]
fn the_cache_backed_store_round_trips_through_a_serializing_backend() {
let cache = Arc::new(SerializingCache::default());
let store = CacheKvStore(Arc::clone(&cache) as Arc<dyn crate::cache::Cache>);
let key = namespaced_key("shop", Some("alpha"), "cart");
assert_eq!(store.get(&key), Ok(None), "nothing stored yet");
let _ = store.set(&key, PluginValue::Text("A-1".to_owned()));
assert_eq!(
store.get(&key),
Ok(Some(PluginValue::Text("A-1".to_owned()))),
"a value written through a serializing backend must read back"
);
let _ = store.delete(&key);
assert_eq!(store.get(&key), Ok(None), "and delete must remove it");
}
#[derive(Debug, Default)]
struct DownCache;
impl crate::cache::Cache for DownCache {
fn get_value(&self, _key: &str) -> Option<Arc<dyn std::any::Any + Send + Sync>> {
None
}
fn insert_value(&self, _key: &str, _value: Arc<dyn std::any::Any + Send + Sync>) {}
fn invalidate(&self, _key: &str) {}
fn clear(&self) {}
fn try_get_value(
&self,
_key: &str,
) -> Result<Option<Arc<dyn std::any::Any + Send + Sync>>, crate::cache::CacheBackendError>
{
Err(crate::cache::CacheBackendError::new("backend down"))
}
fn try_insert_raw_bytes(
&self,
_key: &str,
_bytes: Vec<u8>,
_ttl: Option<Duration>,
) -> Result<(), crate::cache::CacheBackendError> {
Err(crate::cache::CacheBackendError::new("backend down"))
}
fn try_invalidate(&self, _key: &str) -> Result<(), crate::cache::InvalidationError> {
Err(crate::cache::InvalidationError::new(3, "backend down"))
}
}
#[derive(Debug, Default)]
struct CorruptCache;
impl crate::cache::Cache for CorruptCache {
fn get_value(&self, _key: &str) -> Option<Arc<dyn std::any::Any + Send + Sync>> {
None
}
fn insert_value(&self, _key: &str, _value: Arc<dyn std::any::Any + Send + Sync>) {}
fn invalidate(&self, _key: &str) {}
fn clear(&self) {}
fn try_get_value(
&self,
_key: &str,
) -> Result<Option<Arc<dyn std::any::Any + Send + Sync>>, crate::cache::CacheBackendError>
{
Ok(Some(Arc::new(crate::cache::RawCacheBytes(b"{".to_vec()))))
}
}
#[test]
fn a_corrupt_stored_value_is_a_failure_not_an_absent_key() {
let store = CacheKvStore(Arc::new(CorruptCache) as Arc<dyn crate::cache::Cache>);
let get = store.get("k");
assert!(
get.as_ref().is_err_and(|e| e.contains("cannot be decoded")),
"{get:?}"
);
}
#[test]
fn a_cache_failure_reaches_the_guest_as_a_failure() {
let store = CacheKvStore(Arc::new(DownCache) as Arc<dyn crate::cache::Cache>);
let key = namespaced_key("shop", Some("alpha"), "cart");
let set = store.set(&key, PluginValue::Int(1));
assert!(
set.as_ref().is_err_and(|e| e.contains("backend down")),
"a write that did not land must not be `Ok`: {set:?}"
);
let get = store.get(&key);
assert!(
get.as_ref().is_err_and(|e| e.contains("backend down")),
"an outage must not read as an absent key: {get:?}"
);
let delete = store.delete(&key);
assert!(
delete.as_ref().is_err_and(|e| e.contains("backend down")),
"a delete that did not happen must not be `Ok`: {delete:?}"
);
}
#[derive(Debug)]
struct UnreachableKvStore;
impl KvStore for UnreachableKvStore {
fn get(&self, _key: &str) -> Result<Option<PluginValue>, String> {
Err("the key/value backend is unreachable".to_owned())
}
fn set(&self, _key: &str, _value: PluginValue) -> Result<(), String> {
Err("the key/value backend is unreachable".to_owned())
}
fn delete(&self, _key: &str) -> Result<(), String> {
Err("the key/value backend is unreachable".to_owned())
}
}
#[test]
fn a_read_that_could_not_happen_is_not_an_absent_key() {
let store = UnreachableKvStore;
let key = namespaced_key("shop", Some("alpha"), "cart");
assert_eq!(
store.get(&key),
Err("the key/value backend is unreachable".to_owned()),
"a failed read must stay a failure, not collapse into None"
);
let present = MemoryKvStore::new();
assert_eq!(
present.get(&key),
Ok(None),
"an absent key is a successful read of nothing"
);
}
}