1use std::collections::BTreeSet;
22
23use serde::{Deserialize, Serialize};
24use smol_str::SmolStr;
25
26#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
31#[serde(tag = "kind", rename_all = "kebab-case")]
32#[non_exhaustive]
33pub enum Capability {
34 Network {
37 #[serde(default)]
41 allow: Vec<SmolStr>,
42 },
43 Filesystem {
45 #[serde(default)]
47 read: Vec<SmolStr>,
48 #[serde(default)]
50 write: Vec<SmolStr>,
51 },
52 HostQuery {
54 #[serde(default)]
56 read_only: bool,
57 #[serde(default)]
59 scopes: Vec<SmolStr>,
60 },
61 Kms {
63 #[serde(default)]
65 key_ids: Vec<SmolStr>,
66 },
67 Secret {
69 #[serde(default)]
71 ids: Vec<SmolStr>,
72 },
73 Lock {
75 granularity: LockGranularity,
77 },
78 Config {
80 #[serde(default)]
82 keys: Vec<SmolStr>,
83 },
84 PluginStorage,
86
87 ScalarFn,
90 AggregateFn,
92 WindowFn,
94 Procedure,
96 ProcedureWrites,
98 ProcedureSchema,
100 ProcedureDbms,
102 LocyAggregate,
104 LocyPredicate,
106 Operator,
108 Index,
110 Storage,
112 Algorithm,
114 Crdt,
116 Hook,
118 Trigger,
120 BackgroundJob {
122 max_concurrent: u32,
124 },
125 Type,
127 Auth,
129 Authz,
131 Connector,
133 Collation,
135 Cdc,
137 Catalog,
139 PluginDeclare,
141
142 MemoryBytes(u64),
145 FuelPerCall(u64),
147 WallClockMillisPerCall(u64),
149 ConcurrentInstances(u32),
151 TotalMemoryBytes(u64),
153 MaxResultRows(u64),
155}
156
157#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
159#[serde(rename_all = "kebab-case")]
160#[non_exhaustive]
161pub enum LockGranularity {
162 Nodes,
164 Edges,
166 Both,
168 Global,
170}
171
172#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(transparent)]
179pub struct CapabilitySet {
180 set: BTreeSet<Capability>,
181}
182
183impl CapabilitySet {
184 #[must_use]
186 pub fn new() -> Self {
187 Self::default()
188 }
189
190 #[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 #[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 pub fn insert(&mut self, cap: Capability) -> bool {
207 self.set.insert(cap)
208 }
209
210 #[must_use]
212 pub fn contains(&self, cap: &Capability) -> bool {
213 self.set.contains(cap)
214 }
215
216 #[must_use]
223 pub fn contains_variant(&self, target: &Capability) -> bool {
224 self.set.iter().any(|c| variant_matches(c, target))
225 }
226
227 #[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 pub fn iter(&self) -> impl Iterator<Item = &Capability> {
251 self.set.iter()
252 }
253
254 #[must_use]
256 pub fn len(&self) -> usize {
257 self.set.len()
258 }
259
260 #[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
271fn 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, |c| network_allow(c))),
281 },
282 Capability::Filesystem { read, write } => Capability::Filesystem {
283 read: intersect_globs(read, &host_lists(host, |c| fs_read(c))),
284 write: intersect_globs(write, &host_lists(host, |c| fs_write(c))),
285 },
286 Capability::Kms { key_ids } => Capability::Kms {
287 key_ids: intersect_globs(key_ids, &host_lists(host, |c| kms_ids(c))),
288 },
289 Capability::Secret { ids } => Capability::Secret {
290 ids: intersect_globs(ids, &host_lists(host, |c| secret_ids(c))),
291 },
292 Capability::Config { keys } => Capability::Config {
293 keys: intersect_globs(keys, &host_lists(host, |c| config_keys(c))),
294 },
295 Capability::HostQuery { read_only, scopes } => {
296 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, |c| host_query_scopes(c));
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 other => other.clone(),
323 }
324}
325
326fn 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
371fn 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
384fn 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 #[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 #[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 #[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 #[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 #[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#[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 #[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 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
494fn 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
537#[serde(rename_all = "kebab-case")]
538pub enum Determinism {
539 Pure,
542 SessionScoped,
545 #[default]
548 Nondeterministic,
549}
550
551#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
553#[serde(rename_all = "kebab-case")]
554pub enum SideEffects {
555 #[default]
557 ReadOnly,
558 Writes,
560 ExternalIo,
562}
563
564#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
566#[serde(rename_all = "kebab-case")]
567pub enum Scope {
568 #[default]
571 Instance,
572 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 #[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 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 #[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 #[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![], },
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 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 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 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 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 let scalar: ManifestCapability = serde_json::from_str("\"scalar-fn\"").unwrap();
779 assert_eq!(scalar.0, Capability::ScalarFn);
780 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 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 assert!(!fs.filesystem_write_allows("/data/x/y.txt"));
803 assert!(!Capability::ScalarFn.filesystem_read_allows("/data/x"));
805 }
806}