use std::fmt::Write as _;
use async_trait::async_trait;
use bytes::Bytes;
use cratefield_core::{HttpClient, HttpError, HttpPolicy, scrub_request_url, scrub_text};
use worker::send::IntoSendFuture;
use worker::{Fetch, Headers, Method, Request as WorkerRequest, RequestInit};
pub struct FetchClient;
#[async_trait]
impl HttpClient for FetchClient {
async fn send(
&self,
request: http::Request<Bytes>,
) -> Result<http::Response<Bytes>, HttpError> {
let policy = HttpPolicy::of_request(&request);
let (parts, body) = request.into_parts();
let url = parts.uri.to_string();
let mut init = RequestInit::new();
init.method = Method::from(parts.method.as_str().to_string());
let headers = Headers::new();
for (name, value) in &parts.headers {
let _ = headers.set(name.as_str(), value.to_str().unwrap_or_default());
}
init.headers = headers;
if !body.is_empty() {
let body_text =
String::from_utf8(body.to_vec()).map_err(|err| transport(&err, &url))?;
init.with_body(Some(worker::wasm_bindgen::JsValue::from_str(&body_text)));
}
let worker_request =
WorkerRequest::new_with_init(&url, &init).map_err(|err| transport(&err, &url))?;
let mut response = Fetch::Request(worker_request)
.send()
.into_send()
.await
.map_err(|err| transport(&err, &url))?;
let declared = response
.headers()
.get("content-length")
.ok()
.flatten()
.and_then(|length| length.trim().parse::<usize>().ok());
if declared.is_some_and(|declared| declared > policy.max_response_bytes) {
return Err(HttpError::ResponseTooLarge {
limit: policy.max_response_bytes,
});
}
let mut builder = http::Response::builder().status(response.status_code());
let response_headers = response.headers();
if let Some(headers) = builder.headers_mut() {
for (name, value) in response_headers {
if let (Ok(name), Ok(value)) = (
http::HeaderName::try_from(name.as_str()),
http::HeaderValue::try_from(value.as_str()),
) {
headers.insert(name, value);
}
}
}
let bytes = response
.bytes()
.into_send()
.await
.map_err(|err| transport(&err, &url))?;
builder
.body(Bytes::from(bytes))
.map_err(|err| transport(&err, &url))
}
}
fn transport(err: &dyn std::error::Error, url: &str) -> HttpError {
HttpError::Transport(safe_message(err, url))
}
fn safe_message(err: &dyn std::error::Error, url: &str) -> String {
let mut message = err.to_string();
let mut cause = err.source();
while let Some(current) = cause {
let _ = write!(message, ": {current}");
cause = current.source();
}
scrub_text(&scrub_request_url(&message, url))
}
#[cfg(test)]
mod tests {
use super::*;
const TOKEN: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff";
fn apns_url() -> String {
format!("https://api.push.apple.com/3/device/{TOKEN}")
}
fn assert_safe(message: &str) {
assert!(
!message.contains(TOKEN),
"the device token leaked: {message}"
);
assert!(
!message.contains("/3/device"),
"the request path leaked: {message}"
);
}
#[test]
fn a_workerd_fetch_failure_names_the_origin_and_never_the_request_path() {
let url = apns_url();
let err = worker::Error::JsError(format!("TypeError: Fetch API cannot load: {url}."));
let message = safe_message(&err, &url);
assert_safe(&message);
assert!(
message.contains("https://api.push.apple.com"),
"the destination is named: {message}"
);
assert!(
message.contains("Fetch API cannot load"),
"the cause survives the redaction: {message}"
);
}
#[test]
fn the_cause_chain_is_walked_and_scrubbed() {
let url = apns_url();
let inner =
std::io::Error::other(format!("connection refused sending to /3/device/{TOKEN}"));
let err = worker::Error::Io(inner);
assert_eq!(
err.to_string(),
"I/O error",
"if `Display` ever carries the cause, this pass can stop appending it"
);
let message = safe_message(&err, &url);
assert_safe(&message);
assert!(
message.contains("connection refused"),
"the cause is what makes this diagnosable: {message}"
);
}
#[test]
fn a_secret_a_cause_quotes_is_scrubbed_even_when_it_is_not_the_url() {
let url = apns_url();
let err = worker::Error::JsError(
"rejected the Authorization: Bearer abcdefghijklmnop12345 we sent".to_owned(),
);
let message = safe_message(&err, &url);
assert!(
!message.contains("abcdefghijklmnop12345"),
"scrub_text is the second net: {message}"
);
}
#[test]
fn every_error_type_a_failure_site_produces_is_redacted() {
let url = apns_url();
let quoting = |what: &str| format!("{what} at {url}");
let utf8 = String::from_utf8(vec![0xff, 0xfe]).expect_err("not UTF-8");
let js = worker::Error::JsError(quoting("TypeError: Fetch API cannot load:"));
let rust = worker::Error::RustError(quoting("could not construct a request"));
let io = worker::Error::Io(std::io::Error::other(quoting("network connection lost")));
let http_err = http::Response::builder()
.status(200)
.header("x-\u{0}bad", "v")
.body(())
.expect_err("an invalid header name is a builder error");
let sites: [&dyn std::error::Error; 5] = [&utf8, &js, &rust, &io, &http_err];
for err in sites {
let HttpError::Transport(message) = transport(err, &url) else {
panic!("every site reports a transport failure");
};
assert_safe(&message);
}
}
#[test]
fn only_one_place_in_this_module_builds_a_transport_error() {
let source = include_str!("http.rs");
let module = source
.split_once("#[cfg(test)]")
.map_or(source, |(before, _)| before);
assert_eq!(
module.matches("HttpError::Transport(").count(),
1,
"a failure site must call `transport`, not build the error itself"
);
}
}