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}