1use std::collections::BTreeSet;
18use std::fmt;
19use std::str::FromStr;
20
21use serde::{Deserialize, Serialize};
22
23pub const KNOWN_DOMAINS: &[&str] = &["fs", "net", "proc", "env", "secret", "time", "rand"];
31
32#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
42pub struct GrantAtom {
43 pub domain: String,
45 pub action: String,
47 pub resource: String,
50}
51
52impl GrantAtom {
53 pub fn new(
55 domain: impl Into<String>,
56 action: impl Into<String>,
57 resource: impl Into<String>,
58 ) -> Self {
59 Self {
60 domain: domain.into(),
61 action: action.into(),
62 resource: resource.into(),
63 }
64 }
65
66 pub fn is_template(&self) -> bool {
72 self.resource.contains('{') && self.resource.contains('}')
73 }
74
75 pub fn is_bare(&self) -> bool {
77 self.resource.is_empty()
78 }
79
80 pub fn has_known_domain(&self) -> bool {
84 KNOWN_DOMAINS.contains(&self.domain.as_str())
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
90pub enum GrantAtomParseError {
91 #[error("a grant atom cannot be empty")]
93 Empty,
94
95 #[error("expected `domain:action[:resource]`, found {input:?} with no action")]
97 MissingAction {
98 input: String,
100 },
101
102 #[error("the {segment} segment of a grant atom cannot be empty")]
104 EmptySegment {
105 segment: &'static str,
107 },
108
109 #[error("the {segment} segment must not contain whitespace, found {ch:?}")]
111 Whitespace {
112 segment: &'static str,
114 ch: char,
116 },
117}
118
119impl FromStr for GrantAtom {
120 type Err = GrantAtomParseError;
121
122 fn from_str(s: &str) -> Result<Self, Self::Err> {
128 if s.trim().is_empty() {
129 return Err(GrantAtomParseError::Empty);
130 }
131
132 let mut parts = s.splitn(3, ':');
133 let domain = parts.next().unwrap_or_default();
134 let Some(action) = parts.next() else {
135 return Err(GrantAtomParseError::MissingAction {
136 input: s.to_string(),
137 });
138 };
139 let resource = parts.next().unwrap_or("");
141
142 check_segment(domain, "domain")?;
143 check_segment(action, "action")?;
144
145 Ok(Self::new(domain, action, resource))
146 }
147}
148
149fn check_segment(value: &str, segment: &'static str) -> Result<(), GrantAtomParseError> {
153 if value.is_empty() {
154 return Err(GrantAtomParseError::EmptySegment { segment });
155 }
156 if let Some(ch) = value.chars().find(|c| c.is_whitespace()) {
157 return Err(GrantAtomParseError::Whitespace { segment, ch });
158 }
159 Ok(())
160}
161
162impl fmt::Display for GrantAtom {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 if self.is_bare() {
165 write!(f, "{}:{}", self.domain, self.action)
166 } else {
167 write!(f, "{}:{}:{}", self.domain, self.action, self.resource)
168 }
169 }
170}
171
172impl fmt::Debug for GrantAtom {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 write!(f, "{self}")
175 }
176}
177
178#[derive(Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
183#[serde(transparent)]
184pub struct GrantSet(BTreeSet<GrantAtom>);
185
186impl GrantSet {
187 pub const EMPTY: Self = Self(BTreeSet::new());
189
190 pub fn new(atoms: impl IntoIterator<Item = GrantAtom>) -> Self {
192 Self(atoms.into_iter().collect())
193 }
194
195 pub fn parse<'a>(
201 atoms: impl IntoIterator<Item = &'a str>,
202 ) -> Result<Self, GrantAtomParseError> {
203 atoms
204 .into_iter()
205 .map(GrantAtom::from_str)
206 .collect::<Result<BTreeSet<_>, _>>()
207 .map(Self)
208 }
209
210 pub fn is_empty(&self) -> bool {
212 self.0.is_empty()
213 }
214
215 pub fn len(&self) -> usize {
217 self.0.len()
218 }
219
220 pub fn iter(&self) -> impl Iterator<Item = &GrantAtom> {
222 self.0.iter()
223 }
224
225 pub fn contains_atom(&self, atom: &GrantAtom) -> bool {
231 self.0.contains(atom)
232 }
233
234 pub fn has_templates(&self) -> bool {
236 self.0.iter().any(GrantAtom::is_template)
237 }
238}
239
240impl FromIterator<GrantAtom> for GrantSet {
241 fn from_iter<I: IntoIterator<Item = GrantAtom>>(iter: I) -> Self {
242 Self::new(iter)
243 }
244}
245
246impl<'a> IntoIterator for &'a GrantSet {
247 type Item = &'a GrantAtom;
248 type IntoIter = std::collections::btree_set::Iter<'a, GrantAtom>;
249
250 fn into_iter(self) -> Self::IntoIter {
251 self.0.iter()
252 }
253}
254
255impl fmt::Debug for GrantSet {
256 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257 f.debug_set().entries(self.0.iter()).finish()
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::canonical::Canonical;
265
266 fn atoms() -> Vec<GrantAtom> {
267 vec![
268 GrantAtom::new("fs", "read", "/src/**"),
269 GrantAtom::new("net", "egress", "api.example.com:443"),
270 ]
271 }
272
273 #[test]
274 fn default_grants_nothing() {
275 assert!(GrantSet::default().is_empty());
276 assert!(GrantSet::EMPTY.is_empty());
277 }
278
279 #[test]
280 fn insertion_order_does_not_change_identity() {
281 let forward = GrantSet::new(atoms());
282 let reverse = GrantSet::new(atoms().into_iter().rev());
283 assert_eq!(forward, reverse);
284 assert_eq!(forward.digest().unwrap(), reverse.digest().unwrap());
285 }
286
287 #[test]
288 fn duplicates_collapse() {
289 let mut with_dupes = atoms();
290 with_dupes.extend(atoms());
291 assert_eq!(GrantSet::new(with_dupes).len(), 2);
292 }
293
294 #[test]
295 fn templates_are_detected() {
296 assert!(GrantAtom::new("fs", "read", "{path}").is_template());
297 assert!(!GrantAtom::new("fs", "read", "/src/**").is_template());
298 assert!(GrantSet::new([GrantAtom::new("fs", "read", "{path}")]).has_templates());
299 assert!(!GrantSet::new(atoms()).has_templates());
300 }
301
302 #[test]
305 fn parses_the_documented_three_part_form() {
306 let a: GrantAtom = "fs:read:/src/**".parse().unwrap();
307 assert_eq!(a, GrantAtom::new("fs", "read", "/src/**"));
308 }
309
310 #[test]
311 fn a_resource_may_itself_contain_colons() {
312 let a: GrantAtom = "net:egress:api.example.com:443".parse().unwrap();
314 assert_eq!(a.domain, "net");
315 assert_eq!(a.action, "egress");
316 assert_eq!(a.resource, "api.example.com:443");
317 }
318
319 #[test]
320 fn parses_the_bare_two_part_form() {
321 for s in ["time:now", "rand:bytes"] {
322 let a: GrantAtom = s.parse().unwrap();
323 assert!(a.is_bare(), "{s} should have no resource");
324 assert_eq!(
325 a.to_string(),
326 s,
327 "bare atoms round-trip without a trailing colon"
328 );
329 }
330 assert_eq!(
332 "time:now:".parse::<GrantAtom>().unwrap(),
333 "time:now".parse::<GrantAtom>().unwrap()
334 );
335 }
336
337 #[test]
338 fn every_documented_domain_parses() {
339 let documented = [
340 "fs:read:/src/**",
341 "fs:write:/build/**",
342 "net:egress:api.example.com:443",
343 "proc:exec:git",
344 "env:read:HOME",
345 "secret:use:stripe_key",
346 "time:now",
347 "rand:bytes",
348 ];
349 for s in documented {
350 let a: GrantAtom = s.parse().unwrap_or_else(|e| panic!("{s} failed: {e}"));
351 assert!(a.has_known_domain(), "{s} should use a documented domain");
352 }
353 }
354
355 #[test]
356 fn display_round_trips_through_parse() {
357 for s in [
358 "fs:read:/src/**",
359 "net:egress:api.example.com:443",
360 "fs:read:{path}",
361 "time:now",
362 "fs:read:/a path/with spaces/**",
363 ] {
364 let a: GrantAtom = s.parse().unwrap();
365 assert_eq!(a.to_string().parse::<GrantAtom>().unwrap(), a);
366 }
367 }
368
369 #[test]
370 fn glob_and_template_resources_are_preserved_verbatim() {
371 assert_eq!(
372 "fs:read:/src/**".parse::<GrantAtom>().unwrap().resource,
373 "/src/**"
374 );
375 assert_eq!(
376 "fs:read:/src/*".parse::<GrantAtom>().unwrap().resource,
377 "/src/*"
378 );
379
380 let t: GrantAtom = "fs:read:{path}".parse().unwrap();
381 assert_eq!(t.resource, "{path}");
382 assert!(t.is_template(), "a parsed template is marked symbolic");
383 }
384
385 #[test]
386 fn an_unknown_domain_parses_but_is_flagged() {
387 let a: GrantAtom = "gpu:use:cuda0".parse().unwrap();
388 assert!(!a.has_known_domain());
389 }
390
391 #[test]
392 fn malformed_atoms_name_the_offending_segment() {
393 assert_eq!("".parse::<GrantAtom>(), Err(GrantAtomParseError::Empty));
394 assert_eq!(" ".parse::<GrantAtom>(), Err(GrantAtomParseError::Empty));
395
396 assert!(matches!(
397 "fsread".parse::<GrantAtom>(),
398 Err(GrantAtomParseError::MissingAction { .. })
399 ));
400 assert_eq!(
401 ":read:/src".parse::<GrantAtom>(),
402 Err(GrantAtomParseError::EmptySegment { segment: "domain" })
403 );
404 assert_eq!(
405 "fs::/src".parse::<GrantAtom>(),
406 Err(GrantAtomParseError::EmptySegment { segment: "action" })
407 );
408 assert_eq!(
409 "f s:read:/src".parse::<GrantAtom>(),
410 Err(GrantAtomParseError::Whitespace {
411 segment: "domain",
412 ch: ' '
413 })
414 );
415 }
416
417 #[test]
418 fn a_set_parses_from_strings() {
419 let g = GrantSet::parse(["fs:read:/src/**", "net:egress:api.example.com:443"]).unwrap();
420 assert_eq!(g.len(), 2);
421 assert!(GrantSet::parse(["fs:read:/src/**", "bad"]).is_err());
422 }
423}