LCOV - code coverage report
Current view: top level - antares-notifier/src - lib.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 94.8 % 440 417
Test Date: 2026-11-02 13:21:27 Functions: 57.1 % 168 96

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Notification delivery.
       3              : //!
       4              : //! A sink serves one family of `endpoint.uri` schemes (6.3.8, and 7.2 for
       5              : //! the optional MQTT binding): it validates its own endpoints at
       6              : //! subscription creation and delivers the prepared notification. The
       7              : //! registry keys sinks by scheme and is the only way one is chosen — a
       8              : //! scheme it does not hold is rejected at creation, never delivered through
       9              : //! another binding. Sinks: http/reqwest, mqtt/rumqttc behind the `mqtt`
      10              : //! feature.
      11              : //!
      12              : //! The egress policy (allowlist, private-range deny, per-destination
      13              : //! breakers) runs in the caller before `deliver`, so a sink registered from
      14              : //! outside this workspace cannot step around it.
      15              : #![cfg_attr(not(test), warn(clippy::expect_used))]
      16              : 
      17              : use antares_model::NgsiError;
      18              : use serde_json::{Map, Value};
      19              : use std::future::Future;
      20              : use std::pin::Pin;
      21              : use std::time::Duration;
      22              : 
      23              : pub mod http;
      24              : #[cfg(feature = "mqtt")]
      25              : pub mod mqtt;
      26              : 
      27              : pub use http::HttpSink;
      28              : 
      29              : /// The TS 104 243 5.2 message (the MQTT binding's 7.2):
      30              : /// `{"metadata": {...}, "body": notification}`. Every binding that writes
      31              : /// this envelope (MQTT, an in-process socket binding) renders it
      32              : /// here, so two bindings cannot drift apart.
      33              : /// `link` is the HTTP-Link-header-formatted @context reference; per Table
      34              : /// 7.2-2 it is included only when the Content-Type is application/json
      35              : /// (with ld+json the @context travels in the body).
      36            6 : pub fn build_message(
      37            6 :     body: &Value,
      38            6 :     content_type: &str,
      39            6 :     link: Option<&str>,
      40            6 :     receiver_info: &[(String, String)],
      41            6 : ) -> Value {
      42            6 :     let mut metadata = Map::new();
      43              :     // receiverInfo goes in first: 7.2 adds its entries "additionally", while
      44              :     // Table 7.2-2 names endpoint.accept as the source of Content-Type and
      45              :     // the served @context as the source of Link. A pair keyed like one of
      46              :     // those two is an entry, never that parameter's value.
      47            8 :     for (k, v) in receiver_info {
      48            8 :         metadata.insert(k.clone(), Value::String(v.clone()));
      49            8 :     }
      50            6 :     metadata.insert("Content-Type".into(), Value::String(content_type.into()));
      51            6 :     if content_type == "application/json" {
      52            4 :         if let Some(l) = link {
      53            4 :             metadata.insert("Link".into(), Value::String(l.to_owned()));
      54            4 :         }
      55            2 :     }
      56            6 :     let mut msg = Map::new();
      57            6 :     msg.insert("metadata".into(), Value::Object(metadata));
      58            6 :     msg.insert("body".into(), body.clone());
      59            6 :     Value::Object(msg)
      60            6 : }
      61              : 
      62              : /// One prepared notification, deliverable any number of times: a retry or a
      63              : /// dead-letter replay renders the identical message from the same parts.
      64              : /// The parts are transport-neutral — 6.3.8 turns them into HTTP headers,
      65              : /// Table 7.2-2 into the MQTT message's `metadata` object.
      66              : #[derive(Clone, Debug, PartialEq)]
      67              : pub struct Outbound {
      68              :     /// The Notification (5.3.1) to deliver.
      69              :     pub body: Value,
      70              :     /// `endpoint.accept` (Table 5.2.15-1): the MIME type of `body`.
      71              :     pub accept: String,
      72              :     /// The JSON-LD `@context` Link value belonging to `body` (6.3.8).
      73              :     pub link: String,
      74              :     /// `endpoint.receiverInfo` (Table 5.2.15-1) followed by the tenant and
      75              :     /// snapshot markers the binding has to convey (6.3.22).
      76              :     pub receiver_info: Vec<(String, String)>,
      77              :     /// `endpoint.notifierInfo` (Table 5.2.15-1): the parameters the binding
      78              :     /// needs to set up its channel, e.g. Table 7.2-1's MQTT-QoS. Opaque to
      79              :     /// every sink but the one whose scheme the endpoint names.
      80              :     pub notifier_info: Vec<(String, String)>,
      81              : }
      82              : 
      83              : impl Outbound {
      84              :     /// A dead letter back into deliverable form. Letters written before the
      85              :     /// bindings moved behind the registry carry a rendered HTTP header list, or
      86              :     /// an already-wrapped clause 7 message, instead of the endpoint members they
      87              :     /// were rendered from; both read back, so an upgrade does not strand the
      88              :     /// letters an operator has not replayed yet.
      89           22 :     pub fn from_dead_letter(letter: &Value) -> Result<Self, String> {
      90           22 :         let pairs = |v: &Value| -> Vec<(String, String)> {
      91            4 :             serde_json::from_value::<Vec<(String, String)>>(v.clone()).unwrap_or_default()
      92            4 :         };
      93           22 :         if letter.get("accept").and_then(Value::as_str).is_some() {
      94            2 :             return Ok(Self {
      95            2 :                 body: letter["payload"].clone(),
      96            2 :                 accept: letter["accept"].as_str().unwrap_or_default().to_owned(),
      97            2 :                 link: letter["link"].as_str().unwrap_or_default().to_owned(),
      98            2 :                 receiver_info: pairs(&letter["receiverInfo"]),
      99            2 :                 notifier_info: pairs(&letter["notifierInfo"]),
     100            2 :             });
     101           20 :         }
     102              :         // Pre-registry HTTP: Content-Type and Link ARE the accept and link they
     103              :         // were rendered from; every other header came from receiverInfo.
     104           20 :         if let Some(headers) = letter.get("headers").filter(|h| !h.is_null()) {
     105           16 :             let headers = serde_json::from_value::<Vec<(String, String)>>(headers.clone())
     106           16 :                 .map_err(|e| format!("dead letter headers unreadable: {e}"))?;
     107           28 :             let take = |name: &str| {
     108           28 :                 headers
     109           28 :                     .iter()
     110           42 :                     .find(|(k, _)| k.eq_ignore_ascii_case(name))
     111           28 :                     .map(|(_, v)| v.clone())
     112           28 :                     .unwrap_or_default()
     113           28 :             };
     114              :             return Ok(Self {
     115           14 :                 body: letter["payload"].clone(),
     116           14 :                 accept: take("Content-Type"),
     117           14 :                 link: take("Link"),
     118           14 :                 receiver_info: headers
     119           14 :                     .iter()
     120           30 :                     .filter(|(k, _)| {
     121           30 :                         !k.eq_ignore_ascii_case("Content-Type") && !k.eq_ignore_ascii_case("Link")
     122           30 :                     })
     123           14 :                     .cloned()
     124           14 :                     .collect(),
     125           14 :                 notifier_info: Vec::new(),
     126              :             });
     127            4 :         }
     128              :         // Pre-registry MQTT: the payload is the 7.2 message, so the notification
     129              :         // and its metadata come back out of the wrapper.
     130            4 :         let meta = &letter["payload"]["metadata"];
     131            4 :         if let Some(meta) = meta.as_object() {
     132            2 :             let mut notifier_info = Vec::new();
     133            2 :             if let Some(q) = letter["mqtt"]["qos"].as_u64() {
     134            2 :                 notifier_info.push(("MQTT-QoS".to_owned(), q.to_string()));
     135            2 :             }
     136            2 :             if let Some(v5) = letter["mqtt"]["v5"].as_bool() {
     137            2 :                 let v = if v5 { "mqtt5.0" } else { "mqtt3.1.1" };
     138            2 :                 notifier_info.push(("MQTT-Version".to_owned(), v.to_owned()));
     139            0 :             }
     140              :             return Ok(Self {
     141            2 :                 body: letter["payload"]["body"].clone(),
     142            2 :                 accept: meta
     143            2 :                     .get("Content-Type")
     144            2 :                     .and_then(Value::as_str)
     145            2 :                     .unwrap_or("application/json")
     146            2 :                     .to_owned(),
     147            2 :                 link: meta
     148            2 :                     .get("Link")
     149            2 :                     .and_then(Value::as_str)
     150            2 :                     .unwrap_or_default()
     151            2 :                     .to_owned(),
     152            2 :                 receiver_info: meta
     153            2 :                     .iter()
     154            6 :                     .filter(|(k, _)| k.as_str() != "Content-Type" && k.as_str() != "Link")
     155            2 :                     .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned())))
     156            2 :                     .collect(),
     157            2 :                 notifier_info,
     158              :             });
     159            2 :         }
     160            2 :         Err("dead letter carries no deliverable notification".to_owned())
     161           22 :     }
     162              : 
     163              :     /// `notifier_info` in the borrowed pair form the sinks parse.
     164            0 :     pub fn notifier_pairs(&self) -> Vec<(&str, &str)> {
     165            0 :         self.notifier_info
     166            0 :             .iter()
     167            0 :             .map(|(k, v)| (k.as_str(), v.as_str()))
     168            0 :             .collect()
     169            0 :     }
     170              : }
     171              : 
     172              : /// Why one delivery attempt did not land. `timed_out` is the only class the
     173              : /// caller's circuit breaker counts: an endpoint that answers — with any
     174              : /// status — is alive.
     175              : #[derive(Clone, Debug, PartialEq)]
     176              : pub struct DeliveryError {
     177              :     /// The attempt ran out of time rather than being answered.
     178              :     pub timed_out: bool,
     179              :     /// Failure text for the log, the subscription status and the dead
     180              :     /// letter. Never carries endpoint credentials.
     181              :     pub message: String,
     182              : }
     183              : 
     184              : impl DeliveryError {
     185              :     /// The endpoint answered, or refused, within the deadline.
     186           50 :     pub fn failed(message: impl Into<String>) -> Self {
     187           50 :         Self {
     188           50 :             timed_out: false,
     189           50 :             message: message.into(),
     190           50 :         }
     191           50 :     }
     192              : 
     193              :     /// The deadline passed with no answer.
     194            0 :     pub fn timeout(message: impl Into<String>) -> Self {
     195            0 :         Self {
     196            0 :             timed_out: true,
     197            0 :             message: message.into(),
     198            0 :         }
     199            0 :     }
     200              : }
     201              : 
     202              : /// The future one `deliver` returns. `Send` on every target: the un-Send
     203              : /// piece of a browser fetch is fenced inside `antares_jsonld::http_interaction`.
     204              : pub type DeliveryFuture<'a> = Pin<Box<dyn Future<Output = Result<(), DeliveryError>> + Send + 'a>>;
     205              : 
     206              : /// How one notification is delivered: how many attempts, how they are
     207              : /// spaced, and how long after the first attempt the last one may still
     208              : /// start. The default is a single attempt — exactly 5.8.6, which sends the
     209              : /// notification once and books the outcome. Retries are an operator choice:
     210              : /// they never move `timesSent` again (the notification is sent ONCE, the
     211              : /// attempts are transport), a retry that succeeds books `lastSuccess` and
     212              : /// `status` ok, an exhausted policy leaves a dead letter.
     213              : #[derive(Clone, Copy, Debug, PartialEq)]
     214              : pub struct DeliveryPolicy {
     215              :     /// Total attempts, first one included. 1 = never retry.
     216              :     pub attempts: u32,
     217              :     /// Delay before the first retry; doubles per retry up to `MAX_BACKOFF`.
     218              :     pub backoff: std::time::Duration,
     219              :     /// Fraction of the delay randomised in both directions (0.2 = ±20 %),
     220              :     /// so many subscriptions to one dead endpoint do not retry in lockstep.
     221              :     pub jitter: f32,
     222              :     /// A retry that would start later than this after the first attempt is
     223              :     /// not made.
     224              :     pub max_age: std::time::Duration,
     225              : }
     226              : 
     227              : impl Default for DeliveryPolicy {
     228         4901 :     fn default() -> Self {
     229         4901 :         Self {
     230         4901 :             attempts: 1,
     231         4901 :             backoff: std::time::Duration::from_secs(1),
     232         4901 :             jitter: 0.2,
     233         4901 :             max_age: std::time::Duration::from_secs(300),
     234         4901 :         }
     235         4901 :     }
     236              : }
     237              : 
     238              : impl DeliveryPolicy {
     239              :     /// Ceiling on one delay, whatever the doubling says.
     240              :     pub const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60);
     241              : 
     242            2 :     pub fn with_max_age(self, max_age: std::time::Duration) -> Self {
     243            2 :         Self { max_age, ..self }
     244            2 :     }
     245              : 
     246              :     /// ANTARES_NOTIFY_ATTEMPTS / ANTARES_NOTIFY_BACKOFF_MS /
     247              :     /// ANTARES_NOTIFY_MAX_AGE_SECS, each optional; a value that is present
     248              :     /// but not a positive integer is a startup error, never a silent default.
     249           61 :     pub fn from_env() -> Result<Self, String> {
     250          183 :         let get = |k: &str| std::env::var(k).ok();
     251           61 :         Self::parse(
     252           61 :             get("ANTARES_NOTIFY_ATTEMPTS").as_deref(),
     253           61 :             get("ANTARES_NOTIFY_BACKOFF_MS").as_deref(),
     254           61 :             get("ANTARES_NOTIFY_MAX_AGE_SECS").as_deref(),
     255              :         )
     256           61 :     }
     257              : 
     258           79 :     pub fn parse(
     259           79 :         attempts: Option<&str>,
     260           79 :         backoff_ms: Option<&str>,
     261           79 :         max_age_secs: Option<&str>,
     262           79 :     ) -> Result<Self, String> {
     263          219 :         fn positive(name: &str, raw: Option<&str>) -> Result<Option<u64>, String> {
     264          219 :             match raw {
     265          197 :                 None => Ok(None),
     266           22 :                 Some(v) => v
     267           22 :                     .trim()
     268           22 :                     .parse::<u64>()
     269           22 :                     .ok()
     270           22 :                     .filter(|n| *n > 0)
     271           22 :                     .map(Some)
     272           22 :                     .ok_or_else(|| format!("{name} must be a positive integer, got {v:?}")),
     273              :             }
     274          219 :         }
     275           79 :         let d = Self::default();
     276              :         Ok(Self {
     277           79 :             attempts: positive("ANTARES_NOTIFY_ATTEMPTS", attempts)?
     278           73 :                 .map_or(d.attempts, |n| u32::try_from(n).unwrap_or(u32::MAX)),
     279           73 :             backoff: positive("ANTARES_NOTIFY_BACKOFF_MS", backoff_ms)?
     280           67 :                 .map_or(d.backoff, std::time::Duration::from_millis),
     281           67 :             jitter: d.jitter,
     282           67 :             max_age: positive("ANTARES_NOTIFY_MAX_AGE_SECS", max_age_secs)?
     283           65 :                 .map_or(d.max_age, std::time::Duration::from_secs),
     284              :         })
     285           79 :     }
     286              : 
     287              :     /// The delay before the next attempt after `made` attempts, `elapsed`
     288              :     /// after the first one — `None` when the policy is exhausted or the
     289              :     /// retry would start past `max_age`.
     290          152 :     pub fn next_delay(
     291          152 :         &self,
     292          152 :         made: u32,
     293          152 :         elapsed: std::time::Duration,
     294          152 :     ) -> Option<std::time::Duration> {
     295          152 :         if made == 0 || made >= self.attempts {
     296           12 :             return None;
     297          140 :         }
     298          140 :         let doubled = self
     299          140 :             .backoff
     300          140 :             .checked_mul(1u32.checked_shl(made - 1).unwrap_or(u32::MAX))
     301          140 :             .unwrap_or(Self::MAX_BACKOFF)
     302          140 :             .min(Self::MAX_BACKOFF);
     303              :         // ±jitter, seeded from the clock's sub-second noise: cheap and
     304              :         // uncorrelated enough to spread retries; no RNG dependency.
     305          140 :         let noise = (std::time::SystemTime::now()
     306          140 :             .duration_since(std::time::UNIX_EPOCH)
     307          140 :             .map(|d| d.subsec_nanos())
     308          140 :             .unwrap_or(0)
     309              :             % 1000) as f32
     310              :             / 1000.0;
     311          140 :         let factor = 1.0 + self.jitter.clamp(0.0, 1.0) * (2.0 * noise - 1.0);
     312          140 :         let delay = doubled.mul_f32(factor.max(0.0));
     313          140 :         (elapsed + delay <= self.max_age).then_some(delay)
     314          152 :     }
     315              : }
     316              : 
     317              : /// Strip the authority's userinfo from an endpoint URI. 7.2 allows
     318              : /// credentials there (`mqtt[s]://[<username>][:<password>]@<host>…`), and a
     319              : /// rejected or failed endpoint travels back to the client as the `detail`
     320              : /// member of the ProblemDetails body (5.5.3) and into the delivery logs —
     321              : /// neither may carry the subscription's password. Everything after the
     322              : /// authority's last `@` is kept; an `@` in the path or topic is data.
     323              : ///
     324              : /// A URI written without the `//` (`http:user:pass@host/n`) has no
     325              : /// authority in RFC 3986 terms, yet a WHATWG URL parser (the one reqwest
     326              : /// uses) reads one there and sends that userinfo. Such a URI is stripped the
     327              : /// same way, from the first `:` on. The authority starts right after the
     328              : /// scheme's `:` (and its `//`, when present), never at a `//` later in the
     329              : /// path or query, which would leave the userinfo in place.
     330         2113 : pub fn redact_userinfo(uri: &str) -> String {
     331         2113 :     let Some(colon) = uri.find(':') else {
     332            4 :         return uri.to_owned();
     333              :     };
     334         2109 :     let start = colon
     335         2109 :         + 1
     336         2109 :         + if uri[colon + 1..].starts_with("//") {
     337         2087 :             2
     338              :         } else {
     339           22 :             0
     340              :         };
     341         2109 :     let rest = &uri[start..];
     342         2109 :     let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
     343         2109 :     if let Some(at) = rest[..authority_end].rfind('@') {
     344          144 :         return format!("{}{}", &uri[..start], &rest[at + 1..]);
     345         1965 :     }
     346         1965 :     uri.to_owned()
     347         2113 : }
     348              : 
     349              : /// A delivery binding for one URI scheme family.
     350              : pub trait NotificationSink: Send + Sync {
     351              :     /// Schemes this sink serves, e.g. `["http", "https"]`.
     352              :     fn schemes(&self) -> &'static [&'static str];
     353              : 
     354              :     /// 5.8.1.4: the endpoint's own syntax and parameters, checked at
     355              :     /// subscription creation rather than at first delivery. `notifier_info`
     356              :     /// is `endpoint.notifierInfo` (Table 5.2.15-1) as key/value pairs. An
     357              :     /// endpoint that does not meet the sink's requirements is
     358              :     /// BadRequestData; the message names the URI with any userinfo
     359              :     /// credentials stripped, since it travels back in `detail` (5.5.3).
     360              :     fn parse_endpoint(&self, uri: &str, notifier_info: &[(&str, &str)]) -> Result<(), NgsiError>;
     361              : 
     362              :     /// Does an endpoint of this binding name a network destination? The
     363              :     /// caller runs the egress guard — host and port policy, private-range
     364              :     /// and metadata-address deny, per-destination circuit breaker — against
     365              :     /// every endpoint that does, before `deliver` and never inside it, so
     366              :     /// one guard covers every binding whatever scheme it serves. The
     367              :     /// default is the safe answer: a sink is policed unless it declares
     368              :     /// that it opens no socket, and a release binary registers no sink that
     369              :     /// declares otherwise.
     370         9529 :     fn network(&self) -> bool {
     371         9529 :         true
     372         9529 :     }
     373              : 
     374              :     /// One attempt on the wire. `timeout` is `endpoint.timeout`
     375              :     /// (Table 5.2.15-1) already clamped by the caller.
     376              :     fn deliver<'a>(
     377              :         &'a self,
     378              :         uri: &'a str,
     379              :         out: &'a Outbound,
     380              :         timeout: Duration,
     381              :     ) -> DeliveryFuture<'a>;
     382              : 
     383              :     /// A notification of `subscription` the broker dropped before
     384              :     /// `deliver` (a full delivery lane). A binding whose consumer can be
     385              :     /// told of the loss in band tells it; the default has no such channel.
     386         3829 :     fn lost(&self, _uri: &str, _tenant: &str, _subscription: &str) {}
     387              : }
     388              : 
     389              : /// Scheme → sink registry; populated by the composition root. Choosing a
     390              : /// binding goes through here and nowhere else, so an endpoint scheme with no
     391              : /// sink can never fall through to the HTTP binding.
     392              : #[derive(Default)]
     393              : pub struct SinkRegistry {
     394              :     sinks: Vec<Box<dyn NotificationSink>>,
     395              : }
     396              : 
     397              : impl SinkRegistry {
     398              :     /// Add a binding. A scheme already served keeps its first sink.
     399         9656 :     pub fn register(&mut self, sink: Box<dyn NotificationSink>) {
     400         9656 :         self.sinks.push(sink);
     401         9656 :     }
     402              : 
     403              :     /// The scheme of an endpoint URI, lowercased per IETF RFC 3986 §3.1.
     404        29945 :     pub fn scheme_of(uri: &str) -> String {
     405        29945 :         uri.split(':').next().unwrap_or("").to_ascii_lowercase()
     406        29945 :     }
     407              : 
     408              :     /// The sink serving `scheme`, if any.
     409        29941 :     pub fn sink_for(&self, scheme: &str) -> Option<&dyn NotificationSink> {
     410              :         // Linear scan is fine at <5 sinks; switch to a map when sinks multiply.
     411        29941 :         self.sinks
     412        29941 :             .iter()
     413        31085 :             .find(|s| s.schemes().contains(&scheme))
     414        29941 :             .map(AsRef::as_ref)
     415        29941 :     }
     416              : 
     417              :     /// The sink serving an endpoint URI, by its scheme.
     418        28665 :     pub fn sink_for_uri(&self, uri: &str) -> Option<&dyn NotificationSink> {
     419        28665 :         self.sink_for(&Self::scheme_of(uri))
     420        28665 :     }
     421              : 
     422              :     /// 5.8.1.4 reject-at-creation: the sink for this endpoint, having
     423              :     /// accepted the endpoint's own syntax. An endpoint whose scheme this
     424              :     /// deployment cannot deliver to is input data that does not meet the
     425              :     /// requirements of the operation — BadRequestData (Table 5.5.2-1, 400
     426              :     /// per Table 6.3.2-1). Not OperationNotSupported: Create Subscription
     427              :     /// is supported, this endpoint value is not.
     428         1274 :     pub fn require(&self, uri: &str, notifier_info: &[(&str, &str)]) -> Result<(), NgsiError> {
     429         1274 :         let scheme = Self::scheme_of(uri);
     430         1274 :         let sink = self.sink_for(&scheme).ok_or_else(|| {
     431            8 :             NgsiError::BadRequestData(format!(
     432            8 :                 "no notification binding registered for endpoint scheme {scheme:?} (6.3.8)"
     433            8 :             ))
     434            8 :         })?;
     435         1266 :         sink.parse_endpoint(uri, notifier_info)
     436         1274 :     }
     437              : 
     438              :     /// Schemes this deployment can deliver to, for `/q/health` and the
     439              :     /// startup banner.
     440           88 :     pub fn schemes(&self) -> Vec<&'static str> {
     441           88 :         let mut v: Vec<&'static str> = self
     442           88 :             .sinks
     443           88 :             .iter()
     444          174 :             .flat_map(|s| s.schemes())
     445           88 :             .copied()
     446           88 :             .collect();
     447           88 :         v.sort_unstable();
     448           88 :         v.dedup();
     449           88 :         v
     450           88 :     }
     451              : }
     452              : 
     453              : #[cfg(test)]
     454              : mod tests {
     455              :     use super::*;
     456              :     use serde_json::json;
     457              : 
     458              :     struct FakeHttp;
     459              :     impl NotificationSink for FakeHttp {
     460           18 :         fn schemes(&self) -> &'static [&'static str] {
     461           18 :             &["http", "https"]
     462           18 :         }
     463            8 :         fn parse_endpoint(&self, uri: &str, _ni: &[(&str, &str)]) -> Result<(), NgsiError> {
     464            8 :             uri.contains("://")
     465            8 :                 .then_some(())
     466            8 :                 .ok_or_else(|| NgsiError::BadRequestData(format!("no authority in {uri:?}")))
     467            8 :         }
     468            0 :         fn deliver<'a>(
     469            0 :             &'a self,
     470            0 :             _uri: &'a str,
     471            0 :             _o: &'a Outbound,
     472            0 :             _t: Duration,
     473            0 :         ) -> DeliveryFuture<'a> {
     474            0 :             Box::pin(async { Ok(()) })
     475            0 :         }
     476              :     }
     477              : 
     478            8 :     fn registry() -> SinkRegistry {
     479            8 :         let mut reg = SinkRegistry::default();
     480            8 :         reg.register(Box::new(FakeHttp));
     481            8 :         reg
     482            8 :     }
     483              : 
     484              :     /// 5.8.1.4 + Table 5.5.2-1: an endpoint scheme no sink serves is input
     485              :     /// data that does not meet the operation's requirements — BadRequestData
     486              :     /// (400), not OperationNotSupported. Create Subscription is supported.
     487              :     #[test]
     488            2 :     fn unknown_scheme_is_bad_request_data() {
     489            2 :         let reg = registry();
     490            2 :         assert!(reg.require("http://h/n", &[]).is_ok());
     491            2 :         assert!(reg.require("https://h/n", &[]).is_ok());
     492            2 :         let err = reg
     493            2 :             .require("ws://h/n", &[])
     494            2 :             .expect_err("ws has no sink in v1");
     495            2 :         assert_eq!(err.status(), 400);
     496            2 :         assert!(matches!(err, NgsiError::BadRequestData(_)), "{err:?}");
     497            2 :         assert!(format!("{err}").contains("ws"), "{err}");
     498            2 :     }
     499              : 
     500              :     /// The scheme comparison is case-insensitive (IETF RFC 3986 §3.1),
     501              :     /// so `HTTP://…` is not silently unroutable.
     502              :     #[test]
     503            2 :     fn scheme_matching_ignores_case() {
     504            2 :         assert!(registry().require("HTTP://h/n", &[]).is_ok());
     505            2 :         assert_eq!(SinkRegistry::scheme_of("MQTTS://h/t"), "mqtts");
     506            2 :     }
     507              : 
     508              :     /// The sink's own endpoint check runs at creation, and its error is the
     509              :     /// one the client sees.
     510              :     #[test]
     511            2 :     fn the_sinks_own_rejection_is_returned() {
     512            2 :         let err = registry()
     513            2 :             .require("http:/malformed", &[])
     514            2 :             .expect_err("no authority");
     515            2 :         assert_eq!(err.status(), 400);
     516            2 :         assert!(format!("{err}").contains("no authority"), "{err}");
     517            2 :     }
     518              : 
     519              :     /// Every binding this workspace ships opens a socket, so every endpoint
     520              :     /// it delivers to passes the caller's egress policy. A sink that
     521              :     /// declares otherwise is in-process only and belongs to a test.
     522              :     #[test]
     523            2 :     fn every_shipped_sink_is_policed() {
     524            2 :         let mut reg = SinkRegistry::default();
     525            2 :         reg.register(Box::new(crate::HttpSink::new(
     526            2 :             antares_jsonld::client_builder(antares_jsonld::EgressPolicy {
     527            2 :                 allow_private: true,
     528            2 :                 private_hosts: &[],
     529            2 :             })
     530            2 :             .build()
     531            2 :             .expect("client"),
     532            2 :         )));
     533              :         #[cfg(feature = "mqtt")]
     534            2 :         reg.register(Box::new(crate::mqtt::MqttSink::default()));
     535            2 :         assert!(!reg.sinks.is_empty());
     536            4 :         for s in &reg.sinks {
     537            4 :             assert!(
     538            4 :                 s.network(),
     539              :                 "shipped sink {:?} skips the egress policy",
     540            0 :                 s.schemes()
     541              :             );
     542              :         }
     543            2 :     }
     544              : 
     545              :     /// A dead letter reads back into the same notification whichever broker
     546              :     /// wrote it: the current shape, and the two shapes written before the
     547              :     /// bindings moved behind the registry.
     548              :     #[test]
     549            2 :     fn dead_letters_of_every_shape_read_back() {
     550            2 :         let body = json!({"type": "Notification", "subscriptionId": "urn:s:1"});
     551            2 :         let link = "<https://ctx>; rel=\"http://www.w3.org/ns/json-ld#context\"";
     552              : 
     553            2 :         let current = json!({"uri": "http://h/n", "payload": body,
     554            2 :             "accept": "application/ld+json", "link": link,
     555            2 :             "receiverInfo": [["Authorization", "Bearer t"]],
     556            2 :             "notifierInfo": []});
     557            2 :         let o = Outbound::from_dead_letter(&current).expect("current shape");
     558            2 :         assert_eq!(o.body, body);
     559            2 :         assert_eq!(o.accept, "application/ld+json");
     560            2 :         assert_eq!(
     561              :             o.receiver_info,
     562            2 :             [("Authorization".into(), "Bearer t".into())]
     563              :         );
     564              : 
     565            2 :         let legacy_http = json!({"uri": "http://h/n", "binding": "http", "payload": body,
     566            2 :             "headers": [["Content-Type", "application/json"], ["Link", link],
     567              :                         ["Authorization", "Bearer t"]]});
     568            2 :         let o = Outbound::from_dead_letter(&legacy_http).expect("legacy http shape");
     569            2 :         assert_eq!(o.body, body);
     570            2 :         assert_eq!(o.accept, "application/json");
     571            2 :         assert_eq!(o.link, link);
     572            2 :         assert_eq!(
     573              :             o.receiver_info,
     574            2 :             [("Authorization".into(), "Bearer t".into())],
     575              :             "Content-Type and Link are the accept and link, not receiverInfo"
     576              :         );
     577              : 
     578            2 :         let legacy_mqtt = json!({"uri": "mqtt://h/t", "binding": "mqtt",
     579            2 :             "mqtt": {"qos": 2, "v5": false},
     580            2 :             "payload": {"metadata": {"Content-Type": "application/json", "Link": link,
     581            2 :                                      "NGSILD-Tenant": "acme"},
     582            2 :                         "body": body}});
     583            2 :         let o = Outbound::from_dead_letter(&legacy_mqtt).expect("legacy mqtt shape");
     584            2 :         assert_eq!(
     585              :             o.body, body,
     586              :             "the notification comes back out of the wrapper"
     587              :         );
     588            2 :         assert_eq!(o.accept, "application/json");
     589            2 :         assert_eq!(o.receiver_info, [("NGSILD-Tenant".into(), "acme".into())]);
     590            2 :         assert_eq!(
     591              :             o.notifier_info,
     592            2 :             [
     593            2 :                 ("MQTT-QoS".to_owned(), "2".to_owned()),
     594            2 :                 ("MQTT-Version".to_owned(), "mqtt3.1.1".to_owned())
     595            2 :             ]
     596              :         );
     597              : 
     598            2 :         assert!(Outbound::from_dead_letter(&json!({"uri": "http://h/n"})).is_err());
     599            2 :     }
     600              : 
     601              :     /// Endpoint URIs may carry credentials (mqtt[s]://user:pass@host, 7.1);
     602              :     /// log lines must never leak them.
     603              :     #[test]
     604            2 :     fn log_redaction_strips_uri_userinfo() {
     605            2 :         let red = redact_userinfo("mqtts://alice:s3cret@broker:8883/topic");
     606            2 :         assert_eq!(red, "mqtts://broker:8883/topic");
     607            2 :         assert!(!red.contains("s3cret"));
     608            2 :         assert!(!red.contains("alice"));
     609            2 :         assert_eq!(
     610            2 :             redact_userinfo("http://host:9090/notify"),
     611              :             "http://host:9090/notify"
     612              :         );
     613              :         // an '@' beyond the authority is path data, not userinfo
     614            2 :         assert_eq!(redact_userinfo("http://h/p@x"), "http://h/p@x");
     615            2 :     }
     616              : 
     617              :     /// `http:user:pass@host` has no `//`, but a WHATWG parser still reads
     618              :     /// userinfo there: the redaction strips it, and the http binding refuses
     619              :     /// the URI without echoing the credential.
     620              :     #[test]
     621            2 :     fn userinfo_without_the_double_slash_is_redacted_and_refused() {
     622            2 :         let uri = "http:hook:pa55word@localhost:1/n";
     623            2 :         let red = redact_userinfo(uri);
     624            2 :         assert!(!red.contains("pa55word") && !red.contains("hook"), "{red}");
     625            2 :         assert_eq!(red, "http:localhost:1/n");
     626            2 :         let err = http::check_http_endpoint(uri).expect_err("no authority");
     627            2 :         assert!(!format!("{err:?}").contains("pa55word"), "{err:?}");
     628              :         // SECV-02: a `//` later in the path does not move the authority
     629            6 :         for uri in [
     630            2 :             "http:hook:pa55word@h/n//x",
     631            2 :             "http:hook:pa55word@h/n?u=//x",
     632            2 :             "mqtt://u:pa55word@h/a//b",
     633            2 :         ] {
     634            6 :             let red = redact_userinfo(uri);
     635            6 :             assert!(!red.contains("pa55word"), "{uri} -> {red}");
     636            6 :             if let Err(err) = http::check_http_endpoint(uri) {
     637            6 :                 assert!(!format!("{err:?}").contains("pa55word"), "{err:?}");
     638            0 :             }
     639              :         }
     640            2 :     }
     641              : 
     642              :     /// An unregistered scheme resolves to no sink at delivery time either —
     643              :     /// there is no fall-through to the first registered binding.
     644              :     #[test]
     645            2 :     fn delivery_lookup_never_falls_through() {
     646            2 :         let reg = registry();
     647            2 :         assert!(reg.sink_for_uri("http://h/n").is_some());
     648            2 :         assert!(reg.sink_for_uri("memory://box").is_none());
     649            2 :         assert!(reg.sink_for("").is_none());
     650            2 :         assert_eq!(reg.schemes(), vec!["http", "https"]);
     651            2 :     }
     652              : }
     653              : 
     654              : #[cfg(test)]
     655              : mod policy_tests {
     656              :     use super::DeliveryPolicy;
     657              :     use std::time::Duration;
     658              : 
     659           10 :     fn policy(attempts: u32, backoff_ms: u64, max_age_secs: u64) -> DeliveryPolicy {
     660           10 :         DeliveryPolicy {
     661           10 :             attempts,
     662           10 :             backoff: Duration::from_millis(backoff_ms),
     663           10 :             jitter: 0.0,
     664           10 :             max_age: Duration::from_secs(max_age_secs),
     665           10 :         }
     666           10 :     }
     667              : 
     668              :     /// Drive `op` the way the delivery loop does: one attempt, then a
     669              :     /// retry after every delay the policy grants. Returns the outcome and
     670              :     /// how many calls were made.
     671            8 :     fn run<E>(p: DeliveryPolicy, mut op: impl FnMut(u32) -> Result<(), E>) -> (Result<(), E>, u32) {
     672            8 :         let mut made = 1;
     673            8 :         let mut elapsed = Duration::ZERO;
     674            8 :         let mut last = op(made);
     675           18 :         while last.is_err() {
     676           16 :             let Some(d) = p.next_delay(made, elapsed) else {
     677            6 :                 break;
     678              :             };
     679           10 :             elapsed += d;
     680           10 :             made += 1;
     681           10 :             last = op(made);
     682              :         }
     683            8 :         (last, made)
     684            8 :     }
     685              : 
     686              :     #[test]
     687            2 :     fn default_is_a_single_attempt() {
     688            2 :         let p = DeliveryPolicy::default();
     689            2 :         assert_eq!(p.attempts, 1);
     690            2 :         assert_eq!(p.next_delay(1, Duration::ZERO), None);
     691            2 :         let (res, made) = run(p, |_| Err::<(), _>("down"));
     692            2 :         assert_eq!(made, 1);
     693            2 :         assert_eq!(res, Err("down"));
     694            2 :     }
     695              : 
     696              :     #[test]
     697            2 :     fn fails_twice_then_succeeds_is_three_calls_and_one_ok() {
     698            2 :         let (res, made) = run(
     699            2 :             policy(3, 100, 60),
     700            6 :             |n| if n < 3 { Err("down") } else { Ok(()) },
     701              :         );
     702            2 :         assert_eq!(made, 3);
     703            2 :         assert_eq!(res, Ok(()));
     704            2 :     }
     705              : 
     706              :     #[test]
     707            2 :     fn always_failing_stops_after_attempts_with_the_last_error() {
     708            2 :         let (res, made) = run(policy(3, 100, 60), Err::<(), _>);
     709            2 :         assert_eq!(made, 3);
     710            2 :         assert_eq!(res, Err(3), "the LAST error is returned");
     711            2 :     }
     712              : 
     713              :     #[test]
     714            2 :     fn max_age_cuts_the_schedule_short() {
     715              :         // 100 ms, 200 ms, 400 ms … but only 250 ms of age allowed: the
     716              :         // second retry would land at 300 ms, so it is never made.
     717            2 :         let (res, made) = run(
     718            2 :             policy(10, 100, 0).with_max_age(Duration::from_millis(250)),
     719              :             |_| Err::<(), _>("down"),
     720              :         );
     721            2 :         assert_eq!(made, 2);
     722            2 :         assert!(res.is_err());
     723            2 :     }
     724              : 
     725              :     #[test]
     726            2 :     fn backoff_doubles_per_retry_and_jitter_stays_within_bounds() {
     727            2 :         let p = policy(5, 100, 60);
     728            2 :         assert_eq!(
     729            2 :             p.next_delay(1, Duration::ZERO),
     730            2 :             Some(Duration::from_millis(100))
     731              :         );
     732            2 :         assert_eq!(
     733            2 :             p.next_delay(2, Duration::ZERO),
     734            2 :             Some(Duration::from_millis(200))
     735              :         );
     736            2 :         assert_eq!(
     737            2 :             p.next_delay(3, Duration::ZERO),
     738            2 :             Some(Duration::from_millis(400))
     739              :         );
     740            2 :         let j = DeliveryPolicy { jitter: 0.5, ..p };
     741            2 :         for _ in 0..50 {
     742          100 :             let d = j.next_delay(1, Duration::ZERO).expect("granted");
     743          100 :             assert!(
     744          100 :                 d >= Duration::from_millis(50) && d <= Duration::from_millis(150),
     745              :                 "{d:?}"
     746              :             );
     747              :         }
     748            2 :         let d = j.next_delay(30, Duration::ZERO);
     749            2 :         assert_eq!(d, None, "attempt 30 of 5 is over");
     750            2 :         let big = policy(40, 100, 3600);
     751            2 :         let d = big.next_delay(35, Duration::ZERO).expect("granted");
     752            2 :         assert!(
     753            2 :             d <= DeliveryPolicy::MAX_BACKOFF,
     754              :             "2^34 backoff must not overflow: {d:?}"
     755              :         );
     756            2 :     }
     757              : 
     758              :     #[test]
     759            2 :     fn env_parsing_rejects_garbage_and_zero_attempts() {
     760            2 :         let parse =
     761           18 :             |a: Option<&str>, b: Option<&str>, m: Option<&str>| DeliveryPolicy::parse(a, b, m);
     762            2 :         assert_eq!(parse(None, None, None), Ok(DeliveryPolicy::default()));
     763            2 :         let p = parse(Some("3"), Some("250"), Some("30")).expect("valid");
     764            2 :         assert_eq!(p.attempts, 3);
     765            2 :         assert_eq!(p.backoff, Duration::from_millis(250));
     766            2 :         assert_eq!(p.max_age, Duration::from_secs(30));
     767           14 :         for bad in [
     768            2 :             parse(Some("0"), None, None),
     769            2 :             parse(Some("-1"), None, None),
     770            2 :             parse(Some("three"), None, None),
     771            2 :             parse(None, Some("0"), None),
     772            2 :             parse(None, Some("1e3"), None),
     773            2 :             parse(None, None, Some("never")),
     774            2 :             parse(Some("2"), Some(""), None),
     775            2 :         ] {
     776           14 :             assert!(bad.is_err(), "{bad:?}");
     777              :         }
     778            2 :     }
     779              : }
        

Generated by: LCOV version 2.0-1