LCOV - code coverage report
Current view: top level - antares-notifier/src - http.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.5 % 191 190
Test Date: 2026-11-02 13:21:27 Functions: 91.7 % 48 44

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! HTTP notification binding — CIM 009 clause 6.3.8.
       3              : //!
       4              : //! A notification is an HTTP POST to `notification.endpoint.uri`. The MIME
       5              : //! type is `endpoint.accept`, defaulting to `"application/json"`; for
       6              : //! `"application/json"` (and, as this broker serves it, for
       7              : //! `"application/geo+json"`) the request carries a Link header naming the
       8              : //! JSON-LD `@context`. Each `endpoint.receiverInfo` pair becomes one custom
       9              : //! header.
      10              : 
      11              : use crate::{DeliveryError, DeliveryFuture, NotificationSink, Outbound};
      12              : use antares_model::NgsiError;
      13              : use std::time::Duration;
      14              : 
      15              : /// The HTTP(S) binding over one shared outbound client. The client carries
      16              : /// the deployment's egress policy (resolver pinning, connect timeouts); the
      17              : /// per-destination policy check and breaker stay in the caller.
      18              : pub struct HttpSink {
      19              :     client: antares_jsonld::HttpClient,
      20              : }
      21              : 
      22              : impl HttpSink {
      23              :     /// Bind to an already-configured outbound client.
      24         4824 :     pub fn new(client: antares_jsonld::HttpClient) -> Self {
      25         4824 :         Self { client }
      26         4824 :     }
      27              : }
      28              : 
      29              : /// An absolute http(s) URL with an authority and a host, the only endpoint
      30              : /// this binding can send to. Every other form is BadRequestData, whose
      31              : /// message names the URI with any userinfo stripped: it travels back in
      32              : /// `detail` (5.5.3). `http:user:pass@host` (no `//`) is refused here, and
      33              : /// its credential is not echoed.
      34         1316 : pub fn check_http_endpoint(uri: &str) -> Result<(), NgsiError> {
      35         1316 :     let safe = crate::redact_userinfo(uri);
      36              :     // IETF RFC 3986 3.1: scheme names are case-insensitive, and
      37              :     // `SinkRegistry::scheme_of` already lowercases to pick this sink.
      38         1316 :     let rest = uri
      39         1316 :         .split_once("://")
      40         1316 :         .filter(|(s, _)| s.eq_ignore_ascii_case("http") || s.eq_ignore_ascii_case("https"))
      41         1316 :         .map(|(_, rest)| rest)
      42         1316 :         .ok_or_else(|| {
      43           24 :             NgsiError::BadRequestData(format!("not an http(s) endpoint URI: {safe:?}"))
      44           24 :         })?;
      45         1292 :     let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
      46         1292 :     let host = rest[..authority_end]
      47         1292 :         .rsplit_once('@')
      48         1292 :         .map_or(&rest[..authority_end], |(_, h)| h);
      49         1292 :     if host.is_empty() {
      50            4 :         return Err(NgsiError::BadRequestData(format!(
      51            4 :             "http endpoint {safe:?} has no host"
      52            4 :         )));
      53         1288 :     }
      54         1288 :     Ok(())
      55         1316 : }
      56              : 
      57              : impl NotificationSink for HttpSink {
      58        34822 :     fn schemes(&self) -> &'static [&'static str] {
      59        34822 :         &["http", "https"]
      60        34822 :     }
      61              : 
      62              :     /// 5.2.15: the endpoint URI has to be dereferenceable — for this binding,
      63              :     /// an absolute http(s) URL with an authority.
      64         1272 :     fn parse_endpoint(&self, uri: &str, _notifier_info: &[(&str, &str)]) -> Result<(), NgsiError> {
      65         1272 :         check_http_endpoint(uri)
      66         1272 :     }
      67              : 
      68         4958 :     fn deliver<'a>(
      69         4958 :         &'a self,
      70         4958 :         uri: &'a str,
      71         4958 :         out: &'a Outbound,
      72         4958 :         timeout: Duration,
      73         4958 :     ) -> DeliveryFuture<'a> {
      74         4958 :         Box::pin(async move {
      75         4958 :             let bytes = antares_model::ordered_vec(&out.body);
      76              :             // Wasm: the page sink takes matching endpoints — a page cannot
      77              :             // listen on a socket, so this IS its delivery channel.
      78              :             #[cfg(target_arch = "wasm32")]
      79              :             if page_sink::try_deliver(uri, &bytes) {
      80              :                 return Ok(());
      81              :             }
      82         4958 :             let mut req = self.client.post(uri);
      83        10096 :             for (k, v) in headers(out) {
      84        10096 :                 req = req.header(k, v);
      85        10096 :             }
      86              :             // endpoint.timeout rides on the request natively (the client's
      87              :             // own total alone would let a stalled endpoint eat the full cap
      88              :             // per delivery); stretched under the sanitizer like the client's
      89              :             // other deadlines.
      90              :             #[cfg(not(target_arch = "wasm32"))]
      91         4958 :             let req =
      92         4958 :                 req.timeout(timeout.saturating_mul(
      93         4958 :                     u32::try_from(antares_jsonld::slow_factor()).unwrap_or(u32::MAX),
      94              :                 ));
      95         4958 :             let deadline_ms = u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX);
      96              :             // One Send unit so the admin replay handler stays Send on wasm32.
      97         4958 :             antares_jsonld::http_interaction(async move {
      98         4958 :                 match antares_jsonld::io_deadline(req.body(bytes).send(), deadline_ms).await {
      99         4706 :                     Some(Ok(r)) if r.status().is_success() => Ok(()),
     100           50 :                     Some(Ok(r)) => Err(DeliveryError::failed(format!(
     101           50 :                         "HTTP {}",
     102           50 :                         r.status().as_u16()
     103           50 :                     ))),
     104          105 :                     Some(Err(e)) => Err(DeliveryError {
     105          105 :                         timed_out: e.is_timeout(),
     106          105 :                         message: crate::redact_userinfo(&e.to_string()),
     107          105 :                     }),
     108            0 :                     None => Err(DeliveryError::timeout("timeout")),
     109              :                 }
     110         4811 :             })
     111         4958 :             .await
     112         4811 :         })
     113         4958 :     }
     114              : }
     115              : 
     116              : /// 6.3.8: the headers one notification POST carries. `application/ld+json`
     117              : /// holds its `@context` in the payload body and takes no Link header; the
     118              : /// other two MIME types carry it in the header. Every `receiverInfo` pair
     119              : /// (and the tenant/snapshot markers the caller appended to it) becomes one
     120              : /// custom header.
     121         4972 : fn headers(out: &Outbound) -> Vec<(String, String)> {
     122         4972 :     let mut h = Vec::with_capacity(out.receiver_info.len() + 2);
     123         4972 :     h.push(("Content-Type".to_owned(), out.accept.clone()));
     124         4972 :     if out.accept != "application/ld+json" {
     125         4954 :         h.push(("Link".to_owned(), out.link.clone()));
     126         4954 :     }
     127              :     // The client APPENDS every header it is handed, so a receiverInfo pair
     128              :     // keyed like one of the two above would travel beside it rather than
     129              :     // replace it, and this clause names the source of both: the MIME type
     130              :     // comes from endpoint.accept, the Link from the served @context. The
     131              :     // colliding pair is dropped, not doubled.
     132         4972 :     let own = h.len();
     133         4972 :     for (k, v) in &out.receiver_info {
     134          424 :         if h[..own].iter().any(|(n, _)| n.eq_ignore_ascii_case(k)) {
     135           12 :             continue;
     136          204 :         }
     137          204 :         h.push((k.clone(), v.clone()));
     138              :     }
     139         4972 :     h
     140         4972 : }
     141              : 
     142              : /// The browser build has no inbound socket to receive notification callbacks
     143              : /// on, so a subscription whose endpoint matches the registered URL prefix is
     144              : /// delivered to page JS instead of the network. Endpoints outside the prefix
     145              : /// still leave via fetch — the Node tier registers nothing and keeps pure
     146              : /// HTTP delivery.
     147              : #[cfg(target_arch = "wasm32")]
     148              : pub mod page_sink {
     149              :     use std::sync::OnceLock;
     150              : 
     151              :     type Sink = Box<dyn Fn(&str, &[u8]) -> bool + Send + Sync>;
     152              :     type Hook = (String, Sink);
     153              :     static HOOK: OnceLock<Hook> = OnceLock::new();
     154              : 
     155              :     /// Register the sink (once per module instance).
     156              :     pub fn set(prefix: String, h: Sink) {
     157              :         let _ = HOOK.set((prefix, h));
     158              :     }
     159              : 
     160              :     /// True when the page sink claimed (and thus delivered) this endpoint.
     161              :     pub fn try_deliver(url: &str, body: &[u8]) -> bool {
     162              :         match HOOK.get() {
     163              :             Some((prefix, h)) if url.starts_with(prefix.as_str()) => h(url, body),
     164              :             _ => false,
     165              :         }
     166              :     }
     167              : }
     168              : 
     169              : #[cfg(test)]
     170              : mod tests {
     171              :     use super::*;
     172              :     use serde_json::json;
     173              : 
     174           14 :     fn out(accept: &str, receiver_info: &[(&str, &str)]) -> Outbound {
     175              :         Outbound {
     176           14 :             body: json!({"type": "Notification"}),
     177           14 :             accept: accept.to_owned(),
     178           14 :             link: "<https://ctx>; rel=\"http://www.w3.org/ns/json-ld#context\"".to_owned(),
     179           14 :             receiver_info: receiver_info
     180           14 :                 .iter()
     181           18 :                 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
     182           14 :                 .collect(),
     183           14 :             notifier_info: Vec::new(),
     184              :         }
     185           14 :     }
     186              : 
     187            8 :     fn sink() -> HttpSink {
     188            8 :         HttpSink::new(
     189            8 :             antares_jsonld::client_builder(antares_jsonld::EgressPolicy {
     190            8 :                 allow_private: true,
     191            8 :                 private_hosts: &[],
     192            8 :             })
     193            8 :             .build()
     194            8 :             .expect("client"),
     195              :         )
     196            8 :     }
     197              : 
     198              :     /// 6.3.8: json and geo+json carry the @context in a Link header,
     199              :     /// ld+json carries it in the body and takes none.
     200              :     #[test]
     201            2 :     fn link_header_follows_the_target_mime_type() {
     202            6 :         let names = |o: &Outbound| {
     203            6 :             headers(o)
     204            6 :                 .into_iter()
     205            6 :                 .map(|(k, _)| k)
     206            6 :                 .collect::<Vec<String>>()
     207            6 :         };
     208            2 :         assert_eq!(
     209            2 :             names(&out("application/json", &[])),
     210              :             ["Content-Type", "Link"]
     211              :         );
     212            2 :         assert_eq!(
     213            2 :             names(&out("application/geo+json", &[])),
     214              :             ["Content-Type", "Link"]
     215              :         );
     216            2 :         assert_eq!(names(&out("application/ld+json", &[])), ["Content-Type"]);
     217            2 :     }
     218              : 
     219              :     /// Every receiverInfo pair becomes one custom header, in order, after
     220              :     /// the binding's own.
     221              :     #[test]
     222            2 :     fn receiver_info_becomes_custom_headers() {
     223            2 :         let h = headers(&out(
     224            2 :             "application/json",
     225            2 :             &[("Authorization", "Bearer t"), ("NGSILD-Tenant", "acme")],
     226            2 :         ));
     227            2 :         assert_eq!(h[0].0, "Content-Type");
     228            2 :         assert_eq!(h[0].1, "application/json");
     229            2 :         assert_eq!(h[2], ("Authorization".to_owned(), "Bearer t".to_owned()));
     230            2 :         assert_eq!(h[3], ("NGSILD-Tenant".to_owned(), "acme".to_owned()));
     231            2 :     }
     232              : 
     233              :     /// 6.3.8 fixes the MIME type of the POST to `endpoint.accept` and, for
     234              :     /// the other two types, mandates the Link header carrying the @context
     235              :     /// reference. The client appends each header it is handed, so a
     236              :     /// receiverInfo pair keyed like either one travels BESIDE the
     237              :     /// binding's: two Content-Type fields make the request malformed (IETF
     238              :     /// RFC 9110 clause 5.5.1 makes it a singleton field) and a second Link
     239              :     /// leaves the receiver two @context references to choose between.
     240              :     #[test]
     241            2 :     fn receiver_info_cannot_double_the_headers_the_binding_owns() {
     242            4 :         for accept in ["application/json", "application/geo+json"] {
     243            4 :             let h = headers(&out(
     244            4 :                 accept,
     245            4 :                 &[
     246            4 :                     ("content-type", "text/plain"),
     247            4 :                     ("LINK", "<https://evil>"),
     248            4 :                     ("X-Kept", "yes"),
     249            4 :                 ],
     250            4 :             ));
     251           20 :             let named = |n: &str| {
     252           20 :                 h.iter()
     253           60 :                     .filter(|(k, _)| k.eq_ignore_ascii_case(n))
     254           20 :                     .collect::<Vec<_>>()
     255           20 :             };
     256            4 :             assert_eq!(named("Content-Type").len(), 1, "{accept}");
     257            4 :             assert_eq!(named("Content-Type")[0].1, accept);
     258            4 :             assert_eq!(named("Link").len(), 1, "{accept}");
     259            4 :             assert!(named("Link")[0].1.contains("json-ld#context"), "{accept}");
     260            4 :             assert_eq!(named("X-Kept").len(), 1, "an ordinary pair still travels");
     261              :         }
     262              :         // ld+json sets no Link of its own, so a pair keyed Link is ordinary
     263            2 :         let h = headers(&out("application/ld+json", &[("Link", "<https://x>")]));
     264            2 :         assert_eq!(
     265            2 :             h.iter()
     266            4 :                 .filter(|(k, _)| k.eq_ignore_ascii_case("Link"))
     267            2 :                 .count(),
     268              :             1
     269              :         );
     270            2 :     }
     271              : 
     272              :     /// 5.2.15 dereferenceable URI: this binding needs an absolute http(s)
     273              :     /// URL with a host.
     274              :     #[test]
     275            2 :     fn endpoint_validation_needs_an_absolute_url_with_a_host() {
     276            2 :         let s = sink();
     277            2 :         assert!(s.parse_endpoint("http://example.org/notify", &[]).is_ok());
     278            2 :         assert!(s.parse_endpoint("https://example.org", &[]).is_ok());
     279            2 :         assert!(s.parse_endpoint("https://u:p@example.org/n", &[]).is_ok());
     280           10 :         for bad in [
     281            2 :             "example.org/notify",
     282            2 :             "http:/notify",
     283            2 :             "ftp://example.org/n",
     284            2 :             "http:///notify",
     285            2 :             "https://@/n",
     286            2 :         ] {
     287           10 :             let err = s.parse_endpoint(bad, &[]).expect_err(bad);
     288           10 :             assert_eq!(err.status(), 400, "{bad}");
     289              :         }
     290            2 :     }
     291              : 
     292              :     /// IETF RFC 3986 3.1: "schemes are case-insensitive ... an implementation
     293              :     /// should accept uppercase letters as equivalent to lowercase in scheme
     294              :     /// names". `SinkRegistry::scheme_of` lowercases before it picks a sink, so
     295              :     /// `HTTP://…` is routed HERE — and this is the layer that decides, so a
     296              :     /// case-sensitive check turns a legal endpoint URI into a 400 the
     297              :     /// registry's own contract says it should not be.
     298              :     #[test]
     299            2 :     fn the_scheme_is_matched_case_insensitively() {
     300            2 :         let s = sink();
     301            6 :         for uri in [
     302            2 :             "HTTP://example.org/notify",
     303            2 :             "HTTPS://example.org/n",
     304            2 :             "HttP://example.org/n",
     305            2 :         ] {
     306            6 :             assert!(s.parse_endpoint(uri, &[]).is_ok(), "{uri}");
     307              :         }
     308            2 :     }
     309              : 
     310              :     /// The HTTP binding hands a DRIVER ERROR to `redact_userinfo`, not a bare
     311              :     /// URI: reqwest prints the failing URL and `url::Url`'s Display serializes
     312              :     /// the password with it. Every message shape the driver produces has to
     313              :     /// lose the credential.
     314              :     #[test]
     315            2 :     fn a_driver_error_carrying_the_url_loses_the_password() {
     316            8 :         for msg in [
     317            2 :             "error sending request for url (https://alice:s3cret@host/notify)",
     318            2 :             "error sending request for url (https://alice:s3cret@host)",
     319            2 :             "error sending request for url (https://alice:s3cret@host): error trying to connect",
     320            2 :             "error sending request for url (https://alice:s3cret@host/n): dns error: no such host",
     321            2 :         ] {
     322            8 :             let red = crate::redact_userinfo(msg);
     323            8 :             assert!(!red.contains("s3cret"), "{msg} -> {red}");
     324            8 :             assert!(!red.contains("alice"), "{msg} -> {red}");
     325            8 :             assert!(
     326            8 :                 red.contains("host"),
     327              :                 "the destination stays readable: {red}"
     328              :             );
     329              :         }
     330            2 :     }
     331              : 
     332              :     /// A rejected endpoint travels back to the client in `detail` (5.5.3)
     333              :     /// and into the logs: the userinfo credentials never ride along.
     334              :     #[test]
     335            2 :     fn rejection_message_carries_no_credentials() {
     336            2 :         let err = sink()
     337            2 :             .parse_endpoint("ftp://user:hunter2@example.org/n", &[])
     338            2 :             .expect_err("not http");
     339            2 :         let text = format!("{err}");
     340            2 :         assert!(!text.contains("hunter2"), "{text}");
     341            2 :         assert!(text.contains("example.org"), "{text}");
     342            2 :     }
     343              : 
     344              :     #[test]
     345            2 :     fn serves_exactly_the_two_http_schemes() {
     346            2 :         assert_eq!(sink().schemes(), &["http", "https"]);
     347            2 :     }
     348              : }
        

Generated by: LCOV version 2.0-1