LCOV - code coverage report
Current view: top level - antares-ql/src - scope.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 100.0 % 172 172
Test Date: 2026-09-21 10:31:06 Functions: 100.0 % 29 29

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Scope Query Language (CIM 009 clause 4.19), evaluated in memory over the
       3              : //! entity's `scope` member.
       4              : 
       5              : use serde_json::Value;
       6              : 
       7              : /// Scope Query evaluation (4.19) — `|`/`,` = OR, `(a;b)` = AND (parenthesis
       8              : /// grouping), `+` one level, trailing `#` the subtree incl. the node, `/#`
       9              : /// any non-empty scope.
      10         1654 : pub fn scope_matches(scope_q: &str, doc: &Value) -> bool {
      11              :     // scope is an array in the entity internal form, but a bare string is
      12              :     // legal on documents stored verbatim (e.g. registrations, 5.2.9)
      13         1654 :     let scopes: Vec<&str> = match doc.get("scope") {
      14         1284 :         Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(),
      15          328 :         Some(Value::String(s)) => vec![s.as_str()],
      16           42 :         _ => Vec::new(),
      17              :     };
      18         1902 :     scope_q.split([',', '|']).any(|and_group| {
      19         1902 :         and_group
      20         1902 :             .trim()
      21         1902 :             .trim_start_matches('(')
      22         1902 :             .trim_end_matches(')')
      23         1902 :             .split(';')
      24         2272 :             .all(|pat| scopes.iter().any(|s| scope_pattern_matches(pat.trim(), s)))
      25         1902 :     })
      26         1654 : }
      27              : 
      28              : /// The Scope Query that selects what BOTH arguments select (4.19).
      29              : ///
      30              : /// A Scope Query is a disjunction of conjunctions, and the conjunction is
      31              : /// over predicates that are independent of each other — each pattern asks
      32              : /// whether SOME Scope of the Entity matches it — so `and` distributes over
      33              : /// `or` and the intersection is a disjunction of the pairwise unions:
      34              : /// `(a1,a2)` and `(b1,b2)` select what `(a1;b1),(a1;b2),(a2;b1),(a2;b2)`
      35              : /// selects. Every term of the result is a `;`-conjunction of plain
      36              : /// `ScopeQ`s, which is what the grammar's parenthesized `OrScopeQ` derives,
      37              : /// so the answer is a Scope Query and not a broker-private structure.
      38              : ///
      39              : /// `None` when either side contributes no group, or when the product would
      40              : /// exceed [`MAX_SCOPE_Q_BYTES`]: the caller then has an intersection it
      41              : /// cannot express and must refuse rather than serve the wider of the two.
      42           32 : pub fn intersect_scope_q(a: &str, b: &str) -> Option<String> {
      43           64 :     let groups = |s: &str| -> Vec<String> {
      44           64 :         s.split([',', '|'])
      45         1668 :             .map(|g| {
      46         1668 :                 g.trim()
      47         1668 :                     .trim_start_matches('(')
      48         1668 :                     .trim_end_matches(')')
      49         1668 :                     .trim()
      50         1668 :             })
      51         1668 :             .filter(|g| !g.is_empty())
      52           64 :             .map(str::to_owned)
      53           64 :             .collect()
      54           64 :     };
      55           32 :     let (ga, gb) = (groups(a), groups(b));
      56           32 :     if ga.is_empty() || gb.is_empty() {
      57            4 :         return None;
      58           28 :     }
      59           28 :     let mut out = String::new();
      60           38 :     for x in &ga {
      61         1234 :         for y in &gb {
      62         1234 :             if !out.is_empty() {
      63         1206 :                 out.push(',');
      64         1206 :             }
      65              :             // `/#` selects every Entity that carries any Scope at all, so it
      66              :             // adds nothing to a conjunction that already names one — and it
      67              :             // is the one ScopesQ alternative the grammar does not derive
      68              :             // inside a parenthesized group.
      69         1234 :             match (x.as_str(), y.as_str()) {
      70         1234 :                 (ANY_SCOPE, ANY_SCOPE) => out.push_str(ANY_SCOPE),
      71         1228 :                 (ANY_SCOPE, term) | (term, ANY_SCOPE) => out.push_str(term),
      72         1228 :                 (l, r) => {
      73         1228 :                     out.push('(');
      74         1228 :                     out.push_str(l);
      75         1228 :                     out.push(';');
      76         1228 :                     out.push_str(r);
      77         1228 :                     out.push(')');
      78         1228 :                 }
      79              :             }
      80         1234 :             if out.len() > MAX_SCOPE_Q_BYTES {
      81            4 :                 return None;
      82         1230 :             }
      83              :         }
      84              :     }
      85           24 :     Some(out)
      86           32 : }
      87              : 
      88              : /// The ScopesQ that selects every Entity carrying a non-empty Scope (4.19).
      89              : const ANY_SCOPE: &str = "/#";
      90              : 
      91              : /// Ceiling on a Scope Query this crate will build. The store's own compiler
      92              : /// declines a longer one and leaves it to the in-memory arbiter; an
      93              : /// intersection past it is refused instead, so no caller is handed a query
      94              : /// that is quietly wider than the one it asked for.
      95              : pub const MAX_SCOPE_Q_BYTES: usize = 4096;
      96              : 
      97              : /// One 4.19 ScopeQ against one Entity Scope: `/`-separated levels compared
      98              : /// in order, `+` standing for any single level and a trailing `#` for the
      99              : /// rest of the hierarchy including the node itself.
     100         2272 : fn scope_pattern_matches(pat: &str, scope: &str) -> bool {
     101         2272 :     if pat == ANY_SCOPE {
     102          108 :         return true;
     103         2164 :     }
     104         5404 :     let pseg: Vec<&str> = pat.split('/').filter(|s| !s.is_empty()).collect();
     105         5394 :     let sseg: Vec<&str> = scope.split('/').filter(|s| !s.is_empty()).collect();
     106         2164 :     let mut i = 0;
     107         3020 :     for (pi, p) in pseg.iter().enumerate() {
     108         3020 :         if *p == "#" {
     109              :             // multi-level wildcard: matches the rest (including nothing)
     110          668 :             return pi == pseg.len() - 1;
     111         2352 :         }
     112         2352 :         let Some(sv) = sseg.get(i) else { return false };
     113         2300 :         if *p != "+" && p != sv {
     114          956 :             return false;
     115         1344 :         }
     116         1344 :         i += 1;
     117              :     }
     118          488 :     i == sseg.len()
     119         2272 : }
     120              : 
     121              : #[cfg(test)]
     122              : mod clause_4_19 {
     123              :     use super::{intersect_scope_q, scope_matches};
     124              :     use serde_json::json;
     125              : 
     126           66 :     fn doc(scopes: &[&str]) -> serde_json::Value {
     127           66 :         json!({"id": "urn:x", "type": ["T"], "scope": scopes})
     128           66 :     }
     129              : 
     130              :     /// 4.19 EXAMPLES 1-3: direct scope, `#` subtree (including the node
     131              :     /// itself), `+` single-level wildcard, `/#` any non-empty scope.
     132              :     #[test]
     133            2 :     fn wildcards_and_direct_scopes() {
     134            2 :         assert!(scope_matches("/Madrid", &doc(&["/Madrid"])));
     135            2 :         assert!(!scope_matches("/Madrid", &doc(&["/Madrid/Gardens"])));
     136            6 :         for s in [
     137            2 :             "/Madrid/Gardens",
     138            2 :             "/Madrid/Gardens/ParqueNorte",
     139            2 :             "/Madrid/Gardens/ParqueNorte/Parterre1",
     140            2 :         ] {
     141            6 :             assert!(scope_matches("/Madrid/Gardens/#", &doc(&[s])), "{s}");
     142              :         }
     143            2 :         assert!(!scope_matches(
     144            2 :             "/Madrid/Gardens/#",
     145            2 :             &doc(&["/Madrid/Sights"])
     146            2 :         ));
     147            2 :         assert!(scope_matches(
     148            2 :             "/Madrid/+/ParqueNorte",
     149            2 :             &doc(&["/Madrid/Sights/ParqueNorte"])
     150              :         ));
     151            2 :         assert!(!scope_matches(
     152            2 :             "/Madrid/+/ParqueNorte",
     153            2 :             &doc(&["/Madrid/ParqueNorte"])
     154            2 :         ));
     155            2 :         assert!(scope_matches("/#", &doc(&["/Anything"])));
     156            2 :         assert!(
     157            2 :             !scope_matches("/#", &doc(&[])),
     158              :             "no scope = no match for /#"
     159              :         );
     160            2 :     }
     161              : 
     162              :     /// 4.19 EXAMPLES 4/5: conjunction needs parentheses; disjunction is `|`
     163              :     /// OR the compatibility comma.
     164              :     #[test]
     165            2 :     fn conjunction_and_both_or_spellings() {
     166            2 :         let both = doc(&["/Madrid/Districts", "/CompanyA"]);
     167            2 :         let only_b = doc(&["/CompanyB"]);
     168            2 :         let only_madrid = doc(&["/Madrid/Districts"]);
     169            2 :         assert!(scope_matches("(/Madrid/Districts;/CompanyA)", &both));
     170            2 :         assert!(
     171            2 :             !scope_matches("(/Madrid/Districts;/CompanyA)", &only_madrid),
     172              :             "conjunction requires ALL scopes"
     173              :         );
     174            4 :         for sel in [
     175            2 :             "(/Madrid/Districts;/CompanyA)|/CompanyB",
     176            2 :             "(/Madrid/Districts;/CompanyA),/CompanyB",
     177            2 :         ] {
     178            4 :             assert!(scope_matches(sel, &both), "{sel}");
     179            4 :             assert!(scope_matches(sel, &only_b), "{sel}");
     180            4 :             assert!(!scope_matches(sel, &only_madrid), "{sel}");
     181              :         }
     182            2 :     }
     183              : 
     184              :     /// The ABNF puts `andOp` inside the parenthesized `OrScopeQ` alone, so
     185              :     /// `(a;b),(c;d)` is the only spelling it derives — yet the official
     186              :     /// suite's `019_01_06 QueryWithAndScope` sends `a;b` bare and expects
     187              :     /// 200 (`testsuite-doubts.md`). Both are served, and they have to MEAN
     188              :     /// the same thing: `;` binds tighter than `,`/`|`, which is the reading
     189              :     /// the parentheses would have forced. A gateway narrowing a request by
     190              :     /// rewriting `scopeQ` may emit either form.
     191              :     #[test]
     192            2 :     fn a_conjunction_means_the_same_parenthesized_or_bare() {
     193            2 :         let docs = [
     194            2 :             doc(&[]),
     195            2 :             doc(&["/A"]),
     196            2 :             doc(&["/B"]),
     197            2 :             doc(&["/C"]),
     198            2 :             doc(&["/A", "/B"]),
     199            2 :             doc(&["/C", "/D"]),
     200            2 :             doc(&["/A", "/D"]),
     201            2 :             doc(&["/A", "/B", "/C", "/D"]),
     202            2 :         ];
     203           16 :         for d in &docs {
     204           16 :             assert_eq!(
     205           16 :                 scope_matches("(/A;/B),(/C;/D)", d),
     206           16 :                 scope_matches("/A;/B,/C;/D", d),
     207              :                 "the two spellings disagree on {d}"
     208              :             );
     209           16 :             assert_eq!(
     210           16 :                 scope_matches("(/A;/B)|(/C;/D)", d),
     211           16 :                 scope_matches("/A;/B|/C;/D", d),
     212              :                 "the two spellings disagree on {d}"
     213              :             );
     214              :         }
     215              :         // and the grouping is the one the parentheses state, not the other
     216              :         // one: `/A;/B,/C` is `(/A AND /B) OR /C`, never `/A AND (/B OR /C)`.
     217            2 :         assert!(scope_matches("/A;/B,/C", &doc(&["/C"])));
     218            2 :         assert!(!scope_matches("/A;/B,/C", &doc(&["/A"])));
     219            2 :         assert!(scope_matches("/A;/B,/C", &doc(&["/A", "/B"])));
     220            2 :     }
     221              : 
     222              :     /// The intersection has to select what BOTH select, for every Entity —
     223              :     /// a policy engine narrowing a request that brought its own Scope Query
     224              :     /// is the caller, and an intersection that is wider than either side is
     225              :     /// a disclosure.
     226              :     #[test]
     227            2 :     fn an_intersection_selects_exactly_what_both_select() {
     228            2 :         let docs = [
     229            2 :             doc(&[]),
     230            2 :             doc(&["/A"]),
     231            2 :             doc(&["/B"]),
     232            2 :             doc(&["/BB"]),
     233            2 :             doc(&["/BB/Traffic"]),
     234            2 :             doc(&["/A", "/B"]),
     235            2 :             doc(&["/A", "/BB/Traffic"]),
     236            2 :             doc(&["/BB", "/BB/Traffic"]),
     237            2 :             doc(&["/A", "/B", "/BB", "/BB/Traffic"]),
     238            2 :         ];
     239           18 :         for (a, b) in [
     240            2 :             ("/A", "/B"),
     241            2 :             ("/A,/B", "/B,/C"),
     242            2 :             ("/BB", "/BB/Traffic"),
     243            2 :             ("/BB/#", "/BB/Traffic"),
     244            2 :             ("(/A;/B)", "/BB/#"),
     245            2 :             ("(/A;/B),/BB", "(/BB;/BB/Traffic),/A"),
     246            2 :             ("/#", "/A,/B"),
     247            2 :             ("/#", "/#"),
     248            2 :             ("/A", "/A"),
     249            2 :         ] {
     250           18 :             let both = intersect_scope_q(a, b).expect("expressible");
     251          162 :             for d in &docs {
     252          162 :                 assert_eq!(
     253          162 :                     scope_matches(&both, d),
     254          162 :                     scope_matches(a, d) && scope_matches(b, d),
     255              :                     "{a} INTERSECT {b} = {both} disagrees on {d}"
     256              :                 );
     257              :             }
     258              :         }
     259            2 :     }
     260              : 
     261              :     /// Nothing to intersect, and an intersection too large to write down:
     262              :     /// both leave the caller to refuse rather than serve the wider side.
     263              :     #[test]
     264            2 :     fn an_inexpressible_intersection_is_none() {
     265            2 :         assert_eq!(intersect_scope_q("", "/A"), None);
     266            2 :         assert_eq!(intersect_scope_q("/A", "  "), None);
     267            2 :         let wide = (0..200)
     268          400 :             .map(|n| format!("/S{n}"))
     269            2 :             .collect::<Vec<_>>()
     270            2 :             .join(",");
     271            2 :         assert_eq!(intersect_scope_q(&wide, &wide), None, "200x200 groups");
     272            2 :     }
     273              : }
        

Generated by: LCOV version 2.0-1