Skip to main content

confium_attributes/
dsl.rs

1//! DSL parser for predicate expressions.
2//!
3//! Supports a small expression language for predicates:
4//!
5//! ```text
6//! min_count("attribute", N)
7//! min_distinct("attribute", N)
8//! none("attribute")
9//! any("attribute")
10//! all("attribute")
11//! and(P1, P2, ...)
12//! or(P1, P2, ...)
13//! not(P)
14//! ```
15
16use crate::ast::Predicate;
17
18/// Maximum nesting depth for recursive DSL constructs (and/or/not).
19/// Limits stack-overflow DoS via adversarial inputs. 32 is generous
20/// (allows `and(and(and(...)))` 32 levels deep) and small enough to
21/// keep Rust stack usage bounded.
22pub const MAX_DSL_DEPTH: usize = 32;
23
24/// Parse a DSL expression into a `Predicate`.
25pub fn parse(expr: &str) -> Result<Predicate, ParseError> {
26    let trimmed = expr.trim();
27    parse_expr(trimmed, 0).map(|(p, _)| p)
28}
29
30/// DSL parse errors.
31#[derive(Debug, thiserror::Error)]
32pub enum ParseError {
33    /// Unexpected end of input.
34    #[error("unexpected end of input at: {0}")]
35    UnexpectedEof(String),
36    /// Unexpected character.
37    #[error("unexpected character '{0}' at position {1}")]
38    UnexpectedChar(char, usize),
39    /// Unknown function.
40    #[error("unknown function: {0}")]
41    UnknownFunction(String),
42    /// Argument count wrong.
43    #[error("argument count mismatch for {0}")]
44    ArgCount(String),
45    /// Number parse error.
46    #[error("number parse error: {0}")]
47    NumberParse(String),
48    /// Recursion depth exceeded (DoS guard).
49    #[error("DSL recursion depth {depth} exceeds max {max}")]
50    DepthExceeded { depth: usize, max: usize },
51}
52
53fn parse_expr(s: &str, depth: usize) -> Result<(Predicate, &str), ParseError> {
54    if depth >= MAX_DSL_DEPTH {
55        return Err(ParseError::DepthExceeded {
56            depth,
57            max: MAX_DSL_DEPTH,
58        });
59    }
60    let s = s.trim();
61    let (name, rest) = parse_ident(s)?;
62    let rest = rest.trim_start();
63    let rest = eat(rest, '(')?;
64    let rest = rest.trim_start();
65
66    match name.as_str() {
67        "min_count" => {
68            let (attr, rest) = parse_string(rest)?;
69            let rest = eat(rest.trim_start(), ',')?;
70            let (count_str, rest) = parse_number(rest.trim_start())?;
71            let (_, rest) = parse_until_close(rest.trim_start())?;
72            let count: usize = count_str
73                .parse()
74                .map_err(|_| ParseError::NumberParse(count_str))?;
75            Ok((
76                Predicate::MinCount {
77                    attribute: attr,
78                    count,
79                },
80                rest,
81            ))
82        }
83        "min_distinct" => {
84            let (attr, rest) = parse_string(rest)?;
85            let rest = eat(rest.trim_start(), ',')?;
86            let (count_str, rest) = parse_number(rest.trim_start())?;
87            let (_, rest) = parse_until_close(rest.trim_start())?;
88            let count: usize = count_str
89                .parse()
90                .map_err(|_| ParseError::NumberParse(count_str))?;
91            Ok((
92                Predicate::MinDistinct {
93                    attribute: attr,
94                    count,
95                },
96                rest,
97            ))
98        }
99        "none" => {
100            let (attr, rest) = parse_string(rest)?;
101            let rest = parse_until_close(rest.trim_start())?.1;
102            Ok((Predicate::None { attribute: attr }, rest))
103        }
104        "any" => {
105            let (attr, rest) = parse_string(rest)?;
106            let rest = parse_until_close(rest.trim_start())?.1;
107            Ok((Predicate::Any { attribute: attr }, rest))
108        }
109        "all" => {
110            let (attr, rest) = parse_string(rest)?;
111            let rest = parse_until_close(rest.trim_start())?.1;
112            Ok((Predicate::All { attribute: attr }, rest))
113        }
114        "and" | "or" => {
115            let mut preds = Vec::new();
116            let mut s = rest;
117            loop {
118                let s2 = s.trim_start();
119                if s2.starts_with(')') {
120                    break;
121                }
122                let (p, rest) = parse_expr(s2, depth + 1)?;
123                preds.push(p);
124                s = rest.trim_start();
125                if s.starts_with(',') {
126                    s = &s[1..];
127                } else if s.starts_with(')') {
128                    break;
129                } else {
130                    return Err(ParseError::UnexpectedEof(s.into()));
131                }
132            }
133            let rest = eat(s, ')')?;
134            if name == "and" {
135                Ok((Predicate::And(preds), rest))
136            } else {
137                Ok((Predicate::Or(preds), rest))
138            }
139        }
140        "not" => {
141            let (inner, rest) = parse_expr(rest, depth + 1)?;
142            let rest = eat(rest.trim_start(), ')')?;
143            Ok((Predicate::Not(Box::new(inner)), rest))
144        }
145        other => Err(ParseError::UnknownFunction(other.into())),
146    }
147}
148
149fn parse_ident(s: &str) -> Result<(String, &str), ParseError> {
150    let mut chars = s.char_indices();
151    let mut end = 0;
152    for (i, c) in chars.by_ref() {
153        if c.is_alphanumeric() || c == '_' {
154            end = i + c.len_utf8();
155        } else {
156            break;
157        }
158    }
159    if end == 0 {
160        return Err(ParseError::UnexpectedEof(s.into()));
161    }
162    Ok((s[..end].to_string(), &s[end..]))
163}
164
165fn parse_string(s: &str) -> Result<(String, &str), ParseError> {
166    let s = eat(s, '"')?;
167    let end = s
168        .find('"')
169        .ok_or_else(|| ParseError::UnexpectedEof(s.into()))?;
170    Ok((s[..end].to_string(), &s[end + 1..]))
171}
172
173fn parse_number(s: &str) -> Result<(String, &str), ParseError> {
174    let end = s
175        .char_indices()
176        .take_while(|(_, c)| c.is_ascii_digit())
177        .last()
178        .map(|(i, c)| i + c.len_utf8())
179        .unwrap_or(0);
180    if end == 0 {
181        return Err(ParseError::NumberParse(s.into()));
182    }
183    Ok((s[..end].to_string(), &s[end..]))
184}
185
186fn eat(s: &str, ch: char) -> Result<&str, ParseError> {
187    let s = s.trim_start();
188    if s.starts_with(ch) {
189        Ok(&s[ch.len_utf8()..])
190    } else {
191        Err(ParseError::UnexpectedChar(
192            s.chars().next().unwrap_or(' '),
193            0,
194        ))
195    }
196}
197
198fn parse_until_close(s: &str) -> Result<(String, &str), ParseError> {
199    let s = eat(s, ')')?;
200    Ok((String::new(), s))
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn parses_min_count() {
209        let p = parse(r#"min_count("role:director", 5)"#).unwrap();
210        match p {
211            Predicate::MinCount { attribute, count } => {
212                assert_eq!(attribute, "role:director");
213                assert_eq!(count, 5);
214            }
215            _ => panic!("wrong variant"),
216        }
217    }
218
219    #[test]
220    fn parses_any() {
221        let p = parse(r#"any("expertise")"#).unwrap();
222        assert!(matches!(p, Predicate::Any { .. }));
223    }
224}
225
226#[cfg(test)]
227mod depth_tests {
228    use super::*;
229
230    #[test]
231    fn shallow_nesting_parses() {
232        let mut expr = String::from("any(\"x\")");
233        for _ in 0..5 {
234            expr = format!("not({expr})");
235        }
236        parse(&expr).expect("5-level not() should parse");
237    }
238
239    #[test]
240    fn deep_nesting_rejected() {
241        // 64 levels of not(not(...)) exceeds MAX_DSL_DEPTH (32).
242        let mut expr = String::from("any(\"x\")");
243        for _ in 0..64 {
244            expr = format!("not({expr})");
245        }
246        let err = parse(&expr).expect_err("64-level not() should be rejected");
247        assert!(matches!(err, ParseError::DepthExceeded { .. }));
248    }
249}