LCOV - code coverage report
Current view: top level - antares-ql/src - scope.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 100.0 % 269 269
Test Date: 2026-11-02 13:21:27 Functions: 100.0 % 41 41

            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 antares_model::NgsiError;
       6              : use serde_json::Value;
       7              : 
       8              : /// Scope Query evaluation (4.19) — `|`/`,` = OR, `(a;b)` = AND (parenthesis
       9              : /// grouping), `+` one level, trailing `#` the subtree incl. the node, `/#`
      10              : /// any non-empty scope.
      11         8402 : pub fn scope_matches(scope_q: &str, doc: &Value) -> bool {
      12              :     // scope is an array in the entity internal form, but a bare string is
      13              :     // legal on documents stored verbatim (e.g. registrations, 5.2.9)
      14         8402 :     let scopes: Vec<&str> = match doc.get("scope") {
      15         4851 :         Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(),
      16          380 :         Some(Value::String(s)) => vec![s.as_str()],
      17         3171 :         _ => Vec::new(),
      18              :     };
      19         8833 :     scope_q.split([',', '|']).any(|and_group| {
      20         8833 :         and_group
      21         8833 :             .trim()
      22         8833 :             .trim_start_matches('(')
      23         8833 :             .trim_end_matches(')')
      24         8833 :             .split(';')
      25         9100 :             .all(|pat| scopes.iter().any(|s| scope_pattern_matches(pat.trim(), s)))
      26         8833 :     })
      27         8402 : }
      28              : 
      29              : /// Checks a `scopeQ` against the 4.19 grammar (TS 104 175 7.2.5) before it is
      30              : /// evaluated: a disjunction (`,` or `|`) of ScopeQs and parenthesized
      31              : /// `;`-conjunctions of them, each `/level(/level)*` with `+` a whole level and
      32              : /// `#` only as the last one, or `/#`; a level starts with a letter. A bare
      33              : /// conjunction (`/a;/b`) is a 400 like any other text outside the grammar. An empty query, an empty group or pattern (`/a,,/b`,
      34              : /// `/a;;/b`) or an empty level (`//a`) is a 400 BadRequestData: without this
      35              : /// [`scope_matches`] skips the empty piece and answers a wider query than the
      36              : /// one asked. Longer than [`MAX_SCOPE_Q_BYTES`] is a 403 TooComplexQuery.
      37              : ///
      38              : /// # Errors
      39              : /// [`NgsiError::BadRequestData`] or [`NgsiError::TooComplexQuery`] as above.
      40          376 : pub fn check_scope_q(scope_q: &str) -> Result<(), NgsiError> {
      41          376 :     if scope_q.len() > MAX_SCOPE_Q_BYTES {
      42            2 :         return Err(NgsiError::TooComplexQuery(format!(
      43            2 :             "scopeQ is longer than {MAX_SCOPE_Q_BYTES} bytes"
      44            2 :         )));
      45          374 :     }
      46          374 :     let bad = |why: &str| NgsiError::BadRequestData(format!("invalid scopeQ {scope_q:?}: {why}"));
      47          822 :     for group in scope_q.split([',', '|']) {
      48          822 :         let group = group.trim();
      49          822 :         let inner = match (group.strip_prefix('('), group.strip_suffix(')')) {
      50           34 :             (Some(_), Some(_)) => group.get(1..group.len().saturating_sub(1)).unwrap_or(""),
      51              :             // TS 104 175 7.2.5: "For logical and grouping parenthesis are needed"
      52          782 :             (None, None) if group.contains(';') => {
      53           41 :                 return Err(bad("a ';' conjunction needs parentheses, e.g. (/a;/b)"));
      54              :             }
      55          741 :             (None, None) => group,
      56            6 :             _ => return Err(bad("unbalanced parenthesis")),
      57              :         };
      58          775 :         if inner.is_empty() {
      59           26 :             return Err(bad("empty group"));
      60          749 :         }
      61          781 :         for pat in inner.split(';') {
      62          781 :             check_scope_pattern(pat.trim()).map_err(bad)?;
      63              :         }
      64              :     }
      65          236 :     Ok(())
      66          376 : }
      67              : 
      68          781 : fn check_scope_pattern(pat: &str) -> Result<(), &'static str> {
      69          781 :     if pat.is_empty() {
      70            4 :         return Err("empty pattern");
      71          777 :     }
      72          777 :     let Some(rest) = pat.strip_prefix('/') else {
      73            2 :         return Err("a ScopeQ starts with '/'");
      74              :     };
      75          775 :     let levels: Vec<&str> = rest.split('/').collect();
      76          775 :     let last = levels.len().saturating_sub(1);
      77         1467 :     for (i, level) in levels.iter().enumerate() {
      78           74 :         match *level {
      79         1467 :             "" => return Err("empty scope level"),
      80         1446 :             "#" if i != last => return Err("'#' is only the last level"),
      81         1372 :             "#" | "+" => {}
      82              :             // ScopeQLevel = unicodeLetter *(unicodeNumber / unicodeLetter / "_"), the
      83              :             // grammar every stored Scope already follows (antares-jsonld valid_scope_value)
      84         1358 :             l => {
      85         1358 :                 let mut ch = l.chars();
      86         1358 :                 if !(ch.next().is_some_and(char::is_alphabetic)
      87         3686 :                     && ch.all(|c| c.is_alphabetic() || c.is_numeric() || c == '_'))
      88              :                 {
      89           32 :                     return Err("a scope level is a letter, then letters, digits or '_'");
      90         1326 :                 }
      91              :             }
      92              :         }
      93              :     }
      94          716 :     Ok(())
      95          781 : }
      96              : 
      97              : /// The Scope Query that selects what BOTH arguments select (4.19).
      98              : ///
      99              : /// A Scope Query is a disjunction of conjunctions, and the conjunction is
     100              : /// over predicates that are independent of each other — each pattern asks
     101              : /// whether SOME Scope of the Entity matches it — so `and` distributes over
     102              : /// `or` and the intersection is a disjunction of the pairwise unions:
     103              : /// `(a1,a2)` and `(b1,b2)` select what `(a1;b1),(a1;b2),(a2;b1),(a2;b2)`
     104              : /// selects. Every term of the result is a `;`-conjunction of plain
     105              : /// `ScopeQ`s, which is what the grammar's parenthesized `OrScopeQ` derives,
     106              : /// so the answer is a Scope Query and not a broker-private structure.
     107              : ///
     108              : /// `None` when either side contributes no group, or when the product would
     109              : /// exceed [`MAX_SCOPE_Q_BYTES`]: the caller then has an intersection it
     110              : /// cannot express and must refuse rather than serve the wider of the two.
     111           36 : pub fn intersect_scope_q(a: &str, b: &str) -> Option<String> {
     112           72 :     let groups = |s: &str| -> Vec<String> {
     113           72 :         s.split([',', '|'])
     114         1676 :             .map(|g| {
     115         1676 :                 g.trim()
     116         1676 :                     .trim_start_matches('(')
     117         1676 :                     .trim_end_matches(')')
     118         1676 :                     .trim()
     119         1676 :             })
     120         1676 :             .filter(|g| !g.is_empty())
     121           72 :             .map(str::to_owned)
     122           72 :             .collect()
     123           72 :     };
     124           36 :     let (ga, gb) = (groups(a), groups(b));
     125           36 :     if ga.is_empty() || gb.is_empty() {
     126            4 :         return None;
     127           32 :     }
     128           32 :     let mut out = String::new();
     129           42 :     for x in &ga {
     130         1238 :         for y in &gb {
     131         1238 :             if !out.is_empty() {
     132         1206 :                 out.push(',');
     133         1206 :             }
     134              :             // `/#` selects every Entity that carries any Scope at all, so it
     135              :             // adds nothing to a conjunction that already names one — and it
     136              :             // is the one ScopesQ alternative the grammar does not derive
     137              :             // inside a parenthesized group.
     138         1238 :             match (x.as_str(), y.as_str()) {
     139         1238 :                 (ANY_SCOPE, ANY_SCOPE) => out.push_str(ANY_SCOPE),
     140         1228 :                 (ANY_SCOPE, term) | (term, ANY_SCOPE) => out.push_str(term),
     141         1228 :                 (l, r) => {
     142         1228 :                     out.push('(');
     143         1228 :                     out.push_str(l);
     144         1228 :                     out.push(';');
     145         1228 :                     out.push_str(r);
     146         1228 :                     out.push(')');
     147         1228 :                 }
     148              :             }
     149         1238 :             if out.len() > MAX_SCOPE_Q_BYTES {
     150            6 :                 return None;
     151         1232 :             }
     152              :         }
     153              :     }
     154           26 :     Some(out)
     155           36 : }
     156              : 
     157              : /// The ScopesQ that selects every Entity carrying a non-empty Scope (4.19).
     158              : const ANY_SCOPE: &str = "/#";
     159              : 
     160              : /// Ceiling on a Scope Query this crate will build. The store's own compiler
     161              : /// declines a longer one and leaves it to the in-memory arbiter; an
     162              : /// intersection past it is refused instead, so no caller is handed a query
     163              : /// that is quietly wider than the one it asked for.
     164              : pub const MAX_SCOPE_Q_BYTES: usize = 4096;
     165              : 
     166              : /// One 4.19 ScopeQ against one Entity Scope: `/`-separated levels compared
     167              : /// in order, `+` standing for any single level and a trailing `#` for the
     168              : /// rest of the hierarchy including the node itself.
     169         6303 : fn scope_pattern_matches(pat: &str, scope: &str) -> bool {
     170         6303 :     if pat == ANY_SCOPE {
     171          172 :         return true;
     172         6131 :     }
     173        14104 :     let pseg: Vec<&str> = pat.split('/').filter(|s| !s.is_empty()).collect();
     174        14345 :     let sseg: Vec<&str> = scope.split('/').filter(|s| !s.is_empty()).collect();
     175         6131 :     let mut i = 0;
     176         7559 :     for (pi, p) in pseg.iter().enumerate() {
     177         7559 :         if *p == "#" {
     178              :             // multi-level wildcard: matches the rest (including nothing)
     179          792 :             return pi.saturating_add(1) == pseg.len();
     180         6767 :         }
     181         6767 :         let Some(sv) = sseg.get(i) else { return false };
     182         6618 :         if *p != "+" && p != sv {
     183         1404 :             return false;
     184         5214 :         }
     185         5214 :         i = i.saturating_add(1);
     186              :     }
     187         3786 :     i == sseg.len()
     188         6303 : }
     189              : 
     190              : #[cfg(test)]
     191              : mod clause_4_19 {
     192              :     use super::{check_scope_q, intersect_scope_q, scope_matches, MAX_SCOPE_Q_BYTES};
     193              :     use serde_json::json;
     194              : 
     195           66 :     fn doc(scopes: &[&str]) -> serde_json::Value {
     196           66 :         json!({"id": "urn:x", "type": ["T"], "scope": scopes})
     197           66 :     }
     198              : 
     199              :     /// 4.19 EXAMPLES 1-3: direct scope, `#` subtree (including the node
     200              :     /// itself), `+` single-level wildcard, `/#` any non-empty scope.
     201              :     #[test]
     202            2 :     fn wildcards_and_direct_scopes() {
     203            2 :         assert!(scope_matches("/Madrid", &doc(&["/Madrid"])));
     204            2 :         assert!(!scope_matches("/Madrid", &doc(&["/Madrid/Gardens"])));
     205            6 :         for s in [
     206            2 :             "/Madrid/Gardens",
     207            2 :             "/Madrid/Gardens/ParqueNorte",
     208            2 :             "/Madrid/Gardens/ParqueNorte/Parterre1",
     209            2 :         ] {
     210            6 :             assert!(scope_matches("/Madrid/Gardens/#", &doc(&[s])), "{s}");
     211              :         }
     212            2 :         assert!(!scope_matches(
     213            2 :             "/Madrid/Gardens/#",
     214            2 :             &doc(&["/Madrid/Sights"])
     215            2 :         ));
     216            2 :         assert!(scope_matches(
     217            2 :             "/Madrid/+/ParqueNorte",
     218            2 :             &doc(&["/Madrid/Sights/ParqueNorte"])
     219              :         ));
     220            2 :         assert!(!scope_matches(
     221            2 :             "/Madrid/+/ParqueNorte",
     222            2 :             &doc(&["/Madrid/ParqueNorte"])
     223            2 :         ));
     224            2 :         assert!(scope_matches("/#", &doc(&["/Anything"])));
     225            2 :         assert!(
     226            2 :             !scope_matches("/#", &doc(&[])),
     227              :             "no scope = no match for /#"
     228              :         );
     229            2 :     }
     230              : 
     231              :     /// 4.19 EXAMPLES 4/5: conjunction needs parentheses; disjunction is `|`
     232              :     /// OR the compatibility comma.
     233              :     #[test]
     234            2 :     fn conjunction_and_both_or_spellings() {
     235            2 :         let both = doc(&["/Madrid/Districts", "/CompanyA"]);
     236            2 :         let only_b = doc(&["/CompanyB"]);
     237            2 :         let only_madrid = doc(&["/Madrid/Districts"]);
     238            2 :         assert!(scope_matches("(/Madrid/Districts;/CompanyA)", &both));
     239            2 :         assert!(
     240            2 :             !scope_matches("(/Madrid/Districts;/CompanyA)", &only_madrid),
     241              :             "conjunction requires ALL scopes"
     242              :         );
     243            4 :         for sel in [
     244            2 :             "(/Madrid/Districts;/CompanyA)|/CompanyB",
     245            2 :             "(/Madrid/Districts;/CompanyA),/CompanyB",
     246            2 :         ] {
     247            4 :             assert!(scope_matches(sel, &both), "{sel}");
     248            4 :             assert!(scope_matches(sel, &only_b), "{sel}");
     249            4 :             assert!(!scope_matches(sel, &only_madrid), "{sel}");
     250              :         }
     251            2 :     }
     252              : 
     253              :     /// The ABNF puts `andOp` inside the parenthesized `OrScopeQ` alone, so
     254              :     /// `(a;b),(c;d)` is the only spelling it derives, and [`check_scope_q`]
     255              :     /// refuses the bare `a;b` at every entry (forge cc8711ce moved
     256              :     /// `019_01_06_05` to the parenthesized form and added `019_03_07_05`).
     257              :     /// [`scope_matches`] still evaluates a bare conjunction an internal
     258              :     /// caller builds, and the two spellings have to MEAN the same thing:
     259              :     /// `;` binds tighter than `,`/`|`, the reading the parentheses force.
     260              :     #[test]
     261            2 :     fn a_conjunction_means_the_same_parenthesized_or_bare() {
     262            2 :         let docs = [
     263            2 :             doc(&[]),
     264            2 :             doc(&["/A"]),
     265            2 :             doc(&["/B"]),
     266            2 :             doc(&["/C"]),
     267            2 :             doc(&["/A", "/B"]),
     268            2 :             doc(&["/C", "/D"]),
     269            2 :             doc(&["/A", "/D"]),
     270            2 :             doc(&["/A", "/B", "/C", "/D"]),
     271            2 :         ];
     272           16 :         for d in &docs {
     273           16 :             assert_eq!(
     274           16 :                 scope_matches("(/A;/B),(/C;/D)", d),
     275           16 :                 scope_matches("/A;/B,/C;/D", d),
     276              :                 "the two spellings disagree on {d}"
     277              :             );
     278           16 :             assert_eq!(
     279           16 :                 scope_matches("(/A;/B)|(/C;/D)", d),
     280           16 :                 scope_matches("/A;/B|/C;/D", d),
     281              :                 "the two spellings disagree on {d}"
     282              :             );
     283              :         }
     284              :         // and the grouping is the one the parentheses state, not the other
     285              :         // one: `/A;/B,/C` is `(/A AND /B) OR /C`, never `/A AND (/B OR /C)`.
     286            2 :         assert!(scope_matches("/A;/B,/C", &doc(&["/C"])));
     287            2 :         assert!(!scope_matches("/A;/B,/C", &doc(&["/A"])));
     288            2 :         assert!(scope_matches("/A;/B,/C", &doc(&["/A", "/B"])));
     289            2 :     }
     290              : 
     291              :     /// The intersection has to select what BOTH select, for every Entity —
     292              :     /// a policy engine narrowing a request that brought its own Scope Query
     293              :     /// is the caller, and an intersection that is wider than either side is
     294              :     /// a disclosure.
     295              :     #[test]
     296            2 :     fn an_intersection_selects_exactly_what_both_select() {
     297            2 :         let docs = [
     298            2 :             doc(&[]),
     299            2 :             doc(&["/A"]),
     300            2 :             doc(&["/B"]),
     301            2 :             doc(&["/BB"]),
     302            2 :             doc(&["/BB/Traffic"]),
     303            2 :             doc(&["/A", "/B"]),
     304            2 :             doc(&["/A", "/BB/Traffic"]),
     305            2 :             doc(&["/BB", "/BB/Traffic"]),
     306            2 :             doc(&["/A", "/B", "/BB", "/BB/Traffic"]),
     307            2 :         ];
     308           18 :         for (a, b) in [
     309            2 :             ("/A", "/B"),
     310            2 :             ("/A,/B", "/B,/C"),
     311            2 :             ("/BB", "/BB/Traffic"),
     312            2 :             ("/BB/#", "/BB/Traffic"),
     313            2 :             ("(/A;/B)", "/BB/#"),
     314            2 :             ("(/A;/B),/BB", "(/BB;/BB/Traffic),/A"),
     315            2 :             ("/#", "/A,/B"),
     316            2 :             ("/#", "/#"),
     317            2 :             ("/A", "/A"),
     318            2 :         ] {
     319           18 :             let both = intersect_scope_q(a, b).expect("expressible");
     320          162 :             for d in &docs {
     321          162 :                 assert_eq!(
     322          162 :                     scope_matches(&both, d),
     323          162 :                     scope_matches(a, d) && scope_matches(b, d),
     324              :                     "{a} INTERSECT {b} = {both} disagrees on {d}"
     325              :                 );
     326              :             }
     327              :         }
     328            2 :     }
     329              : 
     330              :     /// Nothing to intersect, and an intersection too large to write down:
     331              :     /// both leave the caller to refuse rather than serve the wider side.
     332              :     #[test]
     333            2 :     fn an_inexpressible_intersection_is_none() {
     334            2 :         assert_eq!(intersect_scope_q("", "/A"), None);
     335            2 :         assert_eq!(intersect_scope_q("/A", "  "), None);
     336            2 :         let wide = (0..200)
     337          400 :             .map(|n| format!("/S{n}"))
     338            2 :             .collect::<Vec<_>>()
     339            2 :             .join(",");
     340            2 :         assert_eq!(intersect_scope_q(&wide, &wide), None, "200x200 groups");
     341            2 :     }
     342              : 
     343              :     /// A Scope stored as a bare string (a registration kept verbatim) is
     344              :     /// matched like a one-element array; the intersection may be exactly
     345              :     /// MAX_SCOPE_Q_BYTES long, one byte more is refused.
     346              :     #[test]
     347            2 :     fn bare_string_scope_and_the_intersection_ceiling() {
     348              :         use serde_json::json;
     349            2 :         assert!(scope_matches("/a", &json!({"scope": "/a"})));
     350            2 :         assert!(!scope_matches("/b", &json!({"scope": "/a"})));
     351            4 :         let at = |n: usize| format!("/{}", "s".repeat(n - 1));
     352            2 :         assert_eq!(
     353            2 :             intersect_scope_q("/#", &at(super::MAX_SCOPE_Q_BYTES)).map(|s| s.len()),
     354              :             Some(super::MAX_SCOPE_Q_BYTES)
     355              :         );
     356            2 :         assert_eq!(
     357            2 :             intersect_scope_q("/#", &at(super::MAX_SCOPE_Q_BYTES + 1)),
     358              :             None
     359              :         );
     360            2 :     }
     361              : 
     362              :     /// 4.19 grammar: the shapes the examples use pass; an empty piece, a
     363              :     /// level after `#`, or a missing leading `/` is a 400, never a silent
     364              :     /// widening; past the ceiling is a 403.
     365              :     #[test]
     366            2 :     fn check_scope_q_follows_the_grammar() {
     367           22 :         for ok in [
     368            2 :             "/Madrid",
     369            2 :             "/Madrid/+/ParqueNorte",
     370            2 :             "/Madrid/#",
     371            2 :             "/#",
     372            2 :             "/Madrid,/Berlin",
     373            2 :             "/Madrid|/Berlin",
     374            2 :             "(/Madrid;/Gardens),/Berlin",
     375            2 :             "(/Madrid/Gardens/ParqueNorte;/CompanyA/OrganizationB/UnitC)",
     376            2 :             "/Madrid/Gardens/ParqueNorte/#",
     377            2 :             "/Madrid/+/#",
     378            2 :             "/Ma_drid2/Été",
     379            2 :         ] {
     380           22 :             assert!(check_scope_q(ok).is_ok(), "{ok}");
     381              :         }
     382           40 :         for bad in [
     383            2 :             "",
     384            2 :             "/a,,/b",
     385            2 :             "/a;;/b",
     386            2 :             "//a",
     387            2 :             "/a/",
     388            2 :             "Madrid",
     389            2 :             "/a/#/b",
     390            2 :             "(/a;/b",
     391            2 :             "()",
     392            2 :             "/a b",
     393            2 :             "/a#",
     394            2 :             // TS 104 175 7.2.5 (forge 019_03_07): "For logical and grouping parenthesis are
     395            2 :             // needed", and a ScopeQLevel is a unicodeLetter then letters, numbers or "_"
     396            2 :             "/a;/b",
     397            2 :             "/Madrid;",
     398            2 :             "/a;/b,/c",
     399            2 :             "/Madrid/1st",
     400            2 :             "/Madrid/+G",
     401            2 :             "/a.b",
     402            2 :             "/a-b",
     403            2 :             // an empty pattern inside a group
     404            2 :             "(/a;;/b)",
     405            2 :             "(/Madrid;)",
     406            2 :         ] {
     407           40 :             let err = check_scope_q(bad).unwrap_err();
     408           40 :             assert_eq!(err.status(), 400, "{bad:?}: {err}");
     409              :         }
     410            2 :         let long = "/a,".repeat(MAX_SCOPE_Q_BYTES);
     411            2 :         assert_eq!(check_scope_q(&long).unwrap_err().status(), 403);
     412            2 :     }
     413              : }
        

Generated by: LCOV version 2.0-1