Skip to main content

pkix_aia_http/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![forbid(unsafe_code)]
3#![warn(missing_docs, rust_2018_idioms)]
4
5//! # pkix-aia-http
6//!
7//! Reference synchronous HTTP fetcher for
8//! [`pkix-aia`](https://docs.rs/pkix-aia)'s
9//! [`AiaFetcher`] trait.
10//!
11//! AIA (Authority Information Access, RFC 5280 §4.2.2.1) is the
12//! extension that carries `caIssuers` URIs pointing at the
13//! certificate's issuer. Chain-build code can follow these URIs to
14//! fetch missing intermediates when the caller-supplied chain is
15//! incomplete. This crate plugs an HTTP transport into the
16//! [`AiaFetcher`] trait so the chain-build flow in `pkix-chain`
17//! can resolve `caIssuers` URIs whose scheme is `http://` or
18//! `https://`.
19//!
20//! ## Quick start
21//!
22//! ```no_run
23//! use pkix_aia::AiaFetcher;
24//! use pkix_aia_http::HttpFetcher;
25//!
26//! let fetcher = HttpFetcher::new();
27//! let der_bytes = fetcher.fetch("http://ca.example/intermediate.crt")?;
28//! println!("fetched {} bytes", der_bytes.len());
29//! # Ok::<(), pkix_aia::AiaError>(())
30//! ```
31//!
32//! ## Design parallel: `pkix-revocation-http`
33//!
34//! This crate intentionally mirrors `pkix-revocation-http`'s
35//! `UreqFetcher` shape: the same `ureq` dependency, the same
36//! response-size cap pattern, the same HTTPS-via-rustls feature
37//! configuration, the same "construct once, fetch many times"
38//! idiom. Callers running both crates in the same process can
39//! configure a custom `ureq::Agent` once and pass it to both
40//! fetchers via the `with_agent` builders, sharing connection
41//! pools.
42//!
43//! The split into a separate crate per use case
44//! (`pkix-revocation-http` for CRL / OCSP, `pkix-aia-http` for AIA)
45//! follows the workspace's one-callback-per-crate convention. The
46//! revocation and AIA seams in `pkix-chain` are independent: a
47//! caller can use AIA without revocation, revocation without AIA,
48//! or both. Each adapter crate is independently optional.
49//!
50//! ## What is fetched
51//!
52//! [`HttpFetcher::fetch`] issues a synchronous HTTP `GET` against
53//! the supplied `uri`. The response body is returned verbatim as
54//! `Vec<u8>`; parsing the bytes as a DER X.509 certificate is the
55//! caller's responsibility (typically delegated to
56//! `pkix-path-builder` or `pkix-chain`).
57//!
58//! Non-HTTP URI schemes (e.g. `ldap://`, `ftp://`) return
59//! [`AiaError::UriUnsupported`] immediately, before any network
60//! I/O.
61//!
62//! ## Limits
63//!
64//! - **Body size cap** — responses larger than
65//!   [`DEFAULT_MAX_RESPONSE_SIZE`] (1 MiB) are rejected with
66//!   [`AiaError::ResponseTooLarge`]. Override via
67//!   [`HttpFetcher::with_max_response_size`]. Real-world
68//!   intermediate CA certs are typically well under 4 KiB; 1 MiB is
69//!   a generous fail-closed default for an untrusted endpoint.
70//! - **Timeout** — `ureq`'s built-in agent timeouts apply. Construct
71//!   a custom [`ureq::Agent`] with explicit timeouts and pass via
72//!   [`HttpFetcher::with_agent`] if you need a specific bound.
73//! - **No retry, no backoff, no caching** — these are caller-side
74//!   concerns. Wrap [`HttpFetcher`] with a caching layer
75//!   (`pkix-aia`'s rustdoc has a `CachingFetcher` worked example)
76//!   or retry adapter as needed.
77//! - **HTTPS via rustls** — workspace pin
78//!   `ureq = { features = ["rustls"] }`. Consumers with custom TLS
79//!   requirements (PSK, client auth at the AIA endpoint, exotic
80//!   trust roots) should construct their own [`ureq::Agent`] and
81//!   inject via [`HttpFetcher::with_agent`].
82//!
83//! ## # Limitations
84//!
85//! - Synchronous only. An async parallel (mirroring
86//!   `pkix-revocation-http`'s `AsyncHttpCrlFetcher` /
87//!   `AsyncHttpOcspFetcher`) is filed as PKIX-zkjb.5.1, deferred
88//!   until consumer demand surfaces.
89//! - No LDAP transport. RFC 5280 §4.2.2.1 permits any URI scheme in
90//!   AIA `accessLocation` `GeneralName`s; in practice HTTP and HTTPS
91//!   dominate. An `ldap://` fetcher could ship as a sibling
92//!   `pkix-aia-ldap` crate if demand surfaces.
93//! - No HTTP/2 connection pooling tuning beyond `ureq::Agent`'s
94//!   defaults. Sharing an agent across many fetches (the default
95//!   when you construct one [`HttpFetcher`] and keep it) reuses
96//!   connections; per-request tuning is not exposed.
97//!
98//! Tracked as PKIX-zkjb.5 in the project beads.
99
100use std::io::Read;
101use std::time::Duration;
102
103use pkix_aia::{AiaError, AiaFetcher};
104
105/// Default cap on a single response body's size, in bytes.
106///
107/// 1 MiB. Real-world intermediate CA certificates are well under
108/// 4 KiB; the generous default leaves headroom for unusual bundles
109/// (e.g. a server that returns a `application/pkcs7-mime` `certs-only`
110/// SignedData wrapping multiple certs) without enabling
111/// denial-of-service through unbounded body growth. Callers can
112/// raise the cap via [`HttpFetcher::with_max_response_size`] if
113/// their environment has unusually large issuer-cert blobs.
114pub const DEFAULT_MAX_RESPONSE_SIZE: usize = 1024 * 1024;
115
116/// Default per-request timeout.
117///
118/// 10 seconds. AIA fetches happen in the synchronous path of chain
119/// validation; a long stall blocks the caller. The 10-second default
120/// is generous enough for slow CA endpoints and tight enough that a
121/// dead endpoint surfaces as [`AiaError::Timeout`] rather than
122/// stalling the calling thread indefinitely.
123pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
124
125/// HTTP transport backed by `ureq`.
126///
127/// `HttpFetcher` is a thin adapter from [`pkix_aia::AiaFetcher`]
128/// onto a [`ureq::Agent`]. It performs synchronous HTTP `GET`
129/// against the
130/// caller-supplied URI, bounds response body size, and translates
131/// `ureq` failure modes into [`AiaError`] variants.
132///
133/// Construct with [`HttpFetcher::new`] for sensible defaults, or
134/// [`HttpFetcher::with_agent`] to inject a pre-configured
135/// [`ureq::Agent`] (custom TLS config, proxies, additional
136/// timeouts, etc.).
137///
138/// `HttpFetcher` is `Send + Sync`; a single instance can be shared
139/// across threads. The underlying agent reuses HTTP connections, so
140/// keeping one instance per process is more efficient than
141/// constructing a fresh one per fetch.
142#[derive(Debug, Clone)]
143pub struct HttpFetcher {
144    agent: ureq::Agent,
145    max_response_size: usize,
146}
147
148impl Default for HttpFetcher {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154impl HttpFetcher {
155    /// Build a fetcher with the default `ureq::Agent`, a 1 MiB body
156    /// cap, and a 10-second per-request timeout.
157    #[must_use]
158    pub fn new() -> Self {
159        let agent: ureq::Agent = ureq::Agent::config_builder()
160            .timeout_global(Some(DEFAULT_TIMEOUT))
161            .build()
162            .into();
163        Self {
164            agent,
165            max_response_size: DEFAULT_MAX_RESPONSE_SIZE,
166        }
167    }
168
169    /// Build a fetcher around a pre-configured `ureq::Agent`.
170    ///
171    /// Use this when you need a custom TLS config, proxies,
172    /// connection-pool tuning, or non-default timeouts. The agent is
173    /// used as-is; this fetcher does not override its settings.
174    #[must_use]
175    pub fn with_agent(agent: ureq::Agent) -> Self {
176        Self {
177            agent,
178            max_response_size: DEFAULT_MAX_RESPONSE_SIZE,
179        }
180    }
181
182    /// Override the maximum response body size in bytes.
183    ///
184    /// Responses larger than `n` bytes are rejected with
185    /// [`AiaError::ResponseTooLarge`] before the buffer can grow
186    /// unboundedly. `0` is accepted and
187    /// means "reject any body"; only useful as a degenerate test
188    /// setting.
189    #[must_use]
190    pub const fn with_max_response_size(mut self, n: usize) -> Self {
191        self.max_response_size = n;
192        self
193    }
194
195    /// Borrow the underlying `ureq::Agent` for inspection or
196    /// connection-pool reuse across sibling fetchers (e.g. sharing
197    /// the same agent with a `pkix_revocation_http::UreqFetcher`).
198    #[must_use]
199    pub fn agent(&self) -> &ureq::Agent {
200        &self.agent
201    }
202}
203
204impl AiaFetcher for HttpFetcher {
205    fn fetch(&self, uri: &str) -> Result<Vec<u8>, AiaError> {
206        // Reject non-HTTP schemes up front. RFC 5280 §4.2.2.1 allows
207        // any GeneralName in accessLocation; HTTP-only fetchers
208        // signal "I cannot handle this URI" via UriUnsupported so
209        // the chain-build layer can try other AIA entries or fall
210        // through.
211        if !is_http_scheme(uri) {
212            return Err(AiaError::UriUnsupported(uri.to_owned()));
213        }
214
215        let response = self.agent.get(uri).call().map_err(map_ureq_err)?;
216
217        // ureq returns Err for non-2xx by default (http_status_as_error
218        // is on). Reaching here means a 2xx. Capture status anyway
219        // as defensive coding — a server could conceivably reply 1xx.
220        let status = response.status().as_u16();
221        if !(200..300).contains(&status) {
222            return Err(AiaError::HttpStatus(status));
223        }
224
225        // Read the body with a hard byte cap. Take(limit + 1) lets
226        // us distinguish "exactly limit bytes" from "more than
227        // limit bytes" — without the +1 the reader would happily
228        // return `limit` bytes and we could not tell whether the
229        // server stopped or we truncated.
230        let limit = self.max_response_size;
231        let mut reader = response.into_body().into_reader();
232        let mut bytes = Vec::with_capacity(limit.min(8192));
233        let read_count = (&mut reader)
234            .take((limit as u64).saturating_add(1))
235            .read_to_end(&mut bytes)
236            .map_err(|e| AiaError::IoFailure {
237                kind: e.kind(),
238                message: e.to_string(),
239            })?;
240
241        if read_count > limit {
242            return Err(AiaError::ResponseTooLarge {
243                limit,
244                actual: read_count,
245            });
246        }
247
248        Ok(bytes)
249    }
250}
251
252/// Return `true` when `uri` begins with `http://` or `https://`,
253/// case-insensitively (RFC 3986 §3.1: scheme is case-insensitive).
254fn is_http_scheme(uri: &str) -> bool {
255    let lower = uri.split_once(':').map(|(scheme, _)| scheme);
256    matches!(lower, Some(s) if s.eq_ignore_ascii_case("http") || s.eq_ignore_ascii_case("https"))
257}
258
259/// Translate a `ureq::Error` into an [`AiaError`].
260///
261/// `ureq` 3.x surfaces HTTP error statuses as
262/// `Error::StatusCode(code)` when `http_status_as_error` is on (the
263/// default). All other failures — DNS resolution, connection
264/// refused, TLS handshake, body decode — surface through
265/// [`AiaError::IoFailure`]. Per-request timeouts surface as
266/// [`AiaError::Timeout`].
267fn map_ureq_err(e: ureq::Error) -> AiaError {
268    match e {
269        ureq::Error::StatusCode(code) => AiaError::HttpStatus(code),
270        ureq::Error::Timeout(_) => AiaError::Timeout,
271        other => AiaError::IoFailure {
272            kind: std::io::ErrorKind::Other,
273            message: other.to_string(),
274        },
275    }
276}
277
278// ---------------------------------------------------------------------------
279// Send + Sync invariant (AGENTS.md non-negotiable #6 / PKIX-2l0v.2)
280// ---------------------------------------------------------------------------
281
282const _: fn() = || {
283    fn _assert_send_sync<T: Send + Sync>() {}
284    _assert_send_sync::<HttpFetcher>();
285};
286
287// ---------------------------------------------------------------------------
288// Compile-shape and constructor tests
289// ---------------------------------------------------------------------------
290//
291// End-to-end behavioural verification (HTTP responses, body caps in
292// flight, status mapping, timeout enforcement) lives in
293// `tests/integration.rs` because it requires a live local HTTP
294// server. The tests here just prove that the type compiles,
295// implements the trait, and that constructors honour their inputs.
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn default_constructor_uses_default_max_size() {
303        let f = HttpFetcher::new();
304        assert_eq!(f.max_response_size, DEFAULT_MAX_RESPONSE_SIZE);
305    }
306
307    #[test]
308    fn default_trait_returns_same_as_new() {
309        let a = HttpFetcher::default();
310        let b = HttpFetcher::new();
311        assert_eq!(a.max_response_size, b.max_response_size);
312    }
313
314    #[test]
315    fn with_max_response_size_overrides() {
316        let f = HttpFetcher::new().with_max_response_size(123);
317        assert_eq!(f.max_response_size, 123);
318    }
319
320    #[test]
321    fn with_max_response_size_accepts_zero() {
322        // Degenerate but legal: a fetcher that rejects any non-empty
323        // body. Useful only for tests that want to assert the cap
324        // mechanism fires.
325        let f = HttpFetcher::new().with_max_response_size(0);
326        assert_eq!(f.max_response_size, 0);
327    }
328
329    #[test]
330    fn impls_aia_fetcher() {
331        // Compile-only: HttpFetcher must satisfy the AiaFetcher trait
332        // that pkix-chain's Verifier struct will hold via its third
333        // generic (PKIX-zkjb.9).
334        fn _accepts<F: AiaFetcher>(_: &F) {}
335        let f = HttpFetcher::new();
336        _accepts(&f);
337    }
338
339    #[test]
340    fn is_http_scheme_accepts_http_and_https() {
341        assert!(is_http_scheme("http://ca.example/intermediate.crt"));
342        assert!(is_http_scheme("https://ca.example/intermediate.crt"));
343        // RFC 3986: scheme is case-insensitive.
344        assert!(is_http_scheme("HTTP://ca.example/intermediate.crt"));
345        assert!(is_http_scheme("HTTPS://ca.example/intermediate.crt"));
346        assert!(is_http_scheme("HtTp://ca.example/intermediate.crt"));
347    }
348
349    #[test]
350    fn is_http_scheme_rejects_others() {
351        assert!(!is_http_scheme("ldap://ca.example/cn=ca"));
352        assert!(!is_http_scheme("ftp://ca.example/ca.crt"));
353        assert!(!is_http_scheme("file:///etc/ssl/ca.crt"));
354        assert!(!is_http_scheme(
355            "data:application/x-x509-ca-cert;base64,..."
356        ));
357        // Missing scheme entirely.
358        assert!(!is_http_scheme("ca.example/intermediate.crt"));
359        // Empty string.
360        assert!(!is_http_scheme(""));
361        // Bare colon.
362        assert!(!is_http_scheme(":"));
363    }
364
365    #[test]
366    fn fetch_rejects_non_http_scheme_without_network_io() {
367        let f = HttpFetcher::new();
368        // ldap:// must short-circuit to UriUnsupported before any
369        // network I/O happens. We do not need a live server for
370        // this assertion; the scheme check rejects synchronously.
371        let err = f.fetch("ldap://ca.example/cn=ca").unwrap_err();
372        match err {
373            AiaError::UriUnsupported(uri) => {
374                assert_eq!(uri, "ldap://ca.example/cn=ca");
375            }
376            other => panic!("expected UriUnsupported, got {other:?}"),
377        }
378    }
379}