Skip to main content

acme_proxy/filter/
expr.rs

1//! The condition language a `[filter.rule.<name>]` is written in.
2//!
3//! A rule's `when` is a boolean expression over the names of
4//! `[filter.check.<name>]` entries:
5//!
6//! ```text
7//! when = "mgmt-net or (inventory and corp-names)"
8//! ```
9//!
10//! ```text
11//! expr   := term ( "or" term )*
12//! term   := factor ( "and" factor )*
13//! factor := "not" factor | "(" expr ")" | name
14//! name   := [a-z0-9-]+
15//! ```
16//!
17//! `not` binds tightest, then `and`, then `or`; `and` and `or` are
18//! left-associative. The three keywords are matched case-insensitively, and a
19//! check may therefore not be *named* one of them — [`is_reserved_word`] is
20//! what the policy builder asks, since the tokenizer resolves the ambiguity in
21//! the keyword's favour and a check named `and` would simply be unreachable.
22//!
23//! ## Why a string and not nested TOML
24//!
25//! An `all`/`any`/`none` table would need no parser, but
26//! [`Config::merged_sections`](crate::config::Config) merges a profile's
27//! configuration onto the global one **per key** for tables and *wholesale* for
28//! everything else. A global `all = [...]` and a profile's `any = [...]` would
29//! therefore merge into one table carrying both keys, and the profile would have
30//! meant to replace the condition rather than add to it. A string is a scalar,
31//! so it replaces — the only sane inheritance for a policy expression.
32//!
33//! ## Errors carry a column
34//!
35//! Every failure names the character position it gave up at, because the whole
36//! expression is one line in a configuration file and "invalid condition" would
37//! send an operator hunting through it. Columns are 1-based.
38
39use std::fmt;
40
41/// A parsed `when` expression.
42///
43/// Names are kept as written so an "undefined check" error can quote the
44/// operator's own spelling.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Condition {
47    /// A `[filter.check.<name>]` entry, by name.
48    Check(String),
49    Not(Box<Condition>),
50    And(Box<Condition>, Box<Condition>),
51    Or(Box<Condition>, Box<Condition>),
52}
53
54/// Why an expression would not parse, and where.
55#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
56#[error("{message} at column {column}")]
57pub struct ExprError {
58    /// The problem, without the position — [`fmt::Display`] appends that.
59    pub message: String,
60    /// 1-based character position the parser gave up at.
61    pub column: usize,
62}
63
64impl ExprError {
65    fn new(message: impl Into<String>, column: usize) -> Self {
66        Self {
67            message: message.into(),
68            column,
69        }
70    }
71}
72
73/// Whether `name` is one of the three words the condition language reserves.
74///
75/// The tokenizer always reads these as keywords, so a check carrying one as a
76/// name could never be referred to. The policy builder refuses it at startup
77/// rather than letting it sit there unreachable.
78#[must_use]
79pub fn is_reserved_word(name: &str) -> bool {
80    matches!(
81        name.to_ascii_lowercase().as_str(),
82        KEYWORD_AND | KEYWORD_OR | KEYWORD_NOT
83    )
84}
85
86const KEYWORD_AND: &str = "and";
87const KEYWORD_OR: &str = "or";
88const KEYWORD_NOT: &str = "not";
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91enum TokenKind {
92    Name(String),
93    And,
94    Or,
95    Not,
96    Open,
97    Close,
98}
99
100impl TokenKind {
101    /// How the token is quoted back in an error message.
102    fn describe(&self) -> String {
103        match self {
104            Self::Name(name) => format!("the check name `{name}`"),
105            Self::And => "the keyword `and`".to_string(),
106            Self::Or => "the keyword `or`".to_string(),
107            Self::Not => "the keyword `not`".to_string(),
108            Self::Open => "`(`".to_string(),
109            Self::Close => "`)`".to_string(),
110        }
111    }
112}
113
114#[derive(Debug, Clone)]
115struct Token {
116    kind: TokenKind,
117    column: usize,
118}
119
120/// Splits the expression into tokens, remembering where each one started.
121///
122/// A word is lexed as `[A-Za-z0-9_-]+` rather than the grammar's stricter
123/// `[a-z0-9-]+` for two reasons: the keywords are case-insensitive, so `AND`
124/// has to reach the keyword comparison, and a mis-cased or underscored check
125/// name is far better reported as "no such check" — which quotes what the
126/// operator wrote — than as "unexpected character".
127fn tokenize(source: &str) -> Result<Vec<Token>, ExprError> {
128    let characters: Vec<char> = source.chars().collect();
129    let mut tokens = Vec::new();
130    let mut index = 0;
131
132    while index < characters.len() {
133        let character = characters[index];
134        let column = index + 1;
135
136        if character.is_whitespace() {
137            index += 1;
138            continue;
139        }
140
141        match character {
142            '(' => {
143                tokens.push(Token {
144                    kind: TokenKind::Open,
145                    column,
146                });
147                index += 1;
148            }
149            ')' => {
150                tokens.push(Token {
151                    kind: TokenKind::Close,
152                    column,
153                });
154                index += 1;
155            }
156            c if is_word_character(c) => {
157                let start = index;
158                while index < characters.len() && is_word_character(characters[index]) {
159                    index += 1;
160                }
161                let word: String = characters[start..index].iter().collect();
162                let kind = match word.to_ascii_lowercase().as_str() {
163                    KEYWORD_AND => TokenKind::And,
164                    KEYWORD_OR => TokenKind::Or,
165                    KEYWORD_NOT => TokenKind::Not,
166                    _ => TokenKind::Name(word),
167                };
168                tokens.push(Token { kind, column });
169            }
170            other => {
171                return Err(ExprError::new(
172                    format!("unexpected character {other:?}"),
173                    column,
174                ));
175            }
176        }
177    }
178
179    Ok(tokens)
180}
181
182fn is_word_character(character: char) -> bool {
183    character.is_ascii_alphanumeric() || character == '-' || character == '_'
184}
185
186struct Parser {
187    tokens: Vec<Token>,
188    position: usize,
189    /// Column reported for a failure at end of input.
190    end_column: usize,
191}
192
193impl Parser {
194    fn peek(&self) -> Option<&Token> {
195        self.tokens.get(self.position)
196    }
197
198    fn next_column(&self) -> usize {
199        self.peek().map_or(self.end_column, |token| token.column)
200    }
201
202    /// `expr := term ( "or" term )*`
203    fn parse_expr(&mut self) -> Result<Condition, ExprError> {
204        let mut left = self.parse_term()?;
205        while matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Or)) {
206            self.position += 1;
207            let right = self.parse_term()?;
208            left = Condition::Or(Box::new(left), Box::new(right));
209        }
210        Ok(left)
211    }
212
213    /// `term := factor ( "and" factor )*`
214    fn parse_term(&mut self) -> Result<Condition, ExprError> {
215        let mut left = self.parse_factor()?;
216        while matches!(self.peek().map(|token| &token.kind), Some(TokenKind::And)) {
217            self.position += 1;
218            let right = self.parse_factor()?;
219            left = Condition::And(Box::new(left), Box::new(right));
220        }
221        Ok(left)
222    }
223
224    /// `factor := "not" factor | "(" expr ")" | name`
225    fn parse_factor(&mut self) -> Result<Condition, ExprError> {
226        let Some(token) = self.peek().cloned() else {
227            return Err(ExprError::new(
228                "expected a check name, `not` or `(`",
229                self.end_column,
230            ));
231        };
232
233        match token.kind {
234            TokenKind::Not => {
235                self.position += 1;
236                Ok(Condition::Not(Box::new(self.parse_factor()?)))
237            }
238            TokenKind::Open => {
239                let opened_at = token.column;
240                self.position += 1;
241                let inner = self.parse_expr()?;
242                match self.peek().map(|token| &token.kind) {
243                    Some(TokenKind::Close) => {
244                        self.position += 1;
245                        Ok(inner)
246                    }
247                    _ => Err(ExprError::new("unbalanced `(` opened", opened_at)),
248                }
249            }
250            TokenKind::Name(name) => {
251                self.position += 1;
252                Ok(Condition::Check(name))
253            }
254            other => Err(ExprError::new(
255                format!(
256                    "expected a check name, `not` or `(`, found {}",
257                    other.describe()
258                ),
259                token.column,
260            )),
261        }
262    }
263}
264
265impl Condition {
266    /// Parses a `when` expression.
267    ///
268    /// # Errors
269    ///
270    /// Returns the position of the first token that could not be read, or of
271    /// the `(` that was never closed.
272    pub fn parse(source: &str) -> Result<Self, ExprError> {
273        let tokens = tokenize(source)?;
274        let mut parser = Parser {
275            tokens,
276            position: 0,
277            end_column: source.chars().count() + 1,
278        };
279
280        let condition = parser.parse_expr()?;
281
282        if parser.position < parser.tokens.len() {
283            return Err(ExprError::new(
284                format!(
285                    "unexpected trailing input starting at {}",
286                    parser.tokens[parser.position].kind.describe()
287                ),
288                parser.next_column(),
289            ));
290        }
291
292        Ok(condition)
293    }
294
295    /// Every check name the condition mentions, in evaluation order, with
296    /// duplicates kept — the builder deduplicates when it resolves them.
297    pub fn check_names(&self) -> Vec<&str> {
298        let mut names = Vec::new();
299        self.collect_names(&mut names);
300        names
301    }
302
303    fn collect_names<'a>(&'a self, into: &mut Vec<&'a str>) {
304        match self {
305            Self::Check(name) => into.push(name),
306            Self::Not(inner) => inner.collect_names(into),
307            Self::And(left, right) | Self::Or(left, right) => {
308                left.collect_names(into);
309                right.collect_names(into);
310            }
311        }
312    }
313
314    /// Renders a sub-expression, parenthesized unless it is a bare name.
315    fn render_operand(&self) -> String {
316        match self {
317            Self::Check(name) => name.clone(),
318            other => format!("({other})"),
319        }
320    }
321}
322
323impl fmt::Display for Condition {
324    /// Re-prints the expression with **every** grouping made explicit.
325    ///
326    /// This is what `acme-proxy filter show` prints, and the point is that it
327    /// shows what the parser understood rather than what was typed: an operator
328    /// who expected `a or b and c` to mean `(a or b) and c` sees
329    /// `a or (b and c)` and has their answer.
330    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
331        match self {
332            Self::Check(name) => formatter.write_str(name),
333            Self::Not(inner) => write!(formatter, "not {}", inner.render_operand()),
334            Self::And(left, right) => write!(
335                formatter,
336                "{} and {}",
337                left.render_operand(),
338                right.render_operand()
339            ),
340            Self::Or(left, right) => write!(
341                formatter,
342                "{} or {}",
343                left.render_operand(),
344                right.render_operand()
345            ),
346        }
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    /// Parses and re-prints, which is how every precedence assertion below is
355    /// stated: `Display` makes grouping explicit, so the round trip *is* the
356    /// parse tree.
357    fn printed(source: &str) -> String {
358        Condition::parse(source)
359            .unwrap_or_else(|error| panic!("{source:?} should parse: {error}"))
360            .to_string()
361    }
362
363    fn error(source: &str) -> ExprError {
364        Condition::parse(source).expect_err(&format!("{source:?} should not parse"))
365    }
366
367    #[test]
368    fn a_bare_name_is_a_condition() {
369        assert_eq!(printed("mgmt-net"), "mgmt-net");
370        assert_eq!(
371            Condition::parse("mgmt-net").unwrap(),
372            Condition::Check("mgmt-net".to_string())
373        );
374    }
375
376    #[test]
377    fn and_binds_tighter_than_or() {
378        assert_eq!(printed("a or b and c"), "a or (b and c)");
379        assert_eq!(printed("a and b or c"), "(a and b) or c");
380    }
381
382    #[test]
383    fn not_binds_tightest() {
384        assert_eq!(printed("not a and b"), "(not a) and b");
385        assert_eq!(printed("not a or b"), "(not a) or b");
386        assert_eq!(printed("not not a"), "not (not a)");
387    }
388
389    #[test]
390    fn both_operators_are_left_associative() {
391        assert_eq!(printed("a and b and c"), "(a and b) and c");
392        assert_eq!(printed("a or b or c"), "(a or b) or c");
393    }
394
395    #[test]
396    fn parentheses_override_precedence() {
397        assert_eq!(printed("(a or b) and c"), "(a or b) and c");
398        assert_eq!(printed("not (a and b)"), "not (a and b)");
399        assert_eq!(printed("((a))"), "a");
400    }
401
402    #[test]
403    fn keywords_are_case_insensitive() {
404        assert_eq!(printed("a AND b"), "a and b");
405        assert_eq!(printed("a Or NOT b"), "a or (not b)");
406    }
407
408    #[test]
409    fn whitespace_is_irrelevant() {
410        assert_eq!(printed("  a   and\tb  "), "a and b");
411        assert_eq!(printed("not(a)or(b)"), "(not a) or b");
412    }
413
414    #[test]
415    fn names_may_carry_digits_and_hyphens() {
416        assert_eq!(printed("tenant-a1 and net-10"), "tenant-a1 and net-10");
417    }
418
419    #[test]
420    fn check_names_are_collected_in_evaluation_order_with_duplicates() {
421        let condition = Condition::parse("a or (b and not a)").unwrap();
422        assert_eq!(condition.check_names(), vec!["a", "b", "a"]);
423    }
424
425    #[test]
426    fn the_three_keywords_are_reserved_in_any_case() {
427        for word in ["and", "or", "not", "AND", "Or", "NOT"] {
428            assert!(is_reserved_word(word), "{word} should be reserved");
429        }
430        assert!(!is_reserved_word("android"));
431        assert!(!is_reserved_word("nothing"));
432        assert!(!is_reserved_word("mgmt-net"));
433    }
434
435    /// Every failure mode, with the column it must report. The column is the
436    /// whole point of the error type, so it is asserted rather than the
437    /// message alone.
438    #[test]
439    fn parse_failures_report_a_position() {
440        let cases: &[(&str, &str, usize)] = &[
441            ("", "expected a check name", 1),
442            ("   ", "expected a check name", 4),
443            ("a and", "expected a check name", 6),
444            ("a and )", "expected a check name", 7),
445            ("and b", "expected a check name", 1),
446            ("or b", "expected a check name", 1),
447            ("(a and b", "unbalanced `(` opened", 1),
448            ("a and (b or c", "unbalanced `(` opened", 7),
449            ("a b", "unexpected trailing input", 3),
450            ("a) or b", "unexpected trailing input", 2),
451            ("a and b#c", "unexpected character '#'", 8),
452        ];
453
454        for (source, expected, column) in cases {
455            let error = error(source);
456            assert!(
457                error.message.starts_with(expected),
458                "{source:?}: expected a message starting {expected:?}, got {:?}",
459                error.message
460            );
461            assert_eq!(error.column, *column, "{source:?}: wrong column");
462        }
463    }
464
465    #[test]
466    fn an_error_renders_its_column() {
467        assert_eq!(
468            error("a and").to_string(),
469            "expected a check name, `not` or `(` at column 6"
470        );
471    }
472
473    #[test]
474    fn a_keyword_in_a_name_position_is_named_in_the_error() {
475        assert!(error("a and or b").message.contains("the keyword `or`"));
476    }
477
478    #[test]
479    fn a_trailing_name_is_named_in_the_error() {
480        assert!(error("a b").message.contains("the check name `b`"));
481    }
482
483    /// A mis-cased or underscored name reaches the parser as a *name*, so the
484    /// builder can refuse it by quoting what was written rather than the
485    /// tokenizer refusing the character.
486    #[test]
487    fn an_unconventional_name_parses_and_is_left_for_the_builder() {
488        assert_eq!(printed("Mgmt_Net"), "Mgmt_Net");
489    }
490
491    #[test]
492    fn re_parsing_a_rendered_condition_is_a_fixed_point() {
493        for source in [
494            "a",
495            "a or b and c",
496            "not a and (b or not c)",
497            "((a or b) and c) or not d",
498        ] {
499            let once = printed(source);
500            assert_eq!(printed(&once), once, "{source:?} did not round-trip");
501        }
502    }
503}