LCOV - code coverage report
Current view: top level - antares-jsonld/src - context.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 100.0 % 684 684
Test Date: 2026-11-02 13:21:27 Functions: 96.7 % 92 89

            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::borrow::Cow;
      12              : use std::collections::HashMap;
      13              : use std::sync::Arc;
      14              : 
      15              : /// The default vocabulary IRI the core context sets: unknown terms expand here.
      16              : pub const DEFAULT_VOCAB: &str = "https://uri.etsi.org/ngsi-ld/default-context/";
      17              : 
      18              : /// NGSI-LD core vocabulary base (terms like location, observedAt, …).
      19              : pub const NGSI_LD_BASE: &str = "https://uri.etsi.org/ngsi-ld/";
      20              : 
      21              : /// One term definition of a processed @context.
      22              : #[derive(Debug, Clone, Default)]
      23              : pub struct TermDef {
      24              :     /// The absolute IRI the term expands to.
      25              :     pub iri: String,
      26              :     /// `@type: @id` — values are IRIs (compact them on output).
      27              :     pub type_is_id: bool,
      28              :     /// `@type: @vocab` — values are vocab terms.
      29              :     pub type_is_vocab: bool,
      30              :     /// `@container: @list`
      31              :     pub container_list: bool,
      32              :     /// term may be used as a prefix (its IRI ends with a gen-delim, or it was
      33              :     /// defined as a plain string mapping — JSON-LD 1.1 simple-term rule).
      34              :     pub prefix_ok: bool,
      35              : }
      36              : 
      37              : /// The term definitions a scoped context of the Core document brings into
      38              : /// force inside a node of its type or under its property (TS 104 175 Annex B).
      39              : #[derive(Debug, Default)]
      40              : struct Scope {
      41              :     terms: HashMap<String, TermDef>,
      42              :     /// IRI → term, the same tie-break as [`Context::freeze`].
      43              :     inverse: HashMap<String, String>,
      44              : }
      45              : 
      46              : impl Scope {
      47       214598 :     fn new(terms: HashMap<String, TermDef>) -> Self {
      48              :         // shortest, then lexicographically least, term first: the first
      49              :         // claim on an IRI is the one that stays
      50       214598 :         let mut sorted: Vec<(&String, &TermDef)> = terms.iter().collect();
      51      3266866 :         sorted.sort_unstable_by_key(|(t, _)| (t.len(), t.as_str()));
      52       214598 :         let mut inverse: HashMap<String, String> = HashMap::new();
      53       849054 :         for (term, def) in sorted {
      54       849054 :             inverse
      55       849054 :                 .entry(def.iri.clone())
      56       849054 :                 .or_insert_with(|| term.clone());
      57              :         }
      58       214598 :         Self { terms, inverse }
      59       214598 :     }
      60              : }
      61              : 
      62              : /// A processed (merged, frozen) JSON-LD @context: term map, its inverse for
      63              : /// compaction, and the `@vocab` fallback.
      64              : ///
      65              : /// The maps sit behind `Arc` so [`Context::within`] can hand out a view with
      66              : /// a scoped context on top without copying the term map of a large @context.
      67              : #[derive(Debug, Default, Clone)]
      68              : pub struct Context {
      69              :     terms: Arc<HashMap<String, TermDef>>,
      70              :     /// IRI → term for compaction (built after merge; shortest term wins).
      71              :     inverse: Arc<HashMap<String, String>>,
      72              :     /// Prefix compaction index, built by [`Context::freeze`]: the IRI of
      73              :     /// every prefix-capable term → the term to write it as, with the ties
      74              :     /// already resolved. Probing it by candidate LENGTH keeps `compact_iri`
      75              :     /// off a walk over the whole term map, whose cost an @context of 20 000
      76              :     /// terms would otherwise multiply into every attribute of every entity
      77              :     /// in a response.
      78              :     prefixes: Arc<HashMap<String, String>>,
      79              :     /// The distinct lengths of the `prefixes` keys, longest first — the only
      80              :     /// candidate cut points, so the longest match is the first hit.
      81              :     prefix_lens: Arc<Vec<usize>>,
      82              :     /// The Core document's scoped contexts, by the IRI of the type or
      83              :     /// property term that carries each (TS 104 175 Annex B: 23 of them).
      84              :     scopes: Arc<HashMap<String, Arc<Scope>>>,
      85              :     /// The scoped terms in force in this view, consulted before `terms`;
      86              :     /// `None` outside every scope.
      87              :     inner: Option<Arc<Scope>>,
      88              :     /// `@vocab`: the base unknown terms expand against.
      89              :     pub vocab: String,
      90              :     /// The @context value to hand back in responses (Link header / body):
      91              :     /// what the client sent, before the implicit core merge.
      92              :     pub source: Value,
      93              :     /// Total bytes of expanded term IRIs merged so far — see [`Context::charge`].
      94              :     bytes: usize,
      95              : }
      96              : 
      97              : impl Context {
      98              :     /// Merge one raw `@context` object (its term definitions) into this
      99              :     /// context. Later calls override earlier definitions.
     100              :     ///
     101              :     /// The object is a user @context, so 5.5.7's Scoped Context
     102              :     /// prohibition applies to it; [`Context::merge_core_object`] is the
     103              :     /// entry point for the normative Core documents.
     104          923 :     pub fn merge_object(&mut self, obj: &Map<String, Value>) -> Result<(), NgsiError> {
     105          923 :         self.merge_definitions(obj, true)
     106          923 :     }
     107              : 
     108              :     /// Merge one raw `@context` object of a Core @context document.
     109              :     ///
     110              :     /// TS 104 175 5.3.2.1: "Whereas the core NGSI-LD @context contains
     111              :     /// JSON-LD Scoped Contexts, the user @context shall not contain JSON-LD
     112              :     /// Scoped Contexts". Annex B carries 23 of them; each is kept by the IRI
     113              :     /// of its term and brought into force by [`Context::within`].
     114         9378 :     pub fn merge_core_object(&mut self, obj: &Map<String, Value>) -> Result<(), NgsiError> {
     115         9378 :         self.merge_definitions(obj, false)
     116         9378 :     }
     117              : 
     118              :     /// Merge term definitions, rejecting Scoped Contexts (5.5.7) only when
     119              :     /// `user_context` says the object came from a client.
     120        10301 :     fn merge_definitions(
     121        10301 :         &mut self,
     122        10301 :         obj: &Map<String, Value>,
     123        10301 :         user_context: bool,
     124        10301 :     ) -> Result<(), NgsiError> {
     125        10301 :         if let Some(v) = obj.get("@vocab").and_then(Value::as_str) {
     126         9378 :             self.vocab = v.to_owned();
     127         9378 :         }
     128              :         // Two passes so terms can reference prefixes defined in the same object.
     129              :         // Pass 1: raw string mappings that look like absolute IRIs (prefix seeds).
     130      2724747 :         for (term, def) in obj {
     131      2724747 :             if term.starts_with('@') {
     132        28102 :                 continue;
     133      2696645 :             }
     134      2696645 :             if let Some(s) = def.as_str() {
     135      1993401 :                 if is_absolute_iri(s) {
     136      1974549 :                     Arc::make_mut(&mut self.terms).insert(
     137      1974549 :                         term.clone(),
     138      1974549 :                         TermDef {
     139      1974549 :                             iri: s.to_owned(),
     140      1974549 :                             prefix_ok: true,
     141      1974549 :                             ..Default::default()
     142      1974549 :                         },
     143      1974549 :                     );
     144      1974549 :                 }
     145       703244 :             }
     146              :         }
     147              :         // Pass 2: everything, resolving compact IRIs against known terms.
     148      2721891 :         for (term, def) in obj {
     149      2721891 :             if term.starts_with('@') {
     150        28102 :                 continue;
     151      2693789 :             }
     152      2693789 :             match def {
     153      1990545 :                 Value::String(s) => {
     154      1990545 :                     let iri = self.expand_iri_for_def(s);
     155      1990545 :                     self.charge(&iri)?;
     156      1990543 :                     Arc::make_mut(&mut self.terms).insert(
     157      1990543 :                         term.clone(),
     158      1990543 :                         TermDef {
     159      1990543 :                             iri,
     160      1990543 :                             prefix_ok: true,
     161      1990543 :                             ..Default::default()
     162      1990543 :                         },
     163              :                     );
     164              :                 }
     165       703236 :                 Value::Object(o) => {
     166              :                     // 5.5.7: user @contexts shall not contain JSON-LD Scoped
     167              :                     // Contexts — a per-term @context could override core
     168              :                     // terms or reshape elements during expansion →
     169              :                     // BadRequestData. A Core document carries its own
     170              :                     // (TS 104 175 Annex B: 23) and is merged without this.
     171       703236 :                     if user_context && o.contains_key("@context") {
     172            6 :                         return Err(NgsiError::BadRequestData(format!(
     173            6 :                             "term {term:?}: JSON-LD Scoped Contexts are not \
     174            6 :                              allowed in a user @context (5.5.7)"
     175            6 :                         )));
     176       703230 :                     }
     177       703230 :                     let id = match o.get("@id").and_then(Value::as_str) {
     178       703222 :                         Some(id) => self.expand_iri_for_def(id),
     179              :                         // No @id: the term maps into the active vocabulary
     180              :                         // (or is a keyword alias we skip). The core @context
     181              :                         // is merged last (4.4), so its @vocab is not set yet
     182              :                         // — fall back to it rather than leave a RELATIVE IRI
     183              :                         // that 4.5.1 then has to reject.
     184              :                         None => {
     185            8 :                             if o.contains_key("@container") || o.contains_key("@type") {
     186            6 :                                 match self.expand_iri_for_def(term) {
     187            6 :                                     iri if is_absolute_iri(&iri) => iri,
     188            4 :                                     _ => format!("{}{term}", vocab_or_default(&self.vocab)),
     189              :                                 }
     190              :                             } else {
     191            2 :                                 continue;
     192              :                             }
     193              :                         }
     194              :                     };
     195       703228 :                     self.charge(&id)?;
     196       703228 :                     if let Some(scoped) = o.get("@context") {
     197       214596 :                         let scope = self.scope_of(scoped);
     198       214596 :                         Arc::make_mut(&mut self.scopes).insert(id.clone(), Arc::new(scope));
     199       488632 :                     }
     200       703228 :                     Arc::make_mut(&mut self.terms).insert(term.clone(), definition(id, o));
     201              :                 }
     202            2 :                 Value::Null => {
     203            2 :                     Arc::make_mut(&mut self.terms).remove(term);
     204            2 :                 }
     205            6 :                 _ => {}
     206              :             }
     207              :         }
     208        10293 :         Ok(())
     209        10301 :     }
     210              : 
     211              :     /// The term definitions of a Core scoped context (an object, or an array
     212              :     /// whose objects are merged in order). A remote document inside it, the
     213              :     /// W3C data-integrity context `ngsildproof` names, is not fetched: its
     214              :     /// terms expand through @vocab, as they did before Annex B scoped them.
     215       214596 :     fn scope_of(&self, scoped: &Value) -> Scope {
     216       214596 :         let mut terms = HashMap::new();
     217       214596 :         let objects: Vec<&Map<String, Value>> = match scoped {
     218       205262 :             Value::Object(o) => vec![o],
     219         9332 :             Value::Array(items) => items.iter().filter_map(Value::as_object).collect(),
     220            2 :             _ => Vec::new(),
     221              :         };
     222       214596 :         for obj in objects {
     223       849048 :             for (term, def) in obj.iter().filter(|(t, _)| !t.starts_with('@')) {
     224       849046 :                 let def = match def {
     225       522490 :                     Value::String(s) => TermDef {
     226       522490 :                         iri: self.expand_iri_for_def(s),
     227       522490 :                         ..Default::default()
     228       522490 :                     },
     229       326554 :                     Value::Object(o) => match o.get("@id").and_then(Value::as_str) {
     230       326552 :                         Some(id) => definition(self.expand_iri_for_def(id), o),
     231            2 :                         None => continue,
     232              :                     },
     233            2 :                     _ => continue,
     234              :                 };
     235       849042 :                 terms.insert(term.clone(), def);
     236              :             }
     237              :         }
     238       214596 :         Scope::new(terms)
     239       214596 :     }
     240              : 
     241              :     /// This context with the scoped context of the type or property `iri`
     242              :     /// in force on top of the scopes already in force, or itself when the
     243              :     /// Core document scopes nothing under `iri` (TS 104 175 Annex B, 5.2.5).
     244              :     /// A type-scoped context covers the members of a node of that type and
     245              :     /// is not handed further down; a property-scoped one covers everything
     246              :     /// below the property (JSON-LD 1.1 4.1.8, `@propagate`).
     247       190332 :     pub fn within(&self, iri: &str) -> Cow<'_, Context> {
     248       190332 :         let Some(scope) = self.scopes.get(iri) else {
     249       190248 :             return Cow::Borrowed(self);
     250              :         };
     251           84 :         let inner = match &self.inner {
     252           82 :             None => Arc::clone(scope),
     253            2 :             Some(outer) => {
     254            2 :                 let mut terms = outer.terms.clone();
     255            2 :                 terms.extend(scope.terms.iter().map(|(t, d)| (t.clone(), d.clone())));
     256            2 :                 Arc::new(Scope::new(terms))
     257              :             }
     258              :         };
     259           84 :         Cow::Owned(Context {
     260           84 :             inner: Some(inner),
     261           84 :             ..self.clone()
     262           84 :         })
     263       190332 :     }
     264              : 
     265              :     /// This context with the type-scoped contexts of every one of `types`
     266              :     /// (expanded IRIs) in force, in lexicographic order as JSON-LD 1.1
     267              :     /// applies them.
     268        67941 :     pub fn within_types<'a>(&'a self, types: &[&str]) -> Cow<'a, Context> {
     269        67941 :         let mut sorted = types.to_vec();
     270        67941 :         sorted.sort_unstable();
     271        67941 :         let mut view = Cow::Borrowed(self);
     272        67941 :         for t in sorted {
     273        55374 :             let next = match view.within(t) {
     274            4 :                 Cow::Owned(c) => Some(c),
     275        55370 :                 Cow::Borrowed(_) => None,
     276              :             };
     277        55374 :             if let Some(c) = next {
     278            4 :                 view = Cow::Owned(c);
     279        55370 :             }
     280              :         }
     281        67941 :         view
     282        67941 :     }
     283              : 
     284              :     /// The same context outside every scope: what a node below a
     285              :     /// type-scoped one sees.
     286            4 :     pub fn outside(&self) -> Cow<'_, Context> {
     287            4 :         if self.inner.is_none() {
     288            2 :             return Cow::Borrowed(self);
     289            2 :         }
     290            2 :         Cow::Owned(Context {
     291            2 :             inner: None,
     292            2 :             ..self.clone()
     293            2 :         })
     294            4 :     }
     295              : 
     296              :     /// 5.5.6: an `@context` that "is invalid" is BadRequestData. The term map
     297              :     /// is built from client-supplied documents, and a chain of prefix
     298              :     /// definitions (`"t1": "t0:…"`, `"t2": "t1:…"`, …) makes every definition
     299              :     /// carry the whole chain, so N terms expand to O(N²) bytes. The merged
     300              :     /// IRIs are budgeted against the ceiling one @context document may
     301              :     /// occupy, on the Context rather than per call so a chain split across
     302              :     /// several documents cannot walk past it.
     303      2693773 :     fn charge(&mut self, iri: &str) -> Result<(), NgsiError> {
     304      2693773 :         self.bytes = self.bytes.saturating_add(iri.len());
     305      2693773 :         if self.bytes > crate::pinned::MAX_CONTEXT_BYTES {
     306            2 :             return Err(NgsiError::BadRequestData(
     307            2 :                 "@context term definitions exceed the maximum size".into(),
     308            2 :             ));
     309      2693771 :         }
     310      2693771 :         Ok(())
     311      2693773 :     }
     312              : 
     313              :     /// Expand an IRI-position string inside a term definition (@id values).
     314      3542815 :     fn expand_iri_for_def(&self, s: &str) -> String {
     315              :         // JSON-LD keywords ("@type", "@id", …) stay as-is: a term aliased to a
     316              :         // keyword must expand to the keyword, never into the vocab.
     317      3542815 :         if s.starts_with('@') {
     318        18750 :             return s.to_owned();
     319      3524065 :         }
     320      3524065 :         if let Some((prefix, suffix)) = s.split_once(':') {
     321      3523949 :             if !suffix.starts_with("//") {
     322      2154266 :                 if let Some(def) = self.terms.get(prefix) {
     323      2154206 :                     if def.prefix_ok {
     324      2154204 :                         return format!("{}{}", def.iri, suffix);
     325            2 :                     }
     326           60 :                 }
     327      1369683 :             }
     328      1369745 :             if is_absolute_iri(s) {
     329      1369743 :                 return s.to_owned();
     330            2 :             }
     331          116 :         }
     332          118 :         if let Some(def) = self.terms.get(s) {
     333            2 :             return def.iri.clone();
     334          116 :         }
     335          116 :         if !self.vocab.is_empty() {
     336           84 :             return format!("{}{}", self.vocab, s);
     337           32 :         }
     338           32 :         s.to_owned()
     339      3542815 :     }
     340              : 
     341              :     /// Build the compaction inverse map. Call once after all merges.
     342         8958 :     pub fn freeze(&mut self) {
     343         8958 :         let mut inv: HashMap<String, String> = HashMap::new();
     344      2587081 :         for (term, def) in self.terms.iter() {
     345      2587081 :             match inv.get(&def.iri) {
     346          579 :                 Some(existing)
     347          740 :                     if (existing.len(), existing.as_str()) <= (term.len(), term.as_str()) => {}
     348      2586502 :                 _ => {
     349      2586502 :                     inv.insert(def.iri.clone(), term.clone());
     350      2586502 :                 }
     351              :             }
     352              :         }
     353         8958 :         self.inverse = Arc::new(inv);
     354              :         // The prefix index carries the same tie-break the inverse map does
     355              :         // (shortest term, then lexicographic): `terms` is a randomly-seeded
     356              :         // HashMap, so an unresolved tie would pick a different prefix in
     357              :         // each process.
     358         8958 :         let mut pfx: HashMap<String, String> = HashMap::new();
     359      2587081 :         for (term, def) in self.terms.iter() {
     360      2587081 :             if !def.prefix_ok || def.iri.is_empty() {
     361       650062 :                 continue;
     362      1937019 :             }
     363      1937019 :             match pfx.get(&def.iri) {
     364          577 :                 Some(existing)
     365          738 :                     if (existing.len(), existing.as_str()) <= (term.len(), term.as_str()) => {}
     366      1936442 :                 _ => {
     367      1936442 :                     pfx.insert(def.iri.clone(), term.clone());
     368      1936442 :                 }
     369              :             }
     370              :         }
     371         8958 :         let mut lens: Vec<usize> = pfx.keys().map(String::len).collect();
     372         8958 :         lens.sort_unstable();
     373         8958 :         lens.dedup();
     374         8958 :         lens.reverse();
     375         8958 :         self.prefixes = Arc::new(pfx);
     376         8958 :         self.prefix_lens = Arc::new(lens);
     377         8958 :     }
     378              : 
     379              :     /// The definition of `term`, if the context defines it (the scoped
     380              :     /// definition first, inside a scope).
     381       335050 :     pub fn term(&self, term: &str) -> Option<&TermDef> {
     382       335050 :         self.inner
     383       335050 :             .as_ref()
     384       335050 :             .and_then(|s| s.terms.get(term))
     385       335050 :             .or_else(|| self.terms.get(term))
     386       335050 :     }
     387              : 
     388              :     /// The heap this context holds in its own maps: the term map, the
     389              :     /// compaction inverse and the prefix index, each entry charged its string
     390              :     /// bytes plus a fixed slot cost. A counting allocator measured 467-574
     391              :     /// bytes per term for 184 to 20 000 terms; this estimate lands
     392              :     /// within that. The merged-context cache weighs its entries by it, so its
     393              :     /// budget is a memory budget (2 million terms held ~1 GiB on a deployment).
     394              :     /// The Core document's scopes are shared by every context and not counted.
     395         1171 :     pub fn heap_bytes(&self) -> usize {
     396              :         // a String header per key and value, hashbrown control bytes and
     397              :         // load-factor slack, and allocator rounding of the two strings
     398              :         const PAIR: usize = 2 * std::mem::size_of::<String>() + 48;
     399              :         const DEF: usize = std::mem::size_of::<TermDef>() - std::mem::size_of::<String>();
     400      4330861 :         let pair = |acc: usize, k: &String, v: &str| {
     401      4330861 :             acc.saturating_add(PAIR)
     402      4330861 :                 .saturating_add(k.len())
     403      4330861 :                 .saturating_add(v.len())
     404      4330861 :         };
     405      1469022 :         let terms = self.terms.iter().fold(0usize, |acc, (t, d)| {
     406      1469022 :             pair(acc, t, &d.iri).saturating_add(DEF)
     407      1469022 :         });
     408         1171 :         let maps = self
     409         1171 :             .inverse
     410         1171 :             .iter()
     411         1171 :             .chain(self.prefixes.iter())
     412      2861839 :             .fold(terms, |acc, (k, v)| pair(acc, k, v));
     413         1171 :         maps.saturating_add(
     414         1171 :             self.prefix_lens
     415         1171 :                 .len()
     416         1171 :                 .saturating_mul(std::mem::size_of::<usize>()),
     417              :         )
     418         1171 :     }
     419              : 
     420              :     /// Expand a key/term in vocab position (attribute names, type values).
     421       284327 :     pub fn expand_key(&self, key: &str) -> String {
     422       284327 :         if let Some(def) = self.term(key) {
     423        30807 :             return def.iri.clone();
     424       253520 :         }
     425       253520 :         if let Some((prefix, suffix)) = key.split_once(':') {
     426        20292 :             if !suffix.starts_with("//") {
     427           40 :                 if let Some(def) = self.terms.get(prefix) {
     428           24 :                     if def.prefix_ok {
     429           18 :                         return format!("{}{}", def.iri, suffix);
     430            6 :                     }
     431           16 :                 }
     432        20252 :             }
     433        20274 :             if is_absolute_iri(key) {
     434        20268 :                 return key.to_owned();
     435            6 :             }
     436       233228 :         }
     437       233234 :         format!("{}{}", vocab_or_default(&self.vocab), key)
     438       284327 :     }
     439              : 
     440              :     /// Compact an IRI back to a term (attribute names, type values).
     441              :     ///
     442              :     /// Vocab-relative shortening is only valid when the resulting bare term
     443              :     /// would round-trip: if the term is already bound to a DIFFERENT IRI in
     444              :     /// this context, fall back to prefix compaction
     445              :     /// (`ngsi-ld:default-context/x`) — JSON-LD compaction semantics the
     446              :     /// conformance suite depends on.
     447        62696 :     pub fn compact_iri(&self, iri: &str) -> String {
     448        62696 :         if let Some(term) = self.inner.as_ref().and_then(|s| s.inverse.get(iri)) {
     449           10 :             return term.clone();
     450        62686 :         }
     451        62686 :         if let Some(term) = self.inverse.get(iri) {
     452              :             // a scoped term of the same name means something else here
     453        10345 :             if self
     454        10345 :                 .inner
     455        10345 :                 .as_ref()
     456        10345 :                 .is_none_or(|s| !s.terms.contains_key(term))
     457              :             {
     458        10343 :                 return term.clone();
     459            2 :             }
     460        52341 :         }
     461        52343 :         let vocab = vocab_or_default(&self.vocab);
     462        53997 :         for v in [vocab, DEFAULT_VOCAB] {
     463        53997 :             if let Some(rest) = iri.strip_prefix(v) {
     464        50689 :                 let round_trips = !rest.is_empty()
     465        50687 :                     && !rest.contains(':')
     466        50685 :                     && self.term(rest).is_none_or(|d| d.iri == iri);
     467        50689 :                 if round_trips {
     468        50625 :                     return rest.to_owned();
     469           64 :                 }
     470           64 :                 break;
     471         3308 :             }
     472              :         }
     473              :         // Prefix compaction: longest matching prefix-capable term. A prefix
     474              :         // of this IRI can only be one of its own leading slices, so the
     475              :         // index is probed at the lengths it actually holds — longest first,
     476              :         // which makes the first hit the longest match — instead of walking
     477              :         // every term. A 20 000-term vocabulary is an ordinary document, and
     478              :         // this runs once per attribute of every entity in a response.
     479        35574 :         for &n in self.prefix_lens.iter() {
     480        35574 :             if n >= iri.len() || !iri.is_char_boundary(n) {
     481        14258 :                 continue;
     482        21316 :             }
     483        21316 :             if let Some(term) = self.prefixes.get(&iri[..n]) {
     484          182 :                 return format!("{term}:{}", &iri[n..]);
     485        21134 :             }
     486              :         }
     487         1536 :         iri.to_owned()
     488        62696 :     }
     489              : }
     490              : 
     491              : /// A term definition from its JSON-LD object form and resolved `@id`.
     492      1029780 : fn definition(iri: String, o: &Map<String, Value>) -> TermDef {
     493      1029780 :     let t = o.get("@type").and_then(Value::as_str).unwrap_or("");
     494      1029780 :     let c = o.get("@container").and_then(Value::as_str).unwrap_or("");
     495      1029780 :     TermDef {
     496      1029780 :         iri,
     497      1029780 :         type_is_id: t == "@id",
     498      1029780 :         type_is_vocab: t == "@vocab",
     499      1029780 :         container_list: c == "@list",
     500      1029780 :         prefix_ok: o.get("@prefix").and_then(Value::as_bool).unwrap_or(false),
     501      1029780 :     }
     502      1029780 : }
     503              : 
     504       285581 : fn vocab_or_default(vocab: &str) -> &str {
     505       285581 :     if vocab.is_empty() {
     506          276 :         DEFAULT_VOCAB
     507              :     } else {
     508       285305 :         vocab
     509              :     }
     510       285581 : }
     511              : 
     512              : /// RFC 3986 3.1: `scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` — the
     513              : /// scheme starts with a letter, and the part after the colon must not be
     514              : /// empty. 4.5.1/5.5.4 lean on this to keep every expanded Attribute name
     515              : /// absolute.
     516      3520937 : pub fn is_absolute_iri(s: &str) -> bool {
     517      3520937 :     match s.split_once(':') {
     518      3502050 :         Some((scheme, rest)) => {
     519      3502050 :             !rest.is_empty()
     520      3502046 :                 && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
     521      3502020 :                 && scheme
     522      3502020 :                     .chars()
     523     18262291 :                     .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
     524              :         }
     525        18887 :         None => false,
     526              :     }
     527      3520937 : }
     528              : 
     529              : #[cfg(test)]
     530              : mod tests {
     531              :     use super::*;
     532              :     use serde_json::json;
     533              : 
     534          152 :     fn ctx(v: Value) -> Context {
     535          152 :         let mut c = Context::default();
     536          152 :         c.merge_object(v.as_object().unwrap()).unwrap();
     537          152 :         c.freeze();
     538          152 :         c
     539          152 :     }
     540              : 
     541              :     /// The heap estimate grows with every map a term lands in: a prefix-capable
     542              :     /// term (IRI ending in a gen-delim) costs the term map, the inverse and the
     543              :     /// prefix index; the measured ~500 bytes a term sits inside its range.
     544              :     #[test]
     545            2 :     fn heap_bytes_counts_each_map_a_term_lands_in() {
     546            2 :         assert_eq!(Context::default().heap_bytes(), 0);
     547            2 :         let plain = ctx(json!({"a": {"@id": "https://example.org/a"}}));
     548            2 :         let prefix = ctx(json!({"a": "https://example.org/a/"}));
     549            2 :         assert!(plain.heap_bytes() > 0);
     550            2 :         assert!(prefix.heap_bytes() > plain.heap_bytes());
     551            2 :         let mut m = serde_json::Map::new();
     552         2000 :         for i in 0..1000 {
     553         2000 :             m.insert(
     554         2000 :                 format!("attr{i}"),
     555         2000 :                 json!(format!("https://example.org/m/attr{i}")),
     556         2000 :             );
     557         2000 :         }
     558            2 :         let wide = ctx(Value::Object(m));
     559            2 :         assert!((400_000..=700_000).contains(&wide.heap_bytes()));
     560            2 :     }
     561              : 
     562              :     /// 5.5.7: the user @context "shall not contain JSON-LD Scoped Contexts"
     563              :     /// — a term definition carrying its own @context "should result in an
     564              :     /// error of type BadRequestData" (it could reshape core terms during
     565              :     /// expansion). Compaction with no matching term renders the FQN.
     566              :     #[test]
     567            2 :     fn clause_5_5_7_scoped_contexts_rejected_and_fqn_fallback() {
     568            2 :         let mut c = Context::default();
     569            2 :         let err = c
     570            2 :             .merge_object(
     571            2 :                 json!({"Vehicle": {"@id": "https://example.org/Vehicle",
     572            2 :                     "@context": {"speed": "https://example.org/hidden-speed"}}})
     573            2 :                 .as_object()
     574            2 :                 .unwrap(),
     575              :             )
     576            2 :             .expect_err("scoped context must be rejected");
     577            2 :         assert!(
     578            2 :             matches!(err, NgsiError::BadRequestData(_)),
     579              :             "BadRequestData, got {err:?}"
     580              :         );
     581              :         // and the smuggled scoped term must NOT have landed in the context
     582            2 :         assert_ne!(
     583            2 :             ctx(json!({"x": "https://example.org/x"})).expand_key("speed"),
     584              :             "https://example.org/hidden-speed"
     585              :         );
     586              :         // compaction without a matching term renders the FQN verbatim
     587            2 :         let c = ctx(json!({"name": "https://example.org/name"}));
     588            2 :         assert_eq!(
     589            2 :             c.compact_iri("https://elsewhere.org/unmapped"),
     590              :             "https://elsewhere.org/unmapped"
     591              :         );
     592            2 :     }
     593              : 
     594              :     #[test]
     595            2 :     fn plain_term_mapping() {
     596            2 :         let c = ctx(json!({"name": "https://example.org/name"}));
     597            2 :         assert_eq!(c.expand_key("name"), "https://example.org/name");
     598            2 :         assert_eq!(c.compact_iri("https://example.org/name"), "name");
     599            2 :     }
     600              : 
     601              :     #[test]
     602            2 :     fn prefix_expansion() {
     603            2 :         let c = ctx(json!({"ex": "https://example.org/", "a": {"@id": "ex:a"}}));
     604            2 :         assert_eq!(c.expand_key("a"), "https://example.org/a");
     605            2 :         assert_eq!(c.expand_key("ex:b"), "https://example.org/b");
     606            2 :         assert_eq!(c.compact_iri("https://example.org/b"), "ex:b");
     607            2 :     }
     608              : 
     609              :     #[test]
     610            2 :     fn vocab_fallback() {
     611            2 :         let c = ctx(json!({"@vocab": "https://voc.example/"}));
     612            2 :         assert_eq!(c.expand_key("speed"), "https://voc.example/speed");
     613            2 :         assert_eq!(c.compact_iri("https://voc.example/speed"), "speed");
     614            2 :     }
     615              : 
     616              :     #[test]
     617            2 :     fn default_vocab_when_absent() {
     618            2 :         let c = ctx(json!({}));
     619            2 :         assert_eq!(
     620            2 :             c.expand_key("speed"),
     621              :             "https://uri.etsi.org/ngsi-ld/default-context/speed"
     622              :         );
     623            2 :     }
     624              : 
     625              :     // ---- merge_object -------------------------------------------------
     626              : 
     627              :     /// 4.4: the Core @context is merged last, so its definitions win over a
     628              :     /// user redefinition of the same term. The user's own new terms survive.
     629              :     #[test]
     630            2 :     fn later_merge_wins_so_core_terms_cannot_be_redefined() {
     631            2 :         let mut c = Context::default();
     632            2 :         c.merge_object(
     633            2 :             json!({"observedAt": "https://evil.example/observedAt",
     634            2 :                    "speed": "https://example.org/speed"})
     635            2 :             .as_object()
     636            2 :             .unwrap(),
     637              :         )
     638            2 :         .unwrap();
     639            2 :         c.merge_object(
     640            2 :             json!({"observedAt": "https://uri.etsi.org/ngsi-ld/observedAt"})
     641            2 :                 .as_object()
     642            2 :                 .unwrap(),
     643              :         )
     644            2 :         .unwrap();
     645            2 :         c.freeze();
     646            2 :         assert_eq!(
     647            2 :             c.expand_key("observedAt"),
     648              :             "https://uri.etsi.org/ngsi-ld/observedAt"
     649              :         );
     650            2 :         assert_ne!(
     651            2 :             c.expand_key("observedAt"),
     652              :             "https://evil.example/observedAt"
     653              :         );
     654              :         // the user's unrelated term is untouched by the core merge
     655            2 :         assert_eq!(c.expand_key("speed"), "https://example.org/speed");
     656            2 :     }
     657              : 
     658              :     /// A null term definition removes the term; the removed term must fall
     659              :     /// back to the vocabulary rather than keep its old IRI.
     660              :     #[test]
     661            2 :     fn null_definition_removes_term() {
     662            2 :         let mut c = Context::default();
     663            2 :         c.merge_object(
     664            2 :             json!({"speed": "https://example.org/speed"})
     665            2 :                 .as_object()
     666            2 :                 .unwrap(),
     667              :         )
     668            2 :         .unwrap();
     669            2 :         c.merge_object(json!({"speed": null}).as_object().unwrap())
     670            2 :             .unwrap();
     671            2 :         c.freeze();
     672            2 :         assert_ne!(c.expand_key("speed"), "https://example.org/speed");
     673            2 :         assert_eq!(
     674            2 :             c.expand_key("speed"),
     675              :             "https://uri.etsi.org/ngsi-ld/default-context/speed"
     676              :         );
     677              :         // freeze() rebuilds the inverse map: the dropped IRI no longer compacts
     678            2 :         assert_eq!(
     679            2 :             c.compact_iri("https://example.org/speed"),
     680              :             "https://example.org/speed"
     681              :         );
     682            2 :     }
     683              : 
     684              :     /// Keywords and definition values that are neither string, object nor null
     685              :     /// are ignored — a hostile @context of numbers/booleans/arrays must not
     686              :     /// panic and must not create terms.
     687              :     #[test]
     688            2 :     fn keyword_and_non_string_definitions_ignored() {
     689            2 :         let c = ctx(json!({
     690            2 :             "@protected": true,
     691            2 :             "@base": "https://base.example/",
     692            2 :             "n": 1,
     693            2 :             "b": false,
     694            2 :             "a": [1, 2, 3],
     695            2 :             "empty": {},
     696            2 :             "keep": "https://example.org/keep"
     697              :         }));
     698            2 :         assert!(c.term("n").is_none());
     699            2 :         assert!(c.term("b").is_none());
     700            2 :         assert!(c.term("a").is_none());
     701              :         // an object definition with neither @id, @type nor @container is skipped
     702            2 :         assert!(c.term("empty").is_none());
     703            2 :         assert_eq!(c.expand_key("keep"), "https://example.org/keep");
     704              :         // unknown terms must not vanish — they expand into the vocabulary
     705            2 :         assert_eq!(
     706            2 :             c.expand_key("n"),
     707              :             "https://uri.etsi.org/ngsi-ld/default-context/n"
     708              :         );
     709            2 :     }
     710              : 
     711              :     /// Expanded term-definition forms: @type/@container flags and a term
     712              :     /// aliased to a JSON-LD keyword (which must stay the keyword, not expand
     713              :     /// into the vocabulary).
     714              :     /// A Core document's scoped contexts (Annex B): an object, an array of
     715              :     /// objects, or a remote reference (not fetched, so it scopes nothing);
     716              :     /// nested scopes stack, `outside` drops them, and a scoped term of the
     717              :     /// same name keeps the outer term from compacting.
     718              :     #[test]
     719            2 :     fn core_scoped_contexts_stack_and_shadow() {
     720            2 :         let mut c = Context::default();
     721            2 :         c.merge_core_object(
     722            2 :             json!({
     723            2 :                 "ex": "https://example.org/",
     724            2 :                 "name": "https://example.org/name",
     725            2 :                 "Car": {"@id": "ex:Car", "@context": {
     726            2 :                     "@version": 1.1,
     727            2 :                     "name": "https://example.org/carName",
     728            2 :                     "wheels": {"@id": "ex:wheels"},
     729            2 :                     "dupB": "https://example.org/dup",
     730            2 :                     "dupA": "https://example.org/dup",
     731            2 :                     "d": "https://example.org/dup",
     732            2 :                     "skip": {"@type": "@id"},
     733            2 :                     "bad": 3
     734            2 :                 }},
     735            2 :                 "Bus": {"@id": "ex:Bus", "@context": [
     736            2 :                     {"seats": "ex:seats"},
     737            2 :                     "https://example.org/remote.jsonld"
     738            2 :                 ]},
     739            2 :                 "Proof": {"@id": "ex:Proof", "@context": "https://example.org/remote.jsonld"},
     740            2 :                 "same1": "https://example.org/same",
     741            2 :                 "s2": "https://example.org/same"
     742            2 :             })
     743            2 :             .as_object()
     744            2 :             .unwrap(),
     745              :         )
     746            2 :         .unwrap();
     747            2 :         c.freeze();
     748              : 
     749            2 :         assert!(matches!(
     750            2 :             c.within("https://example.org/Nothing"),
     751              :             Cow::Borrowed(_)
     752              :         ));
     753            2 :         assert!(matches!(c.outside(), Cow::Borrowed(_)));
     754            2 :         let car = c.within("https://example.org/Car").into_owned();
     755            2 :         assert_eq!(car.expand_key("wheels"), "https://example.org/wheels");
     756            2 :         assert!(car.term("skip").is_none_or(|d| d.iri != "skip"));
     757              :         // the outer "name" IRI no longer compacts to "name" inside the Car scope
     758            2 :         assert_ne!(car.compact_iri("https://example.org/name"), "name");
     759            2 :         assert_eq!(car.compact_iri("https://example.org/carName"), "name");
     760            2 :         assert_eq!(
     761            2 :             car.compact_iri("https://example.org/dup"),
     762              :             "d",
     763              :             "shortest term wins"
     764              :         );
     765            2 :         let both = car.within("https://example.org/Bus").into_owned();
     766            2 :         assert_eq!(both.expand_key("seats"), "https://example.org/seats");
     767            2 :         assert_eq!(both.expand_key("wheels"), "https://example.org/wheels");
     768            2 :         assert_eq!(both.compact_iri("https://example.org/same"), "s2");
     769            2 :         let proof = c.within("https://example.org/Proof").into_owned();
     770            2 :         assert_eq!(proof.expand_key("name"), "https://example.org/name");
     771            2 :         let out = both.outside();
     772            2 :         assert_eq!(out.expand_key("name"), "https://example.org/name");
     773            2 :         assert_eq!(out.compact_iri("https://example.org/name"), "name");
     774            2 :     }
     775              : 
     776              :     /// `@id` values in a term definition: a prefix that is not
     777              :     /// prefix-capable, an unknown prefix, a colon string that is no IRI, a
     778              :     /// bare term, and a term whose own name is already an absolute IRI.
     779              :     #[test]
     780            2 :     fn definition_ids_that_do_not_expand_through_a_prefix() {
     781            2 :         let c = ctx(json!({
     782            2 :             "ex": "https://example.org/",
     783            2 :             "rel": {"@id": "ex:rel"},
     784            2 :             "viaRel": {"@id": "rel:x"},
     785            2 :             "unknown": {"@id": "nope:x"},
     786            2 :             "odd": {"@id": "1x:y"},
     787            2 :             "bare": {"@id": "ex"},
     788            2 :             "https://example.org/self": {"@type": "@id"}
     789              :         }));
     790            2 :         assert_eq!(c.term("viaRel").unwrap().iri, "rel:x");
     791            2 :         assert_eq!(c.term("unknown").unwrap().iri, "nope:x");
     792            2 :         assert_eq!(c.term("odd").unwrap().iri, "1x:y");
     793            2 :         assert_eq!(c.term("bare").unwrap().iri, "https://example.org/");
     794            2 :         assert_eq!(
     795            2 :             c.term("https://example.org/self").unwrap().iri,
     796              :             "https://example.org/self"
     797              :         );
     798            2 :     }
     799              : 
     800              :     #[test]
     801            2 :     fn expanded_definition_forms() {
     802            2 :         let c = ctx(json!({
     803            2 :             "ex": "https://example.org/",
     804            2 :             "rel": {"@id": "ex:rel", "@type": "@id"},
     805            2 :             "kind": {"@id": "ex:kind", "@type": "@vocab"},
     806            2 :             "items": {"@id": "ex:items", "@container": "@list"},
     807            2 :             "alias": {"@id": "@type"},
     808            2 :             "implicit": {"@type": "@id"},
     809            2 :             "implicitList": {"@container": "@list"}
     810              :         }));
     811            2 :         let rel = c.term("rel").unwrap();
     812            2 :         assert!(rel.type_is_id);
     813            2 :         assert!(!rel.type_is_vocab);
     814            2 :         assert!(!rel.container_list);
     815            2 :         assert_eq!(rel.iri, "https://example.org/rel");
     816            2 :         assert!(c.term("kind").unwrap().type_is_vocab);
     817            2 :         assert!(c.term("items").unwrap().container_list);
     818            2 :         assert!(c.term("implicitList").unwrap().container_list);
     819            2 :         assert_eq!(c.term("alias").unwrap().iri, "@type");
     820              :         // no @id but @type present: the term maps into the vocabulary
     821            2 :         assert_eq!(
     822            2 :             c.term("implicit").unwrap().iri,
     823              :             "https://uri.etsi.org/ngsi-ld/default-context/implicit"
     824              :         );
     825              :         // an expanded definition is not prefix-capable unless @prefix says
     826              :         // so: "rel:x" stays the IRI it already is and is NOT rewritten
     827              :         // through the term.
     828            2 :         assert!(!rel.prefix_ok);
     829            2 :         assert_eq!(c.expand_key("rel:x"), "rel:x");
     830            2 :         assert_ne!(c.expand_key("rel:x"), "https://example.org/relx");
     831            2 :     }
     832              : 
     833              :     /// The @context is attacker-supplied. Prefix chaining ("t1" defined
     834              :     /// through "t0", "t2" through "t1", …) makes each definition carry the
     835              :     /// whole chain, so N such terms expand to O(N²) bytes — a few megabytes of
     836              :     /// request body would otherwise become gigabytes of term map. The merge
     837              :     /// must stop with BadRequestData instead.
     838              :     #[test]
     839            2 :     fn prefix_chain_cannot_amplify_unbounded() {
     840            2 :         let suffix = "a".repeat(32);
     841            2 :         let mut obj = Map::new();
     842            2 :         obj.insert(
     843            2 :             "t000000".into(),
     844            2 :             Value::String(format!("https://ex.example/{suffix}")),
     845              :         );
     846         3998 :         for i in 1..2000u32 {
     847         3998 :             obj.insert(
     848         3998 :                 format!("t{i:06}"),
     849         3998 :                 Value::String(format!("t{:06}:{suffix}", i - 1)),
     850         3998 :             );
     851         3998 :         }
     852            2 :         let mut c = Context::default();
     853            2 :         let err = c
     854            2 :             .merge_object(&obj)
     855            2 :             .expect_err("an amplifying @context must be rejected");
     856            2 :         assert!(
     857            2 :             matches!(err, NgsiError::BadRequestData(_)),
     858              :             "BadRequestData, got {err:?}"
     859              :         );
     860            2 :     }
     861              : 
     862              :     /// The bound must not reject an ordinary large vocabulary: 20 000 plain
     863              :     /// term mappings expand to roughly their own size and are accepted.
     864              :     #[test]
     865            2 :     fn large_plain_context_still_accepted() {
     866            2 :         let mut obj = Map::new();
     867        40000 :         for i in 0..20_000u32 {
     868        40000 :             obj.insert(
     869        40000 :                 format!("term{i}"),
     870        40000 :                 Value::String(format!("https://ex.example/vocab#term{i}")),
     871        40000 :             );
     872        40000 :         }
     873            2 :         let mut c = Context::default();
     874            2 :         c.merge_object(&obj).expect("plain context accepted");
     875            2 :         c.freeze();
     876            2 :         assert_eq!(
     877            2 :             c.expand_key("term19999"),
     878              :             "https://ex.example/vocab#term19999"
     879              :         );
     880            2 :     }
     881              : 
     882              :     /// Self-referential and mutually-referential prefixes must terminate:
     883              :     /// term-definition IRI expansion resolves one level against the terms
     884              :     /// already merged, it never follows a chain recursively.
     885              :     #[test]
     886            2 :     fn self_referential_prefixes_terminate() {
     887            2 :         let c = ctx(json!({"a": "a:x"}));
     888            2 :         assert!(!c.term("a").unwrap().iri.is_empty());
     889            2 :         let c = ctx(json!({"a": "b:x", "b": "a:y"}));
     890              :         // both resolved to something finite; neither hung nor recursed
     891            2 :         assert!(c.term("a").unwrap().iri.len() < 64);
     892            2 :         assert!(c.term("b").unwrap().iri.len() < 64);
     893            2 :     }
     894              : 
     895              :     // ---- freeze -------------------------------------------------------
     896              : 
     897              :     /// Several terms bound to one IRI: compaction picks the shortest, ties
     898              :     /// broken lexicographically, and the choice is stable across freezes.
     899              :     #[test]
     900            2 :     fn freeze_inverse_is_deterministic() {
     901            2 :         let c = ctx(json!({
     902            2 :             "aaa": "https://example.org/x",
     903            2 :             "bb": "https://example.org/x",
     904            2 :             "cc": "https://example.org/x"
     905              :         }));
     906            2 :         assert_eq!(c.compact_iri("https://example.org/x"), "bb");
     907            2 :         for _ in 0..5 {
     908           10 :             let c2 = ctx(json!({
     909           10 :                 "cc": "https://example.org/x",
     910           10 :                 "bb": "https://example.org/x",
     911           10 :                 "aaa": "https://example.org/x"
     912              :             }));
     913           10 :             assert_eq!(c2.compact_iri("https://example.org/x"), "bb");
     914              :         }
     915            2 :     }
     916              : 
     917              :     // ---- expand_key ---------------------------------------------------
     918              : 
     919              :     /// Adversarial keys: empty, lone/leading/trailing colons, "://", huge
     920              :     /// prefixes and multi-byte UTF-8 — expansion slices on ':' and must never
     921              :     /// panic on a character boundary.
     922              :     #[test]
     923            2 :     fn expand_key_adversarial_strings_do_not_panic() {
     924            2 :         let c = ctx(json!({"ex": "https://example.org/", "": "https://empty.example/"}));
     925            2 :         let vocab = "https://uri.etsi.org/ngsi-ld/default-context/";
     926            2 :         assert_eq!(c.expand_key(""), "https://empty.example/");
     927            2 :         assert_eq!(c.expand_key(":"), "https://empty.example/");
     928            2 :         assert_eq!(c.expand_key("://"), format!("{vocab}://"));
     929            2 :         assert_eq!(c.expand_key(":x"), "https://empty.example/x");
     930            2 :         assert_eq!(c.expand_key("ex:"), "https://example.org/");
     931            2 :         assert_eq!(c.expand_key("é"), format!("{vocab}é"));
     932            2 :         assert_eq!(c.expand_key("ex:°C"), "https://example.org/°C");
     933            2 :         assert_eq!(c.expand_key("日本:語"), format!("{vocab}日本:語"));
     934            2 :         assert_eq!(c.expand_key("\u{feff}:x"), format!("{vocab}\u{feff}:x"));
     935            2 :         let huge = "x".repeat(100_000);
     936            2 :         assert_eq!(c.expand_key(&huge), format!("{vocab}{huge}"));
     937            2 :         assert_eq!(
     938            2 :             c.expand_key(&format!("ex:{huge}")),
     939            2 :             format!("https://example.org/{huge}")
     940              :         );
     941            2 :     }
     942              : 
     943              :     /// A term definition wins over reading the same key as prefix:suffix.
     944              :     #[test]
     945            2 :     fn term_lookup_precedes_prefix_split() {
     946            2 :         let c = ctx(json!({"ex": "https://example.org/", "ex:a": "https://direct.example/a"}));
     947            2 :         assert_eq!(c.expand_key("ex:a"), "https://direct.example/a");
     948            2 :         assert_ne!(c.expand_key("ex:a"), "https://example.org/a");
     949            2 :     }
     950              : 
     951              :     /// An absolute IRI used as a key stays itself; a non-prefix-capable term
     952              :     /// before the colon must not be applied.
     953              :     #[test]
     954            2 :     fn absolute_iri_keys_pass_through() {
     955            2 :         let c = ctx(json!({"ex": {"@id": "https://example.org/"}}));
     956            2 :         assert_eq!(
     957            2 :             c.expand_key("https://other.example/a"),
     958              :             "https://other.example/a"
     959              :         );
     960            2 :         assert_eq!(c.expand_key("urn:ngsi-ld:X"), "urn:ngsi-ld:X");
     961              :         // @id-form definitions are not prefixes (JSON-LD 1.1 simple-term rule)
     962            2 :         assert_eq!(c.expand_key("ex:a"), "ex:a");
     963            2 :     }
     964              : 
     965              :     // ---- compact_iri --------------------------------------------------
     966              : 
     967              :     /// Compaction must not leave an expanded IRI in the document when the
     968              :     /// context defines a term for it.
     969              :     #[test]
     970            2 :     fn defined_terms_never_stay_expanded() {
     971            2 :         let c = ctx(json!({"ex": "https://example.org/", "name": "https://example.org/name"}));
     972            2 :         assert_eq!(c.compact_iri("https://example.org/name"), "name");
     973            2 :         assert_ne!(
     974            2 :             c.compact_iri("https://example.org/name"),
     975              :             "https://example.org/name"
     976              :         );
     977              :         // no exact term: longest prefix-capable term wins
     978            2 :         assert_eq!(c.compact_iri("https://example.org/other"), "ex:other");
     979            2 :     }
     980              : 
     981              :     /// Vocab-relative shortening only when the bare term round-trips: if the
     982              :     /// context binds that term to a DIFFERENT IRI the full IRI is kept.
     983              :     #[test]
     984            2 :     fn vocab_shortening_requires_round_trip() {
     985            2 :         let c = ctx(json!({"@vocab": "https://voc.example/",
     986            2 :                            "speed": "https://other.example/speed"}));
     987            2 :         assert_eq!(
     988            2 :             c.compact_iri("https://voc.example/speed"),
     989              :             "https://voc.example/speed"
     990              :         );
     991            2 :         assert_ne!(c.compact_iri("https://voc.example/speed"), "speed");
     992              :         // a vocab-relative remainder containing ':' is not a usable term
     993            2 :         assert_eq!(
     994            2 :             c.compact_iri("https://voc.example/a:b"),
     995              :             "https://voc.example/a:b"
     996              :         );
     997              :         // the vocab IRI itself has an empty remainder
     998            2 :         assert_eq!(
     999            2 :             c.compact_iri("https://voc.example/"),
    1000              :             "https://voc.example/"
    1001              :         );
    1002            2 :     }
    1003              : 
    1004              :     /// Longest matching prefix wins, and a term whose IRI is empty is never
    1005              :     /// used as a prefix (it would match everything).
    1006              :     #[test]
    1007            2 :     fn prefix_compaction_picks_longest_and_skips_empty() {
    1008            2 :         let c = ctx(json!({"ex": "https://example.org/",
    1009            2 :                            "sub": "https://example.org/sub/",
    1010            2 :                            "nil": {"@id": "", "@prefix": true}}));
    1011            2 :         assert_eq!(c.compact_iri("https://example.org/sub/x"), "sub:x");
    1012            2 :         assert_eq!(c.compact_iri("https://example.org/y"), "ex:y");
    1013            2 :         assert!(!c
    1014            2 :             .compact_iri("https://elsewhere.example/z")
    1015            2 :             .starts_with("nil:"));
    1016            2 :     }
    1017              : 
    1018              :     /// Compaction must be reproducible across processes: when several
    1019              :     /// prefix-capable terms share one IRI the winner may not depend on hash
    1020              :     /// map iteration order, which is randomly seeded per map.
    1021              :     #[test]
    1022            2 :     fn prefix_compaction_tie_break_is_stable() {
    1023            2 :         let defs = json!({
    1024            2 :             "h": "https://example.org/", "g": "https://example.org/",
    1025            2 :             "f": "https://example.org/", "e": "https://example.org/",
    1026            2 :             "d": "https://example.org/", "c": "https://example.org/",
    1027            2 :             "b": "https://example.org/", "a": "https://example.org/"
    1028              :         });
    1029            2 :         for _ in 0..50 {
    1030          100 :             assert_eq!(
    1031          100 :                 ctx(defs.clone()).compact_iri("https://example.org/z"),
    1032              :                 "a:z"
    1033              :             );
    1034              :         }
    1035            2 :     }
    1036              : 
    1037              :     // ---- is_absolute_iri ----------------------------------------------
    1038              : 
    1039              :     /// RFC 3986 scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ), and the
    1040              :     /// hierarchical part must not be empty. The edge set below is what a
    1041              :     /// hostile @context or entity key can carry.
    1042              :     #[test]
    1043            2 :     fn is_absolute_iri_edge_set() {
    1044           16 :         for s in [
    1045            2 :             "https://example.org/x",
    1046            2 :             "urn:ngsi-ld:X",
    1047            2 :             "a:b",
    1048            2 :             "a+b-c.d:x",
    1049            2 :             "A:x",
    1050            2 :             "a::b",
    1051            2 :             "x:日本",
    1052            2 :             "http://x",
    1053            2 :         ] {
    1054           16 :             assert!(is_absolute_iri(s), "expected absolute: {s:?}");
    1055              :         }
    1056           28 :         for s in [
    1057            2 :             "",
    1058            2 :             ":",
    1059            2 :             "://",
    1060            2 :             ":x",
    1061            2 :             "a:",
    1062            2 :             "1a:x",
    1063            2 :             "3D:x",
    1064            2 :             "a b:x",
    1065            2 :             "日本:x",
    1066            2 :             "no-colon",
    1067            2 :             "+x:y",
    1068            2 :             "-x:y",
    1069            2 :             ".x:y",
    1070            2 :             "\u{feff}:x",
    1071            2 :         ] {
    1072           28 :             assert!(!is_absolute_iri(s), "expected NOT absolute: {s:?}");
    1073              :         }
    1074            2 :     }
    1075              : 
    1076              :     /// Prefix compaction picks the LONGEST matching prefix-capable term, and
    1077              :     /// resolves a tie on the term itself (shortest, then lexicographic) so
    1078              :     /// the answer cannot differ between processes — `terms` is a
    1079              :     /// randomly-seeded HashMap. The index `freeze` builds must give the same
    1080              :     /// answer whatever else the vocabulary holds, so the same assertions run
    1081              :     /// against a term map three orders of magnitude larger.
    1082              :     #[test]
    1083            2 :     fn prefix_compaction_takes_the_longest_match_whatever_the_vocabulary_size() {
    1084            4 :         let cases = |noise: usize| {
    1085            4 :             let mut m = Map::new();
    1086            4 :             m.insert("short".into(), Value::String("http://ex.example/".into()));
    1087            4 :             m.insert(
    1088            4 :                 "long".into(),
    1089            4 :                 Value::String("http://ex.example/deep/".into()),
    1090              :             );
    1091              :             // two terms on ONE IRI: the tie-break decides which is written
    1092            4 :             m.insert("bb".into(), Value::String("http://tie.example/".into()));
    1093            4 :             m.insert("aa".into(), Value::String("http://tie.example/".into()));
    1094            4 :             m.insert("aaa".into(), Value::String("http://tie.example/".into()));
    1095       200000 :             for i in 0..noise {
    1096       200000 :                 m.insert(
    1097       200000 :                     format!("n{i:06}"),
    1098       200000 :                     Value::String(format!("http://noise.example/{i}/")),
    1099       200000 :                 );
    1100       200000 :             }
    1101            4 :             let mut c = Context::default();
    1102            4 :             c.merge_object(&m).expect("merge");
    1103            4 :             c.freeze();
    1104            4 :             c
    1105            4 :         };
    1106            4 :         for noise in [0usize, 100_000] {
    1107            4 :             let c = cases(noise);
    1108              :             // longest match wins over the shorter one that also matches
    1109            4 :             assert_eq!(
    1110            4 :                 c.compact_iri("http://ex.example/deep/x"),
    1111              :                 "long:x",
    1112              :                 "{noise}"
    1113              :             );
    1114            4 :             assert_eq!(c.compact_iri("http://ex.example/x"), "short:x", "{noise}");
    1115              :             // shortest term, then lexicographic, among terms on one IRI
    1116            4 :             assert_eq!(c.compact_iri("http://tie.example/x"), "aa:x", "{noise}");
    1117              :             // an IRI no term prefixes comes back whole
    1118            4 :             assert_eq!(
    1119            4 :                 c.compact_iri("http://miss.example/x"),
    1120              :                 "http://miss.example/x",
    1121              :                 "{noise}"
    1122              :             );
    1123              :             // an exact IRI match is a term, not a prefix compaction
    1124            4 :             assert_eq!(c.compact_iri("http://ex.example/"), "short", "{noise}");
    1125              :             // the suffix must be non-empty for a prefix to apply, and a
    1126              :             // multi-byte boundary must not split a character
    1127            4 :             assert_eq!(
    1128            4 :                 c.compact_iri("http://ex.example/deep/é"),
    1129              :                 "long:é",
    1130              :                 "{noise}"
    1131              :             );
    1132              :         }
    1133            2 :     }
    1134              : }
        

Generated by: LCOV version 2.0-1