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