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}