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 : }
|