Skip to main content

submilli_engine/stdlib/http/
transport.rs

1//! Transport layer for `submilli:http` — the embedder-facing [`HttpClient`] /
2//! [`AuthProxy`] traits, the default `reqwest` client with SSRF policy, and the
3//! bounded download/decompression plumbing. No Wasm ABI here; the package's
4//! host fns live in [`super`].
5
6use 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    /// Upper-cased.
17    pub method: String,
18    /// Absolute URL, query string included.
19    pub url: String,
20    /// Names as the guest supplied them; lookups downstream are case-insensitive.
21    pub headers: Vec<(String, String)>,
22    pub body: Vec<u8>,
23    pub timeout_ms: u64,
24    /// `HttpError::TooLarge` if exceeded.
25    pub max_response_size: u64,
26    /// `http.download`-only: opt-in transparent decompression via `Content-Encoding` or URL suffix.
27    pub decompress: bool,
28    /// Custom transports must enforce this on the initial URL and every redirect.
29    #[serde(skip)]
30    pub transport_policy: Option<Arc<HttpTransportPolicy>>,
31    /// Capability rules for redirect hops, attached after the auth proxy runs.
32    /// Custom transports must consult it before sending every redirect hop; the
33    /// initial URL is already authorized.
34    #[serde(skip)]
35    pub redirect_guard: Option<Arc<dyn RedirectGuard>>,
36    /// What the call log recorded for the program's own request, before the auth proxy
37    /// changed it. Set only for a client that asks ([`HttpClient::wants_recorded_request`]),
38    /// and by the host functions alone: an auth proxy that builds a new request instead
39    /// of changing this one drops it.
40    #[serde(skip)]
41    pub recorded_as: Option<RecordedRequest>,
42}
43
44/// How the call log keys and digests a request: its masked URL, and the digest of its
45/// masked meta and body. The auth proxy may rewrite the URL and headers (a credential
46/// injected into the query or a header, in another position), so a transport that must
47/// recognize a request it saw recorded reads this instead of deriving it again.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct RecordedRequest {
50    /// [`mask_url`](crate::runtime::call_log::mask_url) of the program's URL.
51    pub masked_url: String,
52    /// The `digest` of the request's payload record.
53    pub digest: String,
54}
55
56/// Authorizes one redirect hop before any byte of it is sent.
57pub 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/// Which request an egress refusal belongs to, for a guard that records decisions.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum EgressAt {
65    /// The original request, or the redirect hop the guard last authorized.
66    CurrentHop,
67    /// A redirect hop refused before the guard authorized it.
68    NewHop,
69}
70
71/// The request a redirect is about to send.
72#[derive(Clone, Copy, Debug)]
73pub struct RedirectHop<'a> {
74    /// Upper-cased; `GET` after a redirect rewrote the original method.
75    pub method: &'a str,
76    pub url: &'a url::Url,
77    /// A redirect on the way here changed the method to `GET` and dropped the
78    /// body: 301/302 for POST, 303 for every method but GET and HEAD.
79    pub method_rewritten: bool,
80    pub body_len: u64,
81}
82
83/// A hop the guard refused. Transports return it unchanged as
84/// [`HttpError::PermissionDenied`] so the guest sees the original denial.
85#[derive(Debug)]
86pub struct RedirectDenied(wasmtime::Error);
87
88impl RedirectDenied {
89    /// A policy denial, surfaced to the guest as `PermissionDeniedError`.
90    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/// 4xx/5xx are not errors at this layer — only transport failures become [`HttpError`].
118#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
119pub struct HttpResponse {
120    pub status: u16,
121    pub status_text: String,
122    /// Names lowercased by [`ReqwestHttpClient`].
123    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/// Body was streamed to the caller's writer; only metadata survives.
130#[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    /// Host setup failure: must terminate execution, not enter a guest catch.
144    Internal(String),
145    Policy(TransportPolicyError),
146    /// A redirect hop the capability rules deny; nothing was sent to it.
147    PermissionDenied(RedirectDenied),
148    Timeout,
149    /// Message includes the cap and suggests `http.download`.
150    TooLarge {
151        limit: u64,
152    },
153    /// Surfaces as a guest `TypeError` (a malformed request, not a transport failure).
154    UnsupportedMethod(String),
155    Other(String),
156}
157
158impl HttpError {
159    /// A stable name for the failure, for records that must tell failures apart.
160    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/// Progress remains observable when a download fails or is cancelled.
200#[derive(Default)]
201pub struct DownloadProgress {
202    received: AtomicU64,
203    written: AtomicU64,
204}
205
206impl DownloadProgress {
207    /// Record wire bytes as they arrive, before decoding or limit checks.
208    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/// Embedder-supplied HTTP transport. 4xx/5xx are not errors; only transport
251/// failures return `Err(HttpError)`. `Send + Sync` for sharing across stores.
252/// Implementations must enforce `HttpRequest::transport_policy`, including
253/// redirect destinations, and must call `HttpRequest::redirect_guard` before
254/// sending each redirect hop, returning its denial as
255/// [`HttpError::PermissionDenied`]; a transport that follows redirects without
256/// the guard bypasses the blueprint's capability rules. Preserve
257/// `HttpError::Internal` as a fatal host failure.
258#[async_trait::async_trait]
259pub trait HttpClient: Send + Sync {
260    /// Whether this client reads [`HttpRequest::recorded_as`]. The host functions
261    /// compute it only for a client that does.
262    fn wants_recorded_request(&self) -> bool {
263        false
264    }
265
266    async fn send(&self, req: &HttpRequest) -> Result<HttpResponse, HttpError>;
267
268    /// Sends `req` to exactly its URL, never following a redirect, even on
269    /// the same host, and writes the body to `body` as it arrives; the
270    /// returned response's `body` is empty. Git fetches through it, streaming
271    /// packs to disk, so an implementation holds one chunk in memory however
272    /// large `max_response_size` is.
273    ///
274    /// Embedders must opt in to this contract before Git can use their
275    /// transport: there is no buffering fallback, for the reason `download`
276    /// has none.
277    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    /// No default impl: the obvious "buffer via `send` then `write_all`" fallback
288    /// would silently break the bounded-memory guarantee `http.download` advertises.
289    /// Implementations must enforce the request's timeout across the entire
290    /// transfer and its byte limit on both wire and decoded output.
291    async fn download(
292        &self,
293        req: &HttpRequest,
294        writer: &mut (dyn std::io::Write + Send),
295    ) -> Result<DownloadMeta, HttpError>;
296
297    /// Existing embedders report the bytes accepted by the destination.
298    /// Override to count actual wire bytes, including bytes rejected before a
299    /// write and compressed bytes. The default cannot observe those bytes.
300    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
311/// Default `HttpClient` — async `reqwest`, built with the SSRF policy resolver.
312/// Redirects are followed by [`ReqwestHttpClient::follow_redirects`] rather than
313/// reqwest, so every hop is checked against the request's own policy and guard
314/// and the pooled client holds no per-request authorization. The `cookies`
315/// feature is intentionally never enabled, so the client carries **no**
316/// cross-request state; the server builds one per session (see
317/// `submilli-server`) for tenant isolation.
318pub struct ReqwestHttpClient {
319    client: OnceLock<Result<reqwest::Client, String>>,
320    /// Also kept here (not just in the DNS resolver) so a **literal-IP** URL —
321    /// which reqwest connects to without ever calling the resolver — is still
322    /// checked. Without this, `http://127.0.0.1` would bypass the SSRF guard.
323    policy: Arc<crate::stdlib::http::policy::NetworkPolicy>,
324}
325
326/// Matches reqwest's default limit, which the resource-limit docs publish.
327const MAX_REDIRECTS: usize = 10;
328
329/// Removed when a redirect leaves the current origin, as reqwest does.
330const CROSS_ORIGIN_SENSITIVE_HEADERS: [&str; 5] = [
331    "authorization",
332    "cookie",
333    "cookie2",
334    "proxy-authorization",
335    "www-authenticate",
336];
337
338/// Headers describing the body that a method-rewriting redirect drops.
339const 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    /// Like [`Self::new`], with a prebuilt client whose builder a test customized.
363    #[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    /// Block a literal-IP host the policy forbids. Hostnames go through the DNS
395    /// resolver (which filters resolved IPs); literal IPs never hit it.
396    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    /// Send `req` and every redirect it earns. Each hop passes the transport
403    /// policy, the network policy and the request's guard before it is sent, so
404    /// a denied destination never receives a request or its body.
405    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            // The intermediate body is never read; dropping it abandons the connection.
433            drop(resp);
434            hop.redirect_to(status, next);
435            self.authorize_hop(req, &initial, &hop)?;
436        }
437    }
438
439    /// The request's own destination, checked as every hop is before it is sent.
440    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    /// Checks for a redirect hop, in the order the initial request applies them,
456    /// ending with the capability guard. Runs before the hop is sent.
457    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    /// One request with no redirect handling, bounded by what remains of `deadline`.
497    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
516/// The request the next hop sends. The body is the original request's until a
517/// redirect drops it.
518struct Hop<'a> {
519    method: reqwest::Method,
520    /// Keeps the request's userinfo, which reqwest sends as Basic auth.
521    url: url::Url,
522    headers: Vec<(String, String)>,
523    body: &'a [u8],
524    /// A 301/302/303 on the way here changed the method to GET.
525    method_rewritten: bool,
526}
527
528impl Hop<'_> {
529    /// Turn this hop into the redirect to `next` after a `status` response,
530    /// matching reqwest's redirect policy: 301/302 turn POST into a bodiless GET,
531    /// 303 turns every method but HEAD into GET and drops the body, and 307/308
532    /// keep both. Credentials, including URL userinfo, stay within the origin.
533    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
556/// Carry `current`'s userinfo to a same-origin `next` that names none, like the
557/// Basic auth header reqwest derives from userinfo.
558fn keep_userinfo(current: &url::Url, next: &mut url::Url) {
559    if !next.username().is_empty() || next.password().is_some() {
560        return;
561    }
562    // Both URLs are http(s) with a host, which always accept userinfo.
563    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
575/// The destination of a redirect response to a request for `current`, or `None`
576/// when the response is final: not a redirect status, or a `Location` that is
577/// missing or unusable (reqwest returns such a response unchanged, too).
578fn 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
597/// One timeout shared by every hop of a request, so the request timeout covers
598/// the whole redirect chain.
599struct Deadline {
600    /// `None` when the timeout is too large to represent as an instant.
601    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        // GET-only in v1; trap here so a future "download via POST" doesn't
694        // silently break the bounded-memory guarantee.
695        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        // Content-Encoding header takes precedence; URL suffix is the fallback.
709        let kind = detect_decompression(&headers, &req.url, req.decompress);
710        let limit = req.max_response_size;
711        // Each chunk goes to the file as it arrives, so host memory holds one
712        // chunk however large `maxBytes` is. The limit applies to the wire bytes
713        // and, separately, to the decoded bytes written.
714        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
738/// Buffer a response body, stopping once the wire body exceeds `limit` so host
739/// memory stays bounded.
740async 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
765/// Lowercase header names so `Headers#get("content-type")` matches any casing.
766fn 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
783/// Public so embedder [`HttpClient::download`] impls can apply the right decoder
784/// before counting bytes against `max_response_size`.
785pub 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    // Header absent or `identity` — fall back to URL suffix.
804    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
815/// Bounded chunked copy (8 KiB stack chunk) with optional gzip/zstd decoding.
816/// Public so embedder [`HttpClient::download`] impls can reuse it: `reader` is the
817/// response body as it arrives, decoded into `writer` exactly as the built-in client
818/// decodes a download, with the same limits on the wire and decoded bytes.
819pub 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
844/// A writer that refuses bytes past `limit`, remembering that it did so, so a
845/// decoder's error can be told apart from a download that is too large.
846struct 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
880/// A download's destination: the file itself, or a decoder writing into it.
881/// The decoders take the body a chunk at a time, and a truncated gzip or zstd
882/// stream fails at `finish`.
883enum 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    /// Flush what the decoder still holds and return the bytes written.
913    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
957/// Bounded outcome class for an [`crate::runtime::metrics::HttpMetric`]: the transport failure mode.
958pub(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
972/// reqwest reports timeout via `is_timeout()`; everything else is a network error.
973///
974/// reqwest's own message stops at "error sending request"; the reason lives at
975/// the bottom of its source chain (a refused connection, or the policy
976/// resolver's "blocked by network policy"). The guest and the operator both act
977/// on that reason, so it is appended when it says more than the top-level message.
978fn 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
1009/// Whether an error chain (as [`describe_error_chain`] renders it) is the network policy
1010/// refusing the destination, which reaches a client builder's caller as a connect error.
1011pub 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/// Pipeline: `host fn → auth_proxy.transform(req, caller) → http_client.send(req)`.
1020/// Consume-and-return so middlewares compose: `m2.transform(m1.transform(req, c)?, c)?`.
1021/// `caller` is the package that made the call (`"main"` for the user's script);
1022/// blueprint auth-proxy injection applies only to `main`.
1023#[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    /// Referenced a secret not declared in the blueprint's `secrets:` block.
1035    UndeclaredSecret(String),
1036    /// Secret declared but source returned no value.
1037    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    /// Feed `wire` in small chunks, as a network stream would.
1151    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}