1#![cfg_attr(
57 not(test),
58 deny(
59 clippy::unwrap_used,
60 clippy::expect_used,
61 clippy::panic,
62 clippy::unreachable,
63 clippy::todo,
64 clippy::unimplemented,
65 clippy::indexing_slicing,
66 clippy::string_slice,
67 clippy::arithmetic_side_effects,
68 )
69)]
70
71pub mod audit;
72pub mod db;
73pub mod jobs;
74pub mod kv;
75pub mod outbound;
76pub mod quota;
77pub mod render;
78
79use std::collections::BTreeMap;
80use std::fmt;
81use std::sync::Arc;
82
83use serde::{Deserialize, Serialize};
84
85use super::grants::CapabilityQuotas;
86use super::manifest::{SandboxCapability, SandboxManifest};
87use quota::QuotaLedger;
88
89pub use audit::{
90 ActivitySummary, CapabilityEvent, CapabilityOutcome, DroppedCalls, MAX_LOG_EVENTS,
91 PluginActivityLog,
92};
93use audit::{CapabilityEvent as Event, CapabilityOutcome as Outcome};
96pub use db::{MemoryPluginStore, PluginStore, QueryPage, Scope, StoreError};
97pub use jobs::MemoryJobSink;
98pub use jobs::{JobSink, PluginJob};
99pub use kv::{CacheKvStore, KvStore, MemoryKvStore};
100pub use outbound::{
101 ALLOWED_OUTBOUND_REQUEST_HEADERS, ALLOWED_OUTBOUND_RESPONSE_HEADERS, MAX_OUTBOUND_HEADERS,
102 MAX_RESPONSE_HEADER_BYTES, OutboundHttp, OutboundRequest, OutboundResponse, RecordingHttp,
103};
104pub use quota::CapabilityRateLimiter;
105pub use render::{ALLOWED_ATTRIBUTES, ALLOWED_TAGS, FragmentNode, RenderError};
106
107pub const MAX_ROW_COLUMNS: usize = 32;
114
115pub const MAX_VALUE_TEXT_BYTES: usize = 64 * 1024;
117
118pub const MAX_KV_KEY_BYTES: usize = 512;
124
125pub const MAX_ROW_BYTES: usize = 256 * 1024;
132
133pub const MAX_RESULT_BYTES: usize = 512 * 1024;
147
148pub const MAX_ROW_ID_BYTES: usize = 128;
150
151pub const MAX_JOB_ID_BYTES: usize = 128;
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173#[serde(untagged)]
174pub enum PluginValue {
175 Null,
177 Bool(bool),
179 Int(i64),
181 Float(f64),
183 Text(String),
185}
186
187thread_local! {
188 static FRAGMENT_NODE_BUDGET: std::cell::Cell<usize> =
197 const { std::cell::Cell::new(usize::MAX) };
198}
199
200pub struct FragmentNodeBudget(usize);
202
203impl FragmentNodeBudget {
204 #[must_use]
206 pub fn reserve(limit: usize) -> Self {
207 Self(FRAGMENT_NODE_BUDGET.with(|budget| budget.replace(limit)))
208 }
209}
210
211impl Drop for FragmentNodeBudget {
212 fn drop(&mut self) {
213 FRAGMENT_NODE_BUDGET.with(|budget| budget.set(self.0));
214 }
215}
216
217pub fn bounded_tree<'de, D, const LIMIT: usize>(
223 deserializer: D,
224) -> Result<Vec<render::FragmentNode>, D::Error>
225where
226 D: serde::Deserializer<'de>,
227{
228 let _budget = FragmentNodeBudget::reserve(LIMIT);
229 bounded_subtree(deserializer)
230}
231
232pub fn bounded_subtree<'de, D>(deserializer: D) -> Result<Vec<render::FragmentNode>, D::Error>
245where
246 D: serde::Deserializer<'de>,
247{
248 use serde::de::{Error as _, SeqAccess, Visitor};
249
250 struct Bounded;
251
252 impl<'de> Visitor<'de> for Bounded {
253 type Value = Vec<render::FragmentNode>;
254
255 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
256 write!(
257 formatter,
258 "at most {} nodes in the whole fragment",
259 render::MAX_NODES
260 )
261 }
262
263 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
264 let mut out = Vec::new();
265 while let Some(node) = seq.next_element()? {
272 let spent = FRAGMENT_NODE_BUDGET.with(|budget| {
273 let left = budget.get();
274 if left == 0 {
275 return false;
276 }
277 budget.set(left.saturating_sub(1));
278 true
279 });
280 if !spent {
281 return Err(A::Error::custom(format!(
282 "more than {} nodes in the fragment",
283 render::MAX_NODES
284 )));
285 }
286 out.push(node);
287 }
288 Ok(out)
289 }
290 }
291
292 deserializer.deserialize_seq(Bounded)
293}
294
295pub fn bounded_vec<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
312where
313 D: serde::Deserializer<'de>,
314 T: serde::Deserialize<'de>,
315{
316 use serde::de::{Error as _, SeqAccess, Visitor};
317
318 struct Bounded<T, const LIMIT: usize>(std::marker::PhantomData<T>);
319
320 impl<'de, T, const LIMIT: usize> Visitor<'de> for Bounded<T, LIMIT>
321 where
322 T: serde::Deserialize<'de>,
323 {
324 type Value = Vec<T>;
325
326 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
327 write!(formatter, "at most {LIMIT} elements")
328 }
329
330 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
331 let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(LIMIT));
334 while let Some(item) = seq.next_element()? {
335 if out.len() >= LIMIT {
336 return Err(A::Error::custom(format!("more than {LIMIT} elements")));
337 }
338 out.push(item);
339 }
340 Ok(out)
341 }
342 }
343
344 deserializer.deserialize_seq(Bounded::<T, LIMIT>(std::marker::PhantomData))
345}
346
347pub fn bounded_row<'de, D>(deserializer: D) -> Result<PluginRow, D::Error>
362where
363 D: serde::Deserializer<'de>,
364{
365 use serde::de::{Error as _, MapAccess, Visitor};
366
367 struct Bounded;
368
369 impl<'de> Visitor<'de> for Bounded {
370 type Value = PluginRow;
371
372 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
373 write!(formatter, "at most {} columns", MAX_ROW_COLUMNS + 1)
374 }
375
376 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
377 const PARSE_CEILING: usize = MAX_ROW_COLUMNS + 1;
391
392 let mut out = PluginRow::new();
393 while let Some((column, value)) = map.next_entry::<String, PluginValue>()? {
394 if out.len() >= PARSE_CEILING && !out.contains_key(&column) {
398 return Err(A::Error::custom(format!(
399 "more than {PARSE_CEILING} columns"
400 )));
401 }
402 out.insert(column, value);
403 }
404 Ok(out)
405 }
406 }
407
408 deserializer.deserialize_map(Bounded)
409}
410
411#[must_use]
429pub fn encoded_text_len(text: &str) -> usize {
430 text.chars()
431 .map(|ch| match ch {
432 '"' | '\\' | '\n' | '\r' | '\t' | '\u{8}' | '\u{c}' => 2,
434 other if other.is_control() => 6,
436 other => other.len_utf8(),
437 })
438 .fold(0, usize::saturating_add)
439}
440
441const fn decimal_len(value: i64) -> usize {
445 if value == i64::MIN {
448 return 20;
449 }
450 let negative = value < 0;
451 let mut left = value.unsigned_abs();
452 let mut digits = 1_usize;
453 while left >= 10 {
454 left /= 10;
455 digits = digits.saturating_add(1);
456 }
457 if negative {
458 digits.saturating_add(1)
459 } else {
460 digits
461 }
462}
463
464impl PluginValue {
465 #[must_use]
470 pub fn weight(&self) -> usize {
471 match self {
472 Self::Text(text) => encoded_text_len(text),
473 Self::Null | Self::Bool(true) => 4,
480 Self::Bool(false) => 5,
481 Self::Int(value) => decimal_len(*value),
482 Self::Float(_) => 24,
489 }
490 }
491}
492
493impl fmt::Display for PluginValue {
494 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
495 match self {
496 Self::Null => f.write_str("null"),
497 Self::Bool(value) => write!(f, "{value}"),
498 Self::Int(value) => write!(f, "{value}"),
499 Self::Float(value) => write!(f, "{value}"),
500 Self::Text(value) => f.write_str(value),
501 }
502 }
503}
504
505pub type PluginRow = BTreeMap<String, PluginValue>;
507
508pub fn check_row(row: &PluginRow) -> Result<(), String> {
517 check_row_without(row, None)
518}
519
520pub fn check_row_without(row: &PluginRow, skip: Option<&str>) -> Result<(), String> {
531 let kept = || {
532 row.iter()
533 .filter(move |(column, _)| Some(column.as_str()) != skip)
534 };
535 let columns = kept().count();
536 if columns > MAX_ROW_COLUMNS {
537 return Err(format!(
538 "a row may carry at most {MAX_ROW_COLUMNS} columns; this one carries {columns}"
539 ));
540 }
541 let weight = kept().fold(0, |sum: usize, (column, value)| {
542 sum.saturating_add(column.len())
543 .saturating_add(value.weight())
544 });
545 if weight > MAX_ROW_BYTES {
546 return Err(format!(
547 "a row may carry at most {MAX_ROW_BYTES} bytes across its columns; this one carries \
548 {weight}"
549 ));
550 }
551 for (column, value) in kept() {
552 if !super::grants::is_grantable_ident(column) {
553 return Err(format!(
554 "column name {:?} is not a lower-case `[a-z][a-z0-9_]*` identifier; the host \
555 derives a physical column name from it",
556 super::manifest::rejected(column)
557 ));
558 }
559 if value.weight() > MAX_VALUE_TEXT_BYTES {
560 return Err(format!(
561 "column {column:?} carries {} bytes, over the {MAX_VALUE_TEXT_BYTES}-byte ceiling",
562 value.weight()
563 ));
564 }
565 }
566 Ok(())
567}
568
569#[must_use]
574pub fn row_weight(row: &PluginRow) -> usize {
575 row.iter().fold(0, |sum, (column, value)| {
576 sum.saturating_add(column.len())
577 .saturating_add(value.weight())
578 })
579}
580
581pub(super) fn row_framing(row: &PluginRow) -> usize {
584 row.len().saturating_mul(8).saturating_add(16)
585}
586
587#[must_use]
605pub fn bounded_rows(rows: Vec<PluginRow>) -> (Vec<PluginRow>, bool) {
606 let mut kept = Vec::with_capacity(rows.len());
607 let mut total = 0_usize;
608 let mut truncated = false;
609 for row in rows {
610 let weight = row_weight(&row).saturating_add(row_framing(&row));
612 if total.saturating_add(weight) > MAX_RESULT_BYTES {
617 truncated = true;
618 break;
619 }
620 total = total.saturating_add(weight);
621 kept.push(row);
622 }
623 (kept, truncated)
624}
625
626#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
639#[serde(tag = "call", rename_all = "kebab-case", deny_unknown_fields)]
640#[non_exhaustive]
641pub enum CapabilityCall {
642 KvGet {
644 id: u64,
646 key: String,
648 },
649 KvSet {
651 id: u64,
653 key: String,
655 value: PluginValue,
657 },
658 KvDelete {
660 id: u64,
662 key: String,
664 },
665 HttpFetch {
667 id: u64,
669 method: String,
671 url: String,
673 #[serde(
678 default,
679 deserialize_with = "bounded_vec::<_, _, MAX_OUTBOUND_HEADERS>"
680 )]
681 headers: Vec<(String, String)>,
682 #[serde(default)]
684 body: String,
685 },
686 DbInsert {
688 id: u64,
690 table: String,
692 #[serde(deserialize_with = "bounded_row")]
694 row: PluginRow,
695 },
696 DbGet {
698 id: u64,
700 table: String,
702 row_id: String,
704 },
705 DbQuery {
707 id: u64,
709 table: String,
711 #[serde(default, deserialize_with = "bounded_row")]
714 filter: PluginRow,
715 #[serde(default)]
717 limit: u32,
718 #[serde(default)]
728 after: Option<String>,
729 },
730 DbUpdate {
732 id: u64,
734 table: String,
736 row_id: String,
738 #[serde(deserialize_with = "bounded_row")]
740 row: PluginRow,
741 },
742 DbDelete {
744 id: u64,
746 table: String,
748 row_id: String,
750 },
751 JobEnqueue {
753 id: u64,
755 job_type: String,
757 #[serde(default, deserialize_with = "bounded_row")]
759 payload: PluginRow,
760 },
761}
762
763impl CapabilityCall {
764 #[must_use]
766 pub const fn id(&self) -> u64 {
767 match self {
768 Self::KvGet { id, .. }
769 | Self::KvSet { id, .. }
770 | Self::KvDelete { id, .. }
771 | Self::HttpFetch { id, .. }
772 | Self::DbInsert { id, .. }
773 | Self::DbGet { id, .. }
774 | Self::DbQuery { id, .. }
775 | Self::DbUpdate { id, .. }
776 | Self::DbDelete { id, .. }
777 | Self::JobEnqueue { id, .. } => *id,
778 }
779 }
780
781 #[must_use]
783 pub const fn capability(&self) -> SandboxCapability {
784 match self {
785 Self::KvGet { .. } | Self::KvSet { .. } | Self::KvDelete { .. } => {
786 SandboxCapability::Kv
787 }
788 Self::HttpFetch { .. } => SandboxCapability::HttpOutbound,
789 Self::DbInsert { .. }
790 | Self::DbGet { .. }
791 | Self::DbQuery { .. }
792 | Self::DbUpdate { .. }
793 | Self::DbDelete { .. } => SandboxCapability::Db,
794 Self::JobEnqueue { .. } => SandboxCapability::Jobs,
795 }
796 }
797
798 #[must_use]
810 pub fn target(&self) -> String {
811 match self {
812 Self::KvGet { key, .. } | Self::KvSet { key, .. } | Self::KvDelete { key, .. } => {
813 key.clone()
814 }
815 Self::HttpFetch { url, .. } => {
822 outbound::host_of(url).unwrap_or_else(|| outbound::attempted_authority(url))
823 }
824 Self::DbInsert { table, .. }
825 | Self::DbGet { table, .. }
826 | Self::DbQuery { table, .. }
827 | Self::DbUpdate { table, .. }
828 | Self::DbDelete { table, .. } => table.clone(),
829 Self::JobEnqueue { job_type, .. } => job_type.clone(),
830 }
831 }
832
833 #[must_use]
840 pub const fn reply_reserve(&self) -> usize {
841 self.reply_reserve_for(MAX_RESULT_BYTES, 500)
842 }
843
844 #[must_use]
850 pub const fn reply_reserve_for(&self, kv_value_bytes: usize, db_rows: usize) -> usize {
851 const ROW_FRAMING: usize = (MAX_ROW_COLUMNS + 1) * 8 + 16;
853 match self {
854 Self::KvGet { .. } => kv_value_bytes.saturating_add(4096),
855 Self::DbGet { .. } => MAX_RESULT_BYTES + ROW_FRAMING + 4096,
856 Self::DbQuery { limit, .. } => {
857 let asked = *limit as usize;
859 let rows = if asked == 0 || asked > db_rows {
860 db_rows
861 } else {
862 asked
863 };
864 let reserve = MAX_RESULT_BYTES
865 .saturating_add(rows.saturating_mul(ROW_FRAMING))
866 .saturating_add(4096);
867 if reserve > super::host::MAX_QUEUED_REPLY_BYTES {
869 super::host::MAX_QUEUED_REPLY_BYTES
870 } else {
871 reserve
872 }
873 }
874 Self::HttpFetch { .. } => {
875 MAX_RESULT_BYTES + outbound::MAX_RESPONSE_HEADER_BYTES * 6 + 4096
876 }
877 _ => 4096,
878 }
879 }
880
881 #[must_use]
887 pub fn target_excerpt(&self) -> String {
888 const URL_PREFIX_CHARS: usize = 4096;
890 match self {
891 Self::KvGet { key, .. } | Self::KvSet { key, .. } | Self::KvDelete { key, .. } => {
892 bounded_target(key)
893 }
894 Self::HttpFetch { url, .. } if url.len() <= URL_PREFIX_CHARS => bounded_target(
895 &outbound::host_of(url).unwrap_or_else(|| outbound::attempted_authority(url)),
896 ),
897 Self::HttpFetch { url, .. } => {
900 let rest = url.split_once("://").map_or(url.as_str(), |(_, rest)| rest);
901 let rest = rest.trim_start_matches('/');
902 let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
903 let host = authority.rsplit('@').next().unwrap_or(authority);
904 bounded_target(host).to_ascii_lowercase()
905 }
906 Self::DbInsert { table, .. }
907 | Self::DbGet { table, .. }
908 | Self::DbQuery { table, .. }
909 | Self::DbUpdate { table, .. }
910 | Self::DbDelete { table, .. } => bounded_target(table),
911 Self::JobEnqueue { job_type, .. } => bounded_target(job_type),
912 }
913 }
914
915 #[must_use]
917 pub const fn operation(&self) -> &'static str {
918 match self {
919 Self::KvGet { .. } => "kv-get",
920 Self::KvSet { .. } => "kv-set",
921 Self::KvDelete { .. } => "kv-delete",
922 Self::HttpFetch { .. } => "http-fetch",
923 Self::DbInsert { .. } => "db-insert",
924 Self::DbGet { .. } => "db-get",
925 Self::DbQuery { .. } => "db-query",
926 Self::DbUpdate { .. } => "db-update",
927 Self::DbDelete { .. } => "db-delete",
928 Self::JobEnqueue { .. } => "job-enqueue",
929 }
930 }
931
932 #[must_use]
937 pub fn commits(&self) -> bool {
938 match self {
939 Self::KvGet { .. } | Self::DbGet { .. } | Self::DbQuery { .. } => false,
940 Self::HttpFetch { method, .. } => {
941 !(method.eq_ignore_ascii_case("GET") || method.eq_ignore_ascii_case("HEAD"))
942 }
943 Self::KvSet { .. }
944 | Self::KvDelete { .. }
945 | Self::DbInsert { .. }
946 | Self::DbUpdate { .. }
947 | Self::DbDelete { .. }
948 | Self::JobEnqueue { .. } => true,
949 }
950 }
951}
952
953#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
961#[serde(rename_all = "kebab-case")]
962#[non_exhaustive]
963pub enum DenialReason {
964 CapabilityNotGranted,
966 NotInGrant,
968 QuotaExceeded,
970 Malformed,
972 Unavailable,
974 BackendError,
976 ResponseTooLarge,
983 ResultRejected,
988}
989
990impl DenialReason {
991 #[must_use]
993 pub const fn as_str(self) -> &'static str {
994 match self {
995 Self::CapabilityNotGranted => "capability-not-granted",
996 Self::NotInGrant => "not-in-grant",
997 Self::QuotaExceeded => "quota-exceeded",
998 Self::Malformed => "malformed",
999 Self::Unavailable => "unavailable",
1000 Self::BackendError => "backend-error",
1001 Self::ResponseTooLarge => "response-too-large",
1002 Self::ResultRejected => "result-rejected",
1003 }
1004 }
1005}
1006
1007impl fmt::Display for DenialReason {
1008 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1009 f.write_str(self.as_str())
1010 }
1011}
1012
1013#[must_use]
1018#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1019#[serde(tag = "status", rename_all = "kebab-case")]
1020#[non_exhaustive]
1021pub enum CallResult {
1022 Ok {
1024 id: u64,
1026 value: CallValue,
1028 },
1029 Denied {
1031 id: u64,
1033 reason: DenialReason,
1035 detail: String,
1037 },
1038}
1039
1040pub const MAX_DETAIL_CHARS: usize = 512;
1050
1051impl CallResult {
1052 pub fn denied(id: u64, reason: DenialReason, detail: impl Into<String>) -> Self {
1062 let detail = detail.into();
1063 let mut chars = detail.chars();
1064 let kept: String = chars.by_ref().take(MAX_DETAIL_CHARS).collect();
1065 let kept = if chars.next().is_some() {
1066 format!("{kept}…")
1067 } else {
1068 kept
1069 };
1070 Self::Denied {
1071 id,
1072 reason,
1073 detail: super::manifest::rejected(&kept),
1076 }
1077 }
1078
1079 #[must_use]
1081 pub const fn id(&self) -> u64 {
1082 match self {
1083 Self::Ok { id, .. } | Self::Denied { id, .. } => *id,
1084 }
1085 }
1086
1087 #[must_use]
1089 pub const fn denial(&self) -> Option<DenialReason> {
1090 match self {
1091 Self::Ok { .. } => None,
1092 Self::Denied { reason, .. } => Some(*reason),
1093 }
1094 }
1095}
1096
1097#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1099#[serde(tag = "kind", rename_all = "kebab-case")]
1100#[non_exhaustive]
1101pub enum CallValue {
1102 Done,
1104 Value {
1106 value: PluginValue,
1109 found: bool,
1111 },
1112 RowId {
1114 row_id: String,
1116 },
1117 Rows {
1119 rows: Vec<PluginRow>,
1121 truncated: bool,
1128 },
1129 Http {
1131 status: u16,
1133 headers: Vec<(String, String)>,
1135 body: String,
1137 },
1138 JobId {
1140 job_id: String,
1142 },
1143}
1144
1145#[derive(Clone, Default)]
1161pub struct CapabilityServices {
1162 pub tenant: Option<String>,
1167 pub kv: Option<Arc<dyn KvStore>>,
1169 pub http: Option<Arc<dyn OutboundHttp>>,
1171 pub db: Option<Arc<dyn PluginStore>>,
1173 pub jobs: Option<Arc<dyn JobSink>>,
1175 pub rate: Option<Arc<quota::CapabilityRateLimiter>>,
1182}
1183
1184impl fmt::Debug for CapabilityServices {
1185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1186 f.debug_struct("CapabilityServices")
1187 .field("tenant", &self.tenant)
1188 .field("kv", &self.kv.is_some())
1189 .field("http", &self.http.is_some())
1190 .field("db", &self.db.is_some())
1191 .field("jobs", &self.jobs.is_some())
1192 .field("rate", &self.rate.is_some())
1193 .finish()
1194 }
1195}
1196
1197impl CapabilityServices {
1198 #[must_use]
1200 pub fn none() -> Self {
1201 Self::default()
1202 }
1203
1204 #[must_use]
1206 pub fn for_tenant(mut self, tenant: impl Into<String>) -> Self {
1207 self.tenant = Some(tenant.into());
1208 self
1209 }
1210}
1211
1212pub const NO_TENANT: &str = "-";
1227
1228const TENANT_PRESENT: char = 't';
1230
1231#[must_use]
1240pub fn tenant_segment(tenant: Option<&str>) -> String {
1241 tenant.map_or_else(
1242 || NO_TENANT.to_owned(),
1243 |id| format!("{TENANT_PRESENT}{id}"),
1244 )
1245}
1246
1247pub struct CapabilityRuntime {
1253 pub(super) plugin: String,
1256 pub(super) artifact_sha256: String,
1259 capabilities: Vec<SandboxCapability>,
1260 pub(super) grants: super::grants::CapabilityGrants,
1263 pub(super) services: CapabilityServices,
1266 quotas: QuotaLedger,
1267 rate: Option<Arc<quota::CapabilityRateLimiter>>,
1268 events: Vec<Event>,
1269 dropped: Option<DroppedCalls>,
1272 warned: Vec<(&'static str, Outcome)>,
1274}
1275
1276const MAX_WARNED: usize = 64;
1282
1283fn bounded_target(target: &str) -> String {
1289 let mut chars = target.chars();
1290 let kept: String = chars.by_ref().take(MAX_TARGET_CHARS).collect();
1291 if chars.next().is_some() {
1292 format!("{kept}…")
1293 } else {
1294 kept
1295 }
1296}
1297
1298pub const MAX_TARGET_CHARS: usize = 96;
1306
1307pub const MAX_EVENTS: usize = 512;
1315
1316impl fmt::Debug for CapabilityRuntime {
1317 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1318 f.debug_struct("CapabilityRuntime")
1319 .field("plugin", &self.plugin)
1320 .field("capabilities", &self.capabilities)
1321 .field("events", &self.events.len())
1322 .finish_non_exhaustive()
1323 }
1324}
1325
1326impl CapabilityRuntime {
1327 #[must_use]
1329 pub fn new(manifest: &SandboxManifest, services: CapabilityServices) -> Self {
1330 Self {
1331 plugin: manifest.name.clone(),
1332 artifact_sha256: manifest.sha256.clone(),
1333 capabilities: manifest.capabilities.clone(),
1334 grants: manifest.grants.clone(),
1335 rate: services.rate.clone(),
1336 services,
1337 quotas: QuotaLedger::new(manifest.quotas),
1338 events: Vec::new(),
1339 dropped: None,
1340 warned: Vec::new(),
1341 }
1342 }
1343
1344 #[must_use]
1348 pub fn with_artifact_sha256(mut self, digest: impl Into<String>) -> Self {
1349 self.artifact_sha256 = digest.into();
1350 self
1351 }
1352
1353 #[must_use]
1355 pub const fn quotas(&self) -> &CapabilityQuotas {
1356 self.quotas.declared()
1357 }
1358
1359 #[must_use]
1371 pub fn tenant(&self) -> Option<&str> {
1372 self.services.tenant.as_deref()
1373 }
1374
1375 #[must_use]
1381 pub fn tenant_key(&self) -> String {
1382 tenant_segment(self.tenant())
1383 }
1384
1385 #[must_use]
1387 pub fn events(&self) -> &[Event] {
1388 &self.events
1389 }
1390
1391 #[must_use]
1393 pub fn take_events(&mut self) -> Vec<Event> {
1394 std::mem::take(&mut self.events)
1395 }
1396
1397 #[must_use]
1402 pub const fn dropped_events(&self) -> u64 {
1403 match self.dropped {
1404 Some(dropped) => dropped.count,
1405 None => 0,
1406 }
1407 }
1408
1409 #[must_use]
1415 pub const fn dropped_calls(&self) -> Option<DroppedCalls> {
1416 self.dropped
1417 }
1418
1419 pub(crate) const fn reply_reserve(&self, call: &CapabilityCall) -> usize {
1422 let quotas = self.quotas();
1423 let reserve =
1424 call.reply_reserve_for(quotas.kv_value_bytes as usize, quotas.db_rows as usize);
1425 match call {
1426 CapabilityCall::HttpFetch { .. } => {
1430 let body = (quotas.outbound_response_bytes as usize).saturating_mul(6);
1431 reserve.saturating_sub(MAX_RESULT_BYTES.saturating_sub(body))
1432 }
1433 _ => reserve,
1434 }
1435 }
1436
1437 #[must_use]
1443 pub fn is_granted(&self, capability: SandboxCapability) -> bool {
1444 self.capabilities.contains(&capability)
1445 }
1446
1447 pub fn dispatch(&mut self, call: &CapabilityCall) -> CallResult {
1452 self.dispatch_within(call, usize::MAX)
1453 }
1454
1455 pub fn dispatch_within(&mut self, call: &CapabilityCall, reply_room: usize) -> CallResult {
1461 self.dispatch_checked(call, reply_room, |_| true)
1462 }
1463
1464 pub(crate) fn dispatch_checked(
1468 &mut self,
1469 call: &CapabilityCall,
1470 reply_room: usize,
1471 fuel_covers: impl FnOnce(usize) -> bool,
1472 ) -> CallResult {
1473 let id = call.id();
1474 let capability = call.capability();
1475 let named = call.target_excerpt();
1484
1485 if let Err(field) = self.quotas.charge_call() {
1497 return self.record(
1498 call,
1499 &named,
1500 CallResult::denied(
1501 id,
1502 DenialReason::QuotaExceeded,
1503 format!("the per-request `{field}` quota is spent"),
1504 ),
1505 );
1506 }
1507
1508 if !self.is_granted(capability) {
1512 return self.record(
1513 call,
1514 &named,
1515 CallResult::denied(
1516 id,
1517 DenialReason::CapabilityNotGranted,
1518 format!("this plugin was not granted `{capability}`"),
1519 ),
1520 );
1521 }
1522
1523 let target = match self.target_of(call) {
1528 Ok(target) => target,
1529 Err(result) => return self.record(call, &named, result),
1530 };
1531
1532 let reserve = self.reply_reserve(call);
1535 if reply_room < reserve {
1536 return self.record(
1537 call,
1538 &target,
1539 CallResult::denied(
1540 id,
1541 DenialReason::QuotaExceeded,
1542 "there is not room to deliver this call's answer; read the pending replies \
1543 and retry",
1544 ),
1545 );
1546 }
1547 if !fuel_covers(reserve) {
1548 return self.record(
1549 call,
1550 &target,
1551 CallResult::denied(
1552 id,
1553 DenialReason::QuotaExceeded,
1554 "the fuel left cannot read this call's answer and finish the request; \
1555 increase `fuel`",
1556 ),
1557 );
1558 }
1559
1560 if let Err(result) = self.validate(call) {
1565 return self.record(call, &target, result);
1566 }
1567
1568 if let Err(field) = self.quotas.charge_capability(call) {
1569 return self.record(
1570 call,
1571 &target,
1572 CallResult::denied(
1573 id,
1574 DenialReason::QuotaExceeded,
1575 format!("the per-request `{field}` quota is spent"),
1576 ),
1577 );
1578 }
1579
1580 if self
1585 .rate
1586 .as_ref()
1587 .is_some_and(|rate| !rate.try_take(capability))
1588 {
1589 return self.record(
1590 call,
1591 &target,
1592 CallResult::denied(
1593 id,
1594 DenialReason::QuotaExceeded,
1595 format!("this plugin is over its `{capability}` calls-per-second ceiling"),
1596 ),
1597 );
1598 }
1599
1600 let result = self.perform(call, &target);
1601 self.record(call, &target, result)
1602 }
1603
1604 fn target_of(&self, call: &CapabilityCall) -> Result<String, CallResult> {
1611 let id = call.id();
1612 match call {
1613 CapabilityCall::KvGet { key, .. }
1614 | CapabilityCall::KvSet { key, .. }
1615 | CapabilityCall::KvDelete { key, .. } => {
1616 if key.is_empty() || key.len() > MAX_KV_KEY_BYTES {
1621 return Err(CallResult::denied(
1622 id,
1623 DenialReason::Malformed,
1624 format!("a kv key must be 1..={MAX_KV_KEY_BYTES} bytes"),
1625 ));
1626 }
1627 if key.chars().any(char::is_control) {
1628 return Err(CallResult::denied(
1629 id,
1630 DenialReason::Malformed,
1631 "a kv key may not carry control characters",
1632 ));
1633 }
1634 Ok(key.clone())
1635 }
1636 CapabilityCall::HttpFetch { url, .. } => {
1637 let host = outbound::host_of(url).ok_or_else(|| {
1638 CallResult::denied(
1639 id,
1640 DenialReason::Malformed,
1641 "expected an absolute http/https URL with a bare host and no userinfo",
1642 )
1643 })?;
1644 self.require_grant(id, SandboxCapability::HttpOutbound, &host, "hosts")?;
1645 Ok(host)
1646 }
1647 CapabilityCall::DbInsert { table, .. }
1648 | CapabilityCall::DbGet { table, .. }
1649 | CapabilityCall::DbQuery { table, .. }
1650 | CapabilityCall::DbUpdate { table, .. }
1651 | CapabilityCall::DbDelete { table, .. } => {
1652 self.require_grant(id, SandboxCapability::Db, table, "tables")?;
1653 Ok(table.clone())
1654 }
1655 CapabilityCall::JobEnqueue { job_type, .. } => {
1656 self.require_grant(id, SandboxCapability::Jobs, job_type, "job_types")?;
1657 Ok(job_type.clone())
1658 }
1659 }
1660 }
1661
1662 fn require_grant(
1663 &self,
1664 id: u64,
1665 capability: SandboxCapability,
1666 entry: &str,
1667 field: &'static str,
1668 ) -> Result<(), CallResult> {
1669 if self.grants.allows(capability, entry) {
1670 return Ok(());
1671 }
1672 Err(CallResult::denied(
1673 id,
1674 DenialReason::NotInGrant,
1675 format!(
1676 "`[grants].{field}` does not name {entry:?}",
1677 entry = super::manifest::rejected(entry)
1678 ),
1679 ))
1680 }
1681
1682 fn validate(&self, call: &CapabilityCall) -> Result<(), CallResult> {
1684 match call {
1685 CapabilityCall::KvSet { .. } => kv::validate(self, call),
1686 CapabilityCall::KvGet { .. } | CapabilityCall::KvDelete { .. } => Ok(()),
1687 CapabilityCall::HttpFetch { .. } => outbound::check_request(self, call).map(|_| ()),
1688 CapabilityCall::DbInsert { .. }
1689 | CapabilityCall::DbGet { .. }
1690 | CapabilityCall::DbQuery { .. }
1691 | CapabilityCall::DbUpdate { .. }
1692 | CapabilityCall::DbDelete { .. } => db::validate(call),
1693 CapabilityCall::JobEnqueue { .. } => jobs::validate(call),
1694 }
1695 }
1696
1697 fn perform(&self, call: &CapabilityCall, target: &str) -> CallResult {
1699 match call {
1700 CapabilityCall::KvGet { .. }
1701 | CapabilityCall::KvSet { .. }
1702 | CapabilityCall::KvDelete { .. } => kv::perform(self, call, target),
1703 CapabilityCall::HttpFetch { .. } => outbound::perform(self, call, target),
1704 CapabilityCall::DbInsert { .. }
1705 | CapabilityCall::DbGet { .. }
1706 | CapabilityCall::DbQuery { .. }
1707 | CapabilityCall::DbUpdate { .. }
1708 | CapabilityCall::DbDelete { .. } => db::perform(self, call, target),
1709 CapabilityCall::JobEnqueue { .. } => jobs::perform(self, call, target),
1710 }
1711 }
1712
1713 fn record(&mut self, call: &CapabilityCall, target: &str, result: CallResult) -> CallResult {
1718 let target = super::manifest::rejected(&bounded_target(target));
1726 let outcome = match result.denial() {
1727 None => Outcome::Allowed,
1728 Some(DenialReason::QuotaExceeded) => Outcome::QuotaExceeded,
1729 Some(reason) => Outcome::Denied(reason),
1730 };
1731 if matches!(outcome, Outcome::Allowed) {
1732 tracing::debug!(
1733 plugin = self.plugin,
1734 capability = call.capability().as_str(),
1735 operation = call.operation(),
1736 target = target.as_str(),
1737 "sandboxed plugin used a granted capability"
1738 );
1739 } else if self
1740 .warned
1741 .iter()
1742 .any(|(operation, seen)| *operation == call.operation() && *seen == outcome)
1743 {
1744 } else {
1751 tracing::warn!(
1754 plugin = self.plugin,
1755 capability = call.capability().as_str(),
1756 operation = call.operation(),
1757 target = target.as_str(),
1758 outcome = %outcome,
1759 "sandboxed plugin was refused a capability call"
1760 );
1761 if self.warned.len() < MAX_WARNED {
1764 self.warned.push((call.operation(), outcome));
1765 }
1766 }
1767 let at = crate::time::ambient_instant();
1768 if self.events.len() < MAX_EVENTS {
1769 self.events.push(Event {
1770 capability: call.capability(),
1771 operation: call.operation(),
1772 target,
1773 outcome,
1774 at,
1775 });
1776 } else {
1777 self.dropped = Some(self.dropped.map_or(
1787 DroppedCalls {
1788 count: 1,
1789 first: at,
1790 last: at,
1791 },
1792 |dropped| DroppedCalls {
1793 count: dropped.count.saturating_add(1),
1794 last: at,
1795 ..dropped
1796 },
1797 ));
1798 }
1799 result
1800 }
1801}
1802
1803#[cfg(test)]
1804mod tests {
1805 use super::super::grants::MAX_QUOTA;
1806 use super::*;
1807 use jobs::MemoryJobSink;
1808 use kv::MemoryKvStore;
1809 use outbound::{OutboundResponse, RecordingHttp};
1810
1811 fn manifest(capabilities: &[SandboxCapability]) -> SandboxManifest {
1814 let caps = capabilities
1815 .iter()
1816 .map(|capability| format!("\"{}\"", capability.as_str()))
1817 .collect::<Vec<_>>()
1818 .join(", ");
1819 let mut grants = String::from("[grants]\n");
1820 if capabilities.contains(&SandboxCapability::HttpOutbound) {
1821 grants.push_str("hosts = [\"api.example.com\"]\n");
1822 }
1823 if capabilities.contains(&SandboxCapability::Db) {
1824 grants.push_str("tables = [\"orders\"]\n");
1825 }
1826 if capabilities.contains(&SandboxCapability::Jobs) {
1827 grants.push_str("job_types = [\"reindex\"]\n");
1828 }
1829 if capabilities.contains(&SandboxCapability::Render) {
1830 grants.push_str("slots = [\"order-summary\"]\n");
1831 }
1832 SandboxManifest::parse(&format!(
1833 r#"
1834name = "shop"
1835version = "0.1.0"
1836wire_version = 1
1837prefix = "/shop"
1838capabilities = [{caps}]
1839sha256 = "{digest}"
1840
1841[[routes]]
1842method = "GET"
1843path = "/shop/panel"
1844
1845{grants}
1846"#,
1847 digest = "c".repeat(64)
1848 ))
1849 .expect("the fixture manifest is valid")
1850 }
1851
1852 fn everything() -> Vec<SandboxCapability> {
1853 SandboxCapability::ALL.to_vec()
1854 }
1855
1856 struct Fixture {
1857 runtime: CapabilityRuntime,
1858 kv: Arc<MemoryKvStore>,
1859 db: Arc<db::MemoryPluginStore>,
1860 jobs: Arc<MemoryJobSink>,
1861 http: Arc<RecordingHttp>,
1862 }
1863
1864 fn fixture(capabilities: &[SandboxCapability], tenant: Option<&str>) -> Fixture {
1865 let kv = MemoryKvStore::new();
1866 let db = db::MemoryPluginStore::new();
1867 let jobs = MemoryJobSink::new();
1868 let http = RecordingHttp::new();
1869 let mut services = CapabilityServices {
1870 kv: Some(Arc::clone(&kv) as Arc<dyn KvStore>),
1871 db: Some(Arc::clone(&db) as Arc<dyn PluginStore>),
1872 jobs: Some(Arc::clone(&jobs) as Arc<dyn JobSink>),
1873 http: Some(Arc::clone(&http) as Arc<dyn OutboundHttp>),
1874 ..CapabilityServices::none()
1875 };
1876 if let Some(tenant) = tenant {
1877 services = services.for_tenant(tenant);
1878 }
1879 Fixture {
1880 runtime: CapabilityRuntime::new(&manifest(capabilities), services),
1881 kv,
1882 db,
1883 jobs,
1884 http,
1885 }
1886 }
1887
1888 fn row(pairs: &[(&str, &str)]) -> PluginRow {
1889 pairs
1890 .iter()
1891 .map(|(key, value)| ((*key).to_owned(), PluginValue::Text((*value).to_owned())))
1892 .collect()
1893 }
1894
1895 struct RogueStore {
1900 inserted: String,
1901 rows: Vec<PluginRow>,
1902 }
1903
1904 impl db::PluginStore for RogueStore {
1905 fn insert(&self, _: &db::Scope, _: PluginRow) -> Result<String, db::StoreError> {
1906 Ok(self.inserted.clone())
1907 }
1908 fn get(&self, _: &db::Scope, _: &str) -> Result<Option<PluginRow>, db::StoreError> {
1909 Ok(self.rows.first().cloned())
1910 }
1911 fn query(
1912 &self,
1913 _: &db::Scope,
1914 _: &PluginRow,
1915 _: usize,
1916 _: usize,
1917 _: Option<&str>,
1918 ) -> Result<db::QueryPage, db::StoreError> {
1919 Ok(db::QueryPage {
1920 rows: self.rows.clone(),
1921 truncated: false,
1922 })
1923 }
1924 fn update(&self, _: &db::Scope, _: &str, _: PluginRow) -> Result<(), db::StoreError> {
1925 Ok(())
1926 }
1927 fn delete(&self, _: &db::Scope, _: &str) -> Result<(), db::StoreError> {
1928 Ok(())
1929 }
1930 }
1931
1932 struct RogueSink(String);
1933
1934 impl JobSink for RogueSink {
1935 fn enqueue(&self, _: jobs::PluginJob) -> Result<String, String> {
1936 Ok(self.0.clone())
1937 }
1938 }
1939
1940 fn rogue_db(inserted: &str, rows: Vec<PluginRow>) -> CapabilityRuntime {
1941 let services = CapabilityServices {
1942 db: Some(Arc::new(RogueStore {
1943 inserted: inserted.to_owned(),
1944 rows,
1945 }) as Arc<dyn PluginStore>),
1946 ..CapabilityServices::none()
1947 };
1948 CapabilityRuntime::new(&manifest(&everything()), services)
1949 }
1950
1951 fn db_get() -> CapabilityCall {
1952 CapabilityCall::DbGet {
1953 id: 1,
1954 table: "orders".to_owned(),
1955 row_id: "r1".to_owned(),
1956 }
1957 }
1958
1959 #[test]
1960 fn a_stored_row_over_the_row_ceiling_is_not_served() {
1961 let mut fat = row(&[("row_id", "r1")]);
1964 for column in 0..5 {
1965 fat.insert(
1966 format!("c{column}"),
1967 PluginValue::Text("x".repeat(MAX_VALUE_TEXT_BYTES)),
1968 );
1969 }
1970 assert!(row_weight(&fat) > MAX_ROW_BYTES && row_weight(&fat) < MAX_RESULT_BYTES);
1971 let mut runtime = rogue_db("r1", vec![fat.clone()]);
1972 assert_eq!(
1973 runtime.dispatch(&db_get()).denial(),
1974 Some(DenialReason::ResultRejected)
1975 );
1976 let query = CapabilityCall::DbQuery {
1977 id: 2,
1978 table: "orders".to_owned(),
1979 filter: PluginRow::new(),
1980 limit: 0,
1981 after: None,
1982 };
1983 let rejected = runtime.dispatch(&query);
1984 assert_eq!(rejected.denial(), Some(DenialReason::ResultRejected));
1985 assert!(format!("{rejected:?}").contains("r1"), "{rejected:?}");
1987 }
1988
1989 #[test]
1990 fn a_stored_row_the_host_would_not_have_accepted_is_not_served() {
1991 let bad_name = row(&[("row_id", "r1"), ("Bad-Name", "x")]);
1992 let host_column = row(&[("row_id", "r1"), (db::TENANT_COLUMN, "alpha")]);
1993 let too_wide: PluginRow = (0..=MAX_ROW_COLUMNS)
1994 .map(|index| (format!("c{index}"), PluginValue::Int(1)))
1995 .collect();
1996 for stored in [bad_name, host_column, too_wide] {
1997 let mut runtime = rogue_db("r1", vec![stored]);
1998 assert_eq!(
1999 runtime.dispatch(&db_get()).denial(),
2000 Some(DenialReason::ResultRejected)
2001 );
2002 }
2003 }
2004
2005 #[test]
2006 fn a_stored_row_without_a_usable_id_is_not_served() {
2007 let long = "r".repeat(MAX_ROW_ID_BYTES + 1);
2008 for id in [
2009 None,
2010 Some(PluginValue::Int(7)),
2011 Some(PluginValue::Text(String::new())),
2012 Some(PluginValue::Text(long)),
2013 ] {
2014 let mut stored = row(&[("sku", "A-1")]);
2015 if let Some(id) = id {
2016 stored.insert("row_id".to_owned(), id);
2017 }
2018 let mut runtime = rogue_db("r1", vec![stored]);
2019 assert_eq!(
2020 runtime.dispatch(&db_get()).denial(),
2021 Some(DenialReason::ResultRejected)
2022 );
2023 }
2024 }
2025
2026 #[test]
2027 fn a_legal_stored_row_is_still_served_with_its_row_id() {
2028 let mut runtime = rogue_db("r1", vec![row(&[("row_id", "r1"), ("sku", "A-1")])]);
2029 assert!(runtime.dispatch(&db_get()).denial().is_none());
2030 }
2031
2032 #[test]
2033 fn a_store_assigned_id_the_guest_could_not_use_is_a_rejected_result() {
2034 for bad in [String::new(), "r".repeat(MAX_ROW_ID_BYTES + 1)] {
2035 let mut runtime = rogue_db(&bad, Vec::new());
2036 let result = runtime.dispatch(&CapabilityCall::DbInsert {
2037 id: 1,
2038 table: "orders".to_owned(),
2039 row: row(&[("sku", "A-1")]),
2040 });
2041 assert_eq!(
2042 result.denial(),
2043 Some(DenialReason::ResultRejected),
2044 "{} byte id",
2045 bad.len()
2046 );
2047 }
2048 }
2049
2050 #[test]
2051 fn a_sink_assigned_job_id_over_the_ceiling_is_refused_but_counted_as_queued() {
2052 let services = CapabilityServices {
2053 jobs: Some(Arc::new(RogueSink("j".repeat(MAX_JOB_ID_BYTES + 1))) as Arc<dyn JobSink>),
2054 ..CapabilityServices::none()
2055 };
2056 let mut runtime = CapabilityRuntime::new(&manifest(&everything()), services);
2057 let result = runtime.dispatch(&CapabilityCall::JobEnqueue {
2058 id: 1,
2059 job_type: "reindex".to_owned(),
2060 payload: PluginRow::new(),
2061 });
2062 assert_eq!(result.denial(), Some(DenialReason::ResultRejected));
2063 let log = PluginActivityLog::new();
2064 log.ingest("shop", runtime.take_events());
2065 let summary = log.summary("shop", std::time::Duration::from_secs(60));
2066 assert_eq!(
2067 summary.job_types.get("reindex").copied(),
2068 Some(1),
2069 "the enqueue committed: {summary:?}"
2070 );
2071 }
2072
2073 #[test]
2074 fn a_call_refused_for_its_shape_spends_no_backend_quota() {
2075 let mut fixture = fixture(&everything(), Some("alpha"));
2078 let fat: PluginRow = (0..=MAX_ROW_COLUMNS)
2079 .map(|index| (format!("c{index}"), PluginValue::Int(1)))
2080 .collect();
2081 for _ in 0..40 {
2082 let result = fixture.runtime.dispatch(&CapabilityCall::DbInsert {
2083 id: 1,
2084 table: "orders".to_owned(),
2085 row: fat.clone(),
2086 });
2087 assert_eq!(result.denial(), Some(DenialReason::Malformed));
2088 }
2089 let ok = fixture.runtime.dispatch(&CapabilityCall::DbInsert {
2090 id: 2,
2091 table: "orders".to_owned(),
2092 row: row(&[("sku", "A-1")]),
2093 });
2094 assert!(ok.denial().is_none(), "{ok:?}");
2095
2096 for _ in 0..10 {
2097 let bad = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
2098 id: 3,
2099 method: "TRACE".to_owned(),
2100 url: "https://api.example.com/".to_owned(),
2101 headers: Vec::new(),
2102 body: String::new(),
2103 });
2104 assert_eq!(bad.denial(), Some(DenialReason::Malformed));
2105 }
2106 let ok = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
2107 id: 4,
2108 method: "GET".to_owned(),
2109 url: "https://api.example.com/".to_owned(),
2110 headers: Vec::new(),
2111 body: String::new(),
2112 });
2113 assert_ne!(ok.denial(), Some(DenialReason::QuotaExceeded), "{ok:?}");
2114 }
2115
2116 #[test]
2117 fn a_huge_target_is_cut_before_it_is_copied() {
2118 let key = "k".repeat(MAX_KV_KEY_BYTES * 1000);
2119 let excerpt = CapabilityCall::KvGet { id: 1, key }.target_excerpt();
2120 assert!(
2121 excerpt.chars().count() <= MAX_TARGET_CHARS + 1,
2122 "{}",
2123 excerpt.len()
2124 );
2125 let url = format!("https://{}.test/", "h".repeat(1 << 20));
2126 let excerpt = CapabilityCall::HttpFetch {
2127 id: 1,
2128 method: "GET".to_owned(),
2129 url,
2130 headers: Vec::new(),
2131 body: String::new(),
2132 }
2133 .target_excerpt();
2134 assert!(
2135 excerpt.chars().count() <= MAX_TARGET_CHARS + 1,
2136 "{}",
2137 excerpt.len()
2138 );
2139 }
2140
2141 #[test]
2142 fn a_row_exactly_at_the_ceiling_is_served_and_one_byte_over_is_not() {
2143 let at_ceiling = |extra: usize| {
2144 let mut stored = row(&[("row_id", "r1")]);
2146 let mut left = MAX_ROW_BYTES + extra;
2147 for column in 0..4 {
2148 let name = format!("c{column}");
2149 left -= name.len();
2150 let take = left.min(MAX_VALUE_TEXT_BYTES);
2151 left -= take;
2152 stored.insert(name, PluginValue::Text("x".repeat(take)));
2153 }
2154 assert_eq!(left, 0);
2155 stored
2156 };
2157 assert!(
2158 rogue_db("r1", vec![at_ceiling(0)])
2159 .dispatch(&db_get())
2160 .denial()
2161 .is_none()
2162 );
2163 assert_eq!(
2164 rogue_db("r1", vec![at_ceiling(1)])
2165 .dispatch(&db_get())
2166 .denial(),
2167 Some(DenialReason::ResultRejected)
2168 );
2169 }
2170
2171 #[test]
2172 fn a_job_id_at_its_ceiling_is_returned_and_an_empty_one_is_not() {
2173 for (id, rejected) in [("j".repeat(MAX_JOB_ID_BYTES), false), (String::new(), true)] {
2174 let services = CapabilityServices {
2175 jobs: Some(Arc::new(RogueSink(id)) as Arc<dyn JobSink>),
2176 ..CapabilityServices::none()
2177 };
2178 let mut runtime = CapabilityRuntime::new(&manifest(&everything()), services);
2179 let result = runtime.dispatch(&CapabilityCall::JobEnqueue {
2180 id: 1,
2181 job_type: "reindex".to_owned(),
2182 payload: PluginRow::new(),
2183 });
2184 assert_eq!(
2185 result.denial() == Some(DenialReason::ResultRejected),
2186 rejected,
2187 "{result:?}"
2188 );
2189 }
2190 }
2191
2192 #[test]
2193 fn a_query_reserve_follows_its_page_and_fits_the_queue() {
2194 let query = |limit| CapabilityCall::DbQuery {
2195 id: 1,
2196 table: "orders".to_owned(),
2197 filter: PluginRow::new(),
2198 limit,
2199 after: None,
2200 };
2201 let huge = 1_000_000;
2203 assert!(query(1).reply_reserve_for(0, huge) < query(0).reply_reserve_for(0, huge));
2204 assert!(query(0).reply_reserve_for(0, huge) <= super::super::host::MAX_QUEUED_REPLY_BYTES);
2205 assert!(query(1).reply_reserve_for(0, huge) < MAX_RESULT_BYTES + 8192);
2207 }
2208
2209 #[test]
2210 fn reply_room_is_checked_at_its_exact_edge() {
2211 let post = CapabilityCall::HttpFetch {
2212 id: 1,
2213 method: "POST".to_owned(),
2214 url: "https://api.example.com/".to_owned(),
2215 headers: Vec::new(),
2216 body: String::new(),
2217 };
2218 let reserve = post.reply_reserve();
2219 let mut fixture = fixture(&everything(), Some("alpha"));
2220 let short = fixture.runtime.dispatch_within(&post, reserve - 1);
2221 assert_eq!(short.denial(), Some(DenialReason::QuotaExceeded));
2222 assert!(fixture.http.seen().is_empty(), "the POST ran");
2223 let fits = fixture.runtime.dispatch_within(&post, reserve);
2224 assert_ne!(fits.denial(), Some(DenialReason::QuotaExceeded), "{fits:?}");
2225 let get = CapabilityCall::KvGet {
2227 id: 2,
2228 key: "cart".to_owned(),
2229 };
2230 let read = get.reply_reserve_for(fixture.runtime.quotas().kv_value_bytes as usize, 0);
2231 assert_eq!(
2232 fixture.runtime.dispatch_within(&get, read - 1).denial(),
2233 Some(DenialReason::QuotaExceeded)
2234 );
2235 assert!(
2236 fixture
2237 .runtime
2238 .dispatch_within(&get, read)
2239 .denial()
2240 .is_none()
2241 );
2242 }
2243
2244 #[test]
2245 fn an_oversized_kv_value_is_refused_without_spending_the_write_quota() {
2246 let mut fixture = fixture(&everything(), Some("alpha"));
2247 let huge = PluginValue::Text("x".repeat(64 * 1024 + 1));
2248 for _ in 0..40 {
2249 let result = fixture.runtime.dispatch(&CapabilityCall::KvSet {
2250 id: 1,
2251 key: "cart".to_owned(),
2252 value: huge.clone(),
2253 });
2254 assert_eq!(result.denial(), Some(DenialReason::QuotaExceeded));
2255 }
2256 let ok = fixture.runtime.dispatch(&CapabilityCall::KvSet {
2258 id: 2,
2259 key: "cart".to_owned(),
2260 value: PluginValue::Int(1),
2261 });
2262 assert!(ok.denial().is_none(), "{ok:?}");
2263 }
2264
2265 #[test]
2268 fn an_ungranted_capability_is_denied_and_recorded() {
2269 let mut fixture = fixture(&[SandboxCapability::HttpRequest], None);
2270 let result = fixture.runtime.dispatch(&CapabilityCall::KvGet {
2271 id: 1,
2272 key: "cart".to_owned(),
2273 });
2274 assert_eq!(result.denial(), Some(DenialReason::CapabilityNotGranted));
2275 assert_eq!(fixture.runtime.events().len(), 1);
2276 assert_eq!(
2277 fixture.runtime.events().first().map(|event| event.outcome),
2278 Some(audit::CapabilityOutcome::Denied(
2279 DenialReason::CapabilityNotGranted
2280 ))
2281 );
2282 assert!(fixture.kv.keys().is_empty(), "nothing was written");
2283 }
2284
2285 #[test]
2286 fn a_refusal_records_what_was_reached_for_not_a_dash() {
2287 let mut granted = fixture(&everything(), Some("alpha"));
2290 let _ = granted.runtime.dispatch(&CapabilityCall::HttpFetch {
2291 id: 1,
2292 method: "GET".to_owned(),
2293 url: "https://attacker.test/steal".to_owned(),
2294 headers: Vec::new(),
2295 body: String::new(),
2296 });
2297 let _ = granted.runtime.dispatch(&CapabilityCall::DbQuery {
2298 id: 2,
2299 table: "users".to_owned(),
2300 filter: PluginRow::new(),
2301 limit: 1,
2302 after: None,
2303 });
2304 let _ = granted.runtime.dispatch(&CapabilityCall::JobEnqueue {
2305 id: 3,
2306 job_type: "drain-accounts".to_owned(),
2307 payload: PluginRow::new(),
2308 });
2309 let targets: Vec<&str> = granted
2310 .runtime
2311 .events()
2312 .iter()
2313 .map(|event| event.target.as_str())
2314 .collect();
2315 assert_eq!(targets, vec!["attacker.test", "users", "drain-accounts"]);
2316
2317 let mut hostile = fixture(&[SandboxCapability::HttpRequest], None);
2320 let _ = hostile.runtime.dispatch(&CapabilityCall::KvGet {
2321 id: 4,
2322 key: "a\u{1b}[2K\rforged".to_owned(),
2323 });
2324 let recorded = hostile
2325 .runtime
2326 .events()
2327 .first()
2328 .map(|event| event.target.clone())
2329 .unwrap_or_default();
2330 assert!(!recorded.contains('\u{1b}'), "{recorded:?}");
2331 }
2332
2333 #[test]
2334 fn an_ungranted_call_cannot_spend_the_shared_call_budget() {
2335 let mut ungranted = fixture(&[SandboxCapability::HttpRequest], None);
2339 for id in 0..1_000 {
2340 let _ = ungranted.runtime.dispatch(&CapabilityCall::KvGet {
2341 id,
2342 key: "cart".to_owned(),
2343 });
2344 }
2345 let mut granted = fixture(&everything(), None);
2348 assert!(
2349 granted
2350 .runtime
2351 .dispatch(&CapabilityCall::KvGet {
2352 id: 1,
2353 key: "cart".to_owned(),
2354 })
2355 .denial()
2356 .is_none()
2357 );
2358 }
2359
2360 #[derive(Debug)]
2364 struct UnreachableKvStore;
2365
2366 impl KvStore for UnreachableKvStore {
2367 fn get(&self, _key: &str) -> Result<Option<PluginValue>, String> {
2368 Err("the key/value backend is unreachable".to_owned())
2369 }
2370
2371 fn set(&self, _key: &str, _value: PluginValue) -> Result<(), String> {
2372 Err("the key/value backend is unreachable".to_owned())
2373 }
2374
2375 fn delete(&self, _key: &str) -> Result<(), String> {
2376 Err("the key/value backend is unreachable".to_owned())
2377 }
2378 }
2379
2380 #[test]
2381 fn a_kv_backend_outage_reaches_the_guest_as_a_failure_not_a_miss() {
2382 let manifest = manifest(&everything());
2387 let mut runtime = CapabilityRuntime::new(
2388 &manifest,
2389 CapabilityServices {
2390 kv: Some(Arc::new(UnreachableKvStore) as Arc<dyn KvStore>),
2391 ..CapabilityServices::none()
2392 }
2393 .for_tenant("alpha"),
2394 );
2395
2396 let result = runtime.dispatch(&CapabilityCall::KvGet {
2397 id: 1,
2398 key: "cart".to_owned(),
2399 });
2400 assert!(
2401 matches!(
2402 result,
2403 CallResult::Denied {
2404 id: 1,
2405 reason: DenialReason::BackendError,
2406 ..
2407 }
2408 ),
2409 "an unreachable store must deny, not report an absent key: {result:?}"
2410 );
2411
2412 let result = runtime.dispatch(&CapabilityCall::KvDelete {
2415 id: 2,
2416 key: "cart".to_owned(),
2417 });
2418 assert!(
2419 matches!(
2420 result,
2421 CallResult::Denied {
2422 id: 2,
2423 reason: DenialReason::BackendError,
2424 ..
2425 }
2426 ),
2427 "an unreachable store must deny the delete too: {result:?}"
2428 );
2429 }
2430
2431 #[test]
2432 fn an_overlong_outbound_method_is_refused_without_being_copied() {
2433 let manifest = manifest(&everything());
2443 let http = RecordingHttp::new();
2444 let mut runtime = CapabilityRuntime::new(
2445 &manifest,
2446 CapabilityServices {
2447 http: Some(Arc::clone(&http) as Arc<dyn OutboundHttp>),
2448 ..CapabilityServices::none()
2449 }
2450 .for_tenant("alpha"),
2451 );
2452
2453 let result = runtime.dispatch(&CapabilityCall::HttpFetch {
2454 id: 1,
2455 method: "G".repeat(1024 * 1024),
2456 url: "https://api.example.com/orders".to_owned(),
2457 headers: Vec::new(),
2458 body: String::new(),
2459 });
2460 assert!(
2461 matches!(
2462 result,
2463 CallResult::Denied {
2464 id: 1,
2465 reason: DenialReason::Malformed,
2466 ..
2467 }
2468 ),
2469 "an overlong method is not a method: {result:?}"
2470 );
2471
2472 let lowercase = runtime.dispatch(&CapabilityCall::HttpFetch {
2474 id: 2,
2475 method: "post".to_owned(),
2476 url: "https://api.example.com/orders".to_owned(),
2477 headers: Vec::new(),
2478 body: String::new(),
2479 });
2480 assert!(
2481 !matches!(
2482 lowercase,
2483 CallResult::Denied {
2484 reason: DenialReason::Malformed,
2485 ..
2486 }
2487 ),
2488 "`post` must still be POST: {lowercase:?}"
2489 );
2490 }
2491
2492 #[test]
2493 fn one_tenants_key_is_unreadable_when_another_is_active() {
2494 let store = MemoryKvStore::new();
2495 let services = |tenant: &str| {
2496 CapabilityServices {
2497 kv: Some(Arc::clone(&store) as Arc<dyn KvStore>),
2498 ..CapabilityServices::none()
2499 }
2500 .for_tenant(tenant)
2501 };
2502
2503 let manifest = manifest(&everything());
2504 let mut alpha = CapabilityRuntime::new(&manifest, services("alpha"));
2505 let _ = alpha.dispatch(&CapabilityCall::KvSet {
2506 id: 1,
2507 key: "cart".to_owned(),
2508 value: PluginValue::Text("alpha's".to_owned()),
2509 });
2510
2511 let mut beta = CapabilityRuntime::new(&manifest, services("beta"));
2512 let result = beta.dispatch(&CapabilityCall::KvGet {
2513 id: 2,
2514 key: "cart".to_owned(),
2515 });
2516 assert_eq!(
2517 result,
2518 CallResult::Ok {
2519 id: 2,
2520 value: CallValue::Value {
2521 value: PluginValue::Null,
2522 found: false
2523 }
2524 },
2525 "tenant beta must not see tenant alpha's key"
2526 );
2527 }
2528
2529 #[test]
2530 fn no_tenant_key_can_be_spelled_to_reach_another_tenants_namespace() {
2531 let store = MemoryKvStore::new();
2535 let manifest = manifest(&everything());
2536 let services = |tenant: &str| {
2537 CapabilityServices {
2538 kv: Some(Arc::clone(&store) as Arc<dyn KvStore>),
2539 ..CapabilityServices::none()
2540 }
2541 .for_tenant(tenant)
2542 };
2543
2544 let mut beta = CapabilityRuntime::new(&manifest, services("b"));
2545 let _ = beta.dispatch(&CapabilityCall::KvSet {
2546 id: 1,
2547 key: "cart".to_owned(),
2548 value: PluginValue::Text("beta's".to_owned()),
2549 });
2550
2551 let mut alpha = CapabilityRuntime::new(&manifest, services("a"));
2552 let result = alpha.dispatch(&CapabilityCall::KvGet {
2553 id: 2,
2554 key: "b:cart".to_owned(),
2555 });
2556 assert_eq!(
2557 result,
2558 CallResult::Ok {
2559 id: 2,
2560 value: CallValue::Value {
2561 value: PluginValue::Null,
2562 found: false
2563 }
2564 }
2565 );
2566 assert_eq!(store.keys().len(), 1, "{:?}", store.keys());
2567 }
2568
2569 #[test]
2570 fn a_tenant_id_carrying_the_separator_cannot_collide_with_another() {
2571 assert_ne!(
2574 kv::namespaced_key("shop", Some("b%3Ax"), "k"),
2575 kv::namespaced_key("shop", Some("b"), "%3Ax:k")
2576 );
2577 assert_ne!(
2578 kv::namespaced_key("shop", Some("a"), "b:c"),
2579 kv::namespaced_key("shop", Some("a:b"), "c")
2580 );
2581 }
2582
2583 #[test]
2584 fn two_plugins_never_share_a_key() {
2585 assert_ne!(
2586 kv::namespaced_key("shop", Some("t"), "cart"),
2587 kv::namespaced_key("other", Some("t"), "cart")
2588 );
2589 }
2590
2591 #[test]
2592 fn no_tenant_is_not_a_tenant_that_could_be_named() {
2593 for spelling in [NO_TENANT, "_", "", "-", "t", "t-"] {
2600 assert_ne!(
2601 tenant_segment(Some(spelling)),
2602 tenant_segment(None),
2603 "a tenant named {spelling:?} reached the single-tenant namespace"
2604 );
2605 assert_ne!(
2606 kv::namespaced_key("shop", Some(spelling), "cart"),
2607 kv::namespaced_key("shop", None, "cart"),
2608 "{spelling:?}"
2609 );
2610 }
2611 assert_ne!(tenant_segment(Some("a")), tenant_segment(Some("b")));
2614 }
2615
2616 #[test]
2617 fn a_stored_value_over_the_current_ceiling_is_refused_on_the_way_out() {
2618 let kv = MemoryKvStore::new();
2623 let manifest = manifest(&everything());
2624 let key = kv::namespaced_key(&manifest.name, Some("alpha"), "cart");
2625 let _ = kv.set(&key, PluginValue::Text("x".repeat(4096)));
2626
2627 let mut quotas = manifest.quotas;
2628 quotas.kv_value_bytes = 64;
2629 let tightened = SandboxManifest { quotas, ..manifest };
2630 let mut runtime = CapabilityRuntime::new(
2631 &tightened,
2632 CapabilityServices {
2633 kv: Some(Arc::clone(&kv) as Arc<dyn KvStore>),
2634 ..CapabilityServices::none()
2635 }
2636 .for_tenant("alpha"),
2637 );
2638 let result = runtime.dispatch(&CapabilityCall::KvGet {
2639 id: 1,
2640 key: "cart".to_owned(),
2641 });
2642 assert_eq!(
2643 result.denial(),
2644 Some(DenialReason::QuotaExceeded),
2645 "{result:?}"
2646 );
2647 }
2648
2649 #[test]
2650 fn a_query_filter_may_not_carry_the_row_id_that_would_widen_it() {
2651 let mut fixture = fixture(&everything(), Some("alpha"));
2657 for sku in ["A-1", "A-2", "A-3"] {
2658 let inserted = fixture.runtime.dispatch(&CapabilityCall::DbInsert {
2659 id: 1,
2660 table: "orders".to_owned(),
2661 row: row(&[("sku", sku)]),
2662 });
2663 assert!(inserted.denial().is_none(), "{inserted:?}");
2664 }
2665 let result = fixture.runtime.dispatch(&CapabilityCall::DbQuery {
2666 id: 2,
2667 table: "orders".to_owned(),
2668 filter: row(&[(db::ID_COLUMN, "r1")]),
2669 limit: 0,
2670 after: None,
2671 });
2672 assert_eq!(result.denial(), Some(DenialReason::Malformed), "{result:?}");
2673 }
2674
2675 #[test]
2676 fn a_truncated_page_can_be_continued_from_where_it_stopped() {
2677 let mut fixture = fixture(&everything(), Some("alpha"));
2681 for sku in ["A-1", "A-2", "A-3", "A-4"] {
2682 let inserted = fixture.runtime.dispatch(&CapabilityCall::DbInsert {
2683 id: 1,
2684 table: "orders".to_owned(),
2685 row: row(&[("sku", sku)]),
2686 });
2687 assert!(inserted.denial().is_none(), "{inserted:?}");
2688 }
2689
2690 let page = |runtime: &mut CapabilityRuntime, after: Option<String>| {
2691 let result = runtime.dispatch(&CapabilityCall::DbQuery {
2692 id: 2,
2693 table: "orders".to_owned(),
2694 filter: PluginRow::new(),
2695 limit: 2,
2696 after,
2697 });
2698 let CallResult::Ok {
2699 value: CallValue::Rows { rows, .. },
2700 ..
2701 } = result
2702 else {
2703 panic!("the query should have succeeded: {result:?}")
2704 };
2705 rows
2706 };
2707
2708 let first = page(&mut fixture.runtime, None);
2709 assert_eq!(first.len(), 2, "{first:?}");
2710 let last = first
2711 .last()
2712 .and_then(|row| row.get(db::ID_COLUMN))
2713 .map(ToString::to_string)
2714 .expect("every returned row carries its id");
2715 let second = page(&mut fixture.runtime, Some(last));
2716 assert_eq!(second.len(), 2, "{second:?}");
2717 for row in &second {
2720 assert!(!first.contains(row), "{row:?} came back twice");
2721 }
2722 }
2723
2724 #[test]
2725 fn a_row_at_the_column_ceiling_survives_a_read_modify_write() {
2726 let mut fixture = fixture(&everything(), Some("alpha"));
2733 let full: PluginRow = (0..MAX_ROW_COLUMNS)
2734 .map(|index| {
2735 (
2736 format!("c{index}"),
2737 PluginValue::Int(i64::try_from(index).unwrap_or(0)),
2738 )
2739 })
2740 .collect();
2741 assert_eq!(full.len(), MAX_ROW_COLUMNS);
2742
2743 let inserted = fixture.runtime.dispatch(&CapabilityCall::DbInsert {
2744 id: 1,
2745 table: "orders".to_owned(),
2746 row: full,
2747 });
2748 let CallResult::Ok {
2749 value: CallValue::RowId { row_id },
2750 ..
2751 } = inserted
2752 else {
2753 panic!("a row at the ceiling should store: {inserted:?}")
2754 };
2755
2756 let read = fixture.runtime.dispatch(&CapabilityCall::DbGet {
2757 id: 2,
2758 table: "orders".to_owned(),
2759 row_id: row_id.clone(),
2760 });
2761 let CallResult::Ok {
2762 value: CallValue::Rows { rows, .. },
2763 ..
2764 } = read
2765 else {
2766 panic!("the read should have succeeded: {read:?}")
2767 };
2768 let mut echoed = rows.first().cloned().unwrap_or_default();
2769 assert_eq!(
2770 echoed.len(),
2771 MAX_ROW_COLUMNS + 1,
2772 "the row comes back with the host's id, which is the whole problem"
2773 );
2774 echoed.insert("c0".to_owned(), PluginValue::Int(99));
2775
2776 let updated = fixture.runtime.dispatch(&CapabilityCall::DbUpdate {
2777 id: 3,
2778 table: "orders".to_owned(),
2779 row_id,
2780 row: echoed,
2781 });
2782 assert!(
2783 updated.denial().is_none(),
2784 "writing back what was read must not be refused: {updated:?}"
2785 );
2786 }
2787
2788 #[test]
2789 fn a_page_cut_by_the_row_quota_says_so_too() {
2790 let base = manifest(&everything());
2796 let mut quotas = base.quotas;
2797 quotas.db_rows = 2;
2798 let manifest = SandboxManifest { quotas, ..base };
2799 let store = db::MemoryPluginStore::new();
2800 let mut runtime = CapabilityRuntime::new(
2801 &manifest,
2802 CapabilityServices {
2803 db: Some(Arc::clone(&store) as Arc<dyn PluginStore>),
2804 ..CapabilityServices::none()
2805 }
2806 .for_tenant("alpha"),
2807 );
2808 for sku in ["A-1", "A-2", "A-3", "A-4"] {
2809 let inserted = runtime.dispatch(&CapabilityCall::DbInsert {
2810 id: 1,
2811 table: "orders".to_owned(),
2812 row: row(&[("sku", sku)]),
2813 });
2814 assert!(inserted.denial().is_none(), "{inserted:?}");
2815 }
2816
2817 let result = runtime.dispatch(&CapabilityCall::DbQuery {
2818 id: 2,
2819 table: "orders".to_owned(),
2820 filter: PluginRow::new(),
2821 limit: 0,
2822 after: None,
2823 });
2824 let CallResult::Ok {
2825 value: CallValue::Rows { rows, truncated },
2826 ..
2827 } = result
2828 else {
2829 panic!("the query should have succeeded: {result:?}")
2830 };
2831 assert_eq!(rows.len(), 2, "the quota caps the page");
2832 assert!(truncated, "and the guest is told there is more");
2833 }
2834
2835 #[test]
2836 fn a_read_modify_write_echo_still_parses_at_the_column_ceiling() {
2837 let columns = (0..MAX_ROW_COLUMNS)
2846 .map(|index| format!(r#""c{index}":"v""#))
2847 .chain(std::iter::once(r#""row_id":"r1""#.to_owned()))
2848 .collect::<Vec<_>>()
2849 .join(",");
2850 let line = format!(
2851 r#"{{"call":"db-update","id":1,"table":"orders","row_id":"r1","row":{{{columns}}}}}"#
2852 );
2853 let parsed = serde_json::from_str::<CapabilityCall>(&line);
2854 assert!(
2855 parsed.is_ok(),
2856 "the echo of a full row must survive parsing: {parsed:?}"
2857 );
2858
2859 let columns = (0..=MAX_ROW_COLUMNS)
2861 .map(|index| format!(r#""c{index}":"v""#))
2862 .chain(std::iter::once(r#""row_id":"r1""#.to_owned()))
2863 .collect::<Vec<_>>()
2864 .join(",");
2865 let line = format!(
2866 r#"{{"call":"db-update","id":1,"table":"orders","row_id":"r1","row":{{{columns}}}}}"#
2867 );
2868 assert!(
2869 serde_json::from_str::<CapabilityCall>(&line).is_err(),
2870 "a row genuinely over the ceiling is still refused"
2871 );
2872 }
2873
2874 #[test]
2875 fn the_root_of_a_fragment_is_bounded_like_its_branches() {
2876 let nodes = (0..=render::MAX_NODES)
2880 .map(|_| r#"{"node":"text","text":""}"#)
2881 .collect::<Vec<_>>()
2882 .join(",");
2883 let line = format!(r#"{{"op":"fragment","nodes":[{nodes}]}}"#);
2884 let err = serde_json::from_str::<crate::plugin_sandbox::wire::GuestFrame>(&line)
2885 .expect_err("a fragment over the node ceiling must not parse");
2886 assert!(err.to_string().contains("more than"), "{err}");
2887
2888 let nodes = (0..render::MAX_NODES)
2890 .map(|_| r#"{"node":"text","text":""}"#)
2891 .collect::<Vec<_>>()
2892 .join(",");
2893 let line = format!(r#"{{"op":"fragment","nodes":[{nodes}]}}"#);
2894 assert!(
2895 serde_json::from_str::<crate::plugin_sandbox::wire::GuestFrame>(&line).is_ok(),
2896 "a fragment at the ceiling is legal"
2897 );
2898 }
2899
2900 #[test]
2901 fn a_guest_collection_is_refused_while_it_is_read_not_after() {
2902 let pairs = (0..=MAX_OUTBOUND_HEADERS)
2908 .map(|_| r#"["",""]"#)
2909 .collect::<Vec<_>>()
2910 .join(",");
2911 let line = format!(
2912 r#"{{"call":"http-fetch","id":1,"method":"GET","url":"https://api.example.com/","headers":[{pairs}],"body":""}}"#
2913 );
2914 let parsed = serde_json::from_str::<CapabilityCall>(&line);
2915 let err = parsed.expect_err("a frame over the header ceiling must not parse");
2916 assert!(
2917 err.to_string().contains("more than"),
2918 "the parser should say why: {err}"
2919 );
2920
2921 let pairs = (0..MAX_OUTBOUND_HEADERS)
2924 .map(|_| r#"["a","b"]"#)
2925 .collect::<Vec<_>>()
2926 .join(",");
2927 let line = format!(
2928 r#"{{"call":"http-fetch","id":1,"method":"GET","url":"https://api.example.com/","headers":[{pairs}],"body":""}}"#
2929 );
2930 assert!(
2931 serde_json::from_str::<CapabilityCall>(&line).is_ok(),
2932 "a frame at the ceiling is legal"
2933 );
2934 }
2935
2936 #[test]
2939 fn a_declared_host_is_reachable_through_the_framework_client() {
2940 let mut fixture = fixture(&everything(), Some("alpha"));
2941 fixture.http.answer(
2942 "https://api.example.com/v1/orders",
2943 OutboundResponse {
2944 status: 200,
2945 headers: vec![("content-type".to_owned(), "application/json".to_owned())],
2946 body: "[]".to_owned(),
2947 final_url: "https://api.example.com/v1/orders".to_owned(),
2948 },
2949 );
2950 let result = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
2951 id: 1,
2952 method: "get".to_owned(),
2953 url: "https://api.example.com/v1/orders".to_owned(),
2954 headers: vec![("accept".to_owned(), "application/json".to_owned())],
2955 body: String::new(),
2956 });
2957 assert_eq!(
2958 result,
2959 CallResult::Ok {
2960 id: 1,
2961 value: CallValue::Http {
2962 status: 200,
2963 headers: vec![("content-type".to_owned(), "application/json".to_owned())],
2964 body: "[]".to_owned(),
2965 }
2966 }
2967 );
2968 assert_eq!(fixture.http.seen().len(), 1);
2969 }
2970
2971 #[test]
2972 fn every_shape_of_undeclared_host_is_denied_and_never_leaves() {
2973 let mut fixture = fixture(&everything(), Some("alpha"));
2974 for url in [
2975 "https://attacker.test/",
2976 "https://api.example.com.attacker.test/",
2977 "https://evil-api.example.com/",
2978 "https://api.example.com@attacker.test/",
2979 "https://attacker.test/?to=api.example.com",
2980 "http://attacker.test/api.example.com",
2981 "file:///etc/passwd",
2982 "//api.example.com/",
2983 "/relative",
2984 "https://API.EXAMPLE.COM.attacker.test/",
2985 "https://[::1]/",
2986 ] {
2987 let result = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
2988 id: 9,
2989 method: "GET".to_owned(),
2990 url: url.to_owned(),
2991 headers: Vec::new(),
2992 body: String::new(),
2993 });
2994 assert!(
2995 matches!(
2996 result.denial(),
2997 Some(DenialReason::NotInGrant | DenialReason::Malformed)
2998 ),
2999 "{url} was allowed: {result:?}"
3000 );
3001 }
3002 assert!(
3003 fixture.http.seen().is_empty(),
3004 "not one of those may have left: {:?}",
3005 fixture.http.seen()
3006 );
3007 }
3008
3009 #[test]
3010 fn a_redirect_off_the_granted_host_is_refused_after_the_fact() {
3011 let mut fixture = fixture(&everything(), Some("alpha"));
3016 fixture
3017 .http
3018 .answer_from("https://api.example.com/r", "https://attacker.test/collect");
3019 let result = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
3020 id: 1,
3021 method: "POST".to_owned(),
3022 url: "https://api.example.com/r".to_owned(),
3023 headers: Vec::new(),
3024 body: "tenant data".to_owned(),
3025 });
3026 assert_eq!(
3027 result.denial(),
3028 Some(DenialReason::NotInGrant),
3029 "{result:?}"
3030 );
3031
3032 let events = fixture.runtime.events();
3035 assert!(
3036 events
3037 .iter()
3038 .any(|event| event.outcome != audit::CapabilityOutcome::Allowed),
3039 "{events:?}"
3040 );
3041 }
3042
3043 #[test]
3044 fn an_implementation_is_handed_what_it_needs_to_refuse_a_hop_itself() {
3045 let mut fixture = fixture(&everything(), Some("alpha"));
3046 fixture.http.answer(
3047 "https://api.example.com/v1",
3048 OutboundResponse::from_url("https://api.example.com/v1", 200, "ok"),
3049 );
3050 let _ = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
3051 id: 1,
3052 method: "GET".to_owned(),
3053 url: "https://api.example.com/v1".to_owned(),
3054 headers: Vec::new(),
3055 body: String::new(),
3056 });
3057 let seen = fixture.http.seen();
3058 let sent = seen.first().expect("the call left");
3059 assert!(
3060 !sent.follow_redirects,
3061 "redirects are never followed for a plugin"
3062 );
3063 assert_eq!(sent.allowed_hosts, vec!["api.example.com".to_owned()]);
3064 assert!(
3065 sent.timeout > std::time::Duration::ZERO,
3066 "a call has a deadline"
3067 );
3068 }
3069
3070 #[test]
3071 fn an_upstream_that_answered_counts_as_a_host_that_was_called() {
3072 let mut manifest = manifest(&everything());
3076 manifest.quotas.outbound_response_bytes = 4;
3077 let http = RecordingHttp::new();
3078 http.answer(
3079 "https://api.example.com/big",
3080 OutboundResponse::from_url("https://api.example.com/big", 200, "far too long"),
3081 );
3082 let mut runtime = CapabilityRuntime::new(
3083 &manifest,
3084 CapabilityServices {
3085 http: Some(Arc::clone(&http) as Arc<dyn OutboundHttp>),
3086 ..CapabilityServices::none()
3087 },
3088 );
3089 let result = runtime.dispatch(&CapabilityCall::HttpFetch {
3090 id: 1,
3091 method: "GET".to_owned(),
3092 url: "https://api.example.com/big".to_owned(),
3093 headers: Vec::new(),
3094 body: String::new(),
3095 });
3096 assert_eq!(result.denial(), Some(DenialReason::ResponseTooLarge));
3097
3098 let log = audit::PluginActivityLog::new();
3099 log.ingest("shop", runtime.take_events());
3100 let summary = log.summary("shop", std::time::Duration::from_secs(3600));
3101 assert_eq!(summary.hosts.get("api.example.com"), Some(&1));
3102 assert!(summary.refused_targets.is_empty(), "{summary:?}");
3103 }
3104
3105 #[test]
3106 fn the_same_host_on_another_port_is_the_granted_host() {
3107 assert_eq!(
3109 outbound::host_of("https://api.example.com:8443/v1"),
3110 Some("api.example.com".to_owned())
3111 );
3112 assert_eq!(
3113 outbound::host_of("https://api.example.com:not-a-port/"),
3114 None
3115 );
3116 }
3117
3118 #[test]
3119 fn a_header_a_plugin_may_not_set_is_denied_before_the_call_leaves() {
3120 for (name, value) in [
3122 ("authorization", "Bearer stolen"),
3123 ("cookie", "session=stolen"),
3124 ("host", "attacker.test"),
3125 ("x-forwarded-for", "10.0.0.1"),
3126 ] {
3127 let mut one = fixture(&everything(), Some("alpha"));
3128 let result = one.runtime.dispatch(&CapabilityCall::HttpFetch {
3129 id: 1,
3130 method: "GET".to_owned(),
3131 url: "https://api.example.com/".to_owned(),
3132 headers: vec![(name.to_owned(), value.to_owned())],
3133 body: String::new(),
3134 });
3135 assert_eq!(result.denial(), Some(DenialReason::NotInGrant), "{name}");
3136 assert!(one.http.seen().is_empty(), "{name} left the host");
3137 }
3138
3139 let mut fixture = fixture(&everything(), Some("alpha"));
3140 let result = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
3141 id: 2,
3142 method: "GET".to_owned(),
3143 url: "https://api.example.com/".to_owned(),
3144 headers: vec![("accept".to_owned(), "a\r\nx-evil: 1".to_owned())],
3145 body: String::new(),
3146 });
3147 assert_eq!(result.denial(), Some(DenialReason::Malformed));
3148 assert!(fixture.http.seen().is_empty());
3149 }
3150
3151 #[test]
3152 fn a_row_no_reply_could_carry_is_refused_where_it_goes_in() {
3153 let mut fixture = fixture(&everything(), Some("alpha"));
3158 let fat: PluginRow = (0..8)
3159 .map(|index| {
3160 (
3161 format!("c{index}"),
3162 PluginValue::Text("x".repeat(MAX_VALUE_TEXT_BYTES)),
3163 )
3164 })
3165 .collect();
3166 assert!(
3167 row_weight(&fat) > MAX_ROW_BYTES,
3168 "the fixture is fat enough"
3169 );
3170 let result = fixture.runtime.dispatch(&CapabilityCall::DbInsert {
3171 id: 1,
3172 table: "orders".to_owned(),
3173 row: fat,
3174 });
3175 assert_eq!(result.denial(), Some(DenialReason::Malformed), "{result:?}");
3176 assert!(fixture.db.keys().is_empty(), "{:?}", fixture.db.keys());
3177 }
3178
3179 #[test]
3180 fn a_query_is_bounded_by_bytes_and_says_when_it_was_cut() {
3181 let mut fixture = fixture(&everything(), Some("alpha"));
3188 let wide = "x".repeat(MAX_VALUE_TEXT_BYTES);
3189 for index in 0..16 {
3190 let mut row = row(&[("kind", "wide")]);
3191 for column in 0..3 {
3195 row.insert(format!("c{column}"), PluginValue::Text(wide.clone()));
3196 }
3197 row.insert("n".to_owned(), PluginValue::Int(index));
3198 let inserted = fixture.runtime.dispatch(&CapabilityCall::DbInsert {
3199 id: 1,
3200 table: "orders".to_owned(),
3201 row,
3202 });
3203 assert!(inserted.denial().is_none(), "row {index}: {inserted:?}");
3204 }
3205
3206 let result = fixture.runtime.dispatch(&CapabilityCall::DbQuery {
3207 id: 2,
3208 table: "orders".to_owned(),
3209 filter: row(&[("kind", "wide")]),
3210 limit: 0,
3211 after: None,
3212 });
3213 let CallResult::Ok {
3214 value: CallValue::Rows { rows, truncated },
3215 ..
3216 } = result
3217 else {
3218 panic!("the query should have succeeded: {result:?}")
3219 };
3220 assert!(truncated, "16 wide rows do not fit one reply");
3221 assert!(!rows.is_empty(), "and the answer is not empty either");
3222 let carried: usize = rows.iter().map(row_weight).sum();
3223 assert!(
3224 carried <= MAX_RESULT_BYTES.saturating_add(MAX_ROW_BYTES),
3225 "the answer carried {carried} bytes"
3226 );
3227 }
3228
3229 #[test]
3230 fn an_upstream_cannot_answer_with_unbounded_headers() {
3231 let mut fixture = fixture(&everything(), Some("alpha"));
3237 let mut answer = OutboundResponse::from_url("https://api.example.com/", 200, "ok");
3238 answer.headers = (0..64)
3239 .map(|_| ("etag".to_owned(), "x".repeat(4096)))
3240 .collect();
3241 fixture.http.answer("https://api.example.com/", answer);
3242
3243 let result = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
3244 id: 1,
3245 method: "GET".to_owned(),
3246 url: "https://api.example.com/".to_owned(),
3247 headers: Vec::new(),
3248 body: String::new(),
3249 });
3250 let CallResult::Ok {
3251 value: CallValue::Http { headers, .. },
3252 ..
3253 } = result
3254 else {
3255 panic!("the fetch should have succeeded: {result:?}")
3256 };
3257 assert!(headers.len() <= MAX_OUTBOUND_HEADERS, "{}", headers.len());
3258 let carried: usize = headers
3259 .iter()
3260 .map(|(name, value)| name.len().saturating_add(value.len()))
3261 .sum();
3262 assert!(
3263 carried <= MAX_RESPONSE_HEADER_BYTES,
3264 "the reply carried {carried} bytes of headers"
3265 );
3266 }
3267
3268 #[test]
3269 fn an_outbound_request_body_is_bounded_like_the_response_is() {
3270 let mut fixture = fixture(&everything(), Some("alpha"));
3276 let ceiling = fixture.runtime.quotas().outbound_request_bytes as usize;
3277 fixture.http.answer(
3278 "https://api.example.com/",
3279 OutboundResponse::from_url("https://api.example.com/", 200, "ok"),
3280 );
3281
3282 let result = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
3283 id: 1,
3284 method: "POST".to_owned(),
3285 url: "https://api.example.com/".to_owned(),
3286 headers: Vec::new(),
3287 body: "x".repeat(ceiling + 1),
3288 });
3289 assert_eq!(
3290 result.denial(),
3291 Some(DenialReason::QuotaExceeded),
3292 "{result:?}"
3293 );
3294 assert!(
3296 fixture.http.seen().is_empty(),
3297 "the call still left the host"
3298 );
3299 }
3300
3301 #[test]
3302 fn an_outbound_requests_headers_count_against_its_byte_quota() {
3303 let mut fixture = fixture(&everything(), Some("alpha"));
3307 let ceiling = fixture.runtime.quotas().outbound_request_bytes as usize;
3308 fixture.http.answer(
3309 "https://api.example.com/",
3310 OutboundResponse::from_url("https://api.example.com/", 200, "ok"),
3311 );
3312
3313 let result = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
3314 id: 1,
3315 method: "POST".to_owned(),
3316 url: "https://api.example.com/".to_owned(),
3317 headers: vec![("user-agent".to_owned(), "x".repeat(ceiling + 1))],
3318 body: String::new(),
3319 });
3320 assert_eq!(
3321 result.denial(),
3322 Some(DenialReason::QuotaExceeded),
3323 "an empty body does not make an oversized request small: {result:?}"
3324 );
3325 assert!(
3326 fixture.http.seen().is_empty(),
3327 "the call still left the host"
3328 );
3329 }
3330
3331 #[test]
3332 fn the_in_memory_kv_store_is_bounded_by_bytes_not_only_by_keys() {
3333 let store = MemoryKvStore::with_capacities(10_000, 4096);
3337 let mut refused = false;
3339 for index in 0..64 {
3340 if store
3341 .set(&format!("k{index}"), PluginValue::Text("x".repeat(512)))
3342 .is_err()
3343 {
3344 refused = true;
3345 break;
3346 }
3347 }
3348 assert!(refused, "the store grew past its byte ceiling");
3349 assert!(
3350 store.keys().len() < 10_000,
3351 "and it refused long before the key ceiling"
3352 );
3353
3354 let small = MemoryKvStore::with_capacities(10_000, 4096);
3356 assert!(small.set("k", PluginValue::Text("x".repeat(64))).is_ok());
3357 assert!(
3358 small.set("k", PluginValue::Text("x".repeat(8192))).is_err(),
3359 "overwriting one key with an oversized value adds no entry and still grows the store"
3360 );
3361 }
3362
3363 #[test]
3364 fn every_in_memory_backend_is_bounded_by_bytes_not_only_by_count() {
3365 let scope = db::Scope {
3370 table: "plugin_shop__orders".to_owned(),
3371 tenant: tenant_segment(Some("alpha")),
3372 };
3373 let store = db::MemoryPluginStore::with_capacities(10_000, 8192);
3374 let fat = row(&[("blob", "x".repeat(2048).as_str())]);
3375 let mut refused = false;
3376 for _ in 0..64 {
3377 if store.insert(&scope, fat.clone()).is_err() {
3378 refused = true;
3379 break;
3380 }
3381 }
3382 assert!(refused, "the row store grew past its byte ceiling");
3383 assert!(
3384 store.keys().len() < 10_000,
3385 "and refused far below its row ceiling"
3386 );
3387
3388 let roomy = db::MemoryPluginStore::with_capacities(10_000, 8192);
3393 let small = roomy
3394 .insert(&scope, row(&[("blob", "x")]))
3395 .expect("a small row fits");
3396 assert!(
3397 roomy
3398 .update(&scope, &small, row(&[("blob", "x".repeat(65536).as_str())]))
3399 .is_err(),
3400 "replacing a small row with an oversized one adds no row and still grows the store"
3401 );
3402 let kv = MemoryKvStore::with_capacities(10_000, 4096);
3403 assert!(kv.set("k", PluginValue::Text("x".repeat(64))).is_ok());
3404 assert!(
3405 kv.set("k", PluginValue::Text("x".repeat(8192))).is_err(),
3406 "the same must hold for the key/value store"
3407 );
3408
3409 let sink = MemoryJobSink::with_capacities(1024, 8192);
3410 let mut refused = false;
3411 for _ in 0..64 {
3412 let job = PluginJob {
3413 plugin: "shop".to_owned(),
3414 artifact_sha256: "c".repeat(64),
3415 tenant: Some("alpha".to_owned()),
3416 job_type: "reindex".to_owned(),
3417 payload: row(&[("blob", "x".repeat(2048).as_str())]),
3418 };
3419 if sink.enqueue(job).is_err() {
3420 refused = true;
3421 break;
3422 }
3423 }
3424 assert!(refused, "the job queue grew past its byte ceiling");
3425 assert!(
3426 sink.queued().len() < 1024,
3427 "and refused far below its depth ceiling"
3428 );
3429 }
3430
3431 #[test]
3434 fn a_plugin_reads_and_writes_only_its_own_tenant_scoped_table() {
3435 let mut fixture = fixture(&everything(), Some("alpha"));
3436 let inserted = fixture.runtime.dispatch(&CapabilityCall::DbInsert {
3437 id: 1,
3438 table: "orders".to_owned(),
3439 row: row(&[("sku", "A-1")]),
3440 });
3441 let CallResult::Ok {
3442 value: CallValue::RowId { row_id },
3443 ..
3444 } = inserted
3445 else {
3446 panic!("the insert should have succeeded: {inserted:?}")
3447 };
3448
3449 let orders = db::physical_table("shop", "orders").expect("derivable");
3454 assert_eq!(
3455 fixture.db.keys(),
3456 vec![(orders, tenant_segment(Some("alpha")), row_id.clone())]
3457 );
3458
3459 let read = fixture.runtime.dispatch(&CapabilityCall::DbGet {
3460 id: 2,
3461 table: "orders".to_owned(),
3462 row_id,
3463 });
3464 let CallResult::Ok {
3465 value: CallValue::Rows { rows, .. },
3466 ..
3467 } = read
3468 else {
3469 panic!("the read should have succeeded: {read:?}")
3470 };
3471 assert_eq!(rows.len(), 1);
3472 }
3473
3474 #[test]
3475 fn an_undeclared_table_is_denied_however_it_is_spelled() {
3476 let mut fixture = fixture(&everything(), Some("alpha"));
3477 for table in [
3478 "users",
3479 "orders_secret",
3480 "public.users",
3481 "orders; drop table users",
3482 "\"users\"",
3483 "Orders",
3484 "plugin_shop_orders",
3485 "",
3486 ] {
3487 let result = fixture.runtime.dispatch(&CapabilityCall::DbQuery {
3488 id: 1,
3489 table: table.to_owned(),
3490 filter: PluginRow::new(),
3491 limit: 10,
3492 after: None,
3493 });
3494 assert_eq!(
3495 result.denial(),
3496 Some(DenialReason::NotInGrant),
3497 "{table} was allowed"
3498 );
3499 }
3500 }
3501
3502 #[test]
3503 fn the_node_ceiling_counts_the_whole_tree_not_each_vector() {
3504 let leaves = std::iter::repeat_n(r#"{"node":"text","text":"x"}"#, 8)
3516 .collect::<Vec<_>>()
3517 .join(",");
3518 let branch = format!(r#"{{"node":"element","tag":"p","children":[{leaves}]}}"#);
3519 let wide = std::iter::repeat_n(branch.as_str(), 128)
3522 .collect::<Vec<_>>()
3523 .join(",");
3524 let line = format!(r#"{{"op":"fragment","nodes":[{wide}]}}"#);
3525 let parsed = crate::plugin_sandbox::wire::from_line::<
3526 crate::plugin_sandbox::wire::GuestFrame,
3527 >(&line);
3528 assert!(
3529 parsed.is_err(),
3530 "a tree past the node ceiling must be refused while it is read, not after"
3531 );
3532
3533 let small = std::iter::repeat_n(branch.as_str(), 4)
3536 .collect::<Vec<_>>()
3537 .join(",");
3538 let line = format!(r#"{{"op":"fragment","nodes":[{small}]}}"#);
3539 assert!(
3540 crate::plugin_sandbox::wire::from_line::<crate::plugin_sandbox::wire::GuestFrame>(
3541 &line
3542 )
3543 .is_ok(),
3544 "a fragment inside the ceiling must still parse"
3545 );
3546
3547 assert!(
3550 crate::plugin_sandbox::wire::from_line::<crate::plugin_sandbox::wire::GuestFrame>(
3551 &line
3552 )
3553 .is_ok(),
3554 "the budget must be restored after each parse"
3555 );
3556 }
3557
3558 #[test]
3559 fn a_page_of_rows_encodes_within_the_result_ceiling() {
3560 let row: PluginRow = (0..MAX_ROW_COLUMNS)
3565 .map(|i| (format!("c{i:02}"), PluginValue::Text("v".to_owned())))
3566 .collect();
3567 let rows = vec![row; 20_000];
3568 let (kept, truncated) = bounded_rows(rows);
3569 assert!(truncated);
3570 let encoded = serde_json::to_vec(&kept).expect("encodes").len();
3571 assert!(
3572 encoded <= MAX_RESULT_BYTES,
3573 "{} rows encode to {encoded} bytes",
3574 kept.len()
3575 );
3576
3577 let row: PluginRow = (0..MAX_ROW_COLUMNS)
3579 .map(|i| (format!("c{i:02}"), PluginValue::Text("\u{1}\"".to_owned())))
3580 .collect();
3581 let (kept, _) = bounded_rows(vec![row; 20_000]);
3582 let encoded = serde_json::to_vec(&kept).expect("encodes").len();
3583 assert!(
3584 encoded <= MAX_RESULT_BYTES,
3585 "escaped rows encode to {encoded}"
3586 );
3587 }
3588
3589 #[test]
3590 fn a_scalar_weighs_what_it_encodes_to() {
3591 assert_eq!(PluginValue::Int(i64::MIN).weight(), 20);
3597 assert_eq!(PluginValue::Int(i64::MAX).weight(), 19);
3598 assert_eq!(PluginValue::Int(0).weight(), 1);
3599 assert_eq!(PluginValue::Int(-1).weight(), 2);
3600 assert_eq!(PluginValue::Int(999).weight(), 3);
3601 assert_eq!(PluginValue::Int(-1000).weight(), 5);
3602 assert_eq!(PluginValue::Null.weight(), 4);
3603 assert_eq!(PluginValue::Bool(true).weight(), 4);
3604 assert_eq!(PluginValue::Bool(false).weight(), 5);
3605
3606 for value in [
3609 PluginValue::Null,
3610 PluginValue::Bool(true),
3611 PluginValue::Bool(false),
3612 PluginValue::Int(0),
3613 PluginValue::Int(-1),
3614 PluginValue::Int(i64::MIN),
3615 PluginValue::Int(i64::MAX),
3616 PluginValue::Float(-2.225_073_858_507_201_4e-308),
3617 PluginValue::Float(1.0),
3618 ] {
3619 let encoded = serde_json::to_string(&value).expect("encodes");
3620 assert!(
3621 value.weight() >= encoded.len(),
3622 "{value:?} weighs {} but encodes to {} bytes",
3623 value.weight(),
3624 encoded.len()
3625 );
3626 }
3627 }
3628
3629 #[test]
3630 fn an_oversized_first_row_is_refused_by_the_store_too() {
3631 let store = db::MemoryPluginStore::new();
3637 let orders = db::physical_table("shop", "orders").expect("derivable");
3638 let tenant = tenant_segment(Some("alpha"));
3639 let huge = "x".repeat(super::MAX_RESULT_BYTES + 1);
3642 store.seed(&orders, &tenant, "r1", row(&[("blob", huge.as_str())]));
3643
3644 let page = store
3645 .query(
3646 &db::Scope {
3647 table: orders,
3648 tenant,
3649 },
3650 &PluginRow::new(),
3651 10,
3652 super::MAX_RESULT_BYTES,
3653 None,
3654 )
3655 .expect("the store answers");
3656 assert!(
3657 page.rows.is_empty(),
3658 "a row over the whole result budget must not be cloned out of the store"
3659 );
3660 assert!(
3661 page.truncated,
3662 "and the caller must be told there was something it did not get"
3663 );
3664 }
3665
3666 #[test]
3667 fn a_generated_row_id_never_lands_on_a_seeded_one() {
3668 let store = db::MemoryPluginStore::new();
3675 let orders = db::physical_table("shop", "orders").expect("derivable");
3676 let tenant = tenant_segment(Some("alpha"));
3677 store.seed(&orders, &tenant, "r1", row(&[("sku", "seeded")]));
3678
3679 let manifest = manifest(&everything());
3680 let mut runtime = CapabilityRuntime::new(
3681 &manifest,
3682 CapabilityServices {
3683 db: Some(Arc::clone(&store) as Arc<dyn PluginStore>),
3684 ..CapabilityServices::none()
3685 }
3686 .for_tenant("alpha"),
3687 );
3688
3689 let inserted = runtime.dispatch(&CapabilityCall::DbInsert {
3690 id: 1,
3691 table: "orders".to_owned(),
3692 row: row(&[("sku", "inserted")]),
3693 });
3694 assert!(
3695 matches!(inserted, CallResult::Ok { .. }),
3696 "the insert must succeed: {inserted:?}"
3697 );
3698
3699 let keys = store.keys();
3702 assert_eq!(
3703 keys.len(),
3704 2,
3705 "the seeded row must survive an insert beside it: {keys:?}"
3706 );
3707 assert!(
3708 keys.iter().any(|(_, _, id)| id == "r1"),
3709 "the seeded id must still be occupied by its own row: {keys:?}"
3710 );
3711 }
3712
3713 #[test]
3714 fn another_tenants_rows_are_invisible_and_unwritable() {
3715 let store = db::MemoryPluginStore::new();
3716 store.seed(
3719 "users",
3720 &tenant_segment(Some("alpha")),
3721 "u1",
3722 row(&[("email", "ceo@example.com")]),
3723 );
3724 let orders = db::physical_table("shop", "orders").expect("derivable");
3728 store.seed(
3729 &orders,
3730 &tenant_segment(Some("beta")),
3731 "r99",
3732 row(&[("sku", "beta's")]),
3733 );
3734
3735 let manifest = manifest(&everything());
3736 let mut alpha = CapabilityRuntime::new(
3737 &manifest,
3738 CapabilityServices {
3739 db: Some(Arc::clone(&store) as Arc<dyn PluginStore>),
3740 ..CapabilityServices::none()
3741 }
3742 .for_tenant("alpha"),
3743 );
3744
3745 let listed = alpha.dispatch(&CapabilityCall::DbQuery {
3746 id: 1,
3747 table: "orders".to_owned(),
3748 filter: PluginRow::new(),
3749 limit: 100,
3750 after: None,
3751 });
3752 assert_eq!(
3753 listed,
3754 CallResult::Ok {
3755 id: 1,
3756 value: CallValue::Rows {
3757 rows: Vec::new(),
3758 truncated: false
3759 }
3760 },
3761 "an unfiltered query returns this tenant's rows, which are none"
3762 );
3763
3764 for call in [
3765 CapabilityCall::DbGet {
3766 id: 2,
3767 table: "orders".to_owned(),
3768 row_id: "r99".to_owned(),
3769 },
3770 CapabilityCall::DbUpdate {
3771 id: 3,
3772 table: "orders".to_owned(),
3773 row_id: "r99".to_owned(),
3774 row: row(&[("sku", "stolen")]),
3775 },
3776 CapabilityCall::DbDelete {
3777 id: 4,
3778 table: "orders".to_owned(),
3779 row_id: "r99".to_owned(),
3780 },
3781 ] {
3782 let result = alpha.dispatch(&call);
3783 let expected_empty = matches!(call, CapabilityCall::DbGet { .. });
3788 if expected_empty {
3789 assert_eq!(
3790 result,
3791 CallResult::Ok {
3792 id: result.id(),
3793 value: CallValue::Rows {
3794 rows: Vec::new(),
3795 truncated: false
3796 }
3797 },
3798 "a read of another tenant's id must miss, not error"
3799 );
3800 } else {
3801 assert_eq!(
3802 result.denial(),
3803 Some(DenialReason::BackendError),
3804 "{call:?} must not touch another tenant's row"
3805 );
3806 }
3807 }
3808 assert_eq!(
3810 store.keys(),
3811 vec![
3812 (orders, tenant_segment(Some("beta")), "r99".to_owned()),
3813 (
3814 "users".to_owned(),
3815 tenant_segment(Some("alpha")),
3816 "u1".to_owned()
3817 ),
3818 ]
3819 );
3820 }
3821
3822 #[test]
3823 fn a_row_may_not_carry_the_column_that_decides_its_tenant() {
3824 let mut fixture = fixture(&everything(), Some("alpha"));
3825 let result = fixture.runtime.dispatch(&CapabilityCall::DbInsert {
3826 id: 1,
3827 table: "orders".to_owned(),
3828 row: row(&[("tenant_id", "beta")]),
3829 });
3830 assert_eq!(result.denial(), Some(DenialReason::NotInGrant));
3831 }
3832
3833 #[test]
3834 fn a_row_id_echoed_back_is_stripped_rather_than_refused() {
3835 let mut fixture = fixture(&everything(), Some("alpha"));
3842 let inserted = fixture.runtime.dispatch(&CapabilityCall::DbInsert {
3843 id: 1,
3844 table: "orders".to_owned(),
3845 row: row(&[("sku", "A-1"), ("row_id", "r999")]),
3846 });
3847 let CallResult::Ok {
3848 value: CallValue::RowId { row_id },
3849 ..
3850 } = inserted
3851 else {
3852 panic!("the insert should have succeeded: {inserted:?}")
3853 };
3854 assert_ne!(row_id, "r999", "the store assigns the id, never the guest");
3855
3856 let read = fixture.runtime.dispatch(&CapabilityCall::DbGet {
3857 id: 2,
3858 table: "orders".to_owned(),
3859 row_id: row_id.clone(),
3860 });
3861 let CallResult::Ok {
3862 value: CallValue::Rows { rows, .. },
3863 ..
3864 } = read
3865 else {
3866 panic!("the read should have succeeded: {read:?}")
3867 };
3868 let stored = rows.first().cloned().unwrap_or_default();
3869 assert_eq!(
3870 stored.get("row_id"),
3871 Some(&PluginValue::Text(row_id.clone())),
3872 "the row comes back addressed by the id the store assigned"
3873 );
3874
3875 let updated = fixture.runtime.dispatch(&CapabilityCall::DbUpdate {
3877 id: 3,
3878 table: "orders".to_owned(),
3879 row_id,
3880 row: stored,
3881 });
3882 assert!(updated.denial().is_none(), "{updated:?}");
3883 }
3884
3885 #[test]
3886 fn two_plugins_can_never_be_named_onto_one_physical_table() {
3887 let victim = db::physical_table("shop", "orders_v2");
3892 let attacker = db::physical_table("shop_orders", "v2");
3893 assert!(victim.is_some() && attacker.is_some());
3894 assert_ne!(victim, attacker, "a boundary shift must not collide");
3895
3896 let names = ["my-shop", "my.shop", "my_shop", "My_Shop", "MY.SHOP"];
3898 let derived: Vec<String> = names
3899 .iter()
3900 .filter_map(|name| db::physical_table(name, "orders"))
3901 .collect();
3902 assert_eq!(derived.len(), names.len(), "{derived:?}");
3903 let mut unique = derived.clone();
3904 unique.sort();
3905 unique.dedup();
3906 assert_eq!(unique.len(), names.len(), "collision in {derived:?}");
3907
3908 for name in &derived {
3910 assert!(
3911 name.starts_with("plugin_")
3912 && name.bytes().all(|byte| byte.is_ascii_lowercase()
3913 || byte.is_ascii_digit()
3914 || byte == b'_'),
3915 "{name}"
3916 );
3917 }
3918 }
3919
3920 #[test]
3921 fn a_truncated_ledger_says_so_rather_than_reporting_a_smaller_number() {
3922 let mut manifest = manifest(&everything());
3927 manifest.quotas.calls = MAX_QUOTA;
3928 manifest.quotas.kv_reads = MAX_QUOTA;
3929 let mut runtime = CapabilityRuntime::new(
3930 &manifest,
3931 CapabilityServices {
3932 kv: Some(MemoryKvStore::new() as Arc<dyn KvStore>),
3933 ..CapabilityServices::none()
3934 },
3935 );
3936 for id in 0..(MAX_EVENTS as u64 + 25) {
3937 let _ = runtime.dispatch(&CapabilityCall::KvGet {
3938 id,
3939 key: "k".to_owned(),
3940 });
3941 }
3942 assert_eq!(runtime.events().len(), MAX_EVENTS);
3943 assert_eq!(runtime.dropped_events(), 25);
3944
3945 let log = audit::PluginActivityLog::new();
3946 let dropped = runtime.dropped_events();
3947 log.ingest("shop", runtime.take_events());
3948 log.ingest_dropped("shop", dropped);
3949 let summary = log.summary("shop", std::time::Duration::from_secs(3600));
3950 assert_eq!(summary.dropped, 25);
3951 let rendered = summary.to_string();
3952 assert!(rendered.contains("floor"), "{rendered}");
3953 }
3954
3955 #[test]
3956 fn a_dropped_call_keeps_its_own_time_not_the_time_of_ingest() {
3957 use chrono::TimeZone as _;
3960
3961 use crate::time::{TickingClock, install_ambient};
3962
3963 let hour = std::time::Duration::from_secs(3600);
3964 let clock = TickingClock::starting_at(
3965 chrono::Utc
3966 .with_ymd_and_hms(2020, 1, 1, 0, 0, 0)
3967 .single()
3968 .expect("a valid date"),
3969 );
3970 let _clock = install_ambient(Arc::new(clock.clone()));
3971 let mut manifest = manifest(&everything());
3972 manifest.quotas.calls = MAX_QUOTA;
3973 manifest.quotas.kv_reads = MAX_QUOTA;
3974 let mut runtime = CapabilityRuntime::new(
3975 &manifest,
3976 CapabilityServices {
3977 kv: Some(MemoryKvStore::new() as Arc<dyn KvStore>),
3978 ..CapabilityServices::none()
3979 },
3980 );
3981 let log = audit::PluginActivityLog::new();
3982 let mut kv_get = (0..).map(|id| CapabilityCall::KvGet {
3983 id,
3984 key: "k".to_owned(),
3985 });
3986 let start = crate::time::ambient_instant();
3987 for call in kv_get.by_ref().take(MAX_EVENTS + 20) {
3988 let _ = runtime.dispatch(&call);
3989 }
3990 clock.advance(hour / 2);
3992 let later = crate::time::ambient_instant();
3993 for call in kv_get.take(5) {
3994 let _ = runtime.dispatch(&call);
3995 }
3996 clock.advance(3 * hour / 2);
3998 let dropped = runtime.dropped_calls().expect("25 calls were dropped");
3999 assert_eq!(dropped.count, 25);
4000 assert_eq!(dropped.first, start);
4001 assert_eq!(dropped.last, later);
4002 log.ingest("shop", runtime.take_events());
4003 log.ingest_dropped_calls("shop", dropped);
4004 assert_eq!(log.summary("shop", hour).dropped, 0);
4005 assert_eq!(log.summary("shop", 7 * hour / 4).dropped, 25);
4007 }
4008
4009 #[test]
4010 fn the_outbound_reply_reserve_follows_the_response_quota() {
4011 let post = CapabilityCall::HttpFetch {
4012 id: 1,
4013 method: "POST".to_owned(),
4014 url: "https://api.example.com/".to_owned(),
4015 headers: Vec::new(),
4016 body: String::new(),
4017 };
4018 let fixture = fixture(&everything(), Some("alpha"));
4019 assert_eq!(fixture.runtime.reply_reserve(&post), post.reply_reserve());
4021 let mut manifest = manifest(&everything());
4022 manifest.quotas.outbound_response_bytes = 4096;
4023 let small = CapabilityRuntime::new(&manifest, CapabilityServices::none());
4024 assert_eq!(
4025 small.reply_reserve(&post),
4026 6 * 4096 + outbound::MAX_RESPONSE_HEADER_BYTES * 6 + 4096
4027 );
4028 }
4029
4030 #[test]
4031 fn a_call_commits_when_it_can_change_state_outside_the_guest() {
4032 let fetch = |method: &str| CapabilityCall::HttpFetch {
4033 id: 1,
4034 method: method.to_owned(),
4035 url: "https://api.example.com/".to_owned(),
4036 headers: Vec::new(),
4037 body: String::new(),
4038 };
4039 let table = || "orders".to_owned();
4040 let commits = [
4041 CapabilityCall::KvSet {
4042 id: 1,
4043 key: "k".to_owned(),
4044 value: PluginValue::Null,
4045 },
4046 CapabilityCall::KvDelete {
4047 id: 1,
4048 key: "k".to_owned(),
4049 },
4050 CapabilityCall::DbInsert {
4051 id: 1,
4052 table: table(),
4053 row: PluginRow::new(),
4054 },
4055 CapabilityCall::DbUpdate {
4056 id: 1,
4057 table: table(),
4058 row_id: "r1".to_owned(),
4059 row: PluginRow::new(),
4060 },
4061 CapabilityCall::DbDelete {
4062 id: 1,
4063 table: table(),
4064 row_id: "r1".to_owned(),
4065 },
4066 CapabilityCall::JobEnqueue {
4067 id: 1,
4068 job_type: "reindex".to_owned(),
4069 payload: PluginRow::new(),
4070 },
4071 fetch("POST"),
4072 fetch("PUT"),
4073 fetch("PATCH"),
4074 fetch("DELETE"),
4075 fetch("BREW"),
4077 ];
4078 for call in &commits {
4079 assert!(call.commits(), "{call:?}");
4080 }
4081 let reads = [
4082 CapabilityCall::KvGet {
4083 id: 1,
4084 key: "k".to_owned(),
4085 },
4086 CapabilityCall::DbGet {
4087 id: 1,
4088 table: table(),
4089 row_id: "r1".to_owned(),
4090 },
4091 CapabilityCall::DbQuery {
4092 id: 1,
4093 table: table(),
4094 filter: PluginRow::new(),
4095 limit: 0,
4096 after: None,
4097 },
4098 fetch("GET"),
4099 fetch("HEAD"),
4100 fetch("get"),
4101 ];
4102 for call in &reads {
4103 assert!(!call.commits(), "{call:?}");
4104 }
4105 }
4106
4107 #[test]
4108 fn an_audit_target_is_bounded_before_it_is_escaped() {
4109 let mut fixture = fixture(&everything(), Some("alpha"));
4110 let _ = fixture.runtime.dispatch(&CapabilityCall::KvGet {
4111 id: 1,
4112 key: "\u{202e}".repeat(400),
4113 });
4114 let recorded = fixture
4115 .runtime
4116 .events()
4117 .first()
4118 .map(|event| event.target.clone())
4119 .unwrap_or_default();
4120 assert!(
4123 recorded.len() <= MAX_TARGET_CHARS * 10 + 8,
4124 "{} bytes: {recorded}",
4125 recorded.len()
4126 );
4127 }
4128
4129 #[test]
4130 fn the_in_memory_stores_refuse_rather_than_growing_without_a_ceiling() {
4131 let kv = MemoryKvStore::with_capacity(2);
4135 assert!(kv.set("a", PluginValue::Int(1)).is_ok());
4136 assert!(kv.set("b", PluginValue::Int(1)).is_ok());
4137 assert!(kv.set("c", PluginValue::Int(1)).is_err());
4138 assert!(kv.set("a", PluginValue::Int(2)).is_ok());
4140
4141 let store = db::MemoryPluginStore::with_capacity(1);
4142 let scope = db::Scope {
4143 table: "plugin_shop__orders".to_owned(),
4144 tenant: "alpha".to_owned(),
4145 };
4146 assert!(store.insert(&scope, PluginRow::new()).is_ok());
4147 assert!(store.insert(&scope, PluginRow::new()).is_err());
4148 }
4149
4150 #[test]
4151 fn a_full_store_is_a_denial_the_guest_can_read() {
4152 let mut runtime = CapabilityRuntime::new(
4153 &manifest(&everything()),
4154 CapabilityServices {
4155 kv: Some(MemoryKvStore::with_capacity(0) as Arc<dyn KvStore>),
4156 ..CapabilityServices::none()
4157 }
4158 .for_tenant("alpha"),
4159 );
4160 assert_eq!(
4161 runtime
4162 .dispatch(&CapabilityCall::KvSet {
4163 id: 1,
4164 key: "cart".to_owned(),
4165 value: PluginValue::Int(1)
4166 })
4167 .denial(),
4168 Some(DenialReason::BackendError)
4169 );
4170 }
4171
4172 #[test]
4173 fn no_physical_table_is_derivable_from_a_name_the_grant_list_could_not_hold() {
4174 assert_eq!(
4175 db::physical_table("shop", "orders"),
4176 Some("plugin_shop__orders".to_owned())
4177 );
4178 assert_eq!(
4182 db::physical_table("my.shop-v2", "orders"),
4183 Some("plugin_my_2eshop_2dv2__orders".to_owned())
4184 );
4185 for (plugin, table) in [
4186 ("shop", "users; drop table x"),
4187 ("shop", "\"users\""),
4188 ("shop", "Orders"),
4189 ("shop\"; --", "orders"),
4190 ("shop", ""),
4191 ("", "orders"),
4192 ("shop", &"o".repeat(64)),
4193 (&"p".repeat(40), &"t".repeat(30)),
4197 ] {
4198 assert_eq!(db::physical_table(plugin, table), None, "{plugin}/{table}");
4199 }
4200 }
4201
4202 #[test]
4205 fn a_job_carries_the_enqueuing_plugin_and_tenant_not_the_guests_word_for_them() {
4206 let mut fixture = fixture(&everything(), Some("alpha"));
4207 let result = fixture.runtime.dispatch(&CapabilityCall::JobEnqueue {
4208 id: 1,
4209 job_type: "reindex".to_owned(),
4210 payload: row(&[("since", "2026-01-01")]),
4211 });
4212 assert!(result.denial().is_none(), "{result:?}");
4213 assert_eq!(
4214 fixture.jobs.queued(),
4215 vec![jobs::PluginJob {
4216 plugin: "shop".to_owned(),
4217 artifact_sha256: "c".repeat(64),
4218 job_type: "reindex".to_owned(),
4219 tenant: Some("alpha".to_owned()),
4220 payload: row(&[("since", "2026-01-01")]),
4221 }]
4222 );
4223 }
4224
4225 #[test]
4226 fn an_undeclared_job_type_never_reaches_the_queue() {
4227 let mut fixture = fixture(&everything(), Some("alpha"));
4228 for job_type in ["send-invoice", "reindex-all", "REINDEX", ""] {
4229 let result = fixture.runtime.dispatch(&CapabilityCall::JobEnqueue {
4230 id: 1,
4231 job_type: job_type.to_owned(),
4232 payload: PluginRow::new(),
4233 });
4234 assert_eq!(
4235 result.denial(),
4236 Some(DenialReason::NotInGrant),
4237 "{job_type}"
4238 );
4239 }
4240 assert!(fixture.jobs.queued().is_empty());
4241 }
4242
4243 #[test]
4246 fn a_spent_quota_denies_the_call_and_nothing_else() {
4247 let mut fixture = fixture(&everything(), Some("alpha"));
4248 let ceiling = fixture.runtime.quotas().kv_reads;
4249 for id in 0..ceiling {
4250 let result = fixture.runtime.dispatch(&CapabilityCall::KvGet {
4251 id: u64::from(id),
4252 key: "cart".to_owned(),
4253 });
4254 assert!(result.denial().is_none(), "call {id}: {result:?}");
4255 }
4256 let over = fixture.runtime.dispatch(&CapabilityCall::KvGet {
4257 id: 999,
4258 key: "cart".to_owned(),
4259 });
4260 assert_eq!(over.denial(), Some(DenialReason::QuotaExceeded));
4261
4262 let job = fixture.runtime.dispatch(&CapabilityCall::JobEnqueue {
4265 id: 1000,
4266 job_type: "reindex".to_owned(),
4267 payload: PluginRow::new(),
4268 });
4269 assert!(job.denial().is_none(), "{job:?}");
4270 }
4271
4272 #[test]
4273 fn one_plugins_spent_quota_does_not_touch_another_plugins() {
4274 let manifest = manifest(&everything());
4275 let store = MemoryKvStore::new();
4276 let services = CapabilityServices {
4277 kv: Some(Arc::clone(&store) as Arc<dyn KvStore>),
4278 ..CapabilityServices::none()
4279 }
4280 .for_tenant("alpha");
4281 let mut first = CapabilityRuntime::new(&manifest, services.clone());
4282 for id in 0..manifest.quotas.kv_reads {
4283 let _ = first.dispatch(&CapabilityCall::KvGet {
4284 id: u64::from(id),
4285 key: "cart".to_owned(),
4286 });
4287 }
4288 assert_eq!(
4289 first
4290 .dispatch(&CapabilityCall::KvGet {
4291 id: 999,
4292 key: "cart".to_owned()
4293 })
4294 .denial(),
4295 Some(DenialReason::QuotaExceeded)
4296 );
4297
4298 let mut second = CapabilityRuntime::new(&manifest, services);
4299 assert!(
4300 second
4301 .dispatch(&CapabilityCall::KvGet {
4302 id: 1,
4303 key: "cart".to_owned()
4304 })
4305 .denial()
4306 .is_none()
4307 );
4308 }
4309
4310 #[test]
4311 fn the_shared_call_budget_bounds_the_sum_of_every_surface() {
4312 let mut manifest = manifest(&everything());
4313 manifest.quotas.calls = 3;
4314 let mut runtime = CapabilityRuntime::new(
4315 &manifest,
4316 CapabilityServices {
4317 kv: Some(MemoryKvStore::new() as Arc<dyn KvStore>),
4318 jobs: Some(MemoryJobSink::new() as Arc<dyn JobSink>),
4319 ..CapabilityServices::none()
4320 },
4321 );
4322 for id in 0..3 {
4323 assert!(
4324 runtime
4325 .dispatch(&CapabilityCall::KvGet {
4326 id,
4327 key: "k".to_owned()
4328 })
4329 .denial()
4330 .is_none()
4331 );
4332 }
4333 assert_eq!(
4335 runtime
4336 .dispatch(&CapabilityCall::JobEnqueue {
4337 id: 4,
4338 job_type: "reindex".to_owned(),
4339 payload: PluginRow::new(),
4340 })
4341 .denial(),
4342 Some(DenialReason::QuotaExceeded)
4343 );
4344 }
4345
4346 #[test]
4347 fn a_calls_per_second_ceiling_is_shared_across_requests_and_kept_per_capability() {
4348 let manifest = manifest(&everything());
4349 let rate = Arc::new(quota::CapabilityRateLimiter::new(1));
4354 let services = CapabilityServices {
4355 kv: Some(MemoryKvStore::new() as Arc<dyn KvStore>),
4356 jobs: Some(MemoryJobSink::new() as Arc<dyn JobSink>),
4357 rate: Some(Arc::clone(&rate)),
4358 ..CapabilityServices::none()
4359 };
4360 let mut first = CapabilityRuntime::new(&manifest, services.clone());
4361 assert!(
4362 first
4363 .dispatch(&CapabilityCall::KvGet {
4364 id: 1,
4365 key: "k".to_owned()
4366 })
4367 .denial()
4368 .is_none()
4369 );
4370 let mut second = CapabilityRuntime::new(&manifest, services);
4372 assert_eq!(
4373 second
4374 .dispatch(&CapabilityCall::KvGet {
4375 id: 3,
4376 key: "k".to_owned()
4377 })
4378 .denial(),
4379 Some(DenialReason::QuotaExceeded)
4380 );
4381 assert!(
4384 second
4385 .dispatch(&CapabilityCall::JobEnqueue {
4386 id: 4,
4387 job_type: "reindex".to_owned(),
4388 payload: PluginRow::new()
4389 })
4390 .denial()
4391 .is_none()
4392 );
4393 }
4394
4395 #[test]
4396 fn an_oversized_row_or_key_is_refused_before_it_is_stored() {
4397 let mut fixture = fixture(&everything(), Some("alpha"));
4398 let long = "k".repeat(MAX_KV_KEY_BYTES + 1);
4399 assert_eq!(
4400 fixture
4401 .runtime
4402 .dispatch(&CapabilityCall::KvGet { id: 1, key: long })
4403 .denial(),
4404 Some(DenialReason::Malformed)
4405 );
4406 let wide: PluginRow = (0..=MAX_ROW_COLUMNS)
4407 .map(|index| (format!("c{index}"), PluginValue::Int(1)))
4408 .collect();
4409 assert_eq!(
4410 fixture
4411 .runtime
4412 .dispatch(&CapabilityCall::DbInsert {
4413 id: 2,
4414 table: "orders".to_owned(),
4415 row: wide
4416 })
4417 .denial(),
4418 Some(DenialReason::Malformed)
4419 );
4420 assert!(fixture.db.keys().is_empty());
4421 }
4422
4423 #[test]
4424 fn a_missing_backend_is_a_denial_rather_than_a_silent_success() {
4425 let mut runtime =
4426 CapabilityRuntime::new(&manifest(&everything()), CapabilityServices::none());
4427 assert_eq!(
4428 runtime
4429 .dispatch(&CapabilityCall::KvSet {
4430 id: 1,
4431 key: "cart".to_owned(),
4432 value: PluginValue::Int(1)
4433 })
4434 .denial(),
4435 Some(DenialReason::Unavailable)
4436 );
4437 }
4438
4439 #[test]
4440 fn a_response_header_a_plugin_may_not_see_never_reaches_it() {
4441 let mut fixture = fixture(&everything(), Some("alpha"));
4445 fixture.http.answer(
4446 "https://api.example.com/v1",
4447 OutboundResponse {
4448 status: 200,
4449 headers: vec![
4450 ("content-type".to_owned(), "application/json".to_owned()),
4451 ("set-cookie".to_owned(), "session=stolen".to_owned()),
4452 ("x-upstream-secret".to_owned(), "s3cret".to_owned()),
4453 ],
4454 body: "{}".to_owned(),
4455 final_url: "https://api.example.com/v1".to_owned(),
4456 },
4457 );
4458 let result = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
4459 id: 1,
4460 method: "GET".to_owned(),
4461 url: "https://api.example.com/v1".to_owned(),
4462 headers: Vec::new(),
4463 body: String::new(),
4464 });
4465 let CallResult::Ok {
4466 value: CallValue::Http { headers, .. },
4467 ..
4468 } = result
4469 else {
4470 panic!("the call should have succeeded: {result:?}")
4471 };
4472 assert_eq!(
4473 headers,
4474 vec![("content-type".to_owned(), "application/json".to_owned())]
4475 );
4476 }
4477
4478 #[test]
4479 fn a_method_or_header_count_outside_the_allow_list_is_refused() {
4480 for method in ["CONNECT", "TRACE", "OPTIONS", " GET", ""] {
4482 let mut fixture = fixture(&everything(), Some("alpha"));
4483 let result = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
4484 id: 1,
4485 method: method.to_owned(),
4486 url: "https://api.example.com/".to_owned(),
4487 headers: Vec::new(),
4488 body: String::new(),
4489 });
4490 assert_eq!(result.denial(), Some(DenialReason::Malformed), "{method}");
4491 assert!(fixture.http.seen().is_empty(), "{method} left the host");
4492 }
4493 let mut fixture = fixture(&everything(), Some("alpha"));
4494 let many: Vec<(String, String)> = (0..=MAX_OUTBOUND_HEADERS)
4495 .map(|_| ("accept".to_owned(), "*/*".to_owned()))
4496 .collect();
4497 assert_eq!(
4498 fixture
4499 .runtime
4500 .dispatch(&CapabilityCall::HttpFetch {
4501 id: 2,
4502 method: "GET".to_owned(),
4503 url: "https://api.example.com/".to_owned(),
4504 headers: many,
4505 body: String::new(),
4506 })
4507 .denial(),
4508 Some(DenialReason::Malformed)
4509 );
4510 assert!(fixture.http.seen().is_empty());
4511 }
4512
4513 #[test]
4514 fn every_byte_ceiling_a_quota_names_is_actually_applied() {
4515 let mut manifest = manifest(&everything());
4518 manifest.quotas.kv_value_bytes = 8;
4519 manifest.quotas.db_rows = 2;
4520 manifest.quotas.outbound_response_bytes = 8;
4521 let http = RecordingHttp::new();
4522 http.answer(
4523 "https://api.example.com/big",
4524 OutboundResponse::from_url("https://api.example.com/big", 200, "x".repeat(64)),
4525 );
4526 let db = db::MemoryPluginStore::new();
4527 let mut runtime = CapabilityRuntime::new(
4528 &manifest,
4529 CapabilityServices {
4530 kv: Some(MemoryKvStore::new() as Arc<dyn KvStore>),
4531 db: Some(Arc::clone(&db) as Arc<dyn PluginStore>),
4532 http: Some(Arc::clone(&http) as Arc<dyn OutboundHttp>),
4533 ..CapabilityServices::none()
4534 }
4535 .for_tenant("alpha"),
4536 );
4537
4538 assert_eq!(
4540 runtime
4541 .dispatch(&CapabilityCall::KvSet {
4542 id: 1,
4543 key: "cart".to_owned(),
4544 value: PluginValue::Text("far too long a value".to_owned())
4545 })
4546 .denial(),
4547 Some(DenialReason::QuotaExceeded)
4548 );
4549
4550 for index in 0..5 {
4553 let inserted = runtime.dispatch(&CapabilityCall::DbInsert {
4554 id: 10 + index,
4555 table: "orders".to_owned(),
4556 row: row(&[("sku", "A")]),
4557 });
4558 assert!(inserted.denial().is_none(), "{inserted:?}");
4559 }
4560 for (id, limit) in [(20, 0), (21, 100)] {
4561 let result = runtime.dispatch(&CapabilityCall::DbQuery {
4562 id,
4563 table: "orders".to_owned(),
4564 filter: PluginRow::new(),
4565 limit,
4566 after: None,
4567 });
4568 let CallResult::Ok {
4569 value: CallValue::Rows { rows, .. },
4570 ..
4571 } = result
4572 else {
4573 panic!("the query should have succeeded: {result:?}")
4574 };
4575 assert_eq!(rows.len(), 2, "limit {limit} is clamped to `db_rows`");
4576 }
4577
4578 assert_eq!(
4580 runtime
4581 .dispatch(&CapabilityCall::HttpFetch {
4582 id: 30,
4583 method: "GET".to_owned(),
4584 url: "https://api.example.com/big".to_owned(),
4585 headers: Vec::new(),
4586 body: String::new(),
4587 })
4588 .denial(),
4589 Some(DenialReason::ResponseTooLarge)
4590 );
4591 }
4592
4593 #[test]
4594 fn a_row_id_outside_its_bounds_is_refused_before_the_store_sees_it() {
4595 let mut fixture = fixture(&everything(), Some("alpha"));
4596 for row_id in [String::new(), "r".repeat(MAX_ROW_ID_BYTES + 1)] {
4597 assert_eq!(
4598 fixture
4599 .runtime
4600 .dispatch(&CapabilityCall::DbGet {
4601 id: 1,
4602 table: "orders".to_owned(),
4603 row_id: row_id.clone(),
4604 })
4605 .denial(),
4606 Some(DenialReason::Malformed),
4607 "{} bytes",
4608 row_id.len()
4609 );
4610 }
4611 }
4612
4613 #[test]
4614 fn a_full_job_queue_is_a_denial_the_guest_can_read() {
4615 let sink = jobs::MemoryJobSink::bounded(1);
4619 let mut runtime = CapabilityRuntime::new(
4620 &manifest(&everything()),
4621 CapabilityServices {
4622 jobs: Some(Arc::clone(&sink) as Arc<dyn JobSink>),
4623 ..CapabilityServices::none()
4624 }
4625 .for_tenant("alpha"),
4626 );
4627 let enqueue = |id| CapabilityCall::JobEnqueue {
4628 id,
4629 job_type: "reindex".to_owned(),
4630 payload: PluginRow::new(),
4631 };
4632 assert!(runtime.dispatch(&enqueue(1)).denial().is_none());
4633 assert_eq!(
4634 runtime.dispatch(&enqueue(2)).denial(),
4635 Some(DenialReason::BackendError)
4636 );
4637 assert_eq!(sink.queued().len(), 1);
4638 }
4639
4640 #[test]
4641 fn the_zero_configuration_job_queue_is_finite() {
4642 let sink = jobs::MemoryJobSink::new();
4653 let job = || PluginJob {
4654 plugin: "shop".to_owned(),
4655 artifact_sha256: "c".repeat(64),
4656 job_type: "reindex".to_owned(),
4657 tenant: Some("alpha".to_owned()),
4658 payload: PluginRow::new(),
4659 };
4660 for index in 0..jobs::DEFAULT_JOB_DEPTH {
4661 assert!(sink.enqueue(job()).is_ok(), "job {index}");
4662 }
4663 let over = sink.enqueue(job());
4664 assert!(over.is_err(), "{over:?}");
4665 assert_eq!(sink.queued().len(), jobs::DEFAULT_JOB_DEPTH);
4666 }
4667
4668 #[test]
4671 fn one_surface_answers_what_this_plugin_did() {
4672 let mut fixture = fixture(&everything(), Some("alpha"));
4673 fixture.http.answer(
4674 "https://api.example.com/v1",
4675 OutboundResponse {
4676 status: 200,
4677 headers: Vec::new(),
4678 body: "ok".to_owned(),
4679 final_url: "https://api.example.com/v1".to_owned(),
4680 },
4681 );
4682 let _ = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
4683 id: 1,
4684 method: "GET".to_owned(),
4685 url: "https://api.example.com/v1".to_owned(),
4686 headers: Vec::new(),
4687 body: String::new(),
4688 });
4689 let _ = fixture.runtime.dispatch(&CapabilityCall::HttpFetch {
4690 id: 2,
4691 method: "GET".to_owned(),
4692 url: "https://attacker.test/".to_owned(),
4693 headers: Vec::new(),
4694 body: String::new(),
4695 });
4696 let _ = fixture.runtime.dispatch(&CapabilityCall::DbInsert {
4697 id: 3,
4698 table: "orders".to_owned(),
4699 row: row(&[("sku", "A-1")]),
4700 });
4701 let _ = fixture.runtime.dispatch(&CapabilityCall::JobEnqueue {
4702 id: 4,
4703 job_type: "reindex".to_owned(),
4704 payload: PluginRow::new(),
4705 });
4706
4707 let log = audit::PluginActivityLog::new();
4708 log.ingest("shop", fixture.runtime.take_events());
4709 let summary = log.summary("shop", std::time::Duration::from_secs(3600));
4710
4711 assert_eq!(summary.hosts.get("api.example.com"), Some(&1));
4712 assert_eq!(summary.tables.get("plugin_shop_orders"), None);
4713 assert_eq!(summary.tables.get("orders"), Some(&1));
4714 assert_eq!(summary.job_types.get("reindex"), Some(&1));
4715 assert_eq!(summary.denied.get("http-fetch"), Some(&1));
4716 let rendered = summary.to_string();
4717 assert!(rendered.contains("api.example.com"), "{rendered}");
4718 assert!(rendered.contains("reindex"), "{rendered}");
4719 }
4720
4721 #[test]
4722 fn the_audit_record_never_carries_a_value() {
4723 let mut fixture = fixture(&everything(), Some("alpha"));
4724 let _ = fixture.runtime.dispatch(&CapabilityCall::KvSet {
4725 id: 1,
4726 key: "cart".to_owned(),
4727 value: PluginValue::Text("the customer's card number".to_owned()),
4728 });
4729 let _ = fixture.runtime.dispatch(&CapabilityCall::DbInsert {
4730 id: 2,
4731 table: "orders".to_owned(),
4732 row: row(&[("note", "the customer's home address")]),
4733 });
4734 let serialized = serde_json::to_string(fixture.runtime.events()).expect("events serialize");
4735 assert!(!serialized.contains("card number"), "{serialized}");
4736 assert!(!serialized.contains("home address"), "{serialized}");
4737 assert!(
4738 serialized.contains("cart"),
4739 "the key is a shape, not a value"
4740 );
4741 }
4742}