LCOV - code coverage report
Current view: top level - antares-jsonld/src - context.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.6 % 502 500
Test Date: 2026-09-21 10:31:06 Functions: 96.3 % 54 52

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! JSON-LD context processing — the NGSI-LD-subset processor (hand-rolled,
       3              : //! no `json-ld` crate dependency).
       4              : //!
       5              : //! A [`Context`] is the merged, resolved term map for one request: user
       6              : //! contexts (in order, later wins) with the core context merged last (core
       7              : //! terms take precedence, CIM 009 4.4: core terms are protected).
       8              : 
       9              : use antares_model::NgsiError;
      10              : use serde_json::{Map, Value};
      11              : use std::collections::HashMap;
      12              : 
      13              : /// The default vocabulary IRI the core context sets: unknown terms expand here.
      14              : pub const DEFAULT_VOCAB: &str = "https://uri.etsi.org/ngsi-ld/default-context/";
      15              : 
      16              : /// NGSI-LD core vocabulary base (terms like location, observedAt, …).
      17              : pub const NGSI_LD_BASE: &str = "https://uri.etsi.org/ngsi-ld/";
      18              : 
      19              : /// One term definition of a processed @context.
      20              : #[derive(Debug, Clone, Default)]
      21              : pub struct TermDef {
      22              :     /// The absolute IRI the term expands to.
      23              :     pub iri: String,
      24              :     /// `@type: @id` — values are IRIs (compact them on output).
      25              :     pub type_is_id: bool,
      26              :     /// `@type: @vocab` — values are vocab terms.
      27              :     pub type_is_vocab: bool,
      28              :     /// `@container: @list`
      29              :     pub container_list: bool,
      30              :     /// term may be used as a prefix (its IRI ends with a gen-delim, or it was
      31              :     /// defined as a plain string mapping — JSON-LD 1.1 simple-term rule).
      32              :     pub prefix_ok: bool,
      33              : }
      34              : 
      35              : /// A processed (merged, frozen) JSON-LD @context: term map, its inverse for
      36              : /// compaction, and the `@vocab` fallback.
      37              : #[derive(Debug, Default)]
      38              : pub struct Context {
      39              :     terms: HashMap<String, TermDef>,
      40              :     /// IRI → term for compaction (built after merge; shortest term wins).
      41              :     inverse: HashMap<String, String>,
      42              :     /// Prefix compaction index, built by [`Context::freeze`]: the IRI of
      43              :     /// every prefix-capable term → the term to write it as, with the ties
      44              :     /// already resolved. Probing it by candidate LENGTH keeps `compact_iri`
      45              :     /// off a walk over the whole term map, whose cost an @context of 20 000
      46              :     /// terms would otherwise multiply into every attribute of every entity
      47              :     /// in a response.
      48              :     prefixes: HashMap<String, String>,
      49              :     /// The distinct lengths of the `prefixes` keys, longest first — the only
      50              :     /// candidate cut points, so the longest match is the first hit.
      51              :     prefix_lens: Vec<usize>,
      52              :     /// `@vocab`: the base unknown terms expand against.
      53              :     pub vocab: String,
      54              :     /// The @context value to hand back in responses (Link header / body):
      55              :     /// what the client sent, before the implicit core merge.
      56              :     pub source: Value,
      57              :     /// Total bytes of expanded term IRIs merged so far — see [`Context::charge`].
      58              :     bytes: usize,
      59              : }
      60              : 
      61              : impl Context {
      62              :     /// Merge one raw `@context` object (its term definitions) into this
      63              :     /// context. Later calls override earlier definitions.
      64              :     ///
      65              :     /// The object is a user @context, so 5.5.7's Scoped Context
      66              :     /// prohibition applies to it; [`Context::merge_core_object`] is the
      67              :     /// entry point for the normative Core documents.
      68          560 :     pub fn merge_object(&mut self, obj: &Map<String, Value>) -> Result<(), NgsiError> {
      69          560 :         self.merge_definitions(obj, true)
      70          560 :     }
      71              : 
      72              :     /// Merge one raw `@context` object of a Core @context document.
      73              :     ///
      74              :     /// 5.5.7 bars Scoped Contexts from the *user* @context, and says why:
      75              :     /// they "could be used to modify terms defined in the Core @context or
      76              :     /// to reshape NGSI-LD Elements during the expansion of terms". The Core
      77              :     /// @context is the thing being protected, not a thing to protect
      78              :     /// against, and Annex B (V1.9.1) defines one Scoped Context of its own,
      79              :     /// on `ngsildproof`. Merging that document as a user @context would
      80              :     /// reject the normative core.
      81         6298 :     pub fn merge_core_object(&mut self, obj: &Map<String, Value>) -> Result<(), NgsiError> {
      82         6298 :         self.merge_definitions(obj, false)
      83         6298 :     }
      84              : 
      85              :     /// Merge term definitions, rejecting Scoped Contexts (5.5.7) only when
      86              :     /// `user_context` says the object came from a client.
      87         6858 :     fn merge_definitions(
      88         6858 :         &mut self,
      89         6858 :         obj: &Map<String, Value>,
      90         6858 :         user_context: bool,
      91         6858 :     ) -> Result<(), NgsiError> {
      92         6858 :         if let Some(v) = obj.get("@vocab").and_then(Value::as_str) {
      93         6302 :             self.vocab = v.to_owned();
      94         6302 :         }
      95              :         // Two passes so terms can reference prefixes defined in the same object.
      96              :         // Pass 1: raw string mappings that look like absolute IRIs (prefix seeds).
      97      2137184 :         for (term, def) in obj {
      98      2137184 :             if term.starts_with('@') {
      99        18874 :                 continue;
     100      2118310 :             }
     101      2118310 :             if let Some(s) = def.as_str() {
     102      1652614 :                 if is_absolute_iri(s) {
     103      1627390 :                     self.terms.insert(
     104      1627390 :                         term.clone(),
     105      1627390 :                         TermDef {
     106      1627390 :                             iri: s.to_owned(),
     107      1627390 :                             prefix_ok: true,
     108      1627390 :                             ..Default::default()
     109      1627390 :                         },
     110      1627390 :                     );
     111      1627390 :                 }
     112       465696 :             }
     113              :         }
     114              :         // Pass 2: everything, resolving compact IRIs against known terms.
     115      2134328 :         for (term, def) in obj {
     116      2134328 :             if term.starts_with('@') {
     117        18874 :                 continue;
     118      2115454 :             }
     119      2115454 :             match def {
     120      1649758 :                 Value::String(s) => {
     121      1649758 :                     let iri = self.expand_iri_for_def(s);
     122      1649758 :                     self.charge(&iri)?;
     123      1649756 :                     self.terms.insert(
     124      1649756 :                         term.clone(),
     125      1649756 :                         TermDef {
     126      1649756 :                             iri,
     127      1649756 :                             prefix_ok: true,
     128      1649756 :                             ..Default::default()
     129      1649756 :                         },
     130              :                     );
     131              :                 }
     132       465688 :                 Value::Object(o) => {
     133              :                     // 5.5.7: user @contexts shall not contain JSON-LD Scoped
     134              :                     // Contexts — a per-term @context could override core
     135              :                     // terms or reshape elements during expansion →
     136              :                     // BadRequestData. A Core document carries its own
     137              :                     // (Annex B defines one) and is merged without this.
     138              :                     // ponytail: a Core Scoped Context is dropped rather than
     139              :                     // applied, so its inner terms expand through @vocab;
     140              :                     // honouring it needs a per-term active context.
     141       465688 :                     if user_context && o.contains_key("@context") {
     142            6 :                         return Err(NgsiError::BadRequestData(format!(
     143            6 :                             "term {term:?}: JSON-LD Scoped Contexts are not \
     144            6 :                              allowed in a user @context (5.5.7)"
     145            6 :                         )));
     146       465682 :                     }
     147       465682 :                     let id = match o.get("@id").and_then(Value::as_str) {
     148       465678 :                         Some(id) => self.expand_iri_for_def(id),
     149              :                         // No @id: the term maps into the active vocabulary
     150              :                         // (or is a keyword alias we skip). The core @context
     151              :                         // is merged last (4.4), so its @vocab is not set yet
     152              :                         // — fall back to it rather than leave a RELATIVE IRI
     153              :                         // that 4.5.1 then has to reject.
     154              :                         None => {
     155            4 :                             if o.contains_key("@container") || o.contains_key("@type") {
     156            2 :                                 match self.expand_iri_for_def(term) {
     157            2 :                                     iri if is_absolute_iri(&iri) => iri,
     158            2 :                                     _ => format!("{}{term}", vocab_or_default(&self.vocab)),
     159              :                                 }
     160              :                             } else {
     161            2 :                                 continue;
     162              :                             }
     163              :                         }
     164              :                     };
     165       465680 :                     self.charge(&id)?;
     166       465680 :                     let t = o.get("@type").and_then(Value::as_str).unwrap_or("");
     167       465680 :                     let c = o.get("@container").and_then(Value::as_str).unwrap_or("");
     168       465680 :                     self.terms.insert(
     169       465680 :                         term.clone(),
     170       465680 :                         TermDef {
     171       465680 :                             iri: id,
     172       465680 :                             type_is_id: t == "@id",
     173       465680 :                             type_is_vocab: t == "@vocab",
     174       465680 :                             container_list: c == "@list",
     175       465680 :                             prefix_ok: o.get("@prefix").and_then(Value::as_bool).unwrap_or(false),
     176       465680 :                         },
     177              :                     );
     178              :                 }
     179            2 :                 Value::Null => {
     180            2 :                     self.terms.remove(term);
     181            2 :                 }
     182            6 :                 _ => {}
     183              :             }
     184              :         }
     185         6850 :         Ok(())
     186         6858 :     }
     187              : 
     188              :     /// 5.5.6: an `@context` that "is invalid" is BadRequestData. The term map
     189              :     /// is built from client-supplied documents, and a chain of prefix
     190              :     /// definitions (`"t1": "t0:…"`, `"t2": "t1:…"`, …) makes every definition
     191              :     /// carry the whole chain, so N terms expand to O(N²) bytes. The merged
     192              :     /// IRIs are budgeted against the ceiling one @context document may
     193              :     /// occupy, on the Context rather than per call so a chain split across
     194              :     /// several documents cannot walk past it.
     195      2115438 :     fn charge(&mut self, iri: &str) -> Result<(), NgsiError> {
     196      2115438 :         self.bytes += iri.len();
     197      2115438 :         if self.bytes > crate::loader::MAX_CONTEXT_BYTES {
     198            2 :             return Err(NgsiError::BadRequestData(
     199            2 :                 "@context term definitions exceed the maximum size".into(),
     200            2 :             ));
     201      2115436 :         }
     202      2115436 :         Ok(())
     203      2115438 :     }
     204              : 
     205              :     /// Expand an IRI-position string inside a term definition (@id values).
     206      2115438 :     fn expand_iri_for_def(&self, s: &str) -> String {
     207              :         // JSON-LD keywords ("@type", "@id", …) stay as-is: a term aliased to a
     208              :         // keyword must expand to the keyword, never into the vocab.
     209      2115438 :         if s.starts_with('@') {
     210        12598 :             return s.to_owned();
     211      2102840 :         }
     212      2102840 :         if let Some((prefix, suffix)) = s.split_once(':') {
     213      2090204 :             if !suffix.starts_with("//") {
     214      1222042 :                 if let Some(def) = self.terms.get(prefix) {
     215      1221988 :                     if def.prefix_ok {
     216      1221988 :                         return format!("{}{}", def.iri, suffix);
     217            0 :                     }
     218           54 :                 }
     219       868162 :             }
     220       868216 :             if is_absolute_iri(s) {
     221       868216 :                 return s.to_owned();
     222            0 :             }
     223        12636 :         }
     224        12636 :         if let Some(def) = self.terms.get(s) {
     225          632 :             return def.iri.clone();
     226        12004 :         }
     227        12004 :         if !self.vocab.is_empty() {
     228        11960 :             return format!("{}{}", self.vocab, s);
     229           44 :         }
     230           44 :         s.to_owned()
     231      2115438 :     }
     232              : 
     233              :     /// Build the compaction inverse map. Call once after all merges.
     234         6172 :     pub fn freeze(&mut self) {
     235         6172 :         let mut inv: HashMap<String, String> = HashMap::new();
     236      2050114 :         for (term, def) in &self.terms {
     237      2050114 :             match inv.get(&def.iri) {
     238          557 :                 Some(existing)
     239          732 :                     if (existing.len(), existing.as_str()) <= (term.len(), term.as_str()) => {}
     240      2049557 :                 _ => {
     241      2049557 :                     inv.insert(def.iri.clone(), term.clone());
     242      2049557 :                 }
     243              :             }
     244              :         }
     245         6172 :         self.inverse = inv;
     246              :         // The prefix index carries the same tie-break the inverse map does
     247              :         // (shortest term, then lexicographic): `terms` is a randomly-seeded
     248              :         // HashMap, so an unresolved tie would pick a different prefix in
     249              :         // each process.
     250         6172 :         let mut pfx: HashMap<String, String> = HashMap::new();
     251      2050114 :         for (term, def) in &self.terms {
     252      2050114 :             if !def.prefix_ok || def.iri.is_empty() {
     253       442700 :                 continue;
     254      1607414 :             }
     255      1607414 :             match pfx.get(&def.iri) {
     256          557 :                 Some(existing)
     257          732 :                     if (existing.len(), existing.as_str()) <= (term.len(), term.as_str()) => {}
     258      1606857 :                 _ => {
     259      1606857 :                     pfx.insert(def.iri.clone(), term.clone());
     260      1606857 :                 }
     261              :             }
     262              :         }
     263         6172 :         let mut lens: Vec<usize> = pfx.keys().map(String::len).collect();
     264         6172 :         lens.sort_unstable();
     265         6172 :         lens.dedup();
     266         6172 :         lens.reverse();
     267         6172 :         self.prefixes = pfx;
     268         6172 :         self.prefix_lens = lens;
     269         6172 :     }
     270              : 
     271              :     /// The definition of `term`, if the context defines it.
     272           24 :     pub fn term(&self, term: &str) -> Option<&TermDef> {
     273           24 :         self.terms.get(term)
     274           24 :     }
     275              : 
     276              :     /// How many terms this context defines. The merged-context cache charges
     277              :     /// an entry by this rather than counting entries: an @context document
     278              :     /// may spend its whole byte budget on short mappings, and a term map of
     279              :     /// hundreds of thousands of terms is what the cache would then hold,
     280              :     /// times its entry ceiling.
     281          550 :     pub fn term_count(&self) -> usize {
     282          550 :         self.terms.len()
     283          550 :     }
     284              : 
     285              :     /// Expand a key/term in vocab position (attribute names, type values).
     286        81341 :     pub fn expand_key(&self, key: &str) -> String {
     287        81341 :         if let Some(def) = self.terms.get(key) {
     288         1322 :             return def.iri.clone();
     289        80019 :         }
     290        80019 :         if let Some((prefix, suffix)) = key.split_once(':') {
     291        12954 :             if !suffix.starts_with("//") {
     292           34 :                 if let Some(def) = self.terms.get(prefix) {
     293           24 :                     if def.prefix_ok {
     294           18 :                         return format!("{}{}", def.iri, suffix);
     295            6 :                     }
     296           10 :                 }
     297        12920 :             }
     298        12936 :             if is_absolute_iri(key) {
     299        12930 :                 return key.to_owned();
     300            6 :             }
     301        67065 :         }
     302        67071 :         format!("{}{}", vocab_or_default(&self.vocab), key)
     303        81341 :     }
     304              : 
     305              :     /// Compact an IRI back to a term (attribute names, type values).
     306              :     ///
     307              :     /// Vocab-relative shortening is only valid when the resulting bare term
     308              :     /// would round-trip: if the term is already bound to a DIFFERENT IRI in
     309              :     /// this context, fall back to prefix compaction
     310              :     /// (`ngsi-ld:default-context/x`) — JSON-LD compaction semantics the
     311              :     /// conformance suite depends on.
     312        31273 :     pub fn compact_iri(&self, iri: &str) -> String {
     313        31273 :         if let Some(term) = self.inverse.get(iri) {
     314          302 :             return term.clone();
     315        30971 :         }
     316        30971 :         let vocab = vocab_or_default(&self.vocab);
     317        31387 :         for v in [vocab, DEFAULT_VOCAB] {
     318        31387 :             if let Some(rest) = iri.strip_prefix(v) {
     319        30555 :                 let round_trips = !rest.is_empty()
     320        30553 :                     && !rest.contains(':')
     321        30551 :                     && self.terms.get(rest).is_none_or(|d| d.iri == iri);
     322        30555 :                 if round_trips {
     323        30535 :                     return rest.to_owned();
     324           20 :                 }
     325           20 :                 break;
     326          832 :             }
     327              :         }
     328              :         // Prefix compaction: longest matching prefix-capable term. A prefix
     329              :         // of this IRI can only be one of its own leading slices, so the
     330              :         // index is probed at the lengths it actually holds — longest first,
     331              :         // which makes the first hit the longest match — instead of walking
     332              :         // every term. A 20 000-term vocabulary is an ordinary document, and
     333              :         // this runs once per attribute of every entity in a response.
     334         5572 :         for &n in &self.prefix_lens {
     335         5572 :             if n >= iri.len() || !iri.is_char_boundary(n) {
     336         4936 :                 continue;
     337          636 :             }
     338          636 :             if let Some(term) = self.prefixes.get(&iri[..n]) {
     339          136 :                 return format!("{term}:{}", &iri[n..]);
     340          500 :             }
     341              :         }
     342          300 :         iri.to_owned()
     343        31273 :     }
     344              : }
     345              : 
     346        98044 : fn vocab_or_default(vocab: &str) -> &str {
     347        98044 :     if vocab.is_empty() {
     348          266 :         DEFAULT_VOCAB
     349              :     } else {
     350        97778 :         vocab
     351              :     }
     352        98044 : }
     353              : 
     354              : /// RFC 3986 3.1: `scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` — the
     355              : /// scheme starts with a letter, and the part after the colon must not be
     356              : /// empty. 4.5.1/5.5.4 lean on this to keep every expanded Attribute name
     357              : /// absolute.
     358      2568440 : pub fn is_absolute_iri(s: &str) -> bool {
     359      2568440 :     match s.split_once(':') {
     360      2543166 :         Some((scheme, rest)) => {
     361      2543166 :             !rest.is_empty()
     362      2543162 :                 && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
     363      2543138 :                 && scheme
     364      2543138 :                     .chars()
     365     12668992 :                     .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
     366              :         }
     367        25274 :         None => false,
     368              :     }
     369      2568440 : }
     370              : 
     371              : #[cfg(test)]
     372              : mod tests {
     373              :     use super::*;
     374              :     use serde_json::json;
     375              : 
     376          144 :     fn ctx(v: Value) -> Context {
     377          144 :         let mut c = Context::default();
     378          144 :         c.merge_object(v.as_object().unwrap()).unwrap();
     379          144 :         c.freeze();
     380          144 :         c
     381          144 :     }
     382              : 
     383              :     /// 5.5.7: the user @context "shall not contain JSON-LD Scoped Contexts"
     384              :     /// — a term definition carrying its own @context "should result in an
     385              :     /// error of type BadRequestData" (it could reshape core terms during
     386              :     /// expansion). Compaction with no matching term renders the FQN.
     387              :     #[test]
     388            2 :     fn clause_5_5_7_scoped_contexts_rejected_and_fqn_fallback() {
     389            2 :         let mut c = Context::default();
     390            2 :         let err = c
     391            2 :             .merge_object(
     392            2 :                 json!({"Vehicle": {"@id": "https://example.org/Vehicle",
     393            2 :                     "@context": {"speed": "https://example.org/hidden-speed"}}})
     394            2 :                 .as_object()
     395            2 :                 .unwrap(),
     396              :             )
     397            2 :             .expect_err("scoped context must be rejected");
     398            2 :         assert!(
     399            2 :             matches!(err, NgsiError::BadRequestData(_)),
     400              :             "BadRequestData, got {err:?}"
     401              :         );
     402              :         // and the smuggled scoped term must NOT have landed in the context
     403            2 :         assert_ne!(
     404            2 :             ctx(json!({"x": "https://example.org/x"})).expand_key("speed"),
     405              :             "https://example.org/hidden-speed"
     406              :         );
     407              :         // compaction without a matching term renders the FQN verbatim
     408            2 :         let c = ctx(json!({"name": "https://example.org/name"}));
     409            2 :         assert_eq!(
     410            2 :             c.compact_iri("https://elsewhere.org/unmapped"),
     411              :             "https://elsewhere.org/unmapped"
     412              :         );
     413            2 :     }
     414              : 
     415              :     #[test]
     416            2 :     fn plain_term_mapping() {
     417            2 :         let c = ctx(json!({"name": "https://example.org/name"}));
     418            2 :         assert_eq!(c.expand_key("name"), "https://example.org/name");
     419            2 :         assert_eq!(c.compact_iri("https://example.org/name"), "name");
     420            2 :     }
     421              : 
     422              :     #[test]
     423            2 :     fn prefix_expansion() {
     424            2 :         let c = ctx(json!({"ex": "https://example.org/", "a": {"@id": "ex:a"}}));
     425            2 :         assert_eq!(c.expand_key("a"), "https://example.org/a");
     426            2 :         assert_eq!(c.expand_key("ex:b"), "https://example.org/b");
     427            2 :         assert_eq!(c.compact_iri("https://example.org/b"), "ex:b");
     428            2 :     }
     429              : 
     430              :     #[test]
     431            2 :     fn vocab_fallback() {
     432            2 :         let c = ctx(json!({"@vocab": "https://voc.example/"}));
     433            2 :         assert_eq!(c.expand_key("speed"), "https://voc.example/speed");
     434            2 :         assert_eq!(c.compact_iri("https://voc.example/speed"), "speed");
     435            2 :     }
     436              : 
     437              :     #[test]
     438            2 :     fn default_vocab_when_absent() {
     439            2 :         let c = ctx(json!({}));
     440            2 :         assert_eq!(
     441            2 :             c.expand_key("speed"),
     442              :             "https://uri.etsi.org/ngsi-ld/default-context/speed"
     443              :         );
     444            2 :     }
     445              : 
     446              :     // ---- merge_object -------------------------------------------------
     447              : 
     448              :     /// 4.4: the Core @context is merged last, so its definitions win over a
     449              :     /// user redefinition of the same term. The user's own new terms survive.
     450              :     #[test]
     451            2 :     fn later_merge_wins_so_core_terms_cannot_be_redefined() {
     452            2 :         let mut c = Context::default();
     453            2 :         c.merge_object(
     454            2 :             json!({"observedAt": "https://evil.example/observedAt",
     455            2 :                    "speed": "https://example.org/speed"})
     456            2 :             .as_object()
     457            2 :             .unwrap(),
     458              :         )
     459            2 :         .unwrap();
     460            2 :         c.merge_object(
     461            2 :             json!({"observedAt": "https://uri.etsi.org/ngsi-ld/observedAt"})
     462            2 :                 .as_object()
     463            2 :                 .unwrap(),
     464              :         )
     465            2 :         .unwrap();
     466            2 :         c.freeze();
     467            2 :         assert_eq!(
     468            2 :             c.expand_key("observedAt"),
     469              :             "https://uri.etsi.org/ngsi-ld/observedAt"
     470              :         );
     471            2 :         assert_ne!(
     472            2 :             c.expand_key("observedAt"),
     473              :             "https://evil.example/observedAt"
     474              :         );
     475              :         // the user's unrelated term is untouched by the core merge
     476            2 :         assert_eq!(c.expand_key("speed"), "https://example.org/speed");
     477            2 :     }
     478              : 
     479              :     /// A null term definition removes the term; the removed term must fall
     480              :     /// back to the vocabulary rather than keep its old IRI.
     481              :     #[test]
     482            2 :     fn null_definition_removes_term() {
     483            2 :         let mut c = Context::default();
     484            2 :         c.merge_object(
     485            2 :             json!({"speed": "https://example.org/speed"})
     486            2 :                 .as_object()
     487            2 :                 .unwrap(),
     488              :         )
     489            2 :         .unwrap();
     490            2 :         c.merge_object(json!({"speed": null}).as_object().unwrap())
     491            2 :             .unwrap();
     492            2 :         c.freeze();
     493            2 :         assert_ne!(c.expand_key("speed"), "https://example.org/speed");
     494            2 :         assert_eq!(
     495            2 :             c.expand_key("speed"),
     496              :             "https://uri.etsi.org/ngsi-ld/default-context/speed"
     497              :         );
     498              :         // freeze() rebuilds the inverse map: the dropped IRI no longer compacts
     499            2 :         assert_eq!(
     500            2 :             c.compact_iri("https://example.org/speed"),
     501              :             "https://example.org/speed"
     502              :         );
     503            2 :     }
     504              : 
     505              :     /// Keywords and definition values that are neither string, object nor null
     506              :     /// are ignored — a hostile @context of numbers/booleans/arrays must not
     507              :     /// panic and must not create terms.
     508              :     #[test]
     509            2 :     fn keyword_and_non_string_definitions_ignored() {
     510            2 :         let c = ctx(json!({
     511            2 :             "@protected": true,
     512            2 :             "@base": "https://base.example/",
     513            2 :             "n": 1,
     514            2 :             "b": false,
     515            2 :             "a": [1, 2, 3],
     516            2 :             "empty": {},
     517            2 :             "keep": "https://example.org/keep"
     518              :         }));
     519            2 :         assert!(c.term("n").is_none());
     520            2 :         assert!(c.term("b").is_none());
     521            2 :         assert!(c.term("a").is_none());
     522              :         // an object definition with neither @id, @type nor @container is skipped
     523            2 :         assert!(c.term("empty").is_none());
     524            2 :         assert_eq!(c.expand_key("keep"), "https://example.org/keep");
     525              :         // unknown terms must not vanish — they expand into the vocabulary
     526            2 :         assert_eq!(
     527            2 :             c.expand_key("n"),
     528              :             "https://uri.etsi.org/ngsi-ld/default-context/n"
     529              :         );
     530            2 :     }
     531              : 
     532              :     /// Expanded term-definition forms: @type/@container flags and a term
     533              :     /// aliased to a JSON-LD keyword (which must stay the keyword, not expand
     534              :     /// into the vocabulary).
     535              :     #[test]
     536            2 :     fn expanded_definition_forms() {
     537            2 :         let c = ctx(json!({
     538            2 :             "ex": "https://example.org/",
     539            2 :             "rel": {"@id": "ex:rel", "@type": "@id"},
     540            2 :             "kind": {"@id": "ex:kind", "@type": "@vocab"},
     541            2 :             "items": {"@id": "ex:items", "@container": "@list"},
     542            2 :             "alias": {"@id": "@type"},
     543            2 :             "implicit": {"@type": "@id"}
     544              :         }));
     545            2 :         let rel = c.term("rel").unwrap();
     546            2 :         assert!(rel.type_is_id && !rel.type_is_vocab && !rel.container_list);
     547            2 :         assert_eq!(rel.iri, "https://example.org/rel");
     548            2 :         assert!(c.term("kind").unwrap().type_is_vocab);
     549            2 :         assert!(c.term("items").unwrap().container_list);
     550            2 :         assert_eq!(c.term("alias").unwrap().iri, "@type");
     551              :         // no @id but @type present: the term maps into the vocabulary
     552            2 :         assert_eq!(
     553            2 :             c.term("implicit").unwrap().iri,
     554              :             "https://uri.etsi.org/ngsi-ld/default-context/implicit"
     555              :         );
     556              :         // an expanded definition is not prefix-capable unless @prefix says
     557              :         // so: "rel:x" stays the IRI it already is and is NOT rewritten
     558              :         // through the term.
     559            2 :         assert!(!rel.prefix_ok);
     560            2 :         assert_eq!(c.expand_key("rel:x"), "rel:x");
     561            2 :         assert_ne!(c.expand_key("rel:x"), "https://example.org/relx");
     562            2 :     }
     563              : 
     564              :     /// The @context is attacker-supplied. Prefix chaining ("t1" defined
     565              :     /// through "t0", "t2" through "t1", …) makes each definition carry the
     566              :     /// whole chain, so N such terms expand to O(N²) bytes — a few megabytes of
     567              :     /// request body would otherwise become gigabytes of term map. The merge
     568              :     /// must stop with BadRequestData instead.
     569              :     #[test]
     570            2 :     fn prefix_chain_cannot_amplify_unbounded() {
     571            2 :         let suffix = "a".repeat(32);
     572            2 :         let mut obj = Map::new();
     573            2 :         obj.insert(
     574            2 :             "t000000".into(),
     575            2 :             Value::String(format!("https://ex.example/{suffix}")),
     576              :         );
     577         3998 :         for i in 1..2000u32 {
     578         3998 :             obj.insert(
     579         3998 :                 format!("t{i:06}"),
     580         3998 :                 Value::String(format!("t{:06}:{suffix}", i - 1)),
     581         3998 :             );
     582         3998 :         }
     583            2 :         let mut c = Context::default();
     584            2 :         let err = c
     585            2 :             .merge_object(&obj)
     586            2 :             .expect_err("an amplifying @context must be rejected");
     587            2 :         assert!(
     588            2 :             matches!(err, NgsiError::BadRequestData(_)),
     589              :             "BadRequestData, got {err:?}"
     590              :         );
     591            2 :     }
     592              : 
     593              :     /// The bound must not reject an ordinary large vocabulary: 20 000 plain
     594              :     /// term mappings expand to roughly their own size and are accepted.
     595              :     #[test]
     596            2 :     fn large_plain_context_still_accepted() {
     597            2 :         let mut obj = Map::new();
     598        40000 :         for i in 0..20_000u32 {
     599        40000 :             obj.insert(
     600        40000 :                 format!("term{i}"),
     601        40000 :                 Value::String(format!("https://ex.example/vocab#term{i}")),
     602        40000 :             );
     603        40000 :         }
     604            2 :         let mut c = Context::default();
     605            2 :         c.merge_object(&obj).expect("plain context accepted");
     606            2 :         c.freeze();
     607            2 :         assert_eq!(
     608            2 :             c.expand_key("term19999"),
     609              :             "https://ex.example/vocab#term19999"
     610              :         );
     611            2 :     }
     612              : 
     613              :     /// Self-referential and mutually-referential prefixes must terminate:
     614              :     /// term-definition IRI expansion resolves one level against the terms
     615              :     /// already merged, it never follows a chain recursively.
     616              :     #[test]
     617            2 :     fn self_referential_prefixes_terminate() {
     618            2 :         let c = ctx(json!({"a": "a:x"}));
     619            2 :         assert!(!c.term("a").unwrap().iri.is_empty());
     620            2 :         let c = ctx(json!({"a": "b:x", "b": "a:y"}));
     621              :         // both resolved to something finite; neither hung nor recursed
     622            2 :         assert!(c.term("a").unwrap().iri.len() < 64);
     623            2 :         assert!(c.term("b").unwrap().iri.len() < 64);
     624            2 :     }
     625              : 
     626              :     // ---- freeze -------------------------------------------------------
     627              : 
     628              :     /// Several terms bound to one IRI: compaction picks the shortest, ties
     629              :     /// broken lexicographically, and the choice is stable across freezes.
     630              :     #[test]
     631            2 :     fn freeze_inverse_is_deterministic() {
     632            2 :         let c = ctx(json!({
     633            2 :             "aaa": "https://example.org/x",
     634            2 :             "bb": "https://example.org/x",
     635            2 :             "cc": "https://example.org/x"
     636              :         }));
     637            2 :         assert_eq!(c.compact_iri("https://example.org/x"), "bb");
     638            2 :         for _ in 0..5 {
     639           10 :             let c2 = ctx(json!({
     640           10 :                 "cc": "https://example.org/x",
     641           10 :                 "bb": "https://example.org/x",
     642           10 :                 "aaa": "https://example.org/x"
     643              :             }));
     644           10 :             assert_eq!(c2.compact_iri("https://example.org/x"), "bb");
     645              :         }
     646            2 :     }
     647              : 
     648              :     // ---- expand_key ---------------------------------------------------
     649              : 
     650              :     /// Adversarial keys: empty, lone/leading/trailing colons, "://", huge
     651              :     /// prefixes and multi-byte UTF-8 — expansion slices on ':' and must never
     652              :     /// panic on a character boundary.
     653              :     #[test]
     654            2 :     fn expand_key_adversarial_strings_do_not_panic() {
     655            2 :         let c = ctx(json!({"ex": "https://example.org/", "": "https://empty.example/"}));
     656            2 :         let vocab = "https://uri.etsi.org/ngsi-ld/default-context/";
     657            2 :         assert_eq!(c.expand_key(""), "https://empty.example/");
     658            2 :         assert_eq!(c.expand_key(":"), "https://empty.example/");
     659            2 :         assert_eq!(c.expand_key("://"), format!("{vocab}://"));
     660            2 :         assert_eq!(c.expand_key(":x"), "https://empty.example/x");
     661            2 :         assert_eq!(c.expand_key("ex:"), "https://example.org/");
     662            2 :         assert_eq!(c.expand_key("é"), format!("{vocab}é"));
     663            2 :         assert_eq!(c.expand_key("ex:°C"), "https://example.org/°C");
     664            2 :         assert_eq!(c.expand_key("日本:語"), format!("{vocab}日本:語"));
     665            2 :         assert_eq!(c.expand_key("\u{feff}:x"), format!("{vocab}\u{feff}:x"));
     666            2 :         let huge = "x".repeat(100_000);
     667            2 :         assert_eq!(c.expand_key(&huge), format!("{vocab}{huge}"));
     668            2 :         assert_eq!(
     669            2 :             c.expand_key(&format!("ex:{huge}")),
     670            2 :             format!("https://example.org/{huge}")
     671              :         );
     672            2 :     }
     673              : 
     674              :     /// A term definition wins over reading the same key as prefix:suffix.
     675              :     #[test]
     676            2 :     fn term_lookup_precedes_prefix_split() {
     677            2 :         let c = ctx(json!({"ex": "https://example.org/", "ex:a": "https://direct.example/a"}));
     678            2 :         assert_eq!(c.expand_key("ex:a"), "https://direct.example/a");
     679            2 :         assert_ne!(c.expand_key("ex:a"), "https://example.org/a");
     680            2 :     }
     681              : 
     682              :     /// An absolute IRI used as a key stays itself; a non-prefix-capable term
     683              :     /// before the colon must not be applied.
     684              :     #[test]
     685            2 :     fn absolute_iri_keys_pass_through() {
     686            2 :         let c = ctx(json!({"ex": {"@id": "https://example.org/"}}));
     687            2 :         assert_eq!(
     688            2 :             c.expand_key("https://other.example/a"),
     689              :             "https://other.example/a"
     690              :         );
     691            2 :         assert_eq!(c.expand_key("urn:ngsi-ld:X"), "urn:ngsi-ld:X");
     692              :         // @id-form definitions are not prefixes (JSON-LD 1.1 simple-term rule)
     693            2 :         assert_eq!(c.expand_key("ex:a"), "ex:a");
     694            2 :     }
     695              : 
     696              :     // ---- compact_iri --------------------------------------------------
     697              : 
     698              :     /// Compaction must not leave an expanded IRI in the document when the
     699              :     /// context defines a term for it.
     700              :     #[test]
     701            2 :     fn defined_terms_never_stay_expanded() {
     702            2 :         let c = ctx(json!({"ex": "https://example.org/", "name": "https://example.org/name"}));
     703            2 :         assert_eq!(c.compact_iri("https://example.org/name"), "name");
     704            2 :         assert_ne!(
     705            2 :             c.compact_iri("https://example.org/name"),
     706              :             "https://example.org/name"
     707              :         );
     708              :         // no exact term: longest prefix-capable term wins
     709            2 :         assert_eq!(c.compact_iri("https://example.org/other"), "ex:other");
     710            2 :     }
     711              : 
     712              :     /// Vocab-relative shortening only when the bare term round-trips: if the
     713              :     /// context binds that term to a DIFFERENT IRI the full IRI is kept.
     714              :     #[test]
     715            2 :     fn vocab_shortening_requires_round_trip() {
     716            2 :         let c = ctx(json!({"@vocab": "https://voc.example/",
     717            2 :                            "speed": "https://other.example/speed"}));
     718            2 :         assert_eq!(
     719            2 :             c.compact_iri("https://voc.example/speed"),
     720              :             "https://voc.example/speed"
     721              :         );
     722            2 :         assert_ne!(c.compact_iri("https://voc.example/speed"), "speed");
     723              :         // a vocab-relative remainder containing ':' is not a usable term
     724            2 :         assert_eq!(
     725            2 :             c.compact_iri("https://voc.example/a:b"),
     726              :             "https://voc.example/a:b"
     727              :         );
     728              :         // the vocab IRI itself has an empty remainder
     729            2 :         assert_eq!(
     730            2 :             c.compact_iri("https://voc.example/"),
     731              :             "https://voc.example/"
     732              :         );
     733            2 :     }
     734              : 
     735              :     /// Longest matching prefix wins, and a term whose IRI is empty is never
     736              :     /// used as a prefix (it would match everything).
     737              :     #[test]
     738            2 :     fn prefix_compaction_picks_longest_and_skips_empty() {
     739            2 :         let c = ctx(json!({"ex": "https://example.org/",
     740            2 :                            "sub": "https://example.org/sub/",
     741            2 :                            "nil": {"@id": "", "@prefix": true}}));
     742            2 :         assert_eq!(c.compact_iri("https://example.org/sub/x"), "sub:x");
     743            2 :         assert_eq!(c.compact_iri("https://example.org/y"), "ex:y");
     744            2 :         assert!(!c
     745            2 :             .compact_iri("https://elsewhere.example/z")
     746            2 :             .starts_with("nil:"));
     747            2 :     }
     748              : 
     749              :     /// Compaction must be reproducible across processes: when several
     750              :     /// prefix-capable terms share one IRI the winner may not depend on hash
     751              :     /// map iteration order, which is randomly seeded per map.
     752              :     #[test]
     753            2 :     fn prefix_compaction_tie_break_is_stable() {
     754            2 :         let defs = json!({
     755            2 :             "h": "https://example.org/", "g": "https://example.org/",
     756            2 :             "f": "https://example.org/", "e": "https://example.org/",
     757            2 :             "d": "https://example.org/", "c": "https://example.org/",
     758            2 :             "b": "https://example.org/", "a": "https://example.org/"
     759              :         });
     760            2 :         for _ in 0..50 {
     761          100 :             assert_eq!(
     762          100 :                 ctx(defs.clone()).compact_iri("https://example.org/z"),
     763              :                 "a:z"
     764              :             );
     765              :         }
     766            2 :     }
     767              : 
     768              :     // ---- is_absolute_iri ----------------------------------------------
     769              : 
     770              :     /// RFC 3986 scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ), and the
     771              :     /// hierarchical part must not be empty. The edge set below is what a
     772              :     /// hostile @context or entity key can carry.
     773              :     #[test]
     774            2 :     fn is_absolute_iri_edge_set() {
     775           16 :         for s in [
     776            2 :             "https://example.org/x",
     777            2 :             "urn:ngsi-ld:X",
     778            2 :             "a:b",
     779            2 :             "a+b-c.d:x",
     780            2 :             "A:x",
     781            2 :             "a::b",
     782            2 :             "x:日本",
     783            2 :             "http://x",
     784            2 :         ] {
     785           16 :             assert!(is_absolute_iri(s), "expected absolute: {s:?}");
     786              :         }
     787           28 :         for s in [
     788            2 :             "",
     789            2 :             ":",
     790            2 :             "://",
     791            2 :             ":x",
     792            2 :             "a:",
     793            2 :             "1a:x",
     794            2 :             "3D:x",
     795            2 :             "a b:x",
     796            2 :             "日本:x",
     797            2 :             "no-colon",
     798            2 :             "+x:y",
     799            2 :             "-x:y",
     800            2 :             ".x:y",
     801            2 :             "\u{feff}:x",
     802            2 :         ] {
     803           28 :             assert!(!is_absolute_iri(s), "expected NOT absolute: {s:?}");
     804              :         }
     805            2 :     }
     806              : 
     807              :     /// Prefix compaction picks the LONGEST matching prefix-capable term, and
     808              :     /// resolves a tie on the term itself (shortest, then lexicographic) so
     809              :     /// the answer cannot differ between processes — `terms` is a
     810              :     /// randomly-seeded HashMap. The index `freeze` builds must give the same
     811              :     /// answer whatever else the vocabulary holds, so the same assertions run
     812              :     /// against a term map three orders of magnitude larger.
     813              :     #[test]
     814            2 :     fn prefix_compaction_takes_the_longest_match_whatever_the_vocabulary_size() {
     815            4 :         let cases = |noise: usize| {
     816            4 :             let mut m = Map::new();
     817            4 :             m.insert("short".into(), Value::String("http://ex.example/".into()));
     818            4 :             m.insert(
     819            4 :                 "long".into(),
     820            4 :                 Value::String("http://ex.example/deep/".into()),
     821              :             );
     822              :             // two terms on ONE IRI: the tie-break decides which is written
     823            4 :             m.insert("bb".into(), Value::String("http://tie.example/".into()));
     824            4 :             m.insert("aa".into(), Value::String("http://tie.example/".into()));
     825            4 :             m.insert("aaa".into(), Value::String("http://tie.example/".into()));
     826       200000 :             for i in 0..noise {
     827       200000 :                 m.insert(
     828       200000 :                     format!("n{i:06}"),
     829       200000 :                     Value::String(format!("http://noise.example/{i}/")),
     830       200000 :                 );
     831       200000 :             }
     832            4 :             let mut c = Context::default();
     833            4 :             c.merge_object(&m).expect("merge");
     834            4 :             c.freeze();
     835            4 :             c
     836            4 :         };
     837            4 :         for noise in [0usize, 100_000] {
     838            4 :             let c = cases(noise);
     839              :             // longest match wins over the shorter one that also matches
     840            4 :             assert_eq!(
     841            4 :                 c.compact_iri("http://ex.example/deep/x"),
     842              :                 "long:x",
     843              :                 "{noise}"
     844              :             );
     845            4 :             assert_eq!(c.compact_iri("http://ex.example/x"), "short:x", "{noise}");
     846              :             // shortest term, then lexicographic, among terms on one IRI
     847            4 :             assert_eq!(c.compact_iri("http://tie.example/x"), "aa:x", "{noise}");
     848              :             // an IRI no term prefixes comes back whole
     849            4 :             assert_eq!(
     850            4 :                 c.compact_iri("http://miss.example/x"),
     851              :                 "http://miss.example/x",
     852              :                 "{noise}"
     853              :             );
     854              :             // an exact IRI match is a term, not a prefix compaction
     855            4 :             assert_eq!(c.compact_iri("http://ex.example/"), "short", "{noise}");
     856              :             // the suffix must be non-empty for a prefix to apply, and a
     857              :             // multi-byte boundary must not split a character
     858            4 :             assert_eq!(
     859            4 :                 c.compact_iri("http://ex.example/deep/é"),
     860              :                 "long:é",
     861              :                 "{noise}"
     862              :             );
     863              :         }
     864            2 :     }
     865              : }
        

Generated by: LCOV version 2.0-1