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