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 (guest-declared) set with the host-granted `other`,
228 /// returning the effective capability set.
229 ///
230 /// Loaders call `declared.intersect(grants)`, so `self` is the guest
231 /// manifest and `other` is the host ceiling. A guest capability survives
232 /// only if the host grants the same variant, and its **payload is attenuated
233 /// against the host**: for the allow-list variants (`Network`,
234 /// `Filesystem`, `Kms`, `Secret`, `Config`) and `HostQuery`, the effective
235 /// grant permits a resource only if *both* the guest and the host permit it
236 /// — the host is a true ceiling a guest cannot widen. Non-payload variants
237 /// (registration gates, resource quotas) retain the guest value as before.
238 #[must_use]
239 pub fn intersect(&self, other: &Self) -> Self {
240 let mut out = Self::new();
241 for c in &self.set {
242 if other.contains_variant(c) {
243 out.insert(attenuate_to_host(c, other));
244 }
245 }
246 out
247 }
248
249 /// Returns an iterator over the contained capabilities.
250 pub fn iter(&self) -> impl Iterator<Item = &Capability> {
251 self.set.iter()
252 }
253
254 /// Returns the number of distinct capabilities in the set.
255 #[must_use]
256 pub fn len(&self) -> usize {
257 self.set.len()
258 }
259
260 /// Returns `true` if the set is empty.
261 #[must_use]
262 pub fn is_empty(&self) -> bool {
263 self.set.is_empty()
264 }
265}
266
267fn variant_matches(a: &Capability, b: &Capability) -> bool {
268 std::mem::discriminant(a) == std::mem::discriminant(b)
269}
270
271/// Attenuate a guest capability against the host grant (the ceiling).
272///
273/// For the allow-list payload variants and `HostQuery`, returns a capability
274/// whose effective grant is the conjunction of guest and host; for every other
275/// variant, returns the guest capability unchanged (registration gates and
276/// quotas have no allow-list to narrow). See [`CapabilitySet::intersect`].
277fn attenuate_to_host(guest: &Capability, host: &CapabilitySet) -> Capability {
278 match guest {
279 Capability::Network { allow } => Capability::Network {
280 allow: intersect_globs(allow, &host_lists(host, network_allow)),
281 },
282 Capability::Filesystem { read, write } => Capability::Filesystem {
283 read: intersect_globs(read, &host_lists(host, fs_read)),
284 write: intersect_globs(write, &host_lists(host, fs_write)),
285 },
286 Capability::Kms { key_ids } => Capability::Kms {
287 key_ids: intersect_globs(key_ids, &host_lists(host, kms_ids)),
288 },
289 Capability::Secret { ids } => Capability::Secret {
290 ids: intersect_globs(ids, &host_lists(host, secret_ids)),
291 },
292 Capability::Config { keys } => Capability::Config {
293 keys: intersect_globs(keys, &host_lists(host, config_keys)),
294 },
295 Capability::HostQuery { read_only, scopes } => {
296 // `read_only` is restrictive-true: either side may force read-only.
297 // `scopes` empty means "unrestricted", so an empty list on a side
298 // imposes no narrowing (unlike the deny-on-empty allow-lists above).
299 let host_read_only = host.set.iter().any(|c| {
300 matches!(
301 c,
302 Capability::HostQuery {
303 read_only: true,
304 ..
305 }
306 )
307 });
308 let host_scopes = host_lists(host, host_query_scopes);
309 let scopes = if scopes.is_empty() {
310 host_scopes
311 } else if host_scopes.is_empty() {
312 scopes.clone()
313 } else {
314 intersect_globs(scopes, &host_scopes)
315 };
316 Capability::HostQuery {
317 read_only: *read_only || host_read_only,
318 scopes,
319 }
320 }
321 // Registration gates and resource quotas carry no allow-list to narrow.
322 other => other.clone(),
323 }
324}
325
326// Per-variant payload extractors used to gather the host ceiling. Each returns
327// the allow-list for capabilities of its variant, `None` otherwise.
328fn network_allow(c: &Capability) -> Option<&[SmolStr]> {
329 match c {
330 Capability::Network { allow } => Some(allow),
331 _ => None,
332 }
333}
334fn fs_read(c: &Capability) -> Option<&[SmolStr]> {
335 match c {
336 Capability::Filesystem { read, .. } => Some(read),
337 _ => None,
338 }
339}
340fn fs_write(c: &Capability) -> Option<&[SmolStr]> {
341 match c {
342 Capability::Filesystem { write, .. } => Some(write),
343 _ => None,
344 }
345}
346fn kms_ids(c: &Capability) -> Option<&[SmolStr]> {
347 match c {
348 Capability::Kms { key_ids } => Some(key_ids),
349 _ => None,
350 }
351}
352fn secret_ids(c: &Capability) -> Option<&[SmolStr]> {
353 match c {
354 Capability::Secret { ids } => Some(ids),
355 _ => None,
356 }
357}
358fn config_keys(c: &Capability) -> Option<&[SmolStr]> {
359 match c {
360 Capability::Config { keys } => Some(keys),
361 _ => None,
362 }
363}
364fn host_query_scopes(c: &Capability) -> Option<&[SmolStr]> {
365 match c {
366 Capability::HostQuery { scopes, .. } => Some(scopes),
367 _ => None,
368 }
369}
370
371/// Union the allow-lists of every host capability matching `extract`'s variant.
372fn host_lists<'a>(
373 host: &'a CapabilitySet,
374 extract: impl Fn(&'a Capability) -> Option<&'a [SmolStr]>,
375) -> Vec<SmolStr> {
376 host.set
377 .iter()
378 .filter_map(extract)
379 .flatten()
380 .cloned()
381 .collect()
382}
383
384/// Intersect two glob allow-lists with each side acting as a ceiling on the
385/// other.
386///
387/// A pattern is kept only when some pattern in the opposite list *subsumes* it
388/// (`wildcard_match(other_pattern, pattern)`), so the result permits a resource
389/// only if both inputs would. Incomparable patterns are dropped (deny — the
390/// safe direction). This is sound for the prefix-glob patterns capability
391/// allow-lists use; it can under-grant only for exotic overlapping-but-
392/// incomparable globs, never over-grant. An empty input yields an empty result
393/// (deny-all), matching the allow-list "empty = deny" convention.
394fn intersect_globs(a: &[SmolStr], b: &[SmolStr]) -> Vec<SmolStr> {
395 let mut out: Vec<SmolStr> = Vec::new();
396 let mut keep = |pat: &SmolStr, ceiling: &[SmolStr]| {
397 if ceiling.iter().any(|q| wildcard_match(q, pat)) && !out.contains(pat) {
398 out.push(pat.clone());
399 }
400 };
401 for pat in a {
402 keep(pat, b);
403 }
404 for pat in b {
405 keep(pat, a);
406 }
407 out
408}
409
410impl Capability {
411 /// True if this is a [`Capability::Network`] grant whose allow-list
412 /// matches `url`.
413 ///
414 /// Used for layer-3 (call-time) attenuation of `uni.http.*` host fns: a
415 /// granted `Network { allow }` only permits URLs matching one of its
416 /// patterns. Non-`Network` capabilities never match.
417 #[must_use]
418 pub fn network_allows(&self, url: &str) -> bool {
419 matches!(self, Capability::Network { allow } if allow.iter().any(|p| wildcard_match(p, url)))
420 }
421
422 /// True if this is a [`Capability::Kms`] grant permitting `key_id`.
423 #[must_use]
424 pub fn kms_allows(&self, key_id: &str) -> bool {
425 matches!(self, Capability::Kms { key_ids } if key_ids.iter().any(|p| wildcard_match(p, key_id)))
426 }
427
428 /// True if this is a [`Capability::Secret`] grant permitting `id`.
429 #[must_use]
430 pub fn secret_allows(&self, id: &str) -> bool {
431 matches!(self, Capability::Secret { ids } if ids.iter().any(|p| wildcard_match(p, id)))
432 }
433
434 /// True if this is a [`Capability::Filesystem`] grant whose `read`
435 /// allow-list matches `path`.
436 ///
437 /// Patterns are matched with `wildcard_match` (path-opaque — `*` and `**`
438 /// both span `/`), which suits the `/data/**`-style grants in use.
439 #[must_use]
440 pub fn filesystem_read_allows(&self, path: &str) -> bool {
441 matches!(self, Capability::Filesystem { read, .. } if read.iter().any(|p| wildcard_match(p, path)))
442 }
443
444 /// True if this is a [`Capability::Filesystem`] grant whose `write`
445 /// allow-list matches `path`.
446 #[must_use]
447 pub fn filesystem_write_allows(&self, path: &str) -> bool {
448 matches!(self, Capability::Filesystem { write, .. } if write.iter().any(|p| wildcard_match(p, path)))
449 }
450}
451
452/// A capability as it appears in a **guest plugin manifest** (WASM / Extism) —
453/// either a bare capability name (`"network"`, `"scalar-fn"`) or a structured
454/// object carrying attenuation patterns
455/// (`{"kind":"network","allow":["https://api.example/**"]}`).
456///
457/// Bare names normalize to their **zero-attenuation** variant — e.g.
458/// `"network"` → `Network { allow: [] }` (deny-all egress) — so a guest must
459/// spell out patterns to gain real host-surface access. This lets guest
460/// manifests opt into the same rich [`Capability`] model the in-process Rhai /
461/// Rust paths use, while staying backward-compatible with manifests that listed
462/// bare capability names.
463#[derive(Clone, Debug)]
464pub struct ManifestCapability(pub Capability);
465
466impl<'de> Deserialize<'de> for ManifestCapability {
467 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
468 where
469 D: serde::Deserializer<'de>,
470 {
471 /// String-or-object shim. A JSON string is a bare name; a map is the
472 /// structured `Capability` form (internally tagged on `kind`).
473 #[derive(Deserialize)]
474 #[serde(untagged)]
475 enum Repr {
476 Bare(String),
477 Full(Capability),
478 }
479
480 let cap = match Repr::deserialize(deserializer)? {
481 Repr::Full(c) => c,
482 Repr::Bare(name) => {
483 // Reconstruct the internally-tagged object `{ "kind": <name> }`
484 // so unit variants and (defaulted-field) structured variants
485 // both round-trip through the canonical `Capability` serde.
486 let tagged = serde_json::json!({ "kind": name });
487 Capability::deserialize(tagged).map_err(serde::de::Error::custom)?
488 }
489 };
490 Ok(ManifestCapability(cap))
491 }
492}
493
494/// Anchored wildcard match where `*` (and `**`) match any run of characters.
495///
496/// Capability attenuation patterns (network URL allow-lists, KMS key ids,
497/// secret ids) are globs over opaque strings, not paths, so `**` is treated
498/// identically to `*` — both match any sequence including `/`. Uses the
499/// standard greedy two-pointer algorithm with backtracking; matching is
500/// anchored at both ends.
501fn wildcard_match(pattern: &str, text: &str) -> bool {
502 let p = pattern.as_bytes();
503 let t = text.as_bytes();
504 let (mut pi, mut ti) = (0usize, 0usize);
505 let mut star: Option<usize> = None;
506 let mut mark = 0usize;
507 while ti < t.len() {
508 if pi < p.len() && p[pi] == b'*' {
509 // Collapse consecutive `*` so `**` behaves like `*`.
510 while pi < p.len() && p[pi] == b'*' {
511 pi += 1;
512 }
513 if pi == p.len() {
514 return true;
515 }
516 star = Some(pi);
517 mark = ti;
518 } else if pi < p.len() && p[pi] == t[ti] {
519 pi += 1;
520 ti += 1;
521 } else if let Some(s) = star {
522 pi = s;
523 mark += 1;
524 ti = mark;
525 } else {
526 return false;
527 }
528 }
529 while pi < p.len() && p[pi] == b'*' {
530 pi += 1;
531 }
532 pi == p.len()
533}
534
535/// Determinism characterization — drives planner caching and hoisting.
536#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
537#[serde(rename_all = "kebab-case")]
538pub enum Determinism {
539 /// Same inputs always produce identical output. Cacheable; hoistable
540 /// from loops. Maps to DataFusion `Volatility::Immutable`.
541 Pure,
542 /// Stable within one session (e.g. `current_user()`). Maps to
543 /// DataFusion `Volatility::Stable`.
544 SessionScoped,
545 /// Non-deterministic (`rand()`, `now()`). Maps to DataFusion
546 /// `Volatility::Volatile`.
547 #[default]
548 Nondeterministic,
549}
550
551/// Declared side-effects of a plugin.
552#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
553#[serde(rename_all = "kebab-case")]
554pub enum SideEffects {
555 /// Reads only. Pure or session-scoped data access.
556 #[default]
557 ReadOnly,
558 /// May write to the graph.
559 Writes,
560 /// May perform external I/O (network, filesystem).
561 ExternalIo,
562}
563
564/// Lifetime scope of a plugin's registrations.
565#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
566#[serde(rename_all = "kebab-case")]
567pub enum Scope {
568 /// Lives until `Uni::remove_plugin` or instance drop. Visible to every
569 /// session. The default for compile-time and WASM plugins.
570 #[default]
571 Instance,
572 /// Lives until the registering `Session` is dropped. Not visible to
573 /// other sessions on the same instance. The default for PyO3 and Lua
574 /// REPL-style plugins.
575 Session,
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 #[test]
583 fn capability_set_default_empty() {
584 let s = CapabilitySet::new();
585 assert!(s.is_empty());
586 assert_eq!(s.len(), 0);
587 }
588
589 #[test]
590 fn capability_set_insert_dedup() {
591 let mut s = CapabilitySet::new();
592 assert!(s.insert(Capability::ScalarFn));
593 assert!(!s.insert(Capability::ScalarFn));
594 assert_eq!(s.len(), 1);
595 }
596
597 #[test]
598 fn intersect_keeps_matching_variants() {
599 let a = CapabilitySet::from_iter_of([
600 Capability::ScalarFn,
601 Capability::Storage,
602 Capability::Network {
603 allow: vec![SmolStr::new("https://api.example/**")],
604 },
605 ]);
606 let b = CapabilitySet::from_iter_of([
607 Capability::ScalarFn,
608 Capability::Network {
609 allow: vec![SmolStr::new("https://api.example/**")],
610 },
611 ]);
612 let inter = a.intersect(&b);
613 assert!(inter.contains(&Capability::ScalarFn));
614 assert!(!inter.contains_variant(&Capability::Storage));
615 assert!(inter.contains_variant(&Capability::Network { allow: vec![] }));
616 }
617
618 /// Regression for the 2026-06-10 review #6: `intersect` must bound the
619 /// guest's allow-list by the host grant (the host is the ceiling), not clone
620 /// the guest's broader list. A guest that declares `**` must not reach hosts
621 /// the grant excludes.
622 #[test]
623 fn intersect_attenuates_network_to_host_ceiling() {
624 let guest = CapabilitySet::from_iter_of([Capability::Network {
625 allow: vec![SmolStr::new("**")],
626 }]);
627 let host = CapabilitySet::from_iter_of([Capability::Network {
628 allow: vec![SmolStr::new("https://api.example/**")],
629 }]);
630
631 // Loaders call declared.intersect(grants) — guest is `self`.
632 let effective = guest.intersect(&host);
633
634 assert!(
635 effective
636 .iter()
637 .any(|c| c.network_allows("https://api.example/v1/x")),
638 "host-permitted URL must remain allowed"
639 );
640 assert!(
641 !effective
642 .iter()
643 .any(|c| c.network_allows("https://evil.example/x")),
644 "guest's `**` must not survive the host ceiling — sandbox escape"
645 );
646 }
647
648 /// A guest narrower than the host keeps its own (narrower) list.
649 #[test]
650 fn intersect_keeps_guest_when_narrower_than_host() {
651 let guest = CapabilitySet::from_iter_of([Capability::Network {
652 allow: vec![SmolStr::new("https://api.example/v1/**")],
653 }]);
654 let host = CapabilitySet::from_iter_of([Capability::Network {
655 allow: vec![SmolStr::new("https://api.example/**")],
656 }]);
657 let effective = guest.intersect(&host);
658 assert!(
659 effective
660 .iter()
661 .any(|c| c.network_allows("https://api.example/v1/x"))
662 );
663 assert!(
664 !effective
665 .iter()
666 .any(|c| c.network_allows("https://api.example/v2/x")),
667 "guest's own restriction must still bind"
668 );
669 }
670
671 /// KMS / Secret / Filesystem payloads attenuate the same way.
672 #[test]
673 fn intersect_attenuates_kms_secret_fs() {
674 let guest = CapabilitySet::from_iter_of([
675 Capability::Kms {
676 key_ids: vec![SmolStr::new("**")],
677 },
678 Capability::Secret {
679 ids: vec![SmolStr::new("**")],
680 },
681 Capability::Filesystem {
682 read: vec![SmolStr::new("**")],
683 write: vec![SmolStr::new("**")],
684 },
685 ]);
686 let host = CapabilitySet::from_iter_of([
687 Capability::Kms {
688 key_ids: vec![SmolStr::new("prod/signing/**")],
689 },
690 Capability::Secret {
691 ids: vec![SmolStr::new("db/**")],
692 },
693 Capability::Filesystem {
694 read: vec![SmolStr::new("/data/**")],
695 write: vec![], // host grants no write
696 },
697 ]);
698 let effective = guest.intersect(&host);
699
700 assert!(effective.iter().any(|c| c.kms_allows("prod/signing/key1")));
701 assert!(!effective.iter().any(|c| c.kms_allows("dev/key")));
702 assert!(effective.iter().any(|c| c.secret_allows("db/password")));
703 assert!(!effective.iter().any(|c| c.secret_allows("kms/root")));
704 // Host grants no write path → no writable path survives.
705 assert!(
706 !effective.iter().any(|c| matches!(
707 c,
708 Capability::Filesystem { write, .. } if !write.is_empty()
709 )),
710 "guest write `**` must not survive an empty host write grant"
711 );
712 }
713
714 #[test]
715 fn contains_variant_ignores_attenuation() {
716 let s = CapabilitySet::from_iter_of([Capability::Network {
717 allow: vec![SmolStr::new("https://x.example/*")],
718 }]);
719 assert!(s.contains_variant(&Capability::Network { allow: vec![] }));
720 // Exact equality requires identical attenuation.
721 assert!(!s.contains(&Capability::Network { allow: vec![] }));
722 }
723
724 #[test]
725 fn determinism_default_is_nondeterministic() {
726 assert_eq!(Determinism::default(), Determinism::Nondeterministic);
727 }
728
729 #[test]
730 fn wildcard_match_basics() {
731 assert!(wildcard_match("*", "anything"));
732 assert!(wildcard_match("**", "any/thing"));
733 assert!(wildcard_match(
734 "https://api.example/**",
735 "https://api.example/v1/x"
736 ));
737 assert!(wildcard_match("exact", "exact"));
738 assert!(!wildcard_match("exact", "other"));
739 assert!(!wildcard_match(
740 "https://api.example/**",
741 "https://evil.example/x"
742 ));
743 assert!(wildcard_match("a*c", "abbbc"));
744 assert!(!wildcard_match("a*c", "abbb"));
745 }
746
747 #[test]
748 fn network_allows_matches_only_network_variant() {
749 let net = Capability::Network {
750 allow: vec![SmolStr::new("https://api.example/**")],
751 };
752 assert!(net.network_allows("https://api.example/v1/data"));
753 assert!(!net.network_allows("https://evil.example/x"));
754 // A non-network capability never grants network access.
755 assert!(!Capability::ScalarFn.network_allows("https://api.example/x"));
756 }
757
758 #[test]
759 fn kms_and_secret_allow_wildcard_and_exact() {
760 let kms = Capability::Kms {
761 key_ids: vec![SmolStr::new("*")],
762 };
763 assert!(kms.kms_allows("signing-key-1"));
764 let secret = Capability::Secret {
765 ids: vec![SmolStr::new("db-password")],
766 };
767 assert!(secret.secret_allows("db-password"));
768 assert!(!secret.secret_allows("other"));
769 }
770
771 #[test]
772 fn manifest_capability_parses_bare_and_structured() {
773 // Bare name → zero-attenuation variant (deny-all egress).
774 let bare: ManifestCapability = serde_json::from_str("\"network\"").unwrap();
775 assert!(matches!(&bare.0, Capability::Network { allow } if allow.is_empty()));
776 assert!(!bare.0.network_allows("https://api.example/x"));
777 // Bare unit variant.
778 let scalar: ManifestCapability = serde_json::from_str("\"scalar-fn\"").unwrap();
779 assert_eq!(scalar.0, Capability::ScalarFn);
780 // Structured object → carries the allow-list.
781 let structured: ManifestCapability =
782 serde_json::from_str(r#"{"kind":"network","allow":["https://api.example/**"]}"#)
783 .unwrap();
784 assert!(structured.0.network_allows("https://api.example/v1/x"));
785 assert!(!structured.0.network_allows("https://evil.example/x"));
786 // A whole manifest list folds into a CapabilitySet.
787 let set = CapabilitySet::from_manifest([bare, scalar, structured]);
788 assert!(set.contains_variant(&Capability::Network { allow: vec![] }));
789 assert!(set.contains(&Capability::ScalarFn));
790 }
791
792 #[test]
793 fn filesystem_allows_read_and_write_separately() {
794 let fs = Capability::Filesystem {
795 read: vec![SmolStr::new("/data/**")],
796 write: vec![SmolStr::new("/tmp/out/**")],
797 };
798 assert!(fs.filesystem_read_allows("/data/x/y.txt"));
799 assert!(!fs.filesystem_read_allows("/etc/passwd"));
800 assert!(fs.filesystem_write_allows("/tmp/out/log"));
801 // read grant does not imply write grant for the same path
802 assert!(!fs.filesystem_write_allows("/data/x/y.txt"));
803 // a non-filesystem capability never matches
804 assert!(!Capability::ScalarFn.filesystem_read_allows("/data/x"));
805 }
806}