LCOV - code coverage report
Current view: top level - antares-sql/src/compile - scope.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 98.7 % 153 151
Test Date: 2026-09-21 10:31:06 Functions: 80.8 % 26 21

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Scope Query Language (CIM 009 clause 4.19) compiled to SQL over the
       3              : //! extracted `scopes text[]` column (GIN-indexed).
       4              : //!
       5              : //! Same one-directional contract as `q` (see `compile::q`): this may only
       6              : //! NARROW. `antares_api::scope_matches` stays the arbiter, so a predicate
       7              : //! that is slightly looser than the matcher is fine and one that is stricter
       8              : //! is a compliance bug. Every construct below is therefore built to be
       9              : //! loose-or-equal on purpose:
      10              : //!
      11              : //! * separators match `/+`, not `/`, because the matcher drops empty segments
      12              : //!   — `/A//B` is two segments to it and must not be excluded here;
      13              : //! * leading and trailing slashes are optional on both sides, for the same
      14              : //!   reason;
      15              : //! * `+` is one segment, `#` is "the rest, including nothing", and `#` is only
      16              : //!   a wildcard in final position (`scope_pattern_matches` returns false for a
      17              : //!   non-terminal `#`) — a pattern that puts it elsewhere refuses to compile.
      18              : //!
      19              : //! The generated regex is OURS; the only client-supplied text inside it is a
      20              : //! literal segment, which is regex-escaped and then travels as a bind.
      21              : 
      22              : use antares_ql::sql::CompiledSql;
      23              : 
      24              : /// Longest `scopeQ` compiled. One pattern is one bind and the whole thing is
      25              : /// unbounded upstream (a POST query body carries it too), so past this ceiling
      26              : /// the statement could ask for more placeholders than the wire protocol has —
      27              : /// and a refusal only means the matcher does the work.
      28              : const MAX_SCOPE_Q_BYTES: usize = 4096;
      29              : 
      30              : /// Compile `scope_q` into a predicate over `col` (a `text[]`).
      31              : /// `None` = outside the exact subset; the caller filters in memory.
      32           72 : pub fn compile_scope_q(scope_q: &str, col: &str, first_bind: usize) -> Option<CompiledSql> {
      33              :     // A NUL has no representation in a `text` value, so binding one raises in
      34              :     // the database — a 500 in `postgres` mode for a query `memory` mode
      35              :     // answers (`antares_ql::sql::literal` refuses one for the same reason).
      36           72 :     if scope_q.len() > MAX_SCOPE_Q_BYTES || scope_q.contains('\0') {
      37           12 :         return None;
      38           60 :     }
      39           60 :     let mut binds = Vec::new();
      40           60 :     let mut or_parts = Vec::new();
      41              :     // 4.19: orOp = `|` / `,`; a conjunction is parenthesized — the parens
      42              :     // only group and must not reach the per-segment regexes.
      43          280 :     for and_group in scope_q.split([',', '|']) {
      44          280 :         let and_group = and_group
      45          280 :             .trim()
      46          280 :             .trim_start_matches('(')
      47          280 :             .trim_end_matches(')');
      48          280 :         let mut and_parts = Vec::new();
      49          310 :         for pat in and_group.split(';') {
      50          310 :             let re = pattern_regex(pat.trim())?;
      51              :             // "some scope of this entity matches the pattern" — the SQL
      52              :             // spelling of the matcher's `scopes.iter().any(...)`. An entity
      53              :             // with no scopes matches nothing, exactly as `any()` over an
      54              :             // empty list is false.
      55          308 :             and_parts.push(format!(
      56              :                 "EXISTS (SELECT 1 FROM unnest({col}) AS s WHERE s ~ ${})",
      57          308 :                 first_bind + binds.len()
      58              :             ));
      59          308 :             binds.push(re);
      60              :         }
      61          278 :         if and_parts.is_empty() {
      62            0 :             return None;
      63          278 :         }
      64          278 :         or_parts.push(format!("({})", and_parts.join(" AND ")));
      65              :     }
      66           58 :     if or_parts.is_empty() {
      67            0 :         return None;
      68           58 :     }
      69           58 :     Some(CompiledSql {
      70           58 :         sql: format!("({})", or_parts.join(" OR ")),
      71           58 :         binds,
      72           58 :     })
      73           72 : }
      74              : 
      75              : /// One scope pattern → an anchored POSIX regex over a stored scope string.
      76          310 : fn pattern_regex(pat: &str) -> Option<String> {
      77          648 :     let segs: Vec<&str> = pat.split('/').filter(|s| !s.is_empty()).collect();
      78              :     // `/#` (or a bare `#`) matches any scope at all — the matcher short-
      79              :     // circuits to true before it even looks at the segments.
      80          310 :     if segs.is_empty() || segs == ["#"] {
      81           12 :         return Some("^.*$".to_owned());
      82          298 :     }
      83          298 :     let mut out = String::from("^/*");
      84          330 :     for (i, seg) in segs.iter().enumerate() {
      85          330 :         let last = i == segs.len() - 1;
      86          330 :         if *seg == "#" {
      87            8 :             if !last {
      88            2 :                 return None; // non-terminal `#` never matches; refuse to guess
      89            6 :             }
      90              :             // "the rest, including nothing": the separator is part of the
      91              :             // optional group so `/A/#` still matches the scope `/A`.
      92            6 :             out.push_str("(/+.*)?");
      93            6 :             out.push_str("/*$");
      94            6 :             return Some(out);
      95          322 :         }
      96          322 :         if i > 0 {
      97           24 :             out.push_str("/+");
      98          298 :         }
      99          322 :         if *seg == "+" {
     100            4 :             out.push_str("[^/]+");
     101          318 :         } else {
     102          318 :             out.push_str(&escape(seg));
     103          318 :         }
     104              :     }
     105          290 :     out.push_str("/*$");
     106          290 :     Some(out)
     107          310 : }
     108              : 
     109              : /// POSIX-ERE escaping. Postgres `~` is ERE, so the metacharacter set is
     110              : /// fixed and small; anything outside it is passed through unchanged.
     111          318 : fn escape(s: &str) -> String {
     112          318 :     let mut out = String::with_capacity(s.len());
     113          458 :     for c in s.chars() {
     114          458 :         if "\\^$.[]|()*+?{}".contains(c) {
     115           12 :             out.push('\\');
     116          446 :         }
     117          458 :         out.push(c);
     118              :     }
     119          318 :     out
     120          318 : }
     121              : 
     122              : #[cfg(test)]
     123              : mod tests {
     124              :     use super::*;
     125              : 
     126              :     /// The matcher this compiler must never be stricter than — copied here as
     127              :     /// a REFERENCE ONLY for the shape of the cases. The authoritative parity
     128              :     /// proof runs both paths against a live database
     129              :     /// (`antares-api/tests/pg_query_parity.rs`).
     130              :     #[test]
     131            2 :     fn literal_and_wildcards_produce_anchored_regexes() {
     132            2 :         let c = compile_scope_q("/Madrid/Gardens", "scopes", 3).expect("compiles");
     133            2 :         assert_eq!(c.binds.len(), 1);
     134            2 :         assert_eq!(c.binds[0], "^/*Madrid/+Gardens/*$");
     135            2 :         assert!(c.sql.contains("unnest(scopes)"));
     136            2 :         assert!(c.sql.contains("$3"));
     137              : 
     138            2 :         assert_eq!(
     139            2 :             compile_scope_q("/Madrid/+/Park", "scopes", 1)
     140            2 :                 .expect("c")
     141            2 :                 .binds[0],
     142              :             "^/*Madrid/+[^/]+/+Park/*$"
     143              :         );
     144            2 :         assert_eq!(
     145            2 :             compile_scope_q("/Madrid/#", "scopes", 1).expect("c").binds[0],
     146              :             "^/*Madrid(/+.*)?/*$"
     147              :         );
     148            2 :         assert_eq!(
     149            2 :             compile_scope_q("/#", "scopes", 1).expect("c").binds[0],
     150              :             "^.*$"
     151              :         );
     152            2 :     }
     153              : 
     154              :     #[test]
     155            2 :     fn and_or_structure_matches_the_language() {
     156              :         // `,` = OR of AND-groups, `;` = AND inside one
     157            2 :         let c = compile_scope_q("/A;/B,/C", "scopes", 1).expect("compiles");
     158            2 :         assert_eq!(c.binds.len(), 3);
     159            2 :         assert_eq!(c.sql.matches(" AND ").count(), 1, "sql: {}", c.sql);
     160            2 :         assert_eq!(c.sql.matches(" OR ").count(), 1, "sql: {}", c.sql);
     161              :         // the AND-group is bracketed as one OR operand — precedence is not
     162              :         // left to the reader
     163            2 :         assert!(c.sql.starts_with("((EXISTS"), "sql: {}", c.sql);
     164            2 :         assert!(c.sql.contains("$3"), "sql: {}", c.sql);
     165            2 :     }
     166              : 
     167              :     /// The pushdown has to read a `scopeQ` the way the in-memory arbiter
     168              :     /// does, or `postgres` answers a query `memory` answers differently.
     169              :     /// The ABNF derives only the parenthesized conjunction, the official
     170              :     /// suite sends it bare (`testsuite-doubts.md`), and a gateway narrowing
     171              :     /// a request may emit either — so the two spellings must compile to the
     172              :     /// same predicate over the same binds.
     173              :     #[test]
     174            2 :     fn a_conjunction_compiles_the_same_parenthesized_or_bare() {
     175            6 :         for (parens, bare) in [
     176            2 :             ("(/A;/B),(/C;/D)", "/A;/B,/C;/D"),
     177            2 :             ("(/A;/B)|(/C;/D)", "/A;/B|/C;/D"),
     178            2 :             ("(/A;/B),/C", "/A;/B,/C"),
     179            2 :         ] {
     180            6 :             let p = compile_scope_q(parens, "scopes", 1).expect("parenthesized compiles");
     181            6 :             let b = compile_scope_q(bare, "scopes", 1).expect("bare compiles");
     182            6 :             assert_eq!(p.sql, b.sql, "{parens} vs {bare}");
     183            6 :             assert_eq!(p.binds, b.binds, "{parens} vs {bare}");
     184              :         }
     185            2 :     }
     186              : 
     187              :     #[test]
     188            2 :     fn non_terminal_multilevel_wildcard_refuses() {
     189              :         // `scope_pattern_matches` only honours `#` in final position; rather
     190              :         // than reproduce its "returns false" branch, leave it to the matcher.
     191            2 :         assert!(compile_scope_q("/A/#/B", "scopes", 1).is_none());
     192            2 :     }
     193              : 
     194              :     #[test]
     195            2 :     fn regex_metacharacters_in_a_segment_are_escaped_not_syntax() {
     196            2 :         let c = compile_scope_q("/a.b+c", "scopes", 1).expect("compiles");
     197            2 :         assert_eq!(c.binds[0], "^/*a\\.b\\+c/*$");
     198            2 :     }
     199              : 
     200              :     /// A scope level is `unicodeLetter *(unicodeNumber / unicodeLetter / "_")`
     201              :     /// (4.19 ABNF), but nothing upstream enforces that grammar — so anything a
     202              :     /// client sends must land in a bind, escaped, and never in the statement.
     203              :     #[test]
     204            2 :     fn client_text_never_reaches_the_statement() {
     205            2 :         let c = compile_scope_q("/a' OR 1=1 --", "scopes", 1).expect("compiles");
     206            6 :         for needle in ["OR 1=1", "--", "'"] {
     207            6 :             assert!(!c.sql.contains(needle), "{needle:?} leaked: {}", c.sql);
     208              :         }
     209            2 :         assert_eq!(
     210              :             c.sql,
     211              :             "((EXISTS (SELECT 1 FROM unnest(scopes) AS s WHERE s ~ $1)))"
     212              :         );
     213            2 :         assert_eq!(c.binds, vec!["^/*a' OR 1=1 --/*$"]);
     214              :         // a regex-level injection is escaped in the bind, not passed through
     215            2 :         let c = compile_scope_q("/(a).*", "scopes", 1).expect("compiles");
     216            2 :         assert_eq!(c.binds, vec!["^/*\\(a\\)\\.\\*/*$"]);
     217            2 :     }
     218              : 
     219              :     /// Every degenerate group must widen. `^.*$` matches any stored scope, so
     220              :     /// an empty pattern can only ADD rows for the matcher to reject — the one
     221              :     /// direction this compiler is allowed to be wrong in.
     222              :     #[test]
     223            2 :     fn degenerate_groups_widen_instead_of_narrowing() {
     224            8 :         for q in ["", "/", "/A,", ";/A"] {
     225            8 :             let c = compile_scope_q(q, "scopes", 1).unwrap_or_else(|| panic!("{q} compiles"));
     226            8 :             assert!(
     227           10 :                 c.binds.iter().any(|b| b == "^.*$"),
     228              :                 "{q} must widen, not narrow: {:?}",
     229              :                 c.binds
     230              :             );
     231              :         }
     232            2 :     }
     233              : 
     234              :     /// `scopeQ` has no length ceiling upstream, and one pattern is one bind —
     235              :     /// a POST-body scopeQ can otherwise ask for more placeholders than the
     236              :     /// wire protocol has. Past the ceiling the matcher does the work.
     237              :     #[test]
     238            2 :     fn an_oversized_scope_query_is_left_to_the_matcher() {
     239            2 :         let huge = vec!["/A"; 40_000].join(",");
     240            2 :         assert!(compile_scope_q(&huge, "scopes", 1).is_none());
     241            2 :         let ok = vec!["/A"; 100].join(",");
     242            2 :         assert_eq!(
     243            2 :             compile_scope_q(&ok, "scopes", 1)
     244            2 :                 .expect("compiles")
     245            2 :                 .binds
     246            2 :                 .len(),
     247              :             100
     248              :         );
     249            2 :     }
     250              : 
     251              :     /// A NUL has no representation in a `text` value, so a bind carrying one
     252              :     /// raises in the database — a 500 in `postgres` mode for a query `memory`
     253              :     /// mode answers from the matcher. `antares_ql::sql::literal` refuses one
     254              :     /// for the same reason; refusing here leaves the verdict to the matcher,
     255              :     /// which compares the byte like any other. Refusing ONE group has to
     256              :     /// refuse the whole query: dropping a branch of an OR would narrow.
     257              :     #[test]
     258            2 :     fn a_nul_never_reaches_a_bind() {
     259            8 :         for q in ["\0", "/A\0B", "/A/B,/A\0", "(/A;/\0)"] {
     260            8 :             assert!(
     261            8 :                 compile_scope_q(q, "scopes", 1).is_none(),
     262              :                 "{q:?} must not reach a bind"
     263              :             );
     264              :         }
     265              :         // and the matcher does answer it — a NUL is a byte in a segment
     266            2 :         let doc = serde_json::json!({"scope": ["/A\u{0}B"]});
     267            2 :         assert!(antares_ql::scope::scope_matches("/A\0B", &doc));
     268            2 :         assert!(!antares_ql::scope::scope_matches("/AB", &doc));
     269            2 :     }
     270              : 
     271              :     /// Doubled slashes: the matcher drops empty segments, so `/A//B` IS a
     272              :     /// match for `/A/B`. A `/`-exact regex would drop that row.
     273              :     #[test]
     274            2 :     fn separators_tolerate_repeats_so_the_matcher_is_never_undercut() {
     275            2 :         let re = &compile_scope_q("/A/B", "scopes", 1).expect("c").binds[0];
     276            2 :         assert!(re.contains("/+"), "separator must be repeatable: {re}");
     277            2 :         assert!(re.starts_with("^/*"), "leading slash optional: {re}");
     278            2 :         assert!(re.ends_with("/*$"), "trailing slash optional: {re}");
     279            2 :     }
     280              : }
     281              : 
     282              : #[cfg(test)]
     283              : mod clause_4_19 {
     284              :     use super::*;
     285              : 
     286              :     /// 4.19 EXAMPLE 5: `(a;b)|c` — the pipe is an orOp and the parentheses
     287              :     /// only group; neither may leak into the compiled per-scope regexes
     288              :     /// (a stricter predicate than the in-memory matcher is a compliance bug).
     289              :     #[test]
     290            2 :     fn pipe_or_and_parenthesized_conjunction_compile() {
     291            2 :         let c = compile_scope_q("(/Madrid/Districts;/CompanyA)|/CompanyB", "scopes", 1)
     292            2 :             .expect("compiles");
     293            2 :         assert_eq!(c.binds.len(), 3, "two ANDed + one ORed pattern");
     294            2 :         assert!(c.sql.contains(" OR "), "the pipe is a disjunction");
     295            2 :         assert!(
     296            6 :             c.binds.iter().all(|b| !b.contains('(') && !b.contains('|')),
     297              :             "grouping characters must not leak into the regexes: {:?}",
     298              :             c.binds
     299              :         );
     300            2 :     }
     301              : }
        

Generated by: LCOV version 2.0-1