use std::collections::HashMap;
use std::fmt::Write as _;
use std::sync::{Arc, Mutex, PoisonError};
use super::{CallResult, CallValue, CapabilityCall, CapabilityRuntime, DenialReason, PluginRow};
pub const TABLE_PREFIX: &str = "plugin_";
pub const TENANT_COLUMN: &str = "tenant_id";
pub const ID_COLUMN: &str = "row_id";
pub const RESERVED_COLUMNS: &[&str] = &[TENANT_COLUMN];
const MAX_IDENTIFIER_LEN: usize = 63;
#[must_use]
pub fn physical_table(plugin: &str, table: &str) -> Option<String> {
if !super::super::grants::is_grantable_ident(table) {
return None;
}
let plugin = escape_identifier_segment(plugin)?;
let name = format!("{TABLE_PREFIX}{plugin}__{table}");
(name.len() <= MAX_IDENTIFIER_LEN).then_some(name)
}
fn escape_identifier_segment(name: &str) -> Option<String> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
{
return None;
}
let mut out = String::with_capacity(name.len());
for byte in name.bytes() {
if byte.is_ascii_lowercase() || byte.is_ascii_digit() {
out.push(char::from(byte));
} else {
out.push('_');
let _ = write!(out, "{byte:02x}");
}
}
Some(out)
}
#[must_use]
pub fn is_derivable(plugin: &str, table: &str) -> bool {
physical_table(plugin, table).is_some()
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum StoreError {
NotFound,
Backend(String),
}
impl std::fmt::Display for StoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound => f.write_str("no such row"),
Self::Backend(detail) => f.write_str(detail),
}
}
}
impl std::error::Error for StoreError {}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Scope {
pub table: String,
pub tenant: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub struct QueryPage {
pub rows: Vec<PluginRow>,
pub truncated: bool,
}
impl QueryPage {
#[must_use]
pub const fn complete(rows: Vec<PluginRow>) -> Self {
Self {
rows,
truncated: false,
}
}
}
pub trait PluginStore: Send + Sync + 'static {
fn insert(&self, scope: &Scope, row: PluginRow) -> Result<String, StoreError>;
fn get(&self, scope: &Scope, row_id: &str) -> Result<Option<PluginRow>, StoreError>;
fn query(
&self,
scope: &Scope,
filter: &PluginRow,
limit: usize,
max_bytes: usize,
after: Option<&str>,
) -> Result<QueryPage, StoreError>;
fn update(&self, scope: &Scope, row_id: &str, row: PluginRow) -> Result<(), StoreError>;
fn delete(&self, scope: &Scope, row_id: &str) -> Result<(), StoreError>;
}
pub(super) fn validate(call: &CapabilityCall) -> Result<(), CallResult> {
let id = call.id();
let checked = match call {
CapabilityCall::DbInsert { row, .. } => check_write_row(row),
CapabilityCall::DbGet { row_id, .. } | CapabilityCall::DbDelete { row_id, .. } => {
check_row_id(row_id)
}
CapabilityCall::DbQuery { filter, after, .. } => {
check_filter(filter).and_then(|()| after.as_deref().map_or(Ok(()), check_row_id))
}
CapabilityCall::DbUpdate { row_id, row, .. } => {
check_row_id(row_id).and_then(|()| check_write_row(row))
}
_ => Ok(()),
};
checked.map_err(|denial| denial(id))
}
fn check_stored_rows<'a>(rows: impl IntoIterator<Item = &'a PluginRow>) -> Result<(), String> {
for row in rows {
if let Some(column) = row
.keys()
.find(|column| RESERVED_COLUMNS.contains(&column.as_str()))
{
return Err(format!(
"the store returned the host's column {column:?}, which a plugin may not write"
));
}
let id = match row.get(ID_COLUMN) {
Some(super::PluginValue::Text(id)) if check_row_id(id).is_ok() => Some(id.as_str()),
_ => None,
};
let fault = match (id, super::check_row_without(row, Some(ID_COLUMN))) {
(None, _) => Some("it carries no usable row id".to_owned()),
(Some(_), Err(detail)) => Some(detail),
(Some(_), Ok(())) => None,
};
if let Some(fault) = fault {
let named = id.unwrap_or("?");
tracing::warn!(
row_id = named,
"a plugin store returned a row the host would not store"
);
return Err(format!(
"the store returned row {named:?}, which the host would not store: {fault}"
));
}
}
Ok(())
}
#[allow(
clippy::too_many_lines,
reason = "one match arm per operation; splitting it would hide the shared scope derivation"
)]
pub(super) fn perform(
runtime: &CapabilityRuntime,
call: &CapabilityCall,
table: &str,
) -> CallResult {
let id = call.id();
let Some(store) = runtime.services.db.clone() else {
return CallResult::denied(
id,
DenialReason::Unavailable,
"this host has no plugin store wired for sandboxed plugins",
);
};
let Some(physical) = physical_table(&runtime.plugin, table) else {
return CallResult::denied(
id,
DenialReason::Malformed,
format!("no physical table can be derived for {table:?} under this plugin's name"),
);
};
let scope = Scope {
table: physical,
tenant: runtime.tenant_key(),
};
let row_limit = runtime.quotas().db_rows as usize;
match call {
CapabilityCall::DbInsert { row, .. } => match validated_row(row) {
Err(result) => result(id),
Ok(row) => match store.insert(&scope, row) {
Ok(row_id) if check_row_id(&row_id).is_err() => CallResult::denied(
id,
DenialReason::ResultRejected,
format!(
"the row was inserted, but the store assigned an id outside 1..={} \
bytes; do not retry",
super::MAX_ROW_ID_BYTES
),
),
Ok(row_id) => CallResult::Ok {
id,
value: CallValue::RowId { row_id },
},
Err(err) => CallResult::denied(id, DenialReason::BackendError, err.to_string()),
},
},
CapabilityCall::DbGet { row_id, .. } => {
if let Err(result) = check_row_id(row_id) {
return result(id);
}
match store.get(&scope, row_id) {
Ok(found) => {
if let Err(detail) = check_stored_rows(found.iter()) {
return CallResult::denied(id, DenialReason::ResultRejected, detail);
}
let (rows, truncated) = super::bounded_rows(found.into_iter().collect());
CallResult::Ok {
id,
value: CallValue::Rows { rows, truncated },
}
}
Err(err) => CallResult::denied(id, DenialReason::BackendError, err.to_string()),
}
}
CapabilityCall::DbQuery {
filter,
limit,
after,
..
} => match validated_filter(filter) {
Err(result) => result(id),
Ok(filter) => {
if let Some(after) = after
&& let Err(result) = check_row_id(after)
{
return result(id);
}
let want = if *limit == 0 {
row_limit
} else {
(*limit as usize).min(row_limit)
};
match store.query(
&scope,
&filter,
want,
super::MAX_RESULT_BYTES,
after.as_deref(),
) {
Ok(page) => {
if let Err(detail) = check_stored_rows(page.rows.iter()) {
return CallResult::denied(id, DenialReason::ResultRejected, detail);
}
let mut rows = page.rows;
let over_asked = rows.len() > want;
rows.truncate(want);
let (rows, cut) = super::bounded_rows(rows);
CallResult::Ok {
id,
value: CallValue::Rows {
rows,
truncated: page.truncated || over_asked || cut,
},
}
}
Err(err) => CallResult::denied(id, DenialReason::BackendError, err.to_string()),
}
}
},
CapabilityCall::DbUpdate { row_id, row, .. } => {
if let Err(result) = check_row_id(row_id) {
return result(id);
}
match validated_row(row) {
Err(result) => result(id),
Ok(row) => match store.update(&scope, row_id, row) {
Ok(()) => CallResult::Ok {
id,
value: CallValue::Done,
},
Err(err) => CallResult::denied(id, DenialReason::BackendError, err.to_string()),
},
}
}
CapabilityCall::DbDelete { row_id, .. } => {
if let Err(result) = check_row_id(row_id) {
return result(id);
}
match store.delete(&scope, row_id) {
Ok(()) => CallResult::Ok {
id,
value: CallValue::Done,
},
Err(err) => CallResult::denied(id, DenialReason::BackendError, err.to_string()),
}
}
_ => CallResult::denied(id, DenialReason::Malformed, "not a db call"),
}
}
type Denial = Box<dyn FnOnce(u64) -> CallResult>;
fn check_write_row(row: &PluginRow) -> Result<(), Denial> {
if let Err(detail) = super::check_row_without(row, Some(ID_COLUMN)) {
return Err(Box::new(move |id| {
CallResult::denied(id, DenialReason::Malformed, detail)
}));
}
for column in row.keys().filter(|column| column.as_str() != ID_COLUMN) {
if RESERVED_COLUMNS.contains(&column.as_str()) {
let column = column.clone();
return Err(Box::new(move |id| {
CallResult::denied(
id,
DenialReason::NotInGrant,
format!(
"column {column:?} is the host's: a row that could set it would be a row \
that chooses its own tenant or identity"
),
)
}));
}
}
Ok(())
}
fn validated_row(row: &PluginRow) -> Result<PluginRow, Denial> {
check_write_row(row)?;
Ok(row
.iter()
.filter(|(column, _)| column.as_str() != ID_COLUMN)
.map(|(column, value)| (column.clone(), value.clone()))
.collect())
}
fn check_filter(row: &PluginRow) -> Result<(), Denial> {
if row.contains_key(ID_COLUMN) {
return Err(Box::new(move |id| {
CallResult::denied(
id,
DenialReason::Malformed,
format!(
"a query filter may not carry {ID_COLUMN:?}; use `db-get` to read one row by its id"
),
)
}));
}
check_write_row(row)
}
fn validated_filter(row: &PluginRow) -> Result<PluginRow, Denial> {
check_filter(row)?;
validated_row(row)
}
fn check_row_id(row_id: &str) -> Result<(), Denial> {
if row_id.is_empty() || row_id.len() > super::MAX_ROW_ID_BYTES {
let max = super::MAX_ROW_ID_BYTES;
return Err(Box::new(move |id| {
CallResult::denied(
id,
DenialReason::Malformed,
format!("a row id must be 1..={max} bytes"),
)
}));
}
Ok(())
}
#[derive(Debug)]
pub struct MemoryPluginStore {
rows: Mutex<StoredRows>,
next: Mutex<u64>,
capacity: usize,
byte_capacity: usize,
}
#[derive(Debug, Default)]
struct StoredRows {
map: HashMap<(String, String, String), PluginRow>,
bytes: usize,
}
pub const DEFAULT_STORE_CAPACITY: usize = 10_000;
pub const DEFAULT_STORE_BYTE_CAPACITY: usize = 64 * 1024 * 1024;
impl Default for MemoryPluginStore {
fn default() -> Self {
Self {
rows: Mutex::new(StoredRows::default()),
next: Mutex::new(0),
capacity: DEFAULT_STORE_CAPACITY,
byte_capacity: DEFAULT_STORE_BYTE_CAPACITY,
}
}
}
impl MemoryPluginStore {
fn entry_weight(key: &(String, String, String), row: &PluginRow) -> usize {
const PER_ENTRY: usize = 128;
key.0
.len()
.saturating_add(key.1.len())
.saturating_add(key.2.len())
.saturating_add(super::row_weight(row))
.saturating_add(PER_ENTRY)
}
#[must_use]
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
#[must_use]
pub fn with_capacity(capacity: usize) -> Arc<Self> {
Arc::new(Self {
rows: Mutex::new(StoredRows::default()),
next: Mutex::new(0),
capacity,
byte_capacity: DEFAULT_STORE_BYTE_CAPACITY,
})
}
#[must_use]
pub fn with_capacities(capacity: usize, byte_capacity: usize) -> Arc<Self> {
Arc::new(Self {
rows: Mutex::new(StoredRows::default()),
next: Mutex::new(0),
capacity,
byte_capacity,
})
}
pub fn seed(&self, table: &str, tenant: &str, row_id: &str, row: PluginRow) {
let key = (table.to_owned(), tenant.to_owned(), row_id.to_owned());
let mut rows = self.rows.lock().unwrap_or_else(PoisonError::into_inner);
let outgoing = rows
.map
.get(&key)
.map_or(0, |existing| Self::entry_weight(&key, existing));
let incoming = Self::entry_weight(&key, &row);
rows.bytes = rows.bytes.saturating_sub(outgoing).saturating_add(incoming);
rows.map.insert(key, row);
}
#[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, String, String)> {
let rows = self.rows.lock().unwrap_or_else(PoisonError::into_inner);
let mut keys: Vec<_> = rows.map.keys().cloned().collect();
keys.sort();
keys
}
}
impl PluginStore for MemoryPluginStore {
#[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 insert(&self, scope: &Scope, row: PluginRow) -> Result<String, StoreError> {
let mut rows = self.rows.lock().unwrap_or_else(PoisonError::into_inner);
if rows.map.len() >= self.capacity {
return Err(StoreError::Backend(format!(
"this host's in-memory plugin store is full at {} rows",
self.capacity
)));
}
let incoming_key = (
scope.table.clone(),
scope.tenant.clone(),
String::from("r0000000000"),
);
let incoming = Self::entry_weight(&incoming_key, &row);
if rows.bytes.saturating_add(incoming) > self.byte_capacity {
return Err(StoreError::Backend(format!(
"this host's in-memory plugin store is full at {} bytes",
self.byte_capacity
)));
}
let mut next = self.next.lock().unwrap_or_else(PoisonError::into_inner);
let mut row_id = None;
for _ in 0..=rows.map.len() {
*next = next.saturating_add(1);
let candidate = format!("r{next}");
if !rows.map.contains_key(&(
scope.table.clone(),
scope.tenant.clone(),
candidate.clone(),
)) {
row_id = Some(candidate);
break;
}
}
drop(next);
let Some(row_id) = row_id else {
return Err(StoreError::Backend(
"this host's in-memory plugin store could not allocate a free row id".to_owned(),
));
};
rows.bytes = rows.bytes.saturating_add(incoming);
rows.map.insert(
(scope.table.clone(), scope.tenant.clone(), row_id.clone()),
row,
);
Ok(row_id)
}
fn get(&self, scope: &Scope, row_id: &str) -> Result<Option<PluginRow>, StoreError> {
Ok(self
.rows
.lock()
.unwrap_or_else(PoisonError::into_inner)
.map
.get(&(scope.table.clone(), scope.tenant.clone(), row_id.to_owned()))
.cloned()
.map(|row| with_id(row, row_id)))
}
#[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 query(
&self,
scope: &Scope,
filter: &PluginRow,
limit: usize,
max_bytes: usize,
after: Option<&str>,
) -> Result<QueryPage, StoreError> {
let rows = self.rows.lock().unwrap_or_else(PoisonError::into_inner);
let mut matched: Vec<&(String, String, String)> = rows
.map
.iter()
.filter(|((table, tenant, _), _)| *table == scope.table && *tenant == scope.tenant)
.filter(|(_, row)| {
filter
.iter()
.all(|(column, want)| row.get(column) == Some(want))
})
.filter(|((_, _, row_id), _)| after.is_none_or(|after| row_id.as_str() > after))
.map(|(key, _)| key)
.collect();
matched.sort_by(|left, right| left.2.cmp(&right.2));
let mut truncated = matched.len() > limit;
let mut out = Vec::new();
let mut total = 0_usize;
for key in matched.into_iter().take(limit) {
let Some(row) = rows.map.get(key) else {
continue;
};
let weight = super::row_weight(row).saturating_add(super::row_framing(row));
if total.saturating_add(weight) > max_bytes {
truncated = true;
break;
}
total = total.saturating_add(weight);
out.push(with_id(row.clone(), &key.2));
}
Ok(QueryPage {
rows: out,
truncated,
})
}
#[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 update(&self, scope: &Scope, row_id: &str, row: PluginRow) -> Result<(), StoreError> {
let key = (scope.table.clone(), scope.tenant.clone(), row_id.to_owned());
let mut rows = self.rows.lock().unwrap_or_else(PoisonError::into_inner);
let Some(existing) = rows.map.get(&key) else {
return Err(StoreError::NotFound);
};
let outgoing = Self::entry_weight(&key, existing);
let incoming = Self::entry_weight(&key, &row);
if rows.bytes.saturating_sub(outgoing).saturating_add(incoming) > self.byte_capacity {
return Err(StoreError::Backend(format!(
"this host's in-memory plugin store is full at {} bytes",
self.byte_capacity
)));
}
rows.bytes = rows.bytes.saturating_sub(outgoing).saturating_add(incoming);
rows.map.insert(key, row);
Ok(())
}
#[allow(
clippy::significant_drop_tightening,
reason = "the removal and the total it changes are one critical section; releasing \
between them would let a reader see a row gone and its bytes still charged"
)]
fn delete(&self, scope: &Scope, row_id: &str) -> Result<(), StoreError> {
let key = (scope.table.clone(), scope.tenant.clone(), row_id.to_owned());
let mut rows = self.rows.lock().unwrap_or_else(PoisonError::into_inner);
let Some(removed) = rows.map.remove(&key) else {
return Err(StoreError::NotFound);
};
let freed = Self::entry_weight(&key, &removed);
rows.bytes = rows.bytes.saturating_sub(freed);
Ok(())
}
}
fn with_id(mut row: PluginRow, row_id: &str) -> PluginRow {
row.insert(
ID_COLUMN.to_owned(),
super::PluginValue::Text(row_id.to_owned()),
);
row
}