use crate::net::cors::{self, CorsError, ResponseTainting};
use crate::net::fetch_metadata::{RequestDestination, RequestMode};
use crate::net::mixed_content::{is_origin_potentially_trustworthy, MixedContentPolicy};
use crate::net::referrer::{self, ReferrerPolicy};
use crate::net::request_ref::RequestReference;
use crate::net::shared_body::SharedBody;
use crate::net::tls::TlsError;
use crate::net::utils::{normalize_url, short_hash, BytesAsyncReader};
use crate::types::{PeekBuf, RequestId};
use bytes::Bytes;
use http::{header, HeaderMap, Method};
use std::fmt::{Debug, Display};
use std::hash::Hash;
use std::pin::Pin;
use std::sync::Arc;
use tokio::io::{AsyncRead, ReadBuf};
use url::{Origin, Url};
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
pub enum Priority {
High,
#[default]
Normal,
Low,
Idle,
}
impl Display for Priority {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Priority::High => "High",
Priority::Normal => "Normal",
Priority::Low => "Low",
Priority::Idle => "Idle",
};
f.write_str(s)
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
pub enum ResourceKind {
#[default]
Primary,
Asset,
Other,
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
pub enum Initiator {
#[default]
User,
Application,
Other,
}
#[derive(Clone, Debug)]
pub struct FetchResultMeta {
pub final_url: Url,
pub status: u16,
pub status_text: String,
pub headers: HeaderMap,
pub content_length: Option<u64>,
pub content_type: Option<String>,
pub has_body: bool,
pub tainting: ResponseTainting,
}
impl FetchResultMeta {
pub fn readable_headers(&self, credentials_include: bool) -> HeaderMap {
cors::readable_headers(self.tainting, &self.headers, credentials_include)
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub enum BlockReason {
MixedContent,
UrlPolicy,
UnsupportedScheme,
Cors(CorsError),
}
impl Display for BlockReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
BlockReason::MixedContent => "mixed content",
BlockReason::UrlPolicy => "blocked by URL policy",
BlockReason::UnsupportedScheme => "unsupported URL scheme",
BlockReason::Cors(err) => return write!(f, "CORS: {err}"),
};
f.write_str(s)
}
}
#[derive(Debug, thiserror::Error, Clone)]
pub enum NetError {
#[error("net error: blocked: {reason}: {url}")]
Blocked {
reason: BlockReason,
url: Url,
},
#[error("net error: reqwest: {0}")]
Reqwest(#[from] Arc<reqwest::Error>),
#[error("net error: tls: {0}")]
Tls(TlsError),
#[error("net error: redirect: {0}")]
Redirect(Arc<anyhow::Error>),
#[error("net error: I/O: {0}")]
Io(#[from] Arc<std::io::Error>),
#[error("net error: cancelled: {0}")]
Cancelled(String),
#[error(transparent)]
Read(Arc<anyhow::Error>),
#[error(transparent)]
Other(Arc<anyhow::Error>),
#[error("net error: timeout: {0}")]
Timeout(String),
}
impl From<std::io::Error> for NetError {
fn from(e: std::io::Error) -> Self {
NetError::Io(Arc::new(e))
}
}
impl NetError {
pub fn to_io(&self) -> std::io::Error {
std::io::Error::other(self.clone())
}
pub fn from_anyhow(e: anyhow::Error) -> Self {
Self::Read(Arc::new(e))
}
}
#[cfg(not(target_arch = "wasm32"))]
pub trait MaybeSend: Send {}
#[cfg(not(target_arch = "wasm32"))]
impl<T: Send> MaybeSend for T {}
#[cfg(target_arch = "wasm32")]
pub trait MaybeSend {}
#[cfg(target_arch = "wasm32")]
impl<T> MaybeSend for T {}
#[cfg(not(target_arch = "wasm32"))]
pub type BoxedAsyncRead = Pin<Box<dyn AsyncRead + Send + 'static>>;
#[cfg(target_arch = "wasm32")]
pub type BoxedAsyncRead = Pin<Box<dyn AsyncRead + 'static>>;
pub struct BodyStream {
inner: BoxedAsyncRead,
pub len: Option<u64>,
pub is_seekable: bool,
pub clonable: bool,
}
impl Debug for BodyStream {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BodyStream")
.field("len", &self.len)
.field("is_seekable", &self.is_seekable)
.field("clonable", &self.clonable)
.finish()
}
}
impl BodyStream {
pub fn new(inner: BoxedAsyncRead, len: Option<u64>) -> Self {
Self {
inner,
len,
is_seekable: false,
clonable: false,
}
}
pub fn from_bytes(bytes: Bytes) -> Self {
let len = bytes.len() as u64;
let reader = Box::pin(BytesAsyncReader {
data: bytes,
pos: 0,
});
Self {
inner: reader,
len: Some(len),
is_seekable: true, clonable: true, }
}
}
impl AsyncRead for BodyStream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
self.inner.as_mut().poll_read(cx, buf)
}
}
#[cfg(not(target_arch = "wasm32"))]
pub type BodyStreamFactory =
Arc<dyn Fn() -> std::io::Result<BoxedAsyncRead> + Send + Sync + 'static>;
#[derive(Clone, Default)]
pub struct RequestBody {
payload: Payload,
pub content_type: Option<String>,
}
#[derive(Clone)]
enum Payload {
Bytes(Bytes),
#[cfg(not(target_arch = "wasm32"))]
Stream {
open: BodyStreamFactory,
len: Option<u64>,
},
}
impl Default for Payload {
fn default() -> Self {
Payload::Bytes(Bytes::new())
}
}
impl Debug for RequestBody {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut d = f.debug_struct("RequestBody");
match &self.payload {
Payload::Bytes(b) => d.field("bytes", &b.len()),
#[cfg(not(target_arch = "wasm32"))]
Payload::Stream { len, .. } => d.field("stream", len),
};
d.field("content_type", &self.content_type).finish()
}
}
impl RequestBody {
pub fn bytes(b: impl Into<Bytes>) -> Self {
Self {
payload: Payload::Bytes(b.into()),
content_type: None,
}
}
pub fn json(b: impl Into<Bytes>) -> Self {
Self {
content_type: Some("application/json".into()),
..Self::bytes(b)
}
}
pub fn form(b: impl Into<Bytes>) -> Self {
Self {
content_type: Some("application/x-www-form-urlencoded".into()),
..Self::bytes(b)
}
}
pub fn text(s: impl Into<String>) -> Self {
Self {
content_type: Some("text/plain; charset=utf-8".into()),
..Self::bytes(s.into().into_bytes())
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn stream(
open: impl Fn() -> std::io::Result<BoxedAsyncRead> + Send + Sync + 'static,
len: Option<u64>,
) -> Self {
Self {
payload: Payload::Stream {
open: Arc::new(open),
len,
},
content_type: None,
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn file(path: impl Into<std::path::PathBuf>) -> std::io::Result<Self> {
let path = path.into();
let len = std::fs::metadata(&path)?.len();
Ok(Self::stream(
move || {
let f = std::fs::File::open(&path)?;
Ok(Box::pin(tokio::fs::File::from_std(f)) as BoxedAsyncRead)
},
Some(len),
))
}
pub fn as_bytes(&self) -> Option<&Bytes> {
match &self.payload {
Payload::Bytes(b) => Some(b),
#[cfg(not(target_arch = "wasm32"))]
Payload::Stream { .. } => None,
}
}
pub fn len(&self) -> Option<u64> {
match &self.payload {
Payload::Bytes(b) => Some(b.len() as u64),
#[cfg(not(target_arch = "wasm32"))]
Payload::Stream { len, .. } => *len,
}
}
pub fn is_empty(&self) -> bool {
self.len() == Some(0)
}
pub(crate) fn to_reqwest_body(&self) -> std::io::Result<(reqwest::Body, Option<u64>)> {
match &self.payload {
Payload::Bytes(b) => Ok((reqwest::Body::from(b.clone()), None)),
#[cfg(not(target_arch = "wasm32"))]
Payload::Stream { open, len } => {
let reader = open()?;
let stream = tokio_util::io::ReaderStream::new(reader);
Ok((reqwest::Body::wrap_stream(stream), *len))
}
}
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
pub enum RequestCredentials {
Omit,
SameOrigin,
#[default]
Include,
}
#[derive(Debug, Clone)]
pub struct FetchRequest {
pub reference: RequestReference,
pub req_id: RequestId,
pub priority: Priority,
pub initiator: Initiator,
pub kind: ResourceKind,
pub streaming: bool,
pub auto_decode: bool,
pub max_bytes: Option<usize>,
pub method: Method,
pub url: Url,
pub origin: Option<Origin>,
pub mixed_content: Option<MixedContentPolicy>,
pub referrer: Option<Url>,
pub referrer_policy: ReferrerPolicy,
pub destination: RequestDestination,
pub mode: RequestMode,
pub credentials: RequestCredentials,
pub headers: HeaderMap,
pub body: Option<RequestBody>,
}
impl FetchRequest {
pub fn builder(method: Method, url: Url) -> FetchRequestBuilder {
FetchRequestBuilder::new(method, url)
}
pub fn generate_request_key(&self) -> Option<String> {
match self.method {
Method::GET | Method::HEAD => {}
_ => return None,
}
let url = normalize_url(&self.url);
let h = &self.headers;
let range = h
.get(header::RANGE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let accept = h
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let accept_enc = h
.get(header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let accept_lang = h
.get(header::ACCEPT_LANGUAGE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let auth_hash = h
.get(header::AUTHORIZATION)
.map(|v| format!("{:x}", short_hash(v.as_bytes())))
.unwrap_or_default();
let cookie_hash = h
.get(header::COOKIE)
.map(|v| format!("{:x}", short_hash(v.as_bytes())))
.unwrap_or_default();
let mixed_content = if !self
.origin
.as_ref()
.is_some_and(is_origin_potentially_trustworthy)
{
"n"
} else {
match self.mixed_content {
None => "default",
Some(MixedContentPolicy::Allow) => "allow",
Some(MixedContentPolicy::Upgrade) => "upgrade",
Some(MixedContentPolicy::Block) => "block",
}
};
let referrer = match self.referrer.as_ref() {
Some(r) if !referrer::never_sends(r, self.referrer_policy) => {
let source = match self.referrer_policy {
ReferrerPolicy::Origin | ReferrerPolicy::StrictOrigin => {
r.origin().ascii_serialization()
}
_ => r.as_str().to_string(),
};
let policy = match self.referrer_policy {
ReferrerPolicy::NoReferrer => "no-referrer",
ReferrerPolicy::NoReferrerWhenDowngrade => "no-referrer-when-downgrade",
ReferrerPolicy::SameOrigin => "same-origin",
ReferrerPolicy::Origin => "origin",
ReferrerPolicy::StrictOrigin => "strict-origin",
ReferrerPolicy::OriginWhenCrossOrigin => "origin-when-cross-origin",
ReferrerPolicy::StrictOriginWhenCrossOrigin => {
"strict-origin-when-cross-origin"
}
ReferrerPolicy::UnsafeUrl => "unsafe-url",
};
format!("{:x}:{}", short_hash(source.as_bytes()), policy)
}
_ => match self.headers.get(header::REFERER) {
Some(manual) => format!("h{:x}", short_hash(manual.as_bytes())),
None => "n".to_string(),
},
};
let fetch_meta = {
let origin = match self.origin.as_ref() {
Some(o) => format!("{:x}", short_hash(o.ascii_serialization().as_bytes())),
None => match self.headers.get(header::ORIGIN) {
Some(manual) => format!("h{:x}", short_hash(manual.as_bytes())),
None => "n".to_string(),
},
};
let user = if self.mode == RequestMode::Navigate && self.initiator == Initiator::User {
"u"
} else {
"-"
};
format!(
"{}:{}:{}:{}",
self.destination.as_str(),
self.mode.as_str(),
origin,
user
)
};
let credentials = match self.credentials {
RequestCredentials::Omit => "omit",
RequestCredentials::SameOrigin => "same-origin",
RequestCredentials::Include => "include",
};
Some(format!(
"M={};U={};R={};A={};AL={};AE={};Auth={};C={};MC={};Ref={};FM={};Cred={}",
self.method,
url,
range,
accept,
accept_lang,
accept_enc,
auth_hash,
cookie_hash,
mixed_content,
referrer,
fetch_meta,
credentials
))
}
}
pub struct FetchRequestBuilder {
reference: RequestReference,
req_id: RequestId,
priority: Priority,
initiator: Initiator,
kind: ResourceKind,
streaming: bool,
auto_decode: bool,
max_bytes: Option<usize>,
method: Method,
headers: HeaderMap,
url: Url,
origin: Option<Origin>,
mixed_content: Option<MixedContentPolicy>,
referrer: Option<Url>,
referrer_policy: ReferrerPolicy,
destination: RequestDestination,
mode: RequestMode,
credentials: RequestCredentials,
body: Option<RequestBody>,
}
impl FetchRequestBuilder {
pub fn new(method: Method, url: Url) -> Self {
Self {
url,
method,
headers: HeaderMap::default(),
reference: RequestReference::default(),
req_id: RequestId::default(),
priority: Priority::default(),
initiator: Initiator::default(),
kind: ResourceKind::default(),
streaming: false,
auto_decode: true,
max_bytes: None,
origin: None,
mixed_content: None,
referrer: None,
referrer_policy: ReferrerPolicy::default(),
destination: RequestDestination::default(),
mode: RequestMode::default(),
credentials: RequestCredentials::default(),
body: None,
}
}
pub fn with_reference(mut self, reference: RequestReference) -> Self {
self.reference = reference;
self
}
pub fn with_req_id(mut self, req_id: RequestId) -> Self {
self.req_id = req_id;
self
}
pub fn with_priority(mut self, priority: Priority) -> Self {
self.priority = priority;
self
}
pub fn with_initiator(mut self, initiator: Initiator) -> Self {
self.initiator = initiator;
self
}
pub fn with_kind(mut self, kind: ResourceKind) -> Self {
self.kind = kind;
self
}
pub fn with_streaming(mut self, streaming: bool) -> Self {
self.streaming = streaming;
self
}
pub fn with_auto_decode(mut self, auto_decode: bool) -> Self {
self.auto_decode = auto_decode;
self
}
pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
self.max_bytes = Some(max_bytes);
self
}
pub fn with_body(mut self, body: RequestBody) -> Self {
self.body = Some(body);
self
}
pub fn with_url(mut self, url: Url) -> Self {
self.url = url;
self
}
pub fn with_origin(mut self, origin: Origin) -> Self {
self.origin = Some(origin);
self
}
pub fn with_mixed_content(mut self, policy: MixedContentPolicy) -> Self {
self.mixed_content = Some(policy);
self
}
pub fn with_referrer(mut self, referrer: Url) -> Self {
self.referrer = Some(referrer);
self
}
pub fn with_referrer_policy(mut self, policy: ReferrerPolicy) -> Self {
self.referrer_policy = policy;
self
}
pub fn with_destination(mut self, destination: RequestDestination) -> Self {
self.destination = destination;
self
}
pub fn with_mode(mut self, mode: RequestMode) -> Self {
self.mode = mode;
self
}
pub fn with_credentials(mut self, credentials: RequestCredentials) -> Self {
self.credentials = credentials;
self
}
pub fn with_method(mut self, method: Method) -> Self {
self.method = method;
self
}
pub fn with_headers(mut self, headers: HeaderMap) -> Self {
self.headers = headers;
self
}
pub fn build(self) -> FetchRequest {
FetchRequest {
reference: self.reference,
req_id: self.req_id,
priority: self.priority,
initiator: self.initiator,
kind: self.kind,
streaming: self.streaming,
auto_decode: self.auto_decode,
max_bytes: self.max_bytes,
headers: self.headers,
method: self.method,
url: self.url,
origin: self.origin,
mixed_content: self.mixed_content,
referrer: self.referrer,
referrer_policy: self.referrer_policy,
destination: self.destination,
mode: self.mode,
credentials: self.credentials,
body: self.body,
}
}
}
#[derive(Clone)]
pub enum FetchResult {
Stream {
meta: FetchResultMeta,
peek_buf: PeekBuf,
shared: Arc<SharedBody>,
},
Buffered {
meta: FetchResultMeta,
body: Bytes,
},
Error(NetError),
}
impl FetchResult {
pub fn is_error(&self) -> bool {
matches!(self, FetchResult::Error(_))
}
pub fn meta(&self) -> Option<&FetchResultMeta> {
match self {
FetchResult::Stream { meta, .. } => Some(meta),
FetchResult::Buffered { meta, .. } => Some(meta),
FetchResult::Error(_) => None,
}
}
}
impl Debug for FetchResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FetchResult::Stream { meta, .. } => f
.debug_struct("FetchResult::Stream")
.field("meta", meta)
.finish(),
FetchResult::Buffered { meta, body } => f
.debug_struct("FetchResult::Buffered")
.field("meta", meta)
.field("body_len", &body.len())
.finish(),
FetchResult::Error(e) => f.debug_tuple("FetchResult::Error").field(e).finish(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cow_utils::CowUtils;
use tokio::io::AsyncReadExt;
#[tokio::test(flavor = "current_thread")]
async fn bodystream_from_bytes_reads_all() {
let data = Bytes::from_static(b"hello world");
let mut s = BodyStream::from_bytes(data.clone());
assert_eq!(s.len, Some(11));
assert!(s.is_seekable);
assert!(s.clonable);
let mut out = Vec::new();
s.read_to_end(&mut out).await.unwrap();
assert_eq!(&out[..], &data[..]);
let n = s.read(&mut [0u8; 8]).await.unwrap();
assert_eq!(n, 0);
}
#[test]
fn stream_body_reports_len_and_no_bytes() {
let sized = RequestBody::stream(|| Ok(Box::pin(&b""[..]) as BoxedAsyncRead), Some(3));
assert_eq!(sized.len(), Some(3));
assert!(sized.as_bytes().is_none());
assert!(!sized.is_empty());
let unsized_body = RequestBody::stream(|| Ok(Box::pin(&b""[..]) as BoxedAsyncRead), None);
assert_eq!(unsized_body.len(), None);
assert!(!unsized_body.is_empty());
let buffered = RequestBody::bytes(&b"abc"[..]);
assert_eq!(buffered.len(), Some(3));
assert_eq!(buffered.as_bytes().map(|b| b.len()), Some(3));
}
#[test]
fn builder_decodes_by_default() {
let fr =
FetchRequest::builder(Method::GET, Url::parse("https://example.org").unwrap()).build();
assert!(fr.auto_decode);
}
#[test]
fn fetch_request_generate_get_and_headers() {
let mut fr = FetchRequest::builder(
Method::default(),
Url::parse("https://example.org/a/b#frag").unwrap(),
)
.build();
fr.headers
.insert(header::RANGE, "bytes=0-99".parse().unwrap());
fr.headers
.insert(header::ACCEPT, "text/html".parse().unwrap());
fr.headers
.insert(header::ACCEPT_LANGUAGE, "en-US".parse().unwrap());
fr.headers
.insert(header::ACCEPT_ENCODING, "gzip".parse().unwrap());
fr.headers
.insert(header::AUTHORIZATION, "Bearer abc".parse().unwrap());
fr.headers
.insert(header::COOKIE, "a=1; b=2".parse().unwrap());
let key = fr.generate_request_key().expect("GET should produce a key");
let url_norm = normalize_url(&fr.url);
let auth_hash = format!("{:x}", short_hash(b"Bearer abc"));
let cookie_hash = format!("{:x}", short_hash(b"a=1; b=2"));
let expected = format!(
"M={};U={};R={};A={};AL={};AE={};Auth={};C={};MC=n;Ref=n;FM=empty:no-cors:n:-;Cred=include",
fr.method, url_norm, "bytes=0-99", "text/html", "en-US", "gzip", auth_hash, cookie_hash
);
assert_eq!(key, expected);
assert!(key.starts_with("M=GET;U=https://example.org/a/b"));
assert!(!key.contains("#frag"));
}
#[test]
fn coalescing_key_separates_secure_from_insecure_initiators() {
let target = Url::parse("http://cdn.example.org/a.js").unwrap();
let key_for = |origin: Option<&str>| {
let mut b = FetchRequest::builder(Method::GET, target.clone());
if let Some(o) = origin {
b = b.with_origin(Url::parse(o).unwrap().origin());
}
b.build().generate_request_key().unwrap()
};
let secure = key_for(Some("https://a.example.com"));
let insecure = key_for(Some("http://b.example.com"));
let none = key_for(None);
assert_ne!(secure, insecure);
assert_ne!(insecure, none);
assert_ne!(secure, key_for(Some("https://c.example.com")));
}
#[test]
fn coalescing_key_separates_per_request_policy_overrides() {
let target = Url::parse("http://cdn.example.org/a.js").unwrap();
let origin = Url::parse("https://example.com").unwrap().origin();
let key_for = |policy: Option<MixedContentPolicy>| {
let mut b =
FetchRequest::builder(Method::GET, target.clone()).with_origin(origin.clone());
if let Some(p) = policy {
b = b.with_mixed_content(p);
}
b.build().generate_request_key().unwrap()
};
let keys = [
key_for(None),
key_for(Some(MixedContentPolicy::Allow)),
key_for(Some(MixedContentPolicy::Upgrade)),
key_for(Some(MixedContentPolicy::Block)),
];
for (i, a) in keys.iter().enumerate() {
for b in &keys[i + 1..] {
assert_ne!(a, b, "each policy reaches a different verdict");
}
}
}
#[test]
fn coalescing_key_does_not_trust_an_https_initial_url() {
let target = Url::parse("https://redirector.example.org/r").unwrap();
let key = |origin: Option<&str>| {
let mut b = FetchRequest::builder(Method::GET, target.clone());
if let Some(o) = origin {
b = b.with_origin(Url::parse(o).unwrap().origin());
}
b.build().generate_request_key().unwrap()
};
assert_ne!(
key(Some("https://example.com")),
key(None),
"a secure-origin request must not share a bucket with an unprotected one, \
however trustworthy the initial URL looks"
);
}
#[test]
fn coalescing_key_separates_different_referrers() {
let target = Url::parse("https://cdn.example.org/a.js").unwrap();
let key = |referrer: Option<&str>, policy: ReferrerPolicy| {
let mut b =
FetchRequest::builder(Method::GET, target.clone()).with_referrer_policy(policy);
if let Some(r) = referrer {
b = b.with_referrer(Url::parse(r).unwrap());
}
b.build().generate_request_key().unwrap()
};
let default = ReferrerPolicy::default();
assert_ne!(
key(Some("https://a.example.com/x"), default),
key(Some("https://b.example.com/y"), default)
);
assert_ne!(
key(Some("https://a.example.com/x"), default),
key(Some("https://a.example.com/x"), ReferrerPolicy::UnsafeUrl)
);
assert_eq!(
key(Some("https://a.example.com/x"), default),
key(Some("https://a.example.com/x"), default)
);
assert_eq!(key(None, default), key(None, ReferrerPolicy::UnsafeUrl));
assert_eq!(
key(None, default),
key(Some("https://a.example.com/x"), ReferrerPolicy::NoReferrer)
);
}
#[test]
fn coalescing_key_is_not_derived_from_the_first_hop_value() {
let target = Url::parse("https://other.example.org/r").unwrap();
let key = |referrer: &str| {
FetchRequest::builder(Method::GET, target.clone())
.with_referrer(Url::parse(referrer).unwrap())
.build()
.generate_request_key()
.unwrap()
};
let (a, b) = ("https://example.com/page-a", "https://example.com/page-b");
let policy = ReferrerPolicy::default();
let hop0 = |r: &str| {
referrer::determine(&Url::parse(r).unwrap(), policy, &target).map(|u| u.to_string())
};
assert_eq!(hop0(a), hop0(b));
assert_ne!(key(a), key(b));
}
#[test]
fn coalescing_key_accounts_for_a_hand_set_referer_header() {
let target = Url::parse("https://cdn.example.org/a.js").unwrap();
let key = |manual: Option<&str>| {
let mut req = FetchRequest::builder(Method::GET, target.clone()).build();
if let Some(value) = manual {
req.headers.insert(header::REFERER, value.parse().unwrap());
}
req.generate_request_key().unwrap()
};
assert_ne!(key(Some("https://a.example.com/x")), key(None));
assert_ne!(
key(Some("https://a.example.com/x")),
key(Some("https://b.example.com/y"))
);
assert_eq!(
key(Some("https://a.example.com/x")),
key(Some("https://a.example.com/x"))
);
}
#[test]
fn coalescing_key_accounts_for_fetch_metadata() {
let target = Url::parse("https://cdn.example.org/a.js").unwrap();
let key = |dest: RequestDestination, mode: RequestMode, origin: Option<&str>| {
let mut b = FetchRequest::builder(Method::GET, target.clone())
.with_destination(dest)
.with_mode(mode);
if let Some(o) = origin {
b = b.with_origin(Url::parse(o).unwrap().origin());
}
b.build().generate_request_key().unwrap()
};
let (dest, mode) = (RequestDestination::default(), RequestMode::default());
assert_ne!(
key(RequestDestination::Script, mode, None),
key(RequestDestination::Image, mode, None)
);
assert_ne!(
key(dest, RequestMode::NoCors, None),
key(dest, RequestMode::Cors, None)
);
assert_ne!(
key(dest, mode, Some("https://a.example.com")),
key(dest, mode, Some("https://b.example.com"))
);
assert_ne!(
key(dest, mode, Some("https://a.example.com")),
key(dest, mode, None)
);
assert_eq!(
key(
RequestDestination::Script,
mode,
Some("https://a.example.com")
),
key(
RequestDestination::Script,
mode,
Some("https://a.example.com")
)
);
}
#[test]
fn coalescing_key_accounts_for_a_hand_set_origin_header() {
let target = Url::parse("https://cdn.example.org/a.js").unwrap();
let key = |manual: Option<&str>| {
let mut req = FetchRequest::builder(Method::GET, target.clone()).build();
if let Some(value) = manual {
req.headers.insert(header::ORIGIN, value.parse().unwrap());
}
req.generate_request_key().unwrap()
};
assert_ne!(key(Some("https://a.example.com")), key(None));
assert_ne!(
key(Some("https://a.example.com")),
key(Some("https://b.example.com"))
);
assert_eq!(
key(Some("https://a.example.com")),
key(Some("https://a.example.com"))
);
}
#[test]
fn fetch_request_generate_post_is_none() {
let mut fr = FetchRequest::builder(
Method::default(),
Url::parse("https://example.org/").unwrap(),
)
.build();
fr.method = Method::POST;
assert!(fr.generate_request_key().is_none());
}
#[test]
fn priority_display_is_stable() {
assert_eq!(format!("{}", Priority::High), "High");
assert_eq!(format!("{}", Priority::Normal), "Normal");
assert_eq!(format!("{}", Priority::Low), "Low");
assert_eq!(format!("{}", Priority::Idle), "Idle");
}
#[test]
fn neterror_helpers_work() {
let io = NetError::Timeout("oops".into()).to_io();
assert_eq!(io.kind(), std::io::ErrorKind::Other);
assert!(io.to_string().cow_to_ascii_lowercase().contains("timeout"));
let ne = NetError::from_anyhow(anyhow::anyhow!("boom"));
assert!(matches!(ne, NetError::Read(_)));
}
#[test]
fn net_error_redirect_formats_with_redirect_prefix() {
let e = NetError::Redirect(Arc::new(anyhow::anyhow!("too many redirects")));
assert!(e.to_string().contains("redirect"));
}
#[tokio::test(flavor = "current_thread")]
async fn body_stream_new_creates_non_seekable_stream() {
use tokio::io::AsyncReadExt;
let mut s = BodyStream::new(Box::pin(tokio::io::empty()), Some(0));
assert_eq!(s.len, Some(0));
assert!(!s.is_seekable);
assert!(!s.clonable);
let n = s.read(&mut [0u8; 4]).await.unwrap();
assert_eq!(n, 0);
}
#[test]
fn fetch_result_meta_returns_none_for_error() {
let e = FetchResult::Error(NetError::Cancelled("x".into()));
assert!(e.meta().is_none());
assert!(e.is_error());
}
#[tokio::test(flavor = "current_thread")]
async fn fetch_result_meta_returns_some_for_stream_and_buffered() {
use crate::net::shared_body::SharedBody;
use crate::types::PeekBuf;
use http::HeaderMap;
let meta = FetchResultMeta {
final_url: Url::parse("http://example.com/").unwrap(),
status: 200,
status_text: "OK".into(),
headers: HeaderMap::new(),
content_length: None,
content_type: None,
has_body: false,
tainting: ResponseTainting::Basic,
};
let buffered = FetchResult::Buffered {
meta: meta.clone(),
body: bytes::Bytes::new(),
};
assert_eq!(buffered.meta().unwrap().status, 200);
assert!(!buffered.is_error());
assert!(format!("{:?}", buffered).contains("Buffered"));
let stream = FetchResult::Stream {
meta: meta.clone(),
peek_buf: PeekBuf::empty(),
shared: Arc::new(SharedBody::new(1)),
};
assert_eq!(stream.meta().unwrap().status, 200);
assert!(format!("{:?}", stream).contains("Stream"));
}
#[test]
fn fetch_request_builder_builds_correctly() {
let mut headers = HeaderMap::new();
headers.insert("ACCEPT", "text/html".parse().unwrap());
headers.insert("CONTENT_TYPE", "application/json".parse().unwrap());
let reference = RequestReference::default();
let req_id = RequestId::new();
let priority = Priority::High;
let initiator = Initiator::Application;
let kind = ResourceKind::Asset;
let body = RequestBody::json(r#"{"key": "value"}"#);
let request =
FetchRequest::builder(Method::POST, Url::parse("https://example.com/api").unwrap())
.with_reference(reference)
.with_req_id(req_id)
.with_priority(priority)
.with_initiator(initiator)
.with_kind(kind)
.with_headers(headers)
.with_streaming(true)
.with_auto_decode(true)
.with_max_bytes(1024)
.with_body(body)
.build();
assert_eq!(request.reference, reference);
assert_eq!(request.req_id, req_id);
assert_eq!(request.priority, priority);
assert_eq!(request.initiator, initiator);
assert_eq!(request.kind, kind);
assert!(request.streaming);
assert!(request.auto_decode);
assert_eq!(request.max_bytes, Some(1024));
assert_eq!(
request.body.as_ref().unwrap().content_type,
Some("application/json".into())
);
assert_eq!(request.url.as_str(), "https://example.com/api");
assert_eq!(request.method, Method::POST);
assert!(request.headers.contains_key("ACCEPT"));
assert!(request.headers.contains_key("CONTENT_TYPE"));
}
}