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