1use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, OnceLock};
8
9use super::{HttpTransportPolicy, TransportPolicyError};
10use std::time::{Duration, Instant};
11
12use futures::StreamExt as _;
13
14#[derive(Clone, Debug, serde::Serialize)]
15pub struct HttpRequest {
16 pub method: String,
18 pub url: String,
20 pub headers: Vec<(String, String)>,
22 pub body: Vec<u8>,
23 pub timeout_ms: u64,
24 pub max_response_size: u64,
26 pub decompress: bool,
28 #[serde(skip)]
30 pub transport_policy: Option<Arc<HttpTransportPolicy>>,
31 #[serde(skip)]
35 pub redirect_guard: Option<Arc<dyn RedirectGuard>>,
36 #[serde(skip)]
41 pub recorded_as: Option<RecordedRequest>,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct RecordedRequest {
50 pub masked_url: String,
52 pub digest: String,
54}
55
56pub trait RedirectGuard: Send + Sync + std::fmt::Debug {
58 fn authorize(&self, hop: &RedirectHop<'_>) -> Result<(), RedirectDenied>;
59 fn audit_egress_denial(&self, _hop: &RedirectHop<'_>, _at: EgressAt) {}
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum EgressAt {
65 CurrentHop,
67 NewHop,
69}
70
71#[derive(Clone, Copy, Debug)]
73pub struct RedirectHop<'a> {
74 pub method: &'a str,
76 pub url: &'a url::Url,
77 pub method_rewritten: bool,
80 pub body_len: u64,
81}
82
83#[derive(Debug)]
86pub struct RedirectDenied(wasmtime::Error);
87
88impl RedirectDenied {
89 pub fn new(
91 caller: impl Into<String>,
92 capability: impl Into<String>,
93 reason: impl Into<String>,
94 ) -> Self {
95 Self(crate::runtime::host::permission_denied(
96 caller, capability, reason,
97 ))
98 }
99
100 pub(crate) fn from_error(error: wasmtime::Error) -> Self {
101 Self(error)
102 }
103
104 pub(crate) fn into_error(self) -> wasmtime::Error {
105 self.0
106 }
107}
108
109impl std::fmt::Display for RedirectDenied {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 self.0.fmt(f)
112 }
113}
114
115impl std::error::Error for RedirectDenied {}
116
117#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
119pub struct HttpResponse {
120 pub status: u16,
121 pub status_text: String,
122 pub headers: Vec<(String, String)>,
124 #[serde(with = "crate::runtime::effects::binary")]
125 pub body: Vec<u8>,
126 pub final_url: String,
127}
128
129#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
131pub struct DownloadMeta {
132 pub status: u16,
133 pub status_text: String,
134 pub headers: Vec<(String, String)>,
135 pub final_url: String,
136 pub bytes_written: u64,
137}
138
139#[derive(Debug)]
140pub enum HttpError {
141 Network(String),
142 EgressDenied(String),
143 Internal(String),
145 Policy(TransportPolicyError),
146 PermissionDenied(RedirectDenied),
148 Timeout,
149 TooLarge {
151 limit: u64,
152 },
153 UnsupportedMethod(String),
155 Other(String),
156}
157
158impl HttpError {
159 pub(super) fn kind(&self) -> &'static str {
161 match self {
162 HttpError::Network(_) => "network",
163 HttpError::EgressDenied(_) => "egress-denied",
164 HttpError::Internal(_) => "internal",
165 HttpError::Policy(_) => "policy",
166 HttpError::PermissionDenied(_) => "permission-denied",
167 HttpError::Timeout => "timeout",
168 HttpError::TooLarge { .. } => "too-large",
169 HttpError::UnsupportedMethod(_) => "unsupported-method",
170 HttpError::Other(_) => "other",
171 }
172 }
173}
174
175impl std::fmt::Display for HttpError {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 match self {
178 HttpError::Internal(msg) => write!(f, "internal HTTP transport error: {msg}"),
179 HttpError::Policy(error) => error.fmt(f),
180 HttpError::PermissionDenied(denied) => denied.fmt(f),
181 HttpError::Network(msg) | HttpError::EgressDenied(msg) => {
182 write!(f, "network error: {msg}")
183 }
184 HttpError::Timeout => write!(f, "request timed out"),
185 HttpError::TooLarge { limit } => write!(
186 f,
187 "response too large (limit: {limit} bytes); consider http.download",
188 ),
189 HttpError::UnsupportedMethod(method) => {
190 write!(f, "unsupported HTTP method: {method}")
191 }
192 HttpError::Other(msg) => write!(f, "{msg}"),
193 }
194 }
195}
196
197impl std::error::Error for HttpError {}
198
199#[derive(Default)]
201pub struct DownloadProgress {
202 received: AtomicU64,
203 written: AtomicU64,
204}
205
206impl DownloadProgress {
207 pub fn received(&self, bytes: u64) {
209 let _ = self
210 .received
211 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |total| {
212 Some(total.saturating_add(bytes))
213 });
214 }
215
216 pub fn bytes_received(&self) -> u64 {
217 self.received.load(Ordering::Relaxed)
218 }
219
220 pub(crate) fn written(&self, bytes: u64) {
221 let _ = self
222 .written
223 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |total| {
224 Some(total.saturating_add(bytes))
225 });
226 }
227
228 pub(crate) fn bytes_written(&self) -> u64 {
229 self.written.load(Ordering::Relaxed)
230 }
231}
232
233struct ReceivedWriter<'a> {
234 writer: &'a mut (dyn std::io::Write + Send),
235 progress: &'a DownloadProgress,
236}
237
238impl std::io::Write for ReceivedWriter<'_> {
239 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
240 let n = self.writer.write(bytes)?;
241 self.progress.received(n as u64);
242 Ok(n)
243 }
244
245 fn flush(&mut self) -> std::io::Result<()> {
246 self.writer.flush()
247 }
248}
249
250#[async_trait::async_trait]
259pub trait HttpClient: Send + Sync {
260 fn wants_recorded_request(&self) -> bool {
263 false
264 }
265
266 async fn send(&self, req: &HttpRequest) -> Result<HttpResponse, HttpError>;
267
268 async fn send_without_redirects_to(
278 &self,
279 _req: &HttpRequest,
280 _body: &mut (dyn std::io::Write + Send),
281 ) -> Result<HttpResponse, HttpError> {
282 Err(HttpError::Other(
283 "transport does not support requests without redirects".into(),
284 ))
285 }
286
287 async fn download(
292 &self,
293 req: &HttpRequest,
294 writer: &mut (dyn std::io::Write + Send),
295 ) -> Result<DownloadMeta, HttpError>;
296
297 async fn download_with_progress(
301 &self,
302 req: &HttpRequest,
303 writer: &mut (dyn std::io::Write + Send),
304 progress: &DownloadProgress,
305 ) -> Result<DownloadMeta, HttpError> {
306 self.download(req, &mut ReceivedWriter { writer, progress })
307 .await
308 }
309}
310
311pub struct ReqwestHttpClient {
319 client: OnceLock<Result<reqwest::Client, String>>,
320 policy: Arc<crate::stdlib::http::policy::NetworkPolicy>,
324}
325
326const MAX_REDIRECTS: usize = 10;
328
329const CROSS_ORIGIN_SENSITIVE_HEADERS: [&str; 5] = [
331 "authorization",
332 "cookie",
333 "cookie2",
334 "proxy-authorization",
335 "www-authenticate",
336];
337
338const BODY_HEADERS: [&str; 4] = [
340 "content-type",
341 "content-length",
342 "content-encoding",
343 "transfer-encoding",
344];
345
346impl Default for ReqwestHttpClient {
347 fn default() -> Self {
348 Self::new(Arc::new(
349 crate::stdlib::http::policy::NetworkPolicy::allow_all(),
350 ))
351 }
352}
353
354impl ReqwestHttpClient {
355 pub fn new(policy: Arc<crate::stdlib::http::policy::NetworkPolicy>) -> Self {
356 Self {
357 client: OnceLock::new(),
358 policy,
359 }
360 }
361
362 #[cfg(test)]
364 pub(super) fn with_client(
365 policy: Arc<crate::stdlib::http::policy::NetworkPolicy>,
366 customize: impl FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder,
367 ) -> Self {
368 let client = Self::new(policy);
369 let built = customize(client.client_builder())
370 .build()
371 .map_err(|error| error.to_string());
372 let _ = client.client.set(built);
373 client
374 }
375
376 fn client_builder(&self) -> reqwest::ClientBuilder {
377 self.policy
378 .client_builder()
379 .redirect(reqwest::redirect::Policy::none())
380 .referer(false)
381 }
382
383 fn client(&self) -> Result<&reqwest::Client, HttpError> {
384 self.client
385 .get_or_init(|| {
386 self.client_builder()
387 .build()
388 .map_err(|error| error.to_string())
389 })
390 .as_ref()
391 .map_err(|error| HttpError::Internal(error.clone()))
392 }
393
394 fn check_literal_ip(&self, url: &str) -> Result<(), HttpError> {
397 self.policy
398 .check_literal_host(url)
399 .map_err(HttpError::EgressDenied)
400 }
401
402 async fn follow_redirects(&self, req: &HttpRequest) -> Result<reqwest::Response, HttpError> {
406 let mut hop = self.initial_hop(req).inspect_err(|error| {
407 audit_egress(req, &req.url, &req.method, error, EgressAt::CurrentHop);
408 })?;
409 let initial = hop.url.clone();
410 let deadline = Deadline::new(req.timeout_ms);
411 let mut redirects = 0;
412 loop {
413 let resp = self.send_hop(&hop, &deadline).await.inspect_err(|error| {
414 audit_egress(
415 req,
416 hop.url.as_str(),
417 hop.method.as_str(),
418 error,
419 EgressAt::CurrentHop,
420 );
421 })?;
422 let Some(next) = redirect_location(&resp, &hop.url) else {
423 return Ok(resp);
424 };
425 if redirects >= MAX_REDIRECTS {
426 return Err(HttpError::Network(format!(
427 "too many redirects (limit: {MAX_REDIRECTS})"
428 )));
429 }
430 redirects += 1;
431 let status = resp.status();
432 drop(resp);
434 hop.redirect_to(status, next);
435 self.authorize_hop(req, &initial, &hop)?;
436 }
437 }
438
439 fn initial_hop<'a>(&self, req: &'a HttpRequest) -> Result<Hop<'a>, HttpError> {
441 let url = parse_url(&req.url)?;
442 if let Some(policy) = &req.transport_policy {
443 policy.check_destination(&url).map_err(HttpError::Policy)?;
444 }
445 self.check_literal_ip(&req.url)?;
446 Ok(Hop {
447 method: parse_method(&req.method)?,
448 url,
449 headers: req.headers.clone(),
450 body: &req.body,
451 method_rewritten: false,
452 })
453 }
454
455 fn authorize_hop(
458 &self,
459 req: &HttpRequest,
460 initial: &url::Url,
461 hop: &Hop<'_>,
462 ) -> Result<(), HttpError> {
463 if let Some(policy) = &req.transport_policy {
464 policy
465 .check_redirect(initial, &hop.url)
466 .map_err(HttpError::Policy)?;
467 }
468 if !matches!(hop.url.scheme(), "http" | "https") {
469 return Err(HttpError::Network(
470 "redirect to a URL that is not http or https".into(),
471 ));
472 }
473 self.check_literal_ip(hop.url.as_str())
474 .inspect_err(|error| {
475 audit_egress(
476 req,
477 hop.url.as_str(),
478 hop.method.as_str(),
479 error,
480 EgressAt::NewHop,
481 );
482 })?;
483 let Some(guard) = &req.redirect_guard else {
484 return Ok(());
485 };
486 guard
487 .authorize(&RedirectHop {
488 method: hop.method.as_str(),
489 url: &hop.url,
490 method_rewritten: hop.method_rewritten,
491 body_len: hop.body.len() as u64,
492 })
493 .map_err(HttpError::PermissionDenied)
494 }
495
496 async fn send_hop(
498 &self,
499 hop: &Hop<'_>,
500 deadline: &Deadline,
501 ) -> Result<reqwest::Response, HttpError> {
502 let mut rb = self
503 .client()?
504 .request(hop.method.clone(), hop.url.clone())
505 .timeout(deadline.remaining()?);
506 for (name, value) in &hop.headers {
507 rb = rb.header(name.as_str(), value.as_str());
508 }
509 if !hop.body.is_empty() {
510 rb = rb.body(hop.body.to_vec());
511 }
512 rb.send().await.map_err(map_reqwest_error)
513 }
514}
515
516struct Hop<'a> {
519 method: reqwest::Method,
520 url: url::Url,
522 headers: Vec<(String, String)>,
523 body: &'a [u8],
524 method_rewritten: bool,
526}
527
528impl Hop<'_> {
529 fn redirect_to(&mut self, status: reqwest::StatusCode, mut next: url::Url) {
534 let rewrites_method = match status.as_u16() {
535 301 | 302 => self.method == reqwest::Method::POST,
536 303 => ![reqwest::Method::GET, reqwest::Method::HEAD].contains(&self.method),
537 _ => false,
538 };
539 if rewrites_method {
540 self.method = reqwest::Method::GET;
541 self.method_rewritten = true;
542 }
543 if rewrites_method || status.as_u16() == 303 {
544 self.body = &[];
545 remove_headers(&mut self.headers, &BODY_HEADERS);
546 }
547 if self.url.origin() == next.origin() {
548 keep_userinfo(&self.url, &mut next);
549 } else {
550 remove_headers(&mut self.headers, &CROSS_ORIGIN_SENSITIVE_HEADERS);
551 }
552 self.url = next;
553 }
554}
555
556fn keep_userinfo(current: &url::Url, next: &mut url::Url) {
559 if !next.username().is_empty() || next.password().is_some() {
560 return;
561 }
562 let _ = next.set_username(current.username());
564 let _ = next.set_password(current.password());
565}
566
567fn remove_headers(headers: &mut Vec<(String, String)>, names: &[&str]) {
568 headers.retain(|(name, _)| {
569 !names
570 .iter()
571 .any(|removed| name.eq_ignore_ascii_case(removed))
572 });
573}
574
575fn redirect_location(resp: &reqwest::Response, current: &url::Url) -> Option<url::Url> {
579 if !matches!(resp.status().as_u16(), 301 | 302 | 303 | 307 | 308) {
580 return None;
581 }
582 let location = resp.headers().get(reqwest::header::LOCATION)?;
583 current
584 .join(std::str::from_utf8(location.as_bytes()).ok()?)
585 .ok()
586}
587
588fn parse_url(url: &str) -> Result<url::Url, HttpError> {
589 url::Url::parse(url).map_err(|_| HttpError::Network("invalid HTTP URL".into()))
590}
591
592fn parse_method(method: &str) -> Result<reqwest::Method, HttpError> {
593 reqwest::Method::from_bytes(method.to_ascii_uppercase().as_bytes())
594 .map_err(|_| HttpError::UnsupportedMethod(method.to_string()))
595}
596
597struct Deadline {
600 at: Option<Instant>,
602 timeout: Duration,
603}
604
605impl Deadline {
606 fn new(timeout_ms: u64) -> Self {
607 let timeout = Duration::from_millis(timeout_ms);
608 Self {
609 at: Instant::now().checked_add(timeout),
610 timeout,
611 }
612 }
613
614 fn remaining(&self) -> Result<Duration, HttpError> {
615 let Some(at) = self.at else {
616 return Ok(self.timeout);
617 };
618 let remaining = at.saturating_duration_since(Instant::now());
619 if remaining.is_zero() {
620 return Err(HttpError::Timeout);
621 }
622 Ok(remaining)
623 }
624}
625
626#[async_trait::async_trait]
627impl HttpClient for ReqwestHttpClient {
628 async fn send(&self, req: &HttpRequest) -> Result<HttpResponse, HttpError> {
629 let resp = self.follow_redirects(req).await?;
630 read_response(resp, req.max_response_size).await
631 }
632
633 async fn send_without_redirects_to(
634 &self,
635 req: &HttpRequest,
636 body: &mut (dyn std::io::Write + Send),
637 ) -> Result<HttpResponse, HttpError> {
638 let hop = self.initial_hop(req).inspect_err(|error| {
639 audit_egress(req, &req.url, &req.method, error, EgressAt::CurrentHop);
640 })?;
641 let resp = self
642 .send_hop(&hop, &Deadline::new(req.timeout_ms))
643 .await
644 .inspect_err(|error| {
645 audit_egress(
646 req,
647 hop.url.as_str(),
648 hop.method.as_str(),
649 error,
650 EgressAt::CurrentHop,
651 );
652 })?;
653 let status = resp.status().as_u16();
654 let status_text = resp.status().canonical_reason().unwrap_or("").to_string();
655 let final_url = resp.url().to_string();
656 let headers = collect_headers(resp.headers());
657 let limit = req.max_response_size;
658 let mut received: u64 = 0;
659 let mut stream = resp.bytes_stream();
660 while let Some(chunk) = stream.next().await {
661 let chunk = chunk.map_err(map_reqwest_error)?;
662 received = received.saturating_add(chunk.len() as u64);
663 if received > limit {
664 return Err(HttpError::TooLarge { limit });
665 }
666 body.write_all(&chunk)
667 .map_err(|error| HttpError::Other(error.to_string()))?;
668 }
669 Ok(HttpResponse {
670 status,
671 status_text,
672 headers,
673 body: Vec::new(),
674 final_url,
675 })
676 }
677
678 async fn download(
679 &self,
680 req: &HttpRequest,
681 writer: &mut (dyn std::io::Write + Send),
682 ) -> Result<DownloadMeta, HttpError> {
683 self.download_with_progress(req, writer, &DownloadProgress::default())
684 .await
685 }
686
687 async fn download_with_progress(
688 &self,
689 req: &HttpRequest,
690 writer: &mut (dyn std::io::Write + Send),
691 progress: &DownloadProgress,
692 ) -> Result<DownloadMeta, HttpError> {
693 if req.method != "GET" {
696 return Err(HttpError::Other(format!(
697 "http.download requires GET; got {}",
698 req.method,
699 )));
700 }
701 let resp = self.follow_redirects(req).await?;
702
703 let status = resp.status().as_u16();
704 let status_text = resp.status().canonical_reason().unwrap_or("").to_string();
705 let final_url = resp.url().to_string();
706 let headers = collect_headers(resp.headers());
707
708 let kind = detect_decompression(&headers, &req.url, req.decompress);
710 let limit = req.max_response_size;
711 let mut sink = DecodeSink::new(kind, LimitedWriter::new(writer, limit))?;
715 let mut wire: u64 = 0;
716 let mut stream = resp.bytes_stream();
717 while let Some(chunk) = stream.next().await {
718 let chunk = chunk.map_err(map_reqwest_error)?;
719 progress.received(chunk.len() as u64);
720 wire = wire.saturating_add(chunk.len() as u64);
721 if wire > limit {
722 return Err(HttpError::TooLarge { limit });
723 }
724 sink.write_all(&chunk)?;
725 }
726 let bytes_written = sink.finish()?;
727
728 Ok(DownloadMeta {
729 status,
730 status_text,
731 headers,
732 final_url,
733 bytes_written,
734 })
735 }
736}
737
738async fn read_response(resp: reqwest::Response, limit: u64) -> Result<HttpResponse, HttpError> {
741 let status = resp.status().as_u16();
742 let status_text = resp.status().canonical_reason().unwrap_or("").to_string();
743 let final_url = resp.url().to_string();
744 let headers = collect_headers(resp.headers());
745
746 let mut body_bytes: Vec<u8> = Vec::new();
747 let mut stream = resp.bytes_stream();
748 while let Some(chunk) = stream.next().await {
749 let chunk = chunk.map_err(map_reqwest_error)?;
750 if body_bytes.len() as u64 + chunk.len() as u64 > limit {
751 return Err(HttpError::TooLarge { limit });
752 }
753 body_bytes.extend_from_slice(&chunk);
754 }
755
756 Ok(HttpResponse {
757 status,
758 status_text,
759 headers,
760 body: body_bytes,
761 final_url,
762 })
763}
764
765fn collect_headers(map: &reqwest::header::HeaderMap) -> Vec<(String, String)> {
767 let mut headers = Vec::with_capacity(map.len());
768 for (name, value) in map {
769 if let Ok(v) = value.to_str() {
770 headers.push((name.as_str().to_ascii_lowercase(), v.to_string()));
771 }
772 }
773 headers
774}
775
776#[derive(Clone, Copy, Debug, PartialEq, Eq)]
777pub enum Decompression {
778 None,
779 Gzip,
780 Zstd,
781}
782
783pub fn detect_decompression(
786 headers: &[(String, String)],
787 url: &str,
788 decompress: bool,
789) -> Decompression {
790 if !decompress {
791 return Decompression::None;
792 }
793 let ce = headers
794 .iter()
795 .find(|(k, _)| k.eq_ignore_ascii_case("content-encoding"))
796 .map_or("", |(_, v)| v.as_str());
797 if ce.eq_ignore_ascii_case("gzip") || ce.eq_ignore_ascii_case("x-gzip") {
798 return Decompression::Gzip;
799 }
800 if ce.eq_ignore_ascii_case("zstd") {
801 return Decompression::Zstd;
802 }
803 let path = url.split(['?', '#']).next().unwrap_or(url);
805 let lower = path.to_ascii_lowercase();
806 if lower.ends_with(".gz") {
807 return Decompression::Gzip;
808 }
809 if lower.ends_with(".zst") {
810 return Decompression::Zstd;
811 }
812 Decompression::None
813}
814
815pub fn stream_to_writer(
820 mut reader: impl std::io::Read,
821 writer: &mut dyn std::io::Write,
822 kind: Decompression,
823 limit: u64,
824) -> Result<u64, HttpError> {
825 let mut sink = DecodeSink::new(kind, LimitedWriter::new(writer, limit))?;
826 let mut wire: u64 = 0;
827 let mut chunk = [0u8; 8 * 1024];
828 loop {
829 let n = reader
830 .read(&mut chunk)
831 .map_err(|e| HttpError::Network(format!("io: {e}")))?;
832 if n == 0 {
833 break;
834 }
835 wire = wire.saturating_add(n as u64);
836 if wire > limit {
837 return Err(HttpError::TooLarge { limit });
838 }
839 sink.write_all(&chunk[..n])?;
840 }
841 sink.finish()
842}
843
844struct LimitedWriter<W> {
847 inner: W,
848 written: u64,
849 limit: u64,
850 exceeded: bool,
851}
852
853impl<W: std::io::Write> LimitedWriter<W> {
854 fn new(inner: W, limit: u64) -> Self {
855 Self {
856 inner,
857 written: 0,
858 limit,
859 exceeded: false,
860 }
861 }
862}
863
864impl<W: std::io::Write> std::io::Write for LimitedWriter<W> {
865 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
866 if self.written.saturating_add(buf.len() as u64) > self.limit {
867 self.exceeded = true;
868 return Err(std::io::Error::other("download limit exceeded"));
869 }
870 let n = self.inner.write(buf)?;
871 self.written = self.written.saturating_add(n as u64);
872 Ok(n)
873 }
874
875 fn flush(&mut self) -> std::io::Result<()> {
876 self.inner.flush()
877 }
878}
879
880enum DecodeSink<W: std::io::Write> {
884 Plain(LimitedWriter<W>),
885 Gzip(flate2::write::MultiGzDecoder<LimitedWriter<W>>),
886 Zstd(zstd::stream::zio::Writer<LimitedWriter<W>, zstd::stream::raw::Decoder<'static>>),
887}
888
889impl<W: std::io::Write> DecodeSink<W> {
890 fn new(kind: Decompression, out: LimitedWriter<W>) -> Result<Self, HttpError> {
891 Ok(match kind {
892 Decompression::None => Self::Plain(out),
893 Decompression::Gzip => Self::Gzip(flate2::write::MultiGzDecoder::new(out)),
894 Decompression::Zstd => {
895 let decoder = zstd::stream::raw::Decoder::new()
896 .map_err(|e| HttpError::Network(format!("zstd: {e}")))?;
897 Self::Zstd(zstd::stream::zio::Writer::new(out, decoder))
898 }
899 })
900 }
901
902 fn write_all(&mut self, chunk: &[u8]) -> Result<(), HttpError> {
903 use std::io::Write;
904 let result = match self {
905 Self::Plain(out) => out.write_all(chunk),
906 Self::Gzip(decoder) => decoder.write_all(chunk),
907 Self::Zstd(decoder) => decoder.write_all(chunk),
908 };
909 result.map_err(|e| self.error(e))
910 }
911
912 fn finish(mut self) -> Result<u64, HttpError> {
914 use std::io::Write;
915 let result = match &mut self {
916 Self::Plain(out) => out.flush(),
917 Self::Gzip(decoder) => decoder.try_finish(),
918 Self::Zstd(decoder) => decoder.finish(),
919 };
920 result.map_err(|e| self.error(e))?;
921 Ok(self.out().written)
922 }
923
924 fn out(&self) -> &LimitedWriter<W> {
925 match self {
926 Self::Plain(out) => out,
927 Self::Gzip(decoder) => decoder.get_ref(),
928 Self::Zstd(decoder) => decoder.writer(),
929 }
930 }
931
932 fn error(&self, e: std::io::Error) -> HttpError {
933 let out = self.out();
934 if out.exceeded {
935 HttpError::TooLarge { limit: out.limit }
936 } else {
937 HttpError::Network(format!("io: {e}"))
938 }
939 }
940}
941
942fn audit_egress(req: &HttpRequest, url: &str, method: &str, error: &HttpError, at: EgressAt) {
943 if !matches!(error, HttpError::EgressDenied(_)) {
944 return;
945 }
946 if let (Some(guard), Ok(url)) = (&req.redirect_guard, url::Url::parse(url)) {
947 let hop = RedirectHop {
948 method,
949 url: &url,
950 method_rewritten: method != req.method,
951 body_len: 0,
952 };
953 guard.audit_egress_denial(&hop, at);
954 }
955}
956
957pub(super) fn http_failure_outcome(err: &HttpError) -> &'static str {
959 match err {
960 HttpError::Timeout => "timeout",
961 HttpError::TooLarge { .. } => "too_large",
962 HttpError::Network(_)
963 | HttpError::EgressDenied(_)
964 | HttpError::Internal(_)
965 | HttpError::Policy(_)
966 | HttpError::PermissionDenied(_)
967 | HttpError::UnsupportedMethod(_)
968 | HttpError::Other(_) => "error",
969 }
970}
971
972fn map_reqwest_error(err: reqwest::Error) -> HttpError {
979 if err.is_timeout() {
980 return HttpError::Timeout;
981 }
982 let mut source: Option<&(dyn std::error::Error + 'static)> = Some(&err);
983 while let Some(error) = source {
984 if error
985 .downcast_ref::<super::policy::EgressDenied>()
986 .is_some()
987 {
988 return HttpError::EgressDenied(describe_error_chain(&err.without_url()));
989 }
990 source = error.source();
991 }
992 HttpError::Network(describe_error_chain(&err.without_url()))
993}
994
995pub fn describe_error_chain(err: &dyn std::error::Error) -> String {
996 let top = err.to_string();
997 let mut deepest: Option<String> = None;
998 let mut current = err.source();
999 while let Some(cause) = current {
1000 deepest = Some(cause.to_string());
1001 current = cause.source();
1002 }
1003 match deepest {
1004 Some(reason) if !top.contains(&reason) => format!("{top}: {reason}"),
1005 _ => top,
1006 }
1007}
1008
1009pub fn is_policy_refusal(chain: &str) -> bool {
1012 chain.contains("blocked by network policy")
1013}
1014
1015pub fn default_http_client() -> Arc<dyn HttpClient> {
1016 Arc::new(ReqwestHttpClient::default())
1017}
1018
1019#[async_trait::async_trait]
1024pub trait AuthProxy: Send + Sync {
1025 async fn transform(
1026 &self,
1027 req: HttpRequest,
1028 caller: &str,
1029 ) -> Result<HttpRequest, AuthProxyError>;
1030}
1031
1032#[derive(Debug)]
1033pub enum AuthProxyError {
1034 UndeclaredSecret(String),
1036 MissingSecret(String),
1038 Other(String),
1039}
1040
1041impl std::fmt::Display for AuthProxyError {
1042 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1043 match self {
1044 AuthProxyError::UndeclaredSecret(name) => {
1045 write!(f, "undeclared secret '{name}'")
1046 }
1047 AuthProxyError::MissingSecret(name) => {
1048 write!(f, "missing secret '{name}'")
1049 }
1050 AuthProxyError::Other(msg) => write!(f, "{msg}"),
1051 }
1052 }
1053}
1054
1055impl std::error::Error for AuthProxyError {}
1056
1057pub struct NoopAuthProxy;
1058
1059#[async_trait::async_trait]
1060impl AuthProxy for NoopAuthProxy {
1061 async fn transform(
1062 &self,
1063 req: HttpRequest,
1064 _caller: &str,
1065 ) -> Result<HttpRequest, AuthProxyError> {
1066 Ok(req)
1067 }
1068}
1069
1070pub fn default_auth_proxy() -> Arc<dyn AuthProxy> {
1071 Arc::new(NoopAuthProxy)
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::describe_error_chain;
1077
1078 #[derive(Debug)]
1079 struct Layer {
1080 message: &'static str,
1081 source: Option<Box<Layer>>,
1082 }
1083
1084 impl std::fmt::Display for Layer {
1085 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1086 f.write_str(self.message)
1087 }
1088 }
1089
1090 impl std::error::Error for Layer {
1091 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1092 self.source
1093 .as_deref()
1094 .map(|layer| layer as &(dyn std::error::Error + 'static))
1095 }
1096 }
1097
1098 #[test]
1099 fn appends_the_root_cause_when_the_top_message_omits_it() {
1100 let err = Layer {
1101 message: "error sending request for url (http://x/)",
1102 source: Some(Box::new(Layer {
1103 message: "client error (Connect)",
1104 source: Some(Box::new(Layer {
1105 message: "blocked by network policy: x resolves only to private/loopback IP space",
1106 source: None,
1107 })),
1108 })),
1109 };
1110 assert_eq!(
1111 describe_error_chain(&err),
1112 "error sending request for url (http://x/): blocked by network policy: x resolves only to private/loopback IP space"
1113 );
1114 }
1115
1116 #[test]
1117 fn leaves_a_message_that_already_carries_its_cause_alone() {
1118 let err = Layer {
1119 message: "timeout: deadline elapsed",
1120 source: Some(Box::new(Layer {
1121 message: "deadline elapsed",
1122 source: None,
1123 })),
1124 };
1125 assert_eq!(describe_error_chain(&err), "timeout: deadline elapsed");
1126 let bare = Layer {
1127 message: "plain",
1128 source: None,
1129 };
1130 assert_eq!(describe_error_chain(&bare), "plain");
1131 }
1132}
1133
1134#[cfg(test)]
1135#[path = "transport_tls_tests.rs"]
1136mod tls_tests;
1137
1138#[cfg(test)]
1139mod decode_sink_tests {
1140 use std::io::Write as _;
1141
1142 use super::{DecodeSink, Decompression, HttpError, LimitedWriter};
1143
1144 fn gzip(data: &[u8]) -> Vec<u8> {
1145 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1146 encoder.write_all(data).unwrap();
1147 encoder.finish().unwrap()
1148 }
1149
1150 fn decode(kind: Decompression, wire: &[u8], limit: u64) -> (Result<u64, HttpError>, Vec<u8>) {
1152 let mut out: Vec<u8> = Vec::new();
1153 let result = (|| {
1154 let mut sink = DecodeSink::new(kind, LimitedWriter::new(&mut out, limit))?;
1155 for chunk in wire.chunks(7) {
1156 sink.write_all(chunk)?;
1157 }
1158 sink.finish()
1159 })();
1160 (result, out)
1161 }
1162
1163 #[test]
1164 fn plain_bytes_stop_at_the_limit() {
1165 let (ok, out) = decode(Decompression::None, b"hello", 5);
1166 assert_eq!(ok.unwrap(), 5);
1167 assert_eq!(out, b"hello");
1168 let (over, _) = decode(Decompression::None, b"hello!", 5);
1169 assert!(matches!(over, Err(HttpError::TooLarge { limit: 5 })));
1170 }
1171
1172 #[test]
1173 fn gzip_decodes_across_chunks_and_rejects_truncation() {
1174 let wire = gzip(b"hello gzipped download");
1175 let (ok, out) = decode(Decompression::Gzip, &wire, 1024);
1176 assert_eq!(ok.unwrap(), 22);
1177 assert_eq!(out, b"hello gzipped download");
1178
1179 let (truncated, _) = decode(Decompression::Gzip, &wire[..wire.len() - 4], 1024);
1180 assert!(
1181 matches!(truncated, Err(HttpError::Network(_))),
1182 "{truncated:?}"
1183 );
1184 }
1185
1186 #[test]
1187 fn a_small_gzip_body_that_inflates_past_the_limit_is_too_large() {
1188 let wire = gzip(&vec![b'x'; 100_000]);
1189 assert!(wire.len() < 1_000);
1190 let (result, out) = decode(Decompression::Gzip, &wire, 1_000);
1191 assert!(
1192 matches!(result, Err(HttpError::TooLarge { limit: 1_000 })),
1193 "{result:?}"
1194 );
1195 assert!(out.len() <= 1_000);
1196 }
1197
1198 #[test]
1199 fn zstd_decodes_and_rejects_an_incomplete_frame() {
1200 let wire = zstd::encode_all(&b"hello zstd download"[..], 1).unwrap();
1201 let (ok, out) = decode(Decompression::Zstd, &wire, 1024);
1202 assert_eq!(ok.unwrap(), 19);
1203 assert_eq!(out, b"hello zstd download");
1204
1205 let (truncated, _) = decode(Decompression::Zstd, &wire[..wire.len() - 3], 1024);
1206 assert!(
1207 matches!(truncated, Err(HttpError::Network(_))),
1208 "{truncated:?}"
1209 );
1210 }
1211}
1212
1213#[cfg(test)]
1214mod client_setup_tests {
1215 use super::*;
1216
1217 #[test]
1218 fn invalid_client_configuration_is_retained_as_an_internal_failure() {
1219 let client = ReqwestHttpClient::with_client(
1220 Arc::new(crate::stdlib::http::policy::NetworkPolicy::allow_all()),
1221 |builder| builder.user_agent("\n"),
1222 );
1223 for _ in 0..2 {
1224 assert!(matches!(client.client(), Err(HttpError::Internal(_))));
1225 }
1226 let healthy = ReqwestHttpClient::default();
1227 assert!(healthy.client().is_ok());
1228 assert!(healthy.client().is_ok());
1229 }
1230}