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 LocyGenerator,
108 Operator,
110 Index,
112 Storage,
114 Algorithm,
116 GraphCompute,
124 Crdt,
126 Hook,
128 Trigger,
130 BackgroundJob {
132 max_concurrent: u32,
134 },
135 Type,
137 Auth,
139 Authz,
141 Collation,
143 Cdc,
145 Catalog,
147 PluginDeclare,
149
150 MemoryBytes(u64),
153 FuelPerCall(u64),
155 WallClockMillisPerCall(u64),
157 ConcurrentInstances(u32),
159 TotalMemoryBytes(u64),
161 MaxResultRows(u64),
163 GraphComputeWork(u64),
165 GraphComputeArenaBytes(u64),
167}
168
169#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
171#[serde(rename_all = "kebab-case")]
172#[non_exhaustive]
173pub enum LockGranularity {
174 Nodes,
176 Edges,
178 Both,
180 Global,
182}
183
184#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(transparent)]
191pub struct CapabilitySet {
192 set: BTreeSet<Capability>,
193}
194
195impl CapabilitySet {
196 #[must_use]
198 pub fn new() -> Self {
199 Self::default()
200 }
201
202 #[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 #[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 pub fn insert(&mut self, cap: Capability) -> bool {
219 self.set.insert(cap)
220 }
221
222 #[must_use]
224 pub fn contains(&self, cap: &Capability) -> bool {
225 self.set.contains(cap)
226 }
227
228 #[must_use]
235 pub fn contains_variant(&self, target: &Capability) -> bool {
236 self.set.iter().any(|c| variant_matches(c, target))
237 }
238
239 #[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 #[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 pub fn iter(&self) -> impl Iterator<Item = &Capability> {
295 self.set.iter()
296 }
297
298 #[must_use]
300 pub fn len(&self) -> usize {
301 self.set.len()
302 }
303
304 #[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
315fn 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 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 other => other.clone(),
367 }
368}
369
370fn 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
415fn 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
428fn 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 #[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 #[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 #[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 #[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 #[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
496const GRANTABLE_NAMES: &[&str] = &[
508 "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 "Network",
532 "Filesystem",
533 "HostQuery",
534 "Kms",
535 "Secret",
536 "Config",
537 "Lock",
538];
539
540const QUOTA_NAMES: &[&str] = &[
543 "BackgroundJob",
544 "MemoryBytes",
545 "FuelPerCall",
546 "WallClockMillisPerCall",
547 "ConcurrentInstances",
548 "TotalMemoryBytes",
549 "MaxResultRows",
550 "GraphComputeWork",
551 "GraphComputeArenaBytes",
552];
553
554const INTERNAL_NAMES: &[&str] = &["Auth", "Authz", "Cdc", "Catalog", "PluginDeclare"];
557
558fn grant_key(s: &str) -> String {
563 s.chars()
564 .filter(|c| *c != '-')
565 .map(|c| c.to_ascii_lowercase())
566 .collect()
567}
568
569#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
574#[non_exhaustive]
575pub enum GrantError {
576 #[error("unknown grant `{name}`; grantable capabilities: {supported}")]
578 Unknown {
579 name: String,
581 supported: String,
583 },
584 #[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 name: String,
592 },
593 #[error("grant `{name}` is not grantable to guest plugins")]
595 Internal {
596 name: String,
598 },
599}
600
601impl Capability {
602 #[must_use]
615 pub fn grant_name(&self) -> &'static str {
616 match self {
617 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 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 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 #[must_use]
670 pub fn grantable_names() -> &'static [&'static str] {
671 GRANTABLE_NAMES
672 }
673
674 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
722fn grant_default_for_key(key: &str) -> Option<Capability> {
728 Some(match key {
729 "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 "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#[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 #[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 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
821fn 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
864#[serde(rename_all = "kebab-case")]
865pub enum Determinism {
866 Pure,
869 SessionScoped,
872 #[default]
875 Nondeterministic,
876}
877
878#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
880#[serde(rename_all = "kebab-case")]
881pub enum SideEffects {
882 #[default]
884 ReadOnly,
885 Writes,
887 ExternalIo,
889}
890
891#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
893#[serde(rename_all = "kebab-case")]
894pub enum Scope {
895 #[default]
898 Instance,
899 Session,
903}
904
905#[cfg(test)]
906mod tests {
907 use super::*;
908
909 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 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 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 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 ]);
1067 let effective = declared.intersect(&granted);
1068 let denied = declared.denied_against(&effective);
1069 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 #[test]
1116 fn graph_compute_work_grant_survives_attenuation_verbatim() {
1117 let big = 5_000_000_000u64; 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 #[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 #[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 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 #[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 #[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![], },
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 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 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 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 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 let scalar: ManifestCapability = serde_json::from_str("\"scalar-fn\"").unwrap();
1334 assert_eq!(scalar.0, Capability::ScalarFn);
1335 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 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 assert!(!fs.filesystem_write_allows("/data/x/y.txt"));
1358 assert!(!Capability::ScalarFn.filesystem_read_allows("/data/x"));
1360 }
1361}