pkix_aia/lib.rs
1#![cfg_attr(not(feature = "std"), no_std)]
2#![cfg_attr(docsrs, feature(doc_cfg))]
3#![forbid(unsafe_code)]
4#![warn(missing_docs, rust_2018_idioms)]
5
6//! # pkix-aia
7//!
8//! Authority Information Access (AIA) fetcher trait and types for
9//! `pkix-chain`, per
10//! [RFC 5280 §4.2.2.1](https://www.rfc-editor.org/rfc/rfc5280#section-4.2.2.1).
11//!
12//! AIA is the extension that carries `caIssuers` URIs pointing at the
13//! certificate's issuer. Chain-build code can follow these URIs to
14//! fetch missing intermediate certificates when the caller-supplied
15//! chain is incomplete.
16//!
17//! This crate ships only the *trait surface*: the [`AiaError`] type
18//! (this release), the `AiaFetcher` trait (planned, tracked at
19//! `PKIX-zkjb.3`), and the `NoAiaFetcher` zero-cost default
20//! (planned, tracked at `PKIX-zkjb.4`). Real HTTP fetching lives in
21//! a separate adapter crate (`pkix-aia-http`, planned, tracked at
22//! `PKIX-zkjb.5`).
23//!
24//! ## Architectural placement
25//!
26//! ```text
27//! pkix-chain ----+------> pkix-aia (trait + error + no-op default)
28//! |
29//! +------> pkix-aia-http (real HTTP fetcher adapter)
30//! ```
31//!
32//! `pkix-chain`'s `Verifier` struct holds an `A: AiaFetcher` generic
33//! parameter that defaults to `NoAiaFetcher`. Callers who do not
34//! need AIA fetching see no API change; callers who do can plug in
35//! any `AiaFetcher` implementation, including HTTP adapters shipped
36//! by separate crates or in-process caching wrappers.
37//!
38//! ## `no_std` and feature flags
39//!
40//! The default build is `no_std + alloc`. Enabling the `std` feature
41//! unlocks the [`AiaError::IoFailure`] variant (whose
42//! `kind: std::io::ErrorKind` field requires `std::io`) and the
43//! `std::error::Error` impl. Enabling `serde` derives
44//! `serde::Serialize` / `serde::Deserialize` on [`AiaError`]; with
45//! both `std + serde` the `IoFailure` variant round-trips its
46//! `kind` field through a crate-private label helper.
47//!
48//! Per AGENTS.md non-negotiable #6, [`AiaError`] is
49//! `Clone + Debug + PartialEq + Eq + Send + Sync` (compile-time
50//! asserted) and is `#[non_exhaustive]`. No embedded `std::io::Error`
51//! handle (it is not `Clone + Eq + Serialize`); the variant uses the
52//! `IoFailure { kind, message }` shape mandated by PKIX-2l0v.1 D3.
53//!
54//! ## Status
55//!
56//! Initial release: [`AiaError`] + [`AiaFetcher`] + [`NoAiaFetcher`].
57//! The remaining work under the PKIX-zkjb epic integrates the trait
58//! into `pkix-chain::Verifier` (PKIX-zkjb.9) and ships the HTTP
59//! transport adapter `pkix-aia-http` (PKIX-zkjb.5).
60
61extern crate alloc;
62
63use alloc::string::String;
64use alloc::vec::Vec;
65
66// ---------------------------------------------------------------------------
67// AiaError
68// ---------------------------------------------------------------------------
69
70/// Failure modes for `AiaFetcher` implementations.
71///
72/// The trait surface returns `Result<Vec<u8>, AiaError>` for both the
73/// single-URI `fetch` path and the per-URI entries of a `batch_fetch`
74/// call (both methods land in `PKIX-zkjb.3`). A caller's chain-build
75/// layer translates a non-fatal `AiaError` into a chain-build
76/// failure — typically "could not retrieve missing intermediate" —
77/// and continues to the next candidate path if one is available.
78///
79/// The variant set is `#[non_exhaustive]` so future adapters can
80/// surface additional error categories (DNS resolution, TLS
81/// validation of the AIA endpoint itself, etc.) without breaking
82/// downstream pattern matches.
83///
84/// # Invariants (AGENTS.md non-negotiable #6)
85///
86/// - `Clone + Debug + PartialEq + Eq` — `derive`d.
87/// - `Send + Sync` — auto-derived; compile-time asserted at the
88/// bottom of this module.
89/// - No embedded `std::io::Error`. Transport-level I/O failures
90/// surface through the [`AiaError::IoFailure`] variant whose
91/// `kind: std::io::ErrorKind` plus owned `message: String`
92/// capture the relevant information in a `Clone + Eq + Serialize`
93/// shape.
94/// - `#[non_exhaustive]`.
95/// - Behind the `serde` feature: `Serialize + Deserialize`.
96///
97/// # Variants and adapter semantics
98///
99/// | Variant | When |
100/// |---------|------|
101/// | [`FetchingDisabled`](Self::FetchingDisabled) | `NoAiaFetcher` (PKIX-zkjb.4) and any fetcher that has been wired in but is intentionally off. |
102/// | [`HttpStatus`](Self::HttpStatus) | 4xx/5xx after redirects are followed; 3xx never surfaces. Carries the numeric status. |
103/// | [`ResponseTooLarge`](Self::ResponseTooLarge) | Caller-side size cap exceeded. Carries the configured `limit` and the observed `actual` byte count. |
104/// | [`MalformedCertificate`](Self::MalformedCertificate) | Fetched bytes did not parse as a DER X.509 [`Certificate`]. Caller-provided diagnostic in the inner `String`. |
105/// | [`Timeout`](Self::Timeout) | Fetch did not complete within the adapter's configured deadline. |
106/// | [`UriUnsupported`](Self::UriUnsupported) | A `caIssuers` URI used a scheme the fetcher does not handle (e.g. `ldap://` against an HTTP-only fetcher). Carries the full offending URI. |
107/// | [`IoFailure`](Self::IoFailure) | (requires `std`) Lower-level transport error from the I/O substrate. `kind` is the `std::io::ErrorKind`; `message` is a human-readable description. |
108///
109/// [`Certificate`]: https://docs.rs/x509-cert/latest/x509_cert/struct.Certificate.html
110#[derive(Clone, Debug, PartialEq, Eq)]
111#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
112#[non_exhaustive]
113pub enum AiaError {
114 /// Fetching is intentionally disabled at this layer.
115 ///
116 /// Emitted by `NoAiaFetcher` (PKIX-zkjb.4) and by any fetcher
117 /// that has been wired in but is intentionally off (for example,
118 /// a kill-switch configuration). The chain-build layer treats
119 /// this as "no AIA available; rely on the caller-supplied
120 /// chain".
121 FetchingDisabled,
122
123 /// The remote endpoint responded with a non-success HTTP status.
124 ///
125 /// Carries the numeric status code (e.g. `404`, `503`). Fired for
126 /// 4xx and 5xx responses; 3xx redirects are followed transparently
127 /// by the HTTP backend (e.g. `ureq`) and never surface here. A 2xx
128 /// response is treated as success regardless of `Content-Type` —
129 /// the raw body bytes are returned without MIME validation.
130 ///
131 /// Tuple variant for ergonomic pattern matching:
132 ///
133 /// ```
134 /// # use pkix_aia::AiaError;
135 /// let e = AiaError::HttpStatus(404);
136 /// matches!(e, AiaError::HttpStatus(404));
137 /// ```
138 HttpStatus(u16),
139
140 /// The fetcher refused to load a response that exceeded its
141 /// configured size cap.
142 ///
143 /// Adapters MUST cap response size — accepting arbitrary-size
144 /// bytes from an untrusted endpoint is a denial-of-service
145 /// vector. The cap is adapter-side configuration; this variant
146 /// surfaces both the cap and the actual observed size so callers
147 /// can decide whether to raise the cap or treat the response as
148 /// hostile.
149 ResponseTooLarge {
150 /// Caller-side size limit, in bytes.
151 limit: usize,
152 /// Observed response size at the point the limit was
153 /// exceeded, in bytes.
154 actual: usize,
155 },
156
157 /// Fetched bytes did not parse as a DER-encoded X.509 certificate.
158 ///
159 /// The inner `String` is an adapter-side diagnostic suitable for
160 /// logging. Parsing the bytes is the chain-build layer's job;
161 /// when it fails, the adapter wraps the parse error into this
162 /// variant so the chain-build layer can either skip this URI or
163 /// surface a "no usable intermediate retrieved" failure.
164 MalformedCertificate(String),
165
166 /// The fetcher did not complete within its configured deadline.
167 ///
168 /// Unit variant — no diagnostic data beyond the variant tag.
169 /// Adapters that need to surface per-URI timing details can
170 /// extend the error type in their own adapter-specific result
171 /// shape; the workspace trait surface keeps the timeout
172 /// signal opaque.
173 Timeout,
174
175 /// A `caIssuers` URI used a scheme this fetcher cannot handle.
176 ///
177 /// The inner `String` contains the full offending URI as received
178 /// (e.g. `"ldap://ca.example.com/cn=ca"`), not just the scheme.
179 UriUnsupported(String),
180
181 /// Lower-level transport I/O failure.
182 ///
183 /// Requires the `std` feature: the `kind` field is
184 /// [`std::io::ErrorKind`], which is part of `std::io`. Real
185 /// network-fetching adapters (e.g. `pkix-aia-http`, planned)
186 /// all require `std` anyway, so `no_std` consumers — which can
187 /// only meaningfully use `NoAiaFetcher` — never see this
188 /// variant.
189 ///
190 /// The shape is `{ kind, message }` rather than
191 /// `std::io::Error` directly: `std::io::Error` is not
192 /// `Clone + PartialEq + Eq + Serialize`, which would block
193 /// AGENTS.md non-negotiable #6. The `os_error` numeric code is
194 /// not preserved; in practice the `kind` plus a free-form
195 /// human-readable `message` carries the same diagnostic value
196 /// for log consumers.
197 #[cfg(feature = "std")]
198 #[cfg_attr(docsrs, doc(cfg(feature = "std")))]
199 IoFailure {
200 /// I/O error category from `std::io`.
201 #[cfg_attr(feature = "serde", serde(with = "io_error_kind_serde"))]
202 kind: std::io::ErrorKind,
203 /// Free-form human-readable description; suitable for logs.
204 message: String,
205 },
206}
207
208// ---------------------------------------------------------------------------
209// Display
210// ---------------------------------------------------------------------------
211
212impl core::fmt::Display for AiaError {
213 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
214 match self {
215 Self::FetchingDisabled => f.write_str("AIA fetching is disabled"),
216 Self::HttpStatus(code) => write!(f, "AIA fetch returned HTTP status {code}"),
217 Self::ResponseTooLarge { limit, actual } => write!(
218 f,
219 "AIA response exceeded size cap: limit {limit} bytes, observed {actual} bytes",
220 ),
221 Self::MalformedCertificate(msg) => {
222 write!(f, "AIA-fetched bytes did not parse as a certificate: {msg}")
223 }
224 Self::Timeout => f.write_str("AIA fetch timed out"),
225 Self::UriUnsupported(uri) => write!(f, "AIA URI scheme not supported: {uri}"),
226 #[cfg(feature = "std")]
227 Self::IoFailure { kind, message } => {
228 write!(f, "AIA fetch I/O failure ({kind:?}): {message}")
229 }
230 }
231 }
232}
233
234#[cfg(feature = "std")]
235#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
236impl std::error::Error for AiaError {}
237
238// ---------------------------------------------------------------------------
239// io::ErrorKind serde label round-trip
240// ---------------------------------------------------------------------------
241
242/// `serde` round-trip helper for [`std::io::ErrorKind`].
243///
244/// Serializes as a string label (e.g. `"NotFound"`, `"TimedOut"`)
245/// drawn from the variant name via `Debug`. `ErrorKind`'s `Debug`
246/// impl emits the variant name as a bare identifier (`NotFound`,
247/// `HostUnreachable`, etc.) and is stable across stdlib releases.
248///
249/// Deserializes by matching the label against a static table of
250/// variants known at compile time; unknown labels resolve to
251/// `std::io::ErrorKind::Other`, which matches the way the standard
252/// library treats unrecognized OS-level errors.
253///
254/// `std::io::ErrorKind` is `#[non_exhaustive]` upstream. The
255/// serializer uses `Debug` formatting, so it automatically
256/// preserves the variant name for any `ErrorKind` variant the
257/// consumer's compiler knows about — including variants stabilized
258/// after the workspace MSRV (1.73). The deserializer's explicit
259/// table covers variants stable through 1.73; its `_ => Other`
260/// fallback handles forward-compat for newer variants whose labels
261/// appear in serialized data but whose enum constructors are
262/// unavailable at the MSRV floor.
263// NOTE: This module is duplicated in pkix-truststore.
264// Keep them in sync until a shared crate is extracted.
265#[cfg(all(feature = "std", feature = "serde"))]
266mod io_error_kind_serde {
267 use serde::{Deserialize, Deserializer, Serializer};
268 use std::io::ErrorKind;
269
270 pub(super) fn serialize<S>(kind: &ErrorKind, serializer: S) -> Result<S::Ok, S::Error>
271 where
272 S: Serializer,
273 {
274 use alloc::format;
275 // `ErrorKind`'s `Debug` impl emits the variant name as a bare
276 // identifier (e.g. "NotFound", "HostUnreachable"). This is
277 // stable across stdlib releases — variant names do not change —
278 // and it covers every variant the consumer's compiler knows
279 // about, including those added after the workspace MSRV.
280 let label = format!("{kind:?}");
281 serializer.serialize_str(&label)
282 }
283
284 pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<ErrorKind, D::Error>
285 where
286 D: Deserializer<'de>,
287 {
288 let s = <&str>::deserialize(deserializer)?;
289 Ok(kind_for(s))
290 }
291
292 /// Map a label back to an `ErrorKind`.
293 ///
294 /// Unknown labels resolve to `ErrorKind::Other`. This mirrors the
295 /// standard library's behavior when classifying OS errors it does
296 /// not recognize.
297 fn kind_for(label: &str) -> ErrorKind {
298 match label {
299 "NotFound" => ErrorKind::NotFound,
300 "PermissionDenied" => ErrorKind::PermissionDenied,
301 "ConnectionRefused" => ErrorKind::ConnectionRefused,
302 "ConnectionReset" => ErrorKind::ConnectionReset,
303 "ConnectionAborted" => ErrorKind::ConnectionAborted,
304 "NotConnected" => ErrorKind::NotConnected,
305 "AddrInUse" => ErrorKind::AddrInUse,
306 "AddrNotAvailable" => ErrorKind::AddrNotAvailable,
307 "BrokenPipe" => ErrorKind::BrokenPipe,
308 "AlreadyExists" => ErrorKind::AlreadyExists,
309 "WouldBlock" => ErrorKind::WouldBlock,
310 "InvalidInput" => ErrorKind::InvalidInput,
311 "InvalidData" => ErrorKind::InvalidData,
312 "TimedOut" => ErrorKind::TimedOut,
313 "WriteZero" => ErrorKind::WriteZero,
314 "Interrupted" => ErrorKind::Interrupted,
315 "Unsupported" => ErrorKind::Unsupported,
316 "UnexpectedEof" => ErrorKind::UnexpectedEof,
317 "OutOfMemory" => ErrorKind::OutOfMemory,
318 _ => ErrorKind::Other,
319 }
320 }
321
322 #[cfg(test)]
323 mod tests {
324 use super::*;
325 use alloc::format;
326
327 /// Verify that `Debug` formatting produces the expected label
328 /// for every variant stable through Rust 1.73 (the workspace
329 /// MSRV), and that `kind_for` maps each label back to the
330 /// original variant. This is the serialize/deserialize
331 /// round-trip contract.
332 #[test]
333 fn debug_label_round_trip_covers_msrv_variants() {
334 let cases: &[(ErrorKind, &str)] = &[
335 (ErrorKind::NotFound, "NotFound"),
336 (ErrorKind::PermissionDenied, "PermissionDenied"),
337 (ErrorKind::ConnectionRefused, "ConnectionRefused"),
338 (ErrorKind::ConnectionReset, "ConnectionReset"),
339 (ErrorKind::ConnectionAborted, "ConnectionAborted"),
340 (ErrorKind::NotConnected, "NotConnected"),
341 (ErrorKind::AddrInUse, "AddrInUse"),
342 (ErrorKind::AddrNotAvailable, "AddrNotAvailable"),
343 (ErrorKind::BrokenPipe, "BrokenPipe"),
344 (ErrorKind::AlreadyExists, "AlreadyExists"),
345 (ErrorKind::WouldBlock, "WouldBlock"),
346 (ErrorKind::InvalidInput, "InvalidInput"),
347 (ErrorKind::InvalidData, "InvalidData"),
348 (ErrorKind::TimedOut, "TimedOut"),
349 (ErrorKind::WriteZero, "WriteZero"),
350 (ErrorKind::Interrupted, "Interrupted"),
351 (ErrorKind::Unsupported, "Unsupported"),
352 (ErrorKind::UnexpectedEof, "UnexpectedEof"),
353 (ErrorKind::OutOfMemory, "OutOfMemory"),
354 (ErrorKind::Other, "Other"),
355 ];
356 for (kind, expected_label) in cases {
357 // Serialize side: Debug formatting must produce the
358 // expected label.
359 let debug_label = format!("{kind:?}");
360 assert_eq!(
361 debug_label, *expected_label,
362 "Debug format for {kind:?}"
363 );
364 // Deserialize side: kind_for must recover the
365 // original variant.
366 assert_eq!(
367 kind_for(expected_label),
368 *kind,
369 "kind_for({expected_label:?})"
370 );
371 }
372 }
373
374 #[test]
375 fn unknown_label_resolves_to_other() {
376 // Forward-compat contract: any label we do not recognize
377 // resolves to `ErrorKind::Other`. The deserializer must
378 // not reject the input, otherwise upgrading stdlib
379 // versions (which can rename variants in `Debug`) would
380 // brick existing on-disk caches.
381 assert_eq!(kind_for("DefinitelyNotAVariant"), ErrorKind::Other);
382 assert_eq!(kind_for(""), ErrorKind::Other);
383 // Whitespace handling: we do not trim. Callers feeding
384 // mangled labels are out of contract; we still resolve
385 // to Other rather than panic.
386 assert_eq!(kind_for(" NotFound "), ErrorKind::Other);
387 }
388
389 /// Verify that post-MSRV variants (stabilized in 1.74+ via
390 /// `io_error_more`) serialize with their real variant name
391 /// through Debug formatting, not as "Other". This is the
392 /// core bug this fix addresses.
393 #[test]
394 fn post_msrv_variants_serialize_with_real_name() {
395 // These variants are available on the test compiler
396 // (1.95+) but not at the MSRV floor. The serialize path
397 // uses Debug formatting, so they get their real names.
398 // The deserialize path maps them to Other (graceful
399 // degradation), which is acceptable — the important
400 // thing is that the serialized form preserves the actual
401 // variant name on disk.
402 let post_msrv: &[(ErrorKind, &str)] = &[
403 (ErrorKind::HostUnreachable, "HostUnreachable"),
404 (ErrorKind::NetworkUnreachable, "NetworkUnreachable"),
405 (ErrorKind::NetworkDown, "NetworkDown"),
406 (ErrorKind::NotADirectory, "NotADirectory"),
407 (ErrorKind::IsADirectory, "IsADirectory"),
408 (ErrorKind::DirectoryNotEmpty, "DirectoryNotEmpty"),
409 (ErrorKind::ReadOnlyFilesystem, "ReadOnlyFilesystem"),
410 (ErrorKind::StaleNetworkFileHandle, "StaleNetworkFileHandle"),
411 (ErrorKind::StorageFull, "StorageFull"),
412 (ErrorKind::NotSeekable, "NotSeekable"),
413 (ErrorKind::FileTooLarge, "FileTooLarge"),
414 (ErrorKind::ResourceBusy, "ResourceBusy"),
415 (ErrorKind::ExecutableFileBusy, "ExecutableFileBusy"),
416 (ErrorKind::Deadlock, "Deadlock"),
417 (ErrorKind::CrossesDevices, "CrossesDevices"),
418 (ErrorKind::TooManyLinks, "TooManyLinks"),
419 (ErrorKind::InvalidFilename, "InvalidFilename"),
420 (ErrorKind::ArgumentListTooLong, "ArgumentListTooLong"),
421 ];
422 for (kind, expected_label) in post_msrv {
423 let debug_label = format!("{kind:?}");
424 assert_eq!(
425 debug_label, *expected_label,
426 "Debug format for post-MSRV {kind:?}"
427 );
428 // Deserialize falls back to Other — these labels are
429 // not in the kind_for table (MSRV constraint).
430 assert_eq!(
431 kind_for(expected_label),
432 ErrorKind::Other,
433 "kind_for({expected_label:?}) should gracefully degrade to Other"
434 );
435 }
436 }
437 }
438}
439
440// ---------------------------------------------------------------------------
441// AiaFetcher trait
442// ---------------------------------------------------------------------------
443
444/// Trait for fetching certificate DER bytes by URI.
445///
446/// `AiaFetcher` is the seam where chain-build code asks an adapter to
447/// resolve a `caIssuers` URI from
448/// [RFC 5280 §4.2.2.1](https://www.rfc-editor.org/rfc/rfc5280#section-4.2.2.1)
449/// into the DER-encoded bytes of the referenced certificate.
450///
451/// # Contract
452///
453/// The trait is intentionally minimal: a single required method,
454/// [`fetch`](Self::fetch), and a default-impl batch entry point,
455/// [`batch_fetch`](Self::batch_fetch), that loops to `fetch`.
456///
457/// - **`&self` receiver.** Fetchers take `&self`, not `&mut self`.
458/// This admits caching wrappers using interior mutability (e.g.
459/// `core::cell::RefCell` for single-threaded scenarios,
460/// `std::sync::Mutex` or `std::sync::RwLock` for shared concurrent
461/// access) without the trait surface having to acknowledge them.
462/// See the doctest below for a worked example.
463///
464/// - **Synchronous.** The core trait is synchronous. Async adapters
465/// live in separate crates (e.g. a future async variant in
466/// `pkix-aia-async`) and expose their own trait shape. Keeping
467/// the core trait sync means the workspace stays runtime-agnostic
468/// and avoids forcing every `pkix-chain` consumer to choose a
469/// runtime.
470///
471/// - **Raw bytes, not parsed certificates.** Returns
472/// `Result<Vec<u8>, AiaError>` of certificate DER. Parsing the
473/// bytes into an `x509_cert::Certificate` is the chain-build
474/// layer's job. The fetcher is a dumb-bytes-fetch surface; an
475/// adapter that knows how to parse can still parse internally,
476/// but the trait surface deliberately stays at the byte level so
477/// parser failures can be classified one way (the consumer's
478/// parse step) and transport failures another (this trait).
479///
480/// - **No timeout parameter.** Per-request timeouts are an adapter
481/// concern, configured at construction time on the concrete
482/// fetcher. The trait surface does not expose a deadline argument;
483/// adapters that need one set it via their own builder API.
484///
485/// - **`batch_fetch` may pipeline.** The default-impl is a sequential
486/// loop over `fetch`. Implementers MAY override `batch_fetch` to
487/// pipeline requests (HTTP/2 multiplexing, connection-pool
488/// parallelism, etc.) when doing so produces a real speedup.
489/// The return shape is `Vec<Result<_, _>>` aligned by index with
490/// `uris`: per-URI success/failure is preserved even when some
491/// subset of the batch fails.
492///
493/// # Thread-safety expectation
494///
495/// `AiaFetcher` does not require `Send + Sync` as super-traits; that
496/// would prevent legitimate single-threaded impls (e.g. `RefCell`-
497/// backed caches). Implementers SHOULD be `Send + Sync` whenever
498/// they can — chain-build code in `pkix-chain` calls fetchers
499/// through `&dyn AiaFetcher`, and trait objects stored in a
500/// `Verifier` shared across threads require `Send + Sync` to be
501/// useful in concurrent server code. When this is the intent,
502/// declare the bound at the use site (e.g. `&'a dyn AiaFetcher`
503/// with an inner type that is auto-`Send + Sync`, or
504/// `Arc<dyn AiaFetcher + Send + Sync>` for owned trait objects).
505///
506/// # Errors
507///
508/// All failure modes surface through [`AiaError`]. See its
509/// per-variant rustdoc for adapter semantics.
510///
511/// # Example: caching wrapper
512///
513/// The `&self` receiver admits cache layering without any change
514/// to the trait surface. The example below wraps any
515/// [`AiaFetcher`] in a memoizing cache keyed by URI. It uses
516/// `alloc::collections::BTreeMap` and `core::cell::RefCell` for
517/// portability across `no_std + alloc` targets; production code
518/// targeting `std` should prefer `std::sync::Mutex<HashMap<_, _>>`
519/// (which is `Sync`) for shared concurrent access.
520///
521/// ```
522/// extern crate alloc;
523///
524/// use alloc::collections::BTreeMap;
525/// use alloc::string::{String, ToString};
526/// use alloc::vec::Vec;
527/// use core::cell::RefCell;
528///
529/// use pkix_aia::{AiaError, AiaFetcher};
530///
531/// /// Caching wrapper over any [`AiaFetcher`]. URIs already in the
532/// /// cache return the stored result without delegating to the
533/// /// inner fetcher. The inner result — success or failure — is
534/// /// what gets cached; the wrapper treats every `AiaError` as
535/// /// "the inner fetcher said no for this URI" and records it,
536/// /// which is appropriate for callers who don't want to retry
537/// /// fast-failing URIs.
538/// pub struct CachingFetcher<F: AiaFetcher> {
539/// inner: F,
540/// cache: RefCell<BTreeMap<String, Result<Vec<u8>, AiaError>>>,
541/// }
542///
543/// impl<F: AiaFetcher> CachingFetcher<F> {
544/// pub fn new(inner: F) -> Self {
545/// Self { inner, cache: RefCell::new(BTreeMap::new()) }
546/// }
547/// }
548///
549/// impl<F: AiaFetcher> AiaFetcher for CachingFetcher<F> {
550/// fn fetch(&self, uri: &str) -> Result<Vec<u8>, AiaError> {
551/// // Interior mutability via `RefCell` keeps `fetch` on
552/// // `&self`. The trait surface never sees the borrow.
553/// if let Some(cached) = self.cache.borrow().get(uri) {
554/// return cached.clone();
555/// }
556/// let fresh = self.inner.fetch(uri);
557/// self.cache.borrow_mut().insert(uri.to_string(), fresh.clone());
558/// fresh
559/// }
560/// }
561///
562/// // Demonstration: a stub inner fetcher and a single cached call.
563/// struct AlwaysDisabled;
564/// impl AiaFetcher for AlwaysDisabled {
565/// fn fetch(&self, _uri: &str) -> Result<Vec<u8>, AiaError> {
566/// Err(AiaError::FetchingDisabled)
567/// }
568/// }
569///
570/// let cache = CachingFetcher::new(AlwaysDisabled);
571/// // First call: delegates to the inner fetcher and records the result.
572/// assert_eq!(cache.fetch("http://ca.example/ca.crt"),
573/// Err(AiaError::FetchingDisabled));
574/// // Second call: returns the cached failure, no inner delegation.
575/// assert_eq!(cache.fetch("http://ca.example/ca.crt"),
576/// Err(AiaError::FetchingDisabled));
577/// ```
578pub trait AiaFetcher {
579 /// Fetch the DER-encoded certificate at `uri`.
580 ///
581 /// Returns the raw response bytes on success. Parsing them as
582 /// an X.509 certificate is the caller's job.
583 ///
584 /// # Errors
585 ///
586 /// All failure modes surface through [`AiaError`]:
587 ///
588 /// - [`AiaError::FetchingDisabled`] for fetchers that are
589 /// intentionally off (e.g. `NoAiaFetcher`, planned at
590 /// PKIX-zkjb.4).
591 /// - [`AiaError::UriUnsupported`] for URIs whose scheme this
592 /// fetcher does not handle.
593 /// - [`AiaError::HttpStatus`], [`AiaError::Timeout`],
594 /// [`AiaError::ResponseTooLarge`], or
595 /// [`AiaError::IoFailure`] (under `std`) for transport-level
596 /// issues.
597 /// - [`AiaError::MalformedCertificate`] if the adapter
598 /// pre-parsed the response and the bytes do not look like a
599 /// DER certificate. Adapters that return the bytes verbatim
600 /// leave that classification to the caller.
601 fn fetch(&self, uri: &str) -> Result<Vec<u8>, AiaError>;
602
603 /// Fetch multiple URIs in a single call.
604 ///
605 /// Returns a `Vec` aligned with `uris` by index: the `i`-th
606 /// entry is the result for `uris[i]`. Per-URI success or
607 /// failure is preserved.
608 ///
609 /// The default-impl is a sequential loop over [`fetch`](Self::fetch).
610 /// Implementers MAY override this to pipeline requests
611 /// (HTTP/2 multiplexing, connection-pool parallelism, etc.)
612 /// when their transport supports it; the loop is the floor,
613 /// not the ceiling.
614 ///
615 /// Adapters that fail the entire batch on the first error
616 /// SHOULD instead return `Err(...)` in the relevant slot and
617 /// continue processing remaining URIs. Whole-batch atomic
618 /// failure is not a contract anyone relies on; per-URI errors
619 /// are what callers act on.
620 fn batch_fetch(&self, uris: &[&str]) -> Vec<Result<Vec<u8>, AiaError>> {
621 uris.iter().map(|uri| self.fetch(uri)).collect()
622 }
623}
624
625// ---------------------------------------------------------------------------
626// NoAiaFetcher — zero-cost default
627// ---------------------------------------------------------------------------
628
629/// Zero-cost [`AiaFetcher`] default that never fetches.
630///
631/// Every call to [`fetch`](Self::fetch) returns
632/// [`AiaError::FetchingDisabled`]; [`batch_fetch`](AiaFetcher::batch_fetch)
633/// returns a `Vec` of the same error, one per input URI. Never
634/// panics; performs no I/O; performs no allocation beyond what the
635/// `Err` discriminant requires (the [`AiaError::FetchingDisabled`]
636/// variant carries no payload).
637///
638/// `NoAiaFetcher` is the default placeholder in
639/// `pkix-chain::Verifier<'a, V, R, A = NoAiaFetcher>` (PKIX-zkjb.9,
640/// planned). Callers who do not want AIA fetching wired up — the
641/// historical "caller supplies the complete chain" semantics — can
642/// use it directly, and consumers who later opt into real fetching
643/// simply pass a different `A: AiaFetcher` impl.
644///
645/// # Example
646///
647/// ```
648/// use pkix_aia::{AiaError, AiaFetcher, NoAiaFetcher};
649///
650/// let fetcher = NoAiaFetcher;
651///
652/// // Single fetch returns `FetchingDisabled` regardless of URI.
653/// assert_eq!(
654/// fetcher.fetch("http://ca.example/intermediate.crt"),
655/// Err(AiaError::FetchingDisabled),
656/// );
657///
658/// // Batch returns one `FetchingDisabled` per URI in input order.
659/// let batch = fetcher.batch_fetch(&[
660/// "http://ca.example/a.crt",
661/// "http://ca.example/b.crt",
662/// ]);
663/// assert_eq!(batch.len(), 2);
664/// assert!(batch.iter().all(|r| matches!(r, Err(AiaError::FetchingDisabled))));
665/// ```
666///
667/// # Why `Copy` is intentional
668///
669/// `NoAiaFetcher` is a zero-sized type with a `Copy + Clone` derive.
670/// Callers can pass it by value to APIs that take ownership without
671/// thinking about ownership semantics. The zero-sized struct
672/// compiles down to nothing; there is no monomorphization or
673/// runtime cost beyond the `Err` discriminant write.
674#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
675pub struct NoAiaFetcher;
676
677impl AiaFetcher for NoAiaFetcher {
678 fn fetch(&self, _uri: &str) -> Result<Vec<u8>, AiaError> {
679 Err(AiaError::FetchingDisabled)
680 }
681 // `batch_fetch` deliberately uses the trait's default-impl.
682 // Overriding to short-circuit (return a single allocation of N
683 // identical `FetchingDisabled` errors without N function calls)
684 // is not worth the extra surface area: the default-impl already
685 // produces the correct shape and the cost is one tiny Err per
686 // URI, not a real workload.
687}
688
689// ---------------------------------------------------------------------------
690// Send + Sync invariant (AGENTS.md non-negotiable #6 / PKIX-2l0v.2)
691// ---------------------------------------------------------------------------
692
693// Compile-time assertion that load-bearing types are `Send + Sync`.
694// A future field that breaks this invariant (e.g. an `Rc<T>` or
695// raw-pointer field on `AiaError`) fails the workspace build
696// immediately, not a runtime test. Pattern is the workspace
697// standard recorded in memory
698// `send-sync-invariant-in-pkix-workspace-pkix-2l0v`.
699const _: fn() = || {
700 fn _assert_send_sync<T: Send + Sync>() {}
701 _assert_send_sync::<AiaError>();
702 _assert_send_sync::<NoAiaFetcher>();
703};
704
705// ---------------------------------------------------------------------------
706// Inline tests
707// ---------------------------------------------------------------------------
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712 #[cfg(feature = "std")]
713 use alloc::format;
714 use alloc::string::ToString;
715
716 #[test]
717 fn display_fetching_disabled() {
718 assert_eq!(
719 AiaError::FetchingDisabled.to_string(),
720 "AIA fetching is disabled"
721 );
722 }
723
724 #[test]
725 fn display_http_status() {
726 assert_eq!(
727 AiaError::HttpStatus(503).to_string(),
728 "AIA fetch returned HTTP status 503"
729 );
730 }
731
732 #[test]
733 fn display_response_too_large() {
734 assert_eq!(
735 AiaError::ResponseTooLarge {
736 limit: 65_536,
737 actual: 131_072,
738 }
739 .to_string(),
740 "AIA response exceeded size cap: limit 65536 bytes, observed 131072 bytes",
741 );
742 }
743
744 #[test]
745 fn display_malformed_certificate() {
746 assert_eq!(
747 AiaError::MalformedCertificate("expected SEQUENCE got SET".into()).to_string(),
748 "AIA-fetched bytes did not parse as a certificate: expected SEQUENCE got SET",
749 );
750 }
751
752 #[test]
753 fn display_timeout() {
754 assert_eq!(AiaError::Timeout.to_string(), "AIA fetch timed out");
755 }
756
757 #[test]
758 fn display_uri_unsupported() {
759 assert_eq!(
760 AiaError::UriUnsupported("ldap://ca.example.com".into()).to_string(),
761 "AIA URI scheme not supported: ldap://ca.example.com",
762 );
763 }
764
765 #[test]
766 #[cfg(feature = "std")]
767 fn display_io_failure() {
768 let e = AiaError::IoFailure {
769 kind: std::io::ErrorKind::ConnectionRefused,
770 message: "connection refused by 10.0.0.1:443".into(),
771 };
772 // Use `{kind:?}` in the impl, so the rendered label is the
773 // Debug spelling of the variant. Pin the exact format so a
774 // stdlib change to ErrorKind::Debug surfaces here, not in
775 // downstream log scrapers.
776 assert_eq!(
777 format!("{e}"),
778 "AIA fetch I/O failure (ConnectionRefused): connection refused by 10.0.0.1:443",
779 );
780 }
781
782 #[test]
783 fn clone_and_eq_unit_variants() {
784 assert_eq!(
785 AiaError::FetchingDisabled,
786 AiaError::FetchingDisabled.clone()
787 );
788 assert_eq!(AiaError::Timeout, AiaError::Timeout.clone());
789 }
790
791 #[test]
792 fn clone_and_eq_carrying_variants() {
793 let a = AiaError::HttpStatus(404);
794 assert_eq!(a, a.clone());
795 let b = AiaError::ResponseTooLarge {
796 limit: 1024,
797 actual: 2048,
798 };
799 assert_eq!(b, b.clone());
800 let c = AiaError::MalformedCertificate("parse error at offset 7".into());
801 assert_eq!(c, c.clone());
802 let d = AiaError::UriUnsupported("ldap".into());
803 assert_eq!(d, d.clone());
804 }
805
806 #[test]
807 fn distinct_variants_are_not_equal() {
808 assert_ne!(AiaError::FetchingDisabled, AiaError::Timeout);
809 assert_ne!(AiaError::HttpStatus(404), AiaError::HttpStatus(503));
810 assert_ne!(
811 AiaError::UriUnsupported("ldap".into()),
812 AiaError::UriUnsupported("file".into()),
813 );
814 }
815
816 #[test]
817 #[cfg(feature = "std")]
818 fn io_failure_clone_and_eq() {
819 let a = AiaError::IoFailure {
820 kind: std::io::ErrorKind::TimedOut,
821 message: "deadline exceeded".into(),
822 };
823 assert_eq!(a, a.clone());
824 let b = AiaError::IoFailure {
825 kind: std::io::ErrorKind::TimedOut,
826 message: "different message".into(),
827 };
828 assert_ne!(a, b);
829 let c = AiaError::IoFailure {
830 kind: std::io::ErrorKind::NotFound,
831 message: "deadline exceeded".into(),
832 };
833 assert_ne!(a, c);
834 }
835
836 // -----------------------------------------------------------------------
837 // AiaFetcher trait
838 // -----------------------------------------------------------------------
839
840 use alloc::vec;
841 use core::cell::Cell;
842
843 /// Test-only fetcher that records every URI it is asked about
844 /// and returns a deterministic per-URI result. Demonstrates
845 /// that `&self` is sufficient for non-trivial impls.
846 struct RecordingFetcher {
847 /// Increments on every call to `fetch`. Demonstrates &self
848 /// interior mutability via `Cell` without a Mutex.
849 call_count: Cell<usize>,
850 }
851
852 impl RecordingFetcher {
853 fn new() -> Self {
854 Self {
855 call_count: Cell::new(0),
856 }
857 }
858 }
859
860 impl AiaFetcher for RecordingFetcher {
861 fn fetch(&self, uri: &str) -> Result<Vec<u8>, AiaError> {
862 self.call_count.set(self.call_count.get() + 1);
863 // Echo the URI bytes back as the "DER" — not a real
864 // certificate, but the trait surface is byte-level so
865 // any deterministic mapping is sufficient for behavioural
866 // tests.
867 if uri.starts_with("http://") || uri.starts_with("https://") {
868 Ok(uri.as_bytes().to_vec())
869 } else {
870 Err(AiaError::UriUnsupported(uri.into()))
871 }
872 }
873 }
874
875 #[test]
876 fn fetch_records_each_call() {
877 let f = RecordingFetcher::new();
878 let r = f.fetch("http://ca.example/ca.crt").expect("ok");
879 assert_eq!(r, b"http://ca.example/ca.crt".to_vec());
880 assert_eq!(f.call_count.get(), 1);
881
882 let _ = f.fetch("http://ca.example/ca.crt");
883 let _ = f.fetch("http://ca.example/ca.crt");
884 assert_eq!(f.call_count.get(), 3);
885 }
886
887 #[test]
888 fn fetch_classifies_unsupported_scheme() {
889 let f = RecordingFetcher::new();
890 let r = f.fetch("ldap://ca.example/cn=ca");
891 assert_eq!(
892 r,
893 Err(AiaError::UriUnsupported("ldap://ca.example/cn=ca".into())),
894 );
895 assert_eq!(f.call_count.get(), 1);
896 }
897
898 #[test]
899 fn batch_fetch_default_impl_iterates_each_uri() {
900 let f = RecordingFetcher::new();
901 let uris: &[&str] = &[
902 "http://ca.example/a.crt",
903 "ldap://ca.example/b",
904 "https://ca.example/c.crt",
905 ];
906 let results = f.batch_fetch(uris);
907 assert_eq!(results.len(), 3);
908 assert_eq!(results[0], Ok(b"http://ca.example/a.crt".to_vec()));
909 assert_eq!(
910 results[1],
911 Err(AiaError::UriUnsupported("ldap://ca.example/b".into())),
912 );
913 assert_eq!(results[2], Ok(b"https://ca.example/c.crt".to_vec()));
914 // Default-impl is sequential: one call per URI.
915 assert_eq!(f.call_count.get(), 3);
916 }
917
918 #[test]
919 fn batch_fetch_empty_input_returns_empty_output() {
920 let f = RecordingFetcher::new();
921 let empty: &[&str] = &[];
922 let results = f.batch_fetch(empty);
923 assert!(results.is_empty());
924 // No calls should have happened.
925 assert_eq!(f.call_count.get(), 0);
926 }
927
928 #[test]
929 fn batch_fetch_preserves_order() {
930 // Per-index alignment is part of the trait contract. A
931 // pipelining override MUST preserve this ordering.
932 let f = RecordingFetcher::new();
933 let uris: &[&str] = &["http://a", "http://b", "http://c"];
934 let results = f.batch_fetch(uris);
935 let expected = vec![
936 Ok(b"http://a".to_vec()),
937 Ok(b"http://b".to_vec()),
938 Ok(b"http://c".to_vec()),
939 ];
940 assert_eq!(results, expected);
941 }
942
943 /// A trivially-overridden `batch_fetch` that records its
944 /// invocation count. Verifies overrides take precedence over
945 /// the default-impl.
946 struct OverriddenBatchFetcher {
947 batch_calls: Cell<usize>,
948 }
949
950 impl AiaFetcher for OverriddenBatchFetcher {
951 fn fetch(&self, _uri: &str) -> Result<Vec<u8>, AiaError> {
952 // Should never be called by tests below; if it is, the
953 // override didn't dispatch.
954 unreachable!("override should not delegate to fetch")
955 }
956
957 fn batch_fetch(&self, uris: &[&str]) -> Vec<Result<Vec<u8>, AiaError>> {
958 self.batch_calls.set(self.batch_calls.get() + 1);
959 uris.iter().map(|_| Err(AiaError::Timeout)).collect()
960 }
961 }
962
963 #[test]
964 fn batch_fetch_override_takes_precedence() {
965 let f = OverriddenBatchFetcher {
966 batch_calls: Cell::new(0),
967 };
968 let results = f.batch_fetch(&["http://a", "http://b"]);
969 assert_eq!(results.len(), 2);
970 assert_eq!(results[0], Err(AiaError::Timeout));
971 assert_eq!(results[1], Err(AiaError::Timeout));
972 assert_eq!(f.batch_calls.get(), 1);
973 }
974
975 // -----------------------------------------------------------------------
976 // NoAiaFetcher
977 // -----------------------------------------------------------------------
978
979 #[test]
980 fn no_aia_fetcher_fetch_returns_fetching_disabled_for_any_uri() {
981 let f = NoAiaFetcher;
982 // Every URI shape resolves to the same FetchingDisabled
983 // error: HTTP, HTTPS, schemes the trait understands, schemes
984 // it doesn't, and the empty string.
985 for uri in [
986 "http://ca.example/ca.crt",
987 "https://ca.example/ca.crt",
988 "ldap://ca.example/cn=ca",
989 "file:///etc/ssl/ca.pem",
990 "",
991 ] {
992 assert_eq!(
993 f.fetch(uri),
994 Err(AiaError::FetchingDisabled),
995 "fetch({uri:?})",
996 );
997 }
998 }
999
1000 #[test]
1001 fn no_aia_fetcher_batch_fetch_returns_fetching_disabled_per_uri() {
1002 let f = NoAiaFetcher;
1003 let uris: &[&str] = &[
1004 "http://ca.example/a.crt",
1005 "http://ca.example/b.crt",
1006 "http://ca.example/c.crt",
1007 ];
1008 let results = f.batch_fetch(uris);
1009 assert_eq!(results.len(), 3);
1010 for (i, result) in results.iter().enumerate() {
1011 assert_eq!(
1012 *result,
1013 Err(AiaError::FetchingDisabled),
1014 "batch_fetch index {i}",
1015 );
1016 }
1017 }
1018
1019 #[test]
1020 fn no_aia_fetcher_batch_fetch_empty_input() {
1021 let f = NoAiaFetcher;
1022 let empty: &[&str] = &[];
1023 let results = f.batch_fetch(empty);
1024 assert!(results.is_empty());
1025 }
1026
1027 #[test]
1028 fn no_aia_fetcher_is_zero_sized() {
1029 // Zero-sized type contract is part of the rustdoc; pin it
1030 // as a compile-time invariant. Any future field added to
1031 // `NoAiaFetcher` (even a `PhantomData`-with-bound) fails
1032 // this test.
1033 assert_eq!(core::mem::size_of::<NoAiaFetcher>(), 0);
1034 }
1035
1036 #[test]
1037 fn no_aia_fetcher_derives_default() {
1038 // `Default` is part of the surface — callers using
1039 // `Default::default()` in generic contexts (e.g. an
1040 // `A: AiaFetcher + Default` bound) get the same zero-cost
1041 // unit value.
1042 let f: NoAiaFetcher = Default::default();
1043 assert_eq!(f.fetch("http://x"), Err(AiaError::FetchingDisabled));
1044 }
1045
1046 #[test]
1047 fn no_aia_fetcher_is_copy() {
1048 // `Copy`: callers can pass `NoAiaFetcher` by value without
1049 // ownership friction. `Copy` implies `Clone`, so both
1050 // derives are exercised here. Both produce identical
1051 // behaviour because the type is zero-sized.
1052 let a = NoAiaFetcher;
1053 let b = a;
1054 // After the move-by-copy above, `a` is still usable —
1055 // that's the Copy semantics this test pins.
1056 assert_eq!(a.fetch("http://x"), Err(AiaError::FetchingDisabled));
1057 assert_eq!(b.fetch("http://x"), Err(AiaError::FetchingDisabled));
1058 }
1059}