LCOV - code coverage report
Current view: top level - antares-api/src - bounds.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.4 % 356 354
Test Date: 2026-09-21 10:31:06 Functions: 58.4 % 149 87

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Input bounds wall: every request-shaped resource has
       3              : //! a configured cap, rejected with the spec-shaped error. One middleware
       4              : //! enforces the transport-level caps (URI length 414, body size 413, JSON
       5              : //! nesting 400) — size and depth are checked BEFORE any parse. The
       6              : //! per-feature caps (batch count, joinLevel, @context fetch
       7              : //! count, q= complexity, result ceiling) live at their parse points.
       8              : //! Rejections are counted and exported via /q/health.
       9              : 
      10              : use axum::body::{Body, Bytes};
      11              : use axum::http::{Request, StatusCode};
      12              : use axum::middleware::Next;
      13              : use axum::response::{IntoResponse, Response};
      14              : use std::sync::atomic::{AtomicU64, Ordering};
      15              : 
      16              : /// Hard caps (v1: compile-time constants — a config file is a later knob;
      17              : /// every value is spec-shaped on rejection).
      18              : /// → bare 413 (6.3.4). Deployment knob (ANTARES_MAX_BODY_BYTES): the spec
      19              : /// names no ceiling; 4 MiB is the DoS bound, raised where a trusted
      20              : /// producer legitimately sends bigger batches. Read once at first use.
      21          210 : pub static MAX_BODY_BYTES: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
      22          210 :     std::env::var("ANTARES_MAX_BODY_BYTES")
      23          210 :         .ok()
      24          210 :         .and_then(|v| v.parse().ok())
      25          210 :         .filter(|n| *n > 0)
      26          210 :         .unwrap_or(4 * 1024 * 1024)
      27          210 : });
      28              : pub const MAX_URI_BYTES: usize = 8 * 1024; // → bare 414
      29              : pub const MAX_JSON_DEPTH: usize = 64; // → 400 BadRequestData
      30              : /// → 400 BadRequestData. Maximum coordinate positions in a QUERY geometry
      31              : /// (4.10 geoQ, 4.23 ordering reference). The spec sets no ceiling, and the
      32              : /// geometry is not bounded by the URI length on the POST query path — the
      33              : /// body carries it. Every position is an edge the DE-9IM relate walks once
      34              : /// per candidate entity, so the work a single request can buy is capped
      35              : /// here: 1024 positions describe an administrative boundary at street
      36              : /// resolution, and are already more than the 8 KiB URI ceiling can carry.
      37              : pub use antares_ql::geo::MAX_GEO_VERTICES;
      38              : /// → 400 BadRequestData. Deployment knob (ANTARES_MAX_BATCH_ITEMS): the
      39              : /// spec sets no batch ceiling — 1000 is this broker's DoS-bounds default,
      40              : /// raised where a trusted producer legitimately batches larger (e.g. a
      41              : /// full-fleet upsert). Read once at first use.
      42           54 : pub static MAX_BATCH_ITEMS: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
      43           54 :     std::env::var("ANTARES_MAX_BATCH_ITEMS")
      44           54 :         .ok()
      45           54 :         .and_then(|v| v.parse().ok())
      46           54 :         .filter(|n| *n > 0)
      47           54 :         .unwrap_or(1_000)
      48           54 : });
      49              : // Deployment knob (ANTARES_MAX_FED_RESPONSE_BYTES): ceiling on one forwarded
      50              : // (4.3.6) response body. The spec sets no ceiling; an over-cap peer part
      51              : // fails like an unparseable payload (Table 6.3.17-1, warning 111) instead of
      52              : // ballooning broker memory — one misbehaving peer must not break the
      53              : // 500 MB RSS budget. Read once at first use.
      54           74 : pub static MAX_FED_RESPONSE_BYTES: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
      55           74 :     std::env::var("ANTARES_MAX_FED_RESPONSE_BYTES")
      56           74 :         .ok()
      57           74 :         .and_then(|v| v.parse().ok())
      58           74 :         .filter(|n| *n > 0)
      59           74 :         .unwrap_or(16 * 1024 * 1024)
      60           74 : });
      61              : // Deployment knob (ANTARES_FED_INFLIGHT): forwarded requests in flight for
      62              : // the whole process. Per-request fan-out is bounded below; across requests
      63              : // nothing was, and 6 000 open federated queries × 34 sources each held
      64              : // 7.7 GB of buffers and connections. Callers over the cap wait their turn.
      65           76 : pub static MAX_FED_INFLIGHT: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
      66           76 :     std::env::var("ANTARES_FED_INFLIGHT")
      67           76 :         .ok()
      68           76 :         .and_then(|v| v.parse().ok())
      69           76 :         .filter(|n| *n > 0)
      70           76 :         .unwrap_or(256)
      71           76 : });
      72              : pub static FED_INFLIGHT: std::sync::LazyLock<tokio::sync::Semaphore> =
      73           58 :     std::sync::LazyLock::new(|| tokio::sync::Semaphore::new(*MAX_FED_INFLIGHT));
      74              : // Deployment knob (ANTARES_FED_FANOUT): concurrent forwards per distributed
      75              : // read. 4.3.6.1 orders the MERGE (4.5.5), never the requests, so forwards
      76              : // run concurrently; this bounds how many at once per request.
      77          148 : pub static MAX_FED_FANOUT: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
      78          148 :     std::env::var("ANTARES_FED_FANOUT")
      79          148 :         .ok()
      80          148 :         .and_then(|v| v.parse().ok())
      81          148 :         .filter(|n| *n > 0)
      82          148 :         .unwrap_or(8)
      83          148 : });
      84              : /// 6.3.17: `NGSILD-Warning` values relayed from ONE Context Source. The
      85              : /// clause makes a peer's warnings part of this broker's answer (4.3.6.4 puts
      86              : /// a deeper hop's abnormality on the response that survived it), so the list
      87              : /// is written by the peer but sent by this broker to a client that never
      88              : /// addressed it. Table 6.3.17-1 defines four codes and a cascade adds a few
      89              : /// per hop, so eight carries a real cascade; past it a source would make the
      90              : /// response grow faster than the fan-out does, and would crowd out the
      91              : /// warnings the clause obliges this broker to raise about the other sources.
      92              : pub const MAX_PEER_WARNINGS: usize = 8;
      93              : 
      94              : /// → 500 InternalError. How many [`crate::AppState::call`] frames one
      95              : /// request may be inside. The in-process handle is the façade seam, and a
      96              : /// façade legitimately calls the broker while another façade legitimately
      97              : /// calls it, so the ceiling is a depth rather than a refusal of the second
      98              : /// call. Without one a `/x/` route that translates into a request its own
      99              : /// surface serves recurses until the stack ends, and each frame builds a
     100              : /// router of its own. Eight carries a façade over a façade over the broker
     101              : /// several times and still ends the loop in milliseconds, an order of
     102              : /// magnitude below what ends the process: with the guard removed, a route
     103              : /// asking for thirty-two hops overflows a 2 MiB thread stack in a debug
     104              : /// build. The count is per task — work a handler spawns starts a new chain,
     105              : /// which is right, since a notification is not inside the request that
     106              : /// caused it.
     107              : pub const MAX_IN_PROCESS_CALL_DEPTH: usize = 8;
     108              : 
     109              : pub const MAX_JOIN_LEVEL: usize = 10; // → 400 BadRequestData
     110              : /// → 400 BadRequestData. Documents one @context resolution may fetch, owned
     111              : /// by the loader that enforces it (`antares_jsonld`), and the ceiling on how
     112              : /// many DISTINCT @contexts one batch may name — without the second, the item
     113              : /// count multiplies the first.
     114              : pub use antares_jsonld::MAX_CONTEXT_URLS as MAX_CONTEXT_FETCHES;
     115              : /// Linked-entity lookup budget per `q=`, owned by the shared evaluator.
     116              : pub use antares_ql::eval::MAX_Q_LINK_LOOKUPS;
     117              : /// Regex compile ceiling and retention caps, owned by the shared cache
     118              : /// (`antares_ql::regex`).
     119              : pub use antares_ql::regex::{MAX_REGEX_CACHE, MAX_REGEX_CACHE_BYTES, MAX_REGEX_PROGRAM_BYTES};
     120              : /// → 403 TooComplexQuery. The AST size cap, owned by the parser that
     121              : /// enforces it (`antares_ql::parse_q`).
     122              : pub use antares_ql::MAX_Q_NODES;
     123              : 
     124              : /// → 403 TooManyResults (5.5.6). Deployment knob
     125              : /// (ANTARES_DISCOVERY_SCAN_MAX): documents ONE unpaginated whole-tenant fold
     126              : /// may read or hold. The folds that carry it are the ones the spec gives no
     127              : /// window to push into the store: `/types` and `/attributes` (5.7.5-5.7.10
     128              : /// define no pagination) and the registration query (5.10.2.4 filters first
     129              : /// and pages second, so the page cannot be pushed down). Without it one
     130              : /// request over a tenant at the 100 000-registration target holds every match
     131              : /// at once. Read once at first use.
     132           38 : pub static MAX_FOLD_DOCS: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
     133           38 :     std::env::var("ANTARES_DISCOVERY_SCAN_MAX")
     134           38 :         .ok()
     135           38 :         .and_then(|v| v.parse().ok())
     136           38 :         .filter(|n| *n > 0)
     137           38 :         .unwrap_or(100_000)
     138           38 : });
     139              : 
     140              : /// The ceilings on the notification pipeline. Not input bounds: no request
     141              : /// names them and none is rejected against them. They are published beside
     142              : /// the input caps for the same reason the regex-cache caps above are — an
     143              : /// operator reading `/q/health` has to be able to tell a ceiling from a
     144              : /// coincidence, and reaching one of these is what a dropped change or a
     145              : /// stalled fan-out looks like from outside.
     146              : ///
     147              : /// Depth of the change→matcher ring, the same size the local bus uses. A
     148              : /// full ring drops the batch and counts it
     149              : /// (`antares_notification_changes_dropped_total`), so this number is the
     150              : /// back-pressure a deployment has before delivery loss starts.
     151              : pub const CHANGE_QUEUE: usize = 1024;
     152              : /// Notifications in flight at once per drain: one serial POST at a time
     153              : /// capped a 9-subscription fan-out at ~600 POST/s and overflowed the ring.
     154              : /// Deployment knob (ANTARES_DELIVERY_WIDTH): what a width is worth is a
     155              : /// property of the subscribers, not of this broker — a slot is held for as
     156              : /// long as the endpoint takes to answer, so a fleet of local sinks and a
     157              : /// fleet of remote endpoints sitting at their 30 s timeout (Table 5.2.15-1)
     158              : /// are served by different numbers, and the one that fits is measured
     159              : /// against a deployment rather than compiled in. Read once at first use.
     160           66 : pub static DELIVERY_WIDTH: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
     161           66 :     count_from(
     162           66 :         std::env::var("ANTARES_DELIVERY_WIDTH").ok().as_deref(),
     163              :         64,
     164              :         usize::MAX,
     165              :     )
     166           66 : });
     167              : /// Of that width, what one tenant may hold. A Subscription belongs to one
     168              : /// tenant (5.2.12), and a delivery to an endpoint that accepts and never
     169              : /// answers holds its slot for the endpoint's whole timeout — up to 30 s
     170              : /// (Table 5.2.15-1). Sharing the width with no per-tenant bound, a single
     171              : /// tenant with enough dead endpoints holds every slot and nothing leaves the
     172              : /// broker for anyone else. The share is a fraction of the width and not a
     173              : /// fair split of it: a tenant delivering alone still gets several slots, and
     174              : /// eight of them is what a full width of 64 divides into before the
     175              : /// per-tenant queue becomes the bottleneck for an ordinary fan-out.
     176              : /// Deployment knob (ANTARES_DELIVERY_WIDTH_PER_TENANT), ceilinged at the
     177              : /// width above: a share larger than the width is not a share, and
     178              : /// publishing one would name a ceiling that can never fire. Read once at
     179              : /// first use.
     180           66 : pub static DELIVERY_WIDTH_PER_TENANT: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
     181           66 :     count_from(
     182           66 :         std::env::var("ANTARES_DELIVERY_WIDTH_PER_TENANT")
     183           66 :             .ok()
     184           66 :             .as_deref(),
     185              :         8,
     186           66 :         *DELIVERY_WIDTH,
     187              :     )
     188           66 : });
     189              : 
     190              : /// One configured count: the default stands in for anything unset,
     191              : /// unparseable or zero — a zero would mint a semaphore that admits nobody
     192              : /// and stop delivery altogether — and the ceiling bounds what a deployment
     193              : /// can ask for where a larger number would be meaningless.
     194          164 : fn count_from(raw: Option<&str>, default: usize, ceiling: usize) -> usize {
     195          164 :     raw.and_then(|v| v.parse().ok())
     196          164 :         .filter(|n| *n > 0)
     197          164 :         .unwrap_or(default)
     198          164 :         .min(ceiling)
     199          164 : }
     200              : /// Destinations and registrations the egress breaker tracks. Both maps are
     201              : /// keyed by client-supplied strings, so they need a bound: at the ceiling the
     202              : /// least recently recorded entry is dropped, which costs at most a forgotten
     203              : /// failure count for a destination nobody has touched in a while.
     204              : pub const MAX_TRACKED_DESTINATIONS: usize = 4096;
     205              : 
     206              : /// Rejection counters, exported by /q/health.
     207              : #[derive(Default)]
     208              : pub struct LimitStats {
     209              :     pub uri_too_long: AtomicU64,
     210              :     pub body_too_large: AtomicU64,
     211              :     pub body_too_deep: AtomicU64,
     212              : }
     213              : 
     214              : impl LimitStats {
     215          112 :     pub fn snapshot(&self) -> serde_json::Value {
     216          112 :         serde_json::json!({
     217          112 :             "maxBodyBytes": *MAX_BODY_BYTES,
     218          112 :             "maxUriBytes": MAX_URI_BYTES,
     219          112 :             "maxJsonDepth": MAX_JSON_DEPTH,
     220          112 :             "maxGeoVertices": MAX_GEO_VERTICES,
     221          112 :             "maxBatchItems": *MAX_BATCH_ITEMS,
     222          112 :             "maxFoldDocs": *MAX_FOLD_DOCS,
     223          112 :             "maxFedResponseBytes": *MAX_FED_RESPONSE_BYTES,
     224          112 :             "maxFedFanout": *MAX_FED_FANOUT,
     225          112 :             "maxFedInflight": *MAX_FED_INFLIGHT,
     226          112 :             "maxJoinLevel": MAX_JOIN_LEVEL,
     227          112 :             "maxInProcessCallDepth": MAX_IN_PROCESS_CALL_DEPTH,
     228          112 :             "maxPeerWarnings": MAX_PEER_WARNINGS,
     229          112 :             "maxContextFetches": MAX_CONTEXT_FETCHES,
     230          112 :             "maxQNodes": MAX_Q_NODES,
     231          112 :             "maxQLinkLookups": MAX_Q_LINK_LOOKUPS,
     232          112 :             "maxRegexCache": MAX_REGEX_CACHE,
     233          112 :             "maxRegexCacheBytes": MAX_REGEX_CACHE_BYTES,
     234          112 :             "maxRegexProgramBytes": MAX_REGEX_PROGRAM_BYTES,
     235          112 :             "changeQueue": CHANGE_QUEUE,
     236          112 :             "deliveryWidth": *DELIVERY_WIDTH,
     237          112 :             "deliveryWidthPerTenant": *DELIVERY_WIDTH_PER_TENANT,
     238          112 :             "maxTrackedDestinations": MAX_TRACKED_DESTINATIONS,
     239          112 :             "rejectedUriTooLong": self.uri_too_long.load(Ordering::Relaxed),
     240          112 :             "rejectedBodyTooLarge": self.body_too_large.load(Ordering::Relaxed),
     241          112 :             "rejectedBodyTooDeep": self.body_too_deep.load(Ordering::Relaxed),
     242              :         })
     243          112 :     }
     244              : }
     245              : 
     246              : /// Maximum brace/bracket nesting of a JSON byte stream, string-aware.
     247              : /// A scan, not a parse — depth is checked before serde ever runs.
     248        22964 : pub(crate) fn json_depth(bytes: &[u8]) -> usize {
     249        22964 :     let (mut depth, mut max, mut in_str, mut esc) = (0usize, 0usize, false, false);
     250      7816638 :     for &b in bytes {
     251      7816638 :         if in_str {
     252      4335602 :             if esc {
     253          566 :                 esc = false;
     254      4335036 :             } else if b == b'\\' {
     255          566 :                 esc = true;
     256      4334470 :             } else if b == b'"' {
     257       325998 :                 in_str = false;
     258      4008472 :             }
     259      4335602 :             continue;
     260      3481036 :         }
     261      3481036 :         match b {
     262       325998 :             b'"' => in_str = true,
     263       893454 :             b'{' | b'[' => {
     264       893454 :                 depth += 1;
     265       893454 :                 max = max.max(depth);
     266       893454 :             }
     267       893474 :             b'}' | b']' => depth = depth.saturating_sub(1),
     268      1368110 :             _ => {}
     269              :         }
     270              :     }
     271        22964 :     max
     272        22964 : }
     273              : 
     274        26786 : pub(crate) async fn bounds_layer(
     275        26786 :     axum::extract::State(st): axum::extract::State<crate::AppState>,
     276        26786 :     req: Request<Body>,
     277        26786 :     next: Next,
     278        26786 : ) -> Response {
     279        26786 :     if req.uri().to_string().len() > MAX_URI_BYTES {
     280           18 :         st.limits.uri_too_long.fetch_add(1, Ordering::Relaxed);
     281           18 :         return StatusCode::URI_TOO_LONG.into_response(); // bare, like 6.3.4
     282        26768 :     }
     283              :     // 6.3.4: "For HTTP POST, PATCH and PUT HTTP requests implementations shall
     284              :     // check … Content-Length header shall include the length of the request
     285              :     // payload body", and its absence "shall result in just a 411 HTTP status
     286              :     // code (without any payload body)" — restated in 6.3.2. Scoped to HTTP/1.x:
     287              :     // HTTP/2 and later carry length in the framing layer and legitimately omit
     288              :     // the header, so demanding it there would reject conformant clients.
     289              :     // The clause grants NO exemption for `Transfer-Encoding: chunked` — a
     290              :     // chunked POST without Content-Length is exactly the case 411 covers, so it
     291              :     // is deliberately not carved out here.
     292              :     //
     293              :     // The ONE deviation, made explicit rather than implied: the check is scoped
     294              :     // to HTTP/1.x. 6.3.4 is written against RFC 7230/7231 and predates any h2
     295              :     // consideration; HTTP/2 carries length in its framing and conformant h2
     296              :     // clients routinely omit the header, so applying it there would reject
     297              :     // requests the spec never meant to describe. Recorded in docs/ics.yaml.
     298        26768 :     if matches!(req.method().as_str(), "POST" | "PATCH" | "PUT")
     299        16434 :         && req.version() <= axum::http::Version::HTTP_11
     300        16434 :         && !req
     301        16434 :             .headers()
     302        16434 :             .contains_key(axum::http::header::CONTENT_LENGTH)
     303              :     {
     304           32 :         return StatusCode::LENGTH_REQUIRED.into_response(); // bare 411
     305        26736 :     }
     306        26736 :     let has_body = matches!(req.method().as_str(), "POST" | "PATCH" | "PUT" | "DELETE");
     307        26736 :     if !has_body {
     308         4062 :         return next.run(req).await;
     309        22674 :     }
     310        22674 :     let (parts, body) = req.into_parts();
     311              :     // What the client says it is sending. The read below still decides — a
     312              :     // declared length is a claim, and answering on the claim alone would cut
     313              :     // the client off mid-body, where the RST that follows can take the
     314              :     // response with it.
     315        22674 :     let fits_its_claim = parts
     316        22674 :         .headers
     317        22674 :         .get(axum::http::header::CONTENT_LENGTH)
     318        22674 :         .and_then(|v| v.to_str().ok())
     319        22674 :         .and_then(|v| v.parse::<usize>().ok())
     320        22674 :         .is_some_and(|n| n <= *MAX_BODY_BYTES);
     321        22674 :     let bytes: Bytes = match axum::body::to_bytes(body, *MAX_BODY_BYTES).await {
     322        22656 :         Ok(b) => b,
     323              :         // `to_bytes` reports the length limit and any transport failure the
     324              :         // same way. A body whose declared length fits the cap cannot have
     325              :         // exceeded it, so what failed was the delivery: that is a bad request
     326              :         // and NOT a size rejection — counting it as one would make
     327              :         // `rejectedBodyTooLarge` read client aborts as clients hitting the
     328              :         // cap. A body that declared more than the cap, or declared nothing
     329              :         // (chunked), can only have hit the cap.
     330            4 :         Err(_) if fits_its_claim => {
     331            4 :             return crate::negotiate::ApiError::from(antares_model::NgsiError::InvalidRequest(
     332            4 :                 "request body was not delivered completely".into(),
     333            4 :             ))
     334            4 :             .into_response();
     335              :         }
     336              :         Err(_) => {
     337           14 :             st.limits.body_too_large.fetch_add(1, Ordering::Relaxed);
     338           14 :             return StatusCode::PAYLOAD_TOO_LARGE.into_response(); // bare 413
     339              :         }
     340              :     };
     341              :     // An absent (or unreadable) Content-Type is parsed as JSON downstream —
     342              :     // 6.3.4 mandates Content-Length, not Content-Type — so it is scanned
     343              :     // here too, or the nesting cap has a hole exactly where the parser has
     344              :     // none. A header that names another media type keeps its 415.
     345        22656 :     let is_json = parts
     346        22656 :         .headers
     347        22656 :         .get(axum::http::header::CONTENT_TYPE)
     348        22656 :         .is_none_or(|v| match v.to_str() {
     349        16260 :             Ok(ct) => ct.contains("json"),
     350              :             // A header the parser cannot read is not a header naming another
     351              :             // media type: `negotiate::content_type` reports it as the empty
     352              :             // string, which every route that tolerates an absent
     353              :             // Content-Type reads as absent and parses. Scanning it is what
     354              :             // keeps the cap ahead of the parser on those routes.
     355            6 :             Err(_) => true,
     356        16266 :         });
     357        22656 :     if is_json && json_depth(&bytes) > MAX_JSON_DEPTH {
     358           16 :         st.limits.body_too_deep.fetch_add(1, Ordering::Relaxed);
     359           16 :         return crate::negotiate::ApiError::from(antares_model::NgsiError::BadRequestData(
     360           16 :             format!("JSON nesting exceeds the {MAX_JSON_DEPTH}-level limit"),
     361           16 :         ))
     362           16 :         .into_response();
     363        22640 :     }
     364        22640 :     next.run(Request::from_parts(parts, Body::from(bytes)))
     365        22640 :         .await
     366        26786 : }
     367              : 
     368              : #[cfg(test)]
     369              : mod tests {
     370              :     use super::*;
     371              : 
     372              :     /// The scan is a bound, not a parser: unbalanced closers must not
     373              :     /// underflow, and the value it reports is the one the middleware compares
     374              :     /// against MAX_JSON_DEPTH, so the accept/reject boundary is exact.
     375              :     #[test]
     376            4 :     fn depth_scan_survives_unbalanced_and_boundary_input() {
     377            4 :         assert_eq!(json_depth(b"]]]]"), 0, "stray closers must not underflow");
     378            4 :         assert_eq!(json_depth(b"}}}{"), 1);
     379            4 :         assert_eq!(json_depth(b""), 0);
     380            4 :         assert_eq!(
     381            4 :             json_depth(br#""{{{{""#),
     382              :             0,
     383              :             "a bare string carries no depth"
     384              :         );
     385            4 :         assert_eq!(
     386            4 :             json_depth(br#"{"a": "\\"}"#),
     387              :             1,
     388              :             "an escaped backslash ends the escape"
     389              :         );
     390            4 :         let at_cap = "[".repeat(MAX_JSON_DEPTH) + &"]".repeat(MAX_JSON_DEPTH);
     391            4 :         assert_eq!(json_depth(at_cap.as_bytes()), MAX_JSON_DEPTH);
     392            4 :         assert!(
     393            4 :             json_depth(at_cap.as_bytes()) <= MAX_JSON_DEPTH,
     394              :             "exactly at the cap is accepted"
     395              :         );
     396            4 :         let over = "[".repeat(MAX_JSON_DEPTH + 1) + &"]".repeat(MAX_JSON_DEPTH + 1);
     397            4 :         assert!(
     398            4 :             json_depth(over.as_bytes()) > MAX_JSON_DEPTH,
     399              :             "one over is rejected"
     400              :         );
     401            4 :     }
     402              : 
     403              :     /// A cap `/q/health` publishes is the cap that fires: an OR chain of
     404              :     /// `MAX_Q_NODES - 1` terms (the chain node plus its terms) parses, one
     405              :     /// term more is 5.5.6 TooComplexQuery.
     406              :     #[test]
     407            4 :     fn the_published_q_node_cap_is_the_enforced_one() {
     408            8 :         let chain = |k: usize| vec!["a==1"; k].join("|");
     409            4 :         assert!(antares_ql::parse_q(&chain(MAX_Q_NODES - 1)).is_ok());
     410            4 :         assert!(matches!(
     411            4 :             antares_ql::parse_q(&chain(MAX_Q_NODES)),
     412              :             Err(antares_model::NgsiError::TooComplexQuery(_))
     413              :         ));
     414            4 :     }
     415              : 
     416              :     /// Every cap belongs in `/q/health` AND in the operator's chapter: the
     417              :     /// admin-API chapter prints the whole `limits` object as the answer a
     418              :     /// memory-store broker gives, and an operator reads a bound from there.
     419              :     /// The test above pins the key set in code; nothing pinned the chapter,
     420              :     /// and `maxFedInflight` was published for a release without ever being
     421              :     /// written down. A cap added here now fails until the chapter has it.
     422              :     #[test]
     423            4 :     fn the_documented_health_limits_are_the_published_ones() {
     424            4 :         let book = std::fs::read_to_string(concat!(
     425              :             env!("CARGO_MANIFEST_DIR"),
     426              :             "/../../docs/src/admin-api.md"
     427              :         ))
     428            4 :         .expect("the admin API chapter");
     429            4 :         let block = book
     430            4 :             .split_once("\"limits\": {")
     431            4 :             .expect("the limits object in the sample response")
     432            4 :             .1;
     433            4 :         let block = block.split_once('}').expect("the end of the object").0;
     434            4 :         let mut documented: Vec<&str> = block
     435            4 :             .lines()
     436          108 :             .filter_map(|l| l.trim().strip_prefix('"'))
     437          100 :             .filter_map(|l| l.split_once('"'))
     438            4 :             .map(|(k, _)| k)
     439            4 :             .collect();
     440            4 :         documented.sort_unstable();
     441            4 :         let snap = LimitStats::default().snapshot();
     442            4 :         let mut published: Vec<&str> = snap
     443            4 :             .as_object()
     444            4 :             .expect("object")
     445            4 :             .keys()
     446            4 :             .map(String::as_str)
     447            4 :             .collect();
     448            4 :         published.sort_unstable();
     449            4 :         assert_eq!(
     450              :             documented, published,
     451              :             "the admin API chapter's limits object is not the one /q/health answers"
     452              :         );
     453            4 :     }
     454              : 
     455              :     /// /q/health publishes the caps and the rejection counters — and nothing
     456              :     /// else: no configuration paths, no environment variable values, no
     457              :     /// internal error text.
     458              :     #[test]
     459            4 :     fn health_snapshot_reports_the_caps_and_nothing_internal() {
     460            4 :         let stats = LimitStats::default();
     461            4 :         stats.uri_too_long.fetch_add(3, Ordering::Relaxed);
     462            4 :         let snap = stats.snapshot();
     463            4 :         let obj = snap.as_object().expect("object");
     464            4 :         let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
     465            4 :         keys.sort_unstable();
     466            4 :         assert_eq!(
     467              :             keys,
     468              :             [
     469              :                 "changeQueue",
     470              :                 "deliveryWidth",
     471              :                 "deliveryWidthPerTenant",
     472              :                 "maxBatchItems",
     473              :                 "maxBodyBytes",
     474              :                 "maxContextFetches",
     475              :                 "maxFedFanout",
     476              :                 "maxFedInflight",
     477              :                 "maxFedResponseBytes",
     478              :                 "maxFoldDocs",
     479              :                 "maxGeoVertices",
     480              :                 "maxInProcessCallDepth",
     481              :                 "maxJoinLevel",
     482              :                 "maxJsonDepth",
     483              :                 "maxPeerWarnings",
     484              :                 "maxQLinkLookups",
     485              :                 "maxQNodes",
     486              :                 "maxRegexCache",
     487              :                 "maxRegexCacheBytes",
     488              :                 "maxRegexProgramBytes",
     489              :                 "maxTrackedDestinations",
     490              :                 "maxUriBytes",
     491              :                 "rejectedBodyTooDeep",
     492              :                 "rejectedBodyTooLarge",
     493              :                 "rejectedUriTooLong",
     494              :             ],
     495              :             "no member beyond the caps, the pipeline ceilings and the counters"
     496              :         );
     497            4 :         assert_eq!(snap["rejectedUriTooLong"], 3);
     498            4 :         assert!(
     499          100 :             obj.values().all(|v| v.is_number()),
     500              :             "every member is a number — no strings to leak paths through"
     501              :         );
     502            4 :     }
     503              : 
     504              :     /// The nesting cap is checked BEFORE any parse — including on the path
     505              :     /// that carries no Content-Type header at all, which the body parser
     506              :     /// accepts and parses as JSON (6.3.4 only mandates Content-Length).
     507              :     /// An unparseable Content-Type follows the same rule.
     508              :     #[tokio::test]
     509            4 :     async fn over_depth_body_without_content_type_is_still_rejected() {
     510              :         use tower::ServiceExt;
     511            4 :         let st = crate::AppState::new("http://localhost:0".into());
     512            4 :         let app = axum::Router::new()
     513            4 :             .route(
     514            4 :                 "/x",
     515            4 :                 axum::routing::post(|| async { StatusCode::NO_CONTENT }),
     516              :             )
     517            4 :             .layer(axum::middleware::from_fn_with_state(st, bounds_layer));
     518            4 :         let deep = "[".repeat(MAX_JSON_DEPTH + 5) + &"]".repeat(MAX_JSON_DEPTH + 5);
     519              :         // The third case is the one a header map can hold and `to_str`
     520              :         // cannot read: a Content-Type carrying a byte outside UTF-8. Every
     521              :         // route reads it as an absent Content-Type, so the scan must too.
     522            4 :         let unreadable = axum::http::HeaderValue::from_bytes(b"application/\xffjson")
     523            4 :             .expect("header value from raw bytes");
     524            4 :         assert!(
     525            4 :             unreadable.to_str().is_err(),
     526              :             "the case under test is a header value that cannot be read as text"
     527              :         );
     528           12 :         for ct in [
     529            4 :             None,
     530            4 :             Some(axum::http::HeaderValue::from_static("application/json")),
     531            4 :             Some(unreadable),
     532            4 :         ] {
     533           12 :             let mut req = Request::post("/x")
     534           12 :                 .header(axum::http::header::CONTENT_LENGTH, deep.len().to_string());
     535           12 :             if let Some(ct) = ct.clone() {
     536            8 :                 req = req.header(axum::http::header::CONTENT_TYPE, ct);
     537            8 :             }
     538           12 :             let resp = app
     539           12 :                 .clone()
     540           12 :                 .oneshot(req.body(Body::from(deep.clone())).expect("req"))
     541           12 :                 .await
     542           12 :                 .expect("resp");
     543           12 :             assert_eq!(
     544           12 :                 resp.status(),
     545            4 :                 StatusCode::BAD_REQUEST,
     546            4 :                 "an over-deep body must not reach the handler (content-type {ct:?})"
     547            4 :             );
     548            4 :         }
     549            4 :     }
     550              : 
     551              :     /// 6.3.4's 413 is about size, and `/q/health` publishes how often it
     552              :     /// fired. A body the client abandoned mid-flight is a different event: it
     553              :     /// is not over the cap, it must not be counted as one, and a declared
     554              :     /// length over the cap must be refused before the broker buffers a byte
     555              :     /// of it.
     556              :     #[tokio::test]
     557            4 :     async fn a_broken_body_is_not_an_over_cap_body() {
     558              :         use tower::ServiceExt;
     559            4 :         let st = crate::AppState::new("http://localhost:0".into());
     560            4 :         let app = axum::Router::new()
     561            4 :             .route(
     562            4 :                 "/x",
     563            4 :                 axum::routing::post(|| async { StatusCode::NO_CONTENT }),
     564              :             )
     565            4 :             .layer(axum::middleware::from_fn_with_state(
     566            4 :                 st.clone(),
     567              :                 bounds_layer,
     568              :             ));
     569              : 
     570              :         // the transport gave up: a declared length the body never delivers
     571            4 :         let broken = Body::from_stream(futures_util::stream::once(async {
     572            4 :             Err::<axum::body::Bytes, std::io::Error>(std::io::Error::other("reset"))
     573            4 :         }));
     574            4 :         let resp = app
     575            4 :             .clone()
     576            4 :             .oneshot(
     577            4 :                 Request::post("/x")
     578            4 :                     .header(axum::http::header::CONTENT_LENGTH, "100")
     579            4 :                     .body(broken)
     580            4 :                     .expect("req"),
     581            4 :             )
     582            4 :             .await
     583            4 :             .expect("resp");
     584            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
     585            4 :         assert_eq!(
     586            4 :             st.limits.body_too_large.load(Ordering::Relaxed),
     587              :             0,
     588              :             "a client abort is not a size rejection"
     589              :         );
     590              : 
     591              :         // over the cap for real: 6.3.4's 413, counted
     592            4 :         let over = vec![b'x'; *MAX_BODY_BYTES + 1];
     593            4 :         let resp = app
     594            4 :             .oneshot(
     595            4 :                 Request::post("/x")
     596            4 :                     .header(axum::http::header::CONTENT_LENGTH, over.len().to_string())
     597            4 :                     .body(Body::from(over))
     598            4 :                     .expect("req"),
     599            4 :             )
     600            4 :             .await
     601            4 :             .expect("resp");
     602            4 :         assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
     603            4 :         assert_eq!(st.limits.body_too_large.load(Ordering::Relaxed), 1);
     604            4 :     }
     605              : 
     606              :     /// Every cap this module declares is in the payload `/q/health` serves.
     607              :     /// A cap an operator cannot read is one they cannot tell from a
     608              :     /// coincidence when a request is refused or a change is dropped, so the
     609              :     /// list is read out of this file's own source rather than kept by hand:
     610              :     /// a cap added below without a member above fails here.
     611              :     #[test]
     612            4 :     fn every_declared_cap_is_published() {
     613            4 :         let src = include_str!("bounds.rs");
     614            4 :         let published = LimitStats::default().snapshot();
     615            4 :         let published = published.as_object().expect("an object");
     616            4 :         let mut missing = Vec::new();
     617         2872 :         for line in src.lines() {
     618         2872 :             let line = line.trim_start();
     619              :             // `pub use` re-exports name their cap in another crate; the
     620              :             // declarations here are the ones this file owns.
     621         5744 :             for kw in ["pub const ", "pub static "] {
     622         5744 :                 let Some(rest) = line.strip_prefix(kw) else {
     623         5680 :                     continue;
     624              :                 };
     625           64 :                 let Some((name, ty)) = rest.split_once(':') else {
     626            0 :                     continue;
     627              :                 };
     628           64 :                 let name = name.trim();
     629              :                 // A cap is a count; the semaphore built from one is not a
     630              :                 // second cap and has nothing of its own to publish.
     631           64 :                 if !ty.contains("usize") {
     632            4 :                     continue;
     633           60 :                 }
     634              :                 // MAX_URI_BYTES → maxUriBytes
     635           60 :                 let mut camel = String::new();
     636          188 :                 for (i, word) in name.split('_').enumerate() {
     637          188 :                     let lower = word.to_lowercase();
     638          188 :                     if i == 0 {
     639           60 :                         camel.push_str(&lower);
     640           60 :                     } else {
     641          128 :                         let mut c = lower.chars();
     642          128 :                         if let Some(f) = c.next() {
     643          128 :                             camel.extend(f.to_uppercase());
     644          128 :                             camel.push_str(c.as_str());
     645          128 :                         }
     646              :                     }
     647              :                 }
     648           60 :                 if !published.contains_key(&camel) {
     649            0 :                     missing.push(format!("{name} (expected {camel:?})"));
     650           60 :                 }
     651              :             }
     652              :         }
     653            4 :         assert!(
     654            4 :             missing.is_empty(),
     655              :             "caps declared here and absent from /q/health: {missing:?}"
     656              :         );
     657            4 :     }
     658              : 
     659              :     /// A configured count is read once and never re-read, so a value that
     660              :     /// would stop delivery has no second chance to be corrected: zero mints
     661              :     /// a semaphore that admits nobody, and a per-tenant share above the
     662              :     /// width names a ceiling that can never fire. Both fall back rather
     663              :     /// than take the number as given.
     664              :     #[test]
     665            4 :     fn a_configured_count_falls_back_rather_than_disabling_delivery() {
     666            4 :         assert_eq!(count_from(None, 64, usize::MAX), 64, "unset is the default");
     667            4 :         assert_eq!(count_from(Some("weeks"), 64, usize::MAX), 64);
     668            4 :         assert_eq!(count_from(Some(""), 64, usize::MAX), 64);
     669            4 :         assert_eq!(
     670            4 :             count_from(Some("0"), 64, usize::MAX),
     671              :             64,
     672              :             "zero would admit nobody"
     673              :         );
     674            4 :         assert_eq!(count_from(Some("-8"), 64, usize::MAX), 64);
     675            4 :         assert_eq!(count_from(Some("256"), 64, usize::MAX), 256);
     676            4 :         assert_eq!(
     677            4 :             count_from(Some("512"), 8, 64),
     678              :             64,
     679              :             "a share over the width is the width"
     680              :         );
     681            4 :         assert_eq!(
     682            4 :             count_from(None, 8, 4),
     683              :             4,
     684              :             "the ceiling binds the default too"
     685              :         );
     686            4 :     }
     687              : 
     688              :     /// A guard on the compiled defaults rather than on the clamp: both
     689              :     /// counts are read once per process, so a test cannot set one without
     690              :     /// racing every other test in the binary for the read. What it holds is
     691              :     /// that the two numbers shipped stay a share and a total, and that
     692              :     /// neither default is ever lowered to zero — which would admit nobody.
     693              :     #[test]
     694            4 :     fn the_published_tenant_share_fits_inside_the_published_width() {
     695            4 :         let snap = LimitStats::default().snapshot();
     696            4 :         let width = snap["deliveryWidth"].as_u64().expect("a number");
     697            4 :         let share = snap["deliveryWidthPerTenant"].as_u64().expect("a number");
     698            4 :         assert!(share <= width, "share {share} exceeds width {width}");
     699            4 :         assert!(width > 0 && share > 0, "neither may be zero");
     700            4 :     }
     701              : 
     702              :     #[test]
     703            4 :     fn depth_scan_is_string_aware() {
     704            4 :         assert_eq!(json_depth(br#"{"a": [1, {"b": 2}]}"#), 3);
     705            4 :         assert_eq!(
     706            4 :             json_depth(br#"{"a": "}]}]}]{[{["}"#),
     707              :             1,
     708              :             "braces in strings don't count"
     709              :         );
     710            4 :         assert_eq!(
     711            4 :             json_depth(br#"{"a": "\"}"}"#),
     712              :             1,
     713              :             "escaped quotes stay in-string"
     714              :         );
     715            4 :         let deep = "[".repeat(100) + &"]".repeat(100);
     716            4 :         assert_eq!(json_depth(deep.as_bytes()), 100);
     717            4 :     }
     718              : }
        

Generated by: LCOV version 2.0-1