LCOV - code coverage report
Current view: top level - antares-jsonld/src - loader.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 97.8 % 2259 2210
Test Date: 2026-11-02 13:21:27 Functions: 81.7 % 491 401

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

Generated by: LCOV version 2.0-1