LCOV - code coverage report
Current view: top level - antares-jsonld/src - pinned.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 100.0 % 232 232
Test Date: 2026-11-02 13:21:27 Functions: 74.5 % 55 41

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

Generated by: LCOV version 2.0-1