dwara_core/wasm/
callout.rs

1//! Plugin HTTP callout transport (DW-167, issue #285).
2//!
3//! [`perform_callout`] executes ONE `proxy_http_call` dispatch: the
4//! minimal synchronous HTTP/1.1 client the wasm domain already owns
5//! for registry fetches (`super::source`) extended with the `http://`
6//! scheme (callouts usually target internal services) and arbitrary
7//! methods. The wasm runner stays synchronous — the ASYNC boundary is
8//! `dataplane::plugin_dispatch`, which wraps this function in
9//! `spawn_blocking`, delivers the response to the instance, and
10//! re-collects the phase outcome (the pause/resume model; see the
11//! adapter and plugin_dispatch docs).
12//!
13//! ## Guardrails (hard caps, no config surface)
14//!
15//! - **Timeout**: the plugin's requested timeout (milliseconds, the
16//!   unit the Rust SDK's `dispatch_http_call` sends) clamped to
17//!   `[CALLOUT_TIMEOUT_MIN_MS, CALLOUT_TIMEOUT_MAX_MS]` = [1ms, 5s].
18//!   It is a whole-exchange wall clock: the connect budget and every
19//!   read are clamped to the remaining time, so a target dripping one
20//!   byte per read cannot outlive it by resetting a per-read timer.
21//! - **Body cap**: [`CALLOUT_BODY_CAP_BYTES`] (4 MiB) on the response
22//!   body (Content-Length and received bytes alike). Over-cap is a
23//!   callout error, not truncation — the plugin must not act on a
24//!   silently shortened decision payload.
25//! - **Head cap**: [`CALLOUT_HEAD_CAP_BYTES`] (16 KiB), the same bound
26//!   the registry fetcher uses.
27//! - **Redirects are NOT followed**: a 3xx is delivered to the plugin
28//!   as data (the plugin decides), the same posture as webhook
29//!   deliveries and registry fetches.
30//! - **SSRF**: the gateway `ssrf_filter` is applied at connect time
31//!   against every RESOLVED IP (DNS rebinding mitigation), exactly as
32//!   `source::https_get` does for registry fetches.
33//! - **Schemes**: `http://` and `https://` only; anything else fails
34//!   validation at the hostcall (BadArgument) before any network work.
35//! - **Request-head integrity**: the request head is built by
36//!   interpolating the plugin-supplied method, header names, values,
37//!   and `:path` — every one of them is validated at the hostcall
38//!   (BadArgument, fail-closed, no connection attempted): the method
39//!   and header names must be RFC 7230 tokens, and names, values, and
40//!   the map `:path` must carry no CR, LF, or NUL. A plugin reflecting
41//!   client data into a callout therefore cannot smuggle a second
42//!   request into the gateway-initiated connection (request
43//!   splitting). [`perform_callout`] re-checks the same grammar as
44//!   defense in depth (it is `pub` and callable with a hand-built
45//!   [`CalloutRequest`]).
46//!
47//! ## Failure semantics (deliberate deviation from Envoy)
48//!
49//! Envoy invokes `proxy_on_http_call_response` with zeroed counts on a
50//! callout failure (timeout, refused, reset), letting the plugin
51//! branch on the empty maps. dwara instead FAILS THE ROUTE CLOSED: a
52//! callout error is a plugin failure (500 `plugin_failed`, metric
53//! reason `callout_failed`; the outcome itself is observable as
54//! `dwara_plugin_callouts_total{name,outcome=timeout|error}`). The
55//! plugin cannot make a fail-open decision without receiving a
56//! response, and dwara's plugin posture is fail-closed end to end
57//! (DW-157) — a plugin whose decision input never arrived must not
58//! resume as if it had. Recipes wanting fail-open behavior scope it
59//! explicitly (short timeouts, a fallback verdict implemented without
60//! a callout, or an extension trait instead of a plugin).
61
62use std::io::{Read, Write};
63use std::net::TcpStream;
64use std::sync::Arc;
65use std::time::{Duration, Instant};
66
67use crate::config::ssrf::SsrfFilter;
68
69/// Minimum callout timeout (the plugin's request is clamped UP to
70/// this): a zero/near-zero timeout would make every callout fail on
71/// scheduling jitter alone.
72pub const CALLOUT_TIMEOUT_MIN_MS: u32 = 1;
73/// Maximum callout timeout (the plugin's request is clamped DOWN to
74/// this): a per-request edge plugin must never hold a route for long.
75pub const CALLOUT_TIMEOUT_MAX_MS: u32 = 5_000;
76/// Cap on one callout response body (bytes).
77pub const CALLOUT_BODY_CAP_BYTES: usize = 4 * 1024 * 1024;
78/// Cap on the callout response head while locating the blank line
79/// (bytes; the same bound the registry fetcher uses).
80pub const CALLOUT_HEAD_CAP_BYTES: usize = 16 * 1024;
81/// Maximum callout rounds per phase per request (the loop guard,
82/// enforced by `dataplane::plugin_dispatch`; declared here so the
83/// callout vocabulary lives in one module).
84pub const MAX_CALLOUT_ROUNDS: u32 = 8;
85/// TCP connect timeout per address (clamped to the remaining
86/// callout-deadline budget in flight).
87const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
88/// `User-Agent` stamped on plugin callouts.
89const USER_AGENT: &str = "dwara-plugin-callout";
90
91/// One registered `proxy_http_call` dispatch, parsed and validated at
92/// the hostcall and performed by [`perform_callout`].
93#[derive(Clone, Debug)]
94pub struct CalloutRequest {
95    /// The config-declared plugin name (attribution + metrics).
96    pub plugin: String,
97    /// The token the host handed the plugin (delivered back with the
98    /// response so the SDK dispatcher can route it).
99    pub token: u32,
100    /// The full `http(s)://host[:port]/path?query` URI string.
101    pub uri: String,
102    /// The request method (`:method` from the dispatch header map).
103    pub method: String,
104    /// Ordinary request headers (pseudo-headers stripped at the
105    /// hostcall; framing headers are owned by the transport).
106    pub headers: Vec<(String, String)>,
107    /// The request body (may be empty).
108    pub body: Vec<u8>,
109    /// The plugin-requested timeout in milliseconds (clamped to
110    /// `[CALLOUT_TIMEOUT_MIN_MS, CALLOUT_TIMEOUT_MAX_MS]` at perform
111    /// time).
112    pub timeout_ms: u32,
113}
114
115/// A completed callout response, ready to deliver to the instance.
116/// ANY completed status is delivered — non-2xx is data (the plugin
117/// decides what a 403 from its decision service means); only transport
118/// failures (timeout/refused/reset/caps/SSRF) are errors.
119#[derive(Clone, Debug)]
120pub struct CalloutResponse {
121    /// The response status code.
122    pub status: u16,
123    /// Response headers with hop-by-hop names stripped; `:status` is
124    /// prepended by the host when the map is exposed to the plugin.
125    pub headers: Vec<(String, String)>,
126    /// The response body (bounded by [`CALLOUT_BODY_CAP_BYTES`]).
127    pub body: Vec<u8>,
128}
129
130/// Why a callout could not be completed. `Timeout` gets its own metric
131/// outcome; every other failure is `Error`.
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub enum CalloutError {
134    /// The clamped whole-exchange deadline expired (mid-connect,
135    /// mid-read, or before the exchange started).
136    Timeout,
137    /// Every other failure: DNS, refused, TLS, reset, framing, caps,
138    /// SSRF rejection. The string is operator-facing detail (logs
139    /// only; it never reaches the client).
140    Failed(String),
141}
142
143impl CalloutError {
144    /// The `dwara_plugin_callouts_total` outcome label for this error.
145    pub fn outcome(&self) -> &'static str {
146        match self {
147            CalloutError::Timeout => "timeout",
148            CalloutError::Failed(_) => "error",
149        }
150    }
151}
152
153impl std::fmt::Display for CalloutError {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        match self {
156            CalloutError::Timeout => write!(
157                f,
158                "callout timeout: the clamped whole-exchange deadline \
159                 (max {} ms) expired before the response completed",
160                CALLOUT_TIMEOUT_MAX_MS
161            ),
162            CalloutError::Failed(detail) => write!(f, "callout failed: {detail}"),
163        }
164    }
165}
166
167impl std::error::Error for CalloutError {}
168
169/// Clamp the plugin-requested timeout into the documented window.
170pub fn clamp_timeout_ms(requested_ms: u32) -> u32 {
171    requested_ms.clamp(CALLOUT_TIMEOUT_MIN_MS, CALLOUT_TIMEOUT_MAX_MS)
172}
173
174/// Whether `s` is an RFC 7230 `token` (the `tchar` grammar): the
175/// method and every header NAME must match it. A non-token would
176/// smuggle framing bytes (spaces, CR/LF, control characters) into the
177/// interpolated request head — the request-splitting recipe.
178pub fn is_rfc7230_token(s: &str) -> bool {
179    !s.is_empty()
180        && s.bytes().all(|b| {
181            b.is_ascii_alphanumeric()
182                || matches!(
183                    b,
184                    b'!' | b'#'
185                        | b'$'
186                        | b'%'
187                        | b'&'
188                        | b'\''
189                        | b'*'
190                        | b'+'
191                        | b'-'
192                        | b'.'
193                        | b'^'
194                        | b'_'
195                        | b'`'
196                        | b'|'
197                        | b'~'
198                )
199        })
200}
201
202/// Whether `s` is free of the bytes that terminate or split the
203/// HTTP/1.1 request head: CR, LF, and NUL. Header values and the map
204/// `:path` ride the head verbatim, so any of these bytes is rejected
205/// (a value may legally carry obs-text, which this check leaves alone).
206pub fn is_head_safe(s: &str) -> bool {
207    !s.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
208}
209
210/// Validate the request-head grammar of one callout request (method
211/// token, header-name tokens, delimiter-free values). Shared by the
212/// hostcall (BadArgument before any registration) and
213/// [`perform_callout`] (defense in depth before any network work).
214fn validate_request_head(method: &str, headers: &[(String, String)]) -> Result<(), CalloutError> {
215    if !is_rfc7230_token(method) {
216        return Err(CalloutError::Failed(
217            "callout method is not a valid HTTP token".to_string(),
218        ));
219    }
220    for (name, value) in headers {
221        // A token name is delimiter-free by construction; the value
222        // check is the request-splitting guard.
223        if !is_rfc7230_token(name) {
224            return Err(CalloutError::Failed(format!(
225                "callout header name '{name}' is not a valid HTTP token"
226            )));
227        }
228        if !is_head_safe(value) {
229            return Err(CalloutError::Failed(
230                "callout header value contains CR, LF, or NUL".to_string(),
231            ));
232        }
233    }
234    Ok(())
235}
236
237/// Perform one callout exchange. Synchronous by design (the async
238/// boundary is plugin_dispatch's `spawn_blocking`); reuses the
239/// registry fetcher's transport posture (SSRF at connect, per-read
240/// timeouts clamped to the deadline, `Connection: close`, read to
241/// EOF) with the `http://` scheme and arbitrary methods added.
242pub fn perform_callout(
243    req: &CalloutRequest,
244    ssrf: &SsrfFilter,
245) -> Result<CalloutResponse, CalloutError> {
246    let timeout = Duration::from_millis(clamp_timeout_ms(req.timeout_ms) as u64);
247    let deadline = Instant::now() + timeout;
248    // Defense in depth: the hostcall already rejected a non-token
249    // method, non-token names, and delimiter-carrying values with
250    // BadArgument (register_callout); this re-check keeps the
251    // transport itself request-splitting-safe for any caller that
252    // builds a CalloutRequest by hand.
253    validate_request_head(&req.method, &req.headers)?;
254    let uri: http::Uri = req
255        .uri
256        .parse()
257        .map_err(|e| CalloutError::Failed(format!("callout URI does not parse: {e}")))?;
258    let https = match uri.scheme_str() {
259        Some("https") => true,
260        Some("http") => false,
261        _ => {
262            return Err(CalloutError::Failed(
263                "callout URI must use the http:// or https:// scheme".to_string(),
264            ))
265        }
266    };
267    let host = uri
268        .host()
269        .ok_or_else(|| CalloutError::Failed("callout URI has no host".to_string()))?;
270    let default_port = if https { 443 } else { 80 };
271    let port = uri.port_u16().unwrap_or(default_port);
272    let path_and_query = uri
273        .path_and_query()
274        .map(|p| p.as_str().to_string())
275        .unwrap_or_else(|| "/".to_string());
276    // ServerName wants the bare host (IPv6 brackets stripped for
277    // dialing/TLS, un-bracketed for the Host header logic below).
278    let dial_host = host
279        .strip_prefix('[')
280        .and_then(|h| h.strip_suffix(']'))
281        .unwrap_or(host)
282        .to_string();
283
284    // Resolve + SSRF-check every resolved IP at connect time, then
285    // dial the first address that accepts (DNS rebinding mitigation:
286    // the check runs against what we actually connect to).
287    let addrs: Vec<std::net::SocketAddr> =
288        std::net::ToSocketAddrs::to_socket_addrs(&(dial_host.as_str(), port))
289            .map_err(|e| CalloutError::Failed(format!("DNS resolution failed: {e}")))?
290            .collect();
291    if ssrf.is_enabled() {
292        for addr in &addrs {
293            if let Err(reason) = ssrf.check(addr.ip()) {
294                return Err(CalloutError::Failed(format!(
295                    "SSRF egress filter rejected target: {reason}"
296                )));
297            }
298        }
299    }
300    let mut last_err = String::from("no addresses resolved");
301    let mut stream: Option<TcpStream> = None;
302    for addr in &addrs {
303        let connect_budget =
304            CONNECT_TIMEOUT.min(deadline.saturating_duration_since(Instant::now()));
305        if connect_budget.is_zero() {
306            return Err(CalloutError::Timeout);
307        }
308        match TcpStream::connect_timeout(addr, connect_budget) {
309            Ok(s) => {
310                stream = Some(s);
311                break;
312            }
313            Err(e) => last_err = e.to_string(),
314        }
315    }
316    let tcp = stream.ok_or_else(|| CalloutError::Failed(format!("connect failed: {last_err}")))?;
317    tcp.set_nodelay(true).ok();
318    // Per-write timeout clamped to the wall-clock remainder, like the
319    // read path: a stalled target cannot outlive the callout deadline
320    // one write at a time. A zero remainder means the deadline already
321    // expired mid-connect — the exchange is a timeout, not an error.
322    let write_budget = timeout.min(deadline.saturating_duration_since(Instant::now()));
323    if write_budget.is_zero() {
324        return Err(CalloutError::Timeout);
325    }
326    tcp.set_write_timeout(Some(write_budget)).ok();
327
328    // Build the request head: the plugin's ordinary headers minus the
329    // framing/hop-by-hop names the transport owns.
330    let host_header = if port == default_port {
331        host.to_string()
332    } else {
333        format!("{host}:{port}")
334    };
335    let mut head = format!("{} {} HTTP/1.1\r\n", req.method, path_and_query);
336    head.push_str(&format!("host: {host_header}\r\n"));
337    head.push_str(&format!("user-agent: {USER_AGENT}\r\n"));
338    for (name, value) in &req.headers {
339        let lower = name.to_ascii_lowercase();
340        if matches!(
341            lower.as_str(),
342            "host"
343                | "connection"
344                | "keep-alive"
345                | "transfer-encoding"
346                | "upgrade"
347                | "te"
348                | "proxy-connection"
349                | "content-length"
350        ) {
351            continue;
352        }
353        head.push_str(&format!("{name}: {value}\r\n"));
354    }
355    if !req.body.is_empty() {
356        head.push_str(&format!("content-length: {}\r\n", req.body.len()));
357    }
358    head.push_str("connection: close\r\n\r\n");
359
360    // The exchange: for https the same bytes ride a rustls stream over
361    // the TcpStream (the registry fetcher's shape); for http the raw
362    // TcpStream. One enum transport keeps the two paths identical
363    // below the TLS wrapper (including the per-read timeout reset).
364    let mut io = if https {
365        let name = rustls::pki_types::ServerName::try_from(dial_host.clone()).map_err(|_| {
366            CalloutError::Failed(format!(
367                "host '{dial_host}' is not a usable TLS server name"
368            ))
369        })?;
370        let mut config = rustls::ClientConfig::builder()
371            .with_root_certificates(super::source::webpki_root_store())
372            .with_no_client_auth();
373        config.alpn_protocols = vec![b"http/1.1".to_vec()];
374        let conn = rustls::ClientConnection::new(Arc::new(config), name)
375            .map_err(|e| CalloutError::Failed(format!("TLS setup failed: {e}")))?;
376        CalloutIo::Tls(rustls::StreamOwned::new(conn, tcp))
377    } else {
378        CalloutIo::Plain(tcp)
379    };
380    exchange(&mut io, &head, &req.body, deadline)
381}
382
383/// The established callout transport: plain TCP or rustls over TCP.
384/// Implements Read/Write plus the per-read timeout reset the deadline
385/// loop needs (rustls's `StreamOwned` does not forward setsockopt, but
386/// its `sock` field is reachable). Boxed: the TLS variant is ~1 KiB
387/// and the enum lives on the callout's (spawn_blocking) stack — the
388/// indirection keeps the plain path from paying that footprint.
389#[allow(clippy::large_enum_variant)]
390enum CalloutIo {
391    Plain(TcpStream),
392    Tls(rustls::StreamOwned<rustls::ClientConnection, TcpStream>),
393}
394
395impl CalloutIo {
396    fn set_read_timeout(&mut self, d: Duration) {
397        match self {
398            CalloutIo::Plain(s) => {
399                s.set_read_timeout(Some(d)).ok();
400            }
401            CalloutIo::Tls(t) => {
402                t.sock.set_read_timeout(Some(d)).ok();
403            }
404        }
405    }
406}
407
408impl Read for CalloutIo {
409    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
410        match self {
411            CalloutIo::Plain(s) => s.read(buf),
412            CalloutIo::Tls(t) => t.read(buf),
413        }
414    }
415}
416
417impl Write for CalloutIo {
418    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
419        match self {
420            CalloutIo::Plain(s) => s.write(buf),
421            CalloutIo::Tls(t) => t.write(buf),
422        }
423    }
424
425    fn flush(&mut self) -> std::io::Result<()> {
426        match self {
427            CalloutIo::Plain(s) => s.flush(),
428            CalloutIo::Tls(t) => t.flush(),
429        }
430    }
431}
432
433/// Run one `Connection: close` exchange on an established transport:
434/// write the head + body, read to EOF under the deadline, parse the
435/// response. Reading to EOF keeps the framing simple (CL, chunked, or
436/// close-delimited all complete); a peer that drops without a TLS
437/// close_notify surfaces as UnexpectedEof/reset AFTER bytes arrived
438/// and is treated as end-of-stream — the framing parse decides
439/// whether the body is complete.
440fn exchange(
441    io: &mut CalloutIo,
442    head: &str,
443    body: &[u8],
444    deadline: Instant,
445) -> Result<CalloutResponse, CalloutError> {
446    io.write_all(head.as_bytes())
447        .map_err(|e| CalloutError::Failed(format!("callout request write failed: {e}")))?;
448    if !body.is_empty() {
449        io.write_all(body)
450            .map_err(|e| CalloutError::Failed(format!("callout request write failed: {e}")))?;
451    }
452    io.flush()
453        .map_err(|e| CalloutError::Failed(format!("callout request flush failed: {e}")))?;
454
455    let mut raw = Vec::with_capacity(8 * 1024);
456    let mut chunk = [0u8; 16 * 1024];
457    loop {
458        if raw.len() > CALLOUT_HEAD_CAP_BYTES + CALLOUT_BODY_CAP_BYTES {
459            return Err(CalloutError::Failed(format!(
460                "callout response exceeds the {} MiB body cap",
461                CALLOUT_BODY_CAP_BYTES / (1024 * 1024)
462            )));
463        }
464        let remaining = deadline.checked_duration_since(Instant::now());
465        let Some(remaining) = remaining else {
466            return Err(CalloutError::Timeout);
467        };
468        // Per-read timeout clamped to the deadline remainder: a target
469        // dripping one byte per read cannot outlive the callout.
470        io.set_read_timeout(remaining);
471        match io.read(&mut chunk) {
472            Ok(0) => break,
473            Ok(n) => raw.extend_from_slice(&chunk[..n]),
474            Err(e)
475                if matches!(
476                    e.kind(),
477                    std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::ConnectionReset
478                ) && !raw.is_empty() =>
479            {
480                break;
481            }
482            Err(e)
483                if e.kind() == std::io::ErrorKind::WouldBlock
484                    || e.kind() == std::io::ErrorKind::TimedOut =>
485            {
486                return Err(CalloutError::Timeout)
487            }
488            Err(e) => return Err(CalloutError::Failed(format!("callout read failed: {e}"))),
489        }
490    }
491    parse_callout_response(&raw)
492}
493
494/// Parse a raw HTTP/1.1 callout response: status line, headers, body
495/// framing (Content-Length, chunked, or read-to-EOF). ANY status is
496/// returned (non-2xx is data); redirects are deliberately not
497/// followed. Public for the integration tests, which pin the framing
498/// edge cases as pure functions (the same posture as
499/// `source::parse_response`).
500pub fn parse_callout_response(raw: &[u8]) -> Result<CalloutResponse, CalloutError> {
501    let Some(pos) = find_subslice(raw, b"\r\n\r\n") else {
502        return Err(CalloutError::Failed(
503            "callout response head was not terminated (no blank line)".to_string(),
504        ));
505    };
506    if pos > CALLOUT_HEAD_CAP_BYTES {
507        return Err(CalloutError::Failed(format!(
508            "callout response head exceeded the {CALLOUT_HEAD_CAP_BYTES} byte cap"
509        )));
510    }
511    let head = std::str::from_utf8(&raw[..pos]).map_err(|_| {
512        CalloutError::Failed("callout response head is not valid UTF-8".to_string())
513    })?;
514    let status = head
515        .split_whitespace()
516        .nth(1)
517        .and_then(|s| s.parse::<u16>().ok())
518        .ok_or_else(|| CalloutError::Failed("unparseable callout status line".to_string()))?;
519    let body = &raw[pos + 4..];
520    let mut chunked = false;
521    let mut content_length: Option<usize> = None;
522    let mut headers: Vec<(String, String)> = Vec::new();
523    for line in head.split("\r\n").skip(1) {
524        let Some((name, value)) = line.split_once(':') else {
525            continue;
526        };
527        let name = name.trim().to_ascii_lowercase();
528        let value = value.trim();
529        if name == "transfer-encoding" && value.to_ascii_lowercase().contains("chunked") {
530            chunked = true;
531        } else if name == "content-length" {
532            content_length = value.parse::<usize>().ok();
533        }
534        // Hop-by-hop and framing names never reach the plugin's map.
535        if matches!(
536            name.as_str(),
537            "connection"
538                | "keep-alive"
539                | "transfer-encoding"
540                | "upgrade"
541                | "te"
542                | "proxy-connection"
543        ) {
544            continue;
545        }
546        headers.push((name, value.to_string()));
547    }
548    let body = if chunked {
549        decode_chunked(body, CALLOUT_BODY_CAP_BYTES).map_err(CalloutError::Failed)?
550    } else if let Some(len) = content_length {
551        // Cap before truncation so a short body with a hostile
552        // over-cap Content-Length reports the cap, not a truncation.
553        if len > CALLOUT_BODY_CAP_BYTES {
554            return Err(CalloutError::Failed(format!(
555                "Content-Length {len} exceeds the {} MiB callout body cap",
556                CALLOUT_BODY_CAP_BYTES / (1024 * 1024)
557            )));
558        }
559        if body.len() < len {
560            return Err(CalloutError::Failed(format!(
561                "truncated callout body: Content-Length says {len}, got {} bytes",
562                body.len()
563            )));
564        }
565        body[..len].to_vec()
566    } else {
567        // No framing header: Connection: close read-to-EOF is the body.
568        body.to_vec()
569    };
570    if body.len() > CALLOUT_BODY_CAP_BYTES {
571        return Err(CalloutError::Failed(format!(
572            "callout body exceeds the {} MiB cap",
573            CALLOUT_BODY_CAP_BYTES / (1024 * 1024)
574        )));
575    }
576    Ok(CalloutResponse {
577        status,
578        headers,
579        body,
580    })
581}
582
583/// Minimal chunked transfer decoding with a caller-supplied cap (the
584/// same hardened shape as `source::decode_chunked`: 1-16 ASCII hex
585/// digits, checked arithmetic, per-chunk and aggregate caps).
586pub fn decode_chunked(mut body: &[u8], cap: usize) -> Result<Vec<u8>, String> {
587    let cap_err = || format!("chunked body exceeds the {cap} byte cap");
588    let mut out = Vec::new();
589    loop {
590        let Some(line_end) = find_subslice(body, b"\r\n") else {
591            return Err("chunked body: missing chunk size line".to_string());
592        };
593        let line = std::str::from_utf8(&body[..line_end])
594            .map_err(|_| "chunked body: size line is not valid UTF-8".to_string())?;
595        let size_str = line.split(';').next().unwrap_or("").trim();
596        if size_str.is_empty()
597            || size_str.len() > 16
598            || !size_str.bytes().all(|b| b.is_ascii_hexdigit())
599        {
600            return Err(format!("chunked body: bad chunk size '{size_str}'"));
601        }
602        let size = usize::from_str_radix(size_str, 16)
603            .map_err(|_| format!("chunked body: bad chunk size '{size_str}'"))?;
604        body = &body[line_end + 2..];
605        if size == 0 {
606            break; // trailers (if any) follow; ignored
607        }
608        if size > cap {
609            return Err(cap_err());
610        }
611        let next_len = out.len().checked_add(size).ok_or_else(cap_err)?;
612        if next_len > cap {
613            return Err(cap_err());
614        }
615        let need = size
616            .checked_add(2)
617            .ok_or_else(|| "chunked body: truncated chunk".to_string())?;
618        if body.len() < need {
619            return Err("chunked body: truncated chunk".to_string());
620        }
621        out.extend_from_slice(&body[..size]);
622        body = &body[need..];
623    }
624    Ok(out)
625}
626
627/// Find `needle` in `haystack` (the tiny helper `source` uses too).
628fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
629    haystack.windows(needle.len()).position(|w| w == needle)
630}