Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! The @contexts this crate carries and the resolution that needs nothing
3 : //! else: the pinned core contexts, compiled in, and a
4 : //! [`ContextLoader`] a caller implements to supply any other document. No
5 : //! network: the caching HTTP loader is `loader`, behind the `fetch` feature.
6 :
7 : use crate::context::Context;
8 : use antares_model::NgsiError;
9 : use serde_json::Value;
10 :
11 : /// Core context versions served from the build, never the network
12 : /// (uri.etsi.org serves an HTML landing page to plain HTTP clients).
13 : pub(crate) static PINNED: &[(&str, &str)] = &[
14 : (
15 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.3.jsonld",
16 : include_str!("../contexts/core-v1.6.jsonld"),
17 : ),
18 : (
19 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.4.jsonld",
20 : include_str!("../contexts/core-v1.6.jsonld"),
21 : ),
22 : (
23 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.5.jsonld",
24 : include_str!("../contexts/core-v1.6.jsonld"),
25 : ),
26 : (
27 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.6.jsonld",
28 : include_str!("../contexts/core-v1.6.jsonld"),
29 : ),
30 : (
31 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.7.jsonld",
32 : include_str!("../contexts/core-v1.7.jsonld"),
33 : ),
34 : (
35 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.8.jsonld",
36 : include_str!("../contexts/core-v1.8.jsonld"),
37 : ),
38 : (
39 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld",
40 : include_str!("../contexts/core-v1.9.jsonld"),
41 : ),
42 : ];
43 :
44 : /// 4.4: "The NGSI-LD Core @context is publicly available at
45 : /// `https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld` and
46 : /// shall contain all the terms as mandated by annex B." It is what this broker
47 : /// advertises (the Link header of a `application/json` answer, the `@context`
48 : /// of an `ld+json` one, the context a forwarded request carries) and the
49 : /// document merged last when a request names no @context of its own. Older
50 : /// core versions stay in `PINNED` because a client may still reference one.
51 : pub const CORE_CONTEXT: &str = "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld";
52 :
53 : /// @context responses above this size are refused.
54 : pub(crate) const MAX_CONTEXT_BYTES: usize = 5 * 1024 * 1024;
55 :
56 : /// Fetch-count cap per @context resolution — a hostile context tree must
57 : /// not turn one request into an unbounded crawl. Checked BEFORE each
58 : /// fetch, so at most this many URLs are ever contacted. Public because
59 : /// `/q/health` publishes the caps a request runs under, and a second
60 : /// constant carrying the same number is one that can drift from the one
61 : /// actually enforced.
62 : pub const MAX_CONTEXT_URLS: usize = 32;
63 :
64 : /// The pinned core `@context`, parsed and frozen, with no loader, client or
65 : /// cache behind it: the value every `Loader` pins, and what a test of
66 : /// expansion or matching needs on its own.
67 : #[allow(clippy::expect_used)]
68 7616 : pub fn core_context() -> Context {
69 7616 : let mut core = Context::default();
70 : // PINNED holds CORE_CONTEXT and every entry parses: pinned by
71 : // `every_pinned_context_parses_and_carries_an_at_context`
72 7616 : merge_context_value(&mut core, &pinned(CORE_CONTEXT).expect("pinned core"));
73 7616 : core.freeze();
74 7616 : core.source = Value::String(CORE_CONTEXT.to_owned());
75 7616 : core
76 7616 : }
77 :
78 : /// The core @context URL of NGSI-LD version `major.minor` (TS 104 175
79 : /// 13.4.3 `core`), for the versions whose core is pinned in this build;
80 : /// `None` for anything else, a malformed version included.
81 30 : pub fn core_context_url(version: &str) -> Option<&'static str> {
82 30 : let (major, minor) = version.split_once('.')?;
83 48 : let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
84 26 : if !digits(major) || !digits(minor) {
85 12 : return None;
86 14 : }
87 14 : let want = format!("https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v{version}.jsonld");
88 84 : PINNED.iter().map(|(u, _)| *u).find(|u| *u == want)
89 30 : }
90 :
91 : /// The `@context` of a compiled-in document, or `None` for a URL that is not
92 : /// pinned. The bodies are `include_str!`-ed at build time, so a body that
93 : /// does not parse or carries no `@context` is a build defect rather than
94 : /// anything a request can cause —
95 : /// `every_pinned_context_parses_and_carries_an_at_context` fails on it.
96 62009 : pub(crate) fn pinned(url: &str) -> Option<Value> {
97 411907 : let (_, body) = PINNED.iter().find(|(u, _)| *u == url)?;
98 39113 : let doc: Value = serde_json::from_str(body).ok()?;
99 39113 : doc.get("@context").cloned()
100 62009 : }
101 :
102 : /// Merge a pre-parsed Core @context value (4.4: merged last, so it wins).
103 8647 : pub(crate) fn merge_context_value(ctx: &mut Context, v: &Value) {
104 8647 : match v {
105 8643 : Value::Object(o) => {
106 8643 : let _ = ctx.merge_core_object(o);
107 8643 : }
108 2 : Value::Array(items) => {
109 4 : for i in items {
110 4 : merge_context_value(ctx, i);
111 4 : }
112 : }
113 2 : _ => {}
114 : }
115 8647 : }
116 :
117 : /// Supplies the documents a resolution names beyond the pinned core
118 : /// contexts. `None` means this loader has no such document; the resolution
119 : /// then answers `LdContextNotAvailable`, as the broker does for a URL it
120 : /// cannot fetch. Synchronous: expansion never awaits, and a caller with a
121 : /// network fetches before it resolves.
122 : pub trait ContextLoader {
123 : /// The `@context` member of the document at `url` (absolute).
124 : fn load(&self, url: &str) -> Option<Value>;
125 : }
126 :
127 : /// The loader that knows the pinned core contexts and nothing else.
128 : pub struct PinnedOnly;
129 :
130 : impl ContextLoader for PinnedOnly {
131 2 : fn load(&self, _url: &str) -> Option<Value> {
132 2 : None
133 2 : }
134 : }
135 :
136 : /// Resolve a user `@context` (a URL, an inline object, or an array of
137 : /// both) the way the broker's `Loader` does, without a network or a cache:
138 : /// pinned core URLs first, every other URL from `loader`, the Core
139 : /// `@context` merged last so its terms win (CIM 009 4.4). The same caps
140 : /// apply: nesting deeper than 8 and more than [`MAX_CONTEXT_URLS`]
141 : /// referenced URLs are `BadRequestData`.
142 28 : pub fn resolve_with(loader: &dyn ContextLoader, user: &Value) -> Result<Context, NgsiError> {
143 28 : let mut ctx = Context::default();
144 28 : let mut urls = 0usize;
145 28 : merge_entry(loader, &mut ctx, user, 0, &mut urls, None)?;
146 16 : let core = pinned(CORE_CONTEXT)
147 16 : .ok_or_else(|| NgsiError::InternalError("the core @context is not pinned".into()))?;
148 16 : merge_context_value(&mut ctx, &core);
149 16 : ctx.freeze();
150 16 : ctx.source = user.clone();
151 16 : Ok(ctx)
152 28 : }
153 :
154 204 : fn merge_entry(
155 204 : loader: &dyn ContextLoader,
156 204 : ctx: &mut Context,
157 204 : entry: &Value,
158 204 : depth: usize,
159 204 : urls: &mut usize,
160 204 : base: Option<&str>,
161 204 : ) -> Result<(), NgsiError> {
162 204 : if depth > 8 {
163 2 : return Err(NgsiError::BadRequestData(
164 2 : "@context nesting too deep".into(),
165 2 : ));
166 202 : }
167 202 : match entry {
168 26 : Value::Array(items) => items
169 26 : .iter()
170 98 : .try_for_each(|i| merge_entry(loader, ctx, i, depth.saturating_add(1), urls, base)),
171 86 : Value::String(url) => {
172 86 : if *urls >= MAX_CONTEXT_URLS {
173 2 : return Err(NgsiError::BadRequestData(format!(
174 2 : "@context resolution exceeds {MAX_CONTEXT_URLS} referenced URLs"
175 2 : )));
176 84 : }
177 84 : *urls = urls.saturating_add(1);
178 84 : let resolved = match base {
179 6 : Some(b) if !url.starts_with("http://") && !url.starts_with("https://") => {
180 2 : join(b, url)
181 : }
182 82 : _ => url.clone(),
183 : };
184 84 : let doc = pinned(&resolved)
185 84 : .or_else(|| loader.load(&resolved))
186 84 : .ok_or_else(|| {
187 6 : NgsiError::LdContextNotAvailable(format!(
188 6 : "@context {resolved} is not available"
189 6 : ))
190 6 : })?;
191 78 : merge_entry(
192 78 : loader,
193 78 : ctx,
194 78 : &doc,
195 78 : depth.saturating_add(1),
196 78 : urls,
197 78 : Some(&resolved),
198 : )
199 : }
200 : // 5.5.7: a document fetched from a pinned core URL is a Core
201 : // @context and merges under the Core rule; the rest is the user's.
202 86 : Value::Object(obj) => match base {
203 76 : Some(b) if pinned(b).is_some() => ctx.merge_core_object(obj),
204 18 : _ => ctx.merge_object(obj),
205 : },
206 2 : Value::Null => Ok(()),
207 2 : _ => Err(NgsiError::BadRequestData("invalid @context entry".into())),
208 : }
209 204 : }
210 :
211 : /// A relative @context reference against the URL of the document holding
212 : /// it (JSON-LD 1.1 section 3.1).
213 : // Path-relative and root-relative references only, no `..`
214 : // removal; the fetch loader uses a full URL parser.
215 10 : fn join(base: &str, url: &str) -> String {
216 10 : let after_scheme = base.find("://").map_or(0, |i| i.saturating_add(3));
217 10 : let root = base
218 10 : .get(after_scheme..)
219 10 : .and_then(|rest| rest.find('/'))
220 10 : .map_or(base.len(), |i| i.saturating_add(after_scheme));
221 10 : match url.strip_prefix('/') {
222 2 : Some(_) => format!("{}{url}", base.get(..root).unwrap_or(base)),
223 : None => {
224 8 : let dir = base
225 8 : .rfind('/')
226 8 : .filter(|i| *i >= root)
227 8 : .map_or(base.len(), |i| i.saturating_add(1));
228 8 : let prefix = base.get(..dir).unwrap_or(base);
229 8 : if dir == base.len() && !prefix.ends_with('/') {
230 2 : format!("{prefix}/{url}")
231 : } else {
232 6 : format!("{prefix}{url}")
233 : }
234 : }
235 : }
236 10 : }
237 :
238 : #[cfg(test)]
239 : mod tests {
240 : use super::*;
241 : use std::cell::Cell;
242 :
243 : /// A loader that serves one document and counts every call.
244 : struct Counting {
245 : calls: Cell<usize>,
246 : url: &'static str,
247 : doc: Value,
248 : }
249 :
250 : impl ContextLoader for Counting {
251 4 : fn load(&self, url: &str) -> Option<Value> {
252 4 : self.calls.set(self.calls.get().saturating_add(1));
253 4 : (url == self.url).then(|| self.doc.clone())
254 4 : }
255 : }
256 :
257 6 : fn counting() -> Counting {
258 6 : Counting {
259 6 : calls: Cell::new(0),
260 6 : url: "https://example.org/ctx.jsonld",
261 6 : doc: serde_json::json!({"speed": "https://example.org/speed"}),
262 6 : }
263 6 : }
264 :
265 : #[test]
266 2 : fn a_document_on_the_core_context_alone_expands_without_a_loader() {
267 2 : let ctx = resolve_with(&PinnedOnly, &Value::String(CORE_CONTEXT.into())).expect("resolve");
268 2 : let entity = serde_json::json!({
269 2 : "id": "urn:ngsi-ld:Car:1", "type": "Car",
270 2 : "location": {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [1.0, 2.0]}}
271 : });
272 2 : let entity = entity.as_object().cloned().expect("an object literal");
273 2 : let out =
274 2 : crate::expand_entity(&entity, &ctx, crate::ExpandOpts::default()).expect("expand");
275 2 : assert!(
276 2 : out.get("https://uri.etsi.org/ngsi-ld/location").is_some(),
277 : "{out}"
278 : );
279 2 : assert_eq!(
280 2 : ctx.expand_key("location"),
281 : "https://uri.etsi.org/ngsi-ld/location"
282 : );
283 2 : }
284 :
285 : #[test]
286 2 : fn an_unknown_context_is_not_available_after_one_call_and_a_pinned_one_calls_nothing() {
287 2 : let l = counting();
288 2 : let err = resolve_with(&l, &serde_json::json!("https://example.org/other.jsonld"))
289 2 : .expect_err("unknown");
290 2 : assert!(
291 2 : matches!(err, NgsiError::LdContextNotAvailable(_)),
292 : "{err:?}"
293 : );
294 2 : assert_eq!(l.calls.get(), 1);
295 2 : let l = counting();
296 2 : resolve_with(&l, &serde_json::json!([CORE_CONTEXT, {"a": "https://x/a"}])).expect("pinned");
297 2 : assert_eq!(l.calls.get(), 0, "a pinned URL never reaches the loader");
298 2 : }
299 :
300 : #[test]
301 2 : fn a_loaded_context_applies_and_the_core_terms_still_win() {
302 2 : let l = counting();
303 2 : let user = serde_json::json!(["https://example.org/ctx.jsonld",
304 2 : {"Property": "https://evil.example/Property"}]);
305 2 : let ctx = resolve_with(&l, &user).expect("resolve");
306 2 : assert_eq!(ctx.expand_key("speed"), "https://example.org/speed");
307 2 : assert_eq!(
308 2 : ctx.expand_key("Property"),
309 : "https://uri.etsi.org/ngsi-ld/Property"
310 : );
311 2 : assert_eq!(ctx.source, user);
312 2 : }
313 :
314 : #[test]
315 2 : fn the_caps_hold_without_a_network() {
316 2 : let many: Vec<Value> = (0..=MAX_CONTEXT_URLS)
317 66 : .map(|_| Value::String(CORE_CONTEXT.into()))
318 2 : .collect();
319 2 : assert!(matches!(
320 2 : resolve_with(&PinnedOnly, &Value::Array(many)),
321 : Err(NgsiError::BadRequestData(_))
322 : ));
323 2 : let mut deep = serde_json::json!({});
324 20 : for _ in 0..10 {
325 20 : deep = Value::Array(vec![deep]);
326 20 : }
327 2 : assert!(matches!(
328 2 : resolve_with(&PinnedOnly, &deep),
329 : Err(NgsiError::BadRequestData(_))
330 : ));
331 2 : assert!(matches!(
332 2 : resolve_with(&PinnedOnly, &serde_json::json!(7)),
333 : Err(NgsiError::BadRequestData(_))
334 : ));
335 2 : assert!(resolve_with(&PinnedOnly, &Value::Null).is_ok());
336 2 : }
337 :
338 : #[test]
339 2 : fn relative_references_join_against_their_document() {
340 2 : assert_eq!(
341 2 : join("https://h.example/a/b.jsonld", "c.jsonld"),
342 : "https://h.example/a/c.jsonld"
343 : );
344 2 : assert_eq!(
345 2 : join("https://h.example/a/b.jsonld", "/c.jsonld"),
346 : "https://h.example/c.jsonld"
347 : );
348 2 : assert_eq!(
349 2 : join("https://h.example", "c.jsonld"),
350 : "https://h.example/c.jsonld"
351 : );
352 2 : assert_eq!(
353 2 : join("https://h.example/", "c.jsonld"),
354 : "https://h.example/c.jsonld"
355 : );
356 2 : }
357 :
358 : /// `core_context` and the merge tail unwrap `pinned(CORE_CONTEXT)`,
359 : /// because a broker without the Core @context cannot expand anything and
360 : /// has no useful degraded mode. That is only safe while the compiled-in
361 : /// table really does carry a parseable `@context` for every URL in it —
362 : /// which is a property of the build, so it is checked here rather than
363 : /// left to a comment. A pinned document that stops parsing fails this
364 : /// test instead of the first request after deploy.
365 : #[test]
366 2 : fn every_pinned_context_parses_and_carries_an_at_context() {
367 2 : assert!(!PINNED.is_empty(), "the pinned table is empty");
368 14 : for (url, _) in PINNED {
369 14 : assert!(
370 14 : pinned(url).is_some(),
371 : "pinned document {url} does not parse, or carries no @context"
372 : );
373 : }
374 2 : let core = pinned(CORE_CONTEXT).expect("the Core @context is pinned");
375 2 : assert!(
376 2 : matches!(core, Value::Object(_) | Value::Array(_) | Value::String(_)),
377 : "the Core @context is not a usable @context value: {core}"
378 : );
379 2 : assert!(
380 14 : PINNED.iter().any(|(u, _)| *u == CORE_CONTEXT),
381 : "CORE_CONTEXT must be in the pinned table by URL, not merely resolvable"
382 : );
383 2 : }
384 :
385 : /// The edges of the pinned set: which versions have a core URL, a relative
386 : /// reference resolved against the document that holds it, an array merged member by
387 : /// member, and a URL no loader serves.
388 : #[test]
389 2 : fn versions_relative_references_and_arrays() {
390 2 : assert_eq!(
391 2 : core_context_url("1.3"),
392 : Some("https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.3.jsonld")
393 : );
394 12 : for refused in ["1", "1.99", "x.1", ".1", "1.", "1.3a"] {
395 12 : assert_eq!(core_context_url(refused), None, "{refused}");
396 : }
397 :
398 : struct Two;
399 : impl ContextLoader for Two {
400 10 : fn load(&self, url: &str) -> Option<Value> {
401 10 : match url {
402 10 : "https://example.org/a/outer.jsonld" => Some(serde_json::json!([
403 2 : "inner.jsonld",
404 2 : "https://example.org/abs.jsonld",
405 2 : "http://example.org/plain.jsonld"
406 2 : ])),
407 8 : "https://example.org/a/inner.jsonld" => {
408 2 : Some(serde_json::json!({"speed": "https://example.org/speed"}))
409 : }
410 6 : "https://example.org/abs.jsonld" => {
411 2 : Some(serde_json::json!({"size": "https://example.org/size"}))
412 : }
413 4 : "http://example.org/plain.jsonld" => {
414 2 : Some(serde_json::json!({"plain": "https://example.org/plain"}))
415 : }
416 2 : _ => None,
417 : }
418 10 : }
419 : }
420 2 : let ctx = resolve_with(
421 2 : &Two,
422 2 : &Value::String("https://example.org/a/outer.jsonld".into()),
423 : )
424 2 : .expect("resolve");
425 2 : assert_eq!(ctx.expand_key("speed"), "https://example.org/speed");
426 2 : assert_eq!(ctx.expand_key("size"), "https://example.org/size");
427 2 : assert_eq!(ctx.expand_key("plain"), "https://example.org/plain");
428 2 : assert!(resolve_with(&Two, &Value::String("https://example.org/b.jsonld".into())).is_err());
429 2 : let missing = resolve_with(
430 2 : &PinnedOnly,
431 2 : &Value::String("https://example.org/none.jsonld".into()),
432 : );
433 2 : assert!(
434 2 : matches!(missing, Err(NgsiError::LdContextNotAvailable(_))),
435 : "{missing:?}"
436 : );
437 :
438 2 : let mut ctx = Context::default();
439 2 : merge_context_value(
440 2 : &mut ctx,
441 2 : &serde_json::json!([{"speed": "https://example.org/speed"}, 5]),
442 : );
443 2 : assert_eq!(ctx.expand_key("speed"), "https://example.org/speed");
444 2 : }
445 : }
|