praxis_policy_core/http.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Praxis Contributors
3
4// Outbound HTTP — the transport seam.
5//
6// PPE performs no HTTP of its own. A plugin needing an outbound call (a
7// JWKS fetch, an IdP token exchange, a CIBA backchannel) borrows a
8// host-installed `HttpTransport` through `HostServices`, gated by the
9// `perform_http` capability.
10//
11// The indirection is the point. A proxy embedding PPE already owns an
12// HTTP stack: a connection pool, a TLS trust store, an egress policy, a
13// circuit breaker, outbound tracing. A client compiled into PPE would be
14// a *second* one — a second pool against the same IdP, a second trust
15// store the operator configures separately, and an egress path the
16// host's own policy never sees. Borrowing the host's transport keeps one
17// stack in the process.
18//
19// It is also what makes PPE embeddable where a bundled client could not
20// go. A WASM host with no sockets of its own supplies an implementation
21// backed by host imports; nothing in this module names a runtime, a TLS
22// backend, or a socket.
23//
24// The trait is deliberately one method. Everything a caller might want
25// to layer on top — form encoding, retries, redirect policy, JSON
26// decoding — stays caller-side, so every implementation sees identical
27// bytes. Push encoding into the implementations and two of them can
28// encode differently, which surfaces in production rather than in
29// review.
30
31use std::fmt;
32use std::time::Duration;
33
34use async_trait::async_trait;
35use bytes::Bytes;
36use http::{HeaderName, HeaderValue};
37
38/// Re-exported because they appear in this module's public API.
39///
40/// `HttpRequest::method` and `headers` are public fields, and
41/// `HttpResponse::with_headers` takes a `HeaderMap` — so without this a
42/// host implementing [`HttpTransport`], or a test using
43/// [`FakeTransport::respond`], would have to add a direct `http`
44/// dependency just to name a type this crate handed it.
45///
46/// [`FakeTransport::respond`]: crate::http_testing::FakeTransport::respond
47pub use http::{HeaderMap, Method};
48
49/// Default overall deadline for a request that does not set one.
50///
51/// Five seconds matches the JWKS fetch bound `identity-jwt` already
52/// applied, which was chosen so a slow or hostile endpoint cannot hang
53/// gateway startup.
54pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
55
56/// Default connect bound for a request that does not set one.
57///
58/// Separate from the overall deadline so a half-open endpoint fails
59/// while the connection is still being established rather than
60/// consuming the whole budget.
61pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
62
63/// Default ceiling on a buffered response body, in bytes.
64///
65/// One mebibyte is generous for what PPE actually fetches — a JWKS
66/// document runs to single-digit kilobytes and an OAuth token response
67/// to hundreds of bytes. The ceiling exists because a hand-rolled read
68/// loop against an untrusted endpoint will otherwise buffer whatever it
69/// is sent. `reqwest` imposed no limit here either, so this closes a gap
70/// rather than tightening a bound.
71pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 1024 * 1024;
72
73/// One outbound HTTP request.
74///
75/// Construct with [`HttpRequest::get`] / [`HttpRequest::post`] /
76/// [`HttpRequest::new`] and refine with the builder methods. The struct
77/// is `#[non_exhaustive]`, so fields can be added without breaking
78/// callers; reading the fields from an implementation stays fine.
79///
80/// Bounds are per-request rather than per-transport because the three
81/// call sites differ: a JWKS fetch at startup can afford a longer
82/// deadline than a token exchange sitting in a request's critical path.
83#[non_exhaustive]
84#[derive(Clone)]
85pub struct HttpRequest {
86 /// Request method.
87 pub method: Method,
88
89 /// Absolute request URL, including scheme.
90 ///
91 /// A `String` rather than a parsed type so this crate takes no
92 /// opinion on URL parsing. Implementations parse with whatever their
93 /// stack already uses, and an unparseable URL surfaces as
94 /// [`HttpTransportError::InvalidRequest`].
95 pub url: String,
96
97 /// Request headers. `Host` is the implementation's to set, since it
98 /// derives from the URL and from any connection reuse the transport
99 /// performs.
100 pub headers: HeaderMap,
101
102 /// Request body. Empty for a bodyless method.
103 pub body: Bytes,
104
105 /// Overall deadline covering connect and I/O. Exceeding it is
106 /// [`HttpTransportError::Timeout`].
107 pub timeout: Duration,
108
109 /// Bound on connection establishment alone. `None` leaves it to the
110 /// implementation.
111 ///
112 /// A hint rather than a guarantee. A transport that pools connections
113 /// configures the connect bound once for the pool, so a per-request
114 /// value it cannot apply is ignored — the bundled hyper transport
115 /// does exactly this. `timeout` is the bound that always holds, and
116 /// it covers the connect phase, so ignoring this one shortens no
117 /// deadline and loses no safety.
118 ///
119 /// Set it when the caller wants connect to fail faster than the
120 /// overall deadline would allow, and treat honoring it as a bonus.
121 pub connect_timeout: Option<Duration>,
122
123 /// Ceiling on the buffered response body. Exceeding it is
124 /// [`HttpTransportError::ResponseTooLarge`] rather than a truncated
125 /// body, because a truncated JWKS document is indistinguishable from
126 /// a malformed one.
127 pub max_response_bytes: usize,
128}
129
130/// Headers whose values are safe to show in debug output. Everything
131/// else is redacted by default — an unlisted header may carry a token,
132/// API key, or session cookie, and adding it to a denylist only after
133/// someone notices is too late.
134const SAFE_HEADERS: &[&str] = &[
135 "accept",
136 "content-length",
137 "content-type",
138 "host",
139 "user-agent",
140 "x-request-id",
141];
142
143impl std::fmt::Debug for HttpRequest {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 struct RedactedHeaders<'a>(&'a HeaderMap);
146
147 impl fmt::Debug for RedactedHeaders<'_> {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 f.debug_list()
150 .entries(self.0.keys().map(|k| RedactedEntry(k, self.0)))
151 .finish()
152 }
153 }
154
155 struct RedactedEntry<'a>(&'a HeaderName, &'a HeaderMap);
156
157 impl fmt::Debug for RedactedEntry<'_> {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 if SAFE_HEADERS.contains(&self.0.as_str()) {
160 let val = self
161 .1
162 .get(self.0)
163 .map(|v| v.to_str().unwrap_or("\u{2026}"))
164 .unwrap_or("\u{2026}");
165 write!(f, "{}: {val}", self.0)
166 } else {
167 write!(f, "{}: [REDACTED]", self.0)
168 }
169 }
170 }
171
172 let mut dbg = f.debug_struct("HttpRequest");
173 dbg.field("method", &self.method);
174 dbg.field("url", &self.url);
175 dbg.field("headers", &RedactedHeaders(&self.headers));
176 dbg.field("body", &format_args!("[{} bytes]", self.body.len()));
177 dbg.field("timeout", &self.timeout);
178 dbg.field("connect_timeout", &self.connect_timeout);
179 dbg.field("max_response_bytes", &self.max_response_bytes);
180 dbg.finish()
181 }
182}
183
184impl HttpRequest {
185 /// A request with the given method and URL, carrying the default
186 /// bounds and no headers or body.
187 pub fn new(method: Method, url: impl Into<String>) -> Self {
188 Self {
189 method,
190 url: url.into(),
191 headers: HeaderMap::new(),
192 body: Bytes::new(),
193 timeout: DEFAULT_TIMEOUT,
194 connect_timeout: Some(DEFAULT_CONNECT_TIMEOUT),
195 max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
196 }
197 }
198
199 /// A `GET` for `url`.
200 pub fn get(url: impl Into<String>) -> Self {
201 Self::new(Method::GET, url)
202 }
203
204 /// A `POST` to `url` carrying `body`.
205 pub fn post(url: impl Into<String>, body: Bytes) -> Self {
206 let mut req = Self::new(Method::POST, url);
207 req.body = body;
208 req
209 }
210
211 /// Set a header, replacing any existing value for that name.
212 ///
213 /// # Errors
214 ///
215 /// Returns [`HttpTransportError::InvalidRequest`] when the name or
216 /// value is not a legal header. Taking the error here rather than
217 /// panicking matters because both can come from operator config.
218 pub fn header(
219 mut self,
220 name: impl AsRef<str>,
221 value: impl AsRef<str>,
222 ) -> Result<Self, HttpTransportError> {
223 let name = HeaderName::try_from(name.as_ref())
224 .map_err(|e| HttpTransportError::InvalidRequest(format!("header name: {e}")))?;
225 let value = HeaderValue::try_from(value.as_ref())
226 .map_err(|e| HttpTransportError::InvalidRequest(format!("header value: {e}")))?;
227 self.headers.insert(name, value);
228 Ok(self)
229 }
230
231 /// Replace the request body.
232 #[must_use]
233 pub fn body(mut self, body: Bytes) -> Self {
234 self.body = body;
235 self
236 }
237
238 /// Set the overall deadline.
239 #[must_use]
240 pub fn timeout(mut self, timeout: Duration) -> Self {
241 self.timeout = timeout;
242 self
243 }
244
245 /// Set the connect bound.
246 #[must_use]
247 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
248 self.connect_timeout = Some(timeout);
249 self
250 }
251
252 /// Set the response body ceiling.
253 #[must_use]
254 pub fn max_response_bytes(mut self, max: usize) -> Self {
255 self.max_response_bytes = max;
256 self
257 }
258}
259
260/// Encode `pairs` as an `application/x-www-form-urlencoded` body.
261///
262/// Caller-side rather than a transport concern, deliberately. Every OAuth
263/// exchange PPE performs is a form POST, and if each transport encoded
264/// its own, two of them could encode differently — which surfaces as an
265/// `IdP` rejecting an exchange in production rather than as a diff in
266/// review. One encoder means every transport sees identical bytes.
267///
268/// Follows the WHATWG URL form-urlencoded serializer: unreserved
269/// characters pass through, a space becomes `+`, and everything else is
270/// percent-encoded. Note `*`, `-`, `.` and `_` are unreserved here, so a
271/// scope string such as `read:users` percent-encodes only the colon.
272///
273/// The caller still sets `Content-Type: application/x-www-form-urlencoded`;
274/// this produces the body, not the header, because a transport must not
275/// have to guess which of the two a caller meant.
276pub fn form_urlencode(pairs: &[(&str, &str)]) -> Bytes {
277 fn encode_into(out: &mut String, raw: &str) {
278 for byte in raw.as_bytes() {
279 match byte {
280 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'*' | b'-' | b'.' | b'_' => {
281 out.push(*byte as char);
282 },
283 b' ' => out.push('+'),
284 other => {
285 out.push('%');
286 out.push(
287 char::from_digit(u32::from(other >> 4), 16)
288 .unwrap_or('0')
289 .to_ascii_uppercase(),
290 );
291 out.push(
292 char::from_digit(u32::from(other & 0xF), 16)
293 .unwrap_or('0')
294 .to_ascii_uppercase(),
295 );
296 },
297 }
298 }
299 }
300
301 let mut out = String::new();
302 for (i, (key, value)) in pairs.iter().enumerate() {
303 if i > 0 {
304 out.push('&');
305 }
306 encode_into(&mut out, key);
307 out.push('=');
308 encode_into(&mut out, value);
309 }
310 Bytes::from(out)
311}
312
313/// A buffered HTTP response.
314///
315/// Buffered rather than streaming because every PPE call site parses a
316/// small document in full — a JWKS set, a token response. A streaming
317/// variant can be added later without disturbing this one.
318#[non_exhaustive]
319#[derive(Debug, Clone)]
320pub struct HttpResponse {
321 /// Response status code.
322 pub status: u16,
323
324 /// Response headers.
325 pub headers: HeaderMap,
326
327 /// Response body, already bounded by the request's
328 /// `max_response_bytes`.
329 pub body: Bytes,
330}
331
332impl HttpResponse {
333 /// A response with the given status and body and no headers.
334 ///
335 /// Chiefly for test transports; a real implementation fills
336 /// `headers` from the wire.
337 pub fn new(status: u16, body: Bytes) -> Self {
338 Self {
339 status,
340 headers: HeaderMap::new(),
341 body,
342 }
343 }
344
345 /// Attach response headers.
346 ///
347 /// [`HttpResponse`] is `#[non_exhaustive]`, so a transport outside
348 /// this crate — which is every real one — cannot build it with a
349 /// struct literal. This is the supported path, and it keeps adding a
350 /// field later from breaking implementations.
351 #[must_use]
352 pub fn with_headers(mut self, headers: HeaderMap) -> Self {
353 self.headers = headers;
354 self
355 }
356
357 /// Whether the status is in the 2xx range.
358 ///
359 /// A non-2xx status is *not* an error at this layer. Interpreting a
360 /// status is the caller's job: a `404` from a JWKS endpoint and a
361 /// `400` from a token endpoint mean different things and map to
362 /// different deny codes.
363 ///
364 /// Note this is **false for `304 Not Modified`**, which is a
365 /// successful revalidation rather than a failure. A caller making
366 /// conditional requests must check [`is_not_modified`] first; see
367 /// that method for why.
368 ///
369 /// [`is_not_modified`]: Self::is_not_modified
370 pub fn is_success(&self) -> bool {
371 (200..300).contains(&self.status)
372 }
373
374 /// Whether the peer answered `304 Not Modified`.
375 ///
376 /// The happy path of a conditional request: the cached copy is still
377 /// current and the body was not resent. A JWKS refresh that sends
378 /// `If-None-Match` gets this on every poll where the `IdP` has not
379 /// rotated, which is nearly all of them.
380 ///
381 /// This exists as its own method because `304` sits outside the 2xx
382 /// range, so the reflexive `if !resp.is_success() { return Err }`
383 /// turns every successful revalidation into a failure — and for
384 /// `identity-jwt` that failure is fail-closed.
385 pub fn is_not_modified(&self) -> bool {
386 self.status == 304
387 }
388
389 /// The `ETag` header, when the peer sent a valid one.
390 ///
391 /// Feed it back as `If-None-Match` on the next fetch to make a
392 /// refresh cost a round trip instead of a document.
393 pub fn etag(&self) -> Option<&str> {
394 self.headers.get("etag").and_then(|v| v.to_str().ok())
395 }
396
397 /// The `max-age` directive from `Cache-Control`, in seconds.
398 ///
399 /// A caller uses this to decide when its cached copy is stale rather
400 /// than guessing with a fixed interval. Absent, unparseable, or
401 /// `no-store`/`no-cache` all yield `None`, so the caller falls back
402 /// to its configured default rather than caching something the peer
403 /// asked it not to.
404 pub fn cache_max_age(&self) -> Option<Duration> {
405 let raw = self.headers.get("cache-control")?.to_str().ok()?;
406 let mut max_age = None;
407 for directive in raw.split(',') {
408 let directive = directive.trim();
409 if directive.eq_ignore_ascii_case("no-store")
410 || directive.eq_ignore_ascii_case("no-cache")
411 {
412 return None;
413 }
414 if let Some(v) = directive
415 .split_once('=')
416 .filter(|(k, _)| k.trim().eq_ignore_ascii_case("max-age"))
417 .map(|(_, v)| v.trim())
418 {
419 max_age = v.parse::<u64>().ok().map(Duration::from_secs);
420 }
421 }
422 max_age
423 }
424}
425
426/// Why an outbound request did not produce a response.
427///
428/// Transport-level only. A response with a discouraging status code is
429/// an `Ok(HttpResponse)`, because only the caller knows what a given
430/// status means for its own protocol.
431///
432/// `#[non_exhaustive]` because a host may distinguish failures this
433/// crate has no vocabulary for yet; a caller matching exhaustively today
434/// should not break when one is added.
435#[non_exhaustive]
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub enum HttpTransportError {
438 /// The request exceeded its deadline.
439 ///
440 /// Distinct from [`Self::Connect`] and [`Self::Io`] because callers
441 /// act on it differently. `identity-jwt` treats a timed-out JWKS
442 /// fetch as soft-fail-at-boot rather than a configuration error, and
443 /// a delegation timeout must map to "unknown" rather than
444 /// "rejected", since a token may still have been minted.
445 Timeout,
446
447 /// The connection could not be established.
448 Connect(String),
449
450 /// The connection was established but the exchange failed.
451 Io(String),
452
453 /// The request could not be formed — an unparseable URL, an illegal
454 /// header. Always a bug or a configuration error, never transient.
455 InvalidRequest(String),
456
457 /// The response body exceeded the request's ceiling. The body is
458 /// discarded rather than truncated.
459 ResponseTooLarge {
460 /// Bytes seen before the read was abandoned.
461 actual: usize,
462 /// The ceiling that was exceeded.
463 limit: usize,
464 },
465
466 /// The host declined to make the request at all.
467 ///
468 /// Egress policy, an SSRF guard, admission control, or an open
469 /// circuit. Deliberately distinct from [`Self::Connect`]: "we
470 /// declined to try" and "we tried and failed" send an operator to
471 /// different places, and collapsing them turns a blocked destination
472 /// into a phantom network problem.
473 Rejected(String),
474}
475
476impl HttpTransportError {
477 /// Whether the peer may have received and acted on the request.
478 ///
479 /// This is the question a caller actually needs, and it is not the
480 /// same as "should I retry". Retry safety is idempotency plus this;
481 /// only the caller knows the first half. A JWKS `GET` is safe to
482 /// repeat regardless. An RFC 8693 token exchange is not: repeat one
483 /// that already landed and the `IdP` mints a second credential
484 /// nobody is tracking.
485 ///
486 /// `true` means *unknown*, not *certain*. A timeout cannot
487 /// distinguish "never arrived" from "arrived, and the response was
488 /// lost on the way back", so it answers `true` and the caller must
489 /// treat the outcome as indeterminate rather than failed.
490 ///
491 /// This maps onto the effect-log triad a delegating plugin records:
492 /// `false` is a clean `rejected`, `true` is `unknown` and wants
493 /// reconciliation rather than an assumption.
494 pub fn may_have_reached_peer(&self) -> bool {
495 match self {
496 // Nothing was ever sent.
497 Self::Connect(_) | Self::InvalidRequest(_) | Self::Rejected(_) => false,
498 // Sent, and the outcome is genuinely unknown.
499 Self::Timeout | Self::Io(_) => true,
500 // Definitely delivered and answered — we just refused to
501 // buffer the answer.
502 Self::ResponseTooLarge { .. } => true,
503 }
504 }
505}
506
507impl fmt::Display for HttpTransportError {
508 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
509 match self {
510 Self::Timeout => write!(f, "request timed out"),
511 Self::Connect(m) => write!(f, "connection failed: {m}"),
512 Self::Io(m) => write!(f, "transport error: {m}"),
513 Self::InvalidRequest(m) => write!(f, "malformed request: {m}"),
514 Self::ResponseTooLarge { actual, limit } => write!(
515 f,
516 "response body exceeded the {limit}-byte ceiling (saw at least {actual} bytes)"
517 ),
518 Self::Rejected(m) => write!(f, "request refused by the host: {m}"),
519 }
520 }
521}
522
523impl std::error::Error for HttpTransportError {}
524
525/// Performs outbound HTTP on PPE's behalf.
526///
527/// Implemented by the host, never by this crate. A proxy implements it
528/// over its own client so PPE's outbound calls share the process's pool,
529/// TLS material, egress policy, and tracing. A WASM host implements it
530/// over host imports. Tests implement it over a canned table, which is
531/// how timeout and error mapping become assertable without socket
532/// timing.
533///
534/// # One instance, shared
535///
536/// An implementation is **never cloned**. The host constructs exactly
537/// one, hands it over as `Arc<dyn HttpTransport>`, and every holder
538/// downstream — the engine, each request's `Extensions`, each plugin's
539/// filtered view — carries a refcount bump on that same object. So the
540/// connection pool, the TLS material, and any circuit-breaker state are
541/// process-wide by construction rather than by discipline. That is the
542/// whole point: a second instance would be a second pool against the
543/// same `IdP`, which is what this seam exists to prevent.
544///
545/// The trait therefore does not require `Clone`, and an implementation
546/// holding a pool need not make one cheap to copy. It does need interior
547/// mutability and to be safe to call concurrently from many tasks, since
548/// one instance serves every request in the process.
549///
550/// # Retries
551///
552/// An implementation **must not** retry at the application level. Two of
553/// PPE's three callers issue non-idempotent `POST`s — a token exchange
554/// and a CIBA backchannel registration — where a blind repeat mints a
555/// second credential or asks a human to approve the same thing twice.
556/// The transport cannot know which requests are safe to repeat; the
557/// caller can, and owns the decision. See
558/// [`HttpTransportError::may_have_reached_peer`].
559///
560/// One narrow exception is not an application retry and is expected: if
561/// a connection came from a keepalive pool and turned out to be closed
562/// before any request bytes were written, resending on a fresh
563/// connection is invisible to the peer and correct even for a `POST`.
564/// The distinction is whether the peer could have observed the request,
565/// never whether the failure looked transient.
566///
567/// # Runtime binding
568///
569/// An implementation **must not** bind pooled connections to whichever
570/// runtime happened to construct it. A host may build the transport
571/// during a short-lived initialization runtime that is dropped before
572/// the first request arrives, at which point connections created eagerly
573/// are already dead. Build pools lazily on first use.
574#[async_trait]
575pub trait HttpTransport: Send + Sync + fmt::Debug {
576 /// Perform one request and buffer the response.
577 ///
578 /// # Errors
579 ///
580 /// Returns [`HttpTransportError`] when no response was obtained. A
581 /// response carrying any status, including 5xx, is `Ok`.
582 async fn execute(&self, req: HttpRequest) -> Result<HttpResponse, HttpTransportError>;
583}
584
585#[cfg(test)]
586#[allow(
587 clippy::expect_used,
588 clippy::panic,
589 clippy::unwrap_used,
590 reason = "tests"
591)]
592mod tests {
593 use super::*;
594
595 #[test]
596 fn form_encoding_matches_what_an_idp_expects() {
597 // The OAuth shapes PPE actually sends. An encoder that differs
598 // here fails at the IdP, not in review.
599 assert_eq!(
600 &*form_urlencode(&[("grant_type", "client_credentials")]),
601 b"grant_type=client_credentials"
602 );
603 assert_eq!(&*form_urlencode(&[("a", "1"), ("b", "2")]), b"a=1&b=2");
604 // A space is `+`, not `%20`, in form encoding specifically —
605 // this is the classic way a hand-rolled encoder goes wrong, and
606 // a space-separated `scope` is exactly where it bites.
607 assert_eq!(
608 &*form_urlencode(&[("scope", "read write")]),
609 b"scope=read+write"
610 );
611 // Reserved characters percent-encode uppercase.
612 assert_eq!(
613 &*form_urlencode(&[("scope", "read:users")]),
614 b"scope=read%3Ausers"
615 );
616 // Unreserved pass through untouched.
617 assert_eq!(&*form_urlencode(&[("k", "a*-._z")]), b"k=a*-._z");
618 // A JWT assertion is base64url, whose `-` and `_` must survive
619 // and whose `=` padding must not.
620 assert_eq!(
621 &*form_urlencode(&[("client_assertion", "aB-_9.xY=")]),
622 b"client_assertion=aB-_9.xY%3D"
623 );
624 assert!(form_urlencode(&[]).is_empty());
625 }
626
627 #[test]
628 fn form_encoding_escapes_the_separators_it_uses() {
629 // A value containing `&` or `=` must not be able to forge extra
630 // parameters. Getting this wrong lets a crafted scope inject a
631 // `grant_type` the caller never asked for.
632 assert_eq!(&*form_urlencode(&[("x", "a&b=c")]), b"x=a%26b%3Dc");
633 }
634
635 // ---- response cache headers ----------------------------------------
636 //
637 // These decide how long a caller may hold a JWKS document and whether
638 // a revalidation counts as success. Both failure directions are
639 // expensive: caching past a rotation denies valid tokens, and treating
640 // a `304` as a failure turns the cheap path into a fail-closed one.
641
642 /// A response carrying `headers`, given as `(name, value)` pairs.
643 fn resp_with(status: u16, headers: &[(&str, &str)]) -> HttpResponse {
644 let mut map = HeaderMap::new();
645 for (k, v) in headers {
646 map.insert(
647 http::header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
648 http::header::HeaderValue::from_str(v).unwrap(),
649 );
650 }
651 HttpResponse::new(status, Bytes::new()).with_headers(map)
652 }
653
654 #[test]
655 fn a_revalidation_is_recognized_as_one() {
656 // `304` is outside the 2xx range, so the two must not be confused:
657 // the reflexive `if !is_success() { return Err }` would turn every
658 // successful revalidation into a JWKS fetch failure.
659 assert!(resp_with(304, &[]).is_not_modified());
660 assert!(!resp_with(304, &[]).is_success());
661 assert!(!resp_with(200, &[]).is_not_modified());
662 assert!(!resp_with(404, &[]).is_not_modified());
663 }
664
665 #[test]
666 fn an_etag_is_returned_only_when_the_peer_sent_a_usable_one() {
667 // Quotes are part of the tag and must survive: an `If-None-Match`
668 // sent without them never matches, so every refresh silently
669 // re-downloads the document.
670 assert_eq!(resp_with(200, &[("etag", "\"v1\"")]).etag(), Some("\"v1\""));
671 assert_eq!(
672 resp_with(200, &[("etag", "W/\"weak\"")]).etag(),
673 Some("W/\"weak\"")
674 );
675 assert_eq!(resp_with(200, &[]).etag(), None);
676 }
677
678 #[test]
679 fn max_age_is_read_from_cache_control() {
680 assert_eq!(
681 resp_with(200, &[("cache-control", "max-age=300")]).cache_max_age(),
682 Some(Duration::from_secs(300))
683 );
684 // Multi-directive, in either order, with the whitespace a real
685 // `IdP` sends.
686 assert_eq!(
687 resp_with(200, &[("cache-control", "public, max-age=600")]).cache_max_age(),
688 Some(Duration::from_secs(600))
689 );
690 assert_eq!(
691 resp_with(200, &[("cache-control", "max-age=600, must-revalidate")]).cache_max_age(),
692 Some(Duration::from_secs(600))
693 );
694 // Directive names are case-insensitive per RFC 9111.
695 assert_eq!(
696 resp_with(200, &[("cache-control", "Max-Age=42")]).cache_max_age(),
697 Some(Duration::from_secs(42))
698 );
699 assert_eq!(
700 resp_with(200, &[("cache-control", "max-age=0")]).cache_max_age(),
701 Some(Duration::ZERO)
702 );
703 }
704
705 #[test]
706 fn no_store_and_no_cache_beat_a_max_age_in_the_same_header() {
707 // The peer asking us not to hold the document wins over the
708 // lifetime it also stated. Order must not matter: a caller that
709 // honored `max-age=300` because it appeared first would cache a
710 // document the `IdP` explicitly said not to.
711 for raw in [
712 "no-store",
713 "no-cache",
714 "no-store, max-age=300",
715 "max-age=300, no-store",
716 "max-age=300, no-cache",
717 "public, No-Cache, max-age=300",
718 ] {
719 assert_eq!(
720 resp_with(200, &[("cache-control", raw)]).cache_max_age(),
721 None,
722 "`{raw}` must not yield a cacheable lifetime"
723 );
724 }
725 }
726
727 #[test]
728 fn an_unusable_cache_control_yields_no_lifetime() {
729 // `None` means "the caller falls back to its configured default",
730 // so every one of these has to reach it rather than producing a
731 // number invented from a malformed header.
732 for raw in [
733 // No header at all is covered separately below.
734 "",
735 "max-age",
736 "max-age=",
737 "max-age=abc",
738 // Negative and overflowing values do not parse as `u64`.
739 "max-age=-1",
740 "max-age=99999999999999999999999",
741 // A directive that merely starts the same way is not `max-age`.
742 "s-maxage=600",
743 "public",
744 ] {
745 assert_eq!(
746 resp_with(200, &[("cache-control", raw)]).cache_max_age(),
747 None,
748 "`{raw}` must not yield a lifetime"
749 );
750 }
751 assert_eq!(resp_with(200, &[]).cache_max_age(), None);
752 }
753
754 #[test]
755 fn an_unparseable_max_age_does_not_leave_an_earlier_one_standing() {
756 // A repeated directive is malformed input. The later value wins,
757 // including when it is the unusable one, so a peer cannot get a
758 // lifetime honored by appending garbage after it.
759 assert_eq!(
760 resp_with(200, &[("cache-control", "max-age=300, max-age=abc")]).cache_max_age(),
761 None
762 );
763 assert_eq!(
764 resp_with(200, &[("cache-control", "max-age=abc, max-age=300")]).cache_max_age(),
765 Some(Duration::from_secs(300))
766 );
767 }
768
769 #[test]
770 fn a_qualified_no_cache_is_not_treated_as_a_bare_one() {
771 // `no-cache="set-cookie"` restricts one field rather than the
772 // whole response, so the stated `max-age` still applies. Pinned
773 // because the bare-string comparison that implements the
774 // unqualified form is what makes this fall through, and someone
775 // "fixing" it with `starts_with` would break this case.
776 assert_eq!(
777 resp_with(
778 200,
779 &[("cache-control", "no-cache=\"set-cookie\", max-age=300")]
780 )
781 .cache_max_age(),
782 Some(Duration::from_secs(300))
783 );
784 }
785
786 #[test]
787 fn a_new_request_carries_the_default_bounds() {
788 // The defaults are the safety net for a caller that sets
789 // nothing. An unbounded default would mean a hostile endpoint
790 // could hang or exhaust memory through any plugin that forgot.
791 let req = HttpRequest::get("https://idp.example.com/jwks");
792 assert_eq!(req.method, Method::GET);
793 assert_eq!(req.timeout, DEFAULT_TIMEOUT);
794 assert_eq!(req.connect_timeout, Some(DEFAULT_CONNECT_TIMEOUT));
795 assert_eq!(req.max_response_bytes, DEFAULT_MAX_RESPONSE_BYTES);
796 assert!(req.body.is_empty());
797 }
798
799 #[test]
800 fn builders_override_the_defaults() {
801 let req = HttpRequest::post("https://idp.example.com/token", Bytes::from_static(b"a=1"))
802 .timeout(Duration::from_secs(30))
803 .connect_timeout(Duration::from_secs(1))
804 .max_response_bytes(64);
805 assert_eq!(req.method, Method::POST);
806 assert_eq!(req.timeout, Duration::from_secs(30));
807 assert_eq!(req.connect_timeout, Some(Duration::from_secs(1)));
808 assert_eq!(req.max_response_bytes, 64);
809 assert_eq!(&*req.body, b"a=1");
810 }
811
812 #[test]
813 fn an_illegal_header_is_an_error_rather_than_a_panic() {
814 // Header names and values can come from operator config, so a
815 // bad one must be reportable rather than fatal.
816 let err = HttpRequest::get("https://example.com")
817 .header("not a header name", "v")
818 .expect_err("a header name with a space is not legal");
819 assert!(matches!(err, HttpTransportError::InvalidRequest(_)));
820
821 let err = HttpRequest::get("https://example.com")
822 .header("x-ok", "bad\nvalue")
823 .expect_err("a header value with a newline is not legal");
824 assert!(matches!(err, HttpTransportError::InvalidRequest(_)));
825 }
826
827 #[test]
828 fn a_non_success_status_is_not_a_transport_error() {
829 // The caller decides what a status means. A 404 from a JWKS
830 // endpoint is a configuration problem; a 400 from a token
831 // endpoint is a definitive rejection. Collapsing either into a
832 // transport error would lose that.
833 let resp = HttpResponse::new(404, Bytes::new());
834 assert!(!resp.is_success());
835 let resp = HttpResponse::new(204, Bytes::new());
836 assert!(resp.is_success());
837 }
838
839 #[test]
840 fn rejected_and_connect_render_differently() {
841 // "We declined to try" and "we tried and failed" send an
842 // operator to different places. The messages have to say which.
843 let rejected = HttpTransportError::Rejected("egress policy".to_owned()).to_string();
844 let connect = HttpTransportError::Connect("refused".to_owned()).to_string();
845 assert!(rejected.contains("refused by the host"), "{rejected}");
846 assert!(connect.contains("connection failed"), "{connect}");
847 assert_ne!(rejected, connect);
848 }
849
850 #[test]
851 fn delivery_is_reported_separately_from_failure() {
852 // The caller pairs this with its own idempotency knowledge to
853 // decide on a retry, and a delegating plugin uses it to choose
854 // between recording a mint as `rejected` and as `unknown`.
855 // Getting `Timeout` wrong here mints duplicate credentials.
856 assert!(!HttpTransportError::Connect("refused".to_owned()).may_have_reached_peer());
857 assert!(!HttpTransportError::InvalidRequest("bad url".to_owned()).may_have_reached_peer());
858 assert!(!HttpTransportError::Rejected("egress".to_owned()).may_have_reached_peer());
859
860 assert!(
861 HttpTransportError::Timeout.may_have_reached_peer(),
862 "a timeout cannot distinguish 'never arrived' from 'the reply was lost', \
863 so it must read as indeterminate rather than failed"
864 );
865 assert!(HttpTransportError::Io("reset".to_owned()).may_have_reached_peer());
866 assert!(
867 HttpTransportError::ResponseTooLarge {
868 actual: 2,
869 limit: 1
870 }
871 .may_have_reached_peer(),
872 "the peer answered; we declined to buffer the answer"
873 );
874 }
875
876 #[test]
877 fn response_too_large_names_both_numbers() {
878 // An operator hitting this needs to know the ceiling to decide
879 // whether to raise it or distrust the endpoint.
880 let msg = HttpTransportError::ResponseTooLarge {
881 actual: 2048,
882 limit: 1024,
883 }
884 .to_string();
885 assert!(msg.contains("1024"), "{msg}");
886 assert!(msg.contains("2048"), "{msg}");
887 }
888
889 #[test]
890 fn debug_redacts_sensitive_headers() {
891 let req = HttpRequest::post(
892 "https://vault.test/v1/auth/jwt/login",
893 Bytes::from(r#"{"jwt":"eyJ.SENSITIVE.jwt"}"#),
894 )
895 .header("authorization", "Bearer SENSITIVE_TOKEN")
896 .unwrap()
897 .header("x-vault-token", "s.SENSITIVE_VAULT_TOKEN")
898 .unwrap()
899 .header("content-type", "application/json")
900 .unwrap();
901 let debug = format!("{req:?}");
902 assert!(
903 !debug.contains("SENSITIVE"),
904 "Debug must not expose sensitive header values or body: {debug}"
905 );
906 assert!(debug.contains("[REDACTED]"));
907 assert!(debug.contains("content-type"));
908 assert!(debug.contains("vault.test"));
909 }
910
911 #[test]
912 fn debug_shows_safe_headers_unredacted() {
913 let req = HttpRequest::get("https://example.com")
914 .header("x-request-id", "abc-123")
915 .unwrap();
916 let debug = format!("{req:?}");
917 assert!(debug.contains("abc-123"));
918 }
919}