Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! Remote @context loading + caching + pinned core contexts.
3 :
4 : use crate::context::Context;
5 : use antares_model::{NgsiError, TenantId};
6 : use serde_json::Value;
7 : use std::collections::HashMap;
8 : use std::sync::Arc;
9 : use tokio::sync::RwLock;
10 : // std Instant panics on wasm32; web-time is the std re-export
11 : // natively and performance.now() in the browser.
12 : #[cfg(not(target_arch = "wasm32"))]
13 : use std::time::Instant;
14 : #[cfg(target_arch = "wasm32")]
15 : use web_time::Instant;
16 : // moka's clock panics on wasm32 (std Instant); the browser build swaps
17 : // in the FIFO minicache behind the same call surface.
18 : #[cfg(target_arch = "wasm32")]
19 : use crate::minicache::Cache as BoundedCache;
20 : #[cfg(not(target_arch = "wasm32"))]
21 : use moka::sync::Cache as BoundedCache;
22 :
23 : /// Core context versions served from the build, never the network
24 : /// (uri.etsi.org serves an HTML landing page to plain HTTP clients).
25 : static PINNED: &[(&str, &str)] = &[
26 : (
27 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.3.jsonld",
28 : include_str!("../contexts/core-v1.6.jsonld"),
29 : ),
30 : (
31 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.4.jsonld",
32 : include_str!("../contexts/core-v1.6.jsonld"),
33 : ),
34 : (
35 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.5.jsonld",
36 : include_str!("../contexts/core-v1.6.jsonld"),
37 : ),
38 : (
39 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.6.jsonld",
40 : include_str!("../contexts/core-v1.6.jsonld"),
41 : ),
42 : (
43 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.7.jsonld",
44 : include_str!("../contexts/core-v1.7.jsonld"),
45 : ),
46 : (
47 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.8.jsonld",
48 : include_str!("../contexts/core-v1.8.jsonld"),
49 : ),
50 : (
51 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld",
52 : include_str!("../contexts/core-v1.9.jsonld"),
53 : ),
54 : ];
55 :
56 : /// 4.4: "The NGSI-LD Core @context is publicly available at
57 : /// `https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld` and
58 : /// shall contain all the terms as mandated by annex B." It is what this broker
59 : /// advertises (the Link header of a `application/json` answer, the `@context`
60 : /// of an `ld+json` one, the context a forwarded request carries) and the
61 : /// document merged last when a request names no @context of its own. Older
62 : /// core versions stay in `PINNED` because a client may still reference one.
63 : pub const CORE_CONTEXT: &str = "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld";
64 :
65 : /// Usage bookkeeping for one externally-referenced @context URL (5.13.3.5:
66 : /// localId, createdAt, numberOfHits, lastUsage of "Cached" entries).
67 : #[derive(Clone, Debug)]
68 : pub struct CtxUsage {
69 : /// The @context URL as the client referenced it.
70 : pub url: String,
71 : /// Broker-generated `localId` for the Cached entry.
72 : pub local_id: String,
73 : /// First reference (`createdAt`).
74 : pub created_at: String,
75 : /// Most recent reference (`lastUsage`).
76 : pub last_usage: String,
77 : /// Number of references (`numberOfHits`).
78 : pub hits: u64,
79 : }
80 :
81 : /// Name resolution runs before the HTTP client exists, so none of its
82 : /// timeouts cover it: an unresponsive resolver would hold the request path
83 : /// open indefinitely. A lookup that does not answer within this bound is a
84 : /// DENIAL — the policy never passes a destination it could not check.
85 : const DNS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
86 :
87 : /// Outbound deadlines stretch 10× when the test binary runs under a
88 : /// sanitizer (ANTARES_TEST_SANITIZER, set by the strict workflow):
89 : /// ThreadSanitizer slows every thread, and hundreds of tests sharing one
90 : /// runner pushed loopback fetches past their limits. Production is 1.
91 29570 : pub fn slow_factor() -> u64 {
92 29570 : if std::env::var_os("ANTARES_TEST_SANITIZER").is_some() {
93 0 : 10
94 : } else {
95 29570 : 1
96 : }
97 29570 : }
98 :
99 : /// Egress policy hook for @context fetches: scheme allowlist is
100 : /// enforced in `fetch`; this adds the private-range deny (loopback,
101 : /// RFC 1918, link-local incl. the 169.254.169.254 metadata range, ULA).
102 : /// Private egress is ALLOWED by default (notifications must reach private
103 : /// nets out of the box — dev boxes, compose stacks and the ETSI/IOP mocks
104 : /// all live there); `ANTARES_EGRESS_ALLOW_PRIVATE=false`
105 : /// turns the deny on for internet-exposed deployments.
106 : /// The DNS-pinning resolver and redirect cap that enforce it on the wire
107 : /// are `PolicyResolver` / `client_builder` below.
108 : #[derive(Clone, Copy, Debug)]
109 : pub struct EgressPolicy {
110 : /// Whether fetches to private/loopback/link-local ranges are allowed.
111 : pub allow_private: bool,
112 : }
113 :
114 : /// Programmatic stand-in for `ANTARES_EGRESS_ALLOW_PRIVATE` — wasm32 has
115 : /// NO process environment (`std::env::var` always errs there), so the
116 : /// browser/Node embedder sets this before constructing the broker.
117 : static ALLOW_PRIVATE_OVERRIDE: std::sync::atomic::AtomicBool =
118 : std::sync::atomic::AtomicBool::new(false);
119 :
120 : /// Grant egress to private/loopback ranges for policies created AFTER this
121 : /// call. The wasm constructor path — wasm32 has no process environment — and
122 : /// the way a test grants itself loopback: an atomic store is the same switch
123 : /// as the environment variable with no write a concurrent reader can land in
124 : /// the middle of. Native deployments use `ANTARES_EGRESS_ALLOW_PRIVATE`.
125 498 : pub fn allow_private_egress(v: bool) {
126 498 : ALLOW_PRIVATE_OVERRIDE.store(v, std::sync::atomic::Ordering::Relaxed);
127 498 : }
128 :
129 : impl EgressPolicy {
130 : /// Policy from `ANTARES_EGRESS_ALLOW_PRIVATE` or the programmatic
131 : /// override. One of several `from_env` constructors in the workspace
132 : /// (the notifier's retry policy is another): each reads its own keys for
133 : /// its own type, so the shared name is the convention, not a copy.
134 8442 : pub fn from_env() -> Self {
135 : Self {
136 8442 : allow_private: Self::allow_private_from(
137 8442 : std::env::var("ANTARES_EGRESS_ALLOW_PRIVATE")
138 8442 : .ok()
139 8442 : .as_deref(),
140 0 : ) || ALLOW_PRIVATE_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed),
141 : }
142 8442 : }
143 :
144 : /// Read the switch tolerantly: a security control that only understands
145 : /// one spelling hands the operator the opposite of the intent when the
146 : /// value is `FALSE` or carries stray whitespace.
147 8466 : fn allow_private_from(v: Option<&str>) -> bool {
148 8466 : v.is_none_or(|v| {
149 28 : let v = v.trim();
150 28 : !(v.eq_ignore_ascii_case("false") || v == "0")
151 28 : })
152 8466 : }
153 :
154 : /// The IPv4 destination an IPv6 address stands for. Four spellings
155 : /// reach one and the same host: `::ffff:a.b.c.d` (IPv4-mapped),
156 : /// `::a.b.c.d` (IPv4-compatible, RFC 4291), `64:ff9b::a.b.c.d` (the
157 : /// well-known NAT64 prefix, RFC 6052, which an IPv6-only deployment
158 : /// translates straight back to IPv4) and `2002:a.b.c.d::/48` (6to4,
159 : /// RFC 3056, where the two segments after the prefix ARE the IPv4
160 : /// address a 6to4 tunnel forwards to, whatever the rest of the address
161 : /// holds). Both classifiers below unwrap through this one function, so a
162 : /// range denied in one spelling is denied in every spelling.
163 297 : fn embedded_v4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
164 297 : let s = v6.segments();
165 297 : let v4 = |hi: u16, lo: u16| std::net::Ipv4Addr::from((u32::from(hi) << 16) | u32::from(lo));
166 297 : if s[0] == 0x0064 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] {
167 18 : return Some(v4(s[6], s[7]));
168 279 : }
169 279 : if s[0] == 0x2002 {
170 40 : return Some(v4(s[1], s[2]));
171 239 : }
172 239 : v6.to_ipv4_mapped().or_else(|| v6.to_ipv4())
173 297 : }
174 :
175 : /// The cloud instance-metadata endpoints — the IPv4 link-local range
176 : /// (169.254.0.0/16, RFC 3927) shared by AWS, Azure, GCP and OCI,
177 : /// `100.100.100.200`, which sits in carrier-grade NAT rather than
178 : /// link-local, the IMDS-over-IPv6 ULA `fd00:ec2::254`, and every IPv6
179 : /// spelling of the IPv4 ones. Refused whatever `allow_private` says: no
180 : /// development box, compose stack or conformance mock lives there, so
181 : /// denying it costs nothing, while reaching it from a client-supplied
182 : /// @context URL or notification endpoint is the classic credential-theft
183 : /// SSRF.
184 1683 : pub fn ip_is_metadata(ip: std::net::IpAddr) -> bool {
185 1683 : match ip {
186 1402 : std::net::IpAddr::V4(v4) => v4.is_link_local() || v4.octets() == [100, 100, 100, 200],
187 281 : std::net::IpAddr::V6(v6) => {
188 281 : v6.segments() == [0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x254]
189 263 : || Self::embedded_v4(v6)
190 263 : .is_some_and(|v4| Self::ip_is_metadata(std::net::IpAddr::V4(v4)))
191 : }
192 : }
193 1683 : }
194 :
195 : /// The ranges the private-egress deny covers. IPv4: loopback, RFC 1918,
196 : /// link-local, `0.0.0.0/8`, carrier-grade NAT, the IETF assignment and
197 : /// benchmarking blocks, and everything reserved above `240.0.0.0`.
198 : /// IPv6: loopback, unspecified, `fc00::/7` unique-local and `fe80::/10`
199 : /// link-local, with an embedded IPv4 destination judged as its IPv4
200 : /// self. Public because every client-supplied destination is judged by
201 : /// this one classifier — the @context fetch here and the MQTT endpoint
202 : /// in `antares-notifier` — so a range added to it cannot be missing from
203 : /// one of the bindings.
204 124 : pub fn ip_is_private(ip: std::net::IpAddr) -> bool {
205 124 : match ip {
206 90 : std::net::IpAddr::V4(v4) => {
207 90 : let o = v4.octets();
208 90 : v4.is_loopback()
209 58 : || v4.is_private()
210 40 : || v4.is_link_local()
211 : // 0.0.0.0/8 "this network", which a Linux stack routes to
212 : // the local host; subsumes the unspecified address
213 40 : || o[0] == 0
214 : // 100.64.0.0/10 carrier-grade NAT (RFC 6598), where a
215 : // cloud provider's internal services live
216 24 : || (o[0] == 100 && (64..128).contains(&o[1]))
217 : // 192.0.0.0/24 IETF protocol assignments (RFC 6890)
218 18 : || (o[0] == 192 && o[1] == 0 && o[2] == 0)
219 : // 198.18.0.0/15 benchmarking (RFC 2544)
220 16 : || (o[0] == 198 && (o[1] == 18 || o[1] == 19))
221 : // 240.0.0.0/4 reserved; subsumes the broadcast address
222 14 : || o[0] >= 240
223 : }
224 34 : std::net::IpAddr::V6(v6) => {
225 : // an IPv4 destination in IPv6 spelling is the same host —
226 : // judge it as its IPv4 self, or ::ffff:127.0.0.1 and
227 : // 64:ff9b::a9fe:a9fe slip past the IPv6 checks
228 34 : if Self::embedded_v4(v6)
229 34 : .is_some_and(|v4| Self::ip_is_private(std::net::IpAddr::V4(v4)))
230 : {
231 28 : return true;
232 6 : }
233 6 : v6.is_loopback()
234 6 : || v6.is_unspecified()
235 : // fc00::/7 unique-local + fe80::/10 link-local
236 6 : || (v6.segments()[0] & 0xfe00) == 0xfc00
237 6 : || (v6.segments()[0] & 0xffc0) == 0xfe80
238 : }
239 : }
240 124 : }
241 :
242 : /// Judge one destination against the policy. The instance-metadata
243 : /// ranges are refused whatever the switch says; the private ranges are
244 : /// refused only under `allow_private: false`, which ADR-0010 makes the
245 : /// internet-facing posture rather than the default. A host given as a
246 : /// name is resolved once, and any private address in the answer denies
247 : /// the fetch.
248 5287 : pub async fn check_host(&self, host: &str, port: u16) -> Result<(), String> {
249 5287 : self.check_host_within(host, port, DNS_TIMEOUT * slow_factor() as u32)
250 5287 : .await
251 5287 : }
252 :
253 5289 : async fn check_host_within(
254 5289 : &self,
255 5289 : host: &str,
256 5289 : port: u16,
257 5289 : dns_timeout: std::time::Duration,
258 5289 : ) -> Result<(), String> {
259 : // What this function can judge: a host given as a literal address,
260 : // in any spelling. A metadata address is refused before the
261 : // private-egress switch is consulted, so a deployment that allows
262 : // private egress (the default) cannot be steered at its own instance
263 : // credentials by IP. A host given as a NAME is only resolved here
264 : // when private egress is denied; with it allowed the name passes and
265 : // the verdict on what it resolves to belongs to the transport, which
266 : // judges the addresses it is about to dial: `PolicyResolver` for
267 : // every reqwest client, `checked_addr` for MQTT. Both drop a
268 : // metadata address whatever the switch says. A binding that dials
269 : // without such a filter is not covered by this check alone.
270 5289 : if let Ok(ip) = host.trim_matches(['[', ']']).parse::<std::net::IpAddr>() {
271 1176 : if Self::ip_is_metadata(ip) {
272 90 : return Err(format!("egress to {ip} denied (instance metadata)"));
273 1086 : }
274 4113 : }
275 5199 : if self.allow_private {
276 5125 : return Ok(());
277 74 : }
278 74 : if host.eq_ignore_ascii_case("localhost") {
279 2 : return Err(format!("egress to {host} denied (private range)"));
280 72 : }
281 72 : if let Ok(ip) = host.trim_matches(['[', ']']).parse::<std::net::IpAddr>() {
282 68 : if Self::ip_is_private(ip) {
283 56 : return Err(format!("egress to {ip} denied (private range)"));
284 12 : }
285 12 : return Ok(());
286 4 : }
287 : #[cfg(not(target_arch = "wasm32"))]
288 : {
289 : // The lookup runs under its own deadline and a lookup that does
290 : // not answer is a DENIAL: a destination the policy could not
291 : // check is never allowed through, and the request path never
292 : // waits on the resolver.
293 4 : let addrs = tokio::time::timeout(dns_timeout, tokio::net::lookup_host((host, port)))
294 4 : .await
295 4 : .map_err(|_| format!("resolving {host}: timed out"))?
296 2 : .map_err(|e| format!("resolving {host}: {e}"))?;
297 2 : for a in addrs {
298 2 : if Self::ip_is_private(a.ip()) {
299 : // The denial reaches the client verbatim in the RFC 7807
300 : // `detail`; naming the resolved address would turn the
301 : // request parameter into an internal-DNS oracle.
302 2 : return Err(format!("egress to {host} denied (private range)"));
303 0 : }
304 : }
305 : }
306 : // wasm32: a page cannot resolve DNS — the browser does, and its
307 : // same-origin/CORS machinery is the egress boundary there.
308 : #[cfg(target_arch = "wasm32")]
309 : let _ = (port, dns_timeout);
310 0 : Ok(())
311 5289 : }
312 : }
313 :
314 : /// axum handlers require `Send` futures and axum state requires
315 : /// `Send + Sync`, but reqwest's wasm client and futures are neither — the
316 : /// browser build is single-threaded, so `send_wrapper` bridges the gap
317 : /// soundly (it still panics at runtime on any actual cross-thread use).
318 : /// Natively these are the identity types.
319 : #[cfg(not(target_arch = "wasm32"))]
320 : pub type HttpClient = reqwest::Client;
321 : #[cfg(target_arch = "wasm32")]
322 : #[allow(missing_docs)] // documented on the native arm above
323 : pub type HttpClient = send_wrapper::SendWrapper<reqwest::Client>;
324 :
325 : /// Wrap a reqwest client as [`HttpClient`] (identity natively).
326 11632 : pub fn wrap_client(c: reqwest::Client) -> HttpClient {
327 : #[cfg(not(target_arch = "wasm32"))]
328 : {
329 11632 : c
330 : }
331 : #[cfg(target_arch = "wasm32")]
332 : {
333 : send_wrapper::SendWrapper::new(c)
334 : }
335 11632 : }
336 :
337 : /// Run one whole HTTP interaction (build → send → read body) as a unit
338 : /// whose future is Send on every target. Native: the identity. The inputs
339 : /// must move INTO the future and only Send data may come out.
340 : #[cfg(not(target_arch = "wasm32"))]
341 5123 : pub fn http_interaction<F: std::future::Future>(fut: F) -> F {
342 5123 : fut
343 5123 : }
344 : #[cfg(target_arch = "wasm32")]
345 : #[allow(missing_docs)] // documented on the native arm above
346 : pub fn http_interaction<F: std::future::Future>(fut: F) -> send_wrapper::SendWrapper<F> {
347 : send_wrapper::SendWrapper::new(fut)
348 : }
349 :
350 : /// The recursion box for `merge_entry` — Send on every target: the only
351 : /// un-Send piece (reqwest's wasm fetch) is already fenced inside
352 : /// `http_interaction`, so the box itself can stay Send and the axum handler
353 : /// futures above it keep their required Send bound.
354 : type BoxFut<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
355 :
356 : /// Redirect cap: a fetch may not be bounced more than this many times.
357 : /// Each hop is a fresh destination the policy has to clear, so the cap is what
358 : /// keeps an open redirector from walking us into a private range.
359 : pub const MAX_REDIRECTS: usize = 3;
360 :
361 : /// DNS pinning. `check_host` resolves a name to decide whether egress is
362 : /// allowed, but reqwest would resolve it *again* at connect time — a window in
363 : /// which the answer can change (DNS rebinding). Installing the policy as the
364 : /// client's resolver closes it: the addresses the connector dials are the ones
365 : /// this filter passed, so the check and the connect see the same answer by
366 : /// construction. Redirect hops go through it too.
367 : #[cfg(not(target_arch = "wasm32"))]
368 : #[derive(Debug)]
369 : pub struct PolicyResolver(EgressPolicy);
370 :
371 : #[cfg(not(target_arch = "wasm32"))]
372 : impl reqwest::dns::Resolve for PolicyResolver {
373 55 : fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
374 55 : let allow_private = self.0.allow_private;
375 55 : Box::pin(async move {
376 55 : let host = name.as_str().to_owned();
377 55 : let addrs = tokio::net::lookup_host((host.as_str(), 0)).await?;
378 55 : let kept: Vec<std::net::SocketAddr> = addrs
379 326 : .filter(|a| !EgressPolicy::ip_is_metadata(a.ip()))
380 326 : .filter(|a| allow_private || !EgressPolicy::ip_is_private(a.ip()))
381 55 : .collect();
382 55 : if kept.is_empty() {
383 2 : return Err(format!("egress to {host} denied (private range)").into());
384 53 : }
385 53 : Ok(Box::new(kept.into_iter()) as reqwest::dns::Addrs)
386 55 : })
387 55 : }
388 : }
389 :
390 : /// One rustls crypto provider per process, installed before the first
391 : /// client is built. reqwest is compiled provider-less (`rustls-no-provider`)
392 : /// because its own `rustls` feature would pull aws-lc-rs beside ring, and a
393 : /// provider-less client PANICS at build time with none installed. ring is
394 : /// the workspace's only provider, so a second call here would be the same
395 : /// choice; the result is discarded because another crate installing the same
396 : /// provider first is success, not a conflict.
397 : ///
398 : /// `client_builder` calls it, so every client this workspace builds is
399 : /// covered. It is public because a DEPENDENCY can build a client too — the
400 : /// OTLP exporter does, from inside `opentelemetry-http` — and a binary must
401 : /// therefore install the provider before it wires anything up. The browser
402 : /// owns TLS behind `fetch`, so on wasm32 this is a no-op by design.
403 : #[cfg(not(target_arch = "wasm32"))]
404 11707 : pub fn install_crypto_provider() {
405 : static ONCE: std::sync::OnceLock<()> = std::sync::OnceLock::new();
406 11707 : ONCE.get_or_init(|| {
407 263 : let _ = rustls::crypto::ring::default_provider().install_default();
408 263 : });
409 11707 : }
410 :
411 : #[cfg(target_arch = "wasm32")]
412 : #[allow(missing_docs)] // documented on the native arm above
413 : pub fn install_crypto_provider() {}
414 :
415 : /// The one outbound-client constructor: every reqwest client in the
416 : /// broker — @context fetches, notifications, federation forwards — is built
417 : /// from this, so the policy cannot be forgotten at a call site. Timeouts stay
418 : /// the caller's choice; the security-relevant settings do not.
419 : ///
420 : /// `ANTARES_EXTRA_CA_FILE`: optional PEM bundle of ADDITIONAL trust anchors
421 : /// (private CAs, corporate proxies — and servers that ship an incomplete
422 : /// chain, as forge.etsi.org does). Verification itself is never
423 : /// disableable; this only widens what it trusts, per deployment.
424 : /// Read once per builder call — the wiring constructs clients at startup.
425 : #[cfg(not(target_arch = "wasm32"))]
426 11650 : pub fn client_builder(policy: EgressPolicy) -> reqwest::ClientBuilder {
427 : // SSRF: `PolicyResolver` only fires for HOSTNAME targets — reqwest
428 : // dials IP-LITERAL URLs directly, so a `302 Location: http://169.254.169.254/`
429 : // would skip the egress check on every hop. The custom redirect policy
430 : // re-checks each hop's URL: an IP literal in a private range is refused,
431 : // hostnames still clear through the resolver at connect. Hop count capped.
432 11650 : let allow_private = policy.allow_private;
433 11650 : let redirect = reqwest::redirect::Policy::custom(move |attempt| {
434 : // previous() includes the initial URL, so `> MAX_REDIRECTS` matches
435 : // Policy::limited(MAX_REDIRECTS): 1 initial request + MAX_REDIRECTS hops.
436 12 : if attempt.previous().len() > MAX_REDIRECTS {
437 : // same shape as Policy::limited: a redirect error, is_redirect()==true
438 2 : return attempt.error(format!("exceeded {MAX_REDIRECTS} redirects"));
439 10 : }
440 : // `Url` hands back an IPv6 host bracketed (`[::1]`), which
441 : // `IpAddr::from_str` rejects — trim as `check_host` does, or every
442 : // IPv6 literal hop skips the check.
443 10 : if let Ok(ip) = attempt
444 10 : .url()
445 10 : .host_str()
446 10 : .unwrap_or("")
447 10 : .trim_matches(['[', ']'])
448 10 : .parse::<std::net::IpAddr>()
449 : {
450 10 : if EgressPolicy::ip_is_metadata(ip)
451 10 : || (!allow_private && EgressPolicy::ip_is_private(ip))
452 : {
453 : // stop (don't follow) — the caller sees a non-2xx and fails the
454 : // fetch, but we never connected to the private target.
455 4 : return attempt.stop();
456 6 : }
457 0 : }
458 6 : attempt.follow()
459 12 : });
460 11650 : install_crypto_provider();
461 11650 : let mut b = reqwest::Client::builder()
462 11650 : .redirect(redirect)
463 11650 : .dns_resolver(std::sync::Arc::new(PolicyResolver(policy)));
464 11650 : if let Ok(path) = std::env::var("ANTARES_EXTRA_CA_FILE") {
465 0 : match std::fs::read(&path) {
466 0 : Ok(pem) => match reqwest::Certificate::from_pem_bundle(&pem) {
467 0 : Ok(certs) => {
468 0 : for c in certs {
469 0 : b = b.add_root_certificate(c);
470 0 : }
471 : }
472 : // once at startup; this crate carries no tracing dep
473 0 : Err(e) => eprintln!("ANTARES_EXTRA_CA_FILE {path}: not a PEM bundle ({e})"),
474 : },
475 0 : Err(e) => eprintln!("ANTARES_EXTRA_CA_FILE {path}: unreadable ({e})"),
476 : }
477 11650 : }
478 11650 : b
479 11650 : }
480 :
481 : /// wasm32: the browser owns TLS trust, redirects and name resolution —
482 : /// reqwest's wasm `ClientBuilder` exposes none of those knobs, and the page's
483 : /// CORS sandbox is the egress boundary. The policy still gates URLs via
484 : /// `check_host` before any fetch.
485 : #[cfg(target_arch = "wasm32")]
486 : pub fn client_builder(_policy: EgressPolicy) -> reqwest::ClientBuilder {
487 : reqwest::Client::builder()
488 : }
489 :
490 : /// Client timeouts are native knobs; the browser's fetch has no
491 : /// client-level equivalent, so on wasm32 this is a no-op by design.
492 : #[cfg(not(target_arch = "wasm32"))]
493 11632 : pub fn with_timeouts(
494 11632 : b: reqwest::ClientBuilder,
495 11632 : connect: std::time::Duration,
496 11632 : total: std::time::Duration,
497 11632 : ) -> reqwest::ClientBuilder {
498 11632 : b.connect_timeout(connect).timeout(total)
499 11632 : }
500 :
501 : #[cfg(target_arch = "wasm32")]
502 : #[allow(missing_docs)] // documented on the native arm above
503 : pub fn with_timeouts(
504 : b: reqwest::ClientBuilder,
505 : _connect: std::time::Duration,
506 : _total: std::time::Duration,
507 : ) -> reqwest::ClientBuilder {
508 : b
509 : }
510 :
511 : /// How long a context fetch waits before its one retry. A connection that
512 : /// fails at once — a resolver or a route not answering yet, the first
513 : /// outbound request of a freshly started container — tends to fail again at
514 : /// once, and recovers within a fraction of a second rather than within the
515 : /// microseconds an immediate retry leaves it. The pause is paid only after a
516 : /// connection already carried no response.
517 : const RETRY_PAUSE: std::time::Duration = std::time::Duration::from_millis(500);
518 :
519 : /// Wait `d` on whichever timer the target has.
520 : #[cfg(not(target_arch = "wasm32"))]
521 22 : async fn pause(d: std::time::Duration) {
522 22 : tokio::time::sleep(d).await;
523 22 : }
524 :
525 : #[cfg(target_arch = "wasm32")]
526 : async fn pause(d: std::time::Duration) {
527 : gloo_timers::future::TimeoutFuture::new(u32::try_from(d.as_millis()).unwrap_or(u32::MAX)).await;
528 : }
529 :
530 : /// Hard wall-clock bound for an outbound interaction. Native clients carry
531 : /// their timeouts at construction, so this passes through; on wasm32 the
532 : /// browser fetch has no client-level timeout (and reqwest's AbortController
533 : /// timer does not arm inside a dedicated worker), so an unresolved fetch
534 : /// would pend FOREVER — and a pending context fetch holds its resolve
535 : /// permit, which eventually stalls ALL context resolution (a stopped context
536 : /// server once froze a whole Robot run for over an hour this way, every later
537 : /// fetch queued behind the leaked permits). `None` = deadline exceeded.
538 : #[cfg(not(target_arch = "wasm32"))]
539 5125 : pub async fn io_deadline<T>(fut: impl std::future::Future<Output = T>, _ms: u32) -> Option<T> {
540 5125 : Some(fut.await)
541 5047 : }
542 :
543 : #[cfg(target_arch = "wasm32")]
544 : #[allow(missing_docs)] // documented on the native arm above
545 : pub async fn io_deadline<T>(fut: impl std::future::Future<Output = T>, ms: u32) -> Option<T> {
546 : use futures_util::future::{select, Either};
547 : use futures_util::pin_mut;
548 : let t = gloo_timers::future::TimeoutFuture::new(ms);
549 : pin_mut!(fut);
550 : match select(fut, t).await {
551 : Either::Left((v, _)) => Some(v),
552 : Either::Right(_) => None,
553 : }
554 : }
555 :
556 : /// Ceiling on a header-supplied cache lifetime: one year. The values are
557 : /// remote input; unclamped, `Instant::now() + duration` overflows (and
558 : /// panics) on a hostile max-age or Expires.
559 : const MAX_CONTEXT_TTL: std::time::Duration = std::time::Duration::from_secs(31_536_000);
560 :
561 : /// 6.3.16: cache lifetime of a downloaded @context comes from its response
562 : /// headers. `None` = no explicit lifetime (cache until evicted/reloaded).
563 238 : fn ttl_from_headers(
564 238 : cache_control: Option<&str>,
565 238 : expires: Option<&str>,
566 238 : ) -> Option<std::time::Duration> {
567 238 : if let Some(cc) = cache_control {
568 36 : let cc = cc.to_ascii_lowercase();
569 36 : if cc.contains("no-store") || cc.contains("no-cache") {
570 2 : return Some(std::time::Duration::ZERO);
571 34 : }
572 : // 6.3.16: "a max-age or s-maxage response directive"; the broker is a
573 : // shared cache, so s-maxage takes precedence when both are present
574 : // (RFC 7234 5.2.2.9).
575 64 : for prefix in ["s-maxage=", "max-age="] {
576 64 : if let Some(v) = cc
577 64 : .split(',')
578 72 : .filter_map(|d| d.trim().strip_prefix(prefix))
579 64 : .next()
580 : {
581 34 : if let Ok(secs) = v.trim().parse::<u64>() {
582 34 : return Some(std::time::Duration::from_secs(secs).min(MAX_CONTEXT_TTL));
583 0 : }
584 30 : }
585 : }
586 202 : }
587 202 : if let Some(exp) = expires {
588 : // HTTP-date (RFC 7231); an unparsable or past Expires means "stale".
589 6 : let when = chrono::DateTime::parse_from_rfc2822(exp).ok()?;
590 6 : let delta = when.with_timezone(&chrono::Utc) - chrono::Utc::now();
591 6 : return Some(
592 6 : delta
593 6 : .to_std()
594 6 : .unwrap_or(std::time::Duration::ZERO)
595 6 : .min(MAX_CONTEXT_TTL),
596 6 : );
597 196 : }
598 196 : None
599 238 : }
600 :
601 : /// @context responses above this size are refused.
602 : pub(crate) const MAX_CONTEXT_BYTES: usize = 5 * 1024 * 1024;
603 :
604 : /// Cap on usage-registry entries (client-supplied URLs); past it, adding a
605 : /// new URL evicts the least recently used entry.
606 : const MAX_USAGE_ENTRIES: usize = 4096;
607 :
608 : /// Fetch-count cap per @context resolution — a hostile context tree must
609 : /// not turn one request into an unbounded crawl. Checked BEFORE each
610 : /// fetch, so at most this many URLs are ever contacted. Public because
611 : /// `/q/health` publishes the caps a request runs under, and a second
612 : /// constant carrying the same number is one that can drift from the one
613 : /// actually enforced.
614 : pub const MAX_CONTEXT_URLS: usize = 32;
615 :
616 : /// The merged-context cache is keyed by the SERIALIZED user @context, which
617 : /// an `application/ld+json` body may carry inline up to the body cap — 256
618 : /// multi-megabyte keys (plus the term maps built from them) are no memory
619 : /// bound at all. Past this length the merge is simply not cached: no network
620 : /// is involved, the fetched documents stay warm, and the attacker's lever
621 : /// disappears.
622 : const MAX_MERGED_KEY_BYTES: usize = 8 * 1024;
623 :
624 : /// Byte budget of the fetched-document cache. Entry count alone is not a
625 : /// memory bound when one entry may be MAX_CONTEXT_BYTES.
626 : const MAX_FETCHED_CACHE_BYTES: u64 = 16 * 1024 * 1024;
627 :
628 : /// Entry ceiling, charged through the byte budget: every entry costs at
629 : /// least MAX_FETCHED_CACHE_BYTES/MAX_FETCHED_ENTRIES of it, so a flood of
630 : /// tiny documents cannot turn a byte budget into an unbounded map.
631 : const MAX_FETCHED_ENTRIES: u64 = 256;
632 :
633 : /// Terms the merged-context cache may hold across all its entries. A merged
634 : /// Context costs memory in proportion to its term map (the map itself, its
635 : /// compaction inverse and its prefix index), and an @context document may
636 : /// spend its 5 MiB budget on hundreds of thousands of short mappings — so an
637 : /// entry ceiling alone bounds nothing. 256 entries of an ordinary vocabulary
638 : /// (the core context defines 184 terms; a large domain one runs to tens of
639 : /// thousands) fit inside this comfortably.
640 : const MAX_MERGED_CACHE_TERMS: u64 = 2_000_000;
641 :
642 : /// Entry ceiling of the merged-context cache, charged through the term
643 : /// budget exactly as the fetched cache charges its byte budget.
644 : const MAX_MERGED_ENTRIES: u64 = 256;
645 :
646 : /// The merged-context cache, bounded by TERMS held rather than by entries.
647 : #[cfg(not(target_arch = "wasm32"))]
648 5270 : fn merged_cache() -> BoundedCache<String, Arc<Context>> {
649 5270 : let floor = (MAX_MERGED_CACHE_TERMS / MAX_MERGED_ENTRIES) as u32;
650 5270 : BoundedCache::builder()
651 5270 : .max_capacity(MAX_MERGED_CACHE_TERMS)
652 5270 : .weigher(move |_key: &String, ctx: &Arc<Context>| {
653 550 : u32::try_from(ctx.term_count())
654 550 : .unwrap_or(u32::MAX)
655 550 : .max(floor)
656 550 : })
657 5270 : .build()
658 5270 : }
659 :
660 : /// wasm32: the FIFO minicache carries no weigher, and a browser tab's
661 : /// @context set is tiny — the entry bound is the bound there.
662 : #[cfg(target_arch = "wasm32")]
663 : fn merged_cache() -> BoundedCache<String, Arc<Context>> {
664 : BoundedCache::new(MAX_MERGED_ENTRIES)
665 : }
666 :
667 : /// The fetched-document cache, bounded by BYTES: the weight of an entry is
668 : /// the size of the document it holds (floored, see above).
669 : #[cfg(not(target_arch = "wasm32"))]
670 5268 : fn fetched_cache() -> BoundedCache<String, FetchedDoc> {
671 : // what one entry costs of the budget at minimum
672 5268 : let floor = (MAX_FETCHED_CACHE_BYTES / MAX_FETCHED_ENTRIES) as u32;
673 5268 : BoundedCache::builder()
674 5268 : .max_capacity(MAX_FETCHED_CACHE_BYTES)
675 5268 : .weigher(move |_url: &String, doc: &FetchedDoc| {
676 1328 : u32::try_from(doc.value.to_string().len())
677 1328 : .unwrap_or(u32::MAX)
678 1328 : .max(floor)
679 1328 : })
680 5268 : .build()
681 5268 : }
682 :
683 : /// wasm32: the FIFO minicache carries no weigher, and a browser tab's
684 : /// @context set is tiny — the entry bound is the bound there.
685 : #[cfg(target_arch = "wasm32")]
686 : fn fetched_cache() -> BoundedCache<String, FetchedDoc> {
687 : BoundedCache::new(MAX_FETCHED_ENTRIES)
688 : }
689 :
690 : #[derive(Clone)]
691 : struct FetchedDoc {
692 : value: Arc<Value>,
693 : /// 6.3.16 expiry deadline; `None` = cache until evicted.
694 : stale_at: Option<Instant>,
695 : /// The Tenant this document belongs to, for the locally stored kinds
696 : /// (5.13.1 "Hosted": "@contexts that are explicitly added by users";
697 : /// "ImplicitlyCreated": created as a side effect of an operation). 5.5.10:
698 : /// "If a Tenant is specified for an NGSI-LD operation, the operation
699 : /// shall only be applied to information related to the specified
700 : /// Tenant" — so those mappings expand that Tenant's payloads only.
701 : /// `None` = a "Cached" copy of a document the broker downloaded from a
702 : /// public URL, which belongs to no Tenant and is shared by all of them.
703 : owner: Option<TenantId>,
704 : }
705 :
706 : impl FetchedDoc {
707 : /// Does this document resolve for `tenant`? A document owned by no Tenant
708 : /// resolves for everyone; one owned by a Tenant resolves for that Tenant
709 : /// alone, and for a resolution with no Tenant in scope it does not resolve
710 : /// at all. The store answers the same question about the row this document
711 : /// was read from (ADR-0021), so a `None` that saw every owner here would
712 : /// be a rule the two layers disagree on.
713 222 : fn serves(&self, tenant: Option<&TenantId>) -> bool {
714 222 : match (&self.owner, tenant) {
715 112 : (None, _) => true,
716 0 : (Some(_), None) => false,
717 110 : (Some(owner), Some(t)) => owner == t,
718 : }
719 222 : }
720 :
721 : /// 6.3.16 lifetime reached.
722 170 : fn is_stale(&self) -> bool {
723 170 : self.stale_at.is_some_and(|t| Instant::now() >= t)
724 170 : }
725 : }
726 :
727 : /// The @context loader: fetches, caches and merges @contexts under the
728 : /// egress policy, with the core context pinned.
729 : pub struct Loader {
730 : http: HttpClient,
731 : policy: EgressPolicy,
732 : /// URL → parsed `@context` member of the fetched document (+ 6.3.16 TTL
733 : /// and, for locally stored @contexts, the owning Tenant).
734 : /// Bounded LRU — every cache has a max size.
735 : fetched: BoundedCache<String, FetchedDoc>,
736 : /// cache key (serialized user context) → merged+frozen Context (the
737 : /// parsed-context LRU — the centerpiece, size-capped at 256).
738 : merged: BoundedCache<String, Arc<Context>>,
739 : /// Core context, pre-merged and PINNED outside the LRU (never evicted).
740 : core_only: Arc<Context>,
741 : /// URL → usage stats for every external @context referenced by requests
742 : /// (5.13 Cached-entry bookkeeping). Client-supplied URLs must never grow
743 : /// state without limit: capped at `MAX_USAGE_ENTRIES`, and admitting a
744 : /// new URL past the cap evicts the entry with the oldest lastUsage.
745 : usage: RwLock<HashMap<String, CtxUsage>>,
746 : /// merged-cache key → every URL that resolution touched (so cache hits
747 : /// still bump numberOfHits for nested references).
748 : merged_urls: BoundedCache<String, Arc<Vec<String>>>,
749 : /// Bounded concurrency on cold context FETCHES (one permit per network
750 : /// fetch, not per resolution) — a burst of exotic-context requests can't
751 : /// blow the JSON working-set budget.
752 : resolve_permits: tokio::sync::Semaphore,
753 : /// Write-through: freshly fetched remote contexts are handed to this
754 : /// hook (the broker persists them as kind='Cached' rows) so the cache
755 : /// survives a restart. Set once at wiring; None in tests.
756 : cache_writer: std::sync::RwLock<Option<CacheWriter>>,
757 : /// Shared-store hit counter: bump the persisted row on every counted
758 : /// use; a missing row reports a cross-instance delete. `None` in
759 : /// compositions without a store (bare loader tests).
760 : usage_bump: std::sync::RwLock<Option<UsageBump>>,
761 : /// Store-backed lookup for the @contexts this broker hosts. Set once at
762 : /// wiring; None in tests.
763 : local_lookup: std::sync::RwLock<Option<LocalLookup>>,
764 : }
765 :
766 : /// Request header marking a broker-internal @context fetch (this loader
767 : /// resolving a URL, possibly through the fleet's own LB). The serve endpoint
768 : /// skips its serve-hit bump for these — the resolving instance counts the
769 : /// use itself (5.13.3.5, one client use = one hit).
770 : pub const INTERNAL_FETCH_HEADER: &str = "x-antares-ctx-fetch";
771 :
772 : /// What a store-backed hook hands back. Boxed because the store behind it
773 : /// is asynchronous and the hook is held as a trait object.
774 : pub type HookFuture<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
775 :
776 : /// (the Tenant the resolution acts for, url, parsed `@context` value) —
777 : /// called on every fresh remote fetch. The row it writes is `Cached` and so
778 : /// belongs to no Tenant, but reaching the store to write it is a call the
779 : /// store answers per Tenant (ADR-0021).
780 : pub type CacheWriter = std::sync::Arc<
781 : dyn for<'a> Fn(Option<&'a TenantId>, &'a str, &'a Value) -> HookFuture<'a, ()> + Send + Sync,
782 : >;
783 : /// (Tenant, url) -> "the shared row still exists" (after bumping its hit
784 : /// counter). A Hosted row is only bumped by the Tenant that owns it, which
785 : /// is also the only Tenant whose resolutions reach it.
786 : pub type UsageBump = std::sync::Arc<
787 : dyn for<'a> Fn(Option<&'a TenantId>, &'a str) -> HookFuture<'a, bool> + Send + Sync,
788 : >;
789 : /// (Tenant, url) -> the `@context` value of the row this broker HOSTS under
790 : /// it, with the Tenant that owns it (5.13.1 Hosted/ImplicitlyCreated;
791 : /// `None` = owned by no Tenant). The store is where a broker-local @context
792 : /// comes from — see [`Loader::set_local_lookup`].
793 : pub type LocalLookup = std::sync::Arc<
794 : dyn for<'a> Fn(
795 : Option<&'a TenantId>,
796 : &'a str,
797 : ) -> HookFuture<'a, Option<(Option<TenantId>, Value)>>
798 : + Send
799 : + Sync,
800 : >;
801 :
802 : impl Default for Loader {
803 0 : fn default() -> Self {
804 0 : Self::new()
805 0 : }
806 : }
807 :
808 : impl Loader {
809 : /// A loader with the policy from the environment and default timeouts.
810 5238 : pub fn new() -> Self {
811 5238 : Self::with_policy(EgressPolicy::from_env())
812 5238 : }
813 :
814 : /// A loader with an explicit policy over a freshly built HTTP client.
815 : #[allow(clippy::expect_used)]
816 5268 : pub fn with_policy(policy: EgressPolicy) -> Self {
817 : // TLS backend initialisation is the only failure reqwest reports
818 : // here; without it no @context can ever be fetched.
819 5268 : let client = with_timeouts(
820 5268 : client_builder(policy),
821 5268 : std::time::Duration::from_secs(5 * slow_factor()),
822 5268 : std::time::Duration::from_secs(10 * slow_factor()),
823 : )
824 5268 : .build()
825 5268 : .expect("reqwest client");
826 5268 : Self::with_client(policy, client)
827 5268 : }
828 : }
829 :
830 : /// The pinned core `@context`, parsed and frozen, with no loader, client or
831 : /// cache behind it: the value every `Loader` pins, and what a test of
832 : /// expansion or matching needs on its own.
833 : #[allow(clippy::expect_used)]
834 5436 : pub fn core_context() -> Context {
835 5436 : let mut core = Context::default();
836 : // PINNED holds CORE_CONTEXT and every entry parses: pinned by
837 : // `every_pinned_context_parses_and_carries_an_at_context`
838 5436 : merge_context_value(&mut core, &pinned(CORE_CONTEXT).expect("pinned core"));
839 5436 : core.freeze();
840 5436 : core.source = Value::String(CORE_CONTEXT.to_owned());
841 5436 : core
842 5436 : }
843 :
844 : impl Loader {
845 : /// A loader over the caller's own HTTP client — a gateway with its own
846 : /// proxy, allowlist or TLS setup fetches @contexts through it. `policy`
847 : /// still clears every URL before the fetch (`check_host`), but the
848 : /// transport belongs to the caller: the DNS pin (`PolicyResolver`) and
849 : /// the per-hop redirect cap live in `client_builder`, so a client built
850 : /// any other way resolves names unfiltered and follows redirects under
851 : /// reqwest's own default. Start from `client_builder(policy)` and add
852 : /// the timeouts, proxy and trust anchors to it to keep both. Every
853 : /// cache is per instance, nothing here is process-global.
854 5268 : pub fn with_client(policy: EgressPolicy, client: reqwest::Client) -> Self {
855 5268 : let core = core_context();
856 5268 : Self {
857 5268 : http: wrap_client(client),
858 5268 : policy,
859 5268 : fetched: fetched_cache(),
860 5268 : merged: merged_cache(),
861 5268 : core_only: Arc::new(core),
862 5268 : usage: RwLock::new(HashMap::new()),
863 5268 : merged_urls: BoundedCache::new(MAX_MERGED_ENTRIES),
864 5268 : resolve_permits: tokio::sync::Semaphore::new(32),
865 5268 : cache_writer: std::sync::RwLock::new(None),
866 5268 : usage_bump: std::sync::RwLock::new(None),
867 5268 : local_lookup: std::sync::RwLock::new(None),
868 5268 : }
869 5268 : }
870 :
871 : /// Install the hook that persists a fetched @context (url, document).
872 3182 : pub fn set_cache_writer(&self, w: CacheWriter) {
873 3182 : *self
874 3182 : .cache_writer
875 3182 : .write()
876 3182 : .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(w);
877 3182 : }
878 :
879 : /// Wire the store as the source of the @contexts this broker HOSTS
880 : /// (5.13.1 Hosted and ImplicitlyCreated). The in-process copy those
881 : /// resources leave behind is a CACHE: it is lost on a restart (only
882 : /// `Cached` rows are preloaded) and evictable from the bounded document
883 : /// cache at any time. Without this hook such a miss becomes an outbound
884 : /// GET of a URL the broker minted from a request's `Host` header — a
885 : /// client can then name the host its own @context is fetched from, and
886 : /// with it the term mappings that expand its Tenant's payloads. The row
887 : /// carries the owning Tenant, so 5.5.10 still decides who resolves it.
888 3182 : pub fn set_local_lookup(&self, f: LocalLookup) {
889 3182 : *self
890 3182 : .local_lookup
891 3182 : .write()
892 3182 : .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(f);
893 3182 : }
894 :
895 : /// Wire the shared-store usage bump (5.13.3.5): called on every
896 : /// counted use of a URL. Returns whether the shared row still exists —
897 : /// `false` means another instance deleted the @context, and this
898 : /// instance must drop its warm copies so the delete is honoured here.
899 3182 : pub fn set_usage_bump(&self, f: UsageBump) {
900 3182 : *self
901 3182 : .usage_bump
902 3182 : .write()
903 3182 : .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(f);
904 3182 : }
905 :
906 : /// Boot preload: re-seed a Cached entry persisted by the writer —
907 : /// the parsed doc goes into the fetch cache, the bookkeeping identity
908 : /// (localId/createdAt) into the usage registry, so 5.13 listings look
909 : /// the same across a restart.
910 0 : pub async fn seed_cached(&self, url: &str, local_id: &str, created_at: &str, ctx_value: Value) {
911 0 : self.fetched.insert(
912 0 : url.to_owned(),
913 0 : FetchedDoc {
914 0 : value: Arc::new(ctx_value),
915 0 : stale_at: None,
916 0 : // 5.13.1 "Cached": a copy of a public document, no Tenant
917 0 : owner: None,
918 0 : },
919 : );
920 0 : self.usage.write().await.insert(
921 0 : url.to_owned(),
922 0 : CtxUsage {
923 0 : url: url.to_owned(),
924 0 : local_id: local_id.to_owned(),
925 0 : created_at: created_at.to_owned(),
926 0 : last_usage: created_at.to_owned(),
927 0 : hits: 0,
928 0 : },
929 : );
930 0 : }
931 :
932 9790 : fn now() -> String {
933 9790 : chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
934 9790 : }
935 :
936 : /// Bump usage stats (numberOfHits / lastUsage, 5.13.3.5) for one URL —
937 : /// in this instance's registry AND, via the usage_bump hook, in the
938 : /// shared store row (per-instance counters split-brain behind a
939 : /// load balancer). Returns true when the hook reported the row GONE
940 : /// (deleted through another instance): local copies are evicted so the
941 : /// next resolution refetches and re-creates the entry.
942 9790 : pub async fn bump_url(&self, tenant: Option<&TenantId>, url: &str) -> bool {
943 9790 : let now = Self::now();
944 : {
945 9790 : let mut map = self.usage.write().await;
946 9790 : if let Some(u) = map.get_mut(url) {
947 1358 : u.hits += 1;
948 1358 : u.last_usage = now;
949 1358 : } else {
950 : // hold the size bound: admitting a new URL past the cap
951 : // evicts the entry with the oldest lastUsage (RFC 3339
952 : // strings order chronologically)
953 8432 : if map.len() >= MAX_USAGE_ENTRIES {
954 8 : if let Some(oldest) = map
955 8 : .values()
956 32760 : .min_by(|a, b| a.last_usage.cmp(&b.last_usage))
957 8 : .map(|u| u.url.clone())
958 8 : {
959 8 : map.remove(&oldest);
960 8 : }
961 8424 : }
962 8432 : map.insert(
963 8432 : url.to_owned(),
964 8432 : CtxUsage {
965 8432 : url: url.to_owned(),
966 8432 : // deterministic (uuid5 of the URL): the same identity
967 8432 : // names this entry in the usage registry, the persisted
968 8432 : // Cached row (write-through) and across restarts — an
969 8432 : // API delete can therefore always find the row (5.13.5).
970 8432 : local_id: uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes())
971 8432 : .to_string(),
972 8432 : created_at: now.clone(),
973 8432 : last_usage: now,
974 8432 : hits: 1,
975 8432 : },
976 8432 : );
977 : }
978 : }
979 : // the hook is cloned out of the lock before it is awaited: a
980 : // std guard held across an await is not Send
981 9790 : let bump = self
982 9790 : .usage_bump
983 9790 : .read()
984 9790 : .unwrap_or_else(std::sync::PoisonError::into_inner)
985 9790 : .clone();
986 9790 : let row_exists = match bump {
987 1542 : Some(f) => f(tenant, url).await,
988 8248 : None => true,
989 : };
990 9790 : if row_exists {
991 9786 : return false;
992 4 : }
993 4 : self.usage.write().await.remove(url);
994 4 : self.evict(url).await;
995 4 : true
996 9790 : }
997 :
998 : /// Usage entries of every external @context referenced so far.
999 100 : pub async fn usage_list(&self) -> Vec<CtxUsage> {
1000 100 : self.usage.read().await.values().cloned().collect()
1001 100 : }
1002 :
1003 : /// Find a usage entry by original URL or by its generated localId.
1004 12 : pub async fn usage_get(&self, id: &str) -> Option<CtxUsage> {
1005 12 : let map = self.usage.read().await;
1006 12 : map.get(id)
1007 12 : .or_else(|| map.values().find(|u| u.local_id == id))
1008 12 : .cloned()
1009 12 : }
1010 :
1011 : /// Drop a URL's usage entry and evict it from the caches.
1012 204 : pub async fn usage_remove(&self, url: &str) {
1013 204 : self.usage.write().await.remove(url);
1014 204 : self.evict(url).await;
1015 204 : }
1016 :
1017 : /// 5.13.5.4 Delete and Reload: re-download a Cached @context from its
1018 : /// original URL, replacing the stored copy only on success. Any error —
1019 : /// download failure or invalid content per 5.5.4 — is
1020 : /// LdContextNotAvailable and "the operation ends without removing the
1021 : /// existing @context".
1022 22 : pub async fn refetch(&self, url: &str) -> Result<(), NgsiError> {
1023 22 : let old = self.fetched.get(url);
1024 22 : self.fetched.invalidate(url); // force a network fetch
1025 22 : match self.fetch(url, None).await {
1026 : Ok(_) => {
1027 : // merged contexts built on the old copy are stale
1028 6 : self.invalidate_merged_using(url);
1029 6 : Ok(())
1030 : }
1031 16 : Err(e) => {
1032 16 : if let Some(old) = old {
1033 16 : self.fetched.insert(url.to_owned(), old);
1034 16 : }
1035 16 : Err(match e {
1036 2 : NgsiError::BadRequestData(m) => NgsiError::LdContextNotAvailable(m),
1037 14 : other => other,
1038 : })
1039 : }
1040 : }
1041 22 : }
1042 :
1043 : /// Core-only context (no user @context supplied).
1044 27790 : pub fn core(&self) -> Arc<Context> {
1045 27790 : Arc::clone(&self.core_only)
1046 27790 : }
1047 :
1048 : /// Resolve a user-supplied `@context` value (string URL, object, or array)
1049 : /// into a merged Context with the core context merged last. No Tenant in
1050 : /// scope: locally stored @contexts of every Tenant resolve, so a
1051 : /// resolution serving a request must use `resolve_for` instead (5.5.10).
1052 84 : pub async fn resolve(&self, user: &Value) -> Result<Arc<Context>, NgsiError> {
1053 84 : self.resolve_counted(None, user, true).await
1054 84 : }
1055 :
1056 : /// Resolve WITHOUT counting usage hits — for broker-internal resolutions
1057 : /// (notification building), which are not client @context usage (053_08).
1058 6 : pub async fn resolve_quiet(&self, user: &Value) -> Result<Arc<Context>, NgsiError> {
1059 6 : self.resolve_counted(None, user, false).await
1060 6 : }
1061 :
1062 : /// 5.5.10: resolve within one Tenant — "the operation shall only be
1063 : /// applied to information related to the specified Tenant", so an
1064 : /// @context another Tenant stored locally (5.13.1) does not resolve here.
1065 5588 : pub async fn resolve_for(
1066 5588 : &self,
1067 5588 : tenant: &TenantId,
1068 5588 : user: &Value,
1069 5588 : ) -> Result<Arc<Context>, NgsiError> {
1070 5588 : self.resolve_counted(Some(tenant), user, true).await
1071 5588 : }
1072 :
1073 : /// `resolve_for` without counting usage hits.
1074 695 : pub async fn resolve_quiet_for(
1075 695 : &self,
1076 695 : tenant: &TenantId,
1077 695 : user: &Value,
1078 695 : ) -> Result<Arc<Context>, NgsiError> {
1079 695 : self.resolve_counted(Some(tenant), user, false).await
1080 695 : }
1081 :
1082 6373 : async fn resolve_counted(
1083 6373 : &self,
1084 6373 : tenant: Option<&TenantId>,
1085 6373 : user: &Value,
1086 6373 : count: bool,
1087 6373 : ) -> Result<Arc<Context>, NgsiError> {
1088 6373 : let key = user.to_string();
1089 : // urls already counted on the merged-hit path — the fallthrough
1090 : // rebuild below must not bump them a second time.
1091 6373 : let mut counted: Vec<String> = Vec::new();
1092 6373 : if let Some(hit) = self
1093 6373 : .merged
1094 6373 : .get(&key)
1095 6373 : .filter(|_| self.merged_hit_is_usable(&key, tenant))
1096 : {
1097 1703 : if count {
1098 : // cache hit: bump every URL this context resolution involves.
1099 : // A bump that finds the shared row GONE means another
1100 : // instance deleted this @context — do NOT serve the warm
1101 : // copy; fall through and rebuild (refetch re-creates it).
1102 1263 : let urls = self.merged_urls.get(&key);
1103 1263 : let mut deleted_elsewhere = false;
1104 1299 : for url in urls.iter().flat_map(|u| u.iter()) {
1105 1299 : if self.bump_url(tenant, url).await {
1106 4 : deleted_elsewhere = true;
1107 1295 : } else {
1108 1295 : counted.push(url.clone());
1109 1295 : }
1110 : }
1111 1263 : if !deleted_elsewhere {
1112 1259 : return Ok(hit);
1113 4 : }
1114 : } else {
1115 440 : return Ok(hit);
1116 : }
1117 4670 : }
1118 4674 : let mut ctx = Context::default();
1119 4674 : let urls = std::sync::Mutex::new(Vec::new());
1120 4674 : self.merge_entry(&mut ctx, user, 0, &urls, None, tenant)
1121 4674 : .await?;
1122 546 : let urls = urls.into_inner().unwrap_or_default();
1123 546 : if count {
1124 285 : for url in &urls {
1125 249 : if counted.contains(url) {
1126 0 : continue; // already bumped on the merged-hit path
1127 249 : }
1128 : // only after successful resolution. A bump that reports the
1129 : // shared row GONE (deleted through another instance) while
1130 : // the fetch above was served from this instance's warm doc
1131 : // cache means the write-through never ran — a counted use
1132 : // re-creates the entry (5.13.5.4): refetch (bump_url just
1133 : // evicted the warm copy) so the row exists, then count on it.
1134 249 : if self.bump_url(tenant, url).await && self.fetch(url, tenant).await.is_ok() {
1135 0 : let _ = self.bump_url(tenant, url).await;
1136 249 : }
1137 : }
1138 261 : }
1139 : // Core context last: its (protected) terms win — CIM 009 4.4.
1140 : // CORE_CONTEXT is a PINNED entry and every entry parses: pinned by
1141 : // `every_pinned_context_parses_and_carries_an_at_context`.
1142 : #[allow(clippy::expect_used)]
1143 546 : let core = pinned(CORE_CONTEXT).expect("pinned core");
1144 546 : merge_context_value(&mut ctx, &core);
1145 546 : ctx.freeze();
1146 546 : ctx.source = user.clone();
1147 546 : let arc = Arc::new(ctx);
1148 546 : if key.len() <= MAX_MERGED_KEY_BYTES {
1149 530 : self.merged_urls.insert(key.clone(), Arc::new(urls));
1150 530 : self.merged.insert(key, Arc::clone(&arc));
1151 530 : }
1152 546 : Ok(arc)
1153 6373 : }
1154 :
1155 : /// A merged-context cache hit is usable only while every document it was
1156 : /// built from is still fresh (6.3.16: "implementations shall periodically
1157 : /// invalidate the "Cached" @contexts according to the headers mentioned
1158 : /// above" — a merged context is only as fresh as its sources) and still
1159 : /// resolves for this Tenant (5.5.10): the cache is keyed by the user
1160 : /// @context alone, which two Tenants can send verbatim, so a merge built
1161 : /// from one Tenant's locally stored @context must not be handed to
1162 : /// another.
1163 1727 : fn merged_hit_is_usable(&self, key: &str, tenant: Option<&TenantId>) -> bool {
1164 1727 : match self.merged_urls.get(key) {
1165 : // the documents behind this entry are unknown: it can be shown
1166 : // neither fresh nor in-Tenant, so it is rebuilt
1167 0 : None => false,
1168 1749 : Some(urls) => urls.iter().all(|url| match self.fetched.get(url) {
1169 92 : Some(doc) => doc.serves(tenant) && !doc.is_stale(),
1170 : // Dropped from the document cache (it is a bounded LRU, and
1171 : // a locally stored @context is as evictable as any other):
1172 : // its OWNER is now unknown, and unknown is not public, so the
1173 : // entry is rebuilt — a rebuild re-reads the ownership and
1174 : // refuses what this Tenant may not have. A pinned core
1175 : // context has no cache entry by design and belongs to no
1176 : // Tenant, so it never forces one.
1177 1657 : None => Self::is_pinned_core(url),
1178 1749 : }),
1179 : }
1180 1727 : }
1181 :
1182 8782 : fn merge_entry<'a>(
1183 8782 : &'a self,
1184 8782 : ctx: &'a mut Context,
1185 8782 : entry: &'a Value,
1186 8782 : depth: usize,
1187 8782 : urls: &'a std::sync::Mutex<Vec<String>>,
1188 8782 : // JSON-LD 1.1 (section 3.1): a relative context IRI inside a fetched context
1189 8782 : // document resolves against THAT document's URL. The ETSI compound
1190 8782 : // context references "ngsi-ld-test-suite.jsonld" relatively — without
1191 8782 : // this every request using it dies with LdContextNotAvailable.
1192 8782 : base: Option<std::sync::Arc<String>>,
1193 8782 : tenant: Option<&'a TenantId>,
1194 8782 : ) -> BoxFut<'a, Result<(), NgsiError>> {
1195 8782 : Box::pin(async move {
1196 : // 5.5.6: an @context that "is invalid" is BadRequestData; 504
1197 : // LdContextNotAvailable is reserved for one that "is not
1198 : // available". Both caps below are reached from client-supplied
1199 : // structure alone, so a client must not be able to mint gateway
1200 : // errors on demand.
1201 8782 : if depth > 8 {
1202 2 : return Err(NgsiError::BadRequestData(
1203 2 : "@context nesting too deep".into(),
1204 2 : ));
1205 8780 : }
1206 8780 : match entry {
1207 3356 : Value::Array(items) => {
1208 3484 : for item in items {
1209 3484 : self.merge_entry(ctx, item, depth + 1, urls, base.clone(), tenant)
1210 3484 : .await?;
1211 : }
1212 24 : Ok(())
1213 : }
1214 4746 : Value::String(url) => {
1215 4746 : let resolved: String =
1216 4746 : if url.starts_with("http://") || url.starts_with("https://") {
1217 4742 : url.clone()
1218 4 : } else if let Some(b) = base.as_deref() {
1219 0 : reqwest::Url::parse(b)
1220 0 : .and_then(|b| b.join(url))
1221 0 : .map(String::from)
1222 0 : .map_err(|e| {
1223 0 : NgsiError::LdContextNotAvailable(format!(
1224 0 : "cannot resolve @context URL {url} against {b}: {e}"
1225 0 : ))
1226 0 : })?
1227 : } else {
1228 4 : url.clone()
1229 : };
1230 : // Cap enforced before the network is touched: once the
1231 : // resolution has already fetched MAX_CONTEXT_URLS
1232 : // documents, the next reference fails instead of
1233 : // extending the crawl. Poisoned lock fails closed.
1234 4746 : let fetched_so_far = urls.lock().map(|u| u.len()).unwrap_or(usize::MAX);
1235 4746 : if fetched_so_far >= MAX_CONTEXT_URLS {
1236 2 : return Err(NgsiError::BadRequestData(format!(
1237 2 : "@context resolution exceeds {MAX_CONTEXT_URLS} referenced URLs"
1238 2 : )));
1239 4744 : }
1240 4744 : let doc = self.fetch(&resolved, tenant).await?;
1241 624 : if let Ok(mut u) = urls.lock() {
1242 624 : u.push(resolved.clone());
1243 624 : }
1244 624 : self.merge_entry(
1245 624 : ctx,
1246 624 : &doc,
1247 624 : depth + 1,
1248 624 : urls,
1249 624 : Some(std::sync::Arc::new(resolved)),
1250 624 : tenant,
1251 624 : )
1252 624 : .await
1253 : }
1254 : // 5.5.7's Scoped Context prohibition binds the user
1255 : // @context. A document fetched from a pinned core URL is
1256 : // a Core @context, not client input, so it merges under
1257 : // the Core rule; everything else is the user's.
1258 678 : Value::Object(obj) => match base.as_deref() {
1259 606 : Some(b) if Self::is_pinned_core(b) => ctx.merge_core_object(obj),
1260 362 : _ => ctx.merge_object(obj),
1261 : },
1262 0 : Value::Null => Ok(()),
1263 0 : _ => Err(NgsiError::BadRequestData("invalid @context entry".into())),
1264 : }
1265 8782 : })
1266 8782 : }
1267 :
1268 : /// Is `url` one of the built-in (pinned) core context URLs?
1269 4399 : pub fn is_pinned_core(url: &str) -> bool {
1270 4399 : pinned(url).is_some()
1271 4399 : }
1272 :
1273 : /// Fetch a remote context document, returning its `@context` member.
1274 : /// 5.13.1: Cached @contexts are invalidated per the protocol's explicit
1275 : /// expiration indications — cache hits honour the 6.3.16 lifetime, stale
1276 : /// entries are re-fetched, and a changed body invalidates the
1277 : /// merged-context cache.
1278 4766 : async fn fetch(&self, url: &str, tenant: Option<&TenantId>) -> Result<Arc<Value>, NgsiError> {
1279 4766 : if let Some(v) = pinned(url) {
1280 320 : return Ok(Arc::new(v));
1281 4446 : }
1282 4446 : let err = |m: String| NgsiError::LdContextNotAvailable(m);
1283 4446 : let mut stale_value: Option<Arc<Value>> = None;
1284 4446 : if let Some(hit) = self.fetched.get(url) {
1285 118 : if !hit.serves(tenant) {
1286 : // 5.5.10 + 5.13.1: this URL names an @context another Tenant
1287 : // stored locally, so for this Tenant it does not exist — and
1288 : // fetching it would only reach the same entry back through
1289 : // the broker's own (Tenant-gated) serve endpoint.
1290 20 : return Err(err(format!("@context {url} is not available")));
1291 98 : }
1292 98 : if hit.is_stale() {
1293 2 : stale_value = Some(Arc::clone(&hit.value));
1294 2 : } else {
1295 96 : return Ok(hit.value);
1296 : }
1297 4328 : }
1298 : // A URL this broker hosts is served from its row, never fetched:
1299 : // the warm copy is only a cache (see `set_local_lookup`).
1300 4330 : let lookup = self
1301 4330 : .local_lookup
1302 4330 : .read()
1303 4330 : .unwrap_or_else(std::sync::PoisonError::into_inner)
1304 4330 : .clone();
1305 4330 : let hosted = match lookup {
1306 4238 : Some(f) => f(tenant, url).await,
1307 92 : None => None,
1308 : };
1309 4330 : if let Some((owner, value)) = hosted {
1310 12 : let doc = FetchedDoc {
1311 12 : value: Arc::new(value),
1312 12 : stale_at: None, // hosted locally: no 6.3.16 lifetime
1313 12 : owner,
1314 12 : };
1315 12 : if !doc.serves(tenant) {
1316 0 : return Err(err(format!("@context {url} is not available")));
1317 12 : }
1318 12 : let arc = Arc::clone(&doc.value);
1319 12 : self.fetched.insert(url.to_owned(), doc);
1320 12 : return Ok(arc);
1321 4318 : }
1322 4318 : if !url.starts_with("http://") && !url.starts_with("https://") {
1323 4 : return Err(err(format!("unsupported @context URL: {url}")));
1324 4314 : }
1325 : // SSRF hook: deny private destinations unless configured.
1326 4314 : let parsed = reqwest::Url::parse(url).map_err(|e| err(format!("bad URL {url}: {e}")))?;
1327 4314 : let host = parsed.host_str().unwrap_or_default().to_owned();
1328 4314 : let port = parsed.port_or_known_default().unwrap_or(443);
1329 4314 : self.policy
1330 4314 : .check_host(&host, port)
1331 4314 : .await
1332 4314 : .map_err(|e| err(format!("fetching {url}: {e}")))?;
1333 : // Bounded concurrency on cold fetching. The permit covers this ONE
1334 : // network fetch and is released before the crawl recurses into the
1335 : // document's own references — held across a whole recursive
1336 : // resolution instead, a handful of slow context trees would stall
1337 : // every cold resolution in the process.
1338 : // `acquire` fails only on a closed semaphore, which nothing closes.
1339 : // If that ever changed, proceeding without the permit costs the
1340 : // concurrency bound and not the fetch, so it is not worth a panic.
1341 4314 : let _permit = self.resolve_permits.acquire().await.ok();
1342 : // The whole HTTP interaction is one Send unit (http_interaction);
1343 : // only Send data (ttl + bytes) crosses back out.
1344 4336 : let send = || {
1345 4336 : self.http
1346 4336 : .get(url)
1347 4336 : .header("Accept", "application/ld+json, application/json")
1348 : // marks this as a broker-internal context resolution: the
1349 : // serving instance must not add a serve-hit on top of this
1350 : // instance's own bump_url (053_08 fleet double-count)
1351 4336 : .header(INTERNAL_FETCH_HEADER, "1")
1352 4336 : .send()
1353 4336 : };
1354 4314 : let interact = async {
1355 : // 5.5.6 turns an @context that "is not available" into
1356 : // LdContextNotAvailable, and one connection that carried no
1357 : // response has not established that: a refused or dropped
1358 : // connection is asked once more, after `RETRY_PAUSE`. Only the
1359 : // send is repeated — a response that did arrive is the answer
1360 : // whatever its status, and a read that failed part-way already
1361 : // cost the bytes. The client timeout and the redirect cap are
1362 : // answers in themselves and are not retried, and both attempts
1363 : // share the deadline below.
1364 4314 : let resp = match send().await {
1365 4292 : Ok(r) => r,
1366 22 : Err(e) if e.is_timeout() || e.is_redirect() => {
1367 0 : return Err(err(format!("fetching {url}: {e}")));
1368 : }
1369 : Err(_) => {
1370 22 : pause(RETRY_PAUSE).await;
1371 22 : send()
1372 22 : .await
1373 22 : .map_err(|e| err(format!("fetching {url}: {e}")))?
1374 : }
1375 : };
1376 4296 : if !resp.status().is_success() {
1377 4078 : return Err(err(format!("fetching {url}: HTTP {}", resp.status())));
1378 218 : }
1379 218 : let ttl = ttl_from_headers(
1380 218 : resp.headers()
1381 218 : .get("cache-control")
1382 218 : .and_then(|v| v.to_str().ok()),
1383 218 : resp.headers().get("expires").and_then(|v| v.to_str().ok()),
1384 : );
1385 : // Bounded response size (504 LdContextNotAvailable on breach).
1386 218 : if resp
1387 218 : .content_length()
1388 218 : .is_some_and(|l| l as usize > MAX_CONTEXT_BYTES)
1389 : {
1390 0 : return Err(err(format!("{url}: @context document too large")));
1391 218 : }
1392 : // A declared Content-Length is advisory only — a chunked body
1393 : // has none. Natively the body is accumulated chunk by chunk and
1394 : // refused the moment it would pass the cap, so an oversized
1395 : // response is never buffered in full first.
1396 : #[cfg(not(target_arch = "wasm32"))]
1397 216 : let bytes = {
1398 218 : let mut resp = resp;
1399 218 : let mut buf: Vec<u8> = Vec::new();
1400 716 : while let Some(chunk) = resp
1401 716 : .chunk()
1402 716 : .await
1403 716 : .map_err(|e| err(format!("reading {url}: {e}")))?
1404 : {
1405 500 : if buf.len() + chunk.len() > MAX_CONTEXT_BYTES {
1406 2 : return Err(err(format!("{url}: @context document too large")));
1407 498 : }
1408 498 : buf.extend_from_slice(&chunk);
1409 : }
1410 216 : buf
1411 : };
1412 : // wasm: the browser fetch hands over the body whole; the
1413 : // post-read size check below still applies.
1414 : #[cfg(target_arch = "wasm32")]
1415 : let bytes = resp
1416 : .bytes()
1417 : .await
1418 : .map_err(|e| err(format!("reading {url}: {e}")))?
1419 : .to_vec();
1420 216 : Ok((ttl, bytes))
1421 4314 : };
1422 4314 : let (ttl, bytes) = http_interaction(async {
1423 4314 : match io_deadline(interact, 10_000).await {
1424 4314 : Some(r) => r,
1425 0 : None => Err(err(format!("fetching {url}: deadline exceeded"))),
1426 : }
1427 4314 : })
1428 4314 : .await?;
1429 216 : if bytes.len() > MAX_CONTEXT_BYTES {
1430 0 : return Err(err(format!("{url}: @context document too large")));
1431 216 : }
1432 : // 5.5.6: unavailability is LdContextNotAvailable, but a RETRIEVED
1433 : // remote @context whose content is invalid is BadRequestData.
1434 216 : let doc: Value = serde_json::from_slice(&bytes)
1435 216 : .map_err(|e| NgsiError::BadRequestData(format!("{url} is not a JSON document: {e}")))?;
1436 210 : let ctx_val = doc
1437 210 : .get("@context")
1438 210 : .cloned()
1439 210 : .ok_or_else(|| NgsiError::BadRequestData(format!("{url} has no @context member")))?;
1440 202 : let arc = Arc::new(ctx_val);
1441 202 : if stale_value.is_some_and(|old| *old != *arc) {
1442 2 : // Refreshed content differs: merged contexts built on the old
1443 2 : // copy are invalid.
1444 2 : self.invalidate_merged_using(url);
1445 200 : }
1446 202 : self.fetched.insert(
1447 202 : url.to_owned(),
1448 : FetchedDoc {
1449 202 : value: Arc::clone(&arc),
1450 202 : stale_at: ttl.map(|d| Instant::now() + d),
1451 : // 5.13.1 "Cached": downloaded from a public URL, no Tenant
1452 202 : owner: None,
1453 : },
1454 : );
1455 : // Write-through: persist what was just fetched.
1456 202 : let writer = self
1457 202 : .cache_writer
1458 202 : .read()
1459 202 : .unwrap_or_else(std::sync::PoisonError::into_inner)
1460 202 : .clone();
1461 202 : if let Some(w) = writer {
1462 126 : w(tenant, url, &arc).await;
1463 76 : }
1464 202 : Ok(arc)
1465 4766 : }
1466 :
1467 : /// Insert a locally-hosted context (jsonldContexts API) so later
1468 : /// resolutions of `url` need no network round-trip. Bound to no Tenant:
1469 : /// every resolution sees it.
1470 882 : pub async fn put_local(&self, url: String, context_value: Value) {
1471 882 : self.insert_local(None, url, context_value)
1472 882 : }
1473 :
1474 : /// The same for an @context a client stored THROUGH a Tenant (5.13.1
1475 : /// "Hosted"/"ImplicitlyCreated"): per 5.5.10 those mappings apply to that
1476 : /// Tenant's operations only, so another Tenant naming the same URL
1477 : /// resolves nothing.
1478 216 : pub async fn put_local_for(&self, tenant: &TenantId, url: String, context_value: Value) {
1479 216 : self.insert_local(Some(tenant.clone()), url, context_value)
1480 216 : }
1481 :
1482 1098 : fn insert_local(&self, owner: Option<TenantId>, url: String, context_value: Value) {
1483 1098 : self.fetched.insert(
1484 1098 : url.clone(),
1485 1098 : FetchedDoc {
1486 1098 : value: Arc::new(context_value),
1487 1098 : stale_at: None, // hosted locally: no 6.3.16 lifetime
1488 1098 : owner,
1489 1098 : },
1490 : );
1491 1098 : self.invalidate_merged_using(&url);
1492 1098 : }
1493 :
1494 : /// Drop the merged contexts built from `url` — and only those. One added,
1495 : /// reloaded or deleted @context must not throw away every Tenant's
1496 : /// parsed contexts and make them all re-fetch the world; a merge whose
1497 : /// sources are unknown is dropped, since it cannot be shown unaffected.
1498 1314 : fn invalidate_merged_using(&self, url: &str) {
1499 1314 : let stale: Vec<String> = self
1500 1314 : .merged
1501 1314 : .iter()
1502 1904 : .filter(|(key, _)| match self.merged_urls.get(key.as_str()) {
1503 2560 : Some(urls) => urls.iter().any(|u| u == url),
1504 0 : None => true,
1505 1580 : })
1506 1314 : .map(|(key, _)| (*key).clone())
1507 1314 : .collect();
1508 1314 : for key in stale {
1509 40 : self.merged.invalidate(&key);
1510 40 : self.merged_urls.invalidate(&key);
1511 40 : }
1512 1314 : }
1513 :
1514 : /// Cache occupancy (entries per cache). Feeds /q/health, and is what
1515 : /// the security regression tests assert the cache size caps against.
1516 12 : pub fn cache_stats(&self) -> serde_json::Value {
1517 12 : self.fetched.run_pending_tasks();
1518 12 : self.merged.run_pending_tasks();
1519 12 : self.merged_urls.run_pending_tasks();
1520 12 : serde_json::json!({
1521 12 : "fetched": self.fetched.entry_count(),
1522 12 : "merged": self.merged.entry_count(),
1523 12 : "mergedUrls": self.merged_urls.entry_count(),
1524 : })
1525 12 : }
1526 :
1527 : /// Drop the fetched document for `url` and every merged context using it.
1528 208 : pub async fn evict(&self, url: &str) {
1529 208 : self.fetched.invalidate(url);
1530 208 : self.invalidate_merged_using(url);
1531 208 : }
1532 : }
1533 :
1534 : /// The `@context` of a compiled-in document, or `None` for a URL that is not
1535 : /// pinned. The bodies are `include_str!`-ed at build time, so a body that
1536 : /// does not parse or carries no `@context` is a build defect rather than
1537 : /// anything a request can cause —
1538 : /// `every_pinned_context_parses_and_carries_an_at_context` fails on it.
1539 15167 : fn pinned(url: &str) -> Option<Value> {
1540 105397 : let (_, body) = PINNED.iter().find(|(u, _)| *u == url)?;
1541 9851 : let doc: Value = serde_json::from_str(body).ok()?;
1542 9851 : doc.get("@context").cloned()
1543 15167 : }
1544 :
1545 : /// Merge a pre-parsed Core @context value (4.4: merged last, so it wins).
1546 5982 : fn merge_context_value(ctx: &mut Context, v: &Value) {
1547 5982 : match v {
1548 5982 : Value::Object(o) => {
1549 5982 : let _ = ctx.merge_core_object(o);
1550 5982 : }
1551 0 : Value::Array(items) => {
1552 0 : for i in items {
1553 0 : merge_context_value(ctx, i);
1554 0 : }
1555 : }
1556 0 : _ => {}
1557 : }
1558 5982 : }
1559 :
1560 : #[cfg(test)]
1561 : mod tests {
1562 : use super::*;
1563 :
1564 : /// `core_context` and the merge tail unwrap `pinned(CORE_CONTEXT)`,
1565 : /// because a broker without the Core @context cannot expand anything and
1566 : /// has no useful degraded mode. That is only safe while the compiled-in
1567 : /// table really does carry a parseable `@context` for every URL in it —
1568 : /// which is a property of the build, so it is checked here rather than
1569 : /// left to a comment. A pinned document that stops parsing fails this
1570 : /// test instead of the first request after deploy.
1571 : #[test]
1572 2 : fn every_pinned_context_parses_and_carries_an_at_context() {
1573 2 : assert!(!PINNED.is_empty(), "the pinned table is empty");
1574 14 : for (url, _) in PINNED {
1575 14 : assert!(
1576 14 : pinned(url).is_some(),
1577 : "pinned document {url} does not parse, or carries no @context"
1578 : );
1579 : }
1580 2 : let core = pinned(CORE_CONTEXT).expect("the Core @context is pinned");
1581 2 : assert!(
1582 2 : core.is_object() || core.is_array() || core.is_string(),
1583 : "the Core @context is not a usable @context value: {core}"
1584 : );
1585 2 : assert!(
1586 14 : PINNED.iter().any(|(u, _)| *u == CORE_CONTEXT),
1587 : "CORE_CONTEXT must be in the pinned table by URL, not merely resolvable"
1588 : );
1589 2 : }
1590 :
1591 : /// 4.4: "the Core @context is protected and shall remain immutable and
1592 : /// invariant during expansion or compaction of terms. […] implementations
1593 : /// shall consider the Core @context as if it were in the last position of
1594 : /// the @context array." A user context redefining a core term must not
1595 : /// win, while its own new terms still apply.
1596 : #[tokio::test]
1597 2 : async fn core_terms_are_protected_from_user_redefinition() {
1598 2 : let loader = Loader::new();
1599 2 : let user = serde_json::json!({
1600 2 : "Property": "https://evil.example/Property",
1601 2 : "observedAt": "https://evil.example/observedAt",
1602 2 : "speed": "https://example.org/speed"
1603 : });
1604 2 : let ctx = loader.resolve(&user).await.expect("resolve");
1605 2 : assert_eq!(
1606 2 : ctx.expand_key("Property"),
1607 : "https://uri.etsi.org/ngsi-ld/Property"
1608 : );
1609 2 : assert_eq!(
1610 2 : ctx.expand_key("observedAt"),
1611 : "https://uri.etsi.org/ngsi-ld/observedAt"
1612 : );
1613 2 : assert_eq!(ctx.expand_key("speed"), "https://example.org/speed");
1614 2 : }
1615 :
1616 : /// The resolver is the enforcement point, so a name that
1617 : /// resolves into a private range must fail at DNS time — that is what
1618 : /// makes a rebinding answer between check and connect harmless.
1619 : #[tokio::test]
1620 2 : async fn policy_resolver_filters_private_answers() {
1621 : use reqwest::dns::Resolve;
1622 : use std::str::FromStr;
1623 2 : let deny = PolicyResolver(EgressPolicy {
1624 2 : allow_private: false,
1625 2 : });
1626 2 : let name = reqwest::dns::Name::from_str("localhost").expect("name");
1627 2 : assert!(deny.resolve(name).await.is_err());
1628 :
1629 2 : let allow = PolicyResolver(EgressPolicy {
1630 2 : allow_private: true,
1631 2 : });
1632 2 : let name = reqwest::dns::Name::from_str("localhost").expect("name");
1633 2 : let addrs = allow.resolve(name).await.expect("allowed");
1634 2 : assert!(addrs.count() > 0);
1635 2 : }
1636 :
1637 : /// 5.5.6: "When a remote JSON-LD @context referenced by an incoming
1638 : /// request is not available … LdContextNotAvailable. If the remote
1639 : /// JSON-LD @context is invalid … BadRequestData." Unreachable → 503/504
1640 : /// class; fetched-but-invalid content (not JSON, or no @context member)
1641 : /// → BadRequestData.
1642 : #[tokio::test]
1643 2 : async fn clause_5_5_6_unavailable_vs_invalid_remote_context() {
1644 4 : let serve = |body: &'static str| async move {
1645 4 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1646 4 : .await
1647 4 : .expect("bind");
1648 4 : let addr = listener.local_addr().expect("addr");
1649 4 : tokio::spawn(async move {
1650 8 : while let Ok((mut sock, _)) = listener.accept().await {
1651 : use tokio::io::AsyncWriteExt;
1652 4 : let resp = format!(
1653 : "HTTP/1.1 200 OK\r\nContent-Type: application/ld+json\r\nContent-Length: {}\r\n\r\n{body}",
1654 4 : body.len()
1655 : );
1656 4 : let _ = sock.write_all(resp.as_bytes()).await;
1657 4 : let _ = sock.flush().await;
1658 : }
1659 0 : });
1660 4 : addr
1661 8 : };
1662 2 : let loader = Loader::with_policy(EgressPolicy {
1663 2 : allow_private: true,
1664 2 : });
1665 : // unreachable → LdContextNotAvailable
1666 2 : let err = loader
1667 2 : .resolve(&Value::String("http://127.0.0.1:9/ctx.jsonld".into()))
1668 2 : .await
1669 2 : .expect_err("unreachable context");
1670 2 : assert!(
1671 2 : matches!(err, NgsiError::LdContextNotAvailable(_)),
1672 : "unavailable → LdContextNotAvailable, got {err:?}"
1673 : );
1674 : // served but not a JSON document → BadRequestData
1675 2 : let addr = serve("this is { not json").await;
1676 2 : let err = loader
1677 2 : .resolve(&Value::String(format!("http://{addr}/ctx.jsonld")))
1678 2 : .await
1679 2 : .expect_err("non-JSON context document");
1680 2 : assert!(
1681 2 : matches!(err, NgsiError::BadRequestData(_)),
1682 : "invalid (non-JSON) → BadRequestData, got {err:?}"
1683 : );
1684 : // served JSON without an @context member → BadRequestData
1685 2 : let addr = serve(r#"{"note": "no context here"}"#).await;
1686 2 : let err = loader
1687 2 : .resolve(&Value::String(format!("http://{addr}/ctx.jsonld")))
1688 2 : .await
1689 2 : .expect_err("JSON without @context member");
1690 2 : assert!(
1691 2 : matches!(err, NgsiError::BadRequestData(_)),
1692 2 : "invalid (no @context member) → BadRequestData, got {err:?}"
1693 2 : );
1694 2 : }
1695 :
1696 : /// reqwest is compiled provider-less, and a client built while no rustls
1697 : /// crypto provider is installed does not error — it PANICS inside
1698 : /// `build()`. The one constructor installs ring first, so every client in
1699 : /// the broker is buildable; a feature change that drops the install fails
1700 : /// here instead of at the first outbound request in production.
1701 : #[test]
1702 2 : fn a_client_builds_with_no_crypto_provider_installed_beforehand() {
1703 2 : assert!(
1704 2 : client_builder(EgressPolicy {
1705 2 : allow_private: true,
1706 2 : })
1707 2 : .build()
1708 2 : .is_ok(),
1709 : "the outbound-client constructor must install a crypto provider"
1710 : );
1711 2 : }
1712 :
1713 : /// The redirect cap is only real if it is installed on the client an open
1714 : /// redirector actually talks to — so bounce one against a server that
1715 : /// always redirects to itself and assert the client gives up.
1716 : #[tokio::test]
1717 2 : async fn client_builder_caps_redirects() {
1718 2 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1719 2 : .await
1720 2 : .expect("bind");
1721 2 : let addr = listener.local_addr().expect("addr");
1722 2 : let hops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1723 2 : let seen = hops.clone();
1724 2 : tokio::spawn(async move {
1725 : loop {
1726 10 : let Ok((mut sock, _)) = listener.accept().await else {
1727 0 : return;
1728 : };
1729 8 : seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1730 8 : let resp = format!(
1731 : "HTTP/1.1 302 Found\r\nLocation: http://{addr}/loop\r\nContent-Length: 0\r\n\r\n"
1732 : );
1733 : use tokio::io::AsyncWriteExt;
1734 8 : let _ = sock.write_all(resp.as_bytes()).await;
1735 8 : let _ = sock.flush().await;
1736 : }
1737 0 : });
1738 :
1739 2 : let client = client_builder(EgressPolicy {
1740 2 : allow_private: true,
1741 2 : })
1742 2 : .timeout(std::time::Duration::from_secs(5))
1743 2 : .build()
1744 2 : .expect("client");
1745 2 : let err = client
1746 2 : .get(format!("http://{addr}/start"))
1747 2 : .send()
1748 2 : .await
1749 2 : .expect_err("redirect loop must not be followed forever");
1750 2 : assert!(err.is_redirect(), "gave up for the redirect-cap reason");
1751 2 : assert_eq!(
1752 2 : hops.load(std::sync::atomic::Ordering::SeqCst),
1753 2 : MAX_REDIRECTS + 1,
1754 2 : "one initial request plus MAX_REDIRECTS hops"
1755 2 : );
1756 2 : }
1757 :
1758 : // SSRF: a redirect to a private IP LITERAL is refused per hop even
1759 : // though reqwest's DNS PolicyResolver never sees IP literals. With
1760 : // allow_private=false the redirect target (127.0.0.1) must not be followed.
1761 : #[tokio::test]
1762 2 : async fn redirect_to_private_ip_literal_is_blocked() {
1763 : use std::sync::atomic::{AtomicUsize, Ordering};
1764 : use std::sync::Arc;
1765 2 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1766 2 : .await
1767 2 : .expect("bind");
1768 2 : let addr = listener.local_addr().expect("addr");
1769 2 : let hits = Arc::new(AtomicUsize::new(0));
1770 2 : let seen = hits.clone();
1771 2 : tokio::spawn(async move {
1772 4 : while let Ok((mut sock, _)) = listener.accept().await {
1773 2 : seen.fetch_add(1, Ordering::SeqCst);
1774 : // redirect to a private IP literal (self)
1775 2 : let resp = format!(
1776 : "HTTP/1.1 302 Found\r\nLocation: http://{addr}/internal\r\nContent-Length: 0\r\n\r\n"
1777 : );
1778 : use tokio::io::AsyncWriteExt;
1779 2 : let _ = sock.write_all(resp.as_bytes()).await;
1780 2 : let _ = sock.flush().await;
1781 : }
1782 0 : });
1783 2 : let client = client_builder(EgressPolicy {
1784 2 : allow_private: false,
1785 2 : })
1786 2 : .timeout(std::time::Duration::from_secs(5))
1787 2 : .build()
1788 2 : .expect("client");
1789 : // the initial IP-literal request connects (resolver never runs for it),
1790 : // gets the 302, and the policy STOPS instead of following to the private
1791 : // hop — so the server is hit exactly once and we get the 3xx back.
1792 2 : let resp = client
1793 2 : .get(format!("http://{addr}/start"))
1794 2 : .send()
1795 2 : .await
1796 2 : .expect("stop returns the 3xx, not an error");
1797 2 : assert_eq!(resp.status().as_u16(), 302, "redirect was not followed");
1798 2 : assert_eq!(
1799 2 : hits.load(Ordering::SeqCst),
1800 2 : 1,
1801 2 : "only the initial request; the private-IP hop was refused"
1802 2 : );
1803 2 : }
1804 :
1805 : // SSRF: the same refusal for the IPv6 spelling of a literal. `Url`
1806 : // returns an IPv6 host BRACKETED (`[::1]`), which `IpAddr::from_str`
1807 : // rejects, so a hop check that parses `host_str()` as it comes waves the
1808 : // whole IPv6 literal space through — including `[::ffff:169.254.169.254]`.
1809 : // The redirect target here is a second server on IPv6 loopback: it must
1810 : // never be reached.
1811 : #[tokio::test]
1812 2 : async fn redirect_to_private_ipv6_literal_is_blocked() {
1813 : use std::sync::atomic::{AtomicUsize, Ordering};
1814 : use std::sync::Arc;
1815 2 : let target = tokio::net::TcpListener::bind("[::1]:0")
1816 2 : .await
1817 2 : .expect("bind v6");
1818 2 : let target_addr = target.local_addr().expect("addr");
1819 2 : let reached = Arc::new(AtomicUsize::new(0));
1820 2 : let hit = reached.clone();
1821 2 : tokio::spawn(async move {
1822 2 : while let Ok((mut sock, _)) = target.accept().await {
1823 0 : hit.fetch_add(1, Ordering::SeqCst);
1824 : use tokio::io::AsyncWriteExt;
1825 0 : let _ = sock
1826 0 : .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
1827 0 : .await;
1828 : }
1829 0 : });
1830 :
1831 2 : let entry = tokio::net::TcpListener::bind("127.0.0.1:0")
1832 2 : .await
1833 2 : .expect("bind");
1834 2 : let entry_addr = entry.local_addr().expect("addr");
1835 2 : tokio::spawn(async move {
1836 4 : while let Ok((mut sock, _)) = entry.accept().await {
1837 2 : let resp = format!(
1838 : "HTTP/1.1 302 Found\r\nLocation: http://[::1]:{}/internal\r\nContent-Length: 0\r\n\r\n",
1839 2 : target_addr.port()
1840 : );
1841 : use tokio::io::AsyncWriteExt;
1842 2 : let _ = sock.write_all(resp.as_bytes()).await;
1843 2 : let _ = sock.flush().await;
1844 : }
1845 0 : });
1846 :
1847 2 : let client = client_builder(EgressPolicy {
1848 2 : allow_private: false,
1849 2 : })
1850 2 : .timeout(std::time::Duration::from_secs(5))
1851 2 : .build()
1852 2 : .expect("client");
1853 2 : let resp = client
1854 2 : .get(format!("http://{entry_addr}/start"))
1855 2 : .send()
1856 2 : .await
1857 2 : .expect("stop returns the 3xx, not an error");
1858 2 : assert_eq!(resp.status().as_u16(), 302, "redirect was not followed");
1859 2 : assert_eq!(
1860 2 : reached.load(Ordering::SeqCst),
1861 2 : 0,
1862 2 : "the IPv6-literal hop was followed into loopback"
1863 2 : );
1864 2 : }
1865 :
1866 : #[tokio::test]
1867 2 : async fn core_context_has_ngsi_terms() {
1868 2 : let l = Loader::new();
1869 2 : let c = l.core();
1870 2 : assert_eq!(
1871 2 : c.expand_key("location"),
1872 : "https://uri.etsi.org/ngsi-ld/location"
1873 : );
1874 2 : assert_eq!(
1875 2 : c.expand_key("unknownTerm"),
1876 : "https://uri.etsi.org/ngsi-ld/default-context/unknownTerm"
1877 : );
1878 2 : assert_eq!(
1879 2 : c.compact_iri("https://uri.etsi.org/ngsi-ld/location"),
1880 2 : "location"
1881 2 : );
1882 2 : }
1883 :
1884 : #[test]
1885 2 : fn cache_lifetime_from_headers() {
1886 : // 6.3.16: Cache-Control wins, no-store/no-cache = immediately stale,
1887 : // Expires as the fallback, neither = cache until evicted.
1888 2 : assert_eq!(
1889 2 : ttl_from_headers(Some("max-age=60"), None),
1890 2 : Some(std::time::Duration::from_secs(60))
1891 : );
1892 2 : assert_eq!(
1893 2 : ttl_from_headers(Some("public, max-age=5, immutable"), None),
1894 2 : Some(std::time::Duration::from_secs(5))
1895 : );
1896 2 : assert_eq!(
1897 2 : ttl_from_headers(Some("no-store"), None),
1898 : Some(std::time::Duration::ZERO)
1899 : );
1900 : // 6.3.16 names "a max-age or s-maxage response directive"; the broker
1901 : // is a shared cache, so s-maxage wins over max-age when both appear.
1902 2 : assert_eq!(
1903 2 : ttl_from_headers(Some("s-maxage=120"), None),
1904 2 : Some(std::time::Duration::from_secs(120))
1905 : );
1906 2 : assert_eq!(
1907 2 : ttl_from_headers(Some("max-age=60, s-maxage=120"), None),
1908 2 : Some(std::time::Duration::from_secs(120))
1909 : );
1910 2 : assert_eq!(ttl_from_headers(None, None), None);
1911 2 : let past = ttl_from_headers(None, Some("Tue, 01 Jan 2019 00:00:00 GMT"));
1912 2 : assert_eq!(
1913 : past,
1914 : Some(std::time::Duration::ZERO),
1915 : "past Expires = stale"
1916 : );
1917 2 : let future = ttl_from_headers(None, Some("Fri, 01 Jan 2100 00:00:00 GMT"));
1918 2 : assert!(future.expect("parsed") > std::time::Duration::from_secs(3600));
1919 : // Header values are remote input; an unclamped lifetime overflows
1920 : // Instant arithmetic when added to now(). Both paths clamp to a year.
1921 2 : let year = std::time::Duration::from_secs(31_536_000);
1922 2 : assert_eq!(
1923 2 : ttl_from_headers(Some("max-age=18446744073709551615"), None),
1924 2 : Some(year),
1925 : "huge max-age clamps to one year"
1926 : );
1927 2 : assert_eq!(
1928 2 : ttl_from_headers(None, Some("Fri, 01 Jan 2100 00:00:00 GMT")),
1929 2 : Some(year),
1930 : "far-future Expires clamps to one year"
1931 : );
1932 2 : }
1933 :
1934 : #[tokio::test]
1935 2 : async fn egress_policy_denies_private_ranges() {
1936 2 : let deny = EgressPolicy {
1937 2 : allow_private: false,
1938 2 : };
1939 22 : for host in [
1940 2 : "127.0.0.1",
1941 2 : "10.1.2.3",
1942 2 : "192.168.0.9",
1943 2 : "172.16.5.5",
1944 2 : "169.254.169.254",
1945 2 : "localhost",
1946 2 : "::1",
1947 2 : "0.0.0.0",
1948 2 : // IPv4-mapped IPv6 forms of private targets must not slip
1949 2 : // past the v6 arm — same destinations, different spelling.
1950 2 : "::ffff:127.0.0.1",
1951 2 : "::ffff:169.254.169.254",
1952 2 : "::ffff:10.1.2.3",
1953 2 : ] {
1954 22 : assert!(
1955 22 : deny.check_host(host, 80).await.is_err(),
1956 : "{host} must be denied"
1957 : );
1958 : }
1959 2 : assert!(
1960 2 : deny.check_host("93.184.216.34", 443).await.is_ok(),
1961 : "public IP allowed"
1962 : );
1963 2 : assert!(
1964 2 : deny.check_host("::ffff:8.8.8.8", 443).await.is_ok(),
1965 : "IPv4-mapped public IP allowed"
1966 : );
1967 2 : let allow = EgressPolicy {
1968 2 : allow_private: true,
1969 2 : };
1970 2 : assert!(allow.check_host("127.0.0.1", 80).await.is_ok());
1971 : // ...but the instance-metadata range is refused even then: allowing
1972 : // private egress is a development convenience, handing out cloud
1973 : // credentials is not part of it.
1974 8 : for host in [
1975 2 : "169.254.169.254",
1976 2 : "169.254.170.2",
1977 2 : "::ffff:169.254.169.254",
1978 2 : "::169.254.169.254",
1979 2 : ] {
1980 8 : let err = allow
1981 8 : .check_host(host, 80)
1982 8 : .await
1983 8 : .expect_err("{host} must be denied whatever the private-egress setting");
1984 8 : assert!(err.contains("metadata"), "{host}: {err}");
1985 2 : }
1986 2 : }
1987 :
1988 : /// 5.13.5.4 Delete and Reload: on reload the broker re-downloads BEFORE
1989 : /// removing — a failed or invalid download raises LdContextNotAvailable
1990 : /// and "the operation ends without removing the existing @context"; a
1991 : /// successful download replaces it.
1992 : #[tokio::test]
1993 2 : async fn clause_5_13_5_4_reload_keeps_existing_on_failure_replaces_on_success() {
1994 : // switchable mock: Some(body) → 200 with that body, None → 500
1995 2 : let body = Arc::new(std::sync::Mutex::new(Some(
1996 2 : r#"{"@context":{"speed":"https://a.example/speed"}}"#.to_string(),
1997 2 : )));
1998 2 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1999 2 : .await
2000 2 : .expect("bind");
2001 2 : let addr = listener.local_addr().expect("addr");
2002 2 : let served = body.clone();
2003 2 : tokio::spawn(async move {
2004 10 : while let Ok((mut sock, _)) = listener.accept().await {
2005 : use tokio::io::AsyncWriteExt;
2006 8 : let b = served.lock().expect("lock").clone();
2007 8 : let resp = match b {
2008 6 : Some(b) => format!(
2009 : "HTTP/1.1 200 OK\r\nContent-Type: application/ld+json\r\nContent-Length: {}\r\n\r\n{b}",
2010 6 : b.len()
2011 : ),
2012 2 : None => "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n"
2013 2 : .to_string(),
2014 : };
2015 8 : let _ = sock.write_all(resp.as_bytes()).await;
2016 : }
2017 0 : });
2018 2 : let url = format!("http://{addr}/ctx.jsonld");
2019 2 : let loader = Loader::with_policy(EgressPolicy {
2020 2 : allow_private: true,
2021 2 : });
2022 2 : let ctx = loader
2023 2 : .resolve(&Value::String(url.clone()))
2024 2 : .await
2025 2 : .expect("initial fetch");
2026 2 : assert_eq!(ctx.expand_key("speed"), "https://a.example/speed");
2027 :
2028 : // download fails → LdContextNotAvailable, the existing copy stays
2029 : // usable even through a fresh (uncached) resolution shape
2030 2 : *body.lock().expect("lock") = None;
2031 2 : let err = loader.refetch(&url).await.expect_err("failed reload");
2032 2 : assert!(
2033 2 : matches!(err, NgsiError::LdContextNotAvailable(_)),
2034 : "download failure → LdContextNotAvailable, got {err:?}"
2035 : );
2036 2 : let ctx = loader
2037 2 : .resolve(&serde_json::json!([url.clone()]))
2038 2 : .await
2039 2 : .expect("existing copy kept after failed reload");
2040 2 : assert_eq!(ctx.expand_key("speed"), "https://a.example/speed");
2041 :
2042 : // invalid content → LdContextNotAvailable (5.13.5.4 — not the 5.5.6
2043 : // BadRequestData used outside reload), existing copy kept
2044 2 : *body.lock().expect("lock") = Some(r#"{"note":"no @context member"}"#.to_string());
2045 2 : let err = loader.refetch(&url).await.expect_err("invalid reload");
2046 2 : assert!(
2047 2 : matches!(err, NgsiError::LdContextNotAvailable(_)),
2048 : "invalid content → LdContextNotAvailable, got {err:?}"
2049 : );
2050 2 : let ctx = loader
2051 2 : .resolve(&Value::String(url.clone()))
2052 2 : .await
2053 2 : .expect("existing copy kept after invalid reload");
2054 2 : assert_eq!(ctx.expand_key("speed"), "https://a.example/speed");
2055 :
2056 : // success → "the existing @context is replaced with the newly
2057 : // downloaded one"
2058 2 : *body.lock().expect("lock") =
2059 2 : Some(r#"{"@context":{"speed":"https://b.example/speed"}}"#.to_string());
2060 2 : loader.refetch(&url).await.expect("successful reload");
2061 2 : let ctx = loader
2062 2 : .resolve(&Value::String(url))
2063 2 : .await
2064 2 : .expect("resolve after reload");
2065 2 : assert_eq!(ctx.expand_key("speed"), "https://b.example/speed");
2066 2 : }
2067 :
2068 : /// The response-size cap must trip WHILE the body is being read, not
2069 : /// after the whole thing was buffered: a chunked response (no
2070 : /// Content-Length) that never terminates would otherwise be
2071 : /// accumulated in full before the check. The server here streams 8 MiB
2072 : /// and closes without the final 0-chunk — the resolve must fail with
2073 : /// the size error (cap hit mid-read), never with a read/decode error.
2074 : #[tokio::test]
2075 2 : async fn oversized_chunked_context_is_refused_at_the_cap() {
2076 2 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2077 2 : .await
2078 2 : .expect("bind");
2079 2 : let addr = listener.local_addr().expect("addr");
2080 2 : tokio::spawn(async move {
2081 2 : while let Ok((mut sock, _)) = listener.accept().await {
2082 : use tokio::io::{AsyncReadExt, AsyncWriteExt};
2083 : // drain the request head: closing a socket with unread data
2084 : // pending sends RST, which discards body bytes the client
2085 : // has not consumed yet and turns the test nondeterministic
2086 2 : let mut reqbuf = vec![0u8; 4096];
2087 2 : let _ = sock.read(&mut reqbuf).await;
2088 2 : if sock
2089 2 : .write_all(
2090 2 : b"HTTP/1.1 200 OK\r\nContent-Type: application/ld+json\r\nTransfer-Encoding: chunked\r\n\r\n",
2091 : )
2092 2 : .await
2093 2 : .is_err()
2094 : {
2095 0 : continue;
2096 2 : }
2097 2 : let chunk = vec![b'x'; 64 * 1024];
2098 2 : let head = format!("{:x}\r\n", chunk.len());
2099 2 : for _ in 0..128 {
2100 : // stop early once the client hangs up (cap tripped)
2101 224 : if sock.write_all(head.as_bytes()).await.is_err()
2102 224 : || sock.write_all(&chunk).await.is_err()
2103 222 : || sock.write_all(b"\r\n").await.is_err()
2104 : {
2105 0 : break;
2106 222 : }
2107 : }
2108 : // no terminating 0-chunk: the connection just closes
2109 : }
2110 0 : });
2111 2 : let loader = Loader::with_policy(EgressPolicy {
2112 2 : allow_private: true,
2113 2 : });
2114 2 : let err = loader
2115 2 : .resolve(&Value::String(format!("http://{addr}/big.jsonld")))
2116 2 : .await
2117 2 : .expect_err("oversized chunked body must be refused");
2118 2 : let msg = format!("{err:?}");
2119 2 : assert!(
2120 2 : msg.contains("too large"),
2121 2 : "cap must fire during the read, got {msg}"
2122 2 : );
2123 2 : }
2124 :
2125 : /// 5.5.6: an @context is fetched again when the first connection carried
2126 : /// no response at all. "Not available" is a statement about the document,
2127 : /// and a connection dropped before a status line has not established it —
2128 : /// the server here closes the first connection without writing a byte and
2129 : /// answers the second, so the resolve returns the document. Exactly one
2130 : /// extra attempt: a server that is really down must not be hammered.
2131 : #[tokio::test]
2132 2 : async fn a_dropped_connection_is_retried_before_the_context_is_unavailable() {
2133 : use std::sync::atomic::{AtomicUsize, Ordering};
2134 2 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2135 2 : .await
2136 2 : .expect("bind");
2137 2 : let addr = listener.local_addr().expect("addr");
2138 2 : let hits = Arc::new(AtomicUsize::new(0));
2139 2 : let seen = hits.clone();
2140 2 : tokio::spawn(async move {
2141 6 : while let Ok((mut sock, _)) = listener.accept().await {
2142 : use tokio::io::{AsyncReadExt, AsyncWriteExt};
2143 4 : if seen.fetch_add(1, Ordering::SeqCst) == 0 {
2144 : // close before any response: reqwest reports a send
2145 : // error, the shape a dropped connection takes
2146 2 : drop(sock);
2147 2 : continue;
2148 2 : }
2149 2 : let mut buf = vec![0u8; 4096];
2150 2 : let _ = sock.read(&mut buf).await;
2151 2 : let body = r#"{"@context":{"a":"https://example.org/a"}}"#;
2152 2 : let resp = format!(
2153 : "HTTP/1.1 200 OK\r\nContent-Type: application/ld+json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
2154 2 : body.len()
2155 : );
2156 2 : let _ = sock.write_all(resp.as_bytes()).await;
2157 2 : let _ = sock.flush().await;
2158 : }
2159 0 : });
2160 2 : let loader = Loader::with_policy(EgressPolicy {
2161 2 : allow_private: true,
2162 2 : });
2163 2 : loader
2164 2 : .resolve(&Value::String(format!("http://{addr}/c.jsonld")))
2165 2 : .await
2166 2 : .expect("one dropped connection is not an unavailable @context");
2167 2 : assert_eq!(
2168 2 : hits.load(Ordering::SeqCst),
2169 2 : 2,
2170 2 : "the drop is retried once and no more"
2171 2 : );
2172 2 : }
2173 :
2174 : /// A connection that fails at once tends to fail again at once: a
2175 : /// resolver or a route that is not answering yet recovers within a
2176 : /// fraction of a second, not within the microseconds an immediate retry
2177 : /// leaves it. The second attempt therefore starts no sooner than
2178 : /// `RETRY_PAUSE` after the first connection was dropped.
2179 : #[tokio::test]
2180 2 : async fn the_second_attempt_waits_before_it_connects() {
2181 2 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2182 2 : .await
2183 2 : .expect("bind");
2184 2 : let addr = listener.local_addr().expect("addr");
2185 2 : let accepted = Arc::new(std::sync::Mutex::new(Vec::<std::time::Instant>::new()));
2186 2 : let seen = accepted.clone();
2187 2 : tokio::spawn(async move {
2188 6 : while let Ok((mut sock, _)) = listener.accept().await {
2189 : use tokio::io::{AsyncReadExt, AsyncWriteExt};
2190 4 : let first = {
2191 4 : let mut at = seen.lock().expect("lock");
2192 4 : at.push(std::time::Instant::now());
2193 4 : at.len() == 1
2194 : };
2195 4 : if first {
2196 2 : drop(sock);
2197 2 : continue;
2198 2 : }
2199 2 : let mut buf = vec![0u8; 4096];
2200 2 : let _ = sock.read(&mut buf).await;
2201 2 : let body = r#"{"@context":{"a":"https://example.org/a"}}"#;
2202 2 : let resp = format!(
2203 : "HTTP/1.1 200 OK\r\nContent-Type: application/ld+json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
2204 2 : body.len()
2205 : );
2206 2 : let _ = sock.write_all(resp.as_bytes()).await;
2207 2 : let _ = sock.flush().await;
2208 : }
2209 0 : });
2210 2 : let loader = Loader::with_policy(EgressPolicy {
2211 2 : allow_private: true,
2212 2 : });
2213 2 : loader
2214 2 : .resolve(&Value::String(format!("http://{addr}/c.jsonld")))
2215 2 : .await
2216 2 : .expect("the second attempt is answered");
2217 2 : let at = accepted.lock().expect("lock").clone();
2218 2 : assert_eq!(at.len(), 2, "one retry: {at:?}");
2219 2 : assert!(
2220 2 : at[1] - at[0] >= RETRY_PAUSE,
2221 2 : "the retry connected {:?} after the drop, sooner than {RETRY_PAUSE:?}",
2222 2 : at[1] - at[0]
2223 2 : );
2224 2 : }
2225 :
2226 : /// The retry is bounded: a host that refuses every connection is reported
2227 : /// as unavailable after the second attempt, never tried a third time.
2228 : #[tokio::test]
2229 2 : async fn a_host_that_never_answers_is_unavailable_after_one_retry() {
2230 : use std::sync::atomic::{AtomicUsize, Ordering};
2231 2 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2232 2 : .await
2233 2 : .expect("bind");
2234 2 : let addr = listener.local_addr().expect("addr");
2235 2 : let hits = Arc::new(AtomicUsize::new(0));
2236 2 : let seen = hits.clone();
2237 2 : tokio::spawn(async move {
2238 6 : while let Ok((sock, _)) = listener.accept().await {
2239 4 : seen.fetch_add(1, Ordering::SeqCst);
2240 4 : drop(sock);
2241 4 : }
2242 0 : });
2243 2 : let loader = Loader::with_policy(EgressPolicy {
2244 2 : allow_private: true,
2245 2 : });
2246 2 : let e = loader
2247 2 : .resolve(&Value::String(format!("http://{addr}/c.jsonld")))
2248 2 : .await
2249 2 : .expect_err("a host that answers nothing is unavailable");
2250 2 : assert!(
2251 2 : matches!(e, NgsiError::LdContextNotAvailable(_)),
2252 : "5.5.6: an unreachable @context is LdContextNotAvailable, got {e:?}"
2253 : );
2254 2 : assert_eq!(
2255 2 : hits.load(Ordering::SeqCst),
2256 2 : 2,
2257 2 : "two attempts in total, not a loop"
2258 2 : );
2259 2 : }
2260 :
2261 : /// The per-resolution fetch cap must stop the crawl BEFORE the network
2262 : /// is hit past the limit — a hostile context listing many siblings
2263 : /// must not trigger them all and only then be rejected.
2264 : #[tokio::test]
2265 2 : async fn fetch_cap_stops_crawl_before_the_limit_is_passed() {
2266 : use std::sync::atomic::{AtomicUsize, Ordering};
2267 2 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2268 2 : .await
2269 2 : .expect("bind");
2270 2 : let addr = listener.local_addr().expect("addr");
2271 2 : let hits = Arc::new(AtomicUsize::new(0));
2272 2 : let seen = hits.clone();
2273 2 : tokio::spawn(async move {
2274 66 : while let Ok((mut sock, _)) = listener.accept().await {
2275 64 : seen.fetch_add(1, Ordering::SeqCst);
2276 : use tokio::io::{AsyncReadExt, AsyncWriteExt};
2277 64 : let mut buf = vec![0u8; 4096];
2278 64 : let n = sock.read(&mut buf).await.unwrap_or(0);
2279 64 : let req = String::from_utf8_lossy(&buf[..n]).into_owned();
2280 64 : let body = if req.starts_with("GET /root") {
2281 2 : let children: Vec<String> = (0..40)
2282 80 : .map(|i| format!("\"http://{addr}/c{i}.jsonld\""))
2283 2 : .collect();
2284 2 : format!("{{\"@context\":[{}]}}", children.join(","))
2285 : } else {
2286 62 : r#"{"@context":{"a":"https://example.org/a"}}"#.to_string()
2287 : };
2288 : // Connection: close → one request per connection, so the
2289 : // accept counter equals the number of fetches made.
2290 64 : let resp = format!(
2291 : "HTTP/1.1 200 OK\r\nContent-Type: application/ld+json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
2292 64 : body.len()
2293 : );
2294 64 : let _ = sock.write_all(resp.as_bytes()).await;
2295 64 : let _ = sock.flush().await;
2296 : }
2297 0 : });
2298 2 : let loader = Loader::with_policy(EgressPolicy {
2299 2 : allow_private: true,
2300 2 : });
2301 2 : let err = loader
2302 2 : .resolve(&Value::String(format!("http://{addr}/root.jsonld")))
2303 2 : .await
2304 2 : .expect_err("a 40-URL crawl must be rejected");
2305 2 : assert!(
2306 2 : matches!(err, NgsiError::BadRequestData(_)),
2307 : "5.5.6: a client-supplied cap breach is invalid input, got {err:?}"
2308 : );
2309 2 : let n = hits.load(Ordering::SeqCst);
2310 2 : assert!(n <= 33, "crawl must stop at the cap, made {n} fetches");
2311 2 : }
2312 :
2313 : /// The usage registry records client-supplied URLs — it must hold a
2314 : /// hard size bound, not grow by one entry per distinct URL forever.
2315 : #[tokio::test]
2316 2 : async fn usage_registry_is_bounded() {
2317 2 : let loader = Loader::with_policy(EgressPolicy {
2318 2 : allow_private: true,
2319 2 : });
2320 8200 : for i in 0..4100 {
2321 8200 : let _ = loader
2322 8200 : .bump_url(None, &format!("https://ctx.example/{i}.jsonld"))
2323 8200 : .await;
2324 : }
2325 2 : let list = loader.usage_list().await;
2326 2 : assert!(
2327 2 : list.len() <= 4096,
2328 : "usage registry must stay bounded, got {} entries",
2329 0 : list.len()
2330 : );
2331 : // eviction must sacrifice old entries, never the one just added
2332 2 : assert!(
2333 2181 : list.iter().any(|u| u.url.ends_with("/4099.jsonld")),
2334 2 : "the most recently used entry must survive eviction"
2335 2 : );
2336 2 : }
2337 :
2338 : /// Name resolution is on the request path and outside every client
2339 : /// timeout: a resolver that never answers must not hold the caller, and
2340 : /// the unanswered lookup must DENY rather than let the fetch through.
2341 : #[tokio::test]
2342 2 : async fn unanswered_dns_lookup_denies_instead_of_hanging() {
2343 2 : let deny = EgressPolicy {
2344 2 : allow_private: false,
2345 2 : };
2346 2 : let started = std::time::Instant::now();
2347 2 : let err = deny
2348 2 : .check_host_within(
2349 2 : "ctx.example.invalid",
2350 2 : 443,
2351 2 : std::time::Duration::from_millis(0),
2352 2 : )
2353 2 : .await
2354 2 : .expect_err("an unanswered lookup must be denied, never allowed");
2355 2 : assert!(err.contains("timed out"), "denial names the timeout: {err}");
2356 2 : assert!(
2357 2 : started.elapsed() < std::time::Duration::from_secs(1),
2358 2 : "the caller must not wait on the resolver"
2359 2 : );
2360 2 : }
2361 :
2362 : /// The fetch cache is bounded in BYTES, not just in entries: one entry
2363 : /// may be MAX_CONTEXT_BYTES, so an entry-only bound is no memory bound.
2364 : #[tokio::test]
2365 2 : async fn fetch_cache_holds_a_byte_budget() {
2366 2 : let loader = Loader::with_policy(EgressPolicy {
2367 2 : allow_private: true,
2368 2 : });
2369 2 : let mib = 1024 * 1024;
2370 2 : let doc = Value::String("x".repeat(mib));
2371 80 : for i in 0..40 {
2372 80 : loader
2373 80 : .put_local(format!("https://ctx.example/{i}.jsonld"), doc.clone())
2374 80 : .await;
2375 : }
2376 2 : let entries = loader.cache_stats()["fetched"].as_u64().expect("count");
2377 2 : assert!(
2378 2 : entries <= MAX_FETCHED_CACHE_BYTES / mib as u64,
2379 2 : "byte budget breached: {entries} entries of 1 MiB"
2380 2 : );
2381 2 : }
2382 :
2383 : /// 6.3.16: "implementations shall periodically invalidate the "Cached"
2384 : /// @contexts according to the headers mentioned above." A repeat
2385 : /// resolution of the same @context value is served from the merged cache,
2386 : /// so the lifetime has to be enforced THERE too or a max-age=0 document is
2387 : /// frozen for the process lifetime.
2388 : #[tokio::test]
2389 2 : async fn merged_cache_honours_the_context_lifetime() {
2390 2 : let body = Arc::new(std::sync::Mutex::new(
2391 2 : r#"{"@context":{"speed":"https://a.example/speed"}}"#.to_string(),
2392 : ));
2393 2 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2394 2 : .await
2395 2 : .expect("bind");
2396 2 : let addr = listener.local_addr().expect("addr");
2397 2 : let served = body.clone();
2398 2 : tokio::spawn(async move {
2399 6 : while let Ok((mut sock, _)) = listener.accept().await {
2400 : use tokio::io::{AsyncReadExt, AsyncWriteExt};
2401 4 : let mut buf = vec![0u8; 4096];
2402 4 : let _ = sock.read(&mut buf).await;
2403 4 : let b = served.lock().map(|b| b.clone()).unwrap_or_default();
2404 4 : let resp = format!(
2405 : "HTTP/1.1 200 OK\r\nContent-Type: application/ld+json\r\nCache-Control: max-age=0\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{b}",
2406 4 : b.len()
2407 : );
2408 4 : let _ = sock.write_all(resp.as_bytes()).await;
2409 4 : let _ = sock.flush().await;
2410 : }
2411 0 : });
2412 2 : let loader = Loader::with_policy(EgressPolicy {
2413 2 : allow_private: true,
2414 2 : });
2415 2 : let url = Value::String(format!("http://{addr}/ctx.jsonld"));
2416 2 : let ctx = loader.resolve(&url).await.expect("first resolve");
2417 2 : assert_eq!(ctx.expand_key("speed"), "https://a.example/speed");
2418 :
2419 2 : *body.lock().expect("lock") =
2420 2 : r#"{"@context":{"speed":"https://b.example/speed"}}"#.to_string();
2421 2 : let ctx = loader.resolve(&url).await.expect("second resolve");
2422 2 : assert_eq!(
2423 2 : ctx.expand_key("speed"),
2424 2 : "https://b.example/speed",
2425 2 : "an expired @context must be re-resolved, not served from the merged cache"
2426 2 : );
2427 2 : }
2428 :
2429 : /// The merged cache is keyed by the SERIALIZED user @context, which an
2430 : /// `application/ld+json` body may carry inline up to the body cap — an
2431 : /// entry-only bound is no memory bound when one key is megabytes.
2432 : #[tokio::test]
2433 2 : async fn merged_cache_refuses_oversized_keys() {
2434 2 : let loader = Loader::with_policy(EgressPolicy {
2435 2 : allow_private: true,
2436 2 : });
2437 2 : let mut big = serde_json::Map::new();
2438 4000 : for i in 0..2000 {
2439 4000 : big.insert(
2440 4000 : format!("term{i:06}"),
2441 4000 : Value::String(format!("https://ex.example/{i:06}")),
2442 4000 : );
2443 4000 : }
2444 2 : let user = Value::Object(big);
2445 2 : assert!(user.to_string().len() > MAX_MERGED_KEY_BYTES);
2446 2 : for _ in 0..8 {
2447 16 : loader.resolve(&user).await.expect("resolve");
2448 : }
2449 2 : let stats = loader.cache_stats();
2450 2 : assert_eq!(
2451 2 : stats["merged"].as_u64().expect("count"),
2452 : 0,
2453 : "an oversized inline @context must not be cached: {stats}"
2454 : );
2455 : // negative: a small inline @context still is.
2456 2 : loader
2457 2 : .resolve(&serde_json::json!({"a": "https://ex.example/a"}))
2458 2 : .await
2459 2 : .expect("resolve");
2460 2 : assert_eq!(loader.cache_stats()["merged"].as_u64().expect("count"), 1);
2461 2 : }
2462 :
2463 : /// The doc comment on `ip_is_metadata` promises the metadata range is
2464 : /// "Refused whatever `allow_private` says" — that has to include the
2465 : /// native IPv6 spelling of the AWS IMDS endpoint, which is a ULA and would
2466 : /// otherwise be waved through by the default private-egress setting.
2467 : #[tokio::test]
2468 2 : async fn metadata_endpoint_is_denied_in_every_spelling() {
2469 2 : let allow = EgressPolicy {
2470 2 : allow_private: true,
2471 2 : };
2472 10 : for host in [
2473 2 : "169.254.169.254",
2474 2 : "::ffff:169.254.169.254",
2475 2 : "::169.254.169.254",
2476 2 : "fd00:ec2::254",
2477 2 : "[fd00:ec2::254]",
2478 2 : ] {
2479 10 : let err = allow
2480 10 : .check_host(host, 80)
2481 10 : .await
2482 10 : .expect_err("the metadata endpoint must be denied");
2483 10 : assert!(err.contains("metadata"), "{host}: {err}");
2484 2 : }
2485 2 : }
2486 :
2487 : /// The metadata deny is unconditional, so it has to cover every
2488 : /// provider's endpoint and not only the `169.254.169.254` the big four
2489 : /// share. `100.100.100.200` sits in carrier-grade NAT, which the default
2490 : /// `allow_private: true` posture does not deny, and an IPv6-only
2491 : /// deployment reaches the link-local endpoint through the NAT64 prefix.
2492 : #[tokio::test]
2493 2 : async fn metadata_deny_covers_the_cgnat_and_nat64_spellings() {
2494 2 : let allow = EgressPolicy {
2495 2 : allow_private: true,
2496 2 : };
2497 6 : for host in [
2498 2 : "100.100.100.200",
2499 2 : "[::ffff:100.100.100.200]",
2500 2 : // 64:ff9b::169.254.169.254
2501 2 : "[64:ff9b::a9fe:a9fe]",
2502 2 : ] {
2503 6 : let err = allow
2504 6 : .check_host(host, 80)
2505 6 : .await
2506 6 : .expect_err("the metadata endpoint must be denied");
2507 6 : assert!(err.contains("metadata"), "{host}: {err}");
2508 2 : }
2509 2 : }
2510 :
2511 : /// The merged-context cache is charged by the TERMS an entry holds, not
2512 : /// by counting entries: an @context document may spend its whole byte
2513 : /// budget on short mappings, and a merged Context costs memory in
2514 : /// proportion to its term map, its compaction inverse and its prefix
2515 : /// index. An entry ceiling alone would let 256 such documents pin
2516 : /// hundreds of megabytes.
2517 : #[cfg(not(target_arch = "wasm32"))]
2518 : #[test]
2519 2 : fn the_merged_cache_is_bounded_by_terms_not_by_entries() {
2520 20 : let big = |n: usize| {
2521 20 : let mut m = serde_json::Map::new();
2522 562466 : for i in 0..n {
2523 562466 : m.insert(
2524 562466 : format!("t{i:06}"),
2525 562466 : Value::String(format!("http://a.example/{i}")),
2526 562466 : );
2527 562466 : }
2528 20 : let mut c = Context::default();
2529 20 : c.merge_object(&m).expect("merge");
2530 20 : c.freeze();
2531 20 : Arc::new(c)
2532 20 : };
2533 2 : let cache = merged_cache();
2534 : // one entry far under the floor still costs the floor, so a flood of
2535 : // tiny contexts cannot turn the term budget into an unbounded map
2536 2 : cache.insert("small".into(), big(1));
2537 2 : cache.run_pending_tasks();
2538 2 : let floor = MAX_MERGED_CACHE_TERMS / MAX_MERGED_ENTRIES;
2539 2 : assert_eq!(
2540 2 : cache.weighted_size(),
2541 : floor,
2542 : "a small entry costs the floor"
2543 : );
2544 : // and a large one costs what it holds
2545 2 : let n = (floor as usize) * 4;
2546 2 : cache.insert("large".into(), big(n));
2547 2 : cache.run_pending_tasks();
2548 2 : assert_eq!(
2549 2 : cache.weighted_size(),
2550 2 : floor + n as u64,
2551 : "a large entry is charged its term count"
2552 : );
2553 : // past the budget the cache evicts rather than growing
2554 16 : for i in 0..8 {
2555 16 : cache.insert(format!("f{i}"), big(n));
2556 16 : }
2557 2 : cache.run_pending_tasks();
2558 2 : assert!(
2559 2 : cache.weighted_size() <= MAX_MERGED_CACHE_TERMS,
2560 : "weighted size {} passed the budget",
2561 0 : cache.weighted_size()
2562 : );
2563 2 : }
2564 :
2565 : /// 6to4 (IETF RFC 3056) is the fourth spelling that carries an IPv4
2566 : /// destination inside an IPv6 address: `2002:V4ADDR::/48`, where the two
2567 : /// segments after the prefix ARE the target IPv4 address. A host with a
2568 : /// 6to4 tunnel routes `[2002:a9fe:a9fe::]` to 169.254.169.254, so the
2569 : /// unconditional metadata deny has to unwrap it like the other three.
2570 : #[tokio::test]
2571 2 : async fn metadata_and_private_denies_cover_the_6to4_spelling() {
2572 2 : let allow = EgressPolicy {
2573 2 : allow_private: true,
2574 2 : };
2575 6 : for host in [
2576 2 : // 2002:169.254.169.254::
2577 2 : "[2002:a9fe:a9fe::]",
2578 2 : "[2002:a9fe:a9fe:1:2:3:4:5]",
2579 2 : // 2002:100.100.100.200::
2580 2 : "[2002:6464:64c8::]",
2581 2 : ] {
2582 6 : let err = allow
2583 6 : .check_host(host, 80)
2584 6 : .await
2585 6 : .expect_err("the metadata endpoint must be denied");
2586 6 : assert!(err.contains("metadata"), "{host}: {err}");
2587 : }
2588 2 : let deny = EgressPolicy {
2589 2 : allow_private: false,
2590 2 : };
2591 6 : for host in [
2592 2 : // 2002:127.0.0.1::
2593 2 : "[2002:7f00:1::]",
2594 2 : // 2002:10.1.1.1::
2595 2 : "[2002:a01:101::]",
2596 2 : // 2002:192.168.0.1::
2597 2 : "[2002:c0a8:1::]",
2598 2 : ] {
2599 6 : let err = deny
2600 6 : .check_host(host, 80)
2601 6 : .await
2602 6 : .expect_err("a 6to4 address standing for a private IPv4 must be denied");
2603 6 : assert!(err.contains("denied"), "{host}: {err}");
2604 2 : }
2605 2 : // the prefix alone denies nothing: 2002:5db8:d822:: stands for the
2606 2 : // public 93.184.216.34 and stays reachable
2607 2 : deny.check_host("[2002:5db8:d822::]", 80)
2608 2 : .await
2609 2 : .expect("a 6to4 address for a public IPv4 is public");
2610 2 : }
2611 :
2612 : /// ADR-0010 makes `allow_private: false` the internet-facing posture, so
2613 : /// the classifier has to cover the ranges an internal service actually
2614 : /// sits on: carrier-grade NAT (RFC 6598), `0.0.0.0/8`, which a Linux
2615 : /// stack routes to the local host, the IETF assignment and benchmarking
2616 : /// blocks, the reserved space above `240.0.0.0`, and the NAT64 prefix
2617 : /// (RFC 6052), which translates straight back to an IPv4 target.
2618 : #[tokio::test]
2619 2 : async fn private_deny_covers_the_ranges_internal_services_sit_on() {
2620 2 : let deny = EgressPolicy {
2621 2 : allow_private: false,
2622 2 : };
2623 16 : for host in [
2624 2 : "100.64.0.1",
2625 2 : "100.127.255.254",
2626 2 : "0.1.2.3",
2627 2 : "192.0.0.8",
2628 2 : "198.18.0.1",
2629 2 : "240.0.0.1",
2630 2 : "[::ffff:100.64.0.1]",
2631 2 : // 64:ff9b::10.1.1.1
2632 2 : "[64:ff9b::a01:101]",
2633 2 : ] {
2634 16 : let err = deny
2635 16 : .check_host(host, 80)
2636 16 : .await
2637 16 : .expect_err("an internal-range destination must be denied");
2638 16 : assert!(err.contains("denied"), "{host}: {err}");
2639 2 : }
2640 2 : // the widening stops at the public internet: 100.128.0.0 is the first
2641 2 : // address above carrier-grade NAT and stays reachable.
2642 6 : for host in ["93.184.216.34", "100.128.0.1", "[2606:2800:220::1]"] {
2643 6 : deny.check_host(host, 80)
2644 6 : .await
2645 6 : .unwrap_or_else(|e| panic!("{host} is public and must pass: {e}"));
2646 2 : }
2647 2 : }
2648 :
2649 : /// The denial text is returned to the client verbatim in the RFC 7807
2650 : /// `detail`, so naming the address a hostname resolved to turns the
2651 : /// request parameter into an internal-DNS oracle.
2652 : #[tokio::test]
2653 2 : async fn private_range_denial_does_not_name_the_resolved_address() {
2654 2 : let deny = EgressPolicy {
2655 2 : allow_private: false,
2656 2 : };
2657 : // A NAME that resolves privately takes the resolver path — the one
2658 : // that used to embed the answer. Where the name does not resolve the
2659 : // message is the lookup error, which leaks nothing.
2660 2 : let err = deny
2661 2 : .check_host("ip6-localhost", 80)
2662 2 : .await
2663 2 : .expect_err("a name resolving into a private range is denied");
2664 2 : assert!(
2665 2 : !err.contains("::1") && !err.contains("127.0.0.1"),
2666 : "leaked the resolved address: {err}"
2667 : );
2668 4 : for host in ["10.1.2.3", "127.0.0.1"] {
2669 2 : // an IP LITERAL is the client's own input — echoing it back leaks
2670 2 : // nothing it did not already know.
2671 4 : let err = deny.check_host(host, 80).await.expect_err("denied");
2672 4 : assert!(err.contains("private range"), "{host}: {err}");
2673 2 : }
2674 2 : }
2675 :
2676 : /// 5.5.6 assigns 504 LdContextNotAvailable to a remote @context that "is
2677 : /// not available" and BadRequestData to one that "is invalid". Nested
2678 : /// arrays and an over-long reference tree are entirely client-supplied and
2679 : /// touch no network, so they are the invalid case — a client must not be
2680 : /// able to mint gateway errors on demand.
2681 : #[tokio::test]
2682 2 : async fn client_side_context_caps_are_bad_request_not_gateway_errors() {
2683 2 : let loader = Loader::with_policy(EgressPolicy {
2684 2 : allow_private: true,
2685 2 : });
2686 2 : let mut nested = serde_json::json!(["x"]);
2687 24 : for _ in 0..12 {
2688 24 : nested = Value::Array(vec![nested]);
2689 24 : }
2690 2 : let err = loader
2691 2 : .resolve(&nested)
2692 2 : .await
2693 2 : .expect_err("a too-deep @context must be rejected");
2694 2 : assert!(
2695 2 : matches!(err, NgsiError::BadRequestData(_)),
2696 2 : "an over-nested @context is invalid input, got {err:?}"
2697 2 : );
2698 2 : }
2699 :
2700 : /// A security switch that only understands one spelling silently gives
2701 : /// the operator the opposite of the intent.
2702 : #[test]
2703 2 : fn egress_switch_ignores_case_and_whitespace() {
2704 12 : for v in ["false", "FALSE", "False", " false ", "0", " 0\t"] {
2705 12 : assert!(
2706 12 : !EgressPolicy::allow_private_from(Some(v)),
2707 : "{v:?} must turn the private-egress deny ON"
2708 : );
2709 : }
2710 8 : for v in ["true", "1", "", "yes"] {
2711 8 : assert!(
2712 8 : EgressPolicy::allow_private_from(Some(v)),
2713 : "{v:?} must leave private egress allowed"
2714 : );
2715 : }
2716 2 : assert!(
2717 2 : EgressPolicy::allow_private_from(None),
2718 : "unset means allowed"
2719 : );
2720 2 : }
2721 :
2722 : /// The published posture is part of the switch. SECURITY.md told a reader
2723 : /// private ranges were denied by default while this function allows them,
2724 : /// which reads as an SSRF guard that is on when it is off — and an
2725 : /// operator auditing the broker from that page would never set the
2726 : /// variable. The default lives in one place, here, so the two documents
2727 : /// that state it are checked against it here too.
2728 : #[test]
2729 2 : fn the_documented_egress_default_is_the_compiled_one() {
2730 2 : let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../..");
2731 2 : let book = std::fs::read_to_string(format!("{root}/docs/src/configuration.md"))
2732 2 : .expect("the configuration chapter");
2733 2 : let row = book
2734 2 : .lines()
2735 96 : .find(|l| l.starts_with("| `ANTARES_EGRESS_ALLOW_PRIVATE` |"))
2736 2 : .expect("the variable has no row in the configuration table");
2737 2 : let stated = row
2738 2 : .split('|')
2739 2 : .nth(2)
2740 2 : .expect("the default column")
2741 2 : .trim()
2742 2 : .trim_matches('`');
2743 2 : assert_eq!(
2744 2 : stated.parse::<bool>().ok(),
2745 2 : Some(EgressPolicy::allow_private_from(None)),
2746 : "the chapter states {stated:?} as the default"
2747 : );
2748 :
2749 2 : let policy =
2750 2 : std::fs::read_to_string(format!("{root}/SECURITY.md")).expect("the security policy");
2751 2 : assert!(
2752 2 : !policy.contains("private-range deny by default"),
2753 : "SECURITY.md claims a deny-by-default this switch does not implement"
2754 : );
2755 2 : assert!(
2756 2 : policy.contains("ANTARES_EGRESS_ALLOW_PRIVATE=false"),
2757 : "SECURITY.md must name the switch that turns the deny on"
2758 : );
2759 2 : }
2760 :
2761 : /// A locally hosted @context is stored "for the Tenant" that added it
2762 : /// (5.13.1, 5.13.2.4) and 5.5.10 makes the Tenant the boundary an
2763 : /// operation applies within: another Tenant naming the same URL must not
2764 : /// have its payload expanded by those mappings. The URL is on a dead
2765 : /// port, so a resolution that succeeds can only have come from the local
2766 : /// entry — and for a foreign Tenant the @context is simply not available
2767 : /// (5.5.6).
2768 : #[tokio::test]
2769 2 : async fn clause_5_13_1_hosted_context_is_private_to_its_tenant() {
2770 2 : let loader = Loader::with_policy(EgressPolicy {
2771 2 : allow_private: true,
2772 2 : });
2773 2 : let alpha = TenantId::new("alpha").expect("tenant");
2774 2 : let beta = TenantId::new("beta").expect("tenant");
2775 2 : let url = Value::String(
2776 2 : "http://127.0.0.1:9/ngsi-ld/v1/jsonldContexts/2f2e1a00-0000-4000-8000-000000000001"
2777 2 : .to_owned(),
2778 2 : );
2779 2 : loader
2780 2 : .put_local_for(
2781 2 : &alpha,
2782 2 : url.as_str().expect("url").to_owned(),
2783 2 : serde_json::json!({"secret": "https://alpha.example/secret"}),
2784 2 : )
2785 2 : .await;
2786 :
2787 2 : let ctx = loader
2788 2 : .resolve_for(&alpha, &url)
2789 2 : .await
2790 2 : .expect("the owning Tenant resolves its own @context");
2791 2 : assert_eq!(ctx.expand_key("secret"), "https://alpha.example/secret");
2792 :
2793 2 : let err = loader
2794 2 : .resolve_for(&beta, &url)
2795 2 : .await
2796 2 : .expect_err("another Tenant must not resolve a @context it does not own");
2797 2 : assert!(
2798 2 : matches!(err, NgsiError::LdContextNotAvailable(_)),
2799 : "a foreign Hosted @context is not available, got {err:?}"
2800 : );
2801 : // and nothing of the owner's mappings reaches the other Tenant by way
2802 : // of the merged-context cache either
2803 2 : let ctx = loader
2804 2 : .resolve_for(
2805 2 : &beta,
2806 2 : &serde_json::json!({"other": "https://beta.example/other"}),
2807 2 : )
2808 2 : .await
2809 2 : .expect("resolve");
2810 2 : assert_eq!(
2811 2 : ctx.expand_key("secret"),
2812 2 : "https://uri.etsi.org/ngsi-ld/default-context/secret",
2813 2 : "the owner's term mapping must not expand another Tenant's payload"
2814 2 : );
2815 2 : }
2816 :
2817 : /// The merged cache is keyed by the user @context alone, so the Tenant
2818 : /// gate on a hit is the ONLY thing keeping one Tenant's locally stored
2819 : /// mappings out of another Tenant's resolution — and it reads the
2820 : /// ownership off the document cache, a bounded LRU that evicts under
2821 : /// load. A source document that is no longer there has unknown
2822 : /// ownership, which is not the same as public: the hit must be rebuilt,
2823 : /// not handed over.
2824 : #[tokio::test]
2825 2 : async fn an_evicted_source_document_does_not_open_a_merged_hit_to_another_tenant() {
2826 2 : let loader = Loader::with_policy(EgressPolicy {
2827 2 : allow_private: true,
2828 2 : });
2829 2 : let alpha = TenantId::new("alpha").expect("tenant");
2830 2 : let beta = TenantId::new("beta").expect("tenant");
2831 : // dead port: nothing can be re-fetched, so anything served can only
2832 : // have come from the caches under test
2833 2 : let url =
2834 2 : "http://127.0.0.1:9/ngsi-ld/v1/jsonldContexts/3f3e1a00-0000-4000-8000-000000000003";
2835 2 : let user = Value::String(url.to_owned());
2836 2 : loader
2837 2 : .put_local_for(
2838 2 : &alpha,
2839 2 : url.to_owned(),
2840 2 : serde_json::json!({"secret": "https://alpha.example/secret"}),
2841 2 : )
2842 2 : .await;
2843 2 : let ctx = loader
2844 2 : .resolve_for(&alpha, &user)
2845 2 : .await
2846 2 : .expect("the owning Tenant resolves its own @context");
2847 2 : assert_eq!(ctx.expand_key("secret"), "https://alpha.example/secret");
2848 :
2849 : // the document leaves the bounded cache; the merged entry it fed
2850 : // stays, as an LRU eviction leaves it
2851 2 : loader.fetched.invalidate(url);
2852 :
2853 2 : match loader.resolve_for(&beta, &user).await {
2854 2 : Err(_) => {}
2855 2 : Ok(ctx) => assert_ne!(
2856 2 : ctx.expand_key("secret"),
2857 2 : "https://alpha.example/secret",
2858 2 : "another Tenant's mappings were served from the merged cache"
2859 2 : ),
2860 2 : }
2861 2 : }
2862 :
2863 : /// Adding one @context must not throw away every Tenant's merged
2864 : /// contexts: the merged entries built FROM the written document are
2865 : /// dropped (a rewritten @context is never served stale) and the rest —
2866 : /// another Tenant's warm context included — stay.
2867 : #[tokio::test]
2868 2 : async fn hosted_context_write_keeps_unrelated_merged_contexts() {
2869 2 : let loader = Loader::with_policy(EgressPolicy {
2870 2 : allow_private: true,
2871 2 : });
2872 2 : let alpha = TenantId::new("alpha").expect("tenant");
2873 2 : let beta = TenantId::new("beta").expect("tenant");
2874 2 : let base = "http://127.0.0.1:9/ngsi-ld/v1/jsonldContexts";
2875 2 : let a_url = format!("{base}/aaaa1111-0000-4000-8000-000000000001");
2876 2 : let b_url = format!("{base}/bbbb2222-0000-4000-8000-000000000002");
2877 2 : loader
2878 2 : .put_local_for(
2879 2 : &alpha,
2880 2 : a_url.clone(),
2881 2 : serde_json::json!({"speed": "https://a.example/v1"}),
2882 2 : )
2883 2 : .await;
2884 2 : loader
2885 2 : .put_local_for(
2886 2 : &beta,
2887 2 : b_url.clone(),
2888 2 : serde_json::json!({"level": "https://b.example/level"}),
2889 2 : )
2890 2 : .await;
2891 2 : loader
2892 2 : .resolve_for(&alpha, &Value::String(a_url.clone()))
2893 2 : .await
2894 2 : .expect("alpha resolves its own @context");
2895 2 : loader
2896 2 : .resolve_for(&beta, &Value::String(b_url.clone()))
2897 2 : .await
2898 2 : .expect("beta resolves its own @context");
2899 2 : assert_eq!(loader.cache_stats()["merged"].as_u64(), Some(2));
2900 :
2901 : // alpha adds an UNRELATED @context: no merged context was built from
2902 : // it, so nothing may be discarded
2903 2 : loader
2904 2 : .put_local_for(
2905 2 : &alpha,
2906 2 : format!("{base}/cccc3333-0000-4000-8000-000000000003"),
2907 2 : serde_json::json!({"other": "https://a.example/other"}),
2908 2 : )
2909 2 : .await;
2910 2 : assert_eq!(
2911 2 : loader.cache_stats()["merged"].as_u64(),
2912 : Some(2),
2913 : "one Tenant's @context write must not flush another Tenant's merged context"
2914 : );
2915 :
2916 : // correctness first: rewriting a document a merged context WAS built
2917 : // from drops that entry, so the new mappings are the ones served
2918 2 : loader
2919 2 : .put_local_for(
2920 2 : &alpha,
2921 2 : a_url.clone(),
2922 2 : serde_json::json!({"speed": "https://a.example/v2"}),
2923 2 : )
2924 2 : .await;
2925 2 : let ctx = loader
2926 2 : .resolve_for(&alpha, &Value::String(a_url))
2927 2 : .await
2928 2 : .expect("resolve after rewrite");
2929 2 : assert_eq!(
2930 2 : ctx.expand_key("speed"),
2931 : "https://a.example/v2",
2932 : "a rewritten @context must never be served from the merged cache"
2933 : );
2934 2 : let ctx = loader
2935 2 : .resolve_for(&beta, &Value::String(b_url))
2936 2 : .await
2937 2 : .expect("beta's merged context survived");
2938 2 : assert_eq!(ctx.expand_key("level"), "https://b.example/level");
2939 2 : }
2940 :
2941 : #[tokio::test]
2942 2 : async fn pinned_versions_resolve_without_network() {
2943 2 : let l = Loader::new();
2944 14 : for v in ["1.3", "1.4", "1.5", "1.6", "1.7", "1.8", "1.9"] {
2945 14 : let url = format!("https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v{v}.jsonld");
2946 14 : let ctx = l.resolve(&Value::String(url)).await.expect("resolve");
2947 14 : assert_eq!(
2948 14 : ctx.expand_key("observedAt"),
2949 2 : "https://uri.etsi.org/ngsi-ld/observedAt"
2950 2 : );
2951 2 : }
2952 2 : }
2953 :
2954 : /// 4.4, V1.9.1: "The NGSI-LD Core @context is publicly available at
2955 : /// `https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld` and
2956 : /// shall contain all the terms as mandated by annex B." A request that
2957 : /// names no @context of its own is answered under that document alone,
2958 : /// so the terms V1.9 added have to expand from the implicit core — not
2959 : /// only when a client spells the v1.9 URL out. Under an older core they
2960 : /// fall through @vocab into the default context, and a member the broker
2961 : /// itself renders (`Snapshot`, `valueType`, `orderBy`) then carries a
2962 : /// name no other implementation reads as the core term.
2963 : #[tokio::test]
2964 2 : async fn the_implicit_core_context_is_the_one_v1_9_1_names() {
2965 2 : assert_eq!(
2966 : CORE_CONTEXT,
2967 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld"
2968 : );
2969 2 : let core = Loader::new().core();
2970 12 : for (term, iri) in [
2971 2 : ("Snapshot", "https://uri.etsi.org/ngsi-ld/Snapshot"),
2972 2 : ("orderBy", "https://uri.etsi.org/ngsi-ld/orderBy"),
2973 2 : ("collation", "https://uri.etsi.org/ngsi-ld/collation"),
2974 2 : ("valueType", "https://uri.etsi.org/ngsi-ld/hasValueType"),
2975 2 : ("objectLists", "https://uri.etsi.org/ngsi-ld/hasObjectLists"),
2976 2 : ("aggrMethods", "https://uri.etsi.org/ngsi-ld/aggrMethods"),
2977 2 : ] {
2978 12 : assert_eq!(
2979 12 : core.expand_key(term),
2980 2 : iri,
2981 2 : "{term} must expand to its Annex B IRI, not through @vocab"
2982 2 : );
2983 2 : }
2984 2 : }
2985 :
2986 : /// Annex B (normative), V1.9.1: the core @context served for the v1.9
2987 : /// URL is that version's document. The terms V1.9 added are the test —
2988 : /// a v1.8 document under the v1.9 name expands every one of them
2989 : /// through the @vocab fallback into the default context instead, and
2990 : /// the broker then does not see a core member at all.
2991 : #[tokio::test]
2992 2 : async fn the_v1_9_core_context_carries_the_terms_v1_9_added() {
2993 2 : let l = Loader::new();
2994 2 : let ctx = l
2995 2 : .resolve(&Value::String(
2996 2 : "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld".into(),
2997 2 : ))
2998 2 : .await
2999 2 : .expect("resolve");
3000 34 : for term in [
3001 2 : "Snapshot",
3002 2 : "SnapshotNotification",
3003 2 : "ExecutionResultDetails",
3004 2 : "aggrMethods",
3005 2 : "aggrParams",
3006 2 : "aggrPeriodDuration",
3007 2 : "collation",
3008 2 : "lastUsedAt",
3009 2 : "ngsildproof",
3010 2 : "orderBy",
3011 2 : "ordering",
3012 2 : "problemDetails",
3013 2 : "resultStatus",
3014 2 : "snapshotId",
3015 2 : "snapshotLifetime",
3016 2 : "snapshotPriority",
3017 2 : "snapshotStatus",
3018 2 : ] {
3019 34 : assert_eq!(
3020 34 : ctx.expand_key(term),
3021 34 : format!("https://uri.etsi.org/ngsi-ld/{term}"),
3022 : "{term} must expand to its Annex B IRI, not through @vocab"
3023 : );
3024 : }
3025 : // renamed by the V1.9 annex, and the rename is only visible if the
3026 : // document under the v1.9 name really is the v1.9 one.
3027 2 : assert_eq!(
3028 2 : ctx.expand_key("objectLists"),
3029 : "https://uri.etsi.org/ngsi-ld/hasObjectLists"
3030 : );
3031 4 : let terms = |url: &str| {
3032 4 : pinned(url)
3033 4 : .and_then(|c| c.as_object().cloned())
3034 4 : .expect("pinned document")
3035 4 : };
3036 2 : let v19 = terms("https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld");
3037 2 : let v18 = terms("https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.8.jsonld");
3038 4 : for gone in ["objectsLists", "geometryProperty"] {
3039 4 : assert!(v18.contains_key(gone), "{gone} belongs to the v1.8 annex");
3040 4 : assert!(
3041 4 : !v19.contains_key(gone),
3042 2 : "{gone} is not in the V1.9 annex; the v1.9 document is a copy of v1.8"
3043 2 : );
3044 2 : }
3045 2 : assert!(!v18.contains_key("Snapshot"), "v1.8 predates Snapshot");
3046 2 : }
3047 : }
|