Skip to main content

cratefield_runtime_cloudflare/ports/
http.rs

1//! `HttpClient` over `worker::Fetch`.
2//!
3//! # What a caller gets
4//!
5//! The body is read as a stream under the [`HttpPolicy`] cap: no more
6//! than the cap is ever buffered on the Rust side, and the read stops
7//! at the chunk that crosses it, so an upstream that streams without
8//! declaring a length cannot spend the isolate's memory on order to be
9//! refused. (Workerd may have buffered some of the body before the
10//! headers arrived; what the cap bounds is this side of that.) A request
11//! marked [`StatusOnly`](cratefield_core::StatusOnly) is answered from
12//! the status line alone, its body never read. Redirects are not
13//! followed: a 3xx comes back as it is, with its `Location`.
14//!
15//! `send_streaming` is the same exchange with the body left unbuffered
16//! (issue #859): the head answers as soon as the platform has it, the
17//! chunks arrive as they are read, and dropping the stream cancels the
18//! underlying `ReadableStream` — the upstream fetch stops with it.
19//!
20//! # A failure message here never carries the request URL
21//!
22//! This is the production path for every venture on this harness, and the
23//! runtime whose errors reach Workers Logs through `console_error!`. A
24//! `worker::Error` is not safe to stringify: `Error::JsError(s)` and
25//! `Error::UnknownJsError { .. }` carry workerd's own message, and workerd
26//! names the destination it could not reach — "Fetch API cannot load:
27//! `<the whole URL>`".
28//!
29//! For some callers that URL *is* the credential. An APNs request path is
30//! the device token (`/3/device/<token>`) and a Web Push endpoint is a
31//! bearer capability — whoever holds it can push to that browser
32//! indefinitely, which is why [`cratefield_core::Recipient`] prints
33//! fingerprints and says the value must appear "never in a log, an event
34//! payload, or an error body". A transport failure would otherwise write
35//! that credential into Workers Logs while the caller two lines away is
36//! carefully printing a fingerprint (issue #229; the native port was the
37//! same leak, issue #228).
38//!
39//! So no error is ever stringified whole here. Every failure goes through
40//! [`transport`], which keeps the destination's origin, drops its path, and
41//! then hands the result to [`cratefield_core::scrub_text`] for every other
42//! shape of secret a layer below might have quoted.
43
44use std::fmt::Write as _;
45
46use async_trait::async_trait;
47use bytes::{Bytes, BytesMut};
48use cratefield_core::{
49    ByteStream, HttpClient, HttpError, HttpPolicy, StatusOnly, scrub_request_url, scrub_text,
50};
51use futures_core::Stream;
52use futures_util::StreamExt;
53use worker::send::IntoSendFuture;
54use worker::{Fetch, Headers, Method, Request as WorkerRequest, RequestInit, RequestRedirect};
55
56/// The Workers implementation of the [`HttpClient`] port: one `fetch` per
57/// send, through the isolate's own stack.
58pub struct FetchClient;
59
60/// Buffer the capped read starts with: an ordinary upstream reply fits
61/// without a reallocation, a larger one grows from here. Never more than
62/// the cap itself, whatever the cap is.
63const INITIAL_READ_CAPACITY: usize = 16 * 1024;
64
65#[async_trait]
66impl HttpClient for FetchClient {
67    async fn send(
68        &self,
69        request: http::Request<Bytes>,
70    ) -> Result<http::Response<Bytes>, HttpError> {
71        let policy = HttpPolicy::of_request(&request);
72        let status_only = request.extensions().get::<StatusOnly>().is_some();
73        let (url, worker_request) = build_request(request)?;
74
75        let mut response = Fetch::Request(worker_request)
76            .send()
77            .into_send()
78            .await
79            .map_err(|err| transport(&err, &url))?;
80
81        // The whole body is read into one buffer, so refuse a declared
82        // body the cap has already out before reading any of it (issue
83        // #136). A status-only probe reads no body, so the declared
84        // length is not something it asked for: checking it here would
85        // refuse exactly the large resource a liveness probe exists to
86        // ask about. The body-describing headers are dropped below
87        // instead, so `BoundedHttpClient` sees no length either.
88        if !status_only {
89            let declared = response
90                .headers()
91                .get("content-length")
92                .ok()
93                .flatten()
94                .and_then(|length| length.trim().parse::<usize>().ok());
95            if declared.is_some_and(|declared| declared > policy.max_response_bytes) {
96                return Err(HttpError::ResponseTooLarge {
97                    limit: policy.max_response_bytes,
98                });
99            }
100        }
101
102        let headers = response_headers(response.headers(), status_only);
103        let mut builder = http::Response::builder().status(response.status_code());
104        if let Some(map) = builder.headers_mut() {
105            *map = headers;
106        }
107
108        if status_only {
109            // The body is never read: the `worker::Response` is dropped
110            // without being streamed. Dropping it releases the platform's
111            // handle on the fetch, so nothing is read into the isolate —
112            // how much of the body the platform had already received is
113            // workerd's business, not a promise this port makes.
114            return builder
115                .body(Bytes::new())
116                .map_err(|err| transport(&err, &url));
117        }
118
119        let bytes = capped_body(response.stream(), policy.max_response_bytes, &url)
120            .into_send()
121            .await?;
122        builder.body(bytes).map_err(|err| transport(&err, &url))
123    }
124
125    /// The streamed send has no bounds of its own — head deadline, idle
126    /// gap and whole-body ceiling, byte cap and declared-length refusal
127    /// are all [`cratefield_core::BoundedHttpClient`]'s, which every
128    /// runtime wires around this transport; a bare `FetchClient` streams
129    /// unbounded. Unlike the buffered [`HttpClient::send`], this path does
130    /// not honour a [`StatusOnly`] marker: the caller gets the body
131    /// stream whatever it marked.
132    async fn send_streaming(
133        &self,
134        request: http::Request<Bytes>,
135    ) -> Result<http::Response<ByteStream>, HttpError> {
136        let (url, worker_request) = build_request(request)?;
137        let mut response = Fetch::Request(worker_request)
138            .send()
139            .into_send()
140            .await
141            .map_err(|err| transport(&err, &url))?;
142
143        let headers = response_headers(response.headers(), false);
144        let mut builder = http::Response::builder().status(response.status_code());
145        if let Some(map) = builder.headers_mut() {
146            *map = headers;
147        }
148
149        // `worker::ByteStream` is `!Send` — its `JsFuture` holds an `Rc` —
150        // so it crosses the port's `Send` bound through the crate's
151        // `SendStream` wrapper, and each chunk's failure goes through
152        // `transport` like every other failure here. Dropping the stream
153        // drops the wasm-streams reader, whose `Drop` cancels the
154        // underlying `ReadableStream` — the upstream fetch aborts with it.
155        // The body's own bounds are `cratefield_core::BoundedHttpClient`'s:
156        // nothing is buffered here for a cap to protect, and the head has
157        // already answered under the wrapper's deadline.
158        let body: ByteStream =
159            match response.stream() {
160                Ok(stream) => {
161                    let url = url.clone();
162                    Box::pin(crate::send_stream(stream).map(move |chunk| {
163                        chunk.map(Bytes::from).map_err(|err| transport(&err, &url))
164                    }))
165                }
166                // "There is no stream here" is the platform's answer for every
167                // null-body status (101/103/204/205/304) and a HEAD reply: an
168                // empty body, not a failed send — the same face the capped
169                // read takes it at.
170                Err(_) => Box::pin(futures_util::stream::empty()),
171            };
172        builder.body(body).map_err(|err| transport(&err, &url))
173    }
174}
175
176/// Builds the transport request a send hands the platform: method, copied
177/// headers, the body only when there is one, and the manual-redirect
178/// marker the port's redirect rule hangs on. Shared by the buffered and
179/// the streaming send so the two cannot drift.
180fn build_request(request: http::Request<Bytes>) -> Result<(String, WorkerRequest), HttpError> {
181    let (parts, body) = request.into_parts();
182    // Kept for the whole exchange: it is what every failure below is
183    // scrubbed against, and the only thing here that knows which parts
184    // of a message are this request's URL.
185    let url = parts.uri.to_string();
186    let mut init = RequestInit::new();
187    init.method = Method::from(parts.method.as_str().to_string());
188    let headers = Headers::new();
189    for (name, value) in &parts.headers {
190        let _ = headers.set(name.as_str(), value.to_str().unwrap_or_default());
191    }
192    init.headers = headers;
193    // A redirect is answered, not taken. `Follow` is the platform
194    // default and would let an upstream move the exchange to another
195    // host, or down to `http`, after the destination was chosen. The
196    // caller sees the 3xx with its `Location` and decides for itself.
197    init.with_redirect(RequestRedirect::Manual);
198
199    // A body is attached only when there is one. The Fetch spec refuses
200    // to construct a Request whose method is GET or HEAD and whose body
201    // is non-null, and an empty JS string is not null: setting it
202    // unconditionally makes every GET through this port throw a
203    // TypeError before it leaves the isolate.
204    //
205    // Nothing caught it because every adapter shipped so far POSTs
206    // (Resend, Turnstile). The first GET consumer is OpenID Connect
207    // discovery in the `auth-oidc` crate, which fetches a configuration
208    // document and a JWKS.
209    if !body.is_empty() {
210        // The port's adapters send JSON bodies; non-UTF-8 is a hard
211        // error rather than a lossy corruption.
212        let body_text = String::from_utf8(body.to_vec()).map_err(|err| transport(&err, &url))?;
213        init.with_body(Some(worker::wasm_bindgen::JsValue::from_str(&body_text)));
214    }
215    let worker_request =
216        WorkerRequest::new_with_init(&url, &init).map_err(|err| transport(&err, &url))?;
217    Ok((url, worker_request))
218}
219
220/// Copies a response's headers, dropping the ones that describe a body
221/// when there is no body behind them.
222///
223/// A status-only response carries no body, so a `content-length` left on
224/// it is a length nothing is behind: `BoundedHttpClient` — which every
225/// runtime wraps around this port — refuses a declared length over the
226/// cap, so a probe of a large resource would fail on a length the caller
227/// never asked for.
228///
229/// Generic over the iterator rather than taking a `worker::Headers`,
230/// which cannot be constructed off-wasm, so the decision is testable
231/// natively through the same iterator the platform's headers produce.
232fn response_headers<I>(from: I, status_only: bool) -> http::HeaderMap
233where
234    I: IntoIterator<Item = (String, String)>,
235{
236    let mut headers = http::HeaderMap::new();
237    for (name, value) in from {
238        if status_only && is_body_describing(&name) {
239            continue;
240        }
241        if let (Ok(name), Ok(value)) = (
242            http::HeaderName::try_from(name.as_str()),
243            http::HeaderValue::try_from(value.as_str()),
244        ) {
245            headers.insert(name, value);
246        }
247    }
248    headers
249}
250
251/// The body of a response, capped, with the platform's "there is no
252/// stream here" answer taken at face value.
253///
254/// `worker::Response::stream()` refuses every body that is not a
255/// `Stream`, and `ResponseBody::Empty` is what the platform delivers for
256/// every null-body status (101/103/204/205/304) and for the reply to a
257/// `HEAD`. That is an empty body, not a failed send: the refusal is taken
258/// at face value here, so reading under the cap does not turn every 204 No
259/// Content into one.
260async fn capped_body<S>(
261    stream: worker::Result<S>,
262    limit: usize,
263    url: &str,
264) -> Result<Bytes, HttpError>
265where
266    S: Stream<Item = worker::Result<Vec<u8>>> + Unpin,
267{
268    match stream {
269        Ok(stream) => read_capped(stream, limit, url).await,
270        Err(_) => Ok(Bytes::new()),
271    }
272}
273
274/// Headers that describe a body. A response built with no body must not
275/// keep them, or a caller — and the `BoundedHttpClient` every runtime
276/// wraps around this port — reads a length there is nothing behind.
277fn is_body_describing(name: &str) -> bool {
278    // The platform lowercases what it hands over, but the port promises
279    // nothing about an implementation's casing, and a `content-length`
280    // left in place would be a lie a caller believes.
281    let Ok(name) = http::HeaderName::try_from(name) else {
282        return false;
283    };
284    matches!(
285        name,
286        http::header::CONTENT_LENGTH
287            | http::header::CONTENT_ENCODING
288            | http::header::TRANSFER_ENCODING
289    )
290}
291
292/// Reads a response body as a stream and refuses it the moment it passes
293/// `limit` (issue #714).
294///
295/// The declared `content-length` is only a claim: a customer-supplied
296/// URL can stream a chunked body that never declares one, and
297/// `Response::bytes()` buffers whatever arrives — so the body is read
298/// chunk by chunk and each chunk is checked *before* it is appended. The
299/// chunk that crosses the cap is examined as a length and dropped along
300/// with the buffer; returning here releases the `ByteStream` and with it
301/// the platform's handle on the fetch, so the pending read stops here
302/// and no further bytes are buffered on this side. An upstream that never
303/// stops sending costs one chunk past the cap, not the isolate's memory.
304///
305/// Generic over the stream so the decision logic is testable off-wasm:
306/// `worker::ByteStream` cannot be constructed outside wasm, and an
307/// aborted one is indistinguishable from one that ended.
308async fn read_capped<S>(mut stream: S, limit: usize, url: &str) -> Result<Bytes, HttpError>
309where
310    S: Stream<Item = worker::Result<Vec<u8>>> + Unpin,
311{
312    // A capacity hint, not an allocation of the whole cap: `BytesMut`
313    // grows geometrically, so an unbuffered start can transiently hold
314    // twice what it has been given. This covers an ordinary reply in one
315    // go and never exceeds the cap itself.
316    let mut buffered = BytesMut::with_capacity(limit.min(INITIAL_READ_CAPACITY));
317    while let Some(chunk) = stream.next().await {
318        let chunk = chunk.map_err(|err| transport(&err, url))?;
319        if buffered.len().saturating_add(chunk.len()) > limit {
320            return Err(HttpError::ResponseTooLarge { limit });
321        }
322        buffered.extend_from_slice(&chunk);
323    }
324    Ok(buffered.freeze())
325}
326
327/// The only place in this module that builds an [`HttpError::Transport`],
328/// so a sixth failure site cannot be added that reports a raw error string
329/// — which is exactly how the first five came to (issue #229).
330fn transport(err: &dyn std::error::Error, url: &str) -> HttpError {
331    HttpError::Transport(safe_message(err, url))
332}
333
334/// An error as a message this crate may hand on — to [`HttpError`], and
335/// from there to `console_error!`, a dead-letter row and an operator's
336/// terminal.
337///
338/// Three things happen here, and the last two are why this is not
339/// `err.to_string()`:
340///
341/// 1. **The causes go in.** `worker::Error`'s `Display` is deliberately
342///    thin for every variant that wraps another error — `Error::Io` prints
343///    "I/O error", `Error::Http` prints "HTTP error" — because the crate
344///    expects consumers to walk `source()`, which it implements for the
345///    wrapped Rust errors *and* for a JS error's `cause` chain. Asking
346///    `Display` alone therefore throws away the half that says what went
347///    wrong. This is the same thing the native port learned about
348///    `reqwest::Error` (issue #228), and it means redaction makes these
349///    messages more diagnosable, not less.
350/// 2. **The URL comes out.** With the chain in, a workerd fetch failure
351///    reads "Fetch API cannot load: `<the whole URL>`", path and query
352///    included — see the module note for why that is a disclosure and not
353///    a detail. [`cratefield_core::scrub_request_url`] cuts it back to the
354///    origin, and redacts the bare request target a cause is free to quote
355///    on its own.
356/// 3. **Everything else is scrubbed too.** A cause may quote a `Bearer`
357///    credential, a signed token or an address that has nothing to do with
358///    this request's URL, so the result goes through
359///    [`cratefield_core::scrub_text`] — the same pass every log field takes
360///    (issue #135).
361fn safe_message(err: &dyn std::error::Error, url: &str) -> String {
362    let mut message = err.to_string();
363    let mut cause = err.source();
364    while let Some(current) = cause {
365        let _ = write!(message, ": {current}");
366        cause = current.source();
367    }
368    scrub_text(&scrub_request_url(&message, url))
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use futures_util::stream;
375    use std::sync::Arc;
376    use std::sync::atomic::{AtomicUsize, Ordering};
377
378    /// The APNs shape, because it is the worst case: the device token is
379    /// the request *path*, so a message that quotes the URL publishes the
380    /// credential.
381    const TOKEN: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff";
382
383    fn apns_url() -> String {
384        format!("https://api.push.apple.com/3/device/{TOKEN}")
385    }
386
387    fn assert_safe(message: &str) {
388        assert!(
389            !message.contains(TOKEN),
390            "the device token leaked: {message}"
391        );
392        assert!(
393            !message.contains("/3/device"),
394            "the request path leaked: {message}"
395        );
396    }
397
398    /// What workerd actually throws when a fetch cannot be completed: a
399    /// `TypeError` whose message names the destination in full. Arriving
400    /// as `Error::JsError` (a thrown string) or inside `UnknownJsError`'s
401    /// message, it is the same disclosure either way.
402    #[test]
403    fn a_workerd_fetch_failure_names_the_origin_and_never_the_request_path() {
404        let url = apns_url();
405        let err = worker::Error::JsError(format!("TypeError: Fetch API cannot load: {url}."));
406
407        let message = safe_message(&err, &url);
408
409        assert_safe(&message);
410        // And it is still an error somebody can act on: which host, and
411        // what the runtime actually said.
412        assert!(
413            message.contains("https://api.push.apple.com"),
414            "the destination is named: {message}"
415        );
416        assert!(
417            message.contains("Fetch API cannot load"),
418            "the cause survives the redaction: {message}"
419        );
420    }
421
422    /// The property the native port found and this one had to be checked
423    /// for separately: `Display` is nearly empty for the variants that
424    /// wrap another error, and the detail lives in `source()`. Walking the
425    /// chain is what makes the redacted message *better* than the leaky
426    /// one — and a cause quotes whatever it was handed, so it is scrubbed
427    /// too.
428    #[test]
429    fn the_cause_chain_is_walked_and_scrubbed() {
430        let url = apns_url();
431        let inner =
432            std::io::Error::other(format!("connection refused sending to /3/device/{TOKEN}"));
433        let err = worker::Error::Io(inner);
434
435        assert_eq!(
436            err.to_string(),
437            "I/O error",
438            "if `Display` ever carries the cause, this pass can stop appending it"
439        );
440
441        let message = safe_message(&err, &url);
442
443        assert_safe(&message);
444        assert!(
445            message.contains("connection refused"),
446            "the cause is what makes this diagnosable: {message}"
447        );
448    }
449
450    /// `scrub_text`'s own rules still apply on top: a cause is free to
451    /// quote a credential that has nothing to do with this request's URL.
452    #[test]
453    fn a_secret_a_cause_quotes_is_scrubbed_even_when_it_is_not_the_url() {
454        let url = apns_url();
455        let err = worker::Error::JsError(
456            "rejected the Authorization: Bearer abcdefghijklmnop12345 we sent".to_owned(),
457        );
458
459        let message = safe_message(&err, &url);
460
461        assert!(
462            !message.contains("abcdefghijklmnop12345"),
463            "scrub_text is the second net: {message}"
464        );
465    }
466
467    /// Every failure site in `send` hands a different error type to
468    /// [`transport`], and each one is a `Transport` variant whose message
469    /// has been through the redaction — including the two that are not
470    /// `worker::Error` at all.
471    #[test]
472    fn every_error_type_a_failure_site_produces_is_redacted() {
473        let url = apns_url();
474        let quoting = |what: &str| format!("{what} at {url}");
475
476        // 1. A non-UTF-8 request body (`String::from_utf8`).
477        let utf8 = String::from_utf8(vec![0xff, 0xfe]).expect_err("not UTF-8");
478        // 2-4. Request construction, `Fetch::send`, and reading the body,
479        //      all of which fail as `worker::Error` — as a thrown JS
480        //      string, and as a wrapped Rust error with a cause.
481        let js = worker::Error::JsError(quoting("TypeError: Fetch API cannot load:"));
482        let rust = worker::Error::RustError(quoting("could not construct a request"));
483        let io = worker::Error::Io(std::io::Error::other(quoting("network connection lost")));
484        // 5. Building the `http::Response` back (`http::Error`).
485        let http_err = http::Response::builder()
486            .status(200)
487            .header("x-\u{0}bad", "v")
488            .body(())
489            .expect_err("an invalid header name is a builder error");
490
491        let sites: [&dyn std::error::Error; 5] = [&utf8, &js, &rust, &io, &http_err];
492        for err in sites {
493            let HttpError::Transport(message) = transport(err, &url) else {
494                panic!("every site reports a transport failure");
495            };
496            assert_safe(&message);
497        }
498    }
499
500    /// The five sites were five copies of `err.to_string()`, which is how
501    /// one leak became five. Only one of them may build the error now, so
502    /// a sixth site added later cannot quietly reintroduce the pattern:
503    /// the module has exactly one `HttpError::Transport(` in it, in
504    /// [`transport`].
505    #[test]
506    fn only_one_place_in_this_module_builds_a_transport_error() {
507        let source = include_str!("http.rs");
508        let module = source
509            .split_once("#[cfg(test)]")
510            .map_or(source, |(before, _)| before);
511
512        assert_eq!(
513            module.matches("HttpError::Transport(").count(),
514            1,
515            "a failure site must call `transport`, not build the error itself"
516        );
517    }
518    /// Redirects are answered, not taken: following one would let an
519    /// upstream move the exchange to another host, or down to `http`,
520    /// after this port had already chosen the destination. The init the
521    /// request is built from therefore asks for `Manual` exactly once,
522    /// and the caller gets the 3xx with its `Location` and decides.
523    #[test]
524    fn a_redirect_is_never_followed_by_the_transport_request() {
525        let source = include_str!("http.rs");
526        let module = source
527            .split_once("#[cfg(test)]")
528            .map_or(source, |(before, _)| before);
529
530        // Not a containment check: `with_redirect` and `Manual` also
531        // appear in prose and would be "present" even if the call were
532        // commented out. What matters is this exact call, on the init
533        // the request is actually built from.
534        assert_eq!(
535            module
536                .matches("init.with_redirect(RequestRedirect::Manual)")
537                .count(),
538            1,
539            "the init the request is built from must ask for `Manual` exactly once"
540        );
541    }
542
543    // ---------------------------------------------------- the capped read
544    //
545    // `worker::ByteStream` cannot be constructed outside wasm, so the
546    // reader is generic and these drive it with synthetic streams in the
547    // shape workerd delivers: `Vec<u8>` per chunk, `worker::Error` on a
548    // chunk the runtime could not read.
549
550    /// The cap every capped-read test is measured against.
551    const LIMIT: usize = 4096;
552    /// One chunk's worth of bytes; four of them fill `LIMIT` exactly.
553    const CHUNK: usize = 1024;
554
555    /// A body of `chunks` chunks of `CHUNK` bytes, plus a count of how
556    /// many were handed out, so a test can see how far the reader got
557    /// before it stopped.
558    fn counting_chunks(
559        chunks: usize,
560    ) -> (
561        impl Stream<Item = worker::Result<Vec<u8>>>,
562        Arc<AtomicUsize>,
563    ) {
564        let pulled = Arc::new(AtomicUsize::new(0));
565        let counter = Arc::clone(&pulled);
566        let body = stream::iter(0..chunks).map(move |_| {
567            counter.fetch_add(1, Ordering::SeqCst);
568            Ok(vec![b'x'; CHUNK])
569        });
570        (body, pulled)
571    }
572
573    /// The cap refuses a body it has no header for. A body that arrives
574    /// in chunks, each one inside the cap and the total well past it, is
575    /// refused mid-read; and one chunk larger than the whole cap is
576    /// refused too, because the check runs before the append.
577    #[test]
578    fn a_body_past_the_cap_is_refused_however_it_arrives() {
579        let url = apns_url();
580        let (chunked, _) = counting_chunks(6);
581        let one_big: Vec<worker::Result<Vec<u8>>> = vec![Ok(vec![b'x'; LIMIT + 1])];
582
583        let refusals = [
584            pollster::block_on(read_capped(chunked, LIMIT, &url)),
585            pollster::block_on(read_capped(stream::iter(one_big), LIMIT, &url)),
586        ];
587
588        for got in refusals {
589            assert!(
590                matches!(got, Err(HttpError::ResponseTooLarge { limit: LIMIT })),
591                "a body past the cap must be refused, got {got:?}"
592            );
593        }
594    }
595
596    /// A body under the cap is the ordinary case and must come back whole
597    /// and in order.
598    #[test]
599    fn a_body_under_the_cap_is_read_whole_and_byte_identical() {
600        let url = apns_url();
601        let parts: Vec<worker::Result<Vec<u8>>> = vec![
602            Ok(b"hello ".to_vec()),
603            Ok(b"capped ".to_vec()),
604            Ok(b"world".to_vec()),
605        ];
606
607        let body = pollster::block_on(read_capped(stream::iter(parts), LIMIT, &url))
608            .expect("a body under the cap must read");
609
610        assert_eq!(body, Bytes::from_static(b"hello capped world"));
611    }
612
613    /// Refusing has to be an abort, not a drain: the reader stops at the
614    /// chunk that crosses the cap, which is the whole point against an
615    /// upstream that never stops sending.
616    #[test]
617    fn the_reader_aborts_at_the_cap_instead_of_draining_the_stream() {
618        let url = apns_url();
619        let (chunks, pulled) = counting_chunks(64);
620
621        let err = pollster::block_on(read_capped(chunks, LIMIT, &url))
622            .expect_err("an endless body must be refused, not buffered");
623        assert!(
624            matches!(err, HttpError::ResponseTooLarge { limit: LIMIT }),
625            "got {err}"
626        );
627
628        assert_eq!(
629            pulled.load(Ordering::SeqCst),
630            LIMIT / CHUNK + 1,
631            "reading stops at the chunk that crosses the cap, and nowhere later"
632        );
633    }
634
635    /// A chunk workerd could not read is a failure like any other, and
636    /// goes through [`transport`] — so the redaction this module exists
637    /// to apply still applies, and the streaming read adds no new site
638    /// that could bypass it.
639    #[test]
640    fn a_stream_error_is_reported_through_transport_and_scrubbed() {
641        let url = apns_url();
642        let parts: Vec<worker::Result<Vec<u8>>> = vec![
643            Ok(b"ok".to_vec()),
644            Err(worker::Error::JsError(format!(
645                "TypeError: network error reading {url}"
646            ))),
647        ];
648
649        let err = pollster::block_on(read_capped(stream::iter(parts), LIMIT, &url))
650            .expect_err("an unreadable chunk must fail the send");
651
652        let HttpError::Transport(message) = &err else {
653            panic!("a stream error is reported as a transport failure, got {err}");
654        };
655        assert_safe(message);
656        assert!(
657            message.contains("network error reading"),
658            "the cause survives the redaction: {message}"
659        );
660    }
661
662    /// The `status_only` wiring in `send`, which cannot run off-wasm: the
663    /// header copy is where the marker is honoured, so that decision lives
664    /// in a helper the tests can drive. A status-only reply keeps the
665    /// headers a probe wants — the endpoint in `location`, the answer's
666    /// shape in `content-type` — and loses every header describing a body
667    /// it is not getting, in any casing. A `content-length` left there is a
668    /// length nothing is behind, and `BoundedHttpClient` refuses a declared
669    /// length over the cap, so a probe of a large resource would fail on a
670    /// length it never asked for.
671    #[test]
672    fn a_status_only_response_keeps_every_header_that_does_not_describe_a_body() {
673        let reply: Vec<(String, String)> = [
674            ("content-length", "4194304"),
675            ("Content-Length", "4194304"),
676            ("content-encoding", "gzip"),
677            ("transfer-encoding", "chunked"),
678            ("content-type", "application/json"),
679            ("location", "https://example.com/hook"),
680        ]
681        .into_iter()
682        .map(|(name, value)| (name.to_owned(), value.to_owned()))
683        .collect();
684
685        let probe = response_headers(reply.clone(), true);
686        for dropped in [
687            http::header::CONTENT_LENGTH,
688            http::header::CONTENT_ENCODING,
689            http::header::TRANSFER_ENCODING,
690        ] {
691            assert_eq!(
692                probe.get(&dropped),
693                None,
694                "a probe reads no body, so {dropped:?} describes nothing"
695            );
696        }
697        for kept in [
698            (http::header::CONTENT_TYPE, "application/json"),
699            (http::header::LOCATION, "https://example.com/hook"),
700        ] {
701            assert_eq!(
702                probe.get(kept.0),
703                Some(&http::HeaderValue::from_static(kept.1)),
704                "a header that is not about the body is still the caller's"
705            );
706        }
707
708        // An ordinary send keeps every one of them: the marker decides,
709        // not the header.
710        let whole = response_headers(reply, false);
711        assert_eq!(
712            whole.get(http::header::CONTENT_LENGTH),
713            Some(&http::HeaderValue::from_static("4194304")),
714            "a body that is really there is still described"
715        );
716        assert_eq!(
717            whole.get(http::header::CONTENT_ENCODING),
718            Some(&http::HeaderValue::from_static("gzip"))
719        );
720    }
721
722    /// `worker::Response::stream()` refuses every body that is not a
723    /// stream, and `ResponseBody::Empty` is what the platform hands over for
724    /// a null-body status (101/103/204/205/304) and for the reply to a
725    /// `HEAD`. A 204 is the commonest "accepted, nothing to say" an
726    /// upstream sends; reading it as a transport failure would break every
727    /// adapter on this harness. The other half: a body that *is* a stream
728    /// is still read under the cap, so this is a read and not a swallow.
729    #[test]
730    fn a_null_body_is_empty_and_a_streamable_one_is_still_read() {
731        let url = apns_url();
732        let unstreamable: worker::Result<
733            futures_util::stream::Iter<std::vec::IntoIter<worker::Result<Vec<u8>>>>,
734        > = Err(worker::Error::RustError("body is not streamable".into()));
735        let parts: Vec<worker::Result<Vec<u8>>> =
736            vec![Ok(b"hello ".to_vec()), Ok(b"world".to_vec())];
737
738        let empty = pollster::block_on(capped_body(unstreamable, LIMIT, &url))
739            .expect("a 204 is a successful answer, not a failed read");
740        let read = pollster::block_on(capped_body(Ok(stream::iter(parts)), LIMIT, &url))
741            .expect("a streamable body must read");
742
743        assert!(empty.is_empty(), "a null body reads as empty: {empty:?}");
744        assert_eq!(read, Bytes::from_static(b"hello world"));
745    }
746}