Skip to main content

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}