Skip to main content

gosub_sonar/net/
types.rs

1//! Core types for fetch requests, responses, errors, and priorities.
2
3use crate::net::cors::{self, CorsError, ResponseTainting};
4use crate::net::fetch_metadata::{RequestDestination, RequestMode};
5use crate::net::mixed_content::{is_origin_potentially_trustworthy, MixedContentPolicy};
6use crate::net::referrer::{self, ReferrerPolicy};
7use crate::net::request_ref::RequestReference;
8use crate::net::shared_body::SharedBody;
9use crate::net::tls::TlsError;
10use crate::net::utils::{normalize_url, short_hash, BytesAsyncReader};
11use crate::types::{PeekBuf, RequestId};
12use bytes::Bytes;
13use http::{header, HeaderMap, Method};
14use std::fmt::{Debug, Display};
15use std::hash::Hash;
16use std::pin::Pin;
17use std::sync::Arc;
18use tokio::io::{AsyncRead, ReadBuf};
19use url::{Origin, Url};
20
21/// Priority of the scheduled request. Documents usually have high priority, while images have low.
22/// Currently, the scheduler uses a round-robin system to load resources
23#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
24pub enum Priority {
25    /// Fetched before all lower priorities (e.g. primary documents)
26    High,
27    /// Default priority for most resources
28    #[default]
29    Normal,
30    /// Fetched after normal-priority resources (e.g. images)
31    Low,
32    /// Only fetched when nothing else is pending (e.g. prefetches)
33    Idle,
34}
35
36impl Display for Priority {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        let s = match self {
39            Priority::High => "High",
40            Priority::Normal => "Normal",
41            Priority::Low => "Low",
42            Priority::Idle => "Idle",
43        };
44        f.write_str(s)
45    }
46}
47
48/// Broad category of the resource being fetched.
49///
50/// Callers that need finer-grained classification can extend this at the
51/// application layer; the net crate only uses these values for logging and
52/// to pass them back through [`crate::net::fetcher_context::FetcherContext::observer_for`].
53#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
54pub enum ResourceKind {
55    /// Top-level or primary resource (e.g. a document, feed, or binary download)
56    #[default]
57    Primary,
58    /// Secondary asset loaded on behalf of a primary resource (e.g. image, font, script)
59    Asset,
60    /// Other or unspecified resource kind
61    Other,
62}
63
64/// Who or what triggered the fetch.
65///
66/// Passed back through [`crate::net::fetcher_context::FetcherContext::observer_for`], and
67/// [`User`](Initiator::User) marks a [`RequestMode::Navigate`] request as user-activated,
68/// which sends `Sec-Fetch-User: ?1` — see [`fetch_metadata`](crate::net::fetch_metadata).
69/// The net crate does not alter scheduling based on this value.
70#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
71pub enum Initiator {
72    /// Triggered by a user action (e.g. address bar, link click, button)
73    #[default]
74    User,
75    /// Triggered programmatically by the application
76    Application,
77    /// Other or unspecified initiator
78    Other,
79}
80
81/// Metadata returned by the FetchResult
82#[derive(Clone, Debug)]
83pub struct FetchResultMeta {
84    /// Final URL after redirects
85    pub final_url: Url,
86    /// HTTP status code
87    pub status: u16,
88    /// HTTP status reason phrase
89    pub status_text: String,
90    /// Response headers
91    pub headers: HeaderMap,
92    /// Length of the content (if known from headers)
93    pub content_length: Option<u64>,
94    /// Content-Type header (if any)
95    pub content_type: Option<String>,
96    /// True if the response has a body (e.g. HEAD requests do not)
97    pub has_body: bool,
98    /// How much of this response the initiating document's scripts may read. The fetcher
99    /// annotates; enforcing the visibility boundary is the embedder's job — see
100    /// [`readable_headers`](Self::readable_headers) and [`cors`].
101    ///
102    /// Always [`Basic`](ResponseTainting::Basic) on wasm32, where the browser has already
103    /// filtered what its `fetch()` exposes.
104    pub tainting: ResponseTainting,
105}
106
107impl FetchResultMeta {
108    /// The header view scripts may read, per this response's [`tainting`](Self::tainting):
109    /// everything but `Set-Cookie` for a basic response, the CORS-safelisted set plus
110    /// `Access-Control-Expose-Headers` for a CORS response, nothing for an opaque one.
111    ///
112    /// `credentials_include` is whether the request was made with
113    /// [`RequestCredentials::Include`] — it decides whether a `*` in
114    /// `Access-Control-Expose-Headers` counts as a wildcard. [`headers`](Self::headers)
115    /// itself stays complete either way.
116    pub fn readable_headers(&self, credentials_include: bool) -> HeaderMap {
117        cors::readable_headers(self.tainting, &self.headers, credentials_include)
118    }
119}
120
121/// Why a request hop was refused. The refused hop is never sent.
122///
123/// Carried by [`NetError::Blocked`] and [`NetEvent::Blocked`](crate::net::events::NetEvent::Blocked)
124/// so callers can distinguish a deliberate refusal from a transport failure without matching on
125/// error strings.
126#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
127pub enum BlockReason {
128    /// An insecure sub-resource was requested by a secure document.
129    /// See [`mixed_content`](crate::net::mixed_content).
130    MixedContent,
131    /// Rejected by [`FetcherContext::is_url_allowed`](crate::net::fetcher_context::FetcherContext::is_url_allowed).
132    UrlPolicy,
133    /// The URL scheme is not `http` or `https`.
134    UnsupportedScheme,
135    /// Refused by CORS — the carried [`CorsError`] says which rule.
136    /// See [`cors`].
137    Cors(CorsError),
138}
139
140impl Display for BlockReason {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        let s = match self {
143            BlockReason::MixedContent => "mixed content",
144            BlockReason::UrlPolicy => "blocked by URL policy",
145            BlockReason::UnsupportedScheme => "unsupported URL scheme",
146            BlockReason::Cors(err) => return write!(f, "CORS: {err}"),
147        };
148        f.write_str(s)
149    }
150}
151
152/// Network-level errors.
153#[derive(Debug, thiserror::Error, Clone)]
154pub enum NetError {
155    /// Request hop refused by policy. Applies to the initial URL and to every redirect target.
156    #[error("net error: blocked: {reason}: {url}")]
157    Blocked {
158        /// Why the request was refused
159        reason: BlockReason,
160        /// URL that was refused. On a redirect chain this is the hop that was blocked,
161        /// not the URL originally requested.
162        url: Url,
163    },
164
165    /// Error reported by the underlying HTTP client
166    #[error("net error: reqwest: {0}")]
167    Reqwest(#[from] Arc<reqwest::Error>),
168
169    /// TLS handshake failed (expired certificate, unknown issuer, wrong host name, ...).
170    /// Native only.
171    #[error("net error: tls: {0}")]
172    Tls(TlsError),
173
174    /// Redirect could not be followed (e.g. too many redirects, invalid target)
175    #[error("net error: redirect: {0}")]
176    Redirect(Arc<anyhow::Error>),
177
178    /// I/O error while transferring data
179    #[error("net error: I/O: {0}")]
180    Io(#[from] Arc<std::io::Error>),
181
182    /// Request was cancelled before it completed; the string describes why
183    #[error("net error: cancelled: {0}")]
184    Cancelled(String),
185
186    /// Error while reading the response body
187    #[error(transparent)]
188    Read(Arc<anyhow::Error>),
189
190    /// Any other error not covered by the variants above
191    #[error(transparent)]
192    Other(Arc<anyhow::Error>),
193
194    /// Request did not complete within the configured time limit
195    #[error("net error: timeout: {0}")]
196    Timeout(String),
197}
198
199impl From<std::io::Error> for NetError {
200    fn from(e: std::io::Error) -> Self {
201        NetError::Io(Arc::new(e))
202    }
203}
204
205impl NetError {
206    /// Wrap this error in an `io::Error`, carrying the typed error as the source so the other
207    /// side of an `AsyncRead` boundary can recover the original `NetError` (see
208    /// `stream_to_bytes`) instead of a stringified copy.
209    pub fn to_io(&self) -> std::io::Error {
210        std::io::Error::other(self.clone())
211    }
212
213    /// Wraps an [`anyhow::Error`] as a [`NetError::Read`]
214    pub fn from_anyhow(e: anyhow::Error) -> Self {
215        Self::Read(Arc::new(e))
216    }
217}
218
219/// Marker for types that must be [`Send`] on native targets. On wasm32 the crate runs
220/// single-threaded and its fetch-backed streams wrap `!Send` JS types, so the bound is empty.
221#[cfg(not(target_arch = "wasm32"))]
222pub trait MaybeSend: Send {}
223#[cfg(not(target_arch = "wasm32"))]
224impl<T: Send> MaybeSend for T {}
225/// Marker for types that must be [`Send`] on native targets. On wasm32 the crate runs
226/// single-threaded and its fetch-backed streams wrap `!Send` JS types, so the bound is empty.
227#[cfg(target_arch = "wasm32")]
228pub trait MaybeSend {}
229#[cfg(target_arch = "wasm32")]
230impl<T> MaybeSend for T {}
231
232/// Boxed async reader backing [`BodyStream`]: `Send` on native targets, plain on wasm32
233/// (see [`MaybeSend`]).
234#[cfg(not(target_arch = "wasm32"))]
235pub type BoxedAsyncRead = Pin<Box<dyn AsyncRead + Send + 'static>>;
236/// Boxed async reader backing [`BodyStream`]: `Send` on native targets, plain on wasm32
237/// (see [`MaybeSend`]).
238#[cfg(target_arch = "wasm32")]
239pub type BoxedAsyncRead = Pin<Box<dyn AsyncRead + 'static>>;
240
241/// A BodyStream is an async reader that can be used to read the body of a response.
242pub struct BodyStream {
243    /// Inner reader
244    inner: BoxedAsyncRead,
245    /// Content length (if known)
246    pub len: Option<u64>,
247    /// True when the stream is seekable (most often not, unless it's backed by a memory buffer)
248    pub is_seekable: bool,
249    /// Can be cloned to create a new independent stream starting at the beginning
250    pub clonable: bool,
251}
252
253impl Debug for BodyStream {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        f.debug_struct("BodyStream")
256            .field("len", &self.len)
257            .field("is_seekable", &self.is_seekable)
258            .field("clonable", &self.clonable)
259            .finish()
260    }
261}
262
263impl BodyStream {
264    /// Creates a non-seekable, non-clonable stream from the given reader and optional length
265    pub fn new(inner: BoxedAsyncRead, len: Option<u64>) -> Self {
266        Self {
267            inner,
268            len,
269            is_seekable: false,
270            clonable: false,
271        }
272    }
273
274    /// Converts a series of bytes into a body stream
275    pub fn from_bytes(bytes: Bytes) -> Self {
276        let len = bytes.len() as u64;
277        let reader = Box::pin(BytesAsyncReader {
278            data: bytes,
279            pos: 0,
280        });
281        Self {
282            inner: reader,
283            len: Some(len),
284            is_seekable: true, // It's a buffer so we can seek it
285            clonable: true,    // It's a buffer so we can clone it
286        }
287    }
288}
289
290impl AsyncRead for BodyStream {
291    fn poll_read(
292        mut self: Pin<&mut Self>,
293        cx: &mut std::task::Context<'_>,
294        buf: &mut ReadBuf<'_>,
295    ) -> std::task::Poll<std::io::Result<()>> {
296        self.inner.as_mut().poll_read(cx, buf)
297    }
298}
299
300/// Opens a fresh reader over the body contents, once per send attempt: a 307/308 redirect
301/// replays the body by calling it again, which a one-shot reader could not survive.
302#[cfg(not(target_arch = "wasm32"))]
303pub type BodyStreamFactory =
304    Arc<dyn Fn() -> std::io::Result<BoxedAsyncRead> + Send + Sync + 'static>;
305
306/// Body sent with a non-GET request (POST, PUT, PATCH, …).
307///
308/// Either buffered bytes or a stream opened at send time (see [`RequestBody::stream`] and
309/// [`RequestBody::file`]). The `content_type` field is automatically injected as a
310/// `Content-Type` header when the request headers do not already contain one. The caller is
311/// responsible for encoding the body correctly (JSON, form-encoding, multipart, etc.).
312#[derive(Clone, Default)]
313pub struct RequestBody {
314    payload: Payload,
315    /// Optional `Content-Type` value to inject (e.g. `"application/json"`).
316    /// Ignored if the request headers already set `Content-Type`.
317    pub content_type: Option<String>,
318}
319
320#[derive(Clone)]
321enum Payload {
322    Bytes(Bytes),
323    #[cfg(not(target_arch = "wasm32"))]
324    Stream {
325        open: BodyStreamFactory,
326        len: Option<u64>,
327    },
328}
329
330impl Default for Payload {
331    fn default() -> Self {
332        Payload::Bytes(Bytes::new())
333    }
334}
335
336impl Debug for RequestBody {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        let mut d = f.debug_struct("RequestBody");
339        match &self.payload {
340            Payload::Bytes(b) => d.field("bytes", &b.len()),
341            #[cfg(not(target_arch = "wasm32"))]
342            Payload::Stream { len, .. } => d.field("stream", len),
343        };
344        d.field("content_type", &self.content_type).finish()
345    }
346}
347
348impl RequestBody {
349    /// Plain byte body with no automatic `Content-Type`.
350    pub fn bytes(b: impl Into<Bytes>) -> Self {
351        Self {
352            payload: Payload::Bytes(b.into()),
353            content_type: None,
354        }
355    }
356
357    /// `application/json` body.
358    pub fn json(b: impl Into<Bytes>) -> Self {
359        Self {
360            content_type: Some("application/json".into()),
361            ..Self::bytes(b)
362        }
363    }
364
365    /// `application/x-www-form-urlencoded` body.
366    pub fn form(b: impl Into<Bytes>) -> Self {
367        Self {
368            content_type: Some("application/x-www-form-urlencoded".into()),
369            ..Self::bytes(b)
370        }
371    }
372
373    /// `text/plain; charset=utf-8` body.
374    pub fn text(s: impl Into<String>) -> Self {
375        Self {
376            content_type: Some("text/plain; charset=utf-8".into()),
377            ..Self::bytes(s.into().into_bytes())
378        }
379    }
380
381    /// Body streamed from a reader opened at send time, without buffering it in memory.
382    ///
383    /// With `len` set, `Content-Length` is sent; without it the transfer is chunked. The
384    /// fetcher's `req_timeout` covers the upload, so large bodies may need a higher value.
385    #[cfg(not(target_arch = "wasm32"))]
386    pub fn stream(
387        open: impl Fn() -> std::io::Result<BoxedAsyncRead> + Send + Sync + 'static,
388        len: Option<u64>,
389    ) -> Self {
390        Self {
391            payload: Payload::Stream {
392                open: Arc::new(open),
393                len,
394            },
395            content_type: None,
396        }
397    }
398
399    /// Body streamed from a file on disk, opened at send time.
400    ///
401    /// `Content-Length` is taken from its current size, so the file must not change until
402    /// the request completes.
403    #[cfg(not(target_arch = "wasm32"))]
404    pub fn file(path: impl Into<std::path::PathBuf>) -> std::io::Result<Self> {
405        let path = path.into();
406        let len = std::fs::metadata(&path)?.len();
407        Ok(Self::stream(
408            move || {
409                let f = std::fs::File::open(&path)?;
410                Ok(Box::pin(tokio::fs::File::from_std(f)) as BoxedAsyncRead)
411            },
412            Some(len),
413        ))
414    }
415
416    /// The buffered bytes, or `None` when the body is streamed.
417    pub fn as_bytes(&self) -> Option<&Bytes> {
418        match &self.payload {
419            Payload::Bytes(b) => Some(b),
420            #[cfg(not(target_arch = "wasm32"))]
421            Payload::Stream { .. } => None,
422        }
423    }
424
425    /// Number of body bytes, or `None` for a stream without a declared length.
426    pub fn len(&self) -> Option<u64> {
427        match &self.payload {
428            Payload::Bytes(b) => Some(b.len() as u64),
429            #[cfg(not(target_arch = "wasm32"))]
430            Payload::Stream { len, .. } => *len,
431        }
432    }
433
434    /// Returns true when the body is known to contain no bytes.
435    pub fn is_empty(&self) -> bool {
436        self.len() == Some(0)
437    }
438
439    /// Build the reqwest body for one hop. The returned length, when present, must be sent
440    /// as an explicit `Content-Length`: a wrapped stream is unsized as far as reqwest knows.
441    pub(crate) fn to_reqwest_body(&self) -> std::io::Result<(reqwest::Body, Option<u64>)> {
442        match &self.payload {
443            Payload::Bytes(b) => Ok((reqwest::Body::from(b.clone()), None)),
444            #[cfg(not(target_arch = "wasm32"))]
445            Payload::Stream { open, len } => {
446                let reader = open()?;
447                let stream = tokio_util::io::ReaderStream::new(reader);
448                Ok((reqwest::Body::wrap_stream(stream), *len))
449            }
450        }
451    }
452}
453
454/// Whether credentials — cookies from the
455/// [`FetcherContext`](crate::net::fetcher_context::FetcherContext) jar — ride along with a
456/// request
457/// ([Fetch §2.2.5], *credentials mode*).
458///
459/// This gates only what the fetcher itself attaches; headers set by hand in
460/// [`FetchRequest::headers`] (e.g. `Authorization`) are the embedder's own decision and go out
461/// regardless. Beyond cookies, the mode also drives the CORS rules: a credentialed cross-origin
462/// request needs `Access-Control-Allow-Credentials` and cannot be authorized by a wildcard.
463///
464/// [Fetch §2.2.5]: https://fetch.spec.whatwg.org/#concept-request-credentials-mode
465#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
466pub enum RequestCredentials {
467    /// Never attach credentials.
468    Omit,
469    /// Attach credentials only on hops to the initiating origin. Without a
470    /// [`FetchRequest::origin`] there is no origin to compare against, so this behaves as
471    /// [`Include`](Self::Include).
472    SameOrigin,
473    /// Always attach credentials. The default, matching how browsers load markup
474    /// subresources (the fetcher's default [`RequestMode::NoCors`]); use
475    /// [`SameOrigin`](Self::SameOrigin) for `fetch()`-style requests.
476    #[default]
477    Include,
478}
479
480/// A fetch request defines what needs to be fetched, how and where to send the result to
481#[derive(Debug, Clone)]
482pub struct FetchRequest {
483    /// Reference to what initiated this request (navigation, document, prefetch, background task)
484    pub reference: RequestReference,
485    /// Unique ID of this request (for logging and tracking)
486    pub req_id: RequestId,
487    /// Priority of this request
488    pub priority: Priority,
489    /// Who initiated this request
490    pub initiator: Initiator,
491    /// What kind of resource is being fetched
492    pub kind: ResourceKind,
493    /// Whether to stream the response body or buffer it fully before returning
494    pub streaming: bool,
495    /// Auto decode the request (if for instance, gzipped), or pass directly through to the caller
496    pub auto_decode: bool,
497    /// Maximum amount of (buffered) bytes we can fetch
498    pub max_bytes: Option<usize>,
499    /// HTTP Method used
500    pub method: Method,
501    /// Target Url
502    pub url: Url,
503    /// Origin of the document that initiated this request, used for mixed content checks and
504    /// to compute the `Origin` and `Sec-Fetch-Site` headers.
505    ///
506    /// `None` means "no document context": mixed content blocking is disabled for this request
507    /// entirely, no `Origin` header is computed, and `Sec-Fetch-Site` reports `none` (a request
508    /// not triggered by web content). Set it whenever a request is made on behalf of a page.
509    /// See [`mixed_content`](crate::net::mixed_content) and
510    /// [`fetch_metadata`](crate::net::fetch_metadata).
511    pub origin: Option<Origin>,
512    /// Overrides [`FetcherConfig::mixed_content`](crate::net::fetcher::FetcherConfig::mixed_content)
513    /// for this one request; `None` uses the fetcher-wide setting. Requires `origin`.
514    pub mixed_content: Option<MixedContentPolicy>,
515    /// URL of the document that initiated this request, used to compute the `Referer` header.
516    ///
517    /// `None` sends no `Referer` at all. Any `Referer` set by hand in `headers` is overwritten
518    /// when this is set. See [`referrer`](mod@crate::net::referrer).
519    pub referrer: Option<Url>,
520    /// How much of `referrer` to reveal. Ignored when `referrer` is `None`.
521    pub referrer_policy: ReferrerPolicy,
522    /// What the resource will be used as, sent in `Sec-Fetch-Dest`.
523    /// See [`fetch_metadata`](crate::net::fetch_metadata).
524    pub destination: RequestDestination,
525    /// The request's mode, sent in `Sec-Fetch-Mode` and selecting the CORS regime.
526    /// See [`fetch_metadata`](crate::net::fetch_metadata) and [`cors`].
527    pub mode: RequestMode,
528    /// Whether cookies from the context's jar ride along, and how strict the CORS
529    /// credentialed rules are. See [`RequestCredentials`].
530    pub credentials: RequestCredentials,
531    /// HTTP Headers (unified).
532    pub headers: HeaderMap,
533    /// Optional request body (for POST, PUT, PATCH, DELETE, etc.).
534    /// `None` for GET and HEAD requests.
535    pub body: Option<RequestBody>,
536}
537
538impl FetchRequest {
539    /// Gives a FetchRequestBuilder
540    pub fn builder(method: Method, url: Url) -> FetchRequestBuilder {
541        FetchRequestBuilder::new(method, url)
542    }
543
544    /// Generates a key for coalescing in-flight requests based on the request's method, URL, and headers.
545    pub fn generate_request_key(&self) -> Option<String> {
546        match self.method {
547            Method::GET | Method::HEAD => {}
548            _ => return None,
549        }
550
551        let url = normalize_url(&self.url);
552        let h = &self.headers;
553
554        let range = h
555            .get(header::RANGE)
556            .and_then(|v| v.to_str().ok())
557            .unwrap_or("");
558        let accept = h
559            .get(header::ACCEPT)
560            .and_then(|v| v.to_str().ok())
561            .unwrap_or("");
562        let accept_enc = h
563            .get(header::ACCEPT_ENCODING)
564            .and_then(|v| v.to_str().ok())
565            .unwrap_or("");
566        let accept_lang = h
567            .get(header::ACCEPT_LANGUAGE)
568            .and_then(|v| v.to_str().ok())
569            .unwrap_or("");
570
571        let auth_hash = h
572            .get(header::AUTHORIZATION)
573            .map(|v| format!("{:x}", short_hash(v.as_bytes())))
574            .unwrap_or_default();
575        let cookie_hash = h
576            .get(header::COOKIE)
577            .map(|v| format!("{:x}", short_hash(v.as_bytes())))
578            .unwrap_or_default();
579
580        // Requests are only interchangeable if they reach the same mixed content verdict —
581        // otherwise a permitted fetch would be handed to a subscriber that should have been
582        // blocked.
583        //
584        // This must NOT be narrowed by inspecting `self.url`: enforcement is per-hop, and a
585        // trustworthy https URL can 302 onto plain http. Bucketing an https request as
586        // "mixed content cannot apply" would let a leader with no origin follow that redirect
587        // and hand the http body to a secure-origin follower that asked to be blocked.
588        let mixed_content = if !self
589            .origin
590            .as_ref()
591            .is_some_and(is_origin_potentially_trustworthy)
592        {
593            // No secure document to protect, so every hop of this request resolves to Allow
594            // whatever the policy says. All such requests share one bucket.
595            "n"
596        } else {
597            // The fetcher-wide default is constant across one fetcher, so the per-request
598            // policy is all that can distinguish two verdicts here. The fetcher resolves
599            // `None` to the effective policy before keying; it only survives for requests
600            // keyed outside the scheduler.
601            match self.mixed_content {
602                None => "default",
603                Some(MixedContentPolicy::Allow) => "allow",
604                Some(MixedContentPolicy::Upgrade) => "upgrade",
605                Some(MixedContentPolicy::Block) => "block",
606            }
607        };
608
609        // Servers vary on `Referer` — hotlink protection is the common case — so requests that
610        // would send different values must not share a response.
611        //
612        // Keyed on the *inputs* rather than the value computed for `self.url`: the header is
613        // recomputed at every hop, so two requests that agree on the first hop can still diverge
614        // after a redirect.
615        let referrer = match self.referrer.as_ref() {
616            Some(r) if !referrer::never_sends(r, self.referrer_policy) => {
617                // Under an origin-only policy the path can never be revealed on any hop, so
618                // every page on one origin sends byte-identical values and can share a bucket.
619                // The rest must split on the full URL.
620                let source = match self.referrer_policy {
621                    ReferrerPolicy::Origin | ReferrerPolicy::StrictOrigin => {
622                        r.origin().ascii_serialization()
623                    }
624                    _ => r.as_str().to_string(),
625                };
626                // An explicit token rather than `{:?}`, so renaming a variant cannot silently
627                // change how requests bucket.
628                let policy = match self.referrer_policy {
629                    ReferrerPolicy::NoReferrer => "no-referrer",
630                    ReferrerPolicy::NoReferrerWhenDowngrade => "no-referrer-when-downgrade",
631                    ReferrerPolicy::SameOrigin => "same-origin",
632                    ReferrerPolicy::Origin => "origin",
633                    ReferrerPolicy::StrictOrigin => "strict-origin",
634                    ReferrerPolicy::OriginWhenCrossOrigin => "origin-when-cross-origin",
635                    ReferrerPolicy::StrictOriginWhenCrossOrigin => {
636                        "strict-origin-when-cross-origin"
637                    }
638                    ReferrerPolicy::UnsafeUrl => "unsafe-url",
639                };
640                format!("{:x}:{}", short_hash(source.as_bytes()), policy)
641            }
642            // No referrer of our own to send. A hand-set `Referer` still goes out verbatim on the
643            // first hop, and it varies the response just as a computed one would, so it has to
644            // vary the key too.
645            _ => match self.headers.get(header::REFERER) {
646                Some(manual) => format!("h{:x}", short_hash(manual.as_bytes())),
647                None => "n".to_string(),
648            },
649        };
650
651        // `Origin` and `Sec-Fetch-*` vary the response (CORS allowlists, resource isolation
652        // policies), so requests that would send different values must not share one. Like the
653        // referrer, `Sec-Fetch-Site` and `Origin` are recomputed at every hop, so this keys on
654        // the initiating origin itself rather than on the values computed for the first hop.
655        let fetch_meta = {
656            let origin = match self.origin.as_ref() {
657                Some(o) => format!("{:x}", short_hash(o.ascii_serialization().as_bytes())),
658                // A hand-set `Origin` header goes out verbatim on the first hop, so it has to
659                // vary the key just like a computed one — mirroring the referrer above.
660                None => match self.headers.get(header::ORIGIN) {
661                    Some(manual) => format!("h{:x}", short_hash(manual.as_bytes())),
662                    None => "n".to_string(),
663                },
664            };
665            let user = if self.mode == RequestMode::Navigate && self.initiator == Initiator::User {
666                "u"
667            } else {
668                "-"
669            };
670            format!(
671                "{}:{}:{}:{}",
672                self.destination.as_str(),
673                self.mode.as_str(),
674                origin,
675                user
676            )
677        };
678
679        // The credentials mode decides whether the jar's cookies are attached on each hop —
680        // after the Cookie hash above is computed — so requests that differ on it must not
681        // share a response. It also varies the CORS verdict (wildcard vs credentialed rules).
682        let credentials = match self.credentials {
683            RequestCredentials::Omit => "omit",
684            RequestCredentials::SameOrigin => "same-origin",
685            RequestCredentials::Include => "include",
686        };
687
688        Some(format!(
689            "M={};U={};R={};A={};AL={};AE={};Auth={};C={};MC={};Ref={};FM={};Cred={}",
690            self.method,
691            url,
692            range,
693            accept,
694            accept_lang,
695            accept_enc,
696            auth_hash,
697            cookie_hash,
698            mixed_content,
699            referrer,
700            fetch_meta,
701            credentials
702        ))
703    }
704}
705
706/// Builder for [`FetchRequest`], created via [`FetchRequest::builder`].
707///
708/// All settings are optional; `build()` produces a buffered, decoding request
709/// with [`Priority::Normal`] unless configured otherwise.
710pub struct FetchRequestBuilder {
711    reference: RequestReference,
712    req_id: RequestId,
713    priority: Priority,
714    initiator: Initiator,
715    kind: ResourceKind,
716    streaming: bool,
717    auto_decode: bool,
718    max_bytes: Option<usize>,
719    method: Method,
720    headers: HeaderMap,
721    url: Url,
722    origin: Option<Origin>,
723    mixed_content: Option<MixedContentPolicy>,
724    referrer: Option<Url>,
725    referrer_policy: ReferrerPolicy,
726    destination: RequestDestination,
727    mode: RequestMode,
728    credentials: RequestCredentials,
729    body: Option<RequestBody>,
730}
731
732impl FetchRequestBuilder {
733    /// Create a new FetchRequestBuilder
734    pub fn new(method: Method, url: Url) -> Self {
735        Self {
736            url,
737            method,
738            headers: HeaderMap::default(),
739            reference: RequestReference::default(),
740            req_id: RequestId::default(),
741            priority: Priority::default(),
742            initiator: Initiator::default(),
743            kind: ResourceKind::default(),
744            streaming: false,
745            auto_decode: true,
746            max_bytes: None,
747            origin: None,
748            mixed_content: None,
749            referrer: None,
750            referrer_policy: ReferrerPolicy::default(),
751            destination: RequestDestination::default(),
752            mode: RequestMode::default(),
753            credentials: RequestCredentials::default(),
754            body: None,
755        }
756    }
757
758    /// Sets a reference for the request
759    pub fn with_reference(mut self, reference: RequestReference) -> Self {
760        self.reference = reference;
761        self
762    }
763
764    /// Sets an ID for the request
765    pub fn with_req_id(mut self, req_id: RequestId) -> Self {
766        self.req_id = req_id;
767        self
768    }
769
770    /// Sets the priority of the request
771    pub fn with_priority(mut self, priority: Priority) -> Self {
772        self.priority = priority;
773        self
774    }
775
776    /// Sets initiator of the request
777    pub fn with_initiator(mut self, initiator: Initiator) -> Self {
778        self.initiator = initiator;
779        self
780    }
781
782    /// Sets the kind property of the request
783    pub fn with_kind(mut self, kind: ResourceKind) -> Self {
784        self.kind = kind;
785        self
786    }
787
788    /// Sets whether to stream the response body instead of buffering it (default: buffered)
789    pub fn with_streaming(mut self, streaming: bool) -> Self {
790        self.streaming = streaming;
791        self
792    }
793
794    /// Sets whether to transparently decode compressed responses (default: true).
795    ///
796    /// With decoding on, [`with_max_bytes`](Self::with_max_bytes) caps the decompressed
797    /// size; set `false` to cap bytes as they arrive on the wire.
798    pub fn with_auto_decode(mut self, auto_decode: bool) -> Self {
799        self.auto_decode = auto_decode;
800        self
801    }
802
803    /// Sets the maximum number of body bytes to buffer (default: unlimited)
804    pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
805        self.max_bytes = Some(max_bytes);
806        self
807    }
808
809    /// Sets the request body (for POST, PUT, PATCH, etc.)
810    pub fn with_body(mut self, body: RequestBody) -> Self {
811        self.body = Some(body);
812        self
813    }
814
815    /// Sets the URL for the request
816    pub fn with_url(mut self, url: Url) -> Self {
817        self.url = url;
818        self
819    }
820
821    /// Sets the origin of the document initiating the request, enabling mixed content checks.
822    ///
823    /// Typically `document_url.origin()`. Leaving this unset disables mixed content blocking
824    /// for the request — see [`mixed_content`](crate::net::mixed_content).
825    pub fn with_origin(mut self, origin: Origin) -> Self {
826        self.origin = Some(origin);
827        self
828    }
829
830    /// Overrides the fetcher-wide mixed content policy. Use [`MixedContentPolicy::Allow`] for
831    /// optionally-blockable resources; requires [`with_origin`](Self::with_origin).
832    pub fn with_mixed_content(mut self, policy: MixedContentPolicy) -> Self {
833        self.mixed_content = Some(policy);
834        self
835    }
836
837    /// Sets the URL of the initiating document, enabling the `Referer` header.
838    /// Unset sends no referrer. See [`referrer`](mod@crate::net::referrer).
839    pub fn with_referrer(mut self, referrer: Url) -> Self {
840        self.referrer = Some(referrer);
841        self
842    }
843
844    /// Sets how much of the referrer to reveal. Requires [`with_referrer`](Self::with_referrer).
845    pub fn with_referrer_policy(mut self, policy: ReferrerPolicy) -> Self {
846        self.referrer_policy = policy;
847        self
848    }
849
850    /// Sets what the resource will be used as, sent in `Sec-Fetch-Dest`.
851    /// See [`fetch_metadata`](crate::net::fetch_metadata).
852    pub fn with_destination(mut self, destination: RequestDestination) -> Self {
853        self.destination = destination;
854        self
855    }
856
857    /// Sets the request's mode, sent in `Sec-Fetch-Mode` and selecting the CORS regime.
858    /// See [`fetch_metadata`](crate::net::fetch_metadata) and [`cors`].
859    pub fn with_mode(mut self, mode: RequestMode) -> Self {
860        self.mode = mode;
861        self
862    }
863
864    /// Sets whether cookies from the context's jar ride along (default:
865    /// [`RequestCredentials::Include`]). See [`RequestCredentials`].
866    pub fn with_credentials(mut self, credentials: RequestCredentials) -> Self {
867        self.credentials = credentials;
868        self
869    }
870
871    /// Sets the HTTP method of the request
872    pub fn with_method(mut self, method: Method) -> Self {
873        self.method = method;
874        self
875    }
876
877    /// Sets the headers for the request
878    pub fn with_headers(mut self, headers: HeaderMap) -> Self {
879        self.headers = headers;
880        self
881    }
882
883    /// Builds the [`FetchRequest`]
884    pub fn build(self) -> FetchRequest {
885        FetchRequest {
886            reference: self.reference,
887            req_id: self.req_id,
888            priority: self.priority,
889            initiator: self.initiator,
890            kind: self.kind,
891            streaming: self.streaming,
892            auto_decode: self.auto_decode,
893            max_bytes: self.max_bytes,
894            headers: self.headers,
895            method: self.method,
896            url: self.url,
897            origin: self.origin,
898            mixed_content: self.mixed_content,
899            referrer: self.referrer,
900            referrer_policy: self.referrer_policy,
901            destination: self.destination,
902            mode: self.mode,
903            credentials: self.credentials,
904            body: self.body,
905        }
906    }
907}
908
909/// FetchResult defines the resource response. Either a stream or buffered response are possible
910#[derive(Clone)]
911pub enum FetchResult {
912    /// Streamed response body
913    Stream {
914        /// Response metadata (status, headers, final URL)
915        meta: FetchResultMeta,
916        /// First bytes of the body, for content-type sniffing
917        peek_buf: PeekBuf,
918        /// Shared body that fans the stream out to all subscribers
919        shared: Arc<SharedBody>,
920    },
921    /// Buffered response body
922    Buffered {
923        /// Response metadata (status, headers, final URL)
924        meta: FetchResultMeta,
925        /// Complete response body
926        body: Bytes,
927    },
928    /// Network error occurred
929    Error(NetError),
930}
931
932impl FetchResult {
933    /// Returns true when the result is an error
934    pub fn is_error(&self) -> bool {
935        matches!(self, FetchResult::Error(_))
936    }
937
938    /// Return the metadata if available
939    pub fn meta(&self) -> Option<&FetchResultMeta> {
940        match self {
941            FetchResult::Stream { meta, .. } => Some(meta),
942            FetchResult::Buffered { meta, .. } => Some(meta),
943            FetchResult::Error(_) => None,
944        }
945    }
946}
947
948impl Debug for FetchResult {
949    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
950        match self {
951            FetchResult::Stream { meta, .. } => f
952                .debug_struct("FetchResult::Stream")
953                .field("meta", meta)
954                .finish(),
955            FetchResult::Buffered { meta, body } => f
956                .debug_struct("FetchResult::Buffered")
957                .field("meta", meta)
958                .field("body_len", &body.len())
959                .finish(),
960            FetchResult::Error(e) => f.debug_tuple("FetchResult::Error").field(e).finish(),
961        }
962    }
963}
964
965#[cfg(test)]
966mod tests {
967    use super::*;
968    use cow_utils::CowUtils;
969    use tokio::io::AsyncReadExt;
970
971    #[tokio::test(flavor = "current_thread")]
972    async fn bodystream_from_bytes_reads_all() {
973        let data = Bytes::from_static(b"hello world");
974        let mut s = BodyStream::from_bytes(data.clone());
975        assert_eq!(s.len, Some(11));
976        assert!(s.is_seekable);
977        assert!(s.clonable);
978
979        let mut out = Vec::new();
980        s.read_to_end(&mut out).await.unwrap();
981        assert_eq!(&out[..], &data[..]);
982
983        let n = s.read(&mut [0u8; 8]).await.unwrap();
984        assert_eq!(n, 0);
985    }
986
987    #[test]
988    fn stream_body_reports_len_and_no_bytes() {
989        let sized = RequestBody::stream(|| Ok(Box::pin(&b""[..]) as BoxedAsyncRead), Some(3));
990        assert_eq!(sized.len(), Some(3));
991        assert!(sized.as_bytes().is_none());
992        assert!(!sized.is_empty());
993
994        let unsized_body = RequestBody::stream(|| Ok(Box::pin(&b""[..]) as BoxedAsyncRead), None);
995        assert_eq!(unsized_body.len(), None);
996        assert!(!unsized_body.is_empty());
997
998        let buffered = RequestBody::bytes(&b"abc"[..]);
999        assert_eq!(buffered.len(), Some(3));
1000        assert_eq!(buffered.as_bytes().map(|b| b.len()), Some(3));
1001    }
1002
1003    #[test]
1004    fn builder_decodes_by_default() {
1005        let fr =
1006            FetchRequest::builder(Method::GET, Url::parse("https://example.org").unwrap()).build();
1007        assert!(fr.auto_decode);
1008    }
1009
1010    #[test]
1011    fn fetch_request_generate_get_and_headers() {
1012        let mut fr = FetchRequest::builder(
1013            Method::default(),
1014            Url::parse("https://example.org/a/b#frag").unwrap(),
1015        )
1016        .build();
1017        fr.headers
1018            .insert(header::RANGE, "bytes=0-99".parse().unwrap());
1019        fr.headers
1020            .insert(header::ACCEPT, "text/html".parse().unwrap());
1021        fr.headers
1022            .insert(header::ACCEPT_LANGUAGE, "en-US".parse().unwrap());
1023        fr.headers
1024            .insert(header::ACCEPT_ENCODING, "gzip".parse().unwrap());
1025        fr.headers
1026            .insert(header::AUTHORIZATION, "Bearer abc".parse().unwrap());
1027        fr.headers
1028            .insert(header::COOKIE, "a=1; b=2".parse().unwrap());
1029
1030        let key = fr.generate_request_key().expect("GET should produce a key");
1031
1032        let url_norm = normalize_url(&fr.url);
1033        let auth_hash = format!("{:x}", short_hash(b"Bearer abc"));
1034        let cookie_hash = format!("{:x}", short_hash(b"a=1; b=2"));
1035        let expected = format!(
1036            // MC=n: no secure initiating origin. Ref=n: no referrer set, so none is ever sent.
1037            // FM: default destination and mode, no initiating origin, no user navigation.
1038            // Cred: the default credentials mode.
1039            "M={};U={};R={};A={};AL={};AE={};Auth={};C={};MC=n;Ref=n;FM=empty:no-cors:n:-;Cred=include",
1040            fr.method, url_norm, "bytes=0-99", "text/html", "en-US", "gzip", auth_hash, cookie_hash
1041        );
1042
1043        assert_eq!(key, expected);
1044        assert!(key.starts_with("M=GET;U=https://example.org/a/b"));
1045        assert!(!key.contains("#frag"));
1046    }
1047
1048    /// Two documents fetching the same insecure URL must not coalesce when only one of them is
1049    /// a secure context — otherwise the insecure document's allowed fetch would be handed to the
1050    /// secure one, silently defeating mixed content blocking.
1051    #[test]
1052    fn coalescing_key_separates_secure_from_insecure_initiators() {
1053        let target = Url::parse("http://cdn.example.org/a.js").unwrap();
1054        let key_for = |origin: Option<&str>| {
1055            let mut b = FetchRequest::builder(Method::GET, target.clone());
1056            if let Some(o) = origin {
1057                b = b.with_origin(Url::parse(o).unwrap().origin());
1058            }
1059            b.build().generate_request_key().unwrap()
1060        };
1061
1062        let secure = key_for(Some("https://a.example.com"));
1063        let insecure = key_for(Some("http://b.example.com"));
1064        let none = key_for(None);
1065
1066        assert_ne!(secure, insecure);
1067        // Distinct initiating origins no longer coalesce at all: they send different
1068        // Sec-Fetch-Site values, if not on the first hop then after a redirect.
1069        assert_ne!(insecure, none);
1070        assert_ne!(secure, key_for(Some("https://c.example.com")));
1071    }
1072
1073    /// A permitted image and a blocked script can target the same insecure URL from the same
1074    /// page. They must not coalesce, or the script would inherit the image's fetched body.
1075    #[test]
1076    fn coalescing_key_separates_per_request_policy_overrides() {
1077        let target = Url::parse("http://cdn.example.org/a.js").unwrap();
1078        let origin = Url::parse("https://example.com").unwrap().origin();
1079        let key_for = |policy: Option<MixedContentPolicy>| {
1080            let mut b =
1081                FetchRequest::builder(Method::GET, target.clone()).with_origin(origin.clone());
1082            if let Some(p) = policy {
1083                b = b.with_mixed_content(p);
1084            }
1085            b.build().generate_request_key().unwrap()
1086        };
1087
1088        let keys = [
1089            key_for(None),
1090            key_for(Some(MixedContentPolicy::Allow)),
1091            key_for(Some(MixedContentPolicy::Upgrade)),
1092            key_for(Some(MixedContentPolicy::Block)),
1093        ];
1094        for (i, a) in keys.iter().enumerate() {
1095            for b in &keys[i + 1..] {
1096                assert_ne!(a, b, "each policy reaches a different verdict");
1097            }
1098        }
1099    }
1100
1101    /// Regression: an `https` target must NOT be treated as "mixed content cannot apply".
1102    ///
1103    /// Enforcement is per-hop, so an https URL that 302s onto plain http is still a mixed
1104    /// content decision. Bucketing on the initial URL's scheme let a leader with no origin
1105    /// follow that redirect and hand the http body to a secure-origin follower that had asked
1106    /// to be blocked — defeating the feature entirely.
1107    #[test]
1108    fn coalescing_key_does_not_trust_an_https_initial_url() {
1109        let target = Url::parse("https://redirector.example.org/r").unwrap();
1110        let key = |origin: Option<&str>| {
1111            let mut b = FetchRequest::builder(Method::GET, target.clone());
1112            if let Some(o) = origin {
1113                b = b.with_origin(Url::parse(o).unwrap().origin());
1114            }
1115            b.build().generate_request_key().unwrap()
1116        };
1117
1118        assert_ne!(
1119            key(Some("https://example.com")),
1120            key(None),
1121            "a secure-origin request must not share a bucket with an unprotected one, \
1122             however trustworthy the initial URL looks"
1123        );
1124    }
1125
1126    /// Two documents fetching the same URL send different `Referer` values, and servers vary on
1127    /// it (hotlink protection). They must not share one response.
1128    #[test]
1129    fn coalescing_key_separates_different_referrers() {
1130        let target = Url::parse("https://cdn.example.org/a.js").unwrap();
1131        let key = |referrer: Option<&str>, policy: ReferrerPolicy| {
1132            let mut b =
1133                FetchRequest::builder(Method::GET, target.clone()).with_referrer_policy(policy);
1134            if let Some(r) = referrer {
1135                b = b.with_referrer(Url::parse(r).unwrap());
1136            }
1137            b.build().generate_request_key().unwrap()
1138        };
1139        let default = ReferrerPolicy::default();
1140
1141        assert_ne!(
1142            key(Some("https://a.example.com/x"), default),
1143            key(Some("https://b.example.com/y"), default)
1144        );
1145        // Same source, different policy — different amounts of it get revealed.
1146        assert_ne!(
1147            key(Some("https://a.example.com/x"), default),
1148            key(Some("https://a.example.com/x"), ReferrerPolicy::UnsafeUrl)
1149        );
1150        // Identical inputs coalesce, which is the common case: one page, many sub-resources.
1151        assert_eq!(
1152            key(Some("https://a.example.com/x"), default),
1153            key(Some("https://a.example.com/x"), default)
1154        );
1155        // Anything that never sends a header shares one bucket, whatever the source.
1156        assert_eq!(key(None, default), key(None, ReferrerPolicy::UnsafeUrl));
1157        assert_eq!(
1158            key(None, default),
1159            key(Some("https://a.example.com/x"), ReferrerPolicy::NoReferrer)
1160        );
1161    }
1162
1163    /// The key must not be derived from the value computed for the request's own URL.
1164    ///
1165    /// Two pages on one origin agree on what to send cross-origin (the bare origin), then differ
1166    /// the moment a redirect lands back home and the full path is revealed. Keying on the hop-0
1167    /// value would coalesce them and send one page's path on the other's behalf.
1168    #[test]
1169    fn coalescing_key_is_not_derived_from_the_first_hop_value() {
1170        let target = Url::parse("https://other.example.org/r").unwrap();
1171        let key = |referrer: &str| {
1172            FetchRequest::builder(Method::GET, target.clone())
1173                .with_referrer(Url::parse(referrer).unwrap())
1174                .build()
1175                .generate_request_key()
1176                .unwrap()
1177        };
1178
1179        let (a, b) = ("https://example.com/page-a", "https://example.com/page-b");
1180        // Both send the bare origin to this cross-origin target, so hop 0 is identical...
1181        let policy = ReferrerPolicy::default();
1182        let hop0 = |r: &str| {
1183            referrer::determine(&Url::parse(r).unwrap(), policy, &target).map(|u| u.to_string())
1184        };
1185        assert_eq!(hop0(a), hop0(b));
1186        // ...but the keys must still differ, because a redirect home would diverge.
1187        assert_ne!(key(a), key(b));
1188    }
1189
1190    /// A hand-set `Referer` goes out verbatim, so it varies the response just as a computed one
1191    /// would and must vary the key too.
1192    #[test]
1193    fn coalescing_key_accounts_for_a_hand_set_referer_header() {
1194        let target = Url::parse("https://cdn.example.org/a.js").unwrap();
1195        let key = |manual: Option<&str>| {
1196            let mut req = FetchRequest::builder(Method::GET, target.clone()).build();
1197            if let Some(value) = manual {
1198                req.headers.insert(header::REFERER, value.parse().unwrap());
1199            }
1200            req.generate_request_key().unwrap()
1201        };
1202
1203        assert_ne!(key(Some("https://a.example.com/x")), key(None));
1204        assert_ne!(
1205            key(Some("https://a.example.com/x")),
1206            key(Some("https://b.example.com/y"))
1207        );
1208        assert_eq!(
1209            key(Some("https://a.example.com/x")),
1210            key(Some("https://a.example.com/x"))
1211        );
1212    }
1213
1214    /// `Sec-Fetch-Dest`, `Sec-Fetch-Mode`, and `Sec-Fetch-Site` vary the response (resource
1215    /// isolation policies), so requests that would send different values must not share one.
1216    #[test]
1217    fn coalescing_key_accounts_for_fetch_metadata() {
1218        let target = Url::parse("https://cdn.example.org/a.js").unwrap();
1219        let key = |dest: RequestDestination, mode: RequestMode, origin: Option<&str>| {
1220            let mut b = FetchRequest::builder(Method::GET, target.clone())
1221                .with_destination(dest)
1222                .with_mode(mode);
1223            if let Some(o) = origin {
1224                b = b.with_origin(Url::parse(o).unwrap().origin());
1225            }
1226            b.build().generate_request_key().unwrap()
1227        };
1228
1229        let (dest, mode) = (RequestDestination::default(), RequestMode::default());
1230        // A script and an image request for one URL send different Sec-Fetch-Dest values.
1231        assert_ne!(
1232            key(RequestDestination::Script, mode, None),
1233            key(RequestDestination::Image, mode, None)
1234        );
1235        assert_ne!(
1236            key(dest, RequestMode::NoCors, None),
1237            key(dest, RequestMode::Cors, None)
1238        );
1239        // Different initiating origins diverge on Sec-Fetch-Site — if not on the first hop,
1240        // then after a redirect — so they must not share a bucket.
1241        assert_ne!(
1242            key(dest, mode, Some("https://a.example.com")),
1243            key(dest, mode, Some("https://b.example.com"))
1244        );
1245        assert_ne!(
1246            key(dest, mode, Some("https://a.example.com")),
1247            key(dest, mode, None)
1248        );
1249        // Identical inputs coalesce.
1250        assert_eq!(
1251            key(
1252                RequestDestination::Script,
1253                mode,
1254                Some("https://a.example.com")
1255            ),
1256            key(
1257                RequestDestination::Script,
1258                mode,
1259                Some("https://a.example.com")
1260            )
1261        );
1262    }
1263
1264    /// A hand-set `Origin` goes out verbatim on the first hop, so it varies the key like a
1265    /// hand-set `Referer` does.
1266    #[test]
1267    fn coalescing_key_accounts_for_a_hand_set_origin_header() {
1268        let target = Url::parse("https://cdn.example.org/a.js").unwrap();
1269        let key = |manual: Option<&str>| {
1270            let mut req = FetchRequest::builder(Method::GET, target.clone()).build();
1271            if let Some(value) = manual {
1272                req.headers.insert(header::ORIGIN, value.parse().unwrap());
1273            }
1274            req.generate_request_key().unwrap()
1275        };
1276
1277        assert_ne!(key(Some("https://a.example.com")), key(None));
1278        assert_ne!(
1279            key(Some("https://a.example.com")),
1280            key(Some("https://b.example.com"))
1281        );
1282        assert_eq!(
1283            key(Some("https://a.example.com")),
1284            key(Some("https://a.example.com"))
1285        );
1286    }
1287
1288    #[test]
1289    fn fetch_request_generate_post_is_none() {
1290        let mut fr = FetchRequest::builder(
1291            Method::default(),
1292            Url::parse("https://example.org/").unwrap(),
1293        )
1294        .build();
1295        fr.method = Method::POST;
1296        assert!(fr.generate_request_key().is_none());
1297    }
1298
1299    #[test]
1300    fn priority_display_is_stable() {
1301        assert_eq!(format!("{}", Priority::High), "High");
1302        assert_eq!(format!("{}", Priority::Normal), "Normal");
1303        assert_eq!(format!("{}", Priority::Low), "Low");
1304        assert_eq!(format!("{}", Priority::Idle), "Idle");
1305    }
1306
1307    #[test]
1308    fn neterror_helpers_work() {
1309        let io = NetError::Timeout("oops".into()).to_io();
1310        assert_eq!(io.kind(), std::io::ErrorKind::Other);
1311        assert!(io.to_string().cow_to_ascii_lowercase().contains("timeout"));
1312
1313        let ne = NetError::from_anyhow(anyhow::anyhow!("boom"));
1314        assert!(matches!(ne, NetError::Read(_)));
1315    }
1316
1317    #[test]
1318    fn net_error_redirect_formats_with_redirect_prefix() {
1319        let e = NetError::Redirect(Arc::new(anyhow::anyhow!("too many redirects")));
1320        assert!(e.to_string().contains("redirect"));
1321    }
1322
1323    #[tokio::test(flavor = "current_thread")]
1324    async fn body_stream_new_creates_non_seekable_stream() {
1325        use tokio::io::AsyncReadExt;
1326        let mut s = BodyStream::new(Box::pin(tokio::io::empty()), Some(0));
1327        assert_eq!(s.len, Some(0));
1328        assert!(!s.is_seekable);
1329        assert!(!s.clonable);
1330        let n = s.read(&mut [0u8; 4]).await.unwrap();
1331        assert_eq!(n, 0);
1332    }
1333
1334    #[test]
1335    fn fetch_result_meta_returns_none_for_error() {
1336        let e = FetchResult::Error(NetError::Cancelled("x".into()));
1337        assert!(e.meta().is_none());
1338        assert!(e.is_error());
1339    }
1340
1341    #[tokio::test(flavor = "current_thread")]
1342    async fn fetch_result_meta_returns_some_for_stream_and_buffered() {
1343        use crate::net::shared_body::SharedBody;
1344        use crate::types::PeekBuf;
1345        use http::HeaderMap;
1346
1347        let meta = FetchResultMeta {
1348            final_url: Url::parse("http://example.com/").unwrap(),
1349            status: 200,
1350            status_text: "OK".into(),
1351            headers: HeaderMap::new(),
1352            content_length: None,
1353            content_type: None,
1354            has_body: false,
1355            tainting: ResponseTainting::Basic,
1356        };
1357
1358        let buffered = FetchResult::Buffered {
1359            meta: meta.clone(),
1360            body: bytes::Bytes::new(),
1361        };
1362        assert_eq!(buffered.meta().unwrap().status, 200);
1363        assert!(!buffered.is_error());
1364        assert!(format!("{:?}", buffered).contains("Buffered"));
1365
1366        let stream = FetchResult::Stream {
1367            meta: meta.clone(),
1368            peek_buf: PeekBuf::empty(),
1369            shared: Arc::new(SharedBody::new(1)),
1370        };
1371        assert_eq!(stream.meta().unwrap().status, 200);
1372        assert!(format!("{:?}", stream).contains("Stream"));
1373    }
1374
1375    #[test]
1376    fn fetch_request_builder_builds_correctly() {
1377        let mut headers = HeaderMap::new();
1378        headers.insert("ACCEPT", "text/html".parse().unwrap());
1379        headers.insert("CONTENT_TYPE", "application/json".parse().unwrap());
1380
1381        let reference = RequestReference::default();
1382        let req_id = RequestId::new();
1383        let priority = Priority::High;
1384        let initiator = Initiator::Application;
1385        let kind = ResourceKind::Asset;
1386        let body = RequestBody::json(r#"{"key": "value"}"#);
1387
1388        let request =
1389            FetchRequest::builder(Method::POST, Url::parse("https://example.com/api").unwrap())
1390                .with_reference(reference)
1391                .with_req_id(req_id)
1392                .with_priority(priority)
1393                .with_initiator(initiator)
1394                .with_kind(kind)
1395                .with_headers(headers)
1396                .with_streaming(true)
1397                .with_auto_decode(true)
1398                .with_max_bytes(1024)
1399                .with_body(body)
1400                .build();
1401
1402        assert_eq!(request.reference, reference);
1403        assert_eq!(request.req_id, req_id);
1404        assert_eq!(request.priority, priority);
1405        assert_eq!(request.initiator, initiator);
1406        assert_eq!(request.kind, kind);
1407        assert!(request.streaming);
1408        assert!(request.auto_decode);
1409        assert_eq!(request.max_bytes, Some(1024));
1410        assert_eq!(
1411            request.body.as_ref().unwrap().content_type,
1412            Some("application/json".into())
1413        );
1414
1415        assert_eq!(request.url.as_str(), "https://example.com/api");
1416        assert_eq!(request.method, Method::POST);
1417        assert!(request.headers.contains_key("ACCEPT"));
1418        assert!(request.headers.contains_key("CONTENT_TYPE"));
1419    }
1420}