#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::collections::{BTreeMap, VecDeque};
use std::fmt;
use std::sync::{Mutex, PoisonError};
use std::time::{Duration, Instant};
use serde::Serialize;
use super::super::manifest::SandboxCapability;
use super::DenialReason;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum CapabilityOutcome {
Allowed,
QuotaExceeded,
Denied(DenialReason),
}
impl fmt::Display for CapabilityOutcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Allowed => f.write_str("allowed"),
Self::QuotaExceeded => f.write_str("quota-exceeded"),
Self::Denied(reason) => write!(f, "denied:{reason}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct CapabilityEvent {
pub capability: SandboxCapability,
pub operation: &'static str,
pub target: String,
pub outcome: CapabilityOutcome,
}
pub const MAX_LOG_EVENTS: usize = 4096;
#[derive(Debug, Default)]
pub struct PluginActivityLog {
entries: Mutex<VecDeque<(Instant, String, CapabilityEvent)>>,
dropped: Mutex<VecDeque<(Instant, String, u64)>>,
}
const DROPPED_BUCKET: Duration = Duration::from_secs(1);
impl PluginActivityLog {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn ingest(&self, plugin: &str, events: impl IntoIterator<Item = CapabilityEvent>) {
let now = crate::time::ambient_instant();
let mut evicted: Vec<(Instant, String)> = Vec::new();
{
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
for event in events {
if entries.len() >= MAX_LOG_EVENTS
&& let Some((at, who, _)) = entries.pop_front()
{
evicted.push((at, who));
}
entries.push_back((now, plugin.to_owned(), event));
}
}
if !evicted.is_empty() {
let mut dropped = self.dropped.lock().unwrap_or_else(PoisonError::into_inner);
for (at, who) in evicted {
note_dropped(&mut dropped, at, &who, 1);
}
}
}
pub fn ingest_dropped(&self, plugin: &str, dropped: u64) {
if dropped == 0 {
return;
}
let mut ring = self.dropped.lock().unwrap_or_else(PoisonError::into_inner);
note_dropped(&mut ring, crate::time::ambient_instant(), plugin, dropped);
}
#[must_use]
pub fn summary(&self, plugin: &str, window: Duration) -> ActivitySummary {
let cutoff = crate::time::ambient_instant();
let mut summary = ActivitySummary {
plugin: plugin.to_owned(),
window,
..ActivitySummary::default()
};
{
let entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
for (at, who, event) in entries.iter() {
if who != plugin || cutoff.saturating_duration_since(*at) > window {
continue;
}
summary.record(event);
}
}
summary.dropped = {
let dropped = self.dropped.lock().unwrap_or_else(PoisonError::into_inner);
dropped
.iter()
.filter(|(at, who, _)| {
who == plugin && cutoff.saturating_duration_since(*at) <= window
})
.fold(0_u64, |sum, (_, _, count)| sum.saturating_add(*count))
};
summary
}
#[must_use]
pub fn plugins(&self) -> Vec<String> {
let mut names: Vec<String> = {
let entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
entries.iter().map(|(_, who, _)| who.clone()).collect()
};
names.extend({
let dropped = self.dropped.lock().unwrap_or_else(PoisonError::into_inner);
dropped
.iter()
.map(|(_, who, _)| who.clone())
.collect::<Vec<_>>()
});
names.sort();
names.dedup();
names
}
}
fn note_dropped(
ring: &mut VecDeque<(Instant, String, u64)>,
at: Instant,
plugin: &str,
count: u64,
) {
if let Some((last, who, total)) = ring.back_mut() {
let span = if at >= *last {
at.saturating_duration_since(*last)
} else {
last.saturating_duration_since(at)
};
if who == plugin && span < DROPPED_BUCKET {
*last = (*last).max(at);
*total = total.saturating_add(count);
return;
}
}
if ring.len() >= MAX_LOG_EVENTS {
ring.pop_front();
}
ring.push_back((at, plugin.to_owned(), count));
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct ActivitySummary {
pub allowed: BTreeMap<&'static str, u64>,
pub denied: BTreeMap<&'static str, u64>,
pub quota_hits: BTreeMap<&'static str, u64>,
pub hosts: BTreeMap<String, u64>,
pub tables: BTreeMap<String, u64>,
pub job_types: BTreeMap<String, u64>,
pub refused_targets: BTreeMap<String, u64>,
pub plugin: String,
pub window: Duration,
pub dropped: u64,
}
impl ActivitySummary {
fn record(&mut self, event: &CapabilityEvent) {
let bucket = match event.outcome {
CapabilityOutcome::Allowed => &mut self.allowed,
CapabilityOutcome::QuotaExceeded => &mut self.quota_hits,
CapabilityOutcome::Denied(_) => &mut self.denied,
};
let counter = bucket.entry(event.operation).or_default();
*counter = counter.saturating_add(1);
let allowed = matches!(
event.outcome,
CapabilityOutcome::Allowed
| CapabilityOutcome::Denied(
DenialReason::ResponseTooLarge | DenialReason::BackendError
)
);
let enqueued = matches!(event.outcome, CapabilityOutcome::Allowed);
let bucket = match (event.capability, allowed) {
(SandboxCapability::HttpOutbound, true) => Some(&mut self.hosts),
(SandboxCapability::Db, true) => Some(&mut self.tables),
(SandboxCapability::Jobs, true) if enqueued => Some(&mut self.job_types),
(
SandboxCapability::HttpOutbound | SandboxCapability::Db | SandboxCapability::Jobs,
_,
) => Some(&mut self.refused_targets),
_ => None,
};
if let Some(bucket) = bucket {
let counter = bucket.entry(event.target.clone()).or_default();
*counter = counter.saturating_add(1);
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.allowed.is_empty() && self.denied.is_empty() && self.quota_hits.is_empty()
}
fn write_report(&self, out: &mut String) {
use std::fmt::Write as _;
let _ = writeln!(
out,
"sandboxed plugin `{plugin}` — last {secs}s",
plugin = self.plugin,
secs = self.window.as_secs()
);
if self.is_empty() && self.dropped == 0 {
out.push_str(" no capability calls\n");
return;
}
if self.dropped > 0 {
let _ = writeln!(
out,
" \u{26A0} {dropped} further call(s) were made and are NOT counted below: \
every number here is a floor",
dropped = self.dropped
);
}
for (label, bucket) in [
("performed", &self.allowed),
("denied", &self.denied),
("over quota", &self.quota_hits),
] {
if bucket.is_empty() {
continue;
}
let _ = writeln!(out, " {label}:");
for (operation, count) in bucket {
let _ = writeln!(out, " {operation} × {count}");
}
}
for (label, bucket) in [
("hosts called", &self.hosts),
("tables touched", &self.tables),
("jobs enqueued", &self.job_types),
("targets refused", &self.refused_targets),
] {
if bucket.is_empty() {
continue;
}
let _ = writeln!(out, " {label}:");
for (target, count) in bucket {
let _ = writeln!(out, " {target} × {count}");
}
}
}
}
impl fmt::Display for ActivitySummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut out = String::new();
self.write_report(&mut out);
f.write_str(&out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin_sandbox::manifest::SandboxCapability;
fn event(operation: &'static str, outcome: CapabilityOutcome) -> CapabilityEvent {
CapabilityEvent {
capability: SandboxCapability::Kv,
operation,
target: "cart".to_owned(),
outcome,
}
}
#[test]
fn a_log_from_a_nested_timeline_ages_out_on_the_outer_one() {
use std::sync::Arc;
use chrono::TimeZone as _;
use crate::time::{TickingClock, install_ambient};
let epoch = chrono::Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let hour = Duration::from_secs(3600);
let log = PluginActivityLog::new();
let outer = TickingClock::starting_at(epoch);
let _outer = install_ambient(Arc::new(outer.clone()));
let inner = TickingClock::starting_at(epoch);
let guard = install_ambient(Arc::new(inner.clone()));
inner.advance(2 * hour);
log.ingest("shop", [event("kv-get", CapabilityOutcome::Allowed)]);
log.ingest_dropped("shop", 3);
drop(guard);
let summary = log.summary("shop", hour);
assert_eq!(summary.allowed.get("kv-get").copied(), Some(1));
assert_eq!(summary.dropped, 3);
outer.advance(hour + Duration::from_secs(1));
let summary = log.summary("shop", hour);
assert_eq!(summary.allowed.get("kv-get"), None);
assert_eq!(summary.dropped, 0);
}
#[test]
fn the_ring_evicts_the_oldest_rather_than_growing() {
let log = PluginActivityLog::new();
log.ingest(
"shop",
(0..MAX_LOG_EVENTS + 100).map(|_| event("kv-get", CapabilityOutcome::Allowed)),
);
let summary = log.summary("shop", Duration::from_secs(3600));
assert_eq!(
summary.allowed.get("kv-get").copied(),
Some(MAX_LOG_EVENTS as u64),
"the log holds its ceiling and no more"
);
}
#[test]
fn a_summary_covers_one_plugin_and_one_window() {
let log = PluginActivityLog::new();
log.ingest("shop", [event("kv-get", CapabilityOutcome::Allowed)]);
log.ingest("other", [event("kv-set", CapabilityOutcome::Allowed)]);
let shop = log.summary("shop", Duration::from_secs(3600));
assert_eq!(shop.allowed.get("kv-get").copied(), Some(1));
assert!(!shop.allowed.contains_key("kv-set"), "{shop:?}");
assert!(log.summary("shop", Duration::ZERO).is_empty());
assert!(log.summary("nobody", Duration::from_secs(3600)).is_empty());
assert_eq!(log.plugins(), vec!["other".to_owned(), "shop".to_owned()]);
}
#[test]
fn a_truncated_summary_says_so_before_it_says_anything_else() {
let log = PluginActivityLog::new();
log.ingest("shop", [event("kv-get", CapabilityOutcome::Allowed)]);
log.ingest_dropped("shop", 0);
assert_eq!(log.summary("shop", Duration::from_secs(3600)).dropped, 0);
log.ingest_dropped("shop", 7);
log.ingest_dropped("shop", 5);
let summary = log.summary("shop", Duration::from_secs(3600));
assert_eq!(summary.dropped, 12);
let rendered = summary.to_string();
assert!(rendered.contains("floor"), "{rendered}");
let warn = rendered.find("floor").unwrap_or(usize::MAX);
let count = rendered.find("kv-get").unwrap_or(0);
assert!(warn < count, "{rendered}");
}
#[test]
fn a_summary_names_the_window_it_was_taken_over() {
let log = PluginActivityLog::new();
log.ingest("shop", [event("kv-get", CapabilityOutcome::Allowed)]);
let rendered = log.summary("shop", Duration::from_secs(60)).to_string();
assert!(rendered.contains("last 60s"), "{rendered}");
assert!(rendered.contains("`shop`"), "{rendered}");
}
#[test]
fn an_event_the_ring_evicted_is_reported_as_dropped_rather_than_forgotten() {
let log = PluginActivityLog::new();
for _ in 0..MAX_LOG_EVENTS + 100 {
log.ingest("shop", [event("kv-get", CapabilityOutcome::Allowed)]);
}
let summary = log.summary("shop", Duration::from_secs(3600));
assert_eq!(
summary.allowed.get("kv-get").copied(),
Some(MAX_LOG_EVENTS as u64),
"the ring still holds its ceiling"
);
assert_eq!(summary.dropped, 100, "and says what it could not hold");
assert!(
summary.to_string().contains("NOT counted below"),
"{summary}"
);
}
#[test]
fn a_plugin_visible_only_as_drops_is_still_enumerated() {
let log = PluginActivityLog::new();
log.ingest_dropped("quiet", 7);
for _ in 0..MAX_LOG_EVENTS {
log.ingest("busy", [event("kv-get", CapabilityOutcome::Allowed)]);
}
let names = log.plugins();
assert!(
names.contains(&"quiet".to_owned()),
"a plugin the log can summarise must be one the log can name: {names:?}"
);
assert!(names.contains(&"busy".to_owned()), "{names:?}");
assert_eq!(
log.summary("quiet", Duration::from_secs(3600)).dropped,
7,
"and the summary it was omitted from is not empty"
);
}
#[test]
fn a_dropped_count_leaves_the_window_with_the_calls_it_describes() {
let log = PluginActivityLog::new();
log.ingest_dropped("shop", 9);
assert_eq!(log.summary("shop", Duration::from_secs(3600)).dropped, 9);
assert_eq!(
log.summary("shop", Duration::ZERO).dropped,
0,
"a window covering nothing covers no drops either"
);
assert_eq!(
log.summary("other", Duration::from_secs(3600)).dropped,
0,
"and a drop belongs to the plugin that made it"
);
}
#[test]
fn the_dropped_ledger_is_itself_bounded() {
let log = PluginActivityLog::new();
for _ in 0..MAX_LOG_EVENTS * 3 {
log.ingest("shop", [event("kv-get", CapabilityOutcome::Allowed)]);
}
let held = log
.dropped
.lock()
.unwrap_or_else(PoisonError::into_inner)
.len();
assert!(held <= MAX_LOG_EVENTS, "the dropped ring grew to {held}");
assert!(
log.summary("shop", Duration::from_secs(3600)).dropped >= 1,
"and still reports the drops"
);
}
#[test]
fn a_refused_enqueue_is_not_reported_as_a_job() {
let log = PluginActivityLog::new();
log.ingest(
"shop",
[
CapabilityEvent {
capability: SandboxCapability::Jobs,
operation: "job-enqueue",
target: "reindex".to_owned(),
outcome: CapabilityOutcome::Allowed,
},
CapabilityEvent {
capability: SandboxCapability::Jobs,
operation: "job-enqueue",
target: "reindex".to_owned(),
outcome: CapabilityOutcome::Denied(DenialReason::BackendError),
},
],
);
let summary = log.summary("shop", Duration::from_secs(3600));
assert_eq!(
summary.job_types.get("reindex").copied(),
Some(1),
"only the accepted enqueue is a job: {summary:?}"
);
assert_eq!(
summary.refused_targets.get("reindex").copied(),
Some(1),
"and the refusal is still visible: {summary:?}"
);
}
}