Skip to main content

autumn_web/plugin_sandbox/capability/
mod.rs

1//! What a granted capability actually *does*, and everything it is checked
2//! against on the way (issue #1632).
3//!
4//! [`manifest`](super::manifest) decides what a plugin may ask for.
5//! [`grants`](super::grants) decides what each grant is scoped to. This module
6//! is where a request from the guest meets both, plus the quota and the audit
7//! ledger, before it reaches a subsystem:
8//!
9//! ```text
10//!  guest writes {"op":"call","call":"kv-get","key":"cart"}
11//!        │
12//!        ▼
13//!  granted?      ── no ─► denied: capability-not-granted   (recorded)
14//!        │ yes
15//!        ▼
16//!  in scope?     ── no ─► denied: not-in-grant             (recorded)
17//!        │ yes
18//!        ▼
19//!  under quota?  ── no ─► denied: quota-exceeded           (recorded)
20//!        │ yes
21//!        ▼
22//!  derive the physical name from the manifest, never from the guest
23//!        │
24//!        ▼
25//!  backend ─────────────► {"op":"call_result","id":1,"status":"ok",…} (recorded)
26//! ```
27//!
28//! # The guest never names a physical thing
29//!
30//! Every step above is ordinary defence. The step that makes cross-tenant and
31//! host-table access *impossible* rather than merely refused is the second to
32//! last one: the guest names a **logical** key, table, host or job type, and the
33//! host derives the physical name from the manifest and the ambient tenant. A
34//! guest cannot ask for another tenant's row because there is no field in the
35//! wire protocol where the tenant would go. It cannot ask for a host-application
36//! table because the physical name is `plugin_<plugin>_<table>` and both halves
37//! come from a validated manifest. It cannot smuggle SQL because no call carries
38//! any.
39//!
40//! That is why the adversarial corpus in
41//! `autumn/tests/integration/plugin_sandbox_capabilities.rs` is mostly about
42//! *spelling*: an escape here would have to be a name that survives derivation,
43//! not a missing check.
44//!
45//! # Denials are answers, not failures
46//!
47//! A refused call comes back as a `call_result` the guest can read. It does not
48//! trap and it does not fail the request. A plugin that hits a ceiling should
49//! degrade — render the panel without the live number — and an author debugging
50//! one needs to see *which* rule refused. Every denial is also recorded in the
51//! outcome's ledger and logged once, which is what the operator audit surface
52//! is built from.
53// autumn-panic-gate: request-path module — production code path must be panic-free.
54// See CONTRIBUTING.md "Request-path panic gate". Justify exceptions with
55// #[allow(clippy::<lint>, reason = "…")] at the narrowest scope.
56#![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};
93// Aliased for use *inside* this module only, so the `pub use` above can carry
94// the real names without shadowing itself.
95use 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
107/// The most columns one plugin row may carry.
108///
109/// A row crosses the wire as a map the guest wrote, is held in host memory, and
110/// (for the DB capability) becomes a statement's column list. Every one of those
111/// is work an untrusted map sizes, so the count is bounded before any of it
112/// happens.
113pub const MAX_ROW_COLUMNS: usize = 32;
114
115/// Longest accepted text in one row value or KV key, in bytes.
116pub const MAX_VALUE_TEXT_BYTES: usize = 64 * 1024;
117
118/// Longest accepted logical KV key, in bytes.
119///
120/// The key is the one identifier a guest chooses freely — tables, hosts, job
121/// types and slots all have to appear in the manifest — so it is the one that
122/// needs a length of its own rather than inheriting a grant's.
123pub const MAX_KV_KEY_BYTES: usize = 512;
124
125/// Most bytes one row may carry across all of its columns.
126///
127/// [`MAX_VALUE_TEXT_BYTES`] bounds one column, and 32 of them bounds a row at
128/// 2 MiB — twice what a whole reply may be. A row that cannot be handed back is
129/// a row that must not be accepted, so the ceiling that lets a `db-get` answer
130/// is applied where the row goes *in*.
131pub const MAX_ROW_BYTES: usize = 256 * 1024;
132
133/// Most bytes the rows in one `db-get` or `db-query` answer may carry.
134///
135/// Rows are the one result whose size the *guest* chooses: a `db_rows` quota of
136/// 500 against [`MAX_ROW_BYTES`] is 125 MiB the host would materialise, encode
137/// and hold, per request, on a quota the plugin's own manifest sets. So the
138/// budget travels into the store (see [`PluginStore::query`]) rather than being
139/// checked once the whole answer exists — by which point the allocation has
140/// already happened.
141///
142/// Comfortably under the host's queued-reply ceiling, so a reply that fits this
143/// always fits the queue and a legal read never fails a later call.
144///
145/// [`PluginStore::query`]: db::PluginStore::query
146pub const MAX_RESULT_BYTES: usize = 512 * 1024;
147
148/// Longest accepted plugin row id, in bytes.
149pub const MAX_ROW_ID_BYTES: usize = 128;
150
151/// The longest job id a [`JobSink`] may assign.
152///
153/// The id goes back to the guest after the job is queued, so it is bounded where
154/// it enters the reply.
155pub const MAX_JOB_ID_BYTES: usize = 128;
156
157// ── Values ───────────────────────────────────────────────────────────────
158
159/// One scalar in a plugin row or job payload.
160///
161/// Deliberately **not** `serde_json::Value`. Arbitrary JSON is a recursive
162/// structure an untrusted guest chooses the depth of, and every consumer of it
163/// — the row encoder, the store, the job payload — would need its own depth
164/// bound to be safe. A closed set of scalars has no depth to bound, so the only
165/// ceilings left are a column count and a byte length, both checked once here.
166///
167/// The `untagged` representation is what a plugin author would write by hand:
168/// `{"sku":"A-1","qty":3,"active":true}` rather than
169/// `{"sku":{"text":"A-1"}}`. The variants are ordered so the match is
170/// unambiguous — a JSON integer is `Int` because `Bool` cannot hold it and
171/// `Int` is tried before `Float`.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173#[serde(untagged)]
174pub enum PluginValue {
175    /// JSON `null`.
176    Null,
177    /// JSON `true` / `false`.
178    Bool(bool),
179    /// A JSON integer.
180    Int(i64),
181    /// A JSON number that is not an integer.
182    Float(f64),
183    /// A JSON string.
184    Text(String),
185}
186
187thread_local! {
188    /// Nodes still allowed in the fragment tree currently being deserialized.
189    ///
190    /// A thread-local rather than a parameter because `deserialize_with` takes
191    /// only a `Deserializer`, and the budget has to be shared by a vector and
192    /// every vector nested inside it. Each request runs on its own blocking
193    /// thread, so there is no sharing between plugins, and the guard below
194    /// restores the previous value on the way out — including on the error
195    /// path, which is the common one here.
196    static FRAGMENT_NODE_BUDGET: std::cell::Cell<usize> =
197        const { std::cell::Cell::new(usize::MAX) };
198}
199
200/// Reserve a whole-tree node budget for as long as this value lives.
201pub struct FragmentNodeBudget(usize);
202
203impl FragmentNodeBudget {
204    /// Start a fragment parse with `limit` nodes for the entire tree.
205    #[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
217/// Deserialize the *root* of a fragment tree, reserving a budget for all of it.
218///
219/// # Errors
220///
221/// When the tree carries more than `LIMIT` nodes in total.
222pub 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
232/// Deserialize nodes against the budget the root reserved.
233///
234/// Per-vector ceilings were not enough, and this is the third pass over the
235/// same ground: bounding each `children` vector at `MAX_NODES` still admits
236/// `MAX_NODES` of them nested inside each other, so a frame within the stdout
237/// budget deserialized hundreds of thousands of nodes and was refused only
238/// afterwards, by `render`, on the aggregate. The count has to be shared by the
239/// whole tree or it is not a bound on the tree at all.
240///
241/// # Errors
242///
243/// When the tree's shared budget runs out.
244pub 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            // Read, then charge -- not the other way round. Charging first
266            // meant the probe that discovers the *end* of the sequence was
267            // itself charged, so a vector holding exactly the budget was
268            // refused for being one over it. An element's own children are
269            // charged as they are read, inside this same call, so the tree is
270            // still bounded while it is built rather than after.
271            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
295/// Deserialize a `Vec` that refuses to grow past `LIMIT` while it is being read.
296///
297/// A structural ceiling checked *after* `serde_json` has built the collection
298/// bounds what is rendered or dispatched, never what is allocated. One frame of
299/// a few hundred kilobytes can carry hundreds of thousands of `["",""]` pairs,
300/// each of which becomes two `String`s — tens of megabytes of host memory,
301/// outside the guest's own limiter and multiplied by every concurrent request,
302/// spent to produce a value that is then refused for having too many elements.
303///
304/// The limit is applied by the visitor, so the refusal happens at element
305/// `LIMIT + 1` and nothing larger is ever held.
306///
307/// # Errors
308///
309/// The deserializer's own error when an element cannot be read, and a custom
310/// one naming `LIMIT` when the sequence carries more than that many.
311pub 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            // `size_hint` is a hint from the input and not to be trusted with an
332            // allocation, so it is clamped to what will be accepted anyway.
333            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
347/// Deserialize a [`PluginRow`] that refuses to grow past [`MAX_ROW_COLUMNS`]
348/// while it is being read.
349///
350/// [`bounded_vec`] for maps, and needed for the same reason: `check_row` applies
351/// the column ceiling to a map `serde_json` has already built, so a frame of
352/// hundreds of thousands of one-character columns is materialised in full and
353/// then refused for having too many. Worse than the sequence case, because
354/// `dispatch` deserializes before it checks the capability — so an *ungranted*
355/// call can spend that memory.
356///
357/// # Errors
358///
359/// The deserializer's own error when an entry cannot be read, and a custom one
360/// naming the ceiling when the map carries more entries than that.
361pub 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            // One over the column ceiling, deliberately. `db-get` hands the row
378            // back with the host's `row_id` on it, and the documented
379            // read-modify-write is to change a field and send that map to
380            // `db-update` — so a row stored at exactly `MAX_ROW_COLUMNS`
381            // arrives here with 33 entries. Refusing it *here* would fail the
382            // whole frame as malformed before `validated_row` could strip the
383            // column the host itself added, which is the bug this subsystem
384            // already fixed once at the dispatch layer and this parse bound
385            // reintroduced one layer earlier.
386            //
387            // The real limit is still enforced, after stripping, by
388            // `validated_row`. This is only a bound on what is *allocated*, and
389            // thirty-three entries bounds it exactly as well as thirty-two.
390            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                // Counted on the map, not on entries read: a frame repeating one
395                // column name collapses to a single entry, and refusing it for
396                // its *reads* would reject a legal row written wastefully.
397                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/// The bytes `text` occupies once JSON-escaped, excluding its framing quotes.
412///
413/// Not `text.len()`. JSON escaping is not a constant factor: a NUL becomes the
414/// six bytes `\u0000`, so 256 KiB of them serialize past 1.5 MiB. Every ceiling
415/// in this module exists to stop the host building a reply it cannot send, and
416/// a ceiling measured in *stored* bytes does not do that — it passes a value
417/// that then overruns the reply queue and fails the request, after whatever
418/// side effect produced it has already happened.
419///
420/// The two framing quotes are deliberately *not* counted. They are per-value
421/// encoding overhead, already covered by the per-entry constants the stores
422/// add, and counting them here would quietly redefine `kv_value_bytes` and
423/// every sibling ceiling as two bytes less than the number the operator wrote —
424/// a value of exactly the declared size would be refused for carrying its own
425/// punctuation.
426///
427/// Counted rather than serialized, so asking the size costs no allocation.
428#[must_use]
429pub fn encoded_text_len(text: &str) -> usize {
430    text.chars()
431        .map(|ch| match ch {
432            // The two-byte escapes JSON names.
433            '"' | '\\' | '\n' | '\r' | '\t' | '\u{8}' | '\u{c}' => 2,
434            // Every other control character takes the `\u00XX` long form.
435            other if other.is_control() => 6,
436            other => other.len_utf8(),
437        })
438        .fold(0, usize::saturating_add)
439}
440
441/// How many characters `value` occupies in decimal, sign included.
442///
443/// Counted rather than formatted: `weight` runs per value on the write path.
444const fn decimal_len(value: i64) -> usize {
445    // `i64::MIN` has no positive counterpart, so it cannot be negated to be
446    // counted. It is also the longest, which makes the special case the answer.
447    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    /// The bytes this value costs, measured as it will be *encoded*.
466    ///
467    /// See [`encoded_text_len`]: the quotas this feeds bound a reply the host
468    /// has to serialize, so the stored length is the wrong number.
469    #[must_use]
470    pub fn weight(&self) -> usize {
471        match self {
472            Self::Text(text) => encoded_text_len(text),
473            // Measured, not assumed. A flat eight bytes for every scalar was
474            // wrong in the direction that matters: `i64::MIN` encodes as
475            // twenty, so a `kv_value_bytes` of eight admitted a value the
476            // operator's ceiling forbade — on the way in *and* on the way back
477            // out, since reads are checked against the same number.
478            // `null` and `true` are both four characters; `false` is five.
479            Self::Null | Self::Bool(true) => 4,
480            Self::Bool(false) => 5,
481            Self::Int(value) => decimal_len(*value),
482            // A bound rather than a measurement, and deliberately: the shortest
483            // round-trip form of an `f64` is at most twenty-four characters
484            // (`-2.2250738585072014e-308`), so this can never *under*-count,
485            // which is the only direction a ceiling may not be wrong in.
486            // Formatting one to find out would allocate on a path that runs per
487            // value.
488            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
505/// One row: column name to scalar, ordered so encodings are deterministic.
506pub type PluginRow = BTreeMap<String, PluginValue>;
507
508/// Check one row against the bounds every backend inherits.
509///
510/// Checked in the host rather than in each backend, so a store implementation
511/// an embedder wrote cannot be the place the bound is missing.
512///
513/// # Errors
514///
515/// Names the first rule the row broke.
516pub fn check_row(row: &PluginRow) -> Result<(), String> {
517    check_row_without(row, None)
518}
519
520/// As [`check_row`], ignoring `skip` if the row carries it.
521///
522/// Exists so a caller that is going to strip a column can check the row it will
523/// *build* without building it first. Stripping by cloning and removing meant a
524/// row over the ceiling was copied in full before the ceiling refused it, and a
525/// legal one was copied twice — once to strip, once to return.
526///
527/// # Errors
528///
529/// One line naming the ceiling the row missed.
530pub 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/// What one row costs in host memory: its values, plus its column names.
570///
571/// Column names count because they are repeated in every row of an answer and
572/// in the JSON of every reply, so a row of 32 long names is not free.
573#[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
581/// Bytes of JSON syntax around a row: quotes, colons and commas per column,
582/// and the braces and `row_id` key.
583pub(super) fn row_framing(row: &PluginRow) -> usize {
584    row.len().saturating_mul(8).saturating_add(16)
585}
586
587/// Keep rows while they fit [`MAX_RESULT_BYTES`], reporting whether any were
588/// left behind.
589///
590/// The second line of defence, not the first: [`PluginStore::query`] is given
591/// the same budget so a well-behaved store never builds the rows this would
592/// discard. It runs anyway, because the store is an embedder's trait
593/// implementation and "the host does not hold more than this" cannot rest on
594/// someone else's loop.
595///
596/// A row the host would have accepted always survives: [`MAX_ROW_BYTES`] is
597/// under this ceiling, so a `db-get` of a legally-stored row is never answered
598/// with nothing. A row *larger* than the whole result budget is dropped rather
599/// than exempted — it can only have come from a store that returned what the
600/// host would never have stored, and passing it through is exactly the
601/// oversized reply this exists to refuse.
602///
603/// [`PluginStore::query`]: db::PluginStore::query
604#[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        // JSON syntax counts too. Else many narrow rows exceed the reply room.
611        let weight = row_weight(&row).saturating_add(row_framing(&row));
612        // No "but keep the first one" exemption. Letting an empty `kept` skip
613        // the ceiling meant one corrupt or oversized row from an embedder's
614        // store was serialized in full — and a single row can be arbitrarily
615        // large when it did not come through `check_row`.
616        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// ── The calls ────────────────────────────────────────────────────────────
627
628/// One thing a guest asks the host to do on its behalf.
629///
630/// Internally tagged by `call`, so a frame reads as
631/// `{"op":"call","call":"kv-get","id":1,"key":"cart"}` — one flat object a
632/// plugin author can write without a code generator, which is the same reason
633/// the request frame is what it is.
634///
635/// There is no variant carrying SQL, a URL path on the host, a tenant, or a
636/// physical table name, and that absence is the design: a call cannot ask for
637/// something out of scope because the wire has nowhere to put it.
638#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
639#[serde(tag = "call", rename_all = "kebab-case", deny_unknown_fields)]
640#[non_exhaustive]
641pub enum CapabilityCall {
642    /// Read one key from the plugin's per-tenant namespace.
643    KvGet {
644        /// Correlation id the result echoes.
645        id: u64,
646        /// The logical key.
647        key: String,
648    },
649    /// Write one key.
650    KvSet {
651        /// Correlation id.
652        id: u64,
653        /// The logical key.
654        key: String,
655        /// The value.
656        value: PluginValue,
657    },
658    /// Delete one key.
659    KvDelete {
660        /// Correlation id.
661        id: u64,
662        /// The logical key.
663        key: String,
664    },
665    /// Call a declared upstream.
666    HttpFetch {
667        /// Correlation id.
668        id: u64,
669        /// HTTP method.
670        method: String,
671        /// Absolute URL. Its host must appear in `[grants].hosts`.
672        url: String,
673        /// Request headers.
674        ///
675        /// Bounded while it is read, not after the allow-list check; see
676        /// [`bounded_vec`].
677        #[serde(
678            default,
679            deserialize_with = "bounded_vec::<_, _, MAX_OUTBOUND_HEADERS>"
680        )]
681        headers: Vec<(String, String)>,
682        /// Request body, as text.
683        #[serde(default)]
684        body: String,
685    },
686    /// Insert one row into a declared plugin table.
687    DbInsert {
688        /// Correlation id.
689        id: u64,
690        /// Logical table name, which must appear in `[grants].tables`.
691        table: String,
692        /// The row.
693        #[serde(deserialize_with = "bounded_row")]
694        row: PluginRow,
695    },
696    /// Read one row by id.
697    DbGet {
698        /// Correlation id.
699        id: u64,
700        /// Logical table name.
701        table: String,
702        /// The row id, as returned by `db-insert`.
703        row_id: String,
704    },
705    /// Read rows matching an equality filter.
706    DbQuery {
707        /// Correlation id.
708        id: u64,
709        /// Logical table name.
710        table: String,
711        /// Column equality filter; an empty map matches every row the plugin
712        /// and tenant own.
713        #[serde(default, deserialize_with = "bounded_row")]
714        filter: PluginRow,
715        /// Largest number of rows to return, capped by the `db_rows` quota.
716        #[serde(default)]
717        limit: u32,
718        /// Resume after this `row_id`, for reading past one page.
719        ///
720        /// A page can end early on the `db_rows` quota or on
721        /// [`MAX_RESULT_BYTES`], and `truncated` tells the guest that happened —
722        /// but without a place to say "carry on from here", the same filter
723        /// would return the same prefix forever and the rows behind it would be
724        /// unreachable. Rows come back in ascending `row_id` order (see
725        /// [`PluginStore::query`](db::PluginStore::query)), so the last
726        /// `row_id` of one page is the `after` of the next.
727        #[serde(default)]
728        after: Option<String>,
729    },
730    /// Replace one row by id.
731    DbUpdate {
732        /// Correlation id.
733        id: u64,
734        /// Logical table name.
735        table: String,
736        /// The row id.
737        row_id: String,
738        /// The replacement row.
739        #[serde(deserialize_with = "bounded_row")]
740        row: PluginRow,
741    },
742    /// Delete one row by id.
743    DbDelete {
744        /// Correlation id.
745        id: u64,
746        /// Logical table name.
747        table: String,
748        /// The row id.
749        row_id: String,
750    },
751    /// Enqueue one declared job type.
752    JobEnqueue {
753        /// Correlation id.
754        id: u64,
755        /// The job type, which must appear in `[grants].job_types`.
756        job_type: String,
757        /// The job's arguments.
758        #[serde(default, deserialize_with = "bounded_row")]
759        payload: PluginRow,
760    },
761}
762
763impl CapabilityCall {
764    /// The correlation id the result echoes.
765    #[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    /// The capability this call needs.
782    #[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    /// The logical thing this call names, before any check has run.
799    ///
800    /// Distinct from the *validated* target: a call refused for naming a host
801    /// the manifest never granted still names one, and an operator asking "what
802    /// did this plugin reach for" is asking exactly about those. Recording only
803    /// what passed would make the audit surface answer the easy half of the
804    /// question.
805    ///
806    /// The URL case reports the **host**, not the URL: a URL is a guest-chosen
807    /// string with a query string on it, and the audit ledger is a bounded
808    /// buffer that is not the place to keep one.
809    #[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            // The *authority* it reached for, even when `host_of` refuses the
816            // URL — because the refusals are the interesting ones. Recording a
817            // placeholder would put every userinfo, backslash and scheme-
818            // confusion attempt under one label, so `attacker.test` in
819            // `https://api.example.com@attacker.test/` would never appear in the
820            // one surface built to show what a plugin reached for.
821            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    /// Bytes a reply to this call may take, at its worst.
834    ///
835    /// A read and an outbound call can have a large reply. A write answers with
836    /// `done` or an id. Headers count at the JSON worst case of six bytes per
837    /// byte. The host refuses a call that has no room, so the guest gets a
838    /// denial it can read, not a failed request.
839    #[must_use]
840    pub const fn reply_reserve(&self) -> usize {
841        self.reply_reserve_for(MAX_RESULT_BYTES, 500)
842    }
843
844    /// As [`reply_reserve`](Self::reply_reserve), with the plugin's own
845    /// `kv_value_bytes` for a KV read and `db_rows` for a query.
846    ///
847    /// A DB reply adds JSON framing to the row weight: quotes, colons and
848    /// commas for each column of each row.
849    #[must_use]
850    pub const fn reply_reserve_for(&self, kv_value_bytes: usize, db_rows: usize) -> usize {
851        /// Framing bytes for one row at the column limit, with its `row_id`.
852        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                // The page the call can return: its own limit, within the quota.
858                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                // Never more than the reply queue holds, or no query could run.
868                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    /// [`target`](Self::target), already cut to what the ledger keeps.
882    ///
883    /// Used before any check has run, so it never copies more than
884    /// [`MAX_TARGET_CHARS`] characters of a guest string. A URL is cut first, to
885    /// keep the host parse bounded too.
886    #[must_use]
887    pub fn target_excerpt(&self) -> String {
888        /// Bytes of URL parsed as a whole; a longer URL is scanned in place.
889        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            // A long URL: scan for the authority in place, copy nothing, and
898            // keep the text after the last `@` as a client would.
899            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    /// The operation name the audit ledger records.
916    #[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    /// Whether this call can change state outside the guest.
933    ///
934    /// A failed request that made such a call can make it again on retry. An
935    /// outbound `GET` or `HEAD` is a read. An unknown method counts as a write.
936    #[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// ── The results ──────────────────────────────────────────────────────────
954
955/// Why a call was refused.
956///
957/// A single machine-readable word, because a guest that can tell "you may not"
958/// from "not this time" can degrade correctly, and an author reading a denial
959/// needs to know which rule to fix.
960#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
961#[serde(rename_all = "kebab-case")]
962#[non_exhaustive]
963pub enum DenialReason {
964    /// The manifest does not grant the capability this call needs.
965    CapabilityNotGranted,
966    /// The capability is granted, but `[grants]` does not name this target.
967    NotInGrant,
968    /// A per-request quota is spent.
969    QuotaExceeded,
970    /// The call itself is malformed — an over-long key, an unparseable URL.
971    Malformed,
972    /// The host has no backend wired for this capability.
973    Unavailable,
974    /// The backend was reached and refused or failed.
975    BackendError,
976    /// An upstream answered, and its answer was over a byte ceiling.
977    ///
978    /// Distinct from [`QuotaExceeded`](Self::QuotaExceeded) because the two mean
979    /// opposite things to an operator: a quota denial means nothing left the
980    /// host, and this means the call *was made* and the answer was discarded.
981    /// The audit surface counts the host as called.
982    ResponseTooLarge,
983    /// The call ran, and the host refused the answer the backend gave.
984    ///
985    /// The write or enqueue may have happened, so a guest MUST NOT retry. The
986    /// audit surface counts the target as touched.
987    ResultRejected,
988}
989
990impl DenialReason {
991    /// The wire spelling.
992    #[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/// What the host hands back for one call.
1014///
1015/// `#[must_use]` because dropping it drops the guest's *answer*: the frame the
1016/// host owes it in return for the call it made.
1017#[must_use]
1018#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1019#[serde(tag = "status", rename_all = "kebab-case")]
1020#[non_exhaustive]
1021pub enum CallResult {
1022    /// The call succeeded.
1023    Ok {
1024        /// Correlation id, echoed.
1025        id: u64,
1026        /// The answer.
1027        value: CallValue,
1028    },
1029    /// The call was refused.
1030    Denied {
1031        /// Correlation id, echoed.
1032        id: u64,
1033        /// Which rule refused it.
1034        reason: DenialReason,
1035        /// One line for the author, already bounded and escaped.
1036        detail: String,
1037    },
1038}
1039
1040/// Characters of a denial detail that reach the guest and the log.
1041///
1042/// The host's own sentences are all far shorter. What this bounds is the half
1043/// it does not write: a `StoreError::Backend`, a `JobSink` error or an
1044/// `OutboundHttp` failure is a string from someone else's driver, and a driver
1045/// that echoes the query, the row or the upstream's body makes it as long as
1046/// whoever influenced that input cared to. Generous next to any real message
1047/// and far under the reply queue, so a denial can never be the thing that fails
1048/// the request it was explaining.
1049pub const MAX_DETAIL_CHARS: usize = 512;
1050
1051impl CallResult {
1052    /// Build a denial.
1053    ///
1054    /// Bounds and neutralises `detail` here rather than at each call site,
1055    /// because the field's contract says it is bounded and escaped and the
1056    /// interesting inputs are the ones the host did not write: a backend's
1057    /// error string is an embedder's driver talking, and it may carry as much
1058    /// attacker-influenced text as that driver chose to include. Serialized in
1059    /// full before the reply-queue ceiling is checked, an unbounded one would
1060    /// turn an explanation into the failure it was explaining.
1061    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            // Escaped as well as bounded: this reaches a `tracing` line and the
1074            // rendered operator summary, which a terminal interprets.
1075            detail: super::manifest::rejected(&kept),
1076        }
1077    }
1078
1079    /// The correlation id.
1080    #[must_use]
1081    pub const fn id(&self) -> u64 {
1082        match self {
1083            Self::Ok { id, .. } | Self::Denied { id, .. } => *id,
1084        }
1085    }
1086
1087    /// The denial reason, if this is one.
1088    #[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/// The answer to a successful call.
1098#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1099#[serde(tag = "kind", rename_all = "kebab-case")]
1100#[non_exhaustive]
1101pub enum CallValue {
1102    /// Nothing to say — a successful write or delete.
1103    Done,
1104    /// One optional scalar, from `kv-get`.
1105    Value {
1106        /// The value, or `null` for a miss. `found` distinguishes a stored
1107        /// `null` from a missing key, which a `null` alone could not.
1108        value: PluginValue,
1109        /// Whether the key existed.
1110        found: bool,
1111    },
1112    /// A row id, from `db-insert`.
1113    RowId {
1114        /// The id the store assigned.
1115        row_id: String,
1116    },
1117    /// Rows, from `db-get` and `db-query`.
1118    Rows {
1119        /// The rows, each carrying its `row_id` alongside its columns.
1120        rows: Vec<PluginRow>,
1121        /// Whether [`MAX_RESULT_BYTES`] stopped the answer short.
1122        ///
1123        /// On the wire so a guest can tell "that is all of them" from "that is
1124        /// all that fits". Without it a plugin paging through its own table
1125        /// would read a short answer as the end of the table and stop, which is
1126        /// silent data loss in the plugin rather than in the host.
1127        truncated: bool,
1128    },
1129    /// An HTTP response, from `http-fetch`.
1130    Http {
1131        /// The upstream status code.
1132        status: u16,
1133        /// Response headers the host allowed through.
1134        headers: Vec<(String, String)>,
1135        /// Response body, as text.
1136        body: String,
1137    },
1138    /// A job id, from `job-enqueue`.
1139    JobId {
1140        /// The id the queue assigned.
1141        job_id: String,
1142    },
1143}
1144
1145// ── The services ─────────────────────────────────────────────────────────
1146
1147/// The backends a host will honour a capability against.
1148///
1149/// Deliberately **not** `#[non_exhaustive]`, unlike the types in this module
1150/// that the host produces and a caller only reads: this one is built by the
1151/// embedder, and `CapabilityServices { kv: …, ..CapabilityServices::none() }` is
1152/// how. Marking it would make that spelling impossible outside this crate,
1153/// which is the only spelling there is.
1154///
1155/// Every field is optional, and a missing one is not a hole: a call whose
1156/// backend is absent is denied [`DenialReason::Unavailable`] and recorded, the
1157/// same as any other refusal. That is what lets `SandboxHost::run` — which has
1158/// no application state at all — keep working unchanged, and what lets a test
1159/// exercise one capability without standing up the other four.
1160#[derive(Clone, Default)]
1161pub struct CapabilityServices {
1162    /// The tenant every scoped capability binds to.
1163    ///
1164    /// `None` is the single-tenant application: keys and rows land in a
1165    /// namespace of their own rather than in some other tenant's.
1166    pub tenant: Option<String>,
1167    /// Backing store for `kv`.
1168    pub kv: Option<Arc<dyn KvStore>>,
1169    /// Client for `http-outbound`.
1170    pub http: Option<Arc<dyn OutboundHttp>>,
1171    /// Store for `db`.
1172    pub db: Option<Arc<dyn PluginStore>>,
1173    /// Queue for `jobs`.
1174    pub jobs: Option<Arc<dyn JobSink>>,
1175    /// The plugin's own calls-per-second ceiling, shared across its requests.
1176    ///
1177    /// `None` leaves only the per-request counters, which is the right default
1178    /// for a direct `SandboxHost::run` caller: a rate ceiling with no shared
1179    /// state is a ceiling that resets every call, which is worse than saying it
1180    /// is not there. `SandboxedPlugin` builds one per plugin.
1181    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    /// No backends at all — every capability call is `unavailable`.
1199    #[must_use]
1200    pub fn none() -> Self {
1201        Self::default()
1202    }
1203
1204    /// The same services, bound to `tenant`.
1205    #[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
1212// ── The runtime ──────────────────────────────────────────────────────────
1213
1214/// The tenant namespace segment used when the application is single-tenant.
1215///
1216/// A literal rather than an empty string, so a single-tenant deployment's keys
1217/// are as unambiguously namespaced as a multi-tenant one's — and so that a
1218/// tenant later appearing does not collide with the keys written before it.
1219///
1220/// **Never spellable by a real tenant.** A tenant id has no charset
1221/// restriction, so a literal any tenant could also be named would put a
1222/// deployment's tenant `_` in the same namespace as its single-tenant
1223/// requests — reachable in a tenancy migration, or with the plugin router
1224/// mounted outside the tenancy middleware. Presence is therefore encoded in the
1225/// segment itself: see [`tenant_segment`].
1226pub const NO_TENANT: &str = "-";
1227
1228/// Prefix marking a namespace segment that names a real tenant.
1229const TENANT_PRESENT: char = 't';
1230
1231/// The namespace segment for `tenant`, injective in `Option<&str>`.
1232///
1233/// A present tenant is `TENANT_PRESENT` followed by its id; absence is
1234/// [`NO_TENANT`], which no present tenant can produce because every one of them
1235/// starts with that prefix. This is the whole of "an absent tenant is not a
1236/// tenant named `_`", and it is a derivation rather than a check for the same
1237/// reason the physical table name is: the colliding spelling stops existing
1238/// instead of being refused.
1239#[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
1247/// Enforcement, once, for every capability.
1248///
1249/// Holds the manifest's grants and quotas, the tenant, the backends and the
1250/// ledger, so that "is this allowed" is answered in one place rather than in
1251/// five backends that each have to remember.
1252pub struct CapabilityRuntime {
1253    /// Read by each capability module to name itself in a derived key or a
1254    /// job record; never written after construction.
1255    pub(super) plugin: String,
1256    /// The sealed digest of the artifact this runtime serves, stamped on every
1257    /// job it enqueues.
1258    pub(super) artifact_sha256: String,
1259    capabilities: Vec<SandboxCapability>,
1260    /// Read by the outbound module to hand an implementation the host list it
1261    /// needs for a per-hop decision; never written after construction.
1262    pub(super) grants: super::grants::CapabilityGrants,
1263    /// The backends. `pub(super)` so each capability module reaches its own and
1264    /// no more; nothing outside this module tree can substitute one.
1265    pub(super) services: CapabilityServices,
1266    quotas: QuotaLedger,
1267    rate: Option<Arc<quota::CapabilityRateLimiter>>,
1268    events: Vec<Event>,
1269    /// Calls the ledger could not hold, and when, so a truncated summary says
1270    /// it is one.
1271    dropped: Option<DroppedCalls>,
1272    /// `(operation, outcome)` pairs already warned about this request.
1273    warned: Vec<(&'static str, Outcome)>,
1274}
1275
1276/// The most distinct `(operation, outcome)` pairs one request warns about.
1277///
1278/// Both sets are closed — ten operations, a handful of outcomes — so this is a
1279/// ceiling the vocabulary already implies rather than a policy. It exists so the
1280/// scan above stays bounded if either set grows.
1281const MAX_WARNED: usize = 64;
1282
1283/// Truncate a guest-chosen target to [`MAX_TARGET_CHARS`] *characters*.
1284///
1285/// By characters, before escaping: `rejected` expands a hostile character to as
1286/// many as ten bytes, so truncating afterwards would still have materialised the
1287/// expansion first.
1288fn 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
1298/// Characters of a guest-chosen target the audit ledger keeps.
1299///
1300/// Deliberately far below the 512 characters the manifest module's `rejected`
1301/// escaper allows. A ledger entry answers "which table / host / key", and no honest one
1302/// needs a paragraph — while `rejected` escapes a hostile character to as many
1303/// as ten bytes, so 512 characters is up to 5 KiB per entry and
1304/// [`MAX_EVENTS`] of those is megabytes the request footprint never budgeted.
1305pub const MAX_TARGET_CHARS: usize = 96;
1306
1307/// The most events one request's ledger holds.
1308///
1309/// Bounded for the same reason the denial ledger is: a guest that calls in a
1310/// loop must not be able to turn the evidence of it into the host's
1311/// memory-exhaustion channel. The per-request `calls` quota bounds the count
1312/// long before this does; this is the backstop for a build whose quota was
1313/// raised.
1314pub 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    /// Build the runtime one request will use.
1328    #[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    /// Stamp jobs with the sealed digest of the whole artifact.
1345    ///
1346    /// Defaults to the module digest, which is all a bare host has.
1347    #[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    /// The quotas this runtime enforces.
1354    #[must_use]
1355    pub const fn quotas(&self) -> &CapabilityQuotas {
1356        self.quotas.declared()
1357    }
1358
1359    /// The tenant every scoped call binds to, as the caller supplied it.
1360    ///
1361    /// `None` when the caller supplied none. That is right for a single-tenant
1362    /// application and **wrong** for a multi-tenant one whose plugin router was
1363    /// mounted outside the tenancy middleware: every tenant's keys and rows
1364    /// would pool into one namespace. Nothing inside the sandbox can tell those
1365    /// two apart — `CURRENT_TENANT` is simply absent in both — so the caller is
1366    /// the one that has to know, which is why
1367    /// [`CapabilityServices::for_tenant`] exists and why
1368    /// `SandboxedPlugin::serve` reads the task-local on the async task rather
1369    /// than inside `spawn_blocking`, where it is absent for a third reason.
1370    #[must_use]
1371    pub fn tenant(&self) -> Option<&str> {
1372        self.services.tenant.as_deref()
1373    }
1374
1375    /// The namespace segment every scoped derivation uses.
1376    ///
1377    /// [`tenant_segment`] of [`tenant`](Self::tenant): the *physical* tenant,
1378    /// the way [`physical_table`](db::physical_table) is the physical table.
1379    /// Never the raw id, because the raw id cannot say whether there was one.
1380    #[must_use]
1381    pub fn tenant_key(&self) -> String {
1382        tenant_segment(self.tenant())
1383    }
1384
1385    /// Everything this request did, in order.
1386    #[must_use]
1387    pub fn events(&self) -> &[Event] {
1388        &self.events
1389    }
1390
1391    /// Take the ledger, leaving it empty.
1392    #[must_use]
1393    pub fn take_events(&mut self) -> Vec<Event> {
1394        std::mem::take(&mut self.events)
1395    }
1396
1397    /// Calls this request made that the bounded ledger could not hold.
1398    ///
1399    /// Non-zero means the activity summary is a floor, not a count — which is
1400    /// the one thing an operator must not have to guess at.
1401    #[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    /// As [`dropped_events`](Self::dropped_events), with the times of the
1410    /// first and last dropped call. `None` when no call was dropped.
1411    ///
1412    /// Give it to [`PluginActivityLog::ingest_dropped_calls`]. The log then
1413    /// windows the drops by when they occurred, not by when the request ended.
1414    #[must_use]
1415    pub const fn dropped_calls(&self) -> Option<DroppedCalls> {
1416        self.dropped
1417    }
1418
1419    /// The bytes `call`'s reply may take at its worst, under this plugin's
1420    /// quotas. See [`CapabilityCall::reply_reserve_for`].
1421    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            // The body is at most `outbound_response_bytes`, and one byte
1427            // escapes to at most six. Smaller than `MAX_RESULT_BYTES` when the
1428            // plugin asks for small replies.
1429            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    /// Whether this plugin may use `capability` at all.
1438    ///
1439    /// `is_granted`, not `grants`, for the reason
1440    /// [`SandboxManifest::is_granted`](super::manifest::SandboxManifest::is_granted)
1441    /// carries the same name: `grants` is the field holding the scope lists.
1442    #[must_use]
1443    pub fn is_granted(&self, capability: SandboxCapability) -> bool {
1444        self.capabilities.contains(&capability)
1445    }
1446
1447    /// Answer one call.
1448    ///
1449    /// Never fails and never panics: everything a guest can get wrong comes back
1450    /// as a [`CallResult::Denied`] it can read.
1451    pub fn dispatch(&mut self, call: &CapabilityCall) -> CallResult {
1452        self.dispatch_within(call, usize::MAX)
1453    }
1454
1455    /// As [`dispatch`](Self::dispatch), for a caller that has `reply_room`
1456    /// bytes left to carry the answer.
1457    ///
1458    /// A call whose worst-case reply does not fit is refused before it runs.
1459    /// See [`CapabilityCall::reply_reserve`].
1460    pub fn dispatch_within(&mut self, call: &CapabilityCall, reply_room: usize) -> CallResult {
1461        self.dispatch_checked(call, reply_room, |_| true)
1462    }
1463
1464    /// As [`dispatch_within`](Self::dispatch_within). `fuel_covers` gets the
1465    /// worst-case reply in bytes. When it returns `false`, the call is refused
1466    /// before it runs.
1467    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        // What the call *names*, computed before any check, so every refusal
1476        // records what was reached for rather than a dash. The operator surface
1477        // is built from these, and "this plugin tried to read `users`" is the
1478        // half of the answer a validated-target-only ledger would drop.
1479        //
1480        // Bounded on the way in: `record` truncates and neutralises it, because
1481        // a KV key is a guest-chosen string that ends up in a log line and in
1482        // the rendered summary.
1483        let named = call.target_excerpt();
1484
1485        // The shared `calls` budget is charged first, for every dispatch —
1486        // including one that is about to be refused. A refusal is cheaper than
1487        // a call but it is not free: it costs a grant scan, an encoded reply, a
1488        // ledger entry and a log line, and leaving refusals unmetered let a
1489        // plugin with a generous fuel budget emit ~10^5 warn lines per request
1490        // against a `calls` quota of 128. Fuel alone is the wrong ceiling for
1491        // work whose cost is a log write.
1492        //
1493        // The *per-capability* counters stay where they are, below, so a call
1494        // refused before it could reach a backend does not spend the budget
1495        // that bounds backend work.
1496        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        // Then the capability, before the scope: a plugin that was never
1509        // granted `db` must learn *that*, not that some table is not in a list
1510        // it does not have.
1511        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        // Scope before the per-capability charge: an out-of-scope target never
1524        // reaches a backend, so spending the budget that bounds backend work on
1525        // it would let one manifest mistake starve the calls the plugin is
1526        // entitled to make.
1527        let target = match self.target_of(call) {
1528            Ok(target) => target,
1529            Err(result) => return self.record(call, &named, result),
1530        };
1531
1532        // Room for the answer, before anything is charged or committed: once a
1533        // write has happened its reply can no longer be refused.
1534        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        // Shape before the per-capability charge, for the same reason: a call
1561        // refused for its own malformed content never reaches a backend, and
1562        // charging `db_writes` for it would deny a valid call that follows.
1563        // The shared `calls` budget was already charged above.
1564        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        // The rate ceiling last of the three, and after the per-request charge:
1581        // it is the only one that depends on what *other* requests did, so a
1582        // plugin reading its denials sees "I asked too much" before "everyone
1583        // asked too much at once", which is the order the two are fixable in.
1584        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    /// The thing this call names, checked against the grant list.
1605    ///
1606    /// Returns the *logical* target — the same string
1607    /// [`CapabilityCall::target`] names, once it has been checked against the
1608    /// grant list. A physical name is derived later and is an implementation
1609    /// detail nobody auditing the plugin asked about.
1610    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                // The one identifier a guest chooses freely, so it is the one
1617                // with a shape rule of its own. Control characters are refused
1618                // because the key is printed in the audit surface an operator
1619                // reads.
1620                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    /// The call's shape and size, checked without copying it or charging a quota.
1683    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    /// Run the call against its backend, everything already checked.
1698    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    /// Record one call and hand the result back unchanged.
1714    ///
1715    /// Every exit from `dispatch` goes through here, so there is no path that
1716    /// answers the guest without also telling the operator.
1717    fn record(&mut self, call: &CapabilityCall, target: &str, result: CallResult) -> CallResult {
1718        // Bounded and neutralised once, here, rather than at each of the six
1719        // exits. A KV key is the one identifier a guest chooses freely, a table
1720        // or job type is bounded only by the wire, and from here both go into a
1721        // `tracing` line and the rendered operator summary — which a terminal
1722        // renders. `MAX_TARGET_CHARS` is far below what `rejected` would allow,
1723        // because a ledger this deep with 512-character entries is megabytes the
1724        // footprint never budgeted.
1725        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            // Already said, this request. Deduplicated by `(operation,
1745            // outcome)` exactly as `HostState::deny` deduplicates a refused
1746            // import, and for the same reason: a guest that calls in a loop
1747            // must not be able to turn the evidence of it into the operator's
1748            // log-storage problem. The ledger below still counts every one, so
1749            // nothing is lost — only repeated.
1750        } else {
1751            // Denials are warned, once, at the point they happen — whoever is
1752            // driving the host should not have to remember to log it.
1753            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            // Bounded by the vocabulary: at most one entry per (operation,
1762            // outcome) pair, and both sets are closed.
1763            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            // Counted rather than dropped silently. The ledger is bounded
1778            // because a guest chooses how many entries it makes, but an
1779            // operator reading "12 db-inserts" off a truncated ledger would be
1780            // reading a number the plugin chose. `MAX_EVENTS` is not
1781            // unreachable: the quotas that would bound it live in the plugin's
1782            // own manifest, so the plugin sets them.
1783            //
1784            // Keep the time too. The log ingests the request when it ends. A
1785            // slow request must not make an old drop look recent (#2523).
1786            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    /// A manifest granting everything, so each test can turn one thing off
1812    /// rather than assembling a manifest per case.
1813    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    // ── Results an embedder's backend hands back (issue #2523) ───────
1896
1897    /// A store that answers with whatever it was given, to stand in for an
1898    /// embedder's implementation that breaks the trait contract.
1899    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        // Between `MAX_ROW_BYTES` and `MAX_RESULT_BYTES`: `bounded_rows` let it
1962        // through although the host would refuse the same row on insert.
1963        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        // The row id is named, so the guest can `db-delete` the row.
1986        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        // Default `db_writes` is 32 and `calls` is 128: 40 malformed inserts
2076        // would have spent the first and denied the valid one that follows.
2077        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            // 3 names ("c0".."c2", "row_id" excluded from the count) + values.
2145            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        // A huge quota must not make every query undeliverable.
2202        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        // A page of one row reserves one row of framing.
2206        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        // A read reserves room too.
2226        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        // 40 refusals, `kv_writes` is 32: a valid write still succeeds.
2257        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    // ── Capability gating ────────────────────────────────────────────
2266
2267    #[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        // "What did this plugin do" includes what it *tried* to do. A ledger
2288        // that named only the targets that passed would answer the easy half.
2289        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        // And it is neutralised on the way in: the one identifier a guest
2318        // chooses freely ends up in a log line and in the operator summary.
2319        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        // The order of the checks is load-bearing: if a call to an ungranted
2336        // capability charged the shared `calls` quota first, a plugin could be
2337        // starved of the surface it *was* granted by one it was not.
2338        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        // The ledger is bounded, but nothing was ever charged: a granted call
2346        // on a fresh runtime with the same quotas still succeeds.
2347        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    // ── KV containment ───────────────────────────────────────────────
2361
2362    /// A `KvStore` that is down rather than empty.
2363    #[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        // `found: false` is an answer about the *data*; an outage is an answer
2383        // about the *backend*. Collapsing the second into the first tells a
2384        // plugin its cart is empty when the truth is that nobody could look,
2385        // and the audit ledger then records a read that never happened.
2386        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        // And a delete that did not happen must not read as `Done` either: a
2413        // plugin releasing a lock would carry on over a value still there.
2414        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        // The method was `to_ascii_uppercase`d before anything decided it was
2434        // a method, so a frame carrying a megabyte of "method" was copied for
2435        // the fold and again for the denial — ahead of the check that refuses
2436        // it, and outside `outbound_request_bytes`, which is measured later
2437        // and does not count the method. The match is now allocation-free and
2438        // length-first, so this is refused in constant time.
2439        // A client has to be wired, or the call is refused as `Unavailable`
2440        // before the method is looked at — which would have made this test pass
2441        // for the wrong reason. It caught exactly that on its first run.
2442        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        // And the match stays case-insensitive, which is what the fold was for.
2473        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        // The escape a naive `format!("{plugin}:{tenant}:{key}")` would allow:
2532        // tenant `a` asking for the key `b:cart` lands where tenant `b` writes
2533        // `cart`. Both segments are escaped, so the two keys stay distinct.
2534        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        // Tenant ids are the application's, not the manifest's: they come from
2572        // a header or a subdomain, and nothing validates their charset.
2573        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        // A tenant id has no charset restriction, so the single-tenant sentinel
2594        // must be a segment no tenant could also be. It used to be `_`, which a
2595        // deployment can name a tenant — and then that tenant shared a
2596        // namespace with every request that arrived without one, reachable in a
2597        // tenancy migration or with the router mounted outside the tenancy
2598        // middleware.
2599        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        // And distinct tenants stay distinct, which is the property the marker
2612        // must not have broken.
2613        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        // Lowering `kv_value_bytes` is meant to reduce authority, but the store
2619        // keeps what the old ceiling allowed. Serving it anyway would make the
2620        // tightened quota cosmetic, and a large enough value would overrun the
2621        // reply queue and fail the request rather than being refused.
2622        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        // `validated_row` strips `row_id` on a write, which is right: a row read
2652        // back carries its id and echoing it means nothing. On a *filter* the
2653        // same strip turns "the row with this id" into "every row this tenant
2654        // has" — narrowing that silently widens, which is the one direction a
2655        // containment bug can go and still look like it worked.
2656        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        // A ceiling the guest is told about and cannot act on is worse than no
2678        // ceiling: `truncated` would say "there is more" while the same filter
2679        // returned the same prefix forever.
2680        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        // Disjoint, which is the whole property: a cursor that returned the
2718        // same rows again would page forever without advancing.
2719        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        // The documented pattern: read a row, change a field, send it back. The
2727        // row comes back carrying the `row_id` the host added, so a row stored
2728        // at exactly `MAX_ROW_COLUMNS` arrived at `db-update` with one column
2729        // too many — refused for a column the guest never wrote. The ceilings
2730        // must be a function of what the plugin stored, not of where the row
2731        // had been.
2732        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        // `truncated` is the guest's only signal that more rows exist. A page
2791        // cut by `db_rows` looked complete, and with `limit: 0` the guest is
2792        // never told the quota — so a full page and a finished table were
2793        // indistinguishable, and the cursor documented for continuing was never
2794        // reached.
2795        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        // The round-trip this asserts was already fixed once, at the dispatch
2838        // layer, and the parse-time column bound reintroduced it one layer
2839        // earlier — where the existing test could not see it, because that one
2840        // builds a `CapabilityCall` directly instead of parsing a frame.
2841        //
2842        // `db-get` returns the row plus the host's `row_id`, so a row stored at
2843        // exactly `MAX_ROW_COLUMNS` comes back with one more entry than a row
2844        // may have, and sending it to `db-update` is the documented workflow.
2845        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        // And one genuine column past the ceiling is still refused while read.
2860        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        // The branches were bounded first and the root was left open, which
2877        // made bounding the branches pointless: `render` applies `MAX_NODES` to
2878        // a tree serde has already built, and the host then clones it.
2879        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        // Exactly at the ceiling still parses.
2889        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        // The distinction this asserts is invisible from the outside if you
2903        // only check that an oversized frame is refused: the *old* code also
2904        // refused it, having first allocated the whole thing. What is being
2905        // tested is that the refusal comes from the parser, so nothing larger
2906        // than the ceiling is ever held.
2907        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        // And exactly at the ceiling still parses, so the bound is not off by
2922        // one in the direction that breaks a legal call.
2923        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    // ── Outbound containment ─────────────────────────────────────────
2937
2938    #[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        // Checking the URL the guest wrote bounds the first hop and nothing
3012        // else. A client that follows redirects — which most do by default —
3013        // would otherwise send the body to a host the manifest never named and
3014        // record one allowed call to the granted one.
3015        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        // …and the operator sees where it ended up, not just that something
3033        // was refused.
3034        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        // A response discarded for being over a byte ceiling still *left the
3073        // host*. Filing it under "refused" would make the audit surface
3074        // undercount exactly the calls an operator most wants to see.
3075        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        // The grant is about where the bytes go, and the name decides that.
3108        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        // A fresh fixture per case, so each refusal is read on its own.
3121        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        // `MAX_VALUE_TEXT_BYTES` bounds one column and 32 of them bounds a row
3154        // at 2 MiB — twice what one reply may be. Accepting such a row would
3155        // store something `db-get` could never hand back, so the ceiling that
3156        // makes a read answerable belongs on the write.
3157        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        // The finding: `db_rows` defaults to 500 and a row may be
3182        // `MAX_ROW_BYTES`, so a granted plugin could make the host materialise,
3183        // encode and hold a hundred megabytes per request against a quota its
3184        // own manifest sets. The budget now reaches the store, and the guest is
3185        // told its answer was cut rather than reading a short page as the end
3186        // of the table.
3187        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            // Three, not four: four maxed-out columns plus the names is over
3192            // `MAX_ROW_BYTES`, and a row that cannot be stored cannot
3193            // demonstrate an answer that is too big to return.
3194            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        // The allow-list says which response headers come back and nothing
3232        // about their size, and every one of them is the *upstream's* choice
3233        // rather than the plugin's or the host's. A legal body plus megabytes
3234        // of `etag` is an oversized allocation the host makes and then fails
3235        // the plugin's request over.
3236        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        // The quota table bounded what an upstream may *return* and said nothing
3271        // about what the plugin may *send*. A body is guest-chosen, so its only
3272        // bound was the stdout frame it arrived in — megabytes by default. An
3273        // operator granting a hostname agreed to calls, not to unbounded egress
3274        // through them.
3275        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        // Refused before dispatch: nothing reached the backend.
3295        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        // Bounding the body alone left the quota sidesteppable: the allow-list
3304        // caps how *many* headers may be set and says nothing about their size,
3305        // so one `user-agent` could carry what a body was just refused for.
3306        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        // A key count is not a memory bound: the default capacity at the
3334        // default `kv_value_bytes` is hundreds of megabytes, and gigabytes at
3335        // the largest legal quota.
3336        let store = MemoryKvStore::with_capacities(10_000, 4096);
3337        // Well inside the key ceiling, well past the byte one.
3338        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        // A replacement is checked too: no new key, but more bytes.
3355        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        // The same defect in three places, so it is asserted in one test: a
3366        // count ceiling times the largest legal entry is gigabytes for the row
3367        // store, hundreds of megabytes for the job queue. Whatever the count
3368        // allows, the byte ceiling has to stop first when entries are large.
3369        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        // Replacement is the path that adds no entry and still grows the store,
3389        // so every backend that has one is asserted here rather than left to be
3390        // found a round later: `db-update` was bounded only on insert, while
3391        // `kv-set` already accounted for it.
3392        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    // ── DB containment ───────────────────────────────────────────────
3432
3433    #[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        // The physical row landed under the derived name, never under a name
3450        // the guest chose.
3451        // Derived, never spelled: a literal here is a test that silently stops
3452        // testing anything the moment the derivation changes.
3453        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        // Third pass over this ground, and the one that makes the count mean
3505        // what it says. Bounding each `children` vector at `MAX_NODES`
3506        // separately still admitted many such vectors, so a frame well inside
3507        // the stdout budget deserialized far more than `MAX_NODES` nodes and
3508        // was refused only afterwards, on the aggregate, by `render` — after
3509        // `serde_json` had built the whole tree.
3510        //
3511        // Wide and shallow on purpose. Deep nesting is refused by
3512        // `serde_json`'s own recursion limit long before any of this is
3513        // reached, so a deep tree would pass whether or not the budget is
3514        // shared — which is exactly what the first version of this test did.
3515        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        // 128 branches of 9 nodes each: every vector is inside `MAX_NODES`, and
3520        // the tree is over four times it.
3521        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        // A tree inside the ceiling still parses, so the budget is not simply
3534        // refusing anything with children.
3535        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        // Twice on the same thread: the budget is thread-local, so the failed
3548        // parse above must not have left this one poorer.
3549        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        // `row_weight` counts names and values, not the quotes, colons and
3561        // commas around them. Many narrow rows then encoded well past
3562        // `MAX_RESULT_BYTES`, and a reply the reserve had promised to fit was
3563        // refused after the read.
3564        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        // Text that grows when escaped: a control character takes six bytes.
3578        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        // A flat eight bytes for every non-text scalar was wrong in the one
3592        // direction a ceiling may not be wrong in: `i64::MIN` encodes as
3593        // twenty, so a `kv_value_bytes` of eight admitted a value the operator
3594        // forbade — on the write, and again on the read, which is checked
3595        // against the same number.
3596        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        // Against the encoder rather than against arithmetic, so the two cannot
3607        // drift: no scalar may weigh less than it serializes to.
3608        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        // `bounded_rows` dropped its "keep the first one" exemption a while
3632        // back; the store's own query loop kept its copy of the same
3633        // exemption, so an oversized row was cloned in full under the store's
3634        // mutex on every query that matched it and only then discarded a layer
3635        // up. The clone was the cost, and a guest could repeat it at will.
3636        let store = db::MemoryPluginStore::new();
3637        let orders = db::physical_table("shop", "orders").expect("derivable");
3638        let tenant = tenant_segment(Some("alpha"));
3639        // Seeded past what `check_row` would accept, which is the only way such
3640        // a row exists at all.
3641        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        // `seed` places a row at an id of the caller's choosing and cannot
3669        // advance the insert counter, because that id need not be a number. So
3670        // a store seeded with `r1` handed `r1` back out on its first insert:
3671        // `HashMap::insert` replaced the seeded row while both weights had been
3672        // charged, losing a row with no error and leaving bytes charged against
3673        // capacity that nothing occupies.
3674        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        // Both rows must still be there. The seeded one disappearing is the
3700        // defect; counting them is what proves it did not.
3701        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        // Seeded directly: a host-application table, and another tenant's row
3717        // in the plugin's own table.
3718        store.seed(
3719            "users",
3720            &tenant_segment(Some("alpha")),
3721            "u1",
3722            row(&[("email", "ceo@example.com")]),
3723        );
3724        // Derived, not hard-coded: a literal here turns the whole test vacuous
3725        // the moment the derivation changes, because beta's row would land in a
3726        // table alpha was never going to reach anyway.
3727        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            // Each call gets the *specific* answer it should, rather than
3784            // "anything except a populated row": a loop that accepts a denial
3785            // or an empty read for every operation passes even if `db-delete`
3786            // started returning `Done` after deleting beta's row.
3787            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        // Untouched, in every sense.
3809        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        // `db-get` stamps `row_id` onto the row it hands back, so the obvious
3836        // read-modify-write echoes it. Refusing that would make the obvious
3837        // code the wrong code; the id is the row's address and travels in its
3838        // own field, so a value for it in the row conveys nothing and can
3839        // override nothing. `tenant_id` stays a hard refusal above, because a
3840        // row that could set *that* would choose its own tenant.
3841        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        // …and that same row writes straight back.
3876        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        // The collision that matters is not punctuation, it is the *separator*:
3888        // a hostile author picks a plugin name that shifts the boundary onto a
3889        // victim's table. Both manifests validate, both consent screens are
3890        // truthful, and `AppBuilder` sees two distinct plugin names.
3891        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        // Punctuation and case are the same bug one variation along.
3897        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        // And the derived name is still a bare, unquoted SQL identifier.
3909        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        // `MAX_EVENTS` is reachable: the quotas that would bound it live in the
3923        // plugin's own manifest, so the plugin sets them. An operator reading
3924        // "12 db-inserts" off a truncated ledger would be reading a number the
3925        // plugin chose.
3926        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        // #2523 finding 18. The log ingests a request when it ends. A slow
3958        // request must not make its old drops look recent.
3959        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        // Five more drops 30 minutes later.
3991        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        // The request ends 90 minutes after that.
3997        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        // The window holds the last drop, not the first.
4006        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        // The default quota escapes past `MAX_RESULT_BYTES`, so that bound holds.
4020        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            // An unknown method is refused later. Here it counts as a write.
4076            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        // `rejected` expands a hostile character to as many as ten bytes, so
4121        // the bound has to be on characters and has to be applied first.
4122        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        // A plugin declares its own `kv_writes` and `db_writes`, and both may
4132        // legally be `MAX_QUOTA`. A shipped store with no ceiling of its own
4133        // turns that into the host's memory.
4134        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        // Overwriting an existing key adds nothing, so it is never refused.
4139        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        // A plugin name's charset is wider than SQL's, so it is escaped
4179        // injectively rather than folded: `.` is `_2e` and `-` is `_2d`, and
4180        // `__` — which no escape can produce — is the separator.
4181        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            // Long, but each half is legal: the *derived* name is what cannot
4194            // fit, and `SandboxManifest::validate` refuses this at load so an
4195            // operator never approves a capability that can only be denied.
4196            (&"p".repeat(40), &"t".repeat(30)),
4197        ] {
4198            assert_eq!(db::physical_table(plugin, table), None, "{plugin}/{table}");
4199        }
4200    }
4201
4202    // ── Jobs ─────────────────────────────────────────────────────────
4203
4204    #[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    // ── Quotas ───────────────────────────────────────────────────────
4244
4245    #[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        // A different capability is untouched: quotas are per surface, so
4263        // running out of reads does not cost the plugin its ability to enqueue.
4264        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        // The fourth call is refused even though `job_enqueues` is untouched.
4334        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        // One token per second, not two: the assertion below needs the bucket
4350        // to still be empty when it runs, and a `per_second` of 2 gave it only
4351        // 500 ms of slack — enough for a loaded debug-build runner to refill
4352        // one token between the take and the check and flip this green to red.
4353        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        // A *second request* of the same plugin shares the bucket.
4371        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        // A different capability has its own bucket, so a busy cache does not
4382        // cost the plugin its ability to enqueue.
4383        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        // The response side of the outbound allow-list. Nothing exercised it,
4442        // so deleting the filter left the suite green — and `set-cookie` from
4443        // an upstream is a cookie the *plugin* then learns.
4444        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        // A fresh fixture per case, so each refusal is read on its own.
4481        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        // Four ceilings that had no test between them, so four `if` statements
4516        // nothing reached.
4517        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        // `kv_value_bytes`.
4539        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        // `db_rows`, both as the clamp on an explicit limit and as the default
4551        // for `limit: 0`.
4552        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        // `outbound_response_bytes`.
4579        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        // `MemoryJobSink::bounded` existed with no caller — test-support API
4616        // written for a test that was never written, which is the same thing as
4617        // an untested `BackendError` arm.
4618        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        // `MemoryJobSink::new()` used to be unbounded, and this slice ships no
4643        // consumer that removes a queued job — so an application that wired the
4644        // exported default gave a granted plugin a way to grow the host without
4645        // limit. The quotas slow that and never stop it. There is no unbounded
4646        // spelling any more; a default that must be tightened to be safe is a
4647        // default that will be shipped as it is.
4648        //
4649        // Driven against the sink rather than through `dispatch`: the per-request
4650        // quotas would refuse long before the queue filled, and it is the queue
4651        // that outlives the request.
4652        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    // ── Audit ────────────────────────────────────────────────────────
4669
4670    #[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}