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}