Skip to main content

uni_plugin/
capability.rs

1//! Plugin capabilities — declared in manifest, granted at load time.
2//!
3//! A `Capability` is the unit of permission in the plugin framework. Every
4//! extension surface (`Capability::ScalarFn`, `Capability::Storage`, …) is
5//! gated by a capability; every host import that exposes powerful primitives
6//! (network, filesystem, secrets, host-side query) is gated by an attenuated
7//! capability (`Capability::Network { allow }`).
8//!
9//! Enforcement happens in three layers:
10//!
11//! 1. **Registrar gate** — `PluginRegistrar::scalar_fn` etc. check the
12//!    effective capability set before accepting a registration.
13//! 2. **WIT linker** — for WASM plugins, host imports for capability-gated
14//!    functions are linked into the wasmtime `Linker` only when the
15//!    corresponding capability is granted. Ungranted host functions are
16//!    not present in the plugin's imports table.
17//! 3. **Runtime pattern checks** — capability grants with patterns
18//!    (`Filesystem { read: vec!["/data/**"] }`) validate the actual call
19//!    arguments against the pattern before dispatching.
20
21use std::collections::BTreeSet;
22
23use serde::{Deserialize, Serialize};
24use smol_str::SmolStr;
25
26/// A single permission grant.
27///
28/// `Capability` is the leaf node of the permission model. A
29/// [`CapabilitySet`] is a collection of capabilities.
30#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
31#[serde(tag = "kind", rename_all = "kebab-case")]
32#[non_exhaustive]
33pub enum Capability {
34    // ---- Host import surfaces (capability-gated host functions) ----
35    /// HTTP / TCP egress; allow-list of URI patterns.
36    Network {
37        /// Glob patterns of permitted URIs (`https://api.example/**`). Defaults
38        /// to empty (deny-all) so a bare `"network"` declaration grants no
39        /// egress until patterns are specified.
40        #[serde(default)]
41        allow: Vec<SmolStr>,
42    },
43    /// Filesystem read / write access with per-direction path patterns.
44    Filesystem {
45        /// Glob patterns of readable paths (empty = deny-all).
46        #[serde(default)]
47        read: Vec<SmolStr>,
48        /// Glob patterns of writable paths (empty = deny-all).
49        #[serde(default)]
50        write: Vec<SmolStr>,
51    },
52    /// Invoking Cypher / Locy queries back into the host session.
53    HostQuery {
54        /// If `true`, only read queries are permitted.
55        #[serde(default)]
56        read_only: bool,
57        /// Optional scope-restriction (label / edge-type prefixes).
58        #[serde(default)]
59        scopes: Vec<SmolStr>,
60    },
61    /// KMS access for sign / verify operations.
62    Kms {
63        /// Permitted key identifiers (empty = deny-all).
64        #[serde(default)]
65        key_ids: Vec<SmolStr>,
66    },
67    /// Acquiring named secret handles (opaque to the plugin).
68    Secret {
69        /// Permitted secret identifiers (empty = deny-all).
70        #[serde(default)]
71        ids: Vec<SmolStr>,
72    },
73    /// Explicit lock primitives (`host.lock_nodes`, `host.lock_edges`).
74    Lock {
75        /// Granularity of locks permitted.
76        granularity: LockGranularity,
77    },
78    /// Scoped configuration K/V access (`host.config_get`).
79    Config {
80        /// Patterns of permitted config keys (empty = deny-all).
81        #[serde(default)]
82        keys: Vec<SmolStr>,
83    },
84    /// Per-plugin K/V store (scoped namespace).
85    PluginStorage,
86
87    // ---- Extension surfaces (gate Registrar methods) ----
88    /// Register Cypher scalar functions.
89    ScalarFn,
90    /// Register Cypher aggregate functions.
91    AggregateFn,
92    /// Register Cypher window functions.
93    WindowFn,
94    /// Register Cypher procedures (read-only mode).
95    Procedure,
96    /// Register procedures that may mutate the graph.
97    ProcedureWrites,
98    /// Register procedures that may issue DDL.
99    ProcedureSchema,
100    /// Register administrative procedures.
101    ProcedureDbms,
102    /// Register Locy aggregate functions.
103    LocyAggregate,
104    /// Register Locy predicates (including neural).
105    LocyPredicate,
106    /// Register Locy generator predicates (table-valued, 1:N).
107    LocyGenerator,
108    /// Register physical operators / optimizer rules.
109    Operator,
110    /// Register index kinds.
111    Index,
112    /// Register storage backends by URI scheme.
113    Storage,
114    /// Register graph algorithms.
115    Algorithm,
116    /// Drive the GraphCompute coarse-kernel catalog from a guest algorithm.
117    ///
118    /// Gates the kernel surface (`graph-compute@1`). Orthogonal to
119    /// [`Capability::HostQuery`], which additionally gates the data-read
120    /// `project` kernel: a guest algorithm needs both to project a graph, but
121    /// only `GraphCompute` to run kernels over an already-projected handle
122    /// (GraphCompute proposal §4.6).
123    GraphCompute,
124    /// Register CRDT kinds.
125    Crdt,
126    /// Register session / query lifecycle hooks.
127    Hook,
128    /// Register fine-grained mutation triggers.
129    Trigger,
130    /// Register background / scheduled jobs.
131    BackgroundJob {
132        /// Maximum concurrent invocations of this plugin's jobs.
133        max_concurrent: u32,
134    },
135    /// Register logical (Arrow extension) types.
136    Type,
137    /// Register authentication providers.
138    Auth,
139    /// Register authorization policies.
140    Authz,
141    /// Register collations (sort orders).
142    Collation,
143    /// Register CDC output sinks.
144    Cdc,
145    /// Register catalogs / virtual schemas.
146    Catalog,
147    /// Authority to call meta-procedures (`uni.plugin.declare*`).
148    PluginDeclare,
149
150    // ---- Resource quotas ----
151    /// Maximum wasm linear memory per instance.
152    MemoryBytes(u64),
153    /// Maximum wasmtime fuel per call.
154    FuelPerCall(u64),
155    /// Maximum wall-clock milliseconds per call.
156    WallClockMillisPerCall(u64),
157    /// Maximum concurrent instances in the wasm pool.
158    ConcurrentInstances(u32),
159    /// Maximum total memory across all instances.
160    TotalMemoryBytes(u64),
161    /// Cap on rows yielded by a procedure.
162    MaxResultRows(u64),
163    /// Cap on GraphCompute native-work units per invocation (proposal §12).
164    GraphComputeWork(u64),
165    /// Cap on GraphCompute handle-arena bytes per invocation (proposal §12).
166    GraphComputeArenaBytes(u64),
167}
168
169/// Granularity of lock-capability grants.
170#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
171#[serde(rename_all = "kebab-case")]
172#[non_exhaustive]
173pub enum LockGranularity {
174    /// Per-node locks only.
175    Nodes,
176    /// Per-edge locks only.
177    Edges,
178    /// Both nodes and edges.
179    Both,
180    /// Global (graph-wide) locks.
181    Global,
182}
183
184/// A set of capabilities — declared by manifest, granted by loader.
185///
186/// The *effective* capability set is the intersection of declared and
187/// granted. Registrations attempted without the corresponding capability in
188/// the effective set fail with [`crate::PluginError::CapabilityRequired`].
189#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(transparent)]
191pub struct CapabilitySet {
192    set: BTreeSet<Capability>,
193}
194
195impl CapabilitySet {
196    /// Construct an empty capability set.
197    #[must_use]
198    pub fn new() -> Self {
199        Self::default()
200    }
201
202    /// Construct a capability set from an iterable.
203    #[must_use]
204    pub fn from_iter_of(caps: impl IntoIterator<Item = Capability>) -> Self {
205        Self {
206            set: caps.into_iter().collect(),
207        }
208    }
209
210    /// Construct a capability set from guest-manifest declarations, each of
211    /// which may be a bare name or a structured [`ManifestCapability`].
212    #[must_use]
213    pub fn from_manifest(caps: impl IntoIterator<Item = ManifestCapability>) -> Self {
214        Self::from_iter_of(caps.into_iter().map(|m| m.0))
215    }
216
217    /// Insert a capability; returns `true` if the capability was not already present.
218    pub fn insert(&mut self, cap: Capability) -> bool {
219        self.set.insert(cap)
220    }
221
222    /// Check whether the set contains the given capability (exact equality).
223    #[must_use]
224    pub fn contains(&self, cap: &Capability) -> bool {
225        self.set.contains(cap)
226    }
227
228    /// Check whether the set contains a registration-gating capability.
229    ///
230    /// Match is on the *variant* — `contains_variant(Capability::ScalarFn)`
231    /// returns `true` regardless of any associated data on other variants.
232    /// Useful for registrar gates like "any `BackgroundJob { max_concurrent }`
233    /// is sufficient regardless of the cap."
234    #[must_use]
235    pub fn contains_variant(&self, target: &Capability) -> bool {
236        self.set.iter().any(|c| variant_matches(c, target))
237    }
238
239    /// Intersect this (guest-declared) set with the host-granted `other`,
240    /// returning the effective capability set.
241    ///
242    /// Loaders call `declared.intersect(grants)`, so `self` is the guest
243    /// manifest and `other` is the host ceiling. A guest capability survives
244    /// only if the host grants the same variant, and its **payload is attenuated
245    /// against the host**: for the allow-list variants (`Network`,
246    /// `Filesystem`, `Kms`, `Secret`, `Config`) and `HostQuery`, the effective
247    /// grant permits a resource only if *both* the guest and the host permit it
248    /// — the host is a true ceiling a guest cannot widen. Non-payload variants
249    /// (registration gates, resource quotas) retain the guest value as before.
250    #[must_use]
251    pub fn intersect(&self, other: &Self) -> Self {
252        let mut out = Self::new();
253        for c in &self.set {
254            if other.contains_variant(c) {
255                out.insert(attenuate_to_host(c, other));
256            }
257        }
258        out
259    }
260
261    /// Capabilities this (guest-declared) set requested but the host withheld.
262    ///
263    /// Membership is tested by *variant* against the post-attenuation
264    /// `effective` set: a payload capability that survived
265    /// [`CapabilitySet::intersect`] with a narrowed allow-list (e.g. a
266    /// `HostQuery` whose `scopes` the host tightened) is **granted, not denied**,
267    /// even though its effective payload differs from what was declared. Exact
268    /// equality against the raw grant would misreport such a cap as denied; this
269    /// helper — shared by every loader — is the single correct derivation.
270    ///
271    /// # Examples
272    ///
273    /// ```
274    /// use uni_plugin::{Capability, CapabilitySet};
275    ///
276    /// let declared = CapabilitySet::from_iter_of([
277    ///     Capability::ScalarFn,
278    ///     Capability::Algorithm,
279    /// ]);
280    /// let granted = CapabilitySet::from_iter_of([Capability::ScalarFn]);
281    /// let effective = declared.intersect(&granted);
282    /// assert_eq!(declared.denied_against(&effective), vec![Capability::Algorithm]);
283    /// ```
284    #[must_use]
285    pub fn denied_against(&self, effective: &CapabilitySet) -> Vec<Capability> {
286        self.set
287            .iter()
288            .filter(|c| !effective.contains_variant(c))
289            .cloned()
290            .collect()
291    }
292
293    /// Returns an iterator over the contained capabilities.
294    pub fn iter(&self) -> impl Iterator<Item = &Capability> {
295        self.set.iter()
296    }
297
298    /// Returns the number of distinct capabilities in the set.
299    #[must_use]
300    pub fn len(&self) -> usize {
301        self.set.len()
302    }
303
304    /// Returns `true` if the set is empty.
305    #[must_use]
306    pub fn is_empty(&self) -> bool {
307        self.set.is_empty()
308    }
309}
310
311fn variant_matches(a: &Capability, b: &Capability) -> bool {
312    std::mem::discriminant(a) == std::mem::discriminant(b)
313}
314
315/// Attenuate a guest capability against the host grant (the ceiling).
316///
317/// For the allow-list payload variants and `HostQuery`, returns a capability
318/// whose effective grant is the conjunction of guest and host; for every other
319/// variant, returns the guest capability unchanged (registration gates and
320/// quotas have no allow-list to narrow). See [`CapabilitySet::intersect`].
321fn attenuate_to_host(guest: &Capability, host: &CapabilitySet) -> Capability {
322    match guest {
323        Capability::Network { allow } => Capability::Network {
324            allow: intersect_globs(allow, &host_lists(host, network_allow)),
325        },
326        Capability::Filesystem { read, write } => Capability::Filesystem {
327            read: intersect_globs(read, &host_lists(host, fs_read)),
328            write: intersect_globs(write, &host_lists(host, fs_write)),
329        },
330        Capability::Kms { key_ids } => Capability::Kms {
331            key_ids: intersect_globs(key_ids, &host_lists(host, kms_ids)),
332        },
333        Capability::Secret { ids } => Capability::Secret {
334            ids: intersect_globs(ids, &host_lists(host, secret_ids)),
335        },
336        Capability::Config { keys } => Capability::Config {
337            keys: intersect_globs(keys, &host_lists(host, config_keys)),
338        },
339        Capability::HostQuery { read_only, scopes } => {
340            // `read_only` is restrictive-true: either side may force read-only.
341            // `scopes` empty means "unrestricted", so an empty list on a side
342            // imposes no narrowing (unlike the deny-on-empty allow-lists above).
343            let host_read_only = host.set.iter().any(|c| {
344                matches!(
345                    c,
346                    Capability::HostQuery {
347                        read_only: true,
348                        ..
349                    }
350                )
351            });
352            let host_scopes = host_lists(host, host_query_scopes);
353            let scopes = if scopes.is_empty() {
354                host_scopes
355            } else if host_scopes.is_empty() {
356                scopes.clone()
357            } else {
358                intersect_globs(scopes, &host_scopes)
359            };
360            Capability::HostQuery {
361                read_only: *read_only || host_read_only,
362                scopes,
363            }
364        }
365        // Registration gates and resource quotas carry no allow-list to narrow.
366        other => other.clone(),
367    }
368}
369
370// Per-variant payload extractors used to gather the host ceiling. Each returns
371// the allow-list for capabilities of its variant, `None` otherwise.
372fn network_allow(c: &Capability) -> Option<&[SmolStr]> {
373    match c {
374        Capability::Network { allow } => Some(allow),
375        _ => None,
376    }
377}
378fn fs_read(c: &Capability) -> Option<&[SmolStr]> {
379    match c {
380        Capability::Filesystem { read, .. } => Some(read),
381        _ => None,
382    }
383}
384fn fs_write(c: &Capability) -> Option<&[SmolStr]> {
385    match c {
386        Capability::Filesystem { write, .. } => Some(write),
387        _ => None,
388    }
389}
390fn kms_ids(c: &Capability) -> Option<&[SmolStr]> {
391    match c {
392        Capability::Kms { key_ids } => Some(key_ids),
393        _ => None,
394    }
395}
396fn secret_ids(c: &Capability) -> Option<&[SmolStr]> {
397    match c {
398        Capability::Secret { ids } => Some(ids),
399        _ => None,
400    }
401}
402fn config_keys(c: &Capability) -> Option<&[SmolStr]> {
403    match c {
404        Capability::Config { keys } => Some(keys),
405        _ => None,
406    }
407}
408fn host_query_scopes(c: &Capability) -> Option<&[SmolStr]> {
409    match c {
410        Capability::HostQuery { scopes, .. } => Some(scopes),
411        _ => None,
412    }
413}
414
415/// Union the allow-lists of every host capability matching `extract`'s variant.
416fn host_lists<'a>(
417    host: &'a CapabilitySet,
418    extract: impl Fn(&'a Capability) -> Option<&'a [SmolStr]>,
419) -> Vec<SmolStr> {
420    host.set
421        .iter()
422        .filter_map(extract)
423        .flatten()
424        .cloned()
425        .collect()
426}
427
428/// Intersect two glob allow-lists with each side acting as a ceiling on the
429/// other.
430///
431/// A pattern is kept only when some pattern in the opposite list *subsumes* it
432/// (`wildcard_match(other_pattern, pattern)`), so the result permits a resource
433/// only if both inputs would. Incomparable patterns are dropped (deny — the
434/// safe direction). This is sound for the prefix-glob patterns capability
435/// allow-lists use; it can under-grant only for exotic overlapping-but-
436/// incomparable globs, never over-grant. An empty input yields an empty result
437/// (deny-all), matching the allow-list "empty = deny" convention.
438fn intersect_globs(a: &[SmolStr], b: &[SmolStr]) -> Vec<SmolStr> {
439    let mut out: Vec<SmolStr> = Vec::new();
440    let mut keep = |pat: &SmolStr, ceiling: &[SmolStr]| {
441        if ceiling.iter().any(|q| wildcard_match(q, pat)) && !out.contains(pat) {
442            out.push(pat.clone());
443        }
444    };
445    for pat in a {
446        keep(pat, b);
447    }
448    for pat in b {
449        keep(pat, a);
450    }
451    out
452}
453
454impl Capability {
455    /// True if this is a [`Capability::Network`] grant whose allow-list
456    /// matches `url`.
457    ///
458    /// Used for layer-3 (call-time) attenuation of `uni.http.*` host fns: a
459    /// granted `Network { allow }` only permits URLs matching one of its
460    /// patterns. Non-`Network` capabilities never match.
461    #[must_use]
462    pub fn network_allows(&self, url: &str) -> bool {
463        matches!(self, Capability::Network { allow } if allow.iter().any(|p| wildcard_match(p, url)))
464    }
465
466    /// True if this is a [`Capability::Kms`] grant permitting `key_id`.
467    #[must_use]
468    pub fn kms_allows(&self, key_id: &str) -> bool {
469        matches!(self, Capability::Kms { key_ids } if key_ids.iter().any(|p| wildcard_match(p, key_id)))
470    }
471
472    /// True if this is a [`Capability::Secret`] grant permitting `id`.
473    #[must_use]
474    pub fn secret_allows(&self, id: &str) -> bool {
475        matches!(self, Capability::Secret { ids } if ids.iter().any(|p| wildcard_match(p, id)))
476    }
477
478    /// True if this is a [`Capability::Filesystem`] grant whose `read`
479    /// allow-list matches `path`.
480    ///
481    /// Patterns are matched with `wildcard_match` (path-opaque — `*` and `**`
482    /// both span `/`), which suits the `/data/**`-style grants in use.
483    #[must_use]
484    pub fn filesystem_read_allows(&self, path: &str) -> bool {
485        matches!(self, Capability::Filesystem { read, .. } if read.iter().any(|p| wildcard_match(p, path)))
486    }
487
488    /// True if this is a [`Capability::Filesystem`] grant whose `write`
489    /// allow-list matches `path`.
490    #[must_use]
491    pub fn filesystem_write_allows(&self, path: &str) -> bool {
492        matches!(self, Capability::Filesystem { write, .. } if write.iter().any(|p| wildcard_match(p, path)))
493    }
494}
495
496// ---- Grant-string parsing (single source of truth) ----------------------------
497//
498// Host-facing grant APIs (the Python binding, the CLI) express grants as strings.
499// This section is the *one* place that maps a grant string to a `Capability`, so
500// those parsers delegate here instead of each hard-coding a drifting `match`.
501// Rust guideline compliant.
502
503/// Canonical PascalCase names grantable from a bare capability string.
504///
505/// Buckets A (unit registration-gates) and B (allow-list host surfaces). Bucket B
506/// names build with a permissive default payload (see [`Capability::parse_grant`]).
507const GRANTABLE_NAMES: &[&str] = &[
508    // Bucket A — unit registration-gates.
509    "ScalarFn",
510    "AggregateFn",
511    "WindowFn",
512    "Procedure",
513    "ProcedureWrites",
514    "ProcedureSchema",
515    "ProcedureDbms",
516    "LocyAggregate",
517    "LocyPredicate",
518    "LocyGenerator",
519    "Operator",
520    "Index",
521    "Storage",
522    "Algorithm",
523    "GraphCompute",
524    "Crdt",
525    "Hook",
526    "Trigger",
527    "Type",
528    "Collation",
529    "PluginStorage",
530    // Bucket B — allow-list host surfaces.
531    "Network",
532    "Filesystem",
533    "HostQuery",
534    "Kms",
535    "Secret",
536    "Config",
537    "Lock",
538];
539
540/// Names of resource-quota capabilities — grantable only with a numeric value,
541/// via the plugin manifest, never a bare grant string (bucket C).
542const QUOTA_NAMES: &[&str] = &[
543    "BackgroundJob",
544    "MemoryBytes",
545    "FuelPerCall",
546    "WallClockMillisPerCall",
547    "ConcurrentInstances",
548    "TotalMemoryBytes",
549    "MaxResultRows",
550    "GraphComputeWork",
551    "GraphComputeArenaBytes",
552];
553
554/// Names of internal / first-party capabilities — never grantable to a guest
555/// plugin via a grant string (bucket D, plus meta-procedure authority).
556const INTERNAL_NAMES: &[&str] = &["Auth", "Authz", "Cdc", "Catalog", "PluginDeclare"];
557
558/// Fold a grant string to its canonical key: lowercase, with `-` removed.
559///
560/// This makes the parser accept both the PascalCase variant name (`"GraphCompute"`)
561/// and the serde kebab-case tag (`"graph-compute"`).
562fn grant_key(s: &str) -> String {
563    s.chars()
564        .filter(|c| *c != '-')
565        .map(|c| c.to_ascii_lowercase())
566        .collect()
567}
568
569/// Failure to interpret a capability grant string.
570///
571/// Returned by [`Capability::parse_grant`]; the host-facing parsers map each
572/// variant onto their own reporting policy (hard error, skip, or warn).
573#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
574#[non_exhaustive]
575pub enum GrantError {
576    /// The name matches no known capability.
577    #[error("unknown grant `{name}`; grantable capabilities: {supported}")]
578    Unknown {
579        /// The rejected input string.
580        name: String,
581        /// The `/`-joined list of grantable names, for the message.
582        supported: String,
583    },
584    /// The capability is a resource quota needing a numeric value.
585    #[error(
586        "grant `{name}` is a resource quota; declare it with a value in the \
587         plugin manifest `capabilities:` list, not as a bare grant"
588    )]
589    Quota {
590        /// The canonical capability name.
591        name: String,
592    },
593    /// The capability is internal / first-party and not grantable to guests.
594    #[error("grant `{name}` is not grantable to guest plugins")]
595    Internal {
596        /// The canonical capability name.
597        name: String,
598    },
599}
600
601impl Capability {
602    /// The canonical PascalCase grant name for this capability variant.
603    ///
604    /// Exhaustive by construction: adding a `Capability` variant fails to compile
605    /// here until it is named, which keeps the grant-string parsers from silently
606    /// drifting from the enum (see the module's grant-parsing tests).
607    ///
608    /// # Examples
609    ///
610    /// ```
611    /// use uni_plugin::Capability;
612    /// assert_eq!(Capability::GraphCompute.grant_name(), "GraphCompute");
613    /// ```
614    #[must_use]
615    pub fn grant_name(&self) -> &'static str {
616        match self {
617            // Host import surfaces (bucket B).
618            Capability::Network { .. } => "Network",
619            Capability::Filesystem { .. } => "Filesystem",
620            Capability::HostQuery { .. } => "HostQuery",
621            Capability::Kms { .. } => "Kms",
622            Capability::Secret { .. } => "Secret",
623            Capability::Lock { .. } => "Lock",
624            Capability::Config { .. } => "Config",
625            Capability::PluginStorage => "PluginStorage",
626            // Extension surfaces (bucket A) and internal gates (bucket D).
627            Capability::ScalarFn => "ScalarFn",
628            Capability::AggregateFn => "AggregateFn",
629            Capability::WindowFn => "WindowFn",
630            Capability::Procedure => "Procedure",
631            Capability::ProcedureWrites => "ProcedureWrites",
632            Capability::ProcedureSchema => "ProcedureSchema",
633            Capability::ProcedureDbms => "ProcedureDbms",
634            Capability::LocyAggregate => "LocyAggregate",
635            Capability::LocyPredicate => "LocyPredicate",
636            Capability::LocyGenerator => "LocyGenerator",
637            Capability::Operator => "Operator",
638            Capability::Index => "Index",
639            Capability::Storage => "Storage",
640            Capability::Algorithm => "Algorithm",
641            Capability::GraphCompute => "GraphCompute",
642            Capability::Crdt => "Crdt",
643            Capability::Hook => "Hook",
644            Capability::Trigger => "Trigger",
645            Capability::BackgroundJob { .. } => "BackgroundJob",
646            Capability::Type => "Type",
647            Capability::Auth => "Auth",
648            Capability::Authz => "Authz",
649            Capability::Collation => "Collation",
650            Capability::Cdc => "Cdc",
651            Capability::Catalog => "Catalog",
652            Capability::PluginDeclare => "PluginDeclare",
653            // Resource quotas (bucket C).
654            Capability::MemoryBytes(_) => "MemoryBytes",
655            Capability::FuelPerCall(_) => "FuelPerCall",
656            Capability::WallClockMillisPerCall(_) => "WallClockMillisPerCall",
657            Capability::ConcurrentInstances(_) => "ConcurrentInstances",
658            Capability::TotalMemoryBytes(_) => "TotalMemoryBytes",
659            Capability::MaxResultRows(_) => "MaxResultRows",
660            Capability::GraphComputeWork(_) => "GraphComputeWork",
661            Capability::GraphComputeArenaBytes(_) => "GraphComputeArenaBytes",
662        }
663    }
664
665    /// The capability names a host may grant from a bare grant string.
666    ///
667    /// Buckets A + B, in canonical PascalCase. Resource quotas (bucket C) and
668    /// internal capabilities (bucket D) are excluded — see [`Capability::parse_grant`].
669    #[must_use]
670    pub fn grantable_names() -> &'static [&'static str] {
671        GRANTABLE_NAMES
672    }
673
674    /// Parse a host grant string into a `Capability`.
675    ///
676    /// Accepts the canonical PascalCase name (`"GraphCompute"`) or its serde
677    /// kebab-case tag (`"graph-compute"`). Unit registration-gates (bucket A) map
678    /// to their payload-free variant; allow-list host surfaces (bucket B) map to a
679    /// permissive default (`Network { allow: ["**"] }`, `HostQuery { read_only:
680    /// true, scopes: ["**"] }`, …) that the loader then attenuates against the
681    /// guest's declared capability.
682    ///
683    /// # Examples
684    ///
685    /// ```
686    /// use uni_plugin::Capability;
687    /// assert_eq!(Capability::parse_grant("Algorithm").unwrap(), Capability::Algorithm);
688    /// assert_eq!(
689    ///     Capability::parse_grant("graph-compute").unwrap(),
690    ///     Capability::GraphCompute,
691    /// );
692    /// ```
693    ///
694    /// # Errors
695    ///
696    /// Returns [`GrantError::Quota`] for a resource-quota name (bucket C, needs a
697    /// value declared in the manifest), [`GrantError::Internal`] for an
698    /// internal / first-party capability (bucket D and `PluginDeclare`), and
699    /// [`GrantError::Unknown`] for an unrecognized name.
700    pub fn parse_grant(s: &str) -> Result<Self, GrantError> {
701        let key = grant_key(s);
702        if let Some(cap) = grant_default_for_key(&key) {
703            return Ok(cap);
704        }
705        if let Some(name) = QUOTA_NAMES.iter().find(|n| grant_key(n) == key) {
706            return Err(GrantError::Quota {
707                name: (*name).to_owned(),
708            });
709        }
710        if let Some(name) = INTERNAL_NAMES.iter().find(|n| grant_key(n) == key) {
711            return Err(GrantError::Internal {
712                name: (*name).to_owned(),
713            });
714        }
715        Err(GrantError::Unknown {
716            name: s.to_owned(),
717            supported: GRANTABLE_NAMES.join(" / "),
718        })
719    }
720}
721
722/// Build the default grant capability for a canonical grant key, or `None`.
723///
724/// The single definition of the permissive default payloads for bucket-B
725/// (allow-list) grants; bucket-A grants are payload-free. Keys are already folded
726/// by [`grant_key`], so both PascalCase and kebab-case inputs land here.
727fn grant_default_for_key(key: &str) -> Option<Capability> {
728    Some(match key {
729        // Bucket A — unit registration-gates.
730        "scalarfn" => Capability::ScalarFn,
731        "aggregatefn" => Capability::AggregateFn,
732        "windowfn" => Capability::WindowFn,
733        "procedure" => Capability::Procedure,
734        "procedurewrites" => Capability::ProcedureWrites,
735        "procedureschema" => Capability::ProcedureSchema,
736        "proceduredbms" => Capability::ProcedureDbms,
737        "locyaggregate" => Capability::LocyAggregate,
738        "locypredicate" => Capability::LocyPredicate,
739        "locygenerator" => Capability::LocyGenerator,
740        "operator" => Capability::Operator,
741        "index" => Capability::Index,
742        "storage" => Capability::Storage,
743        "algorithm" => Capability::Algorithm,
744        "graphcompute" => Capability::GraphCompute,
745        "crdt" => Capability::Crdt,
746        "hook" => Capability::Hook,
747        "trigger" => Capability::Trigger,
748        "type" => Capability::Type,
749        "collation" => Capability::Collation,
750        "pluginstorage" => Capability::PluginStorage,
751        // Bucket B — allow-list host surfaces, permissive default payload.
752        "network" => Capability::Network {
753            allow: vec!["**".into()],
754        },
755        "filesystem" => Capability::Filesystem {
756            read: vec!["**".into()],
757            write: vec!["**".into()],
758        },
759        "hostquery" => Capability::HostQuery {
760            read_only: true,
761            scopes: vec!["**".into()],
762        },
763        "kms" => Capability::Kms {
764            key_ids: vec!["*".into()],
765        },
766        "secret" => Capability::Secret {
767            ids: vec!["*".into()],
768        },
769        "config" => Capability::Config {
770            keys: vec!["**".into()],
771        },
772        "lock" => Capability::Lock {
773            granularity: LockGranularity::Global,
774        },
775        _ => return None,
776    })
777}
778
779/// A capability as it appears in a **guest plugin manifest** (WASM / Extism) —
780/// either a bare capability name (`"network"`, `"scalar-fn"`) or a structured
781/// object carrying attenuation patterns
782/// (`{"kind":"network","allow":["https://api.example/**"]}`).
783///
784/// Bare names normalize to their **zero-attenuation** variant — e.g.
785/// `"network"` → `Network { allow: [] }` (deny-all egress) — so a guest must
786/// spell out patterns to gain real host-surface access. This lets guest
787/// manifests opt into the same rich [`Capability`] model the in-process Rhai /
788/// Rust paths use, while staying backward-compatible with manifests that listed
789/// bare capability names.
790#[derive(Clone, Debug)]
791pub struct ManifestCapability(pub Capability);
792
793impl<'de> Deserialize<'de> for ManifestCapability {
794    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
795    where
796        D: serde::Deserializer<'de>,
797    {
798        /// String-or-object shim. A JSON string is a bare name; a map is the
799        /// structured `Capability` form (internally tagged on `kind`).
800        #[derive(Deserialize)]
801        #[serde(untagged)]
802        enum Repr {
803            Bare(String),
804            Full(Capability),
805        }
806
807        let cap = match Repr::deserialize(deserializer)? {
808            Repr::Full(c) => c,
809            Repr::Bare(name) => {
810                // Reconstruct the internally-tagged object `{ "kind": <name> }`
811                // so unit variants and (defaulted-field) structured variants
812                // both round-trip through the canonical `Capability` serde.
813                let tagged = serde_json::json!({ "kind": name });
814                Capability::deserialize(tagged).map_err(serde::de::Error::custom)?
815            }
816        };
817        Ok(ManifestCapability(cap))
818    }
819}
820
821/// Anchored wildcard match where `*` (and `**`) match any run of characters.
822///
823/// Capability attenuation patterns (network URL allow-lists, KMS key ids,
824/// secret ids) are globs over opaque strings, not paths, so `**` is treated
825/// identically to `*` — both match any sequence including `/`. Uses the
826/// standard greedy two-pointer algorithm with backtracking; matching is
827/// anchored at both ends.
828fn wildcard_match(pattern: &str, text: &str) -> bool {
829    let p = pattern.as_bytes();
830    let t = text.as_bytes();
831    let (mut pi, mut ti) = (0usize, 0usize);
832    let mut star: Option<usize> = None;
833    let mut mark = 0usize;
834    while ti < t.len() {
835        if pi < p.len() && p[pi] == b'*' {
836            // Collapse consecutive `*` so `**` behaves like `*`.
837            while pi < p.len() && p[pi] == b'*' {
838                pi += 1;
839            }
840            if pi == p.len() {
841                return true;
842            }
843            star = Some(pi);
844            mark = ti;
845        } else if pi < p.len() && p[pi] == t[ti] {
846            pi += 1;
847            ti += 1;
848        } else if let Some(s) = star {
849            pi = s;
850            mark += 1;
851            ti = mark;
852        } else {
853            return false;
854        }
855    }
856    while pi < p.len() && p[pi] == b'*' {
857        pi += 1;
858    }
859    pi == p.len()
860}
861
862/// Determinism characterization — drives planner caching and hoisting.
863#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
864#[serde(rename_all = "kebab-case")]
865pub enum Determinism {
866    /// Same inputs always produce identical output. Cacheable; hoistable
867    /// from loops. Maps to DataFusion `Volatility::Immutable`.
868    Pure,
869    /// Stable within one session (e.g. `current_user()`). Maps to
870    /// DataFusion `Volatility::Stable`.
871    SessionScoped,
872    /// Non-deterministic (`rand()`, `now()`). Maps to DataFusion
873    /// `Volatility::Volatile`.
874    #[default]
875    Nondeterministic,
876}
877
878/// Declared side-effects of a plugin.
879#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
880#[serde(rename_all = "kebab-case")]
881pub enum SideEffects {
882    /// Reads only. Pure or session-scoped data access.
883    #[default]
884    ReadOnly,
885    /// May write to the graph.
886    Writes,
887    /// May perform external I/O (network, filesystem).
888    ExternalIo,
889}
890
891/// Lifetime scope of a plugin's registrations.
892#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
893#[serde(rename_all = "kebab-case")]
894pub enum Scope {
895    /// Lives until `Uni::remove_plugin` or instance drop. Visible to every
896    /// session. The default for compile-time and WASM plugins.
897    #[default]
898    Instance,
899    /// Lives until the registering `Session` is dropped. Not visible to
900    /// other sessions on the same instance. The default for PyO3 and Lua
901    /// REPL-style plugins.
902    Session,
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    /// One representative value per `Capability` variant.
910    ///
911    /// Used by the grant-parsing tests to assert every variant is classified.
912    /// The `grant_name` match is the compile-time guard that forces a new variant
913    /// to be named; the length assertion in `every_variant_classified_exactly_once`
914    /// forces the new variant to be added here too.
915    fn all_capability_variants() -> Vec<Capability> {
916        vec![
917            Capability::Network { allow: vec![] },
918            Capability::Filesystem {
919                read: vec![],
920                write: vec![],
921            },
922            Capability::HostQuery {
923                read_only: true,
924                scopes: vec![],
925            },
926            Capability::Kms { key_ids: vec![] },
927            Capability::Secret { ids: vec![] },
928            Capability::Lock {
929                granularity: LockGranularity::Both,
930            },
931            Capability::Config { keys: vec![] },
932            Capability::PluginStorage,
933            Capability::ScalarFn,
934            Capability::AggregateFn,
935            Capability::WindowFn,
936            Capability::Procedure,
937            Capability::ProcedureWrites,
938            Capability::ProcedureSchema,
939            Capability::ProcedureDbms,
940            Capability::LocyAggregate,
941            Capability::LocyPredicate,
942            Capability::LocyGenerator,
943            Capability::Operator,
944            Capability::Index,
945            Capability::Storage,
946            Capability::Algorithm,
947            Capability::GraphCompute,
948            Capability::Crdt,
949            Capability::Hook,
950            Capability::Trigger,
951            Capability::BackgroundJob { max_concurrent: 1 },
952            Capability::Type,
953            Capability::Auth,
954            Capability::Authz,
955            Capability::Collation,
956            Capability::Cdc,
957            Capability::Catalog,
958            Capability::PluginDeclare,
959            Capability::MemoryBytes(0),
960            Capability::FuelPerCall(0),
961            Capability::WallClockMillisPerCall(0),
962            Capability::ConcurrentInstances(0),
963            Capability::TotalMemoryBytes(0),
964            Capability::MaxResultRows(0),
965            Capability::GraphComputeWork(0),
966            Capability::GraphComputeArenaBytes(0),
967        ]
968    }
969
970    #[test]
971    fn every_variant_classified_exactly_once() {
972        let variants = all_capability_variants();
973        // Guard the representative list against silent omission: if a variant is
974        // added, `grant_name` fails to compile first, and this length check then
975        // fails until the variant is added here too.
976        assert_eq!(
977            variants.len(),
978            GRANTABLE_NAMES.len() + QUOTA_NAMES.len() + INTERNAL_NAMES.len(),
979            "every variant must be represented and classified exactly once",
980        );
981        for cap in variants {
982            let name = cap.grant_name();
983            let grantable = GRANTABLE_NAMES.contains(&name);
984            let quota = QUOTA_NAMES.contains(&name);
985            let internal = INTERNAL_NAMES.contains(&name);
986            assert!(
987                [grantable, quota, internal].iter().filter(|b| **b).count() == 1,
988                "`{name}` must fall in exactly one grant class",
989            );
990        }
991    }
992
993    #[test]
994    fn grantable_names_round_trip() {
995        for name in GRANTABLE_NAMES {
996            let cap = Capability::parse_grant(name)
997                .unwrap_or_else(|e| panic!("`{name}` should be grantable: {e}"));
998            assert_eq!(cap.grant_name(), *name);
999        }
1000    }
1001
1002    #[test]
1003    fn parse_grant_accepts_pascal_and_kebab() {
1004        assert_eq!(
1005            Capability::parse_grant("GraphCompute").unwrap(),
1006            Capability::GraphCompute,
1007        );
1008        assert_eq!(
1009            Capability::parse_grant("graph-compute").unwrap(),
1010            Capability::GraphCompute,
1011        );
1012        // The #150 triple all resolve.
1013        assert_eq!(
1014            Capability::parse_grant("Algorithm").unwrap(),
1015            Capability::Algorithm,
1016        );
1017        assert!(matches!(
1018            Capability::parse_grant("HostQuery").unwrap(),
1019            Capability::HostQuery { read_only: true, scopes } if scopes == vec![SmolStr::new("**")]
1020        ));
1021    }
1022
1023    #[test]
1024    fn parse_grant_rejects_quota_internal_unknown() {
1025        assert!(matches!(
1026            Capability::parse_grant("MemoryBytes"),
1027            Err(GrantError::Quota { .. })
1028        ));
1029        assert!(matches!(
1030            Capability::parse_grant("BackgroundJob"),
1031            Err(GrantError::Quota { .. })
1032        ));
1033        assert!(matches!(
1034            Capability::parse_grant("Auth"),
1035            Err(GrantError::Internal { .. })
1036        ));
1037        assert!(matches!(
1038            Capability::parse_grant("PluginDeclare"),
1039            Err(GrantError::Internal { .. })
1040        ));
1041        assert!(matches!(
1042            Capability::parse_grant("NotARealCapability"),
1043            Err(GrantError::Unknown { .. })
1044        ));
1045    }
1046
1047    #[test]
1048    fn denied_against_ignores_attenuated_but_granted_payload() {
1049        // Guest asks for a narrow HostQuery scope; host grants a broader one.
1050        // After intersect the effective HostQuery survives (with the guest's
1051        // narrowed payload), so it must NOT be reported denied — the bug the
1052        // exact-`contains` derivation had.
1053        let declared = CapabilitySet::from_iter_of([
1054            Capability::HostQuery {
1055                read_only: true,
1056                scopes: vec![SmolStr::new("a")],
1057            },
1058            Capability::Algorithm,
1059        ]);
1060        let granted = CapabilitySet::from_iter_of([
1061            Capability::HostQuery {
1062                read_only: true,
1063                scopes: vec![SmolStr::new("a"), SmolStr::new("b")],
1064            },
1065            // Algorithm withheld.
1066        ]);
1067        let effective = declared.intersect(&granted);
1068        let denied = declared.denied_against(&effective);
1069        // HostQuery is granted (attenuated), only the withheld Algorithm is denied.
1070        assert_eq!(denied, vec![Capability::Algorithm]);
1071    }
1072
1073    #[test]
1074    fn capability_set_default_empty() {
1075        let s = CapabilitySet::new();
1076        assert!(s.is_empty());
1077        assert_eq!(s.len(), 0);
1078    }
1079
1080    #[test]
1081    fn capability_set_insert_dedup() {
1082        let mut s = CapabilitySet::new();
1083        assert!(s.insert(Capability::ScalarFn));
1084        assert!(!s.insert(Capability::ScalarFn));
1085        assert_eq!(s.len(), 1);
1086    }
1087
1088    #[test]
1089    fn intersect_keeps_matching_variants() {
1090        let a = CapabilitySet::from_iter_of([
1091            Capability::ScalarFn,
1092            Capability::Storage,
1093            Capability::Network {
1094                allow: vec![SmolStr::new("https://api.example/**")],
1095            },
1096        ]);
1097        let b = CapabilitySet::from_iter_of([
1098            Capability::ScalarFn,
1099            Capability::Network {
1100                allow: vec![SmolStr::new("https://api.example/**")],
1101            },
1102        ]);
1103        let inter = a.intersect(&b);
1104        assert!(inter.contains(&Capability::ScalarFn));
1105        assert!(!inter.contains_variant(&Capability::Storage));
1106        assert!(inter.contains_variant(&Capability::Network { allow: vec![] }));
1107    }
1108
1109    /// G-3 (proposal §9): a `GraphComputeWork` grant is a resource quota with no
1110    /// allow-list to narrow, so its declared value survives capability
1111    /// attenuation verbatim — the host cannot silently shrink it. This is the
1112    /// property `WorkBudget::resolve` relies on to treat the grant as
1113    /// authoritative and *raise* the ceiling (the §9 revision would be defeated
1114    /// if attenuation clamped the grant down).
1115    #[test]
1116    fn graph_compute_work_grant_survives_attenuation_verbatim() {
1117        let big = 5_000_000_000u64; // deliberately above the 1e9 size ceiling
1118        let guest = CapabilitySet::from_iter_of([
1119            Capability::GraphCompute,
1120            Capability::GraphComputeWork(big),
1121        ]);
1122        let host = CapabilitySet::from_iter_of([
1123            Capability::GraphCompute,
1124            Capability::GraphComputeWork(big),
1125        ]);
1126        let inter = guest.intersect(&host);
1127        let work = inter.iter().find_map(|c| match c {
1128            Capability::GraphComputeWork(w) => Some(*w),
1129            _ => None,
1130        });
1131        assert_eq!(
1132            work,
1133            Some(big),
1134            "the work grant must survive attenuation unchanged"
1135        );
1136    }
1137
1138    /// G-6 (proposal §9): the work grant, arena-bytes cap, and wall-clock
1139    /// deadline are independent dimensions — attenuating a set carrying all three
1140    /// preserves each verbatim and does not let one move another.
1141    #[test]
1142    fn work_grant_is_independent_of_arena_and_wallclock() {
1143        let caps = CapabilitySet::from_iter_of([
1144            Capability::GraphComputeWork(1_234),
1145            Capability::GraphComputeArenaBytes(9_999),
1146            Capability::WallClockMillisPerCall(42),
1147        ]);
1148        let inter = caps.intersect(&caps);
1149        let mut work = None;
1150        let mut arena = None;
1151        let mut wall = None;
1152        for c in inter.iter() {
1153            match c {
1154                Capability::GraphComputeWork(w) => work = Some(*w),
1155                Capability::GraphComputeArenaBytes(b) => arena = Some(*b),
1156                Capability::WallClockMillisPerCall(ms) => wall = Some(*ms),
1157                _ => {}
1158            }
1159        }
1160        assert_eq!(work, Some(1_234));
1161        assert_eq!(
1162            arena,
1163            Some(9_999),
1164            "arena cap must be untouched by the work grant"
1165        );
1166        assert_eq!(
1167            wall,
1168            Some(42),
1169            "wall-clock must be untouched by the work grant"
1170        );
1171    }
1172
1173    /// Regression for the 2026-06-10 review #6: `intersect` must bound the
1174    /// guest's allow-list by the host grant (the host is the ceiling), not clone
1175    /// the guest's broader list. A guest that declares `**` must not reach hosts
1176    /// the grant excludes.
1177    #[test]
1178    fn intersect_attenuates_network_to_host_ceiling() {
1179        let guest = CapabilitySet::from_iter_of([Capability::Network {
1180            allow: vec![SmolStr::new("**")],
1181        }]);
1182        let host = CapabilitySet::from_iter_of([Capability::Network {
1183            allow: vec![SmolStr::new("https://api.example/**")],
1184        }]);
1185
1186        // Loaders call declared.intersect(grants) — guest is `self`.
1187        let effective = guest.intersect(&host);
1188
1189        assert!(
1190            effective
1191                .iter()
1192                .any(|c| c.network_allows("https://api.example/v1/x")),
1193            "host-permitted URL must remain allowed"
1194        );
1195        assert!(
1196            !effective
1197                .iter()
1198                .any(|c| c.network_allows("https://evil.example/x")),
1199            "guest's `**` must not survive the host ceiling — sandbox escape"
1200        );
1201    }
1202
1203    /// A guest narrower than the host keeps its own (narrower) list.
1204    #[test]
1205    fn intersect_keeps_guest_when_narrower_than_host() {
1206        let guest = CapabilitySet::from_iter_of([Capability::Network {
1207            allow: vec![SmolStr::new("https://api.example/v1/**")],
1208        }]);
1209        let host = CapabilitySet::from_iter_of([Capability::Network {
1210            allow: vec![SmolStr::new("https://api.example/**")],
1211        }]);
1212        let effective = guest.intersect(&host);
1213        assert!(
1214            effective
1215                .iter()
1216                .any(|c| c.network_allows("https://api.example/v1/x"))
1217        );
1218        assert!(
1219            !effective
1220                .iter()
1221                .any(|c| c.network_allows("https://api.example/v2/x")),
1222            "guest's own restriction must still bind"
1223        );
1224    }
1225
1226    /// KMS / Secret / Filesystem payloads attenuate the same way.
1227    #[test]
1228    fn intersect_attenuates_kms_secret_fs() {
1229        let guest = CapabilitySet::from_iter_of([
1230            Capability::Kms {
1231                key_ids: vec![SmolStr::new("**")],
1232            },
1233            Capability::Secret {
1234                ids: vec![SmolStr::new("**")],
1235            },
1236            Capability::Filesystem {
1237                read: vec![SmolStr::new("**")],
1238                write: vec![SmolStr::new("**")],
1239            },
1240        ]);
1241        let host = CapabilitySet::from_iter_of([
1242            Capability::Kms {
1243                key_ids: vec![SmolStr::new("prod/signing/**")],
1244            },
1245            Capability::Secret {
1246                ids: vec![SmolStr::new("db/**")],
1247            },
1248            Capability::Filesystem {
1249                read: vec![SmolStr::new("/data/**")],
1250                write: vec![], // host grants no write
1251            },
1252        ]);
1253        let effective = guest.intersect(&host);
1254
1255        assert!(effective.iter().any(|c| c.kms_allows("prod/signing/key1")));
1256        assert!(!effective.iter().any(|c| c.kms_allows("dev/key")));
1257        assert!(effective.iter().any(|c| c.secret_allows("db/password")));
1258        assert!(!effective.iter().any(|c| c.secret_allows("kms/root")));
1259        // Host grants no write path → no writable path survives.
1260        assert!(
1261            !effective.iter().any(|c| matches!(
1262                c,
1263                Capability::Filesystem { write, .. } if !write.is_empty()
1264            )),
1265            "guest write `**` must not survive an empty host write grant"
1266        );
1267    }
1268
1269    #[test]
1270    fn contains_variant_ignores_attenuation() {
1271        let s = CapabilitySet::from_iter_of([Capability::Network {
1272            allow: vec![SmolStr::new("https://x.example/*")],
1273        }]);
1274        assert!(s.contains_variant(&Capability::Network { allow: vec![] }));
1275        // Exact equality requires identical attenuation.
1276        assert!(!s.contains(&Capability::Network { allow: vec![] }));
1277    }
1278
1279    #[test]
1280    fn determinism_default_is_nondeterministic() {
1281        assert_eq!(Determinism::default(), Determinism::Nondeterministic);
1282    }
1283
1284    #[test]
1285    fn wildcard_match_basics() {
1286        assert!(wildcard_match("*", "anything"));
1287        assert!(wildcard_match("**", "any/thing"));
1288        assert!(wildcard_match(
1289            "https://api.example/**",
1290            "https://api.example/v1/x"
1291        ));
1292        assert!(wildcard_match("exact", "exact"));
1293        assert!(!wildcard_match("exact", "other"));
1294        assert!(!wildcard_match(
1295            "https://api.example/**",
1296            "https://evil.example/x"
1297        ));
1298        assert!(wildcard_match("a*c", "abbbc"));
1299        assert!(!wildcard_match("a*c", "abbb"));
1300    }
1301
1302    #[test]
1303    fn network_allows_matches_only_network_variant() {
1304        let net = Capability::Network {
1305            allow: vec![SmolStr::new("https://api.example/**")],
1306        };
1307        assert!(net.network_allows("https://api.example/v1/data"));
1308        assert!(!net.network_allows("https://evil.example/x"));
1309        // A non-network capability never grants network access.
1310        assert!(!Capability::ScalarFn.network_allows("https://api.example/x"));
1311    }
1312
1313    #[test]
1314    fn kms_and_secret_allow_wildcard_and_exact() {
1315        let kms = Capability::Kms {
1316            key_ids: vec![SmolStr::new("*")],
1317        };
1318        assert!(kms.kms_allows("signing-key-1"));
1319        let secret = Capability::Secret {
1320            ids: vec![SmolStr::new("db-password")],
1321        };
1322        assert!(secret.secret_allows("db-password"));
1323        assert!(!secret.secret_allows("other"));
1324    }
1325
1326    #[test]
1327    fn manifest_capability_parses_bare_and_structured() {
1328        // Bare name → zero-attenuation variant (deny-all egress).
1329        let bare: ManifestCapability = serde_json::from_str("\"network\"").unwrap();
1330        assert!(matches!(&bare.0, Capability::Network { allow } if allow.is_empty()));
1331        assert!(!bare.0.network_allows("https://api.example/x"));
1332        // Bare unit variant.
1333        let scalar: ManifestCapability = serde_json::from_str("\"scalar-fn\"").unwrap();
1334        assert_eq!(scalar.0, Capability::ScalarFn);
1335        // Structured object → carries the allow-list.
1336        let structured: ManifestCapability =
1337            serde_json::from_str(r#"{"kind":"network","allow":["https://api.example/**"]}"#)
1338                .unwrap();
1339        assert!(structured.0.network_allows("https://api.example/v1/x"));
1340        assert!(!structured.0.network_allows("https://evil.example/x"));
1341        // A whole manifest list folds into a CapabilitySet.
1342        let set = CapabilitySet::from_manifest([bare, scalar, structured]);
1343        assert!(set.contains_variant(&Capability::Network { allow: vec![] }));
1344        assert!(set.contains(&Capability::ScalarFn));
1345    }
1346
1347    #[test]
1348    fn filesystem_allows_read_and_write_separately() {
1349        let fs = Capability::Filesystem {
1350            read: vec![SmolStr::new("/data/**")],
1351            write: vec![SmolStr::new("/tmp/out/**")],
1352        };
1353        assert!(fs.filesystem_read_allows("/data/x/y.txt"));
1354        assert!(!fs.filesystem_read_allows("/etc/passwd"));
1355        assert!(fs.filesystem_write_allows("/tmp/out/log"));
1356        // read grant does not imply write grant for the same path
1357        assert!(!fs.filesystem_write_allows("/data/x/y.txt"));
1358        // a non-filesystem capability never matches
1359        assert!(!Capability::ScalarFn.filesystem_read_allows("/data/x"));
1360    }
1361}