Skip to main content

praxis_policy/
http_hyper.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Praxis Contributors
3
4// A default `HttpTransport` on hyper, for hosts that inject none.
5//
6// PPE performs no HTTP itself: a host installs a transport and plugins
7// borrow it, so the process has one connection pool, one TLS trust
8// store, and one egress path. A proxy embedding PPE injects its own. But
9// a CLI, a test harness, or anyone using PPE standalone has no client to
10// lend, and "PPE cannot fetch a JWKS unless you write a transport first"
11// is a bad first experience. This is the batteries.
12//
13// hyper rather than a client library, because reqwest *is* hyper plus a
14// convenience layer and that layer is what we are removing. The stack
15// below was already in the tree underneath reqwest, so taking it
16// directly adds nothing and drops the ~28 crates reachable only through
17// reqwest — most of them the ICU block that `url` pulls for IDNA-correct
18// parsing, which `http::Uri` does not need and JWKS endpoints do not use.
19//
20// What we give up with the convenience layer, and why each is fine or
21// better here:
22//
23//   * Redirect following — a JWKS URL that redirects is a URL that can
24//     be redirected to an attacker's host. Not following is the safer
25//     default and reqwest had to be told to stop.
26//   * Automatic decompression — the documents are small.
27//   * A JSON helper — callers parse with serde anyway.
28//   * Cookie handling — irrelevant to a token endpoint.
29//
30// Not auto-wired. `install_default_http_transport` is an explicit call,
31// so a stray feature unification cannot silently give a host a second
32// HTTP stack it did not ask for.
33
34use std::error::Error;
35use std::net::{IpAddr, SocketAddr};
36use std::pin::Pin;
37use std::sync::OnceLock;
38use std::task::{Context, Poll};
39use std::time::Duration;
40
41use async_trait::async_trait;
42use bytes::Bytes;
43use http_body_util::{BodyExt as _, Full, LengthLimitError, Limited};
44use hyper_rustls::HttpsConnector;
45use hyper_util::client::legacy::Client;
46use hyper_util::client::legacy::connect::HttpConnector;
47use hyper_util::client::legacy::connect::dns::{GaiResolver, Name};
48use hyper_util::rt::{TokioExecutor, TokioTimer};
49use praxis_policy_core::http::{HttpRequest, HttpResponse, HttpTransport, HttpTransportError};
50use praxis_policy_core::http_addr::private_address_reason;
51use tower_service::Service;
52
53/// Marker in resolver errors so [`classify`] can turn a filtered DNS
54/// result into [`HttpTransportError::Rejected`] rather than `Connect`.
55const EGRESS_DENIED_PREFIX: &str = "ppe-egress-denied:";
56
57/// The pooling client, shared by every request this transport serves.
58type HyperClient = Client<HttpsConnector<HttpConnector<EgressResolver>>, Full<Bytes>>;
59
60/// A `HttpTransport` backed by hyper with rustls.
61///
62/// One instance serves the whole process. The pool, the TLS
63/// configuration, and every keepalive connection live inside it, which
64/// is the point: a second instance would be a second pool against the
65/// same `IdP`.
66///
67/// # Lazy by construction
68///
69/// The client is built on first use, not in the constructor. A host may
70/// build this during `PolicyEngine::initialize()`, and some hosts drive
71/// that on a short-lived runtime that is dropped before the first
72/// request arrives — Praxis does exactly this, because its filter
73/// factory signature is sync. Connections created eagerly would be bound
74/// to that dead reactor. Building on first use means the pool lands on
75/// whichever runtime actually serves traffic.
76#[derive(Debug)]
77pub struct HyperTransport {
78    client: OnceLock<HyperClient>,
79    connect_timeout: Duration,
80    pool_idle_timeout: Option<Duration>,
81    pool_max_idle_per_host: usize,
82    tcp_keepalive: Option<Duration>,
83    http2: bool,
84    /// When true, skip [`http_addr`](praxis_policy_core::http_addr). For a
85    /// local `IdP` or a test harness on loopback. Default is false.
86    allow_private_destinations: bool,
87}
88
89impl Default for HyperTransport {
90    fn default() -> Self {
91        Self {
92            client: OnceLock::new(),
93            // A fixed default rather than "whatever the first request
94            // asked for": the connector is built once and shared, so
95            // deferring to a request would make the pool-wide bound
96            // depend on which call happened to arrive first.
97            connect_timeout: praxis_policy_core::http::DEFAULT_CONNECT_TIMEOUT,
98            // hyper-util's own default, but only if a timer is wired —
99            // see `client()`.
100            pool_idle_timeout: Some(Duration::from_secs(90)),
101            // Matches what reqwest resolved to. Idle eviction is what
102            // actually bounds the pool; this is the second line.
103            pool_max_idle_per_host: usize::MAX,
104            // Off in both reqwest and hyper-util by default. On here,
105            // deliberately — see the field docs on `with_tcp_keepalive`.
106            tcp_keepalive: Some(Duration::from_secs(60)),
107            // ALPN offers h2 and falls back to http/1.1, so this costs
108            // nothing against a peer that does not speak it.
109            http2: true,
110            allow_private_destinations: false,
111        }
112    }
113}
114
115impl HyperTransport {
116    /// A transport that builds its pool on first use.
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// How long an idle pooled connection is kept before eviction.
122    ///
123    /// `None` keeps idle connections indefinitely, which is rarely what
124    /// anyone wants against an `IdP` behind a load balancer.
125    #[must_use]
126    pub fn with_pool_idle_timeout(mut self, timeout: Option<Duration>) -> Self {
127        self.pool_idle_timeout = timeout;
128        self
129    }
130
131    /// Cap idle connections retained per host.
132    ///
133    /// Defaults to unlimited, matching reqwest, because idle eviction is
134    /// the primary bound. Set this when an embedder needs a hard ceiling
135    /// on sockets rather than a time-based one.
136    #[must_use]
137    pub fn with_pool_max_idle_per_host(mut self, max: usize) -> Self {
138        self.pool_max_idle_per_host = max;
139        self
140    }
141
142    /// TCP keepalive probe interval on pooled connections.
143    ///
144    /// On by default at 60s, which differs from both reqwest and
145    /// hyper-util. The reason is the retry contract rather than
146    /// throughput: a NAT or load balancer that silently drops an idle
147    /// connection leaves the pool holding a socket it believes is good.
148    /// The next request writes into it and gets a reset, which surfaces
149    /// as [`HttpTransportError::Io`] — and `Io` reads as *may have
150    /// reached the peer*, so a token mint will refuse to retry a request
151    /// that in truth never left the process. Keepalive shrinks that
152    /// window by detecting the dead peer before a request is handed the
153    /// socket.
154    ///
155    /// `None` disables it.
156    #[must_use]
157    pub fn with_tcp_keepalive(mut self, interval: Option<Duration>) -> Self {
158        self.tcp_keepalive = interval;
159        self
160    }
161
162    /// The connect bound applied to every request.
163    ///
164    /// Defaults to [`DEFAULT_CONNECT_TIMEOUT`]. It is fixed at
165    /// construction because the connector is built once and shared; a
166    /// per-request connect bound would mean a per-request connector, and
167    /// therefore a per-request pool, which is the whole thing this
168    /// transport exists to avoid.
169    ///
170    /// So this transport does not honor [`HttpRequest::connect_timeout`],
171    /// which is documented as a hint for that reason. A request asking
172    /// for a tighter bound is still bounded by its overall `timeout`.
173    ///
174    /// [`DEFAULT_CONNECT_TIMEOUT`]: praxis_policy_core::http::DEFAULT_CONNECT_TIMEOUT
175    /// [`HttpRequest::connect_timeout`]: praxis_policy_core::http::HttpRequest::connect_timeout
176    #[must_use]
177    pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
178        self.connect_timeout = timeout;
179        self
180    }
181
182    /// Offer HTTP/2 during TLS negotiation.
183    ///
184    /// On by default. ALPN advertises `h2` and `http/1.1`, so a peer that
185    /// does not speak HTTP/2 simply negotiates HTTP/1.1 and nothing
186    /// changes. Plaintext connections stay HTTP/1.1 regardless, since
187    /// there is no ALPN to negotiate over.
188    ///
189    /// It is worth having: a delegating deployment mints a token per
190    /// request, and HTTP/2 carries those concurrently over one connection
191    /// instead of one connection each. That removes both the per-request
192    /// connection churn and the head-of-line blocking an HTTP/1.1 pool
193    /// has, where a slow response holds its connection against every
194    /// other request waiting for one.
195    ///
196    /// Set `false` to force HTTP/1.1. The reason to reach for it is an
197    /// `IdP` whose HTTP/2 implementation misbehaves — rare, but the
198    /// failure would otherwise be a puzzling one to diagnose, so the
199    /// escape hatch is worth its two lines.
200    #[must_use]
201    pub fn with_http2(mut self, enabled: bool) -> Self {
202        self.http2 = enabled;
203        self
204    }
205
206    /// Permit destinations [`http_addr`](praxis_policy_core::http_addr)
207    /// would refuse: loopback, RFC 1918, link-local (including cloud
208    /// metadata), CGNAT.
209    ///
210    /// Default is to refuse them. Reach for this when the `IdP` or
211    /// in-cluster Vault is on a private address, or in tests that bind a
212    /// mock on `127.0.0.1`.
213    /// A host that injects its own transport never sees this knob —
214    /// that transport's egress policy is the one that counts.
215    #[must_use]
216    pub fn with_allow_private_destinations(mut self) -> Self {
217        self.allow_private_destinations = true;
218        self
219    }
220
221    /// Return the shared client, building it on first use.
222    fn client(&self) -> Result<&HyperClient, HttpTransportError> {
223        if let Some(client) = self.client.get() {
224            return Ok(client);
225        }
226        let built = Self::build_client(self)?;
227        // Another caller may win the race; use whichever equivalent client was stored.
228        let _ = self.client.set(built);
229        self.client
230            .get()
231            .ok_or_else(|| HttpTransportError::Connect("HTTP client init raced".to_owned()))
232    }
233
234    /// Build a pooling client.
235    fn build_client(&self) -> Result<HyperClient, HttpTransportError> {
236        let mut http = HttpConnector::new_with_resolver(EgressResolver {
237            allow_private: self.allow_private_destinations,
238        });
239        // The HTTPS connector wraps this one, so it must accept the
240        // `https` scheme rather than rejecting it as non-HTTP.
241        http.enforce_http(false);
242        http.set_connect_timeout(Some(self.connect_timeout));
243        // hyper-util defaults this to false; reqwest sets it true.
244        // Leaving Nagle on would let a small request body — a token
245        // exchange form is a couple of hundred bytes — sit waiting to
246        // coalesce with data that never comes, adding tens of
247        // milliseconds to a call on the request path.
248        http.set_nodelay(true);
249        http.set_keepalive(self.tcp_keepalive);
250
251        // Select `ring` locally: a host may load both supported providers,
252        // leaving rustls without a process default. Do not install one here.
253        // Keep the webpki roots used by the previous connector configuration.
254        let mut roots = rustls::RootCertStore::empty();
255        roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
256        let tls_config = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
257            rustls::crypto::ring::default_provider(),
258        ))
259        .with_safe_default_protocol_versions()
260        .map_err(|e| HttpTransportError::Connect(format!("rustls client configuration: {e}")))?
261        .with_root_certificates(roots)
262        .with_no_client_auth();
263
264        let tls = hyper_rustls::HttpsConnectorBuilder::new()
265            .with_tls_config(tls_config)
266            // `https_or_http`, not `https_only`: `identity-jwt`
267            // supports an explicit `insecure_http: true` for local
268            // development, and it already refuses plaintext by
269            // default one layer up. Enforcing here as well would
270            // make that setting silently ineffective.
271            .https_or_http();
272
273        // `enable_all_versions` advertises ALPN `h2, http/1.1`;
274        // `enable_http1` advertises none. Either way a peer that
275        // cannot do HTTP/2 gets HTTP/1.1, and plaintext gets it
276        // regardless since there is no ALPN without TLS.
277        let https = if self.http2 {
278            tls.enable_all_versions().wrap_connector(http)
279        } else {
280            tls.enable_http1().wrap_connector(http)
281        };
282
283        let client = Client::builder(TokioExecutor::new())
284            // Without a timer, `pool_idle_timeout` silently does
285            // nothing and idle connections are never evicted. With
286            // `pool_max_idle_per_host` defaulting to unlimited, that
287            // is unbounded socket growth against a busy `IdP`, which
288            // is a slow leak rather than an error anyone would see.
289            .pool_timer(TokioTimer::new())
290            .pool_idle_timeout(self.pool_idle_timeout)
291            .pool_max_idle_per_host(self.pool_max_idle_per_host)
292            .build(https);
293
294        Ok(client)
295    }
296}
297
298/// Map a hyper client error onto the transport vocabulary.
299///
300/// The distinction that matters is whether the peer could have acted on
301/// the request: a caller uses it to decide between retrying and
302/// recording an indeterminate outcome. `is_connect()` is the only signal
303/// hyper gives us that nothing was sent, so anything else is `Io`, which
304/// reads as "may have reached the peer" and the safe direction to err
305/// in. Guessing `Connect` for an ambiguous failure would license a retry
306/// that mints a second token.
307///
308/// hyper-util's Display is `client error ({kind})` and does not include
309/// the source chain, so matching that string never sees
310/// [`EGRESS_DENIED_PREFIX`]. Walk [`Error::source`] instead; otherwise a
311/// filtered hostname reports `Connect`, maps to `idp_unreachable`, and
312/// is retried against a destination that can never be reached.
313fn classify(err: &hyper_util::client::legacy::Error) -> HttpTransportError {
314    if let Some(reason) = denied_reason(err) {
315        return HttpTransportError::Rejected(reason);
316    }
317    let msg = err.to_string();
318    if err.is_connect() {
319        HttpTransportError::Connect(msg)
320    } else {
321        HttpTransportError::Io(msg)
322    }
323}
324
325/// The private-address reason [`EgressResolver`] stuffed behind
326/// [`EGRESS_DENIED_PREFIX`], if any layer of `err` carries it.
327fn denied_reason(err: &(dyn Error + 'static)) -> Option<String> {
328    let mut cur = Some(err);
329    while let Some(e) = cur {
330        if let Some((_, reason)) = e.to_string().split_once(EGRESS_DENIED_PREFIX) {
331            return Some(reason.trim().to_owned());
332        }
333        cur = e.source();
334    }
335    None
336}
337
338/// DNS resolver that drops addresses [`private_address_reason`] would
339/// refuse. IP literals never hit DNS, so [`HyperTransport::execute`]
340/// checks those separately; this is the connect-time check the table's
341/// docs require, so a name that rebinds from public to metadata is
342/// refused on the lookup that actually dials.
343///
344/// The connector dials the `SocketAddr`s this resolver returns. A later
345/// DNS update does not change the peer: we never resolve a second time
346/// between the filter and `connect`. The residual is that an address we
347/// accepted is still a public host, and this transport cannot see whether
348/// that host forwards to a private one.
349#[derive(Clone, Copy, Debug)]
350struct EgressResolver {
351    allow_private: bool,
352}
353
354impl Service<Name> for EgressResolver {
355    type Response = std::vec::IntoIter<SocketAddr>;
356    type Error = Box<dyn std::error::Error + Send + Sync>;
357    type Future =
358        Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>>;
359
360    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
361        Poll::Ready(Ok(()))
362    }
363
364    fn call(&mut self, name: Name) -> Self::Future {
365        let allow_private = self.allow_private;
366        let fut = GaiResolver::new().call(name);
367        Box::pin(async move {
368            let addrs: Vec<SocketAddr> = fut.await?.collect();
369            if allow_private {
370                return Ok(addrs.into_iter());
371            }
372            let mut kept = Vec::new();
373            let mut denied = None;
374            for addr in addrs {
375                match private_address_reason(&addr.ip()) {
376                    Some(reason) => denied = Some(reason),
377                    None => kept.push(addr),
378                }
379            }
380            if kept.is_empty() {
381                let reason = denied.unwrap_or("no resolvable addresses");
382                return Err(format!("{EGRESS_DENIED_PREFIX}{reason}").into());
383            }
384            Ok(kept.into_iter())
385        })
386    }
387}
388
389#[async_trait]
390impl HttpTransport for HyperTransport {
391    async fn execute(&self, req: HttpRequest) -> Result<HttpResponse, HttpTransportError> {
392        let uri: http::Uri = req
393            .url
394            .parse()
395            .map_err(|e| HttpTransportError::InvalidRequest(format!("url '{}': {e}", req.url)))?;
396
397        if uri.host().is_none() {
398            return Err(HttpTransportError::InvalidRequest(format!(
399                "url '{}' has no host",
400                req.url
401            )));
402        }
403
404        // Build the pool even when the destination is later refused, so
405        // a refused first request still lands the client on the runtime
406        // that served it.
407        let client = self.client()?;
408
409        if !self.allow_private_destinations
410            && let Some(host) = uri.host()
411            && let Some(ip) = host_as_ip(host)
412            && let Some(reason) = private_address_reason(&ip)
413        {
414            return Err(HttpTransportError::Rejected(reason.to_owned()));
415        }
416
417        let mut builder = http::Request::builder().method(req.method.clone()).uri(uri);
418        // Safe: `Request::builder()` starts with an empty header map and
419        // no error, so the map is present until a fallible step runs.
420        if let Some(headers) = builder.headers_mut() {
421            headers.clone_from(&req.headers);
422        }
423        let hyper_req = builder
424            .body(Full::new(req.body.clone()))
425            .map_err(|e| HttpTransportError::InvalidRequest(e.to_string()))?;
426
427        // `req.connect_timeout` is not consulted: the bound belongs to the
428        // shared connector. See `with_connect_timeout`. The overall
429        // deadline below still covers the connect phase.
430        let limit = req.max_response_bytes;
431
432        // The deadline covers the *whole* exchange, headers and body.
433        //
434        // Wrapping only `client.request()` would bound nothing useful:
435        // that future resolves as soon as the response head arrives, so
436        // a peer that answers `200` and then stalls mid-body would hang
437        // here forever — the precise failure a deadline exists to stop,
438        // and the one that hangs gateway startup when a JWKS endpoint
439        // goes bad. Both legs go inside one timeout.
440        let exchange = async {
441            let resp = client.request(hyper_req).await.map_err(|e| classify(&e))?;
442            let (parts, body) = resp.into_parts();
443
444            // Bound the body *during* collection, not after. Reading it
445            // all and then checking the length is what the ceiling
446            // exists to prevent: a hostile endpoint would already have
447            // been buffered by the time we looked.
448            //
449            // `Limited` fails for two unrelated reasons — the ceiling was
450            // exceeded, or the underlying body errored mid-stream — and
451            // they must not collapse. Reporting a truncated connection as
452            // `ResponseTooLarge` would send an operator to raise a limit
453            // that was never the problem, and it lies about delivery:
454            // an oversized response was answered in full, a broken one
455            // was not.
456            let body = match Limited::new(body, limit).collect().await {
457                Ok(collected) => collected.to_bytes(),
458                Err(e) if e.downcast_ref::<LengthLimitError>().is_some() => {
459                    return Err(HttpTransportError::ResponseTooLarge {
460                        actual: limit.saturating_add(1),
461                        limit,
462                    });
463                },
464                Err(e) => return Err(HttpTransportError::Io(e.to_string())),
465            };
466
467            Ok::<_, HttpTransportError>(
468                HttpResponse::new(parts.status.as_u16(), body).with_headers(parts.headers),
469            )
470        };
471
472        tokio::time::timeout(req.timeout, exchange)
473            .await
474            // `Elapsed` carries no detail worth keeping; the deadline is
475            // already on the request the caller built.
476            .map_err(|_elapsed| HttpTransportError::Timeout)?
477    }
478}
479
480/// Parse a URI host as an IP address.
481///
482/// `http::Uri::host()` keeps the brackets on an IPv6 literal
483/// (`[::ffff:169.254.169.254]`), and that string does not parse as
484/// [`IpAddr`]. Stripping them is what makes the pre-connect check see
485/// the same address hyper would dial. A hostname is `None` and goes
486/// through [`EgressResolver`] instead.
487fn host_as_ip(host: &str) -> Option<IpAddr> {
488    let host = host
489        .strip_prefix('[')
490        .and_then(|h| h.strip_suffix(']'))
491        .unwrap_or(host);
492    host.parse().ok()
493}
494
495#[cfg(test)]
496#[allow(
497    clippy::expect_used,
498    clippy::panic,
499    clippy::unwrap_used,
500    reason = "tests"
501)]
502mod tests {
503    use super::*;
504
505    #[tokio::test]
506    async fn an_unparseable_url_is_a_request_error_not_a_connect_error() {
507        // The distinction is load-bearing: `Connect` reads as "nothing
508        // was sent, safe to retry", and retrying a malformed URL is a
509        // busy loop against a bug.
510        let t = HyperTransport::new();
511        let err = t
512            .execute(HttpRequest::get("not a url at all"))
513            .await
514            .expect_err("that is not a URL");
515        assert!(
516            matches!(err, HttpTransportError::InvalidRequest(_)),
517            "got {err:?}"
518        );
519        assert!(!err.may_have_reached_peer());
520    }
521
522    #[tokio::test]
523    async fn a_link_local_literal_is_rejected_without_dialling() {
524        // 169.254.169.254 is cloud metadata. The table exists so this
525        // transport refuses it at the address it would connect to, not
526        // after the bytes have left.
527        let t = HyperTransport::new();
528        let err = t
529            .execute(HttpRequest::get("http://169.254.169.254/latest/meta-data/"))
530            .await
531            .expect_err("metadata is not a public destination");
532        assert!(!err.may_have_reached_peer());
533        match err {
534            HttpTransportError::Rejected(reason) => {
535                assert!(
536                    reason.contains("link-local") || reason.contains("metadata"),
537                    "the refusal must name the rule: {reason}"
538                );
539            },
540            other => panic!("expected Rejected, got {other:?}"),
541        }
542    }
543
544    #[tokio::test]
545    async fn a_private_literal_is_rejected_without_dialling() {
546        let t = HyperTransport::new();
547        let err = t
548            .execute(HttpRequest::get("http://10.0.0.1/jwks"))
549            .await
550            .expect_err("RFC 1918 is not a public destination");
551        assert!(!err.may_have_reached_peer());
552        match err {
553            HttpTransportError::Rejected(reason) => {
554                assert!(
555                    reason.contains("private"),
556                    "the refusal must name the rule: {reason}"
557                );
558            },
559            other => panic!("expected Rejected, got {other:?}"),
560        }
561    }
562
563    #[tokio::test]
564    async fn loopback_is_rejected_unless_the_hatch_is_set() {
565        let err = HyperTransport::new()
566            .execute(HttpRequest::get("http://127.0.0.1:1/jwks"))
567            .await
568            .expect_err("loopback is in the egress table");
569        assert!(
570            matches!(err, HttpTransportError::Rejected(_)),
571            "expected Rejected, got {err:?}"
572        );
573        assert!(!err.may_have_reached_peer());
574    }
575
576    #[tokio::test]
577    async fn a_mapped_ipv6_metadata_literal_is_rejected_without_dialling() {
578        // `Uri::host()` keeps the brackets on an IPv6 literal. Parsing
579        // that string as `IpAddr` fails, which used to skip the table
580        // and dial. The same address in dotted v4 is already refused.
581        let t = HyperTransport::new();
582        let err = t
583            .execute(HttpRequest::get(
584                "http://[::ffff:169.254.169.254]/latest/meta-data/",
585            ))
586            .await
587            .expect_err("mapped metadata is the same host");
588        assert!(!err.may_have_reached_peer());
589        assert!(
590            matches!(err, HttpTransportError::Rejected(_)),
591            "expected Rejected, got {err:?}"
592        );
593    }
594
595    #[tokio::test]
596    async fn an_ipv6_loopback_literal_is_rejected_without_dialling() {
597        let err = HyperTransport::new()
598            .execute(HttpRequest::get("http://[::1]:1/jwks"))
599            .await
600            .expect_err("IPv6 loopback is in the egress table");
601        assert!(
602            matches!(err, HttpTransportError::Rejected(_)),
603            "expected Rejected, got {err:?}"
604        );
605        assert!(!err.may_have_reached_peer());
606    }
607
608    #[tokio::test]
609    async fn a_hostname_resolving_to_loopback_is_rejected_not_connect() {
610        // IP literals take the pre-connect check and never enter
611        // `EgressResolver`. A name has to, and hyper's Display is
612        // `client error (Connect)` — matching that string used to
613        // report Connect, which retried and mapped to idp_unreachable.
614        let err = HyperTransport::new()
615            .execute(HttpRequest::get("http://localhost:1/jwks"))
616            .await
617            .expect_err("localhost resolves to loopback");
618        assert!(
619            matches!(err, HttpTransportError::Rejected(_)),
620            "expected Rejected, got {err:?}"
621        );
622        assert!(!err.may_have_reached_peer());
623    }
624
625    #[test]
626    fn classify_walks_the_source_chain_for_the_egress_marker() {
627        // hyper-util formats as `client error (Connect)` and keeps the
628        // resolver error on `source()`, the way the live client does.
629        #[derive(Debug)]
630        struct Marker(&'static str);
631        impl std::fmt::Display for Marker {
632            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
633                f.write_str(self.0)
634            }
635        }
636        impl Error for Marker {}
637
638        #[derive(Debug)]
639        struct Wrap(Marker);
640        impl std::fmt::Display for Wrap {
641            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
642                f.write_str("client error (Connect)")
643            }
644        }
645        impl Error for Wrap {
646            fn source(&self) -> Option<&(dyn Error + 'static)> {
647                Some(&self.0)
648            }
649        }
650
651        let err = Wrap(Marker("ppe-egress-denied:loopback"));
652        assert_eq!(denied_reason(&err).as_deref(), Some("loopback"));
653        assert!(denied_reason(&Marker("client error (Connect)")).is_none());
654    }
655
656    #[tokio::test]
657    async fn a_url_with_no_host_is_rejected_before_dialling() {
658        let t = HyperTransport::new();
659        let err = t
660            .execute(HttpRequest::get("file:///etc/passwd"))
661            .await
662            .expect_err("no host to dial");
663        assert!(matches!(err, HttpTransportError::InvalidRequest(_)));
664    }
665
666    #[tokio::test]
667    async fn a_connection_refused_reports_connect_and_is_retryable() {
668        // Port 1 on loopback: nothing listens, and the refusal arrives
669        // without anything being sent — so a caller may safely retry
670        // even a token mint. Loopback is in the egress table, so this
671        // path uses the local-IdP hatch; the table itself is tested
672        // separately.
673        let t = HyperTransport::new().with_allow_private_destinations();
674        let err = t
675            .execute(HttpRequest::get("http://127.0.0.1:1/jwks"))
676            .await
677            .expect_err("nothing listens there");
678        assert!(
679            matches!(err, HttpTransportError::Connect(_)),
680            "expected Connect, got {err:?}"
681        );
682        assert!(
683            !err.may_have_reached_peer(),
684            "a refused connection sent nothing, so a retry cannot duplicate anything"
685        );
686    }
687
688    #[tokio::test]
689    async fn the_pool_is_not_built_until_the_first_request() {
690        // The runtime-binding guard. A host may construct this during
691        // `initialize()` on a runtime that is dropped before any request
692        // arrives; connections created eagerly would be bound to a dead
693        // reactor. Constructing must therefore touch no reactor at all.
694        let t = HyperTransport::new();
695        assert!(
696            t.client.get().is_none(),
697            "constructing must not build the pool"
698        );
699        let _ = t.execute(HttpRequest::get("http://127.0.0.1:1/x")).await;
700        assert!(t.client.get().is_some(), "first use must build the pool");
701    }
702
703    #[tokio::test]
704    async fn the_pool_builds_with_no_process_default_crypto_provider() {
705        // Building against the named provider must neither require nor install a default.
706        assert!(
707            rustls::crypto::CryptoProvider::get_default().is_none(),
708            "the guard only means something while nothing has installed a default"
709        );
710        let t = HyperTransport::new();
711        // A closed loopback port builds the client, then fails to connect.
712        let _ = t.execute(HttpRequest::get("https://127.0.0.1:1/x")).await;
713        assert!(
714            t.client.get().is_some(),
715            "the pool must build against the named provider"
716        );
717        assert!(
718            rustls::crypto::CryptoProvider::get_default().is_none(),
719            "naming a provider must not install one process-wide, which would \
720             race a host installing its own"
721        );
722    }
723
724    #[test]
725    fn the_defaults_bound_the_pool_and_disable_nagle() {
726        // These three are the difference between "works in a demo" and
727        // "survives a week against a busy IdP", and each defaults the
728        // wrong way somewhere in the stack:
729        //
730        //   * hyper-util defaults nodelay off; reqwest turned it on.
731        //   * `pool_idle_timeout` does nothing without `pool_timer`, so
732        //     idle sockets are never evicted.
733        //   * `pool_max_idle_per_host` is unlimited, so eviction is the
734        //     only thing bounding the pool.
735        let t = HyperTransport::new();
736        assert_eq!(
737            t.pool_idle_timeout,
738            Some(Duration::from_secs(90)),
739            "idle connections must be evicted, or the pool grows without bound"
740        );
741        assert!(
742            t.tcp_keepalive.is_some(),
743            "keepalive is what stops a silently-dropped connection being handed to a request"
744        );
745    }
746
747    #[test]
748    fn http2_is_offered_by_default_and_can_be_forced_off() {
749        // ALPN advertises h2 alongside http/1.1, so this is free against
750        // a peer that cannot do it. The escape hatch exists for an IdP
751        // whose HTTP/2 misbehaves, which would otherwise be a puzzling
752        // failure to track down.
753        assert!(HyperTransport::new().http2);
754        assert!(!HyperTransport::new().with_http2(false).http2);
755    }
756
757    #[test]
758    fn pool_bounds_are_tunable_by_the_embedder() {
759        // A host embedding PPE standalone needs to bound sockets to its
760        // own deployment, not to ours.
761        let t = HyperTransport::new()
762            .with_pool_idle_timeout(Some(Duration::from_secs(5)))
763            .with_pool_max_idle_per_host(4)
764            .with_tcp_keepalive(None);
765        assert_eq!(t.pool_idle_timeout, Some(Duration::from_secs(5)));
766        assert_eq!(t.pool_max_idle_per_host, 4);
767        assert!(t.tcp_keepalive.is_none());
768    }
769
770    #[test]
771    fn the_connect_bound_comes_from_the_transport_not_from_a_request() {
772        // The connector is built once and shared, so this has to be
773        // decided before any request arrives. Taking it from a request
774        // instead would mean whichever plugin called first — JWKS at 2s,
775        // a token exchange at its own value — silently set the bound for
776        // every other plugin, and which one that is depends on ordering.
777        assert_eq!(
778            HyperTransport::new().connect_timeout,
779            praxis_policy_core::http::DEFAULT_CONNECT_TIMEOUT,
780        );
781        assert_eq!(
782            HyperTransport::new()
783                .with_connect_timeout(Duration::from_millis(250))
784                .connect_timeout,
785            Duration::from_millis(250),
786        );
787    }
788
789    #[test]
790    fn ipv6_uri_hosts_are_parsed_despite_brackets() {
791        // `http::Uri::host()` keeps brackets on IPv6. The pre-connect
792        // check has to strip them or every v6 literal skips the table.
793        let mapped: IpAddr = host_as_ip("[::ffff:169.254.169.254]").expect("mapped v6");
794        assert!(private_address_reason(&mapped).is_some());
795        assert!(host_as_ip("[::1]").is_some());
796        assert!(host_as_ip("127.0.0.1").is_some());
797        assert!(
798            host_as_ip("idp.example").is_none(),
799            "a hostname must go through DNS, not the literal table"
800        );
801    }
802
803    #[test]
804    fn constructing_outside_a_runtime_does_not_panic() {
805        // Deliberately not a `#[tokio::test]`: there is no reactor here
806        // at all. If construction ever starts needing one, this fails
807        // rather than surfacing as dead connections in production.
808        let t = HyperTransport::new().with_connect_timeout(Duration::from_secs(1));
809        assert!(t.client.get().is_none());
810    }
811}