1use crate::users::glob_matches;
53use std::fmt;
54
55const UNIVERSAL: &str = "Universal";
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Field {
63 Vault,
64 Project,
65 Category,
66 Tag,
67 Env,
68 Type,
69}
70
71impl Field {
72 fn parse(s: &str) -> Option<Field> {
73 match s.to_ascii_lowercase().as_str() {
74 "vault" => Some(Field::Vault),
75 "project" | "proj" => Some(Field::Project),
76 "category" | "cat" => Some(Field::Category),
77 "tag" => Some(Field::Tag),
78 "env" | "environment" => Some(Field::Env),
79 "type" | "secrettype" => Some(Field::Type),
80 _ => None,
81 }
82 }
83
84 fn name(self) -> &'static str {
85 match self {
86 Field::Vault => "vault",
87 Field::Project => "project",
88 Field::Category => "category",
89 Field::Tag => "tag",
90 Field::Env => "env",
91 Field::Type => "type",
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum Expr {
99 Pred { field: Field, glob: String },
100 And(Box<Expr>, Box<Expr>),
101 Or(Box<Expr>, Box<Expr>),
102 Not(Box<Expr>),
103}
104
105impl fmt::Display for Expr {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 match self {
109 Expr::Pred { field, glob } => {
110 let needs_quotes = glob.is_empty()
111 || glob
112 .chars()
113 .any(|c| c.is_whitespace() || c == '(' || c == ')' || c == '"');
114 if needs_quotes {
115 write!(f, "{}:\"{}\"", field.name(), glob.replace('"', "\\\""))
116 } else {
117 write!(f, "{}:{}", field.name(), glob)
118 }
119 }
120 Expr::And(a, b) => write!(f, "({a} AND {b})"),
121 Expr::Or(a, b) => write!(f, "({a} OR {b})"),
122 Expr::Not(a) => write!(f, "NOT {a}"),
123 }
124 }
125}
126
127#[derive(Debug, Default, Clone)]
134pub struct EntryView<'a> {
135 pub categories: Vec<&'a str>,
136 pub project_ids: Vec<&'a str>,
137 pub project_names: Vec<String>,
139 pub tags: Vec<&'a str>,
140 pub environment: Option<&'a str>,
141 pub secret_type: Option<&'a str>,
142}
143
144impl<'a> EntryView<'a> {
145 pub fn from_entry(
150 entry: &'a serde_json::Value,
151 project_names: &std::collections::HashMap<String, String>,
152 ) -> EntryView<'a> {
153 let strs = |key: &str| -> Vec<&'a str> {
154 entry
155 .get(key)
156 .and_then(|v| v.as_array())
157 .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
158 .unwrap_or_default()
159 };
160 let project_ids = strs("projectIds");
161 let names = project_ids
162 .iter()
163 .map(|id| {
164 project_names
165 .get(*id)
166 .cloned()
167 .unwrap_or_else(|| (*id).to_string())
168 })
169 .collect();
170 EntryView {
171 categories: strs("categories"),
172 project_ids,
173 project_names: names,
174 tags: strs("tags"),
175 environment: entry.get("environment").and_then(|v| v.as_str()),
176 secret_type: entry.get("secretType").and_then(|v| v.as_str()),
177 }
178 }
179}
180
181pub fn eval(expr: &Expr, entry: &EntryView<'_>) -> bool {
185 match expr {
186 Expr::And(a, b) => eval(a, entry) && eval(b, entry),
187 Expr::Or(a, b) => eval(a, entry) || eval(b, entry),
188 Expr::Not(a) => !eval(a, entry),
189 Expr::Pred { field, glob } => eval_pred(*field, glob, entry),
190 }
191}
192
193fn eval_pred(field: Field, glob: &str, e: &EntryView<'_>) -> bool {
194 let wildcard = glob == "*";
196 match field {
197 Field::Vault => true,
198 Field::Category => wildcard || e.categories.iter().any(|c| glob_matches(glob, c)),
199 Field::Tag => wildcard || e.tags.iter().any(|t| glob_matches(glob, t)),
200 Field::Env => wildcard || e.environment.is_some_and(|v| glob_matches(glob, v)),
201 Field::Type => wildcard || glob_matches(glob, e.secret_type.unwrap_or("api_key")),
203 Field::Project => {
204 if wildcard {
205 return true;
206 }
207 e.project_ids.iter().enumerate().any(|(i, id)| {
210 if *id == UNIVERSAL {
211 return false;
212 }
213 glob_matches(glob, id)
214 || e.project_names
215 .get(i)
216 .is_some_and(|n| glob_matches(glob, n))
217 })
218 }
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
225enum Tok {
226 Pred(Field, String),
227 And,
228 Or,
229 Not,
230 LParen,
231 RParen,
232}
233
234fn lex(src: &str) -> Result<Vec<Tok>, String> {
235 let chars: Vec<char> = src.chars().collect();
236 let mut out = Vec::new();
237 let mut i = 0usize;
238
239 while i < chars.len() {
240 let c = chars[i];
241 if c.is_whitespace() {
242 i += 1;
243 continue;
244 }
245
246 match c {
247 '(' => {
248 out.push(Tok::LParen);
249 i += 1;
250 continue;
251 }
252 ')' => {
253 out.push(Tok::RParen);
254 i += 1;
255 continue;
256 }
257 '!' => {
258 out.push(Tok::Not);
259 i += 1;
260 continue;
261 }
262 '&' => {
263 i += if i + 1 < chars.len() && chars[i + 1] == '&' {
264 2
265 } else {
266 1
267 };
268 out.push(Tok::And);
269 continue;
270 }
271 '|' => {
272 i += if i + 1 < chars.len() && chars[i + 1] == '|' {
273 2
274 } else {
275 1
276 };
277 out.push(Tok::Or);
278 continue;
279 }
280 _ => {}
281 }
282
283 let start = i;
285 while i < chars.len()
286 && !chars[i].is_whitespace()
287 && chars[i] != '('
288 && chars[i] != ')'
289 && chars[i] != ':'
290 {
291 i += 1;
292 }
293 let word: String = chars[start..i].iter().collect();
294 if word.is_empty() {
295 return Err(format!("unexpected character '{c}' at position {start}"));
296 }
297
298 if i >= chars.len() || chars[i] != ':' {
300 match word.to_ascii_uppercase().as_str() {
301 "AND" => out.push(Tok::And),
302 "OR" => out.push(Tok::Or),
303 "NOT" => out.push(Tok::Not),
304 _ => {
305 return Err(format!(
306 "expected AND, OR, NOT or a `field:value` term, found '{word}'"
307 ))
308 }
309 }
310 continue;
311 }
312
313 i += 1; let Some(field) = Field::parse(&word) else {
315 return Err(format!(
316 "unknown field '{word}' — expected one of vault, project, category, tag, env, type"
317 ));
318 };
319
320 let value = if i < chars.len() && chars[i] == '"' {
322 i += 1;
323 let mut v = String::new();
324 loop {
325 if i >= chars.len() {
326 return Err("unterminated quoted value".to_string());
327 }
328 match chars[i] {
329 '\\' if i + 1 < chars.len() => {
330 v.push(chars[i + 1]);
331 i += 2;
332 }
333 '"' => {
334 i += 1;
335 break;
336 }
337 ch => {
338 v.push(ch);
339 i += 1;
340 }
341 }
342 }
343 v
344 } else {
345 let vs = i;
346 while i < chars.len() && !chars[i].is_whitespace() && chars[i] != '(' && chars[i] != ')'
347 {
348 i += 1;
349 }
350 chars[vs..i].iter().collect()
351 };
352
353 if value.is_empty() {
354 return Err(format!(
355 "field '{word}' has no value — write {word}:* to match everything"
356 ));
357 }
358 out.push(Tok::Pred(field, value));
359 }
360
361 Ok(out)
362}
363
364struct Parser {
367 toks: Vec<Tok>,
368 pos: usize,
369}
370
371impl Parser {
372 fn peek(&self) -> Option<&Tok> {
373 self.toks.get(self.pos)
374 }
375 fn next(&mut self) -> Option<Tok> {
376 let t = self.toks.get(self.pos).cloned();
377 self.pos += 1;
378 t
379 }
380
381 fn parse_or(&mut self) -> Result<Expr, String> {
382 let mut lhs = self.parse_and()?;
383 while matches!(self.peek(), Some(Tok::Or)) {
384 self.next();
385 let rhs = self.parse_and()?;
386 lhs = Expr::Or(Box::new(lhs), Box::new(rhs));
387 }
388 Ok(lhs)
389 }
390
391 fn parse_and(&mut self) -> Result<Expr, String> {
392 let mut lhs = self.parse_not()?;
393 while matches!(self.peek(), Some(Tok::And)) {
394 self.next();
395 let rhs = self.parse_not()?;
396 lhs = Expr::And(Box::new(lhs), Box::new(rhs));
397 }
398 Ok(lhs)
399 }
400
401 fn parse_not(&mut self) -> Result<Expr, String> {
402 if matches!(self.peek(), Some(Tok::Not)) {
403 self.next();
404 return Ok(Expr::Not(Box::new(self.parse_not()?)));
405 }
406 self.parse_primary()
407 }
408
409 fn parse_primary(&mut self) -> Result<Expr, String> {
410 match self.next() {
411 Some(Tok::Pred(field, glob)) => Ok(Expr::Pred { field, glob }),
412 Some(Tok::LParen) => {
413 let inner = self.parse_or()?;
414 match self.next() {
415 Some(Tok::RParen) => Ok(inner),
416 _ => Err("missing closing ')'".to_string()),
417 }
418 }
419 Some(Tok::RParen) => Err("unexpected ')'".to_string()),
420 Some(Tok::And) | Some(Tok::Or) => Err("expression begins with an operator".to_string()),
421 Some(Tok::Not) => unreachable!("handled in parse_not"),
422 None => Err("unexpected end of expression".to_string()),
423 }
424 }
425}
426
427pub fn parse(src: &str) -> Result<Expr, String> {
432 let toks = lex(src)?;
433 if toks.is_empty() {
434 return Err("empty expression".to_string());
435 }
436 let mut p = Parser { toks, pos: 0 };
437 let expr = p.parse_or()?;
438 if p.pos != p.toks.len() {
439 return Err("trailing input after the end of the expression".to_string());
440 }
441 Ok(expr)
442}
443
444pub fn eval_str(src: &str, entry: &EntryView<'_>) -> bool {
446 parse(src).map(|e| eval(&e, entry)).unwrap_or(false)
447}
448
449pub fn compile_scopes<'a, I>(scopes: I) -> Option<Expr>
457where
458 I: IntoIterator<Item = (&'a str, &'a str)>,
459{
460 let mut acc: Option<Expr> = None;
461 for (scope_type, scope_value) in scopes {
462 let field = match Field::parse(scope_type) {
463 Some(f) => f,
464 None => continue, };
466 let pred = Expr::Pred {
467 field,
468 glob: scope_value.to_string(),
469 };
470 acc = Some(match acc {
471 None => pred,
472 Some(prev) => Expr::Or(Box::new(prev), Box::new(pred)),
473 });
474 }
475 acc
476}
477
478pub fn any_of(a: Option<Expr>, b: Option<Expr>) -> Option<Expr> {
492 match (a, b) {
493 (Some(x), Some(y)) => Some(Expr::Or(Box::new(x), Box::new(y))),
494 (Some(x), None) | (None, Some(x)) => Some(x),
495 (None, None) => None,
496 }
497}
498
499pub fn combine(class: Option<Expr>, individual: Option<Expr>) -> Option<Expr> {
500 match (class, individual) {
501 (Some(c), Some(i)) => Some(Expr::And(Box::new(c), Box::new(i))),
502 (Some(c), None) => Some(c),
503 (None, Some(i)) => Some(i),
504 (None, None) => None,
505 }
506}
507
508pub fn require_all(expr: Expr) -> Expr {
525 match expr {
526 Expr::Or(a, b) => Expr::And(Box::new(require_all(*a)), Box::new(require_all(*b))),
527 other => other,
528 }
529}
530
531#[cfg(test)]
534mod tests {
535 use super::*;
536 use serde_json::json;
537 use std::collections::HashMap;
538
539 fn names(pairs: &[(&str, &str)]) -> HashMap<String, String> {
540 pairs
541 .iter()
542 .map(|(a, b)| (a.to_string(), b.to_string()))
543 .collect()
544 }
545
546 fn view<'a>(e: &'a serde_json::Value, pn: &HashMap<String, String>) -> EntryView<'a> {
547 EntryView::from_entry(e, pn)
548 }
549
550 fn entry() -> serde_json::Value {
551 json!({
552 "provider": "X",
553 "categories": ["dev", "shared"],
554 "projectIds": ["Universal", "p1"],
555 "tags": ["team-a"],
556 "environment": "production",
557 "secretType": "api_key",
558 })
559 }
560
561 #[test]
562 fn matches_the_shared_typescript_parity_fixture() {
563 let fixture: serde_json::Value =
564 serde_json::from_str(include_str!("../../tests/fixtures/parity/permex.json")).unwrap();
565 let projects = fixture["projects"]
566 .as_array()
567 .unwrap()
568 .iter()
569 .filter_map(|p| {
570 Some((
571 p["id"].as_str()?.to_string(),
572 p["name"].as_str()?.to_string(),
573 ))
574 })
575 .collect::<HashMap<_, _>>();
576
577 for case in fixture["cases"].as_array().unwrap() {
578 let parse_optional = |key: &str| {
579 case[key]
580 .as_str()
581 .filter(|src| !src.is_empty())
582 .map(parse)
583 .transpose()
584 .unwrap_or_else(|err| panic!("{}: {err}", case["name"]))
585 };
586 let mut expr = combine(parse_optional("class"), parse_optional("individual"));
587 if case["strict"].as_bool() == Some(true) {
588 expr = expr.map(require_all);
589 }
590 for (entry, expected) in fixture["entries"]
591 .as_array()
592 .unwrap()
593 .iter()
594 .zip(case["matches"].as_array().unwrap())
595 {
596 assert_eq!(
597 expr.as_ref()
598 .is_some_and(|e| eval(e, &EntryView::from_entry(entry, &projects))),
599 expected.as_bool().unwrap(),
600 "{}: {}",
601 case["name"],
602 entry["id"],
603 );
604 }
605 }
606 }
607
608 #[test]
611 fn precedence_is_not_then_and_then_or() {
612 let e = parse("category:a OR category:b AND category:c").unwrap();
614 assert_eq!(e.to_string(), "(category:a OR (category:b AND category:c))");
615 }
616
617 #[test]
618 fn not_binds_tighter_than_and() {
619 let e = parse("NOT category:a AND category:b").unwrap();
620 assert_eq!(e.to_string(), "(NOT category:a AND category:b)");
621 }
622
623 #[test]
624 fn parentheses_override_precedence() {
625 let e = parse("(category:a OR category:b) AND category:c").unwrap();
626 assert_eq!(e.to_string(), "((category:a OR category:b) AND category:c)");
627 }
628
629 #[test]
630 fn symbolic_operators_are_aliases() {
631 assert_eq!(
632 parse("category:a && category:b").unwrap(),
633 parse("category:a AND category:b").unwrap()
634 );
635 assert_eq!(
636 parse("category:a || category:b").unwrap(),
637 parse("category:a OR category:b").unwrap()
638 );
639 assert_eq!(
640 parse("!category:a").unwrap(),
641 parse("NOT category:a").unwrap()
642 );
643 }
644
645 #[test]
646 fn operators_are_case_insensitive() {
647 assert_eq!(
648 parse("category:a and category:b").unwrap(),
649 parse("category:a AND category:b").unwrap()
650 );
651 }
652
653 #[test]
654 fn quoted_values_may_contain_spaces() {
655 let e = parse(r#"project:"My Project""#).unwrap();
656 assert_eq!(
657 e,
658 Expr::Pred {
659 field: Field::Project,
660 glob: "My Project".into()
661 }
662 );
663 }
664
665 #[test]
666 fn field_aliases_resolve() {
667 assert_eq!(parse("proj:a").unwrap(), parse("project:a").unwrap());
668 assert_eq!(parse("cat:a").unwrap(), parse("category:a").unwrap());
669 }
670
671 #[test]
672 fn expressions_round_trip_through_display() {
673 for src in [
674 "project:a",
675 "(project:a AND NOT category:b)",
676 "((project:a OR tag:x) AND env:production)",
677 ] {
678 let once = parse(src).unwrap();
679 let twice = parse(&once.to_string()).unwrap();
680 assert_eq!(once, twice, "round-trip failed for {src}");
681 }
682 }
683
684 #[test]
687 fn malformed_expressions_are_rejected() {
688 for bad in [
689 "", "category:", "bogus:a", "category:a AND", "AND category:a", "(category:a", "category:a)", "category:a category:b", "just-a-word", ] {
699 assert!(parse(bad).is_err(), "expected {bad:?} to be rejected");
700 }
701 }
702
703 #[test]
704 fn eval_str_denies_on_malformed_input() {
705 let pn = names(&[]);
706 let e = entry();
707 assert!(
708 !eval_str("category:a AND", &view(&e, &pn)),
709 "a broken expression must deny, not permit"
710 );
711 assert!(!eval_str("", &view(&e, &pn)));
712 }
713
714 #[test]
717 fn predicates_match_their_own_field() {
718 let pn = names(&[("p1", "Alpha")]);
719 let e = entry();
720 let v = view(&e, &pn);
721 assert!(eval_str("category:dev", &v));
722 assert!(
723 eval_str("project:Alpha", &v),
724 "project matches by display name"
725 );
726 assert!(eval_str("project:p1", &v), "project matches by id");
727 assert!(eval_str("tag:team-a", &v));
728 assert!(eval_str("env:production", &v));
729 assert!(eval_str("type:api_key", &v));
730 assert!(!eval_str("category:nope", &v));
731 assert!(!eval_str("env:staging", &v));
732 }
733
734 #[test]
735 fn globs_work_inside_predicates() {
736 let pn = names(&[("p1", "Alpha")]);
737 let e = entry();
738 assert!(eval_str("project:Al*", &view(&e, &pn)));
739 assert!(eval_str("tag:team-?", &view(&e, &pn)));
740 assert!(!eval_str("project:Be*", &view(&e, &pn)));
741 }
742
743 #[test]
744 fn boolean_combinations_evaluate() {
745 let pn = names(&[("p1", "Alpha")]);
746 let e = entry();
747 let v = view(&e, &pn);
748 assert!(eval_str("category:dev AND env:production", &v));
749 assert!(!eval_str("category:dev AND env:staging", &v));
750 assert!(eval_str("category:nope OR tag:team-a", &v));
751 assert!(eval_str("project:Alpha AND NOT category:secret", &v));
752 assert!(!eval_str("project:Alpha AND NOT category:dev", &v));
753 assert!(eval_str(
754 "(category:nope OR project:Alpha) AND env:production",
755 &v
756 ));
757 }
758
759 #[test]
760 fn untyped_entries_are_treated_as_api_key() {
761 let pn = names(&[]);
762 let e = json!({ "provider": "X", "projectIds": ["Universal"] });
763 assert!(eval_str("type:api_key", &view(&e, &pn)));
764 }
765
766 #[test]
769 fn wildcards_are_unconditional_across_every_field() {
770 let pn = names(&[]);
771 let e = json!({ "provider": "X", "projectIds": ["Universal"] });
773 let v = view(&e, &pn);
774 for src in [
775 "project:*",
776 "category:*",
777 "tag:*",
778 "env:*",
779 "type:*",
780 "vault:*",
781 ] {
782 assert!(eval_str(src, &v), "{src} must match an unfiled entry");
783 }
784 }
785
786 #[test]
787 fn a_specific_project_grant_is_never_satisfied_by_universal() {
788 let pn = names(&[]);
789 let e = json!({ "provider": "X", "projectIds": ["Universal"] });
790 assert!(
791 !eval_str("project:Universal", &view(&e, &pn)),
792 "matching the catch-all would promote any project grant to vault-wide"
793 );
794 }
795
796 #[test]
799 fn legacy_rows_compile_to_an_or_chain() {
800 let e = compile_scopes(vec![("project", "Alpha"), ("category", "dev")]).unwrap();
801 assert_eq!(e.to_string(), "(project:Alpha OR category:dev)");
802 }
803
804 #[test]
805 fn compiling_no_rows_yields_no_grant() {
806 assert!(compile_scopes(Vec::<(&str, &str)>::new()).is_none());
807 }
808
809 #[test]
810 fn unknown_legacy_scope_types_are_dropped_not_widened() {
811 assert!(compile_scopes(vec![("nonsense", "*")]).is_none());
813 }
814
815 #[test]
816 fn combine_ands_class_with_individual() {
817 let c = parse("project:*").unwrap();
818 let i = parse("NOT category:secret").unwrap();
819 let combined = combine(Some(c), Some(i)).unwrap();
820 assert_eq!(combined.to_string(), "(project:* AND NOT category:secret)");
821 }
822
823 #[test]
824 fn a_class_exclusion_cannot_be_undone_individually() {
825 let pn = names(&[("p1", "Alpha")]);
826 let secret =
827 json!({ "provider": "S", "categories": ["secret"], "projectIds": ["Universal", "p1"] });
828 let combined = combine(
829 Some(parse("NOT category:secret").unwrap()), Some(parse("project:*").unwrap()), )
832 .unwrap();
833 assert!(
834 !eval(&combined, &view(&secret, &pn)),
835 "the class exclusion must win over the individual grant"
836 );
837 }
838
839 #[test]
840 fn no_expressions_at_all_means_no_grant() {
841 assert!(combine(None, None).is_none());
844 }
845}