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 physical operators / optimizer rules.
107    Operator,
108    /// Register index kinds.
109    Index,
110    /// Register storage backends by URI scheme.
111    Storage,
112    /// Register graph algorithms.
113    Algorithm,
114    /// Register CRDT kinds.
115    Crdt,
116    /// Register session / query lifecycle hooks.
117    Hook,
118    /// Register fine-grained mutation triggers.
119    Trigger,
120    /// Register background / scheduled jobs.
121    BackgroundJob {
122        /// Maximum concurrent invocations of this plugin's jobs.
123        max_concurrent: u32,
124    },
125    /// Register logical (Arrow extension) types.
126    Type,
127    /// Register authentication providers.
128    Auth,
129    /// Register authorization policies.
130    Authz,
131    /// Register wire / connector protocols.
132    Connector,
133    /// Register collations (sort orders).
134    Collation,
135    /// Register CDC output sinks.
136    Cdc,
137    /// Register catalogs / virtual schemas.
138    Catalog,
139    /// Authority to call meta-procedures (`uni.plugin.declare*`).
140    PluginDeclare,
141
142    // ---- Resource quotas ----
143    /// Maximum wasm linear memory per instance.
144    MemoryBytes(u64),
145    /// Maximum wasmtime fuel per call.
146    FuelPerCall(u64),
147    /// Maximum wall-clock milliseconds per call.
148    WallClockMillisPerCall(u64),
149    /// Maximum concurrent instances in the wasm pool.
150    ConcurrentInstances(u32),
151    /// Maximum total memory across all instances.
152    TotalMemoryBytes(u64),
153    /// Cap on rows yielded by a procedure.
154    MaxResultRows(u64),
155}
156
157/// Granularity of lock-capability grants.
158#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
159#[serde(rename_all = "kebab-case")]
160#[non_exhaustive]
161pub enum LockGranularity {
162    /// Per-node locks only.
163    Nodes,
164    /// Per-edge locks only.
165    Edges,
166    /// Both nodes and edges.
167    Both,
168    /// Global (graph-wide) locks.
169    Global,
170}
171
172/// A set of capabilities — declared by manifest, granted by loader.
173///
174/// The *effective* capability set is the intersection of declared and
175/// granted. Registrations attempted without the corresponding capability in
176/// the effective set fail with [`crate::PluginError::CapabilityRequired`].
177#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(transparent)]
179pub struct CapabilitySet {
180    set: BTreeSet<Capability>,
181}
182
183impl CapabilitySet {
184    /// Construct an empty capability set.
185    #[must_use]
186    pub fn new() -> Self {
187        Self::default()
188    }
189
190    /// Construct a capability set from an iterable.
191    #[must_use]
192    pub fn from_iter_of(caps: impl IntoIterator<Item = Capability>) -> Self {
193        Self {
194            set: caps.into_iter().collect(),
195        }
196    }
197
198    /// Construct a capability set from guest-manifest declarations, each of
199    /// which may be a bare name or a structured [`ManifestCapability`].
200    #[must_use]
201    pub fn from_manifest(caps: impl IntoIterator<Item = ManifestCapability>) -> Self {
202        Self::from_iter_of(caps.into_iter().map(|m| m.0))
203    }
204
205    /// Insert a capability; returns `true` if the capability was not already present.
206    pub fn insert(&mut self, cap: Capability) -> bool {
207        self.set.insert(cap)
208    }
209
210    /// Check whether the set contains the given capability (exact equality).
211    #[must_use]
212    pub fn contains(&self, cap: &Capability) -> bool {
213        self.set.contains(cap)
214    }
215
216    /// Check whether the set contains a registration-gating capability.
217    ///
218    /// Match is on the *variant* — `contains_variant(Capability::ScalarFn)`
219    /// returns `true` regardless of any associated data on other variants.
220    /// Useful for registrar gates like "any `BackgroundJob { max_concurrent }`
221    /// is sufficient regardless of the cap."
222    #[must_use]
223    pub fn contains_variant(&self, target: &Capability) -> bool {
224        self.set.iter().any(|c| variant_matches(c, target))
225    }
226
227    /// Intersect this set with another, returning a new set.
228    ///
229    /// The intersection is the effective capability set when manifest
230    /// declarations are intersected with host grants. Caps that match by
231    /// variant but differ in attenuation (e.g., two different `Network
232    /// { allow }` patterns) are *both retained* — the runtime check enforces
233    /// each individually.
234    #[must_use]
235    pub fn intersect(&self, other: &Self) -> Self {
236        let mut out = Self::new();
237        for c in &self.set {
238            if other.contains_variant(c) {
239                out.insert(c.clone());
240            }
241        }
242        out
243    }
244
245    /// Returns an iterator over the contained capabilities.
246    pub fn iter(&self) -> impl Iterator<Item = &Capability> {
247        self.set.iter()
248    }
249
250    /// Returns the number of distinct capabilities in the set.
251    #[must_use]
252    pub fn len(&self) -> usize {
253        self.set.len()
254    }
255
256    /// Returns `true` if the set is empty.
257    #[must_use]
258    pub fn is_empty(&self) -> bool {
259        self.set.is_empty()
260    }
261}
262
263fn variant_matches(a: &Capability, b: &Capability) -> bool {
264    std::mem::discriminant(a) == std::mem::discriminant(b)
265}
266
267impl Capability {
268    /// True if this is a [`Capability::Network`] grant whose allow-list
269    /// matches `url`.
270    ///
271    /// Used for layer-3 (call-time) attenuation of `uni.http.*` host fns: a
272    /// granted `Network { allow }` only permits URLs matching one of its
273    /// patterns. Non-`Network` capabilities never match.
274    #[must_use]
275    pub fn network_allows(&self, url: &str) -> bool {
276        matches!(self, Capability::Network { allow } if allow.iter().any(|p| wildcard_match(p, url)))
277    }
278
279    /// True if this is a [`Capability::Kms`] grant permitting `key_id`.
280    #[must_use]
281    pub fn kms_allows(&self, key_id: &str) -> bool {
282        matches!(self, Capability::Kms { key_ids } if key_ids.iter().any(|p| wildcard_match(p, key_id)))
283    }
284
285    /// True if this is a [`Capability::Secret`] grant permitting `id`.
286    #[must_use]
287    pub fn secret_allows(&self, id: &str) -> bool {
288        matches!(self, Capability::Secret { ids } if ids.iter().any(|p| wildcard_match(p, id)))
289    }
290
291    /// True if this is a [`Capability::Filesystem`] grant whose `read`
292    /// allow-list matches `path`.
293    ///
294    /// Patterns are matched with `wildcard_match` (path-opaque — `*` and `**`
295    /// both span `/`), which suits the `/data/**`-style grants in use.
296    #[must_use]
297    pub fn filesystem_read_allows(&self, path: &str) -> bool {
298        matches!(self, Capability::Filesystem { read, .. } if read.iter().any(|p| wildcard_match(p, path)))
299    }
300
301    /// True if this is a [`Capability::Filesystem`] grant whose `write`
302    /// allow-list matches `path`.
303    #[must_use]
304    pub fn filesystem_write_allows(&self, path: &str) -> bool {
305        matches!(self, Capability::Filesystem { write, .. } if write.iter().any(|p| wildcard_match(p, path)))
306    }
307}
308
309/// A capability as it appears in a **guest plugin manifest** (WASM / Extism) —
310/// either a bare capability name (`"network"`, `"scalar-fn"`) or a structured
311/// object carrying attenuation patterns
312/// (`{"kind":"network","allow":["https://api.example/**"]}`).
313///
314/// Bare names normalize to their **zero-attenuation** variant — e.g.
315/// `"network"` → `Network { allow: [] }` (deny-all egress) — so a guest must
316/// spell out patterns to gain real host-surface access. This lets guest
317/// manifests opt into the same rich [`Capability`] model the in-process Rhai /
318/// Rust paths use, while staying backward-compatible with manifests that listed
319/// bare capability names.
320#[derive(Clone, Debug)]
321pub struct ManifestCapability(pub Capability);
322
323impl<'de> Deserialize<'de> for ManifestCapability {
324    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
325    where
326        D: serde::Deserializer<'de>,
327    {
328        /// String-or-object shim. A JSON string is a bare name; a map is the
329        /// structured `Capability` form (internally tagged on `kind`).
330        #[derive(Deserialize)]
331        #[serde(untagged)]
332        enum Repr {
333            Bare(String),
334            Full(Capability),
335        }
336
337        let cap = match Repr::deserialize(deserializer)? {
338            Repr::Full(c) => c,
339            Repr::Bare(name) => {
340                // Reconstruct the internally-tagged object `{ "kind": <name> }`
341                // so unit variants and (defaulted-field) structured variants
342                // both round-trip through the canonical `Capability` serde.
343                let tagged = serde_json::json!({ "kind": name });
344                Capability::deserialize(tagged).map_err(serde::de::Error::custom)?
345            }
346        };
347        Ok(ManifestCapability(cap))
348    }
349}
350
351/// Anchored wildcard match where `*` (and `**`) match any run of characters.
352///
353/// Capability attenuation patterns (network URL allow-lists, KMS key ids,
354/// secret ids) are globs over opaque strings, not paths, so `**` is treated
355/// identically to `*` — both match any sequence including `/`. Uses the
356/// standard greedy two-pointer algorithm with backtracking; matching is
357/// anchored at both ends.
358fn wildcard_match(pattern: &str, text: &str) -> bool {
359    let p = pattern.as_bytes();
360    let t = text.as_bytes();
361    let (mut pi, mut ti) = (0usize, 0usize);
362    let mut star: Option<usize> = None;
363    let mut mark = 0usize;
364    while ti < t.len() {
365        if pi < p.len() && p[pi] == b'*' {
366            // Collapse consecutive `*` so `**` behaves like `*`.
367            while pi < p.len() && p[pi] == b'*' {
368                pi += 1;
369            }
370            if pi == p.len() {
371                return true;
372            }
373            star = Some(pi);
374            mark = ti;
375        } else if pi < p.len() && p[pi] == t[ti] {
376            pi += 1;
377            ti += 1;
378        } else if let Some(s) = star {
379            pi = s;
380            mark += 1;
381            ti = mark;
382        } else {
383            return false;
384        }
385    }
386    while pi < p.len() && p[pi] == b'*' {
387        pi += 1;
388    }
389    pi == p.len()
390}
391
392/// Determinism characterization — drives planner caching and hoisting.
393#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "kebab-case")]
395pub enum Determinism {
396    /// Same inputs always produce identical output. Cacheable; hoistable
397    /// from loops. Maps to DataFusion `Volatility::Immutable`.
398    Pure,
399    /// Stable within one session (e.g. `current_user()`). Maps to
400    /// DataFusion `Volatility::Stable`.
401    SessionScoped,
402    /// Non-deterministic (`rand()`, `now()`). Maps to DataFusion
403    /// `Volatility::Volatile`.
404    #[default]
405    Nondeterministic,
406}
407
408/// Declared side-effects of a plugin.
409#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "kebab-case")]
411pub enum SideEffects {
412    /// Reads only. Pure or session-scoped data access.
413    #[default]
414    ReadOnly,
415    /// May write to the graph.
416    Writes,
417    /// May perform external I/O (network, filesystem).
418    ExternalIo,
419}
420
421/// Lifetime scope of a plugin's registrations.
422#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
423#[serde(rename_all = "kebab-case")]
424pub enum Scope {
425    /// Lives until `Uni::remove_plugin` or instance drop. Visible to every
426    /// session. The default for compile-time and WASM plugins.
427    #[default]
428    Instance,
429    /// Lives until the registering `Session` is dropped. Not visible to
430    /// other sessions on the same instance. The default for PyO3 and Lua
431    /// REPL-style plugins.
432    Session,
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn capability_set_default_empty() {
441        let s = CapabilitySet::new();
442        assert!(s.is_empty());
443        assert_eq!(s.len(), 0);
444    }
445
446    #[test]
447    fn capability_set_insert_dedup() {
448        let mut s = CapabilitySet::new();
449        assert!(s.insert(Capability::ScalarFn));
450        assert!(!s.insert(Capability::ScalarFn));
451        assert_eq!(s.len(), 1);
452    }
453
454    #[test]
455    fn intersect_keeps_matching_variants() {
456        let a = CapabilitySet::from_iter_of([
457            Capability::ScalarFn,
458            Capability::Storage,
459            Capability::Network {
460                allow: vec![SmolStr::new("https://api.example/**")],
461            },
462        ]);
463        let b = CapabilitySet::from_iter_of([
464            Capability::ScalarFn,
465            Capability::Network {
466                allow: vec![SmolStr::new("https://api.example/**")],
467            },
468        ]);
469        let inter = a.intersect(&b);
470        assert!(inter.contains(&Capability::ScalarFn));
471        assert!(!inter.contains_variant(&Capability::Storage));
472        assert!(inter.contains_variant(&Capability::Network { allow: vec![] }));
473    }
474
475    #[test]
476    fn contains_variant_ignores_attenuation() {
477        let s = CapabilitySet::from_iter_of([Capability::Network {
478            allow: vec![SmolStr::new("https://x.example/*")],
479        }]);
480        assert!(s.contains_variant(&Capability::Network { allow: vec![] }));
481        // Exact equality requires identical attenuation.
482        assert!(!s.contains(&Capability::Network { allow: vec![] }));
483    }
484
485    #[test]
486    fn determinism_default_is_nondeterministic() {
487        assert_eq!(Determinism::default(), Determinism::Nondeterministic);
488    }
489
490    #[test]
491    fn wildcard_match_basics() {
492        assert!(wildcard_match("*", "anything"));
493        assert!(wildcard_match("**", "any/thing"));
494        assert!(wildcard_match(
495            "https://api.example/**",
496            "https://api.example/v1/x"
497        ));
498        assert!(wildcard_match("exact", "exact"));
499        assert!(!wildcard_match("exact", "other"));
500        assert!(!wildcard_match(
501            "https://api.example/**",
502            "https://evil.example/x"
503        ));
504        assert!(wildcard_match("a*c", "abbbc"));
505        assert!(!wildcard_match("a*c", "abbb"));
506    }
507
508    #[test]
509    fn network_allows_matches_only_network_variant() {
510        let net = Capability::Network {
511            allow: vec![SmolStr::new("https://api.example/**")],
512        };
513        assert!(net.network_allows("https://api.example/v1/data"));
514        assert!(!net.network_allows("https://evil.example/x"));
515        // A non-network capability never grants network access.
516        assert!(!Capability::ScalarFn.network_allows("https://api.example/x"));
517    }
518
519    #[test]
520    fn kms_and_secret_allow_wildcard_and_exact() {
521        let kms = Capability::Kms {
522            key_ids: vec![SmolStr::new("*")],
523        };
524        assert!(kms.kms_allows("signing-key-1"));
525        let secret = Capability::Secret {
526            ids: vec![SmolStr::new("db-password")],
527        };
528        assert!(secret.secret_allows("db-password"));
529        assert!(!secret.secret_allows("other"));
530    }
531
532    #[test]
533    fn manifest_capability_parses_bare_and_structured() {
534        // Bare name → zero-attenuation variant (deny-all egress).
535        let bare: ManifestCapability = serde_json::from_str("\"network\"").unwrap();
536        assert!(matches!(&bare.0, Capability::Network { allow } if allow.is_empty()));
537        assert!(!bare.0.network_allows("https://api.example/x"));
538        // Bare unit variant.
539        let scalar: ManifestCapability = serde_json::from_str("\"scalar-fn\"").unwrap();
540        assert_eq!(scalar.0, Capability::ScalarFn);
541        // Structured object → carries the allow-list.
542        let structured: ManifestCapability =
543            serde_json::from_str(r#"{"kind":"network","allow":["https://api.example/**"]}"#)
544                .unwrap();
545        assert!(structured.0.network_allows("https://api.example/v1/x"));
546        assert!(!structured.0.network_allows("https://evil.example/x"));
547        // A whole manifest list folds into a CapabilitySet.
548        let set = CapabilitySet::from_manifest([bare, scalar, structured]);
549        assert!(set.contains_variant(&Capability::Network { allow: vec![] }));
550        assert!(set.contains(&Capability::ScalarFn));
551    }
552
553    #[test]
554    fn filesystem_allows_read_and_write_separately() {
555        let fs = Capability::Filesystem {
556            read: vec![SmolStr::new("/data/**")],
557            write: vec![SmolStr::new("/tmp/out/**")],
558        };
559        assert!(fs.filesystem_read_allows("/data/x/y.txt"));
560        assert!(!fs.filesystem_read_allows("/etc/passwd"));
561        assert!(fs.filesystem_write_allows("/tmp/out/log"));
562        // read grant does not imply write grant for the same path
563        assert!(!fs.filesystem_write_allows("/data/x/y.txt"));
564        // a non-filesystem capability never matches
565        assert!(!Capability::ScalarFn.filesystem_read_allows("/data/x"));
566    }
567}