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}