Skip to main content

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}