Skip to main content

jmap_base_client/
auth.rs

1//! Auth traits and credential implementations for JMAP clients.
2//!
3//! Provides [`TransportConfig`] (TLS/HTTP client construction) and
4//! [`AuthProvider`] (per-request credential injection), plus built-in
5//! implementations: [`DefaultTransport`], [`CustomCaTransport`],
6//! [`NoneAuth`], [`BearerAuth`], and [`BasicAuth`].
7
8use std::sync::Arc;
9
10use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
11use base64::Engine as _;
12use reqwest::header::HeaderValue;
13use zeroize::Zeroizing;
14
15use crate::error::ClientError;
16
17// ---------------------------------------------------------------------------
18// TransportConfig — HTTP client construction (TLS, timeouts, trust roots)
19// ---------------------------------------------------------------------------
20
21/// Opaque HTTP client returned by [`TransportConfig::build_client`]
22/// (bd:JMAP-6r7c.36).
23///
24/// The inner third-party type is private; the wrapper exists so the JMAP
25/// transport identity does not leak through the public trait signature.
26/// A future swap of the underlying HTTP library (e.g. `ureq`, `hyper-util`
27/// directly, `curl`) replaces the wrapped type without breaking any
28/// downstream extension client or custom `TransportConfig` impl that
29/// returns `Result<HttpClient, ClientError>` from `build_client`.
30///
31/// Custom transports construct via [`HttpClient::new`] — that signature
32/// still references [`reqwest::Client`] (the only construction path the
33/// kit knows how to make HTTP requests against). The partial-wrap
34/// argument mirrors [`ParseError`](crate::error::ParseError) /
35/// [`SerializeError`](crate::error::SerializeError): the variant
36/// payload / return type is opaque, but the construction signature still
37/// names the third-party type so callers have a way in. A future
38/// transport swap would deprecate this constructor in favor of an
39/// analogous one for the new HTTP client; the wrapper type itself
40/// stays stable.
41#[non_exhaustive]
42pub struct HttpClient(reqwest::Client);
43
44impl HttpClient {
45    /// Wrap a [`reqwest::Client`] into an opaque [`HttpClient`].
46    ///
47    /// Custom [`TransportConfig`] impls use this constructor to wrap a
48    /// reqwest client they built with their own TLS / proxy / timeout
49    /// configuration:
50    ///
51    /// ```rust,ignore
52    /// impl TransportConfig for MyCustomTransport {
53    ///     fn build_client(&self) -> Result<HttpClient, ClientError> {
54    ///         let client = reqwest::ClientBuilder::new()
55    ///             .proxy(...)
56    ///             .build()
57    ///             .map_err(ClientError::from_reqwest)?;
58    ///         Ok(HttpClient::new(client))
59    ///     }
60    /// }
61    /// ```
62    pub fn new(client: reqwest::Client) -> Self {
63        Self(client)
64    }
65
66    /// Consume the wrapper and return the inner [`reqwest::Client`].
67    ///
68    /// `pub(crate)` so only this crate's [`JmapClient`](crate::JmapClient)
69    /// construction path can unwrap — external code cannot reach inside
70    /// the opaque wrapper. A future swap of the HTTP transport would
71    /// change the return type here without affecting external callers
72    /// (who only see the typed `Result<HttpClient, _>` from
73    /// [`TransportConfig::build_client`]).
74    pub(crate) fn into_inner(self) -> reqwest::Client {
75        self.0
76    }
77}
78
79impl std::fmt::Debug for HttpClient {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.debug_tuple("HttpClient").finish()
82    }
83}
84
85/// Controls how the underlying [`HttpClient`] is constructed.
86///
87/// Implementations configure TLS trust roots, client certificates, and
88/// connect timeouts. This is separate from credential injection
89/// (see [`AuthProvider`]) so transports and credentials compose freely.
90///
91/// **Implement this trait** when you need custom TLS logic (e.g. a private CA
92/// or a client certificate).  For custom per-request credentials only,
93/// implement [`AuthProvider`] instead.  [`DefaultTransport`] covers the common
94/// case of publicly-trusted TLS with no custom certificates.
95///
96/// **Return type contract (bd:JMAP-6r7c.36).** `build_client` returns an
97/// opaque [`HttpClient`] wrapper, not a bare [`reqwest::Client`]. Custom
98/// impls construct via [`HttpClient::new`] after building their reqwest
99/// client; the wrapper insulates the trait's public surface from a
100/// future HTTP-transport swap.
101///
102/// **Maintainer note (bd:JMAP-6lsm.19):** if you add a new method to this
103/// trait, update the manual blanket impl for `Box<dyn TransportConfig>` at
104/// the bottom of this file. The crate ships a hand-written forwarding impl
105/// for the boxed trait object so callers can store heterogeneous transport
106/// configurations behind a single type. Adding a method here without
107/// mirroring it on the blanket impl silently breaks the
108/// `JmapClient::new(Box::<dyn TransportConfig>::new(...))` call shape.
109pub trait TransportConfig: Send + Sync {
110    /// Build the [`HttpClient`] for this transport configuration.
111    fn build_client(&self) -> Result<HttpClient, ClientError>;
112}
113
114/// Standard reqwest client with a 10-second connect timeout; no custom TLS.
115///
116/// Use for servers with publicly-trusted certificates. Pair with any
117/// [`AuthProvider`] for credential injection.
118#[derive(Debug, Clone)]
119pub struct DefaultTransport;
120
121impl TransportConfig for DefaultTransport {
122    fn build_client(&self) -> Result<HttpClient, ClientError> {
123        default_reqwest_client().map(HttpClient::new)
124    }
125}
126
127/// Custom CA trust root (DER-encoded). No `Authorization` header is injected.
128///
129/// Use when the server presents a certificate signed by a private CA.
130/// Pair with any [`AuthProvider`] for credential injection — including
131/// [`BearerAuth`] or [`BasicAuth`] if the server also requires credentials.
132///
133/// # Trust scope (bd:JMAP-6r7c.57)
134///
135/// **The bundled public webpki-roots are DISABLED in the constructed
136/// reqwest client.** This type is intended for private-CA pinning —
137/// connecting to a JMAP server identified by a private CA the operator
138/// controls, *refusing* certificates signed by any public CA. That is
139/// the threat model where this transport matters: a corporate internal
140/// JMAP server, a service-mesh deployment, an air-gapped network. A
141/// compromised or malicious public CA (DigiNotar 2011, Symantec 2017,
142/// etc.) issuing a certificate for the target host name would otherwise
143/// bypass the private-CA defense entirely; disabling the public roots
144/// closes that gap.
145///
146/// If you want trust against BOTH the bundled public roots AND a custom
147/// CA (a "hybrid" deployment), `CustomCaTransport` is the wrong tool —
148/// implement [`TransportConfig`] directly with the additive behaviour
149/// (`reqwest::ClientBuilder::add_root_certificate` does NOT call
150/// `.tls_built_in_root_certs(false)` by default, so a hand-rolled impl
151/// has the additive shape automatically).
152#[derive(Clone)]
153pub struct CustomCaTransport {
154    der_cert: Vec<u8>,
155}
156
157impl CustomCaTransport {
158    /// Construct a `CustomCaTransport` from a DER-encoded CA certificate.
159    pub fn new(der_cert: Vec<u8>) -> Self {
160        Self { der_cert }
161    }
162
163    /// Construct a `CustomCaTransport` from a PEM-encoded CA certificate
164    /// (bd:JMAP-6r7c.37).
165    ///
166    /// Operators typically distribute private-CA certificates as PEM
167    /// files (text-format, `-----BEGIN CERTIFICATE-----` framing).
168    /// Without this helper, every caller has to convert PEM to DER
169    /// themselves before passing to [`CustomCaTransport::new`]:
170    ///
171    /// ```rust,ignore
172    /// // Without from_pem_bytes (the long way):
173    /// let pem_bytes = std::fs::read("ca.pem")?;
174    /// let der = rustls_pemfile::certs(&mut pem_bytes.as_slice())
175    ///     .next()
176    ///     .transpose()?
177    ///     .ok_or("no certificate in PEM file")?
178    ///     .to_vec();
179    /// let transport = CustomCaTransport::new(der);
180    ///
181    /// // With from_pem_bytes (the short way):
182    /// let transport = CustomCaTransport::from_pem_bytes(&std::fs::read("ca.pem")?)?;
183    /// ```
184    ///
185    /// The first PEM-framed certificate in `pem_bytes` is used. To use
186    /// a different certificate from a multi-cert bundle, split the
187    /// bundle yourself and pass the desired one. Multi-cert chains
188    /// (root + intermediate) require constructing a custom
189    /// [`TransportConfig`] implementation that adds multiple roots —
190    /// `CustomCaTransport` is single-root.
191    ///
192    /// # Errors
193    ///
194    /// Returns [`ClientError::InvalidArgument`] if `pem_bytes` does not
195    /// contain a recognisable PEM-framed certificate or if the PEM
196    /// body cannot be base64-decoded.
197    ///
198    /// **DER validity is NOT checked at this stage.** This matches the
199    /// existing [`CustomCaTransport::new`] contract — invalid DER
200    /// (PEM body that decodes to non-DER bytes) is detected later when
201    /// the `JmapClient` is constructed and the underlying transport
202    /// tries to load the root, at which point it surfaces as
203    /// [`ClientError::Http`]. The PEM helper deliberately matches the
204    /// DER helper's behaviour: cheap validation here, full validation
205    /// at client-build time.
206    pub fn from_pem_bytes(pem_bytes: &[u8]) -> Result<Self, ClientError> {
207        // The PEM-to-DER conversion uses a minimal in-line decoder so
208        // this crate does not need to depend on rustls_pemfile. DER
209        // semantic validity is the underlying transport's
210        // responsibility (it happens at build_client time, where
211        // reqwest::Certificate::from_der + ClientBuilder do the
212        // actual rustls/native-tls parse).
213        let cert_bytes = parse_first_pem_cert(pem_bytes).ok_or_else(|| {
214            ClientError::InvalidArgument(
215                "CustomCaTransport::from_pem_bytes: no PEM-framed certificate found in input"
216                    .into(),
217            )
218        })?;
219        Ok(Self {
220            der_cert: cert_bytes,
221        })
222    }
223}
224
225/// Extract the DER bytes of the first PEM-framed certificate in `input`.
226///
227/// PEM (RFC 7468) format: `-----BEGIN <label>-----` / base64 body /
228/// `-----END <label>-----`. We accept any label whose payload is a
229/// valid DER certificate (the most common label is `CERTIFICATE`;
230/// some toolchains emit `X509 CERTIFICATE` or `TRUSTED CERTIFICATE`).
231///
232/// Returns `None` if no PEM frame is found or if the base64 body
233/// cannot be decoded. The DER validity check is the caller's
234/// responsibility (do it via `reqwest::Certificate::from_der`).
235fn parse_first_pem_cert(input: &[u8]) -> Option<Vec<u8>> {
236    use base64::Engine as _;
237    let text = std::str::from_utf8(input).ok()?;
238    // Find any BEGIN line. RFC 7468 §3 mandates exactly five hyphens
239    // and an ASCII-uppercase label; we accept the common shapes.
240    let begin_idx = text.find("-----BEGIN ")?;
241    let after_begin = &text[begin_idx + "-----BEGIN ".len()..];
242    let begin_eol = after_begin.find('\n')?;
243    let label = after_begin[..begin_eol].trim().trim_end_matches('-').trim();
244    let end_marker = format!("-----END {label}-----");
245    let body_start = begin_idx + "-----BEGIN ".len() + begin_eol + 1;
246    let end_offset = text[body_start..].find(end_marker.as_str())?;
247    let body = &text[body_start..body_start + end_offset];
248    // Strip whitespace from the base64 body. PEM allows line wraps
249    // every 64 chars per RFC 7468 §3; the base64 standard engine
250    // does not accept embedded whitespace.
251    let body_no_ws: String = body.chars().filter(|c| !c.is_whitespace()).collect();
252    base64::engine::general_purpose::STANDARD
253        .decode(body_no_ws)
254        .ok()
255}
256
257/// Extract the DER bytes of every PEM-framed certificate in `input`,
258/// in input order (bd:JMAP-6r7c.65).
259///
260/// Iterates `-----BEGIN ... -----` / `-----END ... -----` frames and
261/// decodes each base64 body. Skips frames that fail to decode (matches
262/// `parse_first_pem_cert`'s lenient posture: DER semantic validity is
263/// checked later by the underlying transport at `build_client` time).
264/// Returns an empty `Vec` if no PEM frame is found.
265fn parse_all_pem_certs(input: &[u8]) -> Vec<Vec<u8>> {
266    use base64::Engine as _;
267    let Ok(text) = std::str::from_utf8(input) else {
268        return Vec::new();
269    };
270    let mut out = Vec::new();
271    let mut rest = text;
272    while let Some(begin_idx) = rest.find("-----BEGIN ") {
273        let after_begin = &rest[begin_idx + "-----BEGIN ".len()..];
274        let Some(begin_eol) = after_begin.find('\n') else {
275            break;
276        };
277        let label = after_begin[..begin_eol].trim().trim_end_matches('-').trim();
278        let end_marker = format!("-----END {label}-----");
279        let body_start = begin_idx + "-----BEGIN ".len() + begin_eol + 1;
280        let Some(end_offset) = rest[body_start..].find(end_marker.as_str()) else {
281            break;
282        };
283        let body = &rest[body_start..body_start + end_offset];
284        let body_no_ws: String = body.chars().filter(|c| !c.is_whitespace()).collect();
285        if let Ok(der) = base64::engine::general_purpose::STANDARD.decode(body_no_ws) {
286            out.push(der);
287        }
288        let consumed = body_start + end_offset + end_marker.len();
289        rest = &rest[consumed..];
290    }
291    out
292}
293
294/// Manual `Debug` impl that redacts the DER-encoded CA bytes
295/// (bd:JMAP-6r7c.13).
296///
297/// The DER bytes are not a credential, but they are deployment-identifying
298/// material: a CA certificate uniquely identifies the deployment's PKI
299/// (Subject DN, public key, signing algorithm, validity window, X.509
300/// extensions). In federated or multi-tenant scenarios, surfacing those
301/// bytes in `tracing` output reveals which private-CA-using customer the
302/// client is configured to talk to. Print the length only and let the
303/// caller obtain the bytes via a constructor-controlled path if they
304/// genuinely need them.
305///
306/// Mirrors the redacting `Debug` impls on `BearerAuth` and `BasicAuth`
307/// in this file and on `Session` and `AccountInfo` in `request.rs`.
308impl std::fmt::Debug for CustomCaTransport {
309    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310        f.debug_struct("CustomCaTransport")
311            .field("der_cert", &format_args!("<{} bytes>", self.der_cert.len()))
312            .finish()
313    }
314}
315
316impl TransportConfig for CustomCaTransport {
317    fn build_client(&self) -> Result<HttpClient, ClientError> {
318        let cert =
319            reqwest::Certificate::from_der(&self.der_cert).map_err(ClientError::from_reqwest)?;
320        // Replace (not augment) the trust root set with the configured
321        // private CA. tls_built_in_root_certs(false) disables the bundled
322        // webpki-roots before add_root_certificate adds the private CA —
323        // the order matters because reqwest treats add_root_certificate
324        // as additive (bd:JMAP-6r7c.57).
325        let client = reqwest::ClientBuilder::new()
326            .connect_timeout(std::time::Duration::from_secs(10))
327            .tls_built_in_root_certs(false)
328            .add_root_certificate(cert)
329            .build()
330            .map_err(ClientError::from_reqwest)?;
331        Ok(HttpClient::new(client))
332    }
333}
334
335// ---------------------------------------------------------------------------
336// CustomTransportBuilder (bd:JMAP-6r7c.65)
337// ---------------------------------------------------------------------------
338
339/// Builder for a [`TransportConfig`] with multi-root trust chains and
340/// optional mTLS client certificate (bd:JMAP-6r7c.65).
341///
342/// [`CustomCaTransport`] is single-root and has no mTLS support; the
343/// builder is the richer-configuration counterpart. Common cases:
344///
345/// - **Private PKI with root + intermediate.** Add both via
346///   [`add_root_pem`](Self::add_root_pem) (single cert) or
347///   [`add_roots_pem_bundle`](Self::add_roots_pem_bundle) (bundle of
348///   roots + intermediates).
349/// - **Mutual TLS.** Add a client cert + key via
350///   [`with_client_cert`](Self::with_client_cert).
351/// - **Both.** Compose freely; the builder is chainable.
352///
353/// Like [`CustomCaTransport`], the resulting transport **replaces**
354/// the bundled webpki-roots with the configured trust roots. A
355/// "hybrid" deployment that wants both the bundled public roots AND
356/// custom roots is not currently supported; implement
357/// [`TransportConfig`] directly with the additive behaviour
358/// (`reqwest::ClientBuilder::add_root_certificate` is additive by
359/// default — a hand-rolled impl that omits
360/// `.tls_built_in_root_certs(false)` keeps the bundled roots).
361///
362/// # Usage
363///
364/// ```rust,ignore
365/// use jmap_base_client::auth::CustomTransportBuilder;
366///
367/// let transport = CustomTransportBuilder::new()
368///     .add_root_pem(&std::fs::read("ca-root.pem")?)?
369///     .add_root_pem(&std::fs::read("ca-intermediate.pem")?)?
370///     .with_client_cert(
371///         std::fs::read("client.pem")?,
372///         std::fs::read("client.key.pem")?,
373///     )
374///     .build();
375///
376/// let client = JmapClient::new(
377///     transport,
378///     BearerAuth::new(token)?,
379///     "https://internal-jmap.corp",
380///     ClientConfig::default(),
381/// )?;
382/// ```
383#[derive(Default)]
384pub struct CustomTransportBuilder {
385    roots_der: Vec<Vec<u8>>,
386    // (cert_pem, key_pem) pair — concatenated into a single PEM bundle
387    // for reqwest::Identity::from_pem at build_client time.
388    client_identity: Option<(Vec<u8>, Vec<u8>)>,
389}
390
391impl CustomTransportBuilder {
392    /// Construct an empty builder. A builder with no trust roots and
393    /// no client identity will produce a transport that rejects
394    /// every TLS connection (no trust roots configured); add at
395    /// least one root before [`build`](Self::build).
396    pub fn new() -> Self {
397        Self::default()
398    }
399
400    /// Add a DER-encoded trust-root certificate.
401    ///
402    /// Validation of the DER bytes is deferred to
403    /// [`build`](Self::build) (same posture as
404    /// [`CustomCaTransport::new`]). Invalid DER surfaces as
405    /// [`ClientError::Http`] at `JmapClient::new` time.
406    pub fn add_root_der(mut self, der: Vec<u8>) -> Self {
407        self.roots_der.push(der);
408        self
409    }
410
411    /// Add a PEM-encoded trust-root certificate. The first
412    /// PEM-framed certificate in `pem` is consumed; embedded
413    /// chains require [`add_roots_pem_bundle`](Self::add_roots_pem_bundle).
414    ///
415    /// # Errors
416    ///
417    /// Returns [`ClientError::InvalidArgument`] if `pem` does not
418    /// contain a recognisable PEM-framed certificate.
419    pub fn add_root_pem(self, pem: &[u8]) -> Result<Self, ClientError> {
420        let der = parse_first_pem_cert(pem).ok_or_else(|| {
421            ClientError::InvalidArgument(
422                "CustomTransportBuilder::add_root_pem: no PEM-framed certificate found in input"
423                    .into(),
424            )
425        })?;
426        Ok(self.add_root_der(der))
427    }
428
429    /// Add every PEM-framed certificate in a multi-cert bundle.
430    ///
431    /// A typical private-PKI deployment ships a bundle containing
432    /// the root plus one or more intermediates as concatenated PEM
433    /// blocks. This method iterates each `-----BEGIN CERTIFICATE-----`
434    /// block in input order and adds each to the trust set.
435    ///
436    /// # Errors
437    ///
438    /// Returns [`ClientError::InvalidArgument`] if no PEM-framed
439    /// certificate is found in the bundle.
440    pub fn add_roots_pem_bundle(mut self, pem_bundle: &[u8]) -> Result<Self, ClientError> {
441        let ders = parse_all_pem_certs(pem_bundle);
442        if ders.is_empty() {
443            return Err(ClientError::InvalidArgument(
444                "CustomTransportBuilder::add_roots_pem_bundle: no PEM-framed \
445                 certificates found in bundle"
446                    .into(),
447            ));
448        }
449        self.roots_der.extend(ders);
450        Ok(self)
451    }
452
453    /// Configure a client certificate + private key for mutual TLS.
454    ///
455    /// Replaces any previously-configured client identity. The two
456    /// PEM byte slices are stored verbatim and concatenated at
457    /// [`build`](Self::build) time into a single PEM bundle that
458    /// [`reqwest::Identity::from_pem`] consumes.
459    ///
460    /// `cert_pem` may contain a single client cert or a cert +
461    /// intermediates chain. `key_pem` carries the private key
462    /// (PKCS#1 or PKCS#8, RSA or ECDSA — whatever reqwest's rustls
463    /// build supports).
464    pub fn with_client_cert(mut self, cert_pem: Vec<u8>, key_pem: Vec<u8>) -> Self {
465        self.client_identity = Some((cert_pem, key_pem));
466        self
467    }
468
469    /// Consume the builder and return a [`TransportConfig`]
470    /// implementation that produces a [`reqwest::Client`] configured
471    /// with the accumulated trust roots and optional client identity.
472    ///
473    /// Behaves identically to [`CustomCaTransport::build_client`] for
474    /// single-root use; the additional functionality (multi-root +
475    /// mTLS) kicks in when the builder was configured with more than
476    /// one root or a client identity.
477    pub fn build(self) -> BuilderTransport {
478        BuilderTransport {
479            roots_der: self.roots_der,
480            client_identity: self.client_identity,
481        }
482    }
483}
484
485/// Concrete [`TransportConfig`] produced by [`CustomTransportBuilder::build`].
486///
487/// Stores the accumulated trust roots and optional client identity by
488/// value. Cloneable so consumers that need multiple `JmapClient` instances
489/// sharing the same transport configuration can do so without rebuilding.
490#[derive(Clone)]
491pub struct BuilderTransport {
492    roots_der: Vec<Vec<u8>>,
493    client_identity: Option<(Vec<u8>, Vec<u8>)>,
494}
495
496impl std::fmt::Debug for BuilderTransport {
497    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
498        f.debug_struct("BuilderTransport")
499            .field(
500                "roots_der",
501                &format_args!("<{} root cert(s)>", self.roots_der.len()),
502            )
503            .field(
504                "client_identity",
505                &format_args!(
506                    "<{}>",
507                    if self.client_identity.is_some() {
508                        "client cert configured"
509                    } else {
510                        "no client cert"
511                    }
512                ),
513            )
514            .finish()
515    }
516}
517
518impl TransportConfig for BuilderTransport {
519    fn build_client(&self) -> Result<HttpClient, ClientError> {
520        let mut builder = reqwest::ClientBuilder::new()
521            .connect_timeout(std::time::Duration::from_secs(10))
522            // Replace (not augment) the trust root set. See
523            // CustomCaTransport rationale (bd:JMAP-6r7c.57). Builder
524            // users who want hybrid trust (bundled + private) are
525            // expected to implement TransportConfig directly.
526            .tls_built_in_root_certs(false);
527
528        for der in &self.roots_der {
529            let cert = reqwest::Certificate::from_der(der).map_err(ClientError::from_reqwest)?;
530            builder = builder.add_root_certificate(cert);
531        }
532
533        if let Some((cert_pem, key_pem)) = &self.client_identity {
534            // reqwest::Identity::from_pem expects the cert chain and
535            // private key concatenated into one PEM bundle. Build
536            // that bundle locally without leaking either input across
537            // the function boundary.
538            let mut bundle = Vec::with_capacity(cert_pem.len() + key_pem.len() + 1);
539            bundle.extend_from_slice(cert_pem);
540            if !cert_pem.ends_with(b"\n") {
541                bundle.push(b'\n');
542            }
543            bundle.extend_from_slice(key_pem);
544            let identity =
545                reqwest::Identity::from_pem(&bundle).map_err(ClientError::from_reqwest)?;
546            builder = builder.identity(identity);
547        }
548
549        let client = builder.build().map_err(ClientError::from_reqwest)?;
550        Ok(HttpClient::new(client))
551    }
552}
553
554// ---------------------------------------------------------------------------
555// AuthProvider — per-request credential injection (Authorization header)
556// ---------------------------------------------------------------------------
557
558/// Single HTTP `(name, value)` header pair, returned by
559/// [`AuthProvider::auth_header`] (bd:JMAP-6r7c.62, bd:JMAP-6r7c.20).
560///
561/// The wrapper exists for two purposes:
562///
563/// 1. **Compile-time secret-typing.** [`AuthHeader`]'s `Debug` impl
564///    redacts the header value to `"[REDACTED]"`. A future
565///    [`AuthProvider`] impl that writes
566///    `tracing::trace!(?header, "injecting")` cannot leak the credential
567///    through that path because the wrapper's `Debug` output never
568///    contains the value bytes. The pre-bd:JMAP-6r7c.62 shape
569///    (`Option<(&str, &str)>`) had no such guard — a string tuple
570///    formats verbatim via `?`-syntax.
571/// 2. **Bounded API surface.** The wrapper packages exactly one
572///    `(name, value)` pair. The trait's signature does not admit a
573///    list, a sequence, or a per-request-computed value. This is the
574///    intentional limitation: `AuthProvider` covers "static,
575///    per-connection single-header auth schemes" only (Bearer, Basic,
576///    mTLS via [`TransportConfig`]). Schemes that need multiple
577///    request-dependent headers (AWS SigV4, OAuth request signing) or
578///    async credential refresh require a different abstraction —
579///    currently, custom [`TransportConfig`] impls that wire per-request
580///    middleware (bd:JMAP-6r7c.20).
581///
582/// Construct via [`AuthHeader::new`] — both `name` and `value` are
583/// caller-supplied borrows; the wrapper stashes them as-is. The
584/// constructor does not validate HTTP-header-value syntax; downstream
585/// consumers (e.g. [`connect_ws`](crate::ws::connect_ws)) validate at
586/// the call site and surface [`ClientError::InvalidArgument`] for
587/// invalid bytes.
588#[non_exhaustive]
589#[derive(Clone, Copy)]
590pub struct AuthHeader<'a> {
591    name: &'a str,
592    value: &'a str,
593}
594
595impl<'a> AuthHeader<'a> {
596    /// Construct an [`AuthHeader`] from a header name and value borrow.
597    pub fn new(name: &'a str, value: &'a str) -> Self {
598        Self { name, value }
599    }
600
601    /// Borrow the header name. Lowercase-ASCII per RFC 9110 §5.1.
602    pub fn name(&self) -> &'a str {
603        self.name
604    }
605
606    /// Borrow the header value.
607    ///
608    /// **Do not log this return value.** The value is credential
609    /// material; see the type-level rustdoc. The constructor name is
610    /// deliberately explicit ([`expose_value`](Self::expose_value)) so a
611    /// call site reveals the intent — a `tracing::*` line that
612    /// references `header.expose_value()` is visible in code review,
613    /// whereas a `?header` formatter is not.
614    pub fn expose_value(&self) -> &'a str {
615        self.value
616    }
617}
618
619impl std::fmt::Debug for AuthHeader<'_> {
620    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621        f.debug_struct("AuthHeader")
622            .field("name", &self.name)
623            .field("value", &"[REDACTED]")
624            .finish()
625    }
626}
627
628/// Injects per-request authentication credentials.
629///
630/// Separate from transport configuration ([`TransportConfig`]) so any
631/// credential scheme can be paired with any transport.
632///
633/// **Implement this trait** when you need a custom `Authorization` header or
634/// other per-request credential scheme.  For custom TLS/trust-root logic
635/// implement [`TransportConfig`] instead.  [`NoneAuth`], [`BearerAuth`], and
636/// [`BasicAuth`] cover the common cases.
637///
638/// Implementations **must not** log the return value of [`auth_header`];
639/// it contains credentials. The [`AuthHeader`] return type provides a
640/// compile-time guard against the most common leak path — its `Debug`
641/// impl redacts the value bytes — but the explicit
642/// [`expose_value`](AuthHeader::expose_value) accessor must not be fed
643/// into a `tracing::*` argument either.
644///
645/// [`auth_header`]: AuthProvider::auth_header
646///
647/// # Intentional limitation: static single-header per-connection schemes (bd:JMAP-6r7c.20)
648///
649/// The trait shape commits the kit to "static, per-connection,
650/// single-header auth schemes" — bearer-token, HTTP Basic, mTLS via
651/// [`TransportConfig`]. Three constraints follow from the
652/// [`AuthHeader`] return type:
653///
654/// 1. **One header per request.** A scheme that needs to attach
655///    multiple headers per request (AWS SigV4 carries
656///    `Authorization`, `X-Amz-Date`, and `X-Amz-Security-Token`
657///    together) cannot be expressed by this trait.
658/// 2. **No per-request signature.** [`auth_header`] takes `&self`
659///    only — there is no access to the request URL, method, or body.
660///    Schemes that compute an HMAC over the request body (SigV4,
661///    OAuth request signing) cannot be expressed.
662/// 3. **No async refresh.** [`auth_header`] is sync. A scheme that
663///    needs to refresh an expired OAuth token before returning
664///    cannot await inside this method.
665///
666/// Workaround for callers who need any of the three: implement a
667/// custom [`TransportConfig`] that wires per-request middleware into
668/// the [`HttpClient`] it returns from
669/// [`build_client`](TransportConfig::build_client). The middleware can
670/// observe the full request, compute signatures, and refresh tokens
671/// asynchronously. The cost is the awkward layering inversion — TLS
672/// config and credential injection conceptually belong to different
673/// traits — but it does compose against the existing
674/// [`AuthProvider::auth_header`] trait without breakage.
675///
676/// A future reshape that supports the three constraints (likely a
677/// new trait, not a backward-compatible widening of this one) would
678/// not deprecate `AuthProvider`. The current trait stays as the
679/// "fast path for the common case" alongside any richer abstraction.
680///
681/// # Credential lifetime
682///
683/// Implementations that cache header bytes (e.g. [`BearerAuth`],
684/// [`BasicAuth`]) SHOULD wrap the cached buffer in [`zeroize::Zeroizing`]
685/// or equivalent so the credential is overwritten on drop rather than
686/// left in freed heap until the allocator re-uses the slab. Callers that
687/// build a credential string before passing it into a constructor (e.g.
688/// `BearerAuth::new(token)`) SHOULD likewise store that string in a
689/// `Zeroizing<String>` — the zeroization done by the auth-type is bounded
690/// by what the type owns and cannot reach back into the caller's buffer
691/// (bd:JMAP-6r7c.59).
692///
693/// **Maintainer note (bd:JMAP-6lsm.19):** if you add a new method to this
694/// trait, update BOTH manual blanket impls — `Box<dyn AuthProvider>` and
695/// `Arc<dyn AuthProvider>` — at the bottom of this file. The crate
696/// supports both Box and Arc trait-object call shapes (e.g. for sharing
697/// one credential source across multiple `JmapClient`s), and a missing
698/// blanket method silently breaks one of those shapes without breaking
699/// the other.
700pub trait AuthProvider: Send + Sync {
701    /// Return an optional [`AuthHeader`] to attach to every request.
702    ///
703    /// Returns `None` when no `Authorization` header is required.
704    ///
705    /// The header name and value both borrow from `self` and must live
706    /// at least as long as the `&self` borrow. Implementations that
707    /// pre-compute the values at construction time can return
708    /// `AuthHeader::new("authorization", &self.field)` directly,
709    /// avoiding any per-request allocation.
710    ///
711    /// # Implementation contract
712    ///
713    /// The returned strings **must** be valid HTTP field values (RFC 9110 §5):
714    /// - Header name: lowercase ASCII token characters only (no spaces, no
715    ///   control characters); e.g. `"authorization"`.
716    /// - Header value: visible ASCII characters (0x21–0x7E) and horizontal tab
717    ///   (0x09) only; no other control characters.
718    ///
719    /// Implementations that violate this contract will cause
720    /// [`ClientError::InvalidArgument`] in `connect_ws` (`ws/mod.rs`), which
721    /// parses the value into a typed [`http::HeaderValue`]. On HTTP code paths
722    /// reqwest returns the error from `.send()` as a builder error rather than
723    /// an `InvalidArgument` — the error type differs between the two paths.
724    /// Test all custom `AuthProvider` implementations against both HTTP and
725    /// WebSocket call paths.
726    fn auth_header(&self) -> Option<AuthHeader<'_>>;
727}
728
729/// No authentication: no `Authorization` header.
730#[derive(Debug, Clone)]
731pub struct NoneAuth;
732
733impl AuthProvider for NoneAuth {
734    fn auth_header(&self) -> Option<AuthHeader<'_>> {
735        None
736    }
737}
738
739/// Bearer-token authentication (`Authorization: Bearer <token>`).
740///
741/// # Drop-path zeroization
742///
743/// The cached header string is wrapped in [`zeroize::Zeroizing`] so its
744/// buffer is overwritten with zeros before being returned to the allocator
745/// on drop. This defends against credential recovery from process core
746/// dumps, `/proc/PID/mem` inspection, and post-drop heap re-use across
747/// tenants in long-running multi-user JMAP clients (bd:JMAP-6r7c.59).
748/// Callers that hold the original token string SHOULD also store it in a
749/// `Zeroizing<String>` or equivalent — the zeroization here is bounded by
750/// what this type owns.
751///
752/// # Do not move validation from construction to per-request (bd:JMAP-6r7c.18)
753///
754/// A future contributor may suggest "just store the token field and call
755/// `HeaderValue::from_str` in `auth_header` on each request". This is the
756/// wrong simplification for both `BearerAuth` and `BasicAuth`. Five
757/// reasons:
758///
759/// 1. **Fail-fast at auth setup.** Validation at construction means
760///    invalid credentials surface at `BearerAuth::new()` return value —
761///    the caller fails near the bug source (their auth-setup code).
762///    Per-request validation pushes failures to the first
763///    `JmapClient::call()` or `fetch_session()`, far from the bug and
764///    harder to debug.
765/// 2. **Hot-path performance.** `auth_header` is called on every HTTP
766///    request and every WebSocket connection. `HeaderValue::from_str`
767///    walks the string and rejects on the first non-VCHAR/SP/HTAB
768///    octet (RFC 7230 §3.2.6) — non-trivial work for a hot path.
769///    Pre-validation moves that work out of every request.
770/// 3. **Infallible accessor signature.** Pre-validation lets
771///    `auth_header` keep the signature
772///    `fn auth_header(&self) -> Option<AuthHeader<'_>>` — infallible.
773///    Per-request validation would require
774///    `Result<Option<(&str, &str)>, ClientError>`, propagating an
775///    extra error layer through every call site (HTTP `call`, blob
776///    upload/download, WebSocket connect, session fetch).
777/// 4. **Borrow simplicity.** Storing as `Zeroizing<String>` lets
778///    `auth_header` return borrows directly without ownership tricks
779///    (`Cow`, `Box<str>`, etc.). The borrow checker stays simple, the
780///    call sites stay readable.
781/// 5. **Debug-redaction tripwire compatibility.** The manual `Debug`
782///    impls on `BearerAuth` and `BasicAuth` (auth.rs further below)
783///    target the stored field. A future contributor adding
784///    `#[derive(Debug)]` instead of the manual impl is caught
785///    immediately by the existing canary tests
786///    `bearer_auth_debug_does_not_leak_token` and
787///    `basic_auth_debug_does_not_leak_credentials` (bd:JMAP-sc1b.79).
788///    Moving to per-request validation requires the field shape to
789///    change in a way that re-derives the canary contract — extra
790///    surface area for review without buying anything.
791///
792/// This is the same pre-validate-at-construction pattern `rustls` and
793/// `reqwest` use for their own type designs. It is not over-engineering.
794#[derive(Clone)]
795pub struct BearerAuth {
796    // Pre-validated at construction and stored as String: avoids per-request
797    // allocation and ensures invalid credentials fail at construction, not at
798    // the first request. Storing as String eliminates the need for a fallible
799    // to_str() call in auth_header().
800    //
801    // Wrapped in Zeroizing<String> so the buffer is overwritten on drop
802    // (see type-level doc). Zeroizing<String> Derefs to String, which Derefs
803    // to &str, so `&self.header_string` in auth_header() coerces cleanly.
804    header_string: Zeroizing<String>,
805}
806
807impl BearerAuth {
808    /// Construct a `BearerAuth` from a Bearer token string.
809    ///
810    /// # Errors
811    ///
812    /// - [`ClientError::InvalidArgument`] if `token` is empty or contains
813    ///   whitespace (RFC 6750 §2.1 bearer tokens must not contain whitespace).
814    /// - [`ClientError::InvalidHeaderValue`] if `token` contains characters that
815    ///   are not valid in an HTTP header value (non-visible-ASCII octets).
816    pub fn new(token: &str) -> Result<Self, ClientError> {
817        if token.is_empty() || token.chars().any(|c| c.is_ascii_whitespace()) {
818            return Err(ClientError::InvalidArgument(
819                "BearerAuth token may not be empty or contain whitespace (RFC 6750 §2.1)".into(),
820            ));
821        }
822        let header_string = Zeroizing::new(format!("Bearer {token}"));
823        // Validate the header value is legal (no control characters, etc.).
824        HeaderValue::from_str(&header_string).map_err(ClientError::from_invalid_header)?;
825        Ok(Self { header_string })
826    }
827}
828
829impl std::fmt::Debug for BearerAuth {
830    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
831        f.debug_struct("BearerAuth")
832            .field("token", &"[REDACTED]")
833            .finish()
834    }
835}
836
837impl AuthProvider for BearerAuth {
838    fn auth_header(&self) -> Option<AuthHeader<'_>> {
839        Some(AuthHeader::new("authorization", &self.header_string))
840    }
841}
842
843/// HTTP Basic authentication (`Authorization: Basic <base64(username:password)>`).
844///
845/// Credentials are encoded per RFC 7617: `base64(username ":" password)`.
846///
847/// # Drop-path zeroization
848///
849/// The cached header string is wrapped in [`zeroize::Zeroizing`] so its
850/// buffer is overwritten with zeros before being returned to the allocator
851/// on drop. The intermediate `username:password` plaintext built during
852/// base64 encoding is ALSO zeroized — that buffer is the most
853/// attack-relevant artifact because it carries the raw password rather
854/// than the base64-encoded form. See [`BearerAuth`] for the threat model.
855/// (bd:JMAP-6r7c.59)
856#[derive(Clone)]
857pub struct BasicAuth {
858    // Pre-validated at construction and stored as String: avoids per-request
859    // allocation and ensures invalid credentials fail at construction, not at
860    // the first request. Storing as String eliminates the need for a fallible
861    // to_str() call in auth_header().
862    //
863    // Wrapped in Zeroizing<String> so the buffer is overwritten on drop
864    // (see type-level doc).
865    header_string: Zeroizing<String>,
866}
867
868impl BasicAuth {
869    /// Construct a `BasicAuth` from a username and password.
870    ///
871    /// # Errors
872    ///
873    /// - [`ClientError::InvalidArgument`] if `username` contains a colon (`:`),
874    ///   which is forbidden by RFC 7617 §2.
875    /// - [`ClientError::InvalidHeaderValue`] if the resulting header value
876    ///   contains characters that are not valid in an HTTP header value.
877    pub fn new(username: &str, password: &str) -> Result<Self, ClientError> {
878        if username.contains(':') {
879            return Err(ClientError::InvalidArgument(
880                "BasicAuth username may not contain ':'".into(),
881            ));
882        }
883        // The intermediate plaintext buffer is the most sensitive artifact
884        // — it carries the raw password, whereas the base64-encoded form is
885        // one step further from a credential a replay attacker can use.
886        // Wrap it in Zeroizing so the buffer is overwritten when the local
887        // goes out of scope at the end of this function.
888        let plaintext = Zeroizing::new(format!("{username}:{password}"));
889        let encoded = BASE64_STANDARD.encode(plaintext.as_bytes());
890        let header_string = Zeroizing::new(format!("Basic {encoded}"));
891        // Validate the header value is legal (base64 is always printable ASCII,
892        // but keep the check for correctness).
893        HeaderValue::from_str(&header_string).map_err(ClientError::from_invalid_header)?;
894        Ok(Self { header_string })
895    }
896}
897
898impl std::fmt::Debug for BasicAuth {
899    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
900        f.debug_struct("BasicAuth")
901            .field("credentials", &"[REDACTED]")
902            .finish()
903    }
904}
905
906impl AuthProvider for BasicAuth {
907    fn auth_header(&self) -> Option<AuthHeader<'_>> {
908        Some(AuthHeader::new("authorization", &self.header_string))
909    }
910}
911
912// ---------------------------------------------------------------------------
913// Internal helper
914// ---------------------------------------------------------------------------
915
916/// Build a standard reqwest client with a 10-second connect timeout.
917fn default_reqwest_client() -> Result<reqwest::Client, ClientError> {
918    reqwest::ClientBuilder::new()
919        .connect_timeout(std::time::Duration::from_secs(10))
920        .build()
921        .map_err(ClientError::from_reqwest)
922}
923
924// ---------------------------------------------------------------------------
925// Blanket impl for Box<dyn TransportConfig>
926// ---------------------------------------------------------------------------
927//
928// Allows `Box<dyn TransportConfig>` to satisfy `impl TransportConfig`, so
929// factory functions (e.g. `Config::transport`) can return a boxed
930// trait object and pass it directly to `JmapClient::new`.
931//
932// There is intentionally NO `Arc<dyn TransportConfig>` blanket here.
933// TransportConfig is consumed once at `JmapClient::new` to build the
934// reqwest::Client. The resulting Client is stored; the TransportConfig itself
935// is not kept. Arc would imply shared ownership of something that is not
936// shared after construction.
937//
938// Maintenance cost: every method added to `TransportConfig` must be mirrored here.
939impl TransportConfig for Box<dyn TransportConfig> {
940    fn build_client(&self) -> Result<HttpClient, ClientError> {
941        (**self).build_client()
942    }
943}
944
945// ---------------------------------------------------------------------------
946// Blanket impl for Arc<dyn AuthProvider>
947// ---------------------------------------------------------------------------
948//
949// Allows `Arc<dyn AuthProvider>` to satisfy `impl AuthProvider`, enabling
950// `JmapClient` to be `Clone` (Arc is Clone).
951//
952// Maintenance cost: every method added to `AuthProvider` must be mirrored here.
953impl AuthProvider for Arc<dyn AuthProvider> {
954    fn auth_header(&self) -> Option<AuthHeader<'_>> {
955        (**self).auth_header()
956    }
957}
958
959// ---------------------------------------------------------------------------
960// Blanket impl for Box<dyn AuthProvider>
961// ---------------------------------------------------------------------------
962//
963// Allows `Box<dyn AuthProvider>` to satisfy `impl AuthProvider + 'static`,
964// so factory functions (e.g. `Config::auth`) can return a boxed
965// trait object and pass it directly to `JmapClient::new`.
966//
967// Maintenance cost: every method added to `AuthProvider` must be mirrored here.
968impl AuthProvider for Box<dyn AuthProvider> {
969    fn auth_header(&self) -> Option<AuthHeader<'_>> {
970        (**self).auth_header()
971    }
972}
973
974// ---------------------------------------------------------------------------
975// Tests
976// ---------------------------------------------------------------------------
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981
982    /// Oracle: NoneAuth has no authentication header — verified by inspection of the spec.
983    #[test]
984    fn none_auth_no_header() {
985        assert!(NoneAuth.auth_header().is_none());
986    }
987
988    /// Oracle: BearerAuth constructs successfully with a valid ASCII token.
989    #[test]
990    fn bearer_auth_valid_constructs() {
991        assert!(BearerAuth::new("tok123").is_ok());
992    }
993
994    /// Oracle: BearerAuth header value is "Bearer " + the literal token string.
995    /// Verified by inspection: the Authorization header MUST be "Bearer tok123".
996    #[test]
997    fn bearer_auth_header() {
998        let auth = BearerAuth::new("tok123").expect("valid ASCII token must construct");
999        let header = auth.auth_header().expect("BearerAuth must return a header");
1000        assert_eq!(header.name(), "authorization");
1001        assert_eq!(header.expose_value(), "Bearer tok123");
1002    }
1003
1004    /// Oracle: BearerAuth constructor rejects tokens containing C0 control characters.
1005    /// HeaderValue::from_str rejects bytes 0x00-0x08 and 0x0A-0x1F (C0 controls,
1006    /// excluding HTAB 0x09) and 0x7F (DEL). '\x01' (SOH) is unconditionally invalid
1007    /// per RFC 7230 §3.2.6 and the http crate's header validation.
1008    #[test]
1009    fn bearer_auth_invalid_token_rejected() {
1010        let result = BearerAuth::new("tok\x01abc");
1011        assert!(
1012            result.is_err(),
1013            "token with C0 control character must be rejected by constructor"
1014        );
1015    }
1016
1017    /// Oracle: BasicAuth constructs successfully with valid username and password.
1018    #[test]
1019    fn basic_auth_valid_constructs() {
1020        assert!(BasicAuth::new("alice", "s3cr3t").is_ok());
1021    }
1022
1023    /// Oracle: BasicAuth constructor rejects usernames containing a colon (RFC 7617 §2).
1024    #[test]
1025    fn basic_auth_colon_in_username_rejected() {
1026        let result = BasicAuth::new("ali:ce", "s3cr3t");
1027        match result {
1028            Ok(_) => panic!("username with colon must be rejected by constructor"),
1029            Err(e) => {
1030                let err_msg = e.to_string();
1031                assert!(
1032                    err_msg.contains("username"),
1033                    "error message should mention 'username', got: {err_msg}"
1034                );
1035            }
1036        }
1037    }
1038
1039    /// Oracle: `echo -n "alice:s3cr3t" | base64` → `YWxpY2U6czNjcjN0`  (RFC 7617 §2)
1040    /// This expected value is computed independently of the code under test.
1041    #[test]
1042    fn basic_auth_header() {
1043        let auth = BasicAuth::new("alice", "s3cr3t").expect("valid credentials must construct");
1044        let header = auth.auth_header().expect("BasicAuth must return a header");
1045        assert_eq!(header.name(), "authorization");
1046        assert_eq!(header.expose_value(), "Basic YWxpY2U6czNjcjN0");
1047    }
1048
1049    /// Oracle: CustomCaTransport injects no auth header — it is a transport only.
1050    #[test]
1051    fn custom_ca_transport_no_build_with_empty_cert() {
1052        // Empty DER bytes will fail Certificate::from_der; this test confirms
1053        // CustomCaTransport is constructible and that auth is separate.
1054        let transport = CustomCaTransport::new(vec![]);
1055        assert!(transport.build_client().is_err(), "empty DER must fail");
1056    }
1057
1058    // bd:JMAP-6r7c.65 — CustomTransportBuilder tests below.
1059
1060    /// Oracle: `parse_all_pem_certs` extracts every PEM-framed
1061    /// certificate in a multi-cert bundle and skips non-certificate
1062    /// content between frames. Hand-rolled fixture: two valid PEM
1063    /// frames concatenated with leading and trailing prose.
1064    #[test]
1065    fn parse_all_pem_certs_handles_multi_cert_bundle() {
1066        // Read the single-cert fixture and concatenate it with itself
1067        // so the bundle has two identical PEM frames. The parser MUST
1068        // emit two DER blobs even though the content is duplicate.
1069        let single = std::fs::read("tests/fixtures/tls/test-ca.pem")
1070            .expect("test-ca.pem fixture must exist");
1071        let mut bundle = b"# Comment that the parser must ignore\n".to_vec();
1072        bundle.extend_from_slice(&single);
1073        bundle.extend_from_slice(b"\n# Another comment between frames\n");
1074        bundle.extend_from_slice(&single);
1075        bundle.extend_from_slice(b"\n# Trailing comment\n");
1076
1077        let ders = parse_all_pem_certs(&bundle);
1078        assert_eq!(ders.len(), 2, "two-cert bundle must produce two DER blobs");
1079        assert!(!ders[0].is_empty(), "first DER must be non-empty");
1080        assert!(!ders[1].is_empty(), "second DER must be non-empty");
1081        // Deterministic same-input check: both decoded DERs must match
1082        // because the bundle contains the same cert twice.
1083        assert_eq!(
1084            ders[0], ders[1],
1085            "duplicate-input bundle must produce identical DER blobs"
1086        );
1087    }
1088
1089    /// Oracle: `CustomTransportBuilder::add_root_pem` accepts a
1090    /// fixture PEM and `build` produces a working `TransportConfig`.
1091    #[test]
1092    fn custom_transport_builder_single_pem_root_builds() {
1093        let pem = std::fs::read("tests/fixtures/tls/test-ca.pem")
1094            .expect("test-ca.pem fixture must exist");
1095        let transport = CustomTransportBuilder::new()
1096            .add_root_pem(&pem)
1097            .expect("PEM fixture must parse")
1098            .build();
1099        transport
1100            .build_client()
1101            .expect("single-root build_client must succeed");
1102    }
1103
1104    /// Oracle: `add_roots_pem_bundle` accepts a multi-PEM bundle.
1105    /// Two identical PEM frames concatenated produces a transport
1106    /// with two trust roots loaded.
1107    #[test]
1108    fn custom_transport_builder_multi_root_bundle_builds() {
1109        let single = std::fs::read("tests/fixtures/tls/test-ca.pem")
1110            .expect("test-ca.pem fixture must exist");
1111        let mut bundle = single.clone();
1112        bundle.extend_from_slice(b"\n");
1113        bundle.extend_from_slice(&single);
1114
1115        let transport = CustomTransportBuilder::new()
1116            .add_roots_pem_bundle(&bundle)
1117            .expect("two-cert PEM bundle must parse")
1118            .build();
1119        transport
1120            .build_client()
1121            .expect("multi-root build_client must succeed");
1122    }
1123
1124    /// Oracle: `add_root_pem` rejects input that is not a recognisable
1125    /// PEM-framed certificate. Returns ClientError::InvalidArgument
1126    /// rather than ClientError::Http — the parse error is at the
1127    /// PEM-decode boundary, not at reqwest's TLS layer.
1128    #[test]
1129    fn custom_transport_builder_add_root_pem_invalid_returns_invalid_argument() {
1130        let result = CustomTransportBuilder::new().add_root_pem(b"not a pem");
1131        match result {
1132            Ok(_) => panic!("garbage input must not produce a valid builder"),
1133            Err(ClientError::InvalidArgument(msg)) => {
1134                assert!(
1135                    msg.contains("CustomTransportBuilder::add_root_pem"),
1136                    "error must identify the offending method: {msg}"
1137                );
1138            }
1139            Err(other) => panic!("expected InvalidArgument, got {other:?}"),
1140        }
1141    }
1142
1143    /// Oracle: `add_roots_pem_bundle` on input with no PEM frames
1144    /// returns ClientError::InvalidArgument.
1145    #[test]
1146    fn custom_transport_builder_empty_bundle_returns_invalid_argument() {
1147        let result = CustomTransportBuilder::new().add_roots_pem_bundle(b"plain text");
1148        match result {
1149            Ok(_) => panic!("input without PEM frames must not produce a valid builder"),
1150            Err(ClientError::InvalidArgument(msg)) => {
1151                assert!(
1152                    msg.contains("CustomTransportBuilder::add_roots_pem_bundle"),
1153                    "error must identify the offending method: {msg}"
1154                );
1155            }
1156            Err(other) => panic!("expected InvalidArgument, got {other:?}"),
1157        }
1158    }
1159
1160    /// Oracle: `with_client_cert` configured with bogus PEM bytes
1161    /// surfaces the reqwest::Identity parse failure as
1162    /// ClientError::Http at build_client time. The builder itself
1163    /// does not validate the bytes (matches the DER posture of
1164    /// add_root_der + add_root_pem — full validation is deferred to
1165    /// build).
1166    #[test]
1167    fn custom_transport_builder_with_client_cert_invalid_fails_at_build() {
1168        // Valid root, invalid client identity.
1169        let pem = std::fs::read("tests/fixtures/tls/test-ca.pem")
1170            .expect("test-ca.pem fixture must exist");
1171        let transport = CustomTransportBuilder::new()
1172            .add_root_pem(&pem)
1173            .expect("PEM fixture must parse")
1174            .with_client_cert(b"not a cert PEM".to_vec(), b"not a key PEM".to_vec())
1175            .build();
1176        let result = transport.build_client();
1177        assert!(
1178            matches!(result, Err(ClientError::Http(_))),
1179            "invalid client identity must surface as ClientError::Http, got {result:?}"
1180        );
1181    }
1182
1183    /// Oracle: `BuilderTransport::Debug` opaquely describes the
1184    /// trust-root count and identity presence without leaking the
1185    /// raw cert bytes. Mirror the tripwire pattern from the
1186    /// CustomCaTransport Debug-redaction test (bd:JMAP-6r7c.13).
1187    #[test]
1188    fn builder_transport_debug_does_not_leak_cert_bytes() {
1189        let canary = vec![0xCA_u8; 32];
1190        let transport = CustomTransportBuilder::new().add_root_der(canary).build();
1191        let dbg = format!("{transport:?}");
1192        assert!(
1193            !dbg.contains("cacacacacacacacacacacacacacacacacacacacacacacacacacacacacacacaca"),
1194            "BuilderTransport Debug must not contain lowercase-hex DER bytes; got: {dbg}"
1195        );
1196        assert!(
1197            dbg.contains("1 root cert"),
1198            "BuilderTransport Debug must surface the root count for diagnostics; got: {dbg}"
1199        );
1200    }
1201
1202    /// Oracle: BearerAuth constructor rejects an empty token string.
1203    /// An empty token would produce "Bearer " which is a malformed credential.
1204    #[test]
1205    fn bearer_auth_empty_token_rejected() {
1206        let result = BearerAuth::new("");
1207        match result {
1208            Ok(_) => panic!("empty token must be rejected by constructor"),
1209            Err(ClientError::InvalidArgument(msg)) => {
1210                assert!(
1211                    msg.contains("empty"),
1212                    "error message should mention 'empty', got: {msg}"
1213                );
1214            }
1215            Err(e) => panic!("expected InvalidArgument, got: {e}"),
1216        }
1217    }
1218
1219    /// Oracle: BearerAuth constructor rejects a whitespace-only token string.
1220    /// A whitespace-only token would produce "Bearer   " which is a malformed credential.
1221    #[test]
1222    fn bearer_auth_whitespace_only_token_rejected() {
1223        let result = BearerAuth::new("   ");
1224        match result {
1225            Ok(_) => panic!("whitespace-only token must be rejected by constructor"),
1226            Err(ClientError::InvalidArgument(msg)) => {
1227                assert!(
1228                    msg.contains("whitespace"),
1229                    "error message should mention 'whitespace', got: {msg}"
1230                );
1231            }
1232            Err(e) => panic!("expected InvalidArgument, got: {e}"),
1233        }
1234    }
1235
1236    /// Oracle: DefaultTransport uses the default reqwest::Client which always builds successfully.
1237    #[tokio::test]
1238    async fn default_transport_builds_client() {
1239        DefaultTransport
1240            .build_client()
1241            .expect("DefaultTransport::build_client must succeed");
1242    }
1243
1244    /// bd:JMAP-6r7c.36 — `TransportConfig::build_client` now returns
1245    /// `Result<HttpClient, _>`, not `Result<reqwest::Client, _>`. The
1246    /// wrapper exists so the trait's public signature does not name the
1247    /// underlying HTTP library, insulating extension clients and custom
1248    /// transport impls from a future transport swap.
1249    ///
1250    /// The compile-time witness below pins the new shape; if a future
1251    /// refactor accidentally widens the return type back to
1252    /// `reqwest::Client`, the explicit typed `let` binding here breaks
1253    /// the build.
1254    #[tokio::test]
1255    async fn build_client_returns_opaque_http_client() {
1256        let result: Result<HttpClient, ClientError> = DefaultTransport.build_client();
1257        let http = result.expect("DefaultTransport::build_client must succeed");
1258        // Debug output is opaque — no inner reqwest::Client representation.
1259        let dbg = format!("{http:?}");
1260        assert_eq!(
1261            dbg, "HttpClient",
1262            "HttpClient Debug must be opaque; the wrapper is the only public surface"
1263        );
1264    }
1265
1266    /// bd:JMAP-6r7c.36 — A custom `TransportConfig` impl constructs the
1267    /// returned `HttpClient` via `HttpClient::new(reqwest::Client)`. This
1268    /// pins the public construction path; if the constructor signature
1269    /// changes, the custom-impl pattern below fails to compile and
1270    /// downstream consumers will pick up the same migration signal at
1271    /// build time.
1272    #[test]
1273    fn http_client_new_is_callable_from_custom_transport_impl() {
1274        struct StubTransport;
1275        impl TransportConfig for StubTransport {
1276            fn build_client(&self) -> Result<HttpClient, ClientError> {
1277                let client = reqwest::ClientBuilder::new()
1278                    .build()
1279                    .map_err(ClientError::from_reqwest)?;
1280                Ok(HttpClient::new(client))
1281            }
1282        }
1283
1284        StubTransport
1285            .build_client()
1286            .expect("custom transport must build the opaque HttpClient");
1287    }
1288
1289    /// bd:JMAP-6r7c.62 — `AuthHeader`'s `Debug` impl MUST redact the value
1290    /// bytes to "[REDACTED]". This is the compile-time guard against a
1291    /// future `AuthProvider` impl that writes `tracing::trace!(?header,
1292    /// ...)`. The pre-bd:JMAP-6r7c.62 shape `Option<(&str, &str)>` would
1293    /// have rendered the value verbatim via `?`-formatter. The canary
1294    /// literal is the test's independent oracle, never derived from
1295    /// `AuthHeader`'s internal state.
1296    #[test]
1297    fn auth_header_debug_redacts_value() {
1298        const CANARY: &str = "CANARY-AUTH-VALUE-DO-NOT-LEAK-456";
1299        let header = AuthHeader::new("authorization", CANARY);
1300        let dbg = format!("{header:?}");
1301        assert!(
1302            !dbg.contains(CANARY),
1303            "AuthHeader Debug must not contain the canary value: {dbg}"
1304        );
1305        assert!(
1306            dbg.contains("[REDACTED]"),
1307            "AuthHeader Debug must render '[REDACTED]' for the value field: {dbg}"
1308        );
1309        // The name is non-sensitive and may surface to aid diagnostics.
1310        assert!(
1311            dbg.contains("authorization"),
1312            "AuthHeader Debug should include the header name for diagnostic value: {dbg}"
1313        );
1314    }
1315
1316    /// bd:JMAP-6r7c.62 — `expose_value` is the only path to the credential
1317    /// bytes, so the call-site name (`expose_value`) is the visible
1318    /// signal in code review. This test pins the accessor name + return
1319    /// value, so a future rename of the accessor breaks the test loudly.
1320    #[test]
1321    fn auth_header_expose_value_returns_credential_bytes() {
1322        const VALUE: &str = "Bearer some-token-123";
1323        let header = AuthHeader::new("authorization", VALUE);
1324        assert_eq!(header.name(), "authorization");
1325        assert_eq!(header.expose_value(), VALUE);
1326    }
1327
1328    /// Oracle: BearerAuth's Debug impl never reveals the underlying token.
1329    ///
1330    /// Tripwire against a future refactor that adds `#[derive(Debug)]` to
1331    /// BearerAuth (clearing the manual redacting impl), or that prints the
1332    /// inner `header_string`. The canary literal is the independent
1333    /// oracle — it is under the test's control, never derived from
1334    /// BearerAuth's internal state.
1335    #[test]
1336    fn bearer_auth_debug_does_not_leak_token() {
1337        const CANARY: &str = "CANARY-TOKEN-DO-NOT-LEAK-123";
1338        let auth = BearerAuth::new(CANARY).expect("valid ASCII token must construct");
1339        let dbg = format!("{auth:?}");
1340        assert!(
1341            !dbg.contains(CANARY),
1342            "BearerAuth Debug must not contain the raw token; got: {dbg}"
1343        );
1344    }
1345
1346    /// Oracle: BasicAuth's Debug impl never reveals the underlying credentials.
1347    ///
1348    /// Same tripwire shape as `bearer_auth_debug_does_not_leak_token`.
1349    /// The canary username and password are independent literals; the
1350    /// assertion verifies neither, nor the base64 encoding of their
1351    /// concatenation, appears in the Debug output.
1352    #[test]
1353    fn basic_auth_debug_does_not_leak_credentials() {
1354        const CANARY_USER: &str = "CANARY-USER-DO-NOT-LEAK";
1355        const CANARY_PASS: &str = "CANARY-PASS-DO-NOT-LEAK";
1356        let auth =
1357            BasicAuth::new(CANARY_USER, CANARY_PASS).expect("valid credentials must construct");
1358        let dbg = format!("{auth:?}");
1359        assert!(
1360            !dbg.contains(CANARY_USER),
1361            "BasicAuth Debug must not contain the raw username; got: {dbg}"
1362        );
1363        assert!(
1364            !dbg.contains(CANARY_PASS),
1365            "BasicAuth Debug must not contain the raw password; got: {dbg}"
1366        );
1367        // Also catch a regression that prints the pre-validated header_string,
1368        // which would surface the base64-encoded credentials.
1369        let base64_pair = BASE64_STANDARD.encode(format!("{CANARY_USER}:{CANARY_PASS}"));
1370        assert!(
1371            !dbg.contains(&base64_pair),
1372            "BasicAuth Debug must not contain the base64-encoded credentials; got: {dbg}"
1373        );
1374    }
1375
1376    /// Oracle: `CustomCaTransport`'s Debug impl never prints the raw DER
1377    /// certificate bytes (bd:JMAP-6r7c.13).
1378    ///
1379    /// CA DER bytes are not a credential, but they are deployment-identifying
1380    /// material — Subject DN, public key, signing algorithm, X.509
1381    /// extensions. Surfacing them in `tracing` output reveals which private-
1382    /// CA-using customer the client is configured for. The canary byte
1383    /// sequence is an unmistakable repeating literal `0xCA` 32 times — the
1384    /// test asserts neither the lower-hex nor the upper-hex nor the
1385    /// Rust-debug `[202, 202, ...]` rendering of those bytes appears in the
1386    /// Debug output. Same tripwire shape as the BearerAuth and BasicAuth
1387    /// tests above.
1388    #[test]
1389    fn custom_ca_transport_debug_does_not_leak_der_bytes() {
1390        // 32 copies of 0xCA — an unmistakable sentinel byte. No conformant
1391        // DER encoder produces a run like this, so any leakage path
1392        // surfaces it intact.
1393        let canary_der = vec![0xCA_u8; 32];
1394        let transport = CustomCaTransport::new(canary_der);
1395        let dbg = format!("{transport:?}");
1396        // Lowercase hex rendering of the canary.
1397        assert!(
1398            !dbg.contains("cacacacacacacacacacacacacacacacacacacacacacacacacacacacacacacaca"),
1399            "CustomCaTransport Debug must not contain lowercase-hex DER bytes; got: {dbg}"
1400        );
1401        // Uppercase hex rendering — in case a future fmt::Debug uses {:X}.
1402        assert!(
1403            !dbg.contains("CACACACACACACACACACACACACACACACACACACACACACACACACACACACACACACACA"),
1404            "CustomCaTransport Debug must not contain uppercase-hex DER bytes; got: {dbg}"
1405        );
1406        // Rust `[u8]` default Debug rendering — `[202, 202, ...]`. A
1407        // derive(Debug) regression on the field would emit this shape.
1408        assert!(
1409            !dbg.contains("202, 202, 202"),
1410            "CustomCaTransport Debug must not contain decimal-byte DER bytes; got: {dbg}"
1411        );
1412        // Positive assertion: the redacted form mentions the length, so a
1413        // reader of `tracing` output still knows the field is non-empty.
1414        assert!(
1415            dbg.contains("32 bytes"),
1416            "CustomCaTransport Debug should record the DER byte length; got: {dbg}"
1417        );
1418    }
1419
1420    // bd:JMAP-6r7c.37 — PEM constructor tests.
1421    //
1422    // Oracle: a hand-generated self-signed certificate produced by
1423    // `openssl req -x509 -newkey rsa:2048 -nodes -days 36500
1424    // -subj "/CN=JMAP-6r7c.37 test CA"`. The PEM and DER forms of the
1425    // same certificate are committed under tests/fixtures/tls/. The PEM
1426    // → DER conversion ran via `openssl x509 -outform DER`. Both files
1427    // are oracles independent of the code under test: the PEM was not
1428    // produced by `parse_first_pem_cert` and the DER was not produced
1429    // by reqwest. The test asserts the round-trip matches OpenSSL's
1430    // canonical bytes.
1431    const TEST_CA_PEM: &[u8] = include_bytes!("../tests/fixtures/tls/test-ca.pem");
1432    const TEST_CA_DER: &[u8] = include_bytes!("../tests/fixtures/tls/test-ca.der");
1433
1434    #[test]
1435    fn from_pem_bytes_extracts_der_matching_openssl_oracle() {
1436        let transport = CustomCaTransport::from_pem_bytes(TEST_CA_PEM)
1437            .expect("test-ca.pem fixture must parse as a valid CA");
1438        assert_eq!(
1439            transport.der_cert.as_slice(),
1440            TEST_CA_DER,
1441            "PEM-decoded DER must match the openssl-produced reference DER fixture"
1442        );
1443    }
1444
1445    #[test]
1446    fn from_pem_bytes_rejects_empty_input() {
1447        let err = CustomCaTransport::from_pem_bytes(b"").expect_err("empty input must be rejected");
1448        assert!(
1449            matches!(err, ClientError::InvalidArgument(_)),
1450            "empty input must surface as InvalidArgument; got {err:?}"
1451        );
1452    }
1453
1454    #[test]
1455    fn from_pem_bytes_rejects_input_with_no_pem_framing() {
1456        let err = CustomCaTransport::from_pem_bytes(b"this is not a PEM file")
1457            .expect_err("non-PEM input must be rejected");
1458        assert!(
1459            matches!(err, ClientError::InvalidArgument(_)),
1460            "non-PEM input must surface as InvalidArgument; got {err:?}"
1461        );
1462    }
1463
1464    #[test]
1465    fn from_pem_bytes_rejects_pem_with_invalid_base64() {
1466        // PEM framing with junk inside — should fail base64 decode.
1467        let bad =
1468            b"-----BEGIN CERTIFICATE-----\nNOT VALID BASE64 @#$%\n-----END CERTIFICATE-----\n";
1469        let err =
1470            CustomCaTransport::from_pem_bytes(bad).expect_err("invalid base64 must be rejected");
1471        assert!(
1472            matches!(err, ClientError::InvalidArgument(_)),
1473            "invalid-base64 PEM must surface as InvalidArgument; got {err:?}"
1474        );
1475    }
1476
1477    #[test]
1478    fn from_pem_bytes_accepts_garbage_der_payload_deferring_validation_to_build() {
1479        use base64::Engine as _;
1480        // Properly-PEM-framed garbage bytes: PEM framing is correct,
1481        // base64 decodes OK, but the inner bytes are not a DER
1482        // certificate. By design (matching CustomCaTransport::new's
1483        // contract), from_pem_bytes accepts these bytes — DER validity
1484        // is checked at build_client() time, where it surfaces as
1485        // ClientError::Http through reqwest. This test documents that
1486        // contract.
1487        let garbage_der = [0u8; 16];
1488        let body = base64::engine::general_purpose::STANDARD.encode(garbage_der);
1489        let pem = format!("-----BEGIN CERTIFICATE-----\n{body}\n-----END CERTIFICATE-----\n");
1490        let transport = CustomCaTransport::from_pem_bytes(pem.as_bytes())
1491            .expect("PEM framing OK + base64 OK = constructor accepts");
1492        assert_eq!(
1493            transport.der_cert.as_slice(),
1494            &garbage_der,
1495            "PEM helper must extract the exact base64-decoded bytes"
1496        );
1497        // build_client() is where rustls/native-tls actually parses the
1498        // DER and would reject the garbage. Exercising that here would
1499        // require constructing a real ClientBuilder, which is covered
1500        // by the broader test suite's integration tests.
1501    }
1502
1503    // Note: a dyn-AuthProvider Debug test (bead JMAP-sc1b.79 item #4) is
1504    // intentionally omitted. The AuthProvider trait does not have
1505    // `std::fmt::Debug` as a supertrait, so `Box<dyn AuthProvider>` is
1506    // not `Debug`-formattable. Adding `Debug` to the trait bound would
1507    // be a foundation-crate public API change far outside the scope of
1508    // a regression-test bead. The concrete-type tests above already
1509    // catch the hygiene contract for every shipped AuthProvider
1510    // implementation; the only way a new AuthProvider leaks credentials
1511    // via Debug is if its own concrete impl does so, and that is
1512    // caught by the new-impl reviewer (cookie-cutter rule).
1513}