Skip to main content

act_runtime/
http_client.rs

1//! Reqwest-backed client for `wasi:http/outgoing-handler`. One instance per
2//! `HostState` (per component invocation). Client config — redirect policy,
3//! DNS resolver — is baked in at construction from the component's
4//! `HttpConfig` so we don't need to thread context through each call.
5//!
6//! # Extraction boundary
7//!
8//! `http_client`, `http_policy`, and `network`, plus the `HttpConfig` /
9//! `HttpRule` / `NetworkRule` / `PolicyMode` types in `config`, form a
10//! self-contained "policy-aware HTTP backend for `wasi:http`" unit with
11//! zero act-cli-specific dependencies (no CLI, no component metadata, no
12//! ACT protocol). The boundary is maintained intentionally so this layer
13//! can be lifted into its own crate (e.g. `act-wasi-http-policy`) when a
14//! second consumer appears or when we propose the pattern upstream to
15//! `wasmtime-wasi-http`. Do not reach outside those modules from here; if
16//! you need something else, pass it in via config.
17
18use std::future::Future;
19use std::pin::Pin;
20use std::sync::Arc;
21
22use http_body_util::BodyExt;
23use wasmtime_wasi_http::{Error as HttpError, RequestOptions, WasiBody};
24
25use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
26use act_policy::grant::{HttpConfig, PolicyMode};
27use act_policy::net::{self as network, NetworkRule};
28
29/// A resolver that filters what a name resolves to, against the component's
30/// CIDR rules.
31///
32/// Per resolved address:
33/// 1. Drop if any deny-CIDR matches (respecting `except_ports`).
34/// 2. In `Allowlist` mode, if any allow rule carries a `cidr`, the address must
35///    be covered by either a host-anchored allow (the hostname itself was
36///    allowed, so every address it resolves to is) or an allow-CIDR. This
37///    closes the asymmetry where `allow = [{ cidr = "..." }]` would otherwise
38///    require an IP-literal URI.
39/// 3. `Open` / `Deny` modes: no allow-side filter (`Deny` never reaches a
40///    resolver; `Open` still honours deny-CIDR as a safety net).
41///
42/// ## Two streams, one decision
43///
44/// `hclient`'s `Resolve` returns A and AAAA as **separate streams**, because
45/// RFC 8305 requires starting IPv6 attempts without waiting for the IPv4
46/// answer. That is right for connecting and awkward for auditing: "everything
47/// was filtered" is only knowable once both have ended, and a record emitted
48/// per stream would report one blocked lookup twice.
49///
50/// So this layer no longer emits it. The record moves to where the failure
51/// becomes one event — the request, in [`ActHttpClient::send`], which sees a
52/// resolve error and knows the host it was for. That is also where the old
53/// comment said the decision belonged ("to the guest this is a single
54/// failure"); the two-stream shape merely forced the issue.
55#[derive(Clone)]
56struct PolicyDnsResolver {
57    inner: Arc<hclient_dns_system::SystemDns<hclient_rt_tokio::Tokio>>,
58    /// Per name: how many addresses the upstream offered, and how many
59    /// survived. Read once, by `send`, to tell "policy dropped everything"
60    /// from "the name does not resolve" — a distinction the two streams
61    /// cannot make on their own, and one that must survive: reporting a DNS
62    /// outage as a capability denial sends an operator to the wrong file.
63    seen: Arc<std::sync::Mutex<std::collections::HashMap<String, (usize, usize)>>>,
64    allow_nets: Arc<Vec<NetworkRule>>,
65    deny_nets: Arc<Vec<NetworkRule>>,
66    mode: PolicyMode,
67}
68
69impl PolicyDnsResolver {
70    fn new(cfg: &HttpConfig) -> Self {
71        Self {
72            inner: Arc::new(hclient_dns_system::SystemDns::new(hclient_rt_tokio::Tokio)),
73            seen: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
74            allow_nets: Arc::new(cfg.allow.iter().map(|r| r.net.clone()).collect()),
75            deny_nets: Arc::new(cfg.deny.iter().map(|r| r.net.clone()).collect()),
76            mode: cfg.mode,
77        }
78    }
79
80    /// Whether this address may be connected to at all.
81    ///
82    /// Port zero: a name resolves independently of the port a caller will
83    /// later connect to, so a deny rule scoped to ports cannot be decided
84    /// here. Port-scoped rules are enforced where the port is known — the
85    /// request check in `send`, and the redirect predicate.
86    /// Whether policy — not DNS — is why nothing came back for `host`.
87    ///
88    /// `true` only when the upstream offered addresses and every one was
89    /// dropped. A name that resolves to nothing is a DNS failure and gets no
90    /// capability record: it is not a decision this host made.
91    fn filtered_everything(&self, host: &str) -> bool {
92        self.seen
93            .lock()
94            .unwrap_or_else(std::sync::PoisonError::into_inner)
95            .get(host)
96            .is_some_and(|(offered, kept)| *offered > 0 && *kept == 0)
97    }
98
99    fn permits(&self, host: &str, addr: std::net::IpAddr) -> bool {
100        if network::any_deny_cidr_matches(&self.deny_nets, addr, 0) {
101            return false;
102        }
103        let host_allowed = self.allow_nets.iter().any(|r| {
104            r.host
105                .as_deref()
106                .is_some_and(|pat| network::host_matches(pat, host))
107        });
108        let require_allow_cidr = self.mode == PolicyMode::Allowlist
109            && !host_allowed
110            && self.allow_nets.iter().any(|r| r.cidr.is_some());
111        if require_allow_cidr {
112            return self.allow_nets.iter().any(|r| {
113                r.cidr
114                    .as_deref()
115                    .is_some_and(|c| network::cidr_contains(c, addr))
116            });
117        }
118        true
119    }
120}
121
122impl hclient_dns::Resolve for PolicyDnsResolver {
123    type Records<'a> =
124        futures_util::stream::BoxStream<'a, Result<hclient_dns::Record, hclient::Error>>;
125
126    /// Whatever the inner resolver can answer, this can — the filter drops
127    /// records, it does not add or remove the ability to ask. Deferring
128    /// rather than answering `false` keeps a policy from silently costing
129    /// the connector an HTTPS lookup it would otherwise have made.
130    fn supports(&self, rtype: u16) -> bool {
131        hclient_dns::Resolve::supports(&*self.inner, rtype)
132    }
133
134    fn lookup<'a>(&'a self, name: &str, rtype: u16) -> Self::Records<'a> {
135        self.filtered(name, rtype)
136    }
137}
138
139impl PolicyDnsResolver {
140    fn filtered<'a>(
141        &'a self,
142        name: &str,
143        rtype: u16,
144    ) -> futures_util::stream::BoxStream<'a, Result<hclient_dns::Record, hclient::Error>> {
145        use futures_util::StreamExt;
146        let host = name.to_string();
147        let upstream: futures_util::stream::BoxStream<'a, _> =
148            Box::pin(hclient_dns::Resolve::lookup(&*self.inner, name, rtype));
149        Box::pin(upstream.filter(move |item| {
150            let keep = match item {
151                Ok(record) => match record.rdata {
152                    hclient_dns::RData::A(v4) => self.permits(&host, v4.into()),
153                    hclient_dns::RData::Aaaa(v6) => self.permits(&host, v6.into()),
154                    // **An HTTPS record carries addresses, so it is checked
155                    // like one.** `ipv4hint`/`ipv6hint` are addresses the
156                    // connector may dial without ever asking for A or AAAA,
157                    // so treating this as a mere routing hint would let a
158                    // name whose every address the policy refuses be reached
159                    // anyway through its hints — which is exactly what
160                    // `dns_resolver_requires_allow_cidr_match_for_hostnames`
161                    // caught when this arm returned `true`.
162                    //
163                    // A record with no hints has nothing to dial and is kept:
164                    // its `target` is resolved by a further lookup that comes
165                    // back through this same filter.
166                    hclient_dns::RData::Https(ref ep) => {
167                        ep.ipv4hint
168                            .iter()
169                            .all(|v4| self.permits(&host, (*v4).into()))
170                            && ep
171                                .ipv6hint
172                                .iter()
173                                .all(|v6| self.permits(&host, (*v6).into()))
174                    }
175                    // `RData` is `#[non_exhaustive]`, which is the point of
176                    // alpha.4's redesign: a record type this client learns
177                    // later must not break us. Anything that is not an
178                    // address is not an address to filter, so it passes for
179                    // the same reason `Https` does.
180                    _ => true,
181                },
182                // A resolver error is not a policy decision and is passed
183                // through: swallowing it would turn "DNS is down" into
184                // "policy refused", and an operator would go looking in the
185                // wrong place.
186                Err(_) => true,
187            };
188            // Only address records are counted. `filtered_everything` means
189            // "policy refused every address this name offered", and an HTTPS
190            // record is never refused here — counting one would make a name
191            // whose only answer was a routing hint look like a name whose
192            // addresses were allowed, and suppress the capability record the
193            // operator needs.
194            let is_address = matches!(
195                item,
196                Ok(hclient_dns::Record {
197                    rdata: hclient_dns::RData::A(_) | hclient_dns::RData::Aaaa(_),
198                    ..
199                })
200            );
201            if is_address {
202                let mut seen = self
203                    .seen
204                    .lock()
205                    .unwrap_or_else(std::sync::PoisonError::into_inner);
206                let counts = seen.entry(host.clone()).or_insert((0, 0));
207                counts.0 += 1;
208                if keep {
209                    counts.1 += 1;
210                }
211            }
212            if !keep {
213                tracing::debug!(%host, "http policy dropped a resolved address");
214            }
215            std::future::ready(keep)
216        }))
217    }
218}
219
220/// The per-hop capability check.
221///
222/// Without it a granted host is an open proxy: a component allowed to reach
223/// `api.github.com` asks that server for a redirect and follows it anywhere.
224/// The ceiling is the whole claim, so this runs on **every** hop, and a refusal
225/// stops the chain rather than being reported after the fact.
226///
227/// `hclient` consults it after its own hop count, only about hops that would
228/// have been followed — so switching it on cannot make a chain longer — and
229/// hands it the hop as it would go out: the resolved target, the possibly
230/// downgraded method, whether credentials are about to be stripped.
231fn redirect_verdict(
232    cfg: &HttpConfig,
233    hop: &hclient::redirect::ProposedRedirect<'_>,
234) -> hclient::redirect::RedirectVerdict {
235    use hclient::redirect::RedirectVerdict;
236
237    let to = hop.to();
238    let host = to.host().unwrap_or("");
239    let scheme = to.scheme_str().unwrap_or("http");
240    let port = to
241        .port_u16()
242        .unwrap_or(if scheme == "https" { 443 } else { 80 });
243
244    let allow_nets: Vec<NetworkRule> = cfg.allow.iter().map(|r| r.net.clone()).collect();
245    let deny_nets: Vec<NetworkRule> = cfg.deny.iter().map(|r| r.net.clone()).collect();
246    let decision = network::decide(
247        cfg.mode,
248        &allow_nets,
249        &deny_nets,
250        &network::NetworkCheck::new(host, port),
251    );
252    // `Allow` and `Ask` produce the same verdict for different reasons, and
253    // the comment on `Ask` is the reason. Merging the arms would delete it.
254    #[allow(clippy::match_same_arms)]
255    match decision {
256        act_policy::Decision::Allow => RedirectVerdict::follow(),
257        // `Ask` gates the request itself, at `send`. This callback is sync and
258        // cannot prompt, so a hop inside an already-approved request is
259        // followed. Per-hop asking is a later phase, and would need the
260        // predicate to be async.
261        act_policy::Decision::Ask => RedirectVerdict::follow(),
262        act_policy::Decision::Deny => {
263            tracing::warn!(%to, "http policy: redirect hop blocked");
264            emit_cap_decision(&CapDecisionRecord::statik_with_reason(
265                act_types::constants::CAP_HTTP,
266                &format!("{host}:{port}"),
267                "",
268                Decision4::Deny,
269                &cfg.mode.to_string(),
270                None,
271                Some("redirect target outside ceiling"),
272            ));
273            // `Refuse`, not `Stop`: stopping would hand the 3xx back as an
274            // ordinary answer, and a guest that never checks the status would
275            // read a blocked redirect as a successful request.
276            //
277            // The reason is `&'static str` so the verdict stays `Copy`, so it
278            // names the rule rather than the target. The target is already in
279            // the audit record emitted just above, which is where an operator
280            // looks for which host it was.
281            RedirectVerdict::Refuse("redirect target outside the component's http ceiling")
282        }
283    }
284}
285
286/// [`redirect_verdict`] as the policy object `hclient` now takes.
287///
288/// alpha.4 replaced the `redirect_predicate` closure with a `RedirectPolicy`
289/// trait. The upside for us is that policies compose as a lattice — a future
290/// hop limit becomes `CeilingRedirectPolicy(..).and(Limit::new(n))` rather
291/// than another branch inside one closure — and that the ceiling check keeps
292/// its own named type in the audit story instead of being an anonymous
293/// closure in a builder chain.
294#[derive(Debug)]
295struct CeilingRedirectPolicy(HttpConfig);
296
297impl hclient::redirect::RedirectPolicy for CeilingRedirectPolicy {
298    fn follow(
299        &self,
300        hop: &hclient::redirect::ProposedRedirect<'_>,
301    ) -> hclient::redirect::RedirectVerdict {
302        redirect_verdict(&self.0, hop)
303    }
304}
305
306/// An HTTP client carrying this component's capability ceiling.
307///
308/// Two enforcement points, and both are inside the client rather than around
309/// it: the resolver refuses addresses a CIDR rule excludes, and the redirect
310/// predicate refuses a hop outside the ceiling. A check placed around a client
311/// is a check a redirect walks past.
312///
313/// Cheap to clone; share freely across tasks.
314#[derive(Clone)]
315pub struct ActHttpClient {
316    client: Arc<hclient::Client>,
317    resolver: PolicyDnsResolver,
318    mode: PolicyMode,
319}
320
321impl ActHttpClient {
322    pub fn new(cfg: HttpConfig) -> anyhow::Result<Self> {
323        let cfg_for_hops = cfg.clone();
324
325        act_store::fetch::install_crypto_provider();
326        let resolver = PolicyDnsResolver::new(&cfg);
327        let mode = cfg.mode;
328        let transport = hclient_native::Native::new(
329            hclient_rt_tokio::Tokio,
330            hclient_tls_rustls::Rustls::with_webpki_roots(),
331            resolver.clone(),
332        )
333        // Keep HTTP/2 multiplexed connections alive through idle periods —
334        // SSE and long-poll streams can go 30+ seconds between events, and
335        // without this a NAT or load-balancer flow timer drops them silently.
336        // `every` and `within`, because neither is useful alone. There is no
337        // `while_idle` knob to set: `hclient` pings on a timer rather than on
338        // silence, which its own docs say is the only thing h2 can offer.
339        .h2_keep_alive(hclient_native::H2KeepAlive::new(
340            std::time::Duration::from_secs(30),
341            std::time::Duration::from_secs(10),
342        ))
343        // Long-lived streams must not be evicted while in use. Ten minutes,
344        // where a one-shot request would be happy with far less.
345        .pool(hclient_native::PoolConfig {
346            idle_timeout: std::time::Duration::from_secs(600),
347            ..Default::default()
348        });
349
350        let client = hclient::Client::builder(transport)
351            .redirect(CeilingRedirectPolicy(cfg_for_hops))
352            .build()
353            .map_err(|e| anyhow::anyhow!("the HTTP backend cannot serve this policy: {e}"))?;
354        Ok(Self {
355            client: Arc::new(client),
356            resolver,
357            mode,
358        })
359    }
360
361    /// Perform an outgoing request.
362    ///
363    /// One method since wasmtime 48, which routes p2 and p3 through the same
364    /// hook. `options` carries the guest's `wasi:http/types.request-options`;
365    /// each field falls back to 600 s, matching what wasmtime itself supplies
366    /// when the guest sets none, so the p2 path keeps the deadline it always
367    /// had and the p3 path gains the one it should have had.
368    pub async fn send(
369        &self,
370        request: http::Request<WasiBody>,
371        options: Option<RequestOptions>,
372    ) -> Result<
373        (
374            http::Response<WasiBody>,
375            Pin<Box<dyn Future<Output = Result<(), HttpError>> + Send>>,
376        ),
377        HttpError,
378    > {
379        const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
380        let deadline = options
381            .and_then(|o| o.connect_timeout)
382            .unwrap_or(DEFAULT_TIMEOUT)
383            + options
384                .and_then(|o| o.first_byte_timeout)
385                .unwrap_or(DEFAULT_TIMEOUT);
386
387        let (method, url, headers, body) = to_request_parts(request)?;
388        // Kept for the audit record below: the URL is consumed by the builder.
389        let host = url
390            .parse::<http::Uri>()
391            .ok()
392            .and_then(|u| u.host().map(str::to_string))
393            .unwrap_or_default();
394        let mut req = self.client.request(method, &url);
395        for (name, value) in &headers {
396            req = req.header(name.as_str(), value.to_str().unwrap_or_default());
397        }
398        let resp = match tokio::time::timeout(deadline, req.body(body).send()).await {
399            Err(_) => return Err(HttpError::ConnectionTimeout),
400            Ok(Err(e)) => {
401                // One record per blocked request, emitted here because this is
402                // where the failure becomes a single event: the resolver sees
403                // two independent streams and cannot tell, from either one,
404                // that the other also came back empty.
405                if matches!(e.kind(), hclient::ErrorKind::Resolve)
406                    && self.resolver.filtered_everything(&host)
407                {
408                    emit_cap_decision(&CapDecisionRecord::statik_with_reason(
409                        act_types::constants::CAP_HTTP,
410                        &host,
411                        "",
412                        Decision4::Deny,
413                        &self.mode.to_string(),
414                        None,
415                        Some("all resolved addresses filtered by CIDR rule"),
416                    ));
417                }
418                return Err(client_error_to_wasi(e));
419            }
420            Ok(Ok(resp)) => resp,
421        };
422        let (parts, body) = resp.into_parts();
423        response_to_wasi(parts, body)
424    }
425}
426
427/// Split an outgoing request into the pieces the client takes.
428#[allow(clippy::type_complexity)]
429fn to_request_parts(
430    request: http::Request<WasiBody>,
431) -> Result<(http::Method, String, http::HeaderMap, hclient::RequestBody), HttpError> {
432    let (parts, body) = request.into_parts();
433    let scheme = parts
434        .uri
435        .scheme_str()
436        .map_or_else(|| "https".into(), str::to_string);
437    let authority = parts
438        .uri
439        .authority()
440        .map(std::string::ToString::to_string)
441        .ok_or(HttpError::HttpRequestUriInvalid)?;
442    let path_and_query = parts
443        .uri
444        .path_and_query()
445        .map_or("/", http::uri::PathAndQuery::as_str);
446    let url = format!("{scheme}://{authority}{path_and_query}");
447
448    // The guest's body goes across as a stream, not a buffer: a component
449    // uploading is not required to have the whole thing in memory, and neither
450    // is this host. `RequestBody::Streaming` takes an `http_body::Body`
451    // directly, so there is no adapter between the two — where the previous
452    // backend
453    // needed the frames rewrapped as a byte stream first.
454    let body = hclient::RequestBody::Streaming(Box::new(WasiRequestBody(body)));
455    Ok((parts.method, url, parts.headers, body))
456}
457
458/// The guest's body, with its error type mapped to `hclient`'s.
459///
460/// A newtype rather than a combinator because the only thing that changes is
461/// the error, and `http_body::Body`'s associated types make that a two-line
462/// impl instead of a chain of adapters.
463struct WasiRequestBody(WasiBody);
464
465impl http_body::Body for WasiRequestBody {
466    type Data = bytes::Bytes;
467    type Error = hclient::Error;
468
469    fn poll_frame(
470        self: Pin<&mut Self>,
471        cx: &mut std::task::Context<'_>,
472    ) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
473        let inner = unsafe { self.map_unchecked_mut(|s| &mut s.0) };
474        inner.poll_frame(cx).map(|opt| {
475            opt.map(|res| {
476                res.map_err(|_| {
477                    hclient::Error::new(
478                        hclient::ErrorKind::Body,
479                        std::io::Error::other("wasi:http body stream error"),
480                    )
481                })
482            })
483        })
484    }
485}
486
487/// Translate a client error to the closest `wasi:http` error.
488///
489/// Matching on `ErrorKind` rather than reading the `source()` chain for
490/// substrings, which is what this did against `reqwest`: "dns", "connect",
491/// "deny cidr" sniffed out of whatever text three layers happened to produce.
492/// A typed kind cannot drift when a dependency rewords an error.
493fn client_error_to_wasi(err: hclient::Error) -> HttpError {
494    use hclient::ErrorKind;
495    match err.kind() {
496        ErrorKind::Timeout(_) => HttpError::ConnectionTimeout,
497        ErrorKind::Resolve => HttpError::DnsError {
498            rcode: Some(err.to_string()),
499            info_code: None,
500        },
501        ErrorKind::Connect => HttpError::ConnectionRefused,
502        // A refused hop and an exhausted hop count arrive the same way. Both
503        // are the host declining to go somewhere, which is what
504        // `HttpRequestDenied` says.
505        ErrorKind::Redirect => HttpError::HttpRequestDenied,
506        ErrorKind::Body => HttpError::HttpRequestBodySize(None),
507        // Named separately from the catch-all on purpose: a decode failure is
508        // a protocol error we understand, and the wildcard is everything we
509        // do not. They agree today; that is not a reason to stop naming it.
510        ErrorKind::Decode => HttpError::HttpProtocolError,
511        _ => HttpError::HttpProtocolError,
512    }
513}
514
515/// Convert a client response to the shape the hook expects: an
516/// `http::Response<WasiBody>` plus a future standing for body completion.
517///
518/// Takes the response **already split into parts and body** rather than the
519/// client's wrapper. Two reasons, and the second is the one that matters: the
520/// wrapper carries nothing this needs, and a consumer cannot construct one —
521/// `Response::new` is crate-private — so a test could not reach this at all if
522/// it took the wrapper. Split, it is a plain function over `http` types with
523/// no network anywhere near it.
524/// What the `wasi:http` hook expects back: the response, and a future standing
525/// for the body's completion.
526type HookResponse = (
527    http::Response<WasiBody>,
528    Pin<Box<dyn Future<Output = Result<(), HttpError>> + Send>>,
529);
530
531fn response_to_wasi<B>(parts: http::response::Parts, body: B) -> Result<HookResponse, HttpError>
532where
533    B: http_body::Body<Data = bytes::Bytes, Error = hclient::Error> + Send + 'static,
534{
535    let mut headers = parts.headers.clone();
536    // Hop-by-hop framing the guest must not see: the body it receives is
537    // already de-chunked and decompressed, so a `transfer-encoding` or a
538    // `content-length` describing the wire form would describe something else.
539    headers.remove(http::header::TRANSFER_ENCODING);
540    headers.remove(http::header::CONTENT_LENGTH);
541
542    let body: WasiBody = BodyExt::boxed_unsync(BodyExt::map_err(body, client_error_to_wasi));
543
544    let mut builder = http::Response::builder().status(parts.status);
545    if let Some(hdrs) = builder.headers_mut() {
546        hdrs.extend(headers);
547    }
548    let resp = builder
549        .body(body)
550        .map_err(|_| HttpError::HttpProtocolError)?;
551    let io: Pin<Box<dyn Future<Output = Result<(), HttpError>> + Send>> =
552        Box::pin(async { Ok(()) });
553    Ok((resp, io))
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use act_policy::grant::HttpConfig;
560    use http::Method;
561    use http_body_util::combinators::UnsyncBoxBody;
562    use http_body_util::{BodyExt, Empty};
563    use std::sync::Mutex;
564
565    #[tokio::test(flavor = "current_thread")]
566    async fn converts_response_status_headers_body() {
567        // No network and no client: the conversion takes `http` parts and a
568        // body, so a test can hand it either. It could not take the client's
569        // `Response` — `Response::new` is crate-private there, which is why
570        // this function was reshaped rather than wrapped.
571        let http_resp = http::Response::builder()
572            .status(200)
573            .header("x-echo", "hi")
574            .body(
575                http_body_util::Full::new(bytes::Bytes::from_static(b"hello"))
576                    .map_err(|_: std::convert::Infallible| unreachable!())
577                    .boxed_unsync(),
578            )
579            .unwrap();
580        let (parts, body) = http_resp.into_parts();
581        let body = BodyExt::map_err(body, |_| {
582            hclient::Error::new(hclient::ErrorKind::Body, std::io::Error::other("unused"))
583        });
584
585        let (incoming, _io) = response_to_wasi(parts, body).expect("conversion ok");
586
587        assert_eq!(incoming.status(), hyper::StatusCode::OK);
588        assert_eq!(
589            incoming
590                .headers()
591                .get("x-echo")
592                .and_then(|v| v.to_str().ok()),
593            Some("hi")
594        );
595        let body_bytes = http_body_util::BodyExt::collect(incoming.into_body())
596            .await
597            .expect("body collect")
598            .to_bytes();
599        assert_eq!(&body_bytes[..], b"hello");
600    }
601
602    #[test]
603    fn builds_default_client() {
604        let cfg = HttpConfig::default();
605        let client = ActHttpClient::new(cfg);
606        assert!(client.is_ok(), "{:?}", client.err());
607    }
608
609    #[test]
610    fn builds_client_with_keepalive_defaults() {
611        // Smoke: the builder chain for keep-alive / pool settings accepts the
612        // defaults we want to ship. Can't observe ping behaviour in a unit
613        // test without a live peer, but a regression in the builder call
614        // chain (wrong arg types, renamed methods) would surface here.
615        let cfg = HttpConfig::default();
616        let client = ActHttpClient::new(cfg);
617        assert!(client.is_ok(), "{:?}", client.err());
618    }
619
620    #[test]
621    fn converts_simple_get_request() {
622        let body: UnsyncBoxBody<bytes::Bytes, _> = Empty::<bytes::Bytes>::new()
623            .map_err(|_| unreachable!())
624            .boxed_unsync();
625        let hyper_req = hyper::Request::builder()
626            .method(Method::GET)
627            .uri("https://example.com/foo?bar=baz")
628            .header("x-custom", "hello")
629            .body(body)
630            .expect("hyper request builds");
631
632        let (method, url, headers, _body) =
633            to_request_parts(hyper_req).expect("conversion succeeds");
634
635        assert_eq!(method, Method::GET);
636        assert_eq!(url, "https://example.com/foo?bar=baz");
637        assert_eq!(
638            headers.get("x-custom").and_then(|v| v.to_str().ok()),
639            Some("hello")
640        );
641    }
642
643    #[test]
644    fn converts_post_request_with_body_and_port() {
645        let body_bytes = bytes::Bytes::from_static(b"payload");
646        let body: WasiBody = http_body_util::Full::new(body_bytes)
647            .map_err(|_| unreachable!())
648            .boxed_unsync();
649        let hyper_req = hyper::Request::builder()
650            .method(Method::POST)
651            .uri("http://api.example.com:8080/v1/create")
652            .header("content-type", "application/json")
653            .body(body)
654            .expect("hyper request builds");
655
656        let (method, url, headers, _body) =
657            to_request_parts(hyper_req).expect("conversion succeeds");
658
659        assert_eq!(method, Method::POST);
660        assert_eq!(url, "http://api.example.com:8080/v1/create");
661        assert_eq!(
662            headers.get("content-type").and_then(|v| v.to_str().ok()),
663            Some("application/json")
664        );
665    }
666
667    #[tokio::test(flavor = "current_thread")]
668    async fn send_fetches_example_dot_com() {
669        // Integration-style test: requires network.
670        let body: WasiBody = Empty::<bytes::Bytes>::new()
671            .map_err(|_| unreachable!())
672            .boxed_unsync();
673        let hyper_req = hyper::Request::builder()
674            .method(Method::GET)
675            .uri("https://example.com/")
676            .body(body)
677            .unwrap();
678
679        let cfg = HttpConfig {
680            mode: act_policy::grant::PolicyMode::Open,
681            ..Default::default()
682        };
683        let client = ActHttpClient::new(cfg).expect("client builds");
684        let options = RequestOptions {
685            connect_timeout: Some(std::time::Duration::from_secs(10)),
686            first_byte_timeout: Some(std::time::Duration::from_secs(10)),
687            between_bytes_timeout: Some(std::time::Duration::from_secs(10)),
688        };
689        let (incoming, _io) = client
690            .send(hyper_req, Some(options))
691            .await
692            .expect("send succeeds");
693        assert_eq!(
694            incoming.status().as_u16(),
695            200,
696            "example.com should return 200"
697        );
698    }
699
700    /// The error mapping, without a network round trip.
701    ///
702    /// It used to make a real request to an unroutable address, because a
703    /// `reqwest::Error` could not be constructed — which made a unit test of a
704    /// pure mapping depend on how the machine's network refuses things, and it
705    /// was one of the tests that failed behind an intercepting proxy. A typed
706    /// `ErrorKind` can simply be built.
707    #[test]
708    fn maps_each_error_kind_to_its_wasi_error() {
709        use hclient::ErrorKind;
710        let io = || std::io::Error::other("under test");
711
712        for (kind, expected) in [
713            (ErrorKind::Connect, HttpError::ConnectionRefused),
714            (ErrorKind::Redirect, HttpError::HttpRequestDenied),
715        ] {
716            let named = format!("{kind:?}");
717            let mapped = client_error_to_wasi(hclient::Error::new(kind, io()));
718            assert_eq!(
719                std::mem::discriminant(&mapped),
720                std::mem::discriminant(&expected),
721                "{named} mapped to {mapped:?}"
722            );
723        }
724
725        // Resolve carries the message through, so it is checked by shape
726        // rather than by discriminant alone.
727        let mapped = client_error_to_wasi(hclient::Error::new(ErrorKind::Resolve, io()));
728        assert!(
729            matches!(mapped, HttpError::DnsError { rcode: Some(_), .. }),
730            "a resolve failure must reach the guest as a DNS error naming it, got {mapped:?}"
731        );
732
733        // A refused hop and an exhausted hop count arrive as the same kind;
734        // both are the host declining to go somewhere.
735        assert!(matches!(
736            client_error_to_wasi(hclient::Error::new(ErrorKind::Redirect, io())),
737            HttpError::HttpRequestDenied
738        ));
739    }
740
741    #[tokio::test(flavor = "current_thread")]
742    async fn redirect_policy_blocks_cross_host_hop() {
743        use act_policy::Decision;
744        use act_policy::grant::PolicyMode;
745        use act_policy::net::{NetworkCheck, NetworkRule, decide};
746
747        let allow = vec![NetworkRule {
748            host: Some("primary.example".into()),
749            ..Default::default()
750        }];
751        let deny: Vec<NetworkRule> = vec![];
752
753        let blocked = decide(
754            PolicyMode::Allowlist,
755            &allow,
756            &deny,
757            &NetworkCheck::new("other.example", 443),
758        );
759        assert_eq!(blocked, Decision::Deny);
760
761        let allowed = decide(
762            PolicyMode::Allowlist,
763            &allow,
764            &deny,
765            &NetworkCheck::new("primary.example", 443),
766        );
767        assert_eq!(allowed, Decision::Allow);
768    }
769
770    #[tokio::test(flavor = "current_thread")]
771    async fn dns_resolver_filters_denied_cidr() {
772        use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
773        use act_policy::net::NetworkRule;
774
775        let cfg = HttpConfig {
776            mode: PolicyMode::Allowlist,
777            allow: vec![HttpRule {
778                net: NetworkRule {
779                    host: Some("localhost".into()),
780                    ..Default::default()
781                },
782                ..Default::default()
783            }],
784            // Deny any resolved IP in 127/8.
785            deny: vec![HttpRule {
786                net: NetworkRule {
787                    cidr: Some("127.0.0.0/8".into()),
788                    ..Default::default()
789                },
790                ..Default::default()
791            }],
792        };
793        let client = ActHttpClient::new(cfg).expect("client builds");
794        let body: WasiBody = Empty::<bytes::Bytes>::new()
795            .map_err(|_| unreachable!())
796            .boxed_unsync();
797        let hyper_req = hyper::Request::builder()
798            .method(Method::GET)
799            .uri("http://localhost/")
800            .body(body)
801            .unwrap();
802        let options = RequestOptions {
803            connect_timeout: Some(std::time::Duration::from_secs(5)),
804            first_byte_timeout: Some(std::time::Duration::from_secs(5)),
805            between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
806        };
807        let err = match client.send(hyper_req, Some(options)).await {
808            Ok(_) => panic!("localhost resolves into denied 127/8, should fail"),
809            Err(e) => e,
810        };
811        // DnsError because the resolver returned zero non-denied addresses.
812        // (Or ConnectionRefused if the test harness has nothing listening on 127.0.0.1:80,
813        //  in which case the DNS filter wasn't applied — test is weak but valid positive-deny check.)
814        assert!(
815            matches!(err, HttpError::DnsError { .. })
816                || matches!(err, HttpError::ConnectionRefused),
817            "expected DnsError or ConnectionRefused, got {err:?}"
818        );
819    }
820
821    #[tokio::test(flavor = "current_thread")]
822    // Its sibling below already carries this marker for the same reason: the
823    // assertion needs a resolver that actually answers. Without DNS the call
824    // still fails, but with `DnsError` for the opposite reason, and the
825    // `filtered_everything` assertion above is what turns that from a silent
826    // false pass into a loud one. Marked rather than rewritten against a stub
827    // because what it proves — the real resolver's addresses meeting the real
828    // policy — is exactly the part a stub would remove.
829    #[ignore = "network: resolves example.com through the system resolver"]
830    async fn dns_resolver_requires_allow_cidr_match_for_hostnames() {
831        // mode=Allowlist with only an allow-CIDR rule. Any URI whose
832        // resolved IPs land outside that CIDR must fail at DNS level.
833        use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
834        use act_policy::net::NetworkRule;
835
836        let cfg = HttpConfig {
837            mode: PolicyMode::Allowlist,
838            // Only permit internal RFC1918 space — example.com is public.
839            allow: vec![HttpRule {
840                net: NetworkRule {
841                    cidr: Some("10.0.0.0/8".into()),
842                    ..Default::default()
843                },
844                ..Default::default()
845            }],
846            deny: vec![],
847        };
848        let client = ActHttpClient::new(cfg).expect("client builds");
849        let body: WasiBody = Empty::<bytes::Bytes>::new()
850            .map_err(|_| unreachable!())
851            .boxed_unsync();
852        let hyper_req = hyper::Request::builder()
853            .method(Method::GET)
854            .uri("https://example.com/")
855            .body(body)
856            .unwrap();
857        let options = RequestOptions {
858            connect_timeout: Some(std::time::Duration::from_secs(5)),
859            first_byte_timeout: Some(std::time::Duration::from_secs(5)),
860            between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
861        };
862        let err = match client.send(hyper_req, Some(options)).await {
863            Ok(_) => panic!("example.com IPs not in 10/8, must fail at DNS"),
864            Err(e) => e,
865        };
866        assert!(
867            matches!(err, HttpError::DnsError { .. }),
868            "expected DnsError, got {err:?}"
869        );
870        // **`DnsError` alone does not prove the policy did anything.** A
871        // sandbox that cannot reach DNS produces the identical error for the
872        // opposite reason — the name never resolved — so asserting only the
873        // variant makes this test pass most loudly when it is testing
874        // nothing. `filtered_everything` is the state that separates the two:
875        // it is true only when the resolver offered addresses and policy
876        // refused every one.
877        assert!(
878            client.resolver.filtered_everything("example.com"),
879            "the DnsError must come from policy refusing every address, not \
880             from a resolver that never answered — this test needs DNS"
881        );
882    }
883
884    #[tokio::test(flavor = "current_thread")]
885    #[ignore = "network: makes a real HTTPS request to example.com"]
886    async fn dns_resolver_host_match_bypasses_allow_cidr() {
887        // mode=Allowlist with BOTH a host-allow AND an allow-CIDR. A
888        // request to the allowed host should succeed even if its IPs
889        // don't fall in the CIDR — the host match approves all IPs.
890        use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
891        use act_policy::net::NetworkRule;
892
893        let cfg = HttpConfig {
894            mode: PolicyMode::Allowlist,
895            allow: vec![
896                HttpRule {
897                    net: NetworkRule {
898                        host: Some("example.com".into()),
899                        ..Default::default()
900                    },
901                    ..Default::default()
902                },
903                HttpRule {
904                    net: NetworkRule {
905                        cidr: Some("10.0.0.0/8".into()),
906                        ..Default::default()
907                    },
908                    ..Default::default()
909                },
910            ],
911            deny: vec![],
912        };
913        let client = ActHttpClient::new(cfg).expect("client builds");
914        let body: WasiBody = Empty::<bytes::Bytes>::new()
915            .map_err(|_| unreachable!())
916            .boxed_unsync();
917        let hyper_req = hyper::Request::builder()
918            .method(Method::GET)
919            .uri("https://example.com/")
920            .body(body)
921            .unwrap();
922        let options = RequestOptions {
923            connect_timeout: Some(std::time::Duration::from_secs(10)),
924            first_byte_timeout: Some(std::time::Duration::from_secs(10)),
925            between_bytes_timeout: Some(std::time::Duration::from_secs(10)),
926        };
927        let (incoming, _io) = client
928            .send(hyper_req, Some(options))
929            .await
930            .expect("example.com allowed via host rule");
931        assert_eq!(incoming.status().as_u16(), 200);
932    }
933
934    /// A capturing `AuditWriter`, local to this module — `crate::audit`'s own
935    /// `TestWriter` (in `layer::tests`) isn't exported, and the point here
936    /// is to observe the real `AuditLayer` render a real emission, not to
937    /// re-test the layer itself (that's the audit module's job).
938    #[derive(Clone, Default)]
939    struct CapturingWriter(Arc<Mutex<Vec<String>>>);
940    impl crate::audit::layer::AuditWriter for CapturingWriter {
941        fn write_line(&self, line: &str) {
942            self.0.lock().unwrap().push(line.to_string());
943        }
944    }
945
946    /// `build_redirect_policy`'s `Decision::Deny` arm used to only
947    /// `tracing::warn!` — a component granted its origin host but redirected
948    /// off it was blocked with nothing in the audit trail. Drives a real
949    /// redirect through a local raw-socket server (no external network) so
950    /// this exercises the actual `redirect::Policy` closure reqwest invokes,
951    /// not just `net::decide` in isolation (that's what
952    /// `redirect_policy_blocks_cross_host_hop` above already covers, and
953    /// continues to).
954    ///
955    /// Builds a bare `reqwest::Client` with `build_redirect_policy` directly,
956    /// rather than going through `ActHttpClient::send`: `to_reqwest`
957    /// wraps every outgoing body — even an empty GET's — via
958    /// `reqwest::Body::wrap_stream`, and reqwest silently declines to follow
959    /// a redirect at all when the original body isn't provably re-sendable,
960    /// so `send` never reaches the redirect policy for *any* outcome
961    /// (allow or deny). That's a real, separate gap in the WASI conversion
962    /// layer — outside this task's scope (it would affect the redirect
963    /// *decision* on the allow side too, not just this audit gap) — noted in
964    /// the report rather than fixed here. A plain `.get()` has no body at
965    /// all, so it sidesteps that gap and exercises the redirect policy the
966    /// way a normal reqwest caller would.
967    #[tokio::test(flavor = "current_thread")]
968    async fn redirect_hop_denial_is_audited() {
969        use tokio::io::{AsyncReadExt, AsyncWriteExt};
970        use tracing_subscriber::prelude::*;
971
972        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
973            .await
974            .expect("bind loopback");
975        let addr = listener.local_addr().unwrap();
976        let server = tokio::spawn(async move {
977            let (mut stream, _) = listener.accept().await.expect("accept");
978            let mut buf = [0u8; 1024];
979            let _ = stream.read(&mut buf).await; // drain the request line/headers
980            let resp = b"HTTP/1.1 302 Found\r\n\
981                          Location: http://blocked.example/\r\n\
982                          Content-Length: 0\r\n\
983                          Connection: close\r\n\r\n";
984            let _ = stream.write_all(resp).await;
985            let _ = stream.shutdown().await;
986        });
987
988        // Allows the origin (127.0.0.1, where the 302 comes from) but not
989        // the redirect target (blocked.example) — the redirect hop itself
990        // must be what gets denied, not the initial request.
991        let cfg = HttpConfig {
992            mode: PolicyMode::Allowlist,
993            allow: vec![act_policy::grant::HttpRule {
994                net: NetworkRule {
995                    host: Some("127.0.0.1".into()),
996                    ..Default::default()
997                },
998                ..Default::default()
999            }],
1000            deny: vec![],
1001        };
1002        act_store::fetch::install_crypto_provider();
1003        let resolver = PolicyDnsResolver::new(&cfg);
1004        let transport = hclient_native::Native::new(
1005            hclient_rt_tokio::Tokio,
1006            hclient_tls_rustls::Rustls::with_webpki_roots(),
1007            resolver.clone(),
1008        );
1009        let client = hclient::Client::builder(transport)
1010            .redirect(CeilingRedirectPolicy(cfg))
1011            .build()
1012            .expect("client builds");
1013
1014        let writer = CapturingWriter::default();
1015        let sink = writer.0.clone();
1016        let sub = tracing_subscriber::registry().with(crate::audit::AuditLayer::new(
1017            writer,
1018            crate::audit::Detail::Rollup,
1019        ));
1020        let _guard = tracing::subscriber::set_default(sub);
1021
1022        let result = client.get(format!("http://{addr}/")).send().await;
1023
1024        drop(_guard);
1025        server.await.expect("server task");
1026
1027        let err = result.expect_err("redirect target denied, the request must fail");
1028        assert!(
1029            matches!(err.kind(), hclient::ErrorKind::Redirect),
1030            "expected a redirect-class error, got {err:?}"
1031        );
1032
1033        let lines = sink.lock().unwrap().clone();
1034        let deny_line = lines
1035            .iter()
1036            .find(|l| l.contains("blocked.example"))
1037            .unwrap_or_else(|| panic!("no redirect-deny audit line, got {lines:?}"));
1038        assert!(deny_line.contains("wasi:http"), "got {deny_line}");
1039        assert!(
1040            deny_line.contains("redirect target outside ceiling"),
1041            "reason must distinguish this from an ordinary ceiling denial, got {deny_line}"
1042        );
1043    }
1044
1045    /// `PolicyDnsResolver::resolve`'s `filtered.is_empty()` arm used to just
1046    /// return an `Err` — a component granted a host whose every resolved
1047    /// address then got dropped by a deny-CIDR was blocked with nothing in
1048    /// the audit trail, indistinguishable from a plain DNS failure. Denies
1049    /// BOTH loopback families (`127.0.0.0/8` and `::1/128`) so `filtered` is
1050    /// empty deterministically regardless of whether this host's resolver
1051    /// returns v4, v6, or both for "localhost" — the flakiness the
1052    /// neighbouring `dns_resolver_filters_denied_cidr` test above already
1053    /// warns about in its own comment. The allow rule is host-anchored
1054    /// (`host = "localhost"`, not a CIDR), so this is exactly the scenario
1055    /// the review called out: the host itself was granted, but its resolved
1056    /// address got filtered anyway.
1057    #[tokio::test(flavor = "current_thread")]
1058    async fn dns_cidr_filtered_resolution_is_audited() {
1059        use act_policy::grant::{HttpConfig as PolicyHttpConfig, HttpRule};
1060        use act_policy::net::NetworkRule as PolicyNetworkRule;
1061        use tracing_subscriber::prelude::*;
1062
1063        let cfg = PolicyHttpConfig {
1064            mode: PolicyMode::Allowlist,
1065            allow: vec![HttpRule {
1066                net: PolicyNetworkRule {
1067                    host: Some("localhost".into()),
1068                    ..Default::default()
1069                },
1070                ..Default::default()
1071            }],
1072            deny: vec![
1073                HttpRule {
1074                    net: PolicyNetworkRule {
1075                        cidr: Some("127.0.0.0/8".into()),
1076                        ..Default::default()
1077                    },
1078                    ..Default::default()
1079                },
1080                HttpRule {
1081                    net: PolicyNetworkRule {
1082                        cidr: Some("::1/128".into()),
1083                        ..Default::default()
1084                    },
1085                    ..Default::default()
1086                },
1087            ],
1088        };
1089        let client = ActHttpClient::new(cfg).expect("client builds");
1090        let body: WasiBody = Empty::<bytes::Bytes>::new()
1091            .map_err(|_| unreachable!())
1092            .boxed_unsync();
1093        let hyper_req = hyper::Request::builder()
1094            .method(Method::GET)
1095            .uri("http://localhost/")
1096            .body(body)
1097            .unwrap();
1098        let options = RequestOptions {
1099            connect_timeout: Some(std::time::Duration::from_secs(5)),
1100            first_byte_timeout: Some(std::time::Duration::from_secs(5)),
1101            between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
1102        };
1103
1104        let writer = CapturingWriter::default();
1105        let sink = writer.0.clone();
1106        let sub = tracing_subscriber::registry().with(crate::audit::AuditLayer::new(
1107            writer,
1108            crate::audit::Detail::Rollup,
1109        ));
1110        let _guard = tracing::subscriber::set_default(sub);
1111
1112        let err = match client.send(hyper_req, Some(options)).await {
1113            Ok(_) => panic!("both loopback families are denied, must fail at DNS"),
1114            Err(e) => e,
1115        };
1116
1117        drop(_guard);
1118
1119        assert!(
1120            matches!(err, HttpError::DnsError { .. }),
1121            "expected DnsError, got {err:?}"
1122        );
1123
1124        let lines = sink.lock().unwrap().clone();
1125        let deny_line = lines
1126            .iter()
1127            .find(|l| l.contains("localhost"))
1128            .unwrap_or_else(|| panic!("no dns-filtered deny audit line, got {lines:?}"));
1129        assert!(deny_line.contains("wasi:http"), "got {deny_line}");
1130        assert!(
1131            deny_line.contains("all resolved addresses filtered by CIDR rule"),
1132            "reason must distinguish this from an ordinary ceiling denial, got {deny_line}"
1133        );
1134    }
1135}