Skip to main content

codex_http_client/
custom_ca.rs

1//! Custom CA handling shared by Codex outbound HTTP and websocket clients.
2//!
3//! Codex constructs outbound reqwest clients and secure websocket connections in a few crates, but
4//! they all need the same trust-store policy when enterprise proxies or gateways intercept TLS.
5//! This module centralizes that policy so callers can start from an ordinary
6//! `reqwest::ClientBuilder` or rustls client config, layer in custom CA support, and either get
7//! back a configured transport or a user-facing error that explains how to fix a misconfigured CA
8//! bundle.
9//!
10//! The module intentionally has a narrow responsibility:
11//!
12//! - read CA material from `CODEX_CA_CERTIFICATE`, falling back to `SSL_CERT_FILE`
13//! - normalize PEM variants that show up in real deployments, including OpenSSL-style
14//!   `TRUSTED CERTIFICATE` labels and bundles that also contain CRLs
15//! - return user-facing errors that explain how to fix misconfigured CA files
16//!
17//! Its production contract is narrow: produce a transport configuration whose root store contains
18//! every parseable certificate block from the configured PEM bundle, or fail early with a precise
19//! error before the caller starts network traffic.
20//!
21//! In this module's test setup, a hermetic test is one whose result depends only on the CA file
22//! and environment variables that the test chose for itself. That matters here because the normal
23//! reqwest client-construction path is not hermetic enough for environment-sensitive tests:
24//!
25//! - on macOS seatbelt runs, `reqwest::Client::builder().build()` can panic inside
26//!   `system-configuration` while probing platform proxy settings, which means the process can die
27//!   before the custom-CA code reports success or a structured error. That matters in practice
28//!   because Codex itself commonly runs spawned test processes under seatbelt, so this is not just
29//!   a hypothetical CI edge case.
30//! - child processes inherit CA-related environment variables by default, which lets developer
31//!   shell state or CI configuration affect a test unless the test scrubs those variables first
32//!
33//! The tests in this crate therefore stay split across two layers:
34//!
35//! - unit tests in this module cover env-selection logic without constructing a real client
36//! - subprocess integration tests under `tests/` cover real client construction through
37//!   [`build_reqwest_client_for_subprocess_tests`], which disables reqwest proxy autodetection so
38//!   the tests can observe custom-CA success and failure directly, including one TLS handshake
39//!   through a local HTTPS server
40//! - those subprocess tests also scrub inherited CA environment variables before launch so their
41//!   result depends only on the test fixtures and env vars set by the test itself
42
43use std::env;
44use std::fs;
45use std::io;
46use std::path::Path;
47use std::path::PathBuf;
48use std::sync::Arc;
49
50use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
51use rustls::ClientConfig;
52use rustls::RootCertStore;
53use rustls_pki_types::CertificateDer;
54use rustls_pki_types::pem::PemObject;
55use rustls_pki_types::pem::SectionKind;
56use rustls_pki_types::pem::{self};
57use thiserror::Error;
58use tracing::info;
59use tracing::warn;
60
61pub const CODEX_CA_CERT_ENV: &str = "CODEX_CA_CERTIFICATE";
62pub const SSL_CERT_FILE_ENV: &str = "SSL_CERT_FILE";
63const CA_CERT_HINT: &str = "If you set CODEX_CA_CERTIFICATE or SSL_CERT_FILE, ensure it points to a PEM file containing one or more CERTIFICATE blocks, or unset it to use system roots.";
64type PemSection = (SectionKind, Vec<u8>);
65
66/// Describes why a transport using shared custom CA support could not be constructed.
67///
68/// These failure modes apply to both reqwest client construction and websocket TLS
69/// configuration. A build can fail because the configured CA file could not be read, could not be
70/// parsed as certificates, contained certs that the target TLS stack refused to register, or
71/// because the final reqwest client builder failed. Callers that do not care about the
72/// distinction can rely on the `From<BuildCustomCaTransportError> for io::Error` conversion.
73#[derive(Debug, Error)]
74pub enum BuildCustomCaTransportError {
75    /// Reading the selected CA file from disk failed before any PEM parsing could happen.
76    #[error(
77        "Failed to read CA certificate file {} selected by {}: {source}. {hint}",
78        path.display(),
79        source_env,
80        hint = CA_CERT_HINT
81    )]
82    ReadCaFile {
83        source_env: &'static str,
84        path: PathBuf,
85        source: io::Error,
86    },
87
88    /// The selected CA file was readable, but did not produce usable certificate material.
89    #[error(
90        "Failed to load CA certificates from {} selected by {}: {detail}. {hint}",
91        path.display(),
92        source_env,
93        hint = CA_CERT_HINT
94    )]
95    InvalidCaFile {
96        source_env: &'static str,
97        path: PathBuf,
98        detail: String,
99    },
100
101    /// One parsed certificate block could not be registered with the reqwest client builder.
102    #[error(
103        "Failed to parse certificate #{certificate_index} from {} selected by {}: {source}. {hint}",
104        path.display(),
105        source_env,
106        hint = CA_CERT_HINT
107    )]
108    RegisterCertificate {
109        source_env: &'static str,
110        path: PathBuf,
111        certificate_index: usize,
112        source: reqwest::Error,
113    },
114
115    /// Reqwest rejected the final client configuration after a custom CA bundle was loaded.
116    #[error(
117        "Failed to build HTTP client while using CA bundle from {} ({}): {source}",
118        source_env,
119        path.display()
120    )]
121    BuildClientWithCustomCa {
122        source_env: &'static str,
123        path: PathBuf,
124        #[source]
125        source: reqwest::Error,
126    },
127
128    /// Reqwest rejected the final client configuration while using only system roots.
129    #[error("Failed to build HTTP client while using system root certificates: {0}")]
130    BuildClientWithSystemRoots(#[source] reqwest::Error),
131
132    /// One parsed certificate block could not be registered with the websocket TLS root store.
133    #[error(
134        "Failed to register certificate #{certificate_index} from {} selected by {} in rustls root store: {source}. {hint}",
135        path.display(),
136        source_env,
137        hint = CA_CERT_HINT
138    )]
139    RegisterRustlsCertificate {
140        source_env: &'static str,
141        path: PathBuf,
142        certificate_index: usize,
143        source: rustls::Error,
144    },
145}
146
147impl From<BuildCustomCaTransportError> for io::Error {
148    fn from(error: BuildCustomCaTransportError) -> Self {
149        match error {
150            BuildCustomCaTransportError::ReadCaFile { ref source, .. } => {
151                io::Error::new(source.kind(), error)
152            }
153            BuildCustomCaTransportError::InvalidCaFile { .. }
154            | BuildCustomCaTransportError::RegisterCertificate { .. }
155            | BuildCustomCaTransportError::RegisterRustlsCertificate { .. } => {
156                io::Error::new(io::ErrorKind::InvalidData, error)
157            }
158            BuildCustomCaTransportError::BuildClientWithCustomCa { .. }
159            | BuildCustomCaTransportError::BuildClientWithSystemRoots(_) => io::Error::other(error),
160        }
161    }
162}
163
164/// Builds a reqwest client that honors Codex custom CA environment variables.
165///
166/// Callers supply the baseline builder configuration they need, and this helper layers in custom
167/// CA handling before finally constructing the client. `CODEX_CA_CERTIFICATE` takes precedence
168/// over `SSL_CERT_FILE`, and empty values for either are treated as unset so callers do not
169/// accidentally turn `VAR=""` into a bogus path lookup.
170///
171/// Callers that build a raw `reqwest::Client` directly bypass this policy entirely. That is an
172/// easy mistake to make when adding a new outbound Codex HTTP path, and the resulting bug only
173/// shows up in environments where a proxy or gateway requires a custom root CA.
174///
175/// # Errors
176///
177/// Returns a [`BuildCustomCaTransportError`] when the configured CA file is unreadable,
178/// malformed, or contains a certificate block that `reqwest` cannot register as a root.
179pub fn build_reqwest_client_with_custom_ca(
180    builder: reqwest::ClientBuilder,
181) -> Result<reqwest::Client, BuildCustomCaTransportError> {
182    build_reqwest_client_with_env(&ProcessEnv, builder)
183}
184
185/// Builds a rustls client config when a Codex custom CA bundle is configured.
186///
187/// This is the websocket-facing sibling of [`build_reqwest_client_with_custom_ca`]. When
188/// `CODEX_CA_CERTIFICATE` or `SSL_CERT_FILE` selects a CA bundle, the returned config starts from
189/// the platform native roots and then adds the configured custom CA certificates. When no custom
190/// CA env var is set, this returns `Ok(None)` so websocket callers can keep using their ordinary
191/// default connector path.
192///
193/// Callers that let tungstenite build its default TLS connector directly bypass this policy
194/// entirely. That bug only shows up in environments where secure websocket traffic needs the same
195/// enterprise root CA bundle as HTTPS traffic.
196pub fn maybe_build_rustls_client_config_with_custom_ca()
197-> Result<Option<Arc<ClientConfig>>, BuildCustomCaTransportError> {
198    maybe_build_rustls_client_config_with_env(&ProcessEnv)
199}
200
201/// Builds a rustls client config using native roots and any configured Codex custom CA bundle.
202///
203/// Unlike [`maybe_build_rustls_client_config_with_custom_ca`], this always returns a config. Use
204/// this when the caller must perform TLS itself instead of delegating default configuration to a
205/// transport library.
206pub fn build_rustls_client_config_with_custom_ca()
207-> Result<Arc<ClientConfig>, BuildCustomCaTransportError> {
208    build_rustls_client_config_with_env(&ProcessEnv)
209}
210
211/// Builds a reqwest client for spawned subprocess tests that exercise CA behavior.
212///
213/// This is the test-only client-construction path used by the subprocess coverage in `tests/`.
214/// The module-level docs explain the hermeticity problem in full; this helper only addresses the
215/// reqwest proxy-discovery panic side of that problem by disabling proxy autodetection. The tests
216/// still scrub inherited CA environment variables themselves. Normal production callers should use
217/// [`build_reqwest_client_with_custom_ca`] so test-only proxy behavior does not leak into
218/// ordinary client construction.
219pub fn build_reqwest_client_for_subprocess_tests(
220    builder: reqwest::ClientBuilder,
221) -> Result<reqwest::Client, BuildCustomCaTransportError> {
222    build_reqwest_client_with_env(&ProcessEnv, builder.no_proxy())
223}
224
225fn maybe_build_rustls_client_config_with_env(
226    env_source: &dyn EnvSource,
227) -> Result<Option<Arc<ClientConfig>>, BuildCustomCaTransportError> {
228    let Some(bundle) = env_source.configured_ca_bundle() else {
229        return Ok(None);
230    };
231
232    build_rustls_client_config(Some(&bundle)).map(Some)
233}
234
235fn build_rustls_client_config_with_env(
236    env_source: &dyn EnvSource,
237) -> Result<Arc<ClientConfig>, BuildCustomCaTransportError> {
238    let bundle = env_source.configured_ca_bundle();
239    build_rustls_client_config(bundle.as_ref())
240}
241
242fn build_rustls_client_config(
243    bundle: Option<&ConfiguredCaBundle>,
244) -> Result<Arc<ClientConfig>, BuildCustomCaTransportError> {
245    ensure_rustls_crypto_provider();
246
247    // Start from the platform roots so websocket callers keep the same baseline trust behavior
248    // they would get from tungstenite's default rustls connector, then layer in the Codex custom
249    // CA bundle on top when configured.
250    let mut root_store = RootCertStore::empty();
251    let rustls_native_certs::CertificateResult { certs, errors, .. } =
252        rustls_native_certs::load_native_certs();
253    if !errors.is_empty() {
254        warn!(
255            native_root_error_count = errors.len(),
256            "encountered errors while loading native root certificates"
257        );
258    }
259    let _ = root_store.add_parsable_certificates(certs);
260
261    if let Some(bundle) = bundle {
262        let certificates = bundle.load_certificates()?;
263        for (idx, cert) in certificates.into_iter().enumerate() {
264            if let Err(source) = root_store.add(cert) {
265                warn!(
266                    source_env = bundle.source_env,
267                    ca_path = %bundle.path.display(),
268                    certificate_index = idx + 1,
269                    error = %source,
270                    "failed to register CA certificate in rustls root store"
271                );
272                return Err(BuildCustomCaTransportError::RegisterRustlsCertificate {
273                    source_env: bundle.source_env,
274                    path: bundle.path.clone(),
275                    certificate_index: idx + 1,
276                    source,
277                });
278            }
279        }
280    }
281
282    Ok(Arc::new(
283        ClientConfig::builder()
284            .with_root_certificates(root_store)
285            .with_no_client_auth(),
286    ))
287}
288
289/// Builds a reqwest client using an injected environment source and reqwest builder.
290///
291/// This exists so tests can exercise precedence behavior deterministically without mutating the
292/// real process environment. It selects the CA bundle, delegates file parsing to
293/// [`ConfiguredCaBundle::load_certificates`], preserves the caller's chosen `reqwest` builder
294/// configuration, forces rustls when a custom CA is configured, and finally registers each parsed
295/// certificate with that builder.
296fn build_reqwest_client_with_env(
297    env_source: &dyn EnvSource,
298    mut builder: reqwest::ClientBuilder,
299) -> Result<reqwest::Client, BuildCustomCaTransportError> {
300    if let Some(bundle) = env_source.configured_ca_bundle() {
301        ensure_rustls_crypto_provider();
302        info!(
303            source_env = bundle.source_env,
304            ca_path = %bundle.path.display(),
305            "building HTTP client with rustls backend for custom CA bundle"
306        );
307        builder = builder.use_rustls_tls();
308
309        let certificates = bundle.load_certificates()?;
310
311        for (idx, cert) in certificates.iter().enumerate() {
312            let certificate = match reqwest::Certificate::from_der(cert.as_ref()) {
313                Ok(certificate) => certificate,
314                Err(source) => {
315                    warn!(
316                        source_env = bundle.source_env,
317                        ca_path = %bundle.path.display(),
318                        certificate_index = idx + 1,
319                        error = %source,
320                        "failed to register CA certificate"
321                    );
322                    return Err(BuildCustomCaTransportError::RegisterCertificate {
323                        source_env: bundle.source_env,
324                        path: bundle.path.clone(),
325                        certificate_index: idx + 1,
326                        source,
327                    });
328                }
329            };
330            builder = builder.add_root_certificate(certificate);
331        }
332        return match builder.build() {
333            Ok(client) => Ok(client),
334            Err(source) => {
335                warn!(
336                    source_env = bundle.source_env,
337                    ca_path = %bundle.path.display(),
338                    error = %source,
339                    "failed to build client after loading custom CA bundle"
340                );
341                Err(BuildCustomCaTransportError::BuildClientWithCustomCa {
342                    source_env: bundle.source_env,
343                    path: bundle.path.clone(),
344                    source,
345                })
346            }
347        };
348    }
349
350    info!(
351        codex_ca_certificate_configured = false,
352        ssl_cert_file_configured = false,
353        "using system root certificates because no CA override environment variable was selected"
354    );
355
356    match builder.build() {
357        Ok(client) => Ok(client),
358        Err(source) => {
359            warn!(
360                error = %source,
361                "failed to build client while using system root certificates"
362            );
363            Err(BuildCustomCaTransportError::BuildClientWithSystemRoots(
364                source,
365            ))
366        }
367    }
368}
369
370/// Abstracts environment access so tests can cover precedence rules without mutating process-wide
371/// variables.
372trait EnvSource {
373    /// Returns the environment variable value for `key`, if this source considers it set.
374    ///
375    /// Implementations should return `None` for absent values and may also collapse unreadable
376    /// process-environment states into `None`, because the custom CA logic treats both cases as
377    /// "no override configured". Callers build precedence and empty-string handling on top of this
378    /// method, so implementations should not trim or normalize the returned string.
379    fn var(&self, key: &str) -> Option<String>;
380
381    /// Returns a non-empty environment variable value interpreted as a filesystem path.
382    ///
383    /// Empty strings are treated as unset because presence here acts as a boolean "custom CA
384    /// override requested" signal. This keeps the precedence logic from treating `VAR=""` as an
385    /// attempt to open the current working directory or some other platform-specific oddity once
386    /// it is converted into a path.
387    fn non_empty_path(&self, key: &str) -> Option<PathBuf> {
388        self.var(key)
389            .filter(|value| !value.is_empty())
390            .map(PathBuf::from)
391    }
392
393    /// Returns the configured CA bundle and which environment variable selected it.
394    ///
395    /// `CODEX_CA_CERTIFICATE` wins over `SSL_CERT_FILE` because it is the Codex-specific override.
396    /// Keeping the winning variable name with the path lets later logging explain not only which
397    /// file was used but also why that file was chosen.
398    fn configured_ca_bundle(&self) -> Option<ConfiguredCaBundle> {
399        self.non_empty_path(CODEX_CA_CERT_ENV)
400            .map(|path| ConfiguredCaBundle {
401                source_env: CODEX_CA_CERT_ENV,
402                path,
403            })
404            .or_else(|| {
405                self.non_empty_path(SSL_CERT_FILE_ENV)
406                    .map(|path| ConfiguredCaBundle {
407                        source_env: SSL_CERT_FILE_ENV,
408                        path,
409                    })
410            })
411    }
412}
413
414/// Reads CA configuration from the real process environment.
415///
416/// This is the production `EnvSource` implementation used by
417/// [`build_reqwest_client_with_custom_ca`]. Tests substitute in-memory env maps so they can
418/// exercise precedence and empty-value behavior without mutating process-global variables.
419struct ProcessEnv;
420
421impl EnvSource for ProcessEnv {
422    fn var(&self, key: &str) -> Option<String> {
423        env::var(key).ok()
424    }
425}
426
427/// Identifies the CA bundle selected for a client and the policy decision that selected it.
428///
429/// This is the concrete output of the environment-precedence logic. Callers use `source_env` for
430/// logging and diagnostics, while `path` is the bundle that will actually be loaded.
431struct ConfiguredCaBundle {
432    /// The environment variable that won the precedence check for this bundle.
433    source_env: &'static str,
434    /// The filesystem path that should be read as PEM certificate input.
435    path: PathBuf,
436}
437
438impl ConfiguredCaBundle {
439    /// Loads certificates from this selected CA bundle.
440    ///
441    /// The bundle already represents the output of environment-precedence selection, so this is
442    /// the natural point where the file-loading phase begins. The method owns the high-level
443    /// success/failure logs for that phase and keeps the source env and path together for lower-
444    /// level parsing and error shaping.
445    fn load_certificates(
446        &self,
447    ) -> Result<Vec<CertificateDer<'static>>, BuildCustomCaTransportError> {
448        match self.parse_certificates() {
449            Ok(certificates) => {
450                info!(
451                    source_env = self.source_env,
452                    ca_path = %self.path.display(),
453                    certificate_count = certificates.len(),
454                    "loaded certificates from custom CA bundle"
455                );
456                Ok(certificates)
457            }
458            Err(error) => {
459                warn!(
460                    source_env = self.source_env,
461                    ca_path = %self.path.display(),
462                    error = %error,
463                    "failed to load custom CA bundle"
464                );
465                Err(error)
466            }
467        }
468    }
469
470    /// Loads every certificate block from a PEM file intended for Codex CA overrides.
471    ///
472    /// This accepts a few common real-world variants so Codex behaves like other CA-aware tooling:
473    /// leading comments are preserved, `TRUSTED CERTIFICATE` labels are normalized to standard
474    /// certificate labels, and embedded CRLs are ignored when they are well-formed enough for the
475    /// section iterator to classify them.
476    fn parse_certificates(
477        &self,
478    ) -> Result<Vec<CertificateDer<'static>>, BuildCustomCaTransportError> {
479        let pem_data = self.read_pem_data()?;
480        let normalized_pem = NormalizedPem::from_pem_data(self.source_env, &self.path, &pem_data);
481
482        let mut certificates = Vec::new();
483        let mut logged_crl_presence = false;
484        for section_result in normalized_pem.sections() {
485            // Known limitation: if `rustls-pki-types` fails while parsing a malformed CRL section,
486            // that error is reported here before we can classify the block as ignorable. A bundle
487            // containing valid certificates plus a malformed `X509 CRL` therefore still fails to
488            // load today, even though well-formed CRLs are ignored.
489            let (section_kind, der) = match section_result {
490                Ok(section) => section,
491                Err(error) => return Err(self.pem_parse_error(&error)),
492            };
493            match section_kind {
494                SectionKind::Certificate => {
495                    // Standard CERTIFICATE blocks already decode to the exact DER bytes reqwest
496                    // wants. Only OpenSSL TRUSTED CERTIFICATE blocks need trimming to drop any
497                    // trailing X509_AUX trust metadata before registration.
498                    let cert_der = normalized_pem.certificate_der(&der).ok_or_else(|| {
499                        self.invalid_ca_file(
500                            "failed to extract certificate data from TRUSTED CERTIFICATE: invalid DER length",
501                        )
502                    })?;
503                    certificates.push(CertificateDer::from(cert_der.to_vec()));
504                }
505                SectionKind::Crl if !logged_crl_presence => {
506                    info!(
507                        source_env = self.source_env,
508                        ca_path = %self.path.display(),
509                        "ignoring X509 CRL entries found in custom CA bundle"
510                    );
511                    logged_crl_presence = true;
512                }
513                _ => {}
514            }
515        }
516
517        if certificates.is_empty() {
518            return Err(self.pem_parse_error(&pem::Error::NoItemsFound));
519        }
520
521        Ok(certificates)
522    }
523
524    /// Reads the CA bundle bytes while preserving the original filesystem error kind.
525    ///
526    /// The caller wants a user-facing error that includes the bundle path and remediation hint, but
527    /// higher-level surfaces still benefit from distinguishing "not found" from other I/O
528    /// failures. This helper keeps both pieces together.
529    fn read_pem_data(&self) -> Result<Vec<u8>, BuildCustomCaTransportError> {
530        fs::read(&self.path).map_err(|source| BuildCustomCaTransportError::ReadCaFile {
531            source_env: self.source_env,
532            path: self.path.clone(),
533            source,
534        })
535    }
536
537    /// Rewrites PEM parsing failures into user-facing configuration errors.
538    ///
539    /// The underlying parser knows whether the file was empty, malformed, or contained unsupported
540    /// PEM content, but callers need a message that also points them back to the relevant
541    /// environment variables and the expected remediation.
542    fn pem_parse_error(&self, error: &pem::Error) -> BuildCustomCaTransportError {
543        let detail = match error {
544            pem::Error::NoItemsFound => "no certificates found in PEM file".to_string(),
545            _ => format!("failed to parse PEM file: {error}"),
546        };
547
548        self.invalid_ca_file(detail)
549    }
550
551    /// Creates an invalid-CA error tied to this file path.
552    ///
553    /// Most parse-time failures in this module eventually collapse to "the configured CA bundle is
554    /// not usable", but the detailed reason still matters for operator debugging. Centralizing that
555    /// formatting keeps the path and hint text consistent across the different parser branches.
556    fn invalid_ca_file(&self, detail: impl std::fmt::Display) -> BuildCustomCaTransportError {
557        BuildCustomCaTransportError::InvalidCaFile {
558            source_env: self.source_env,
559            path: self.path.clone(),
560            detail: detail.to_string(),
561        }
562    }
563}
564
565/// The PEM text shape after OpenSSL compatibility normalization.
566///
567/// `Standard` means the input already used ordinary PEM certificate labels. `TrustedCertificate`
568/// means the input used OpenSSL's `TRUSTED CERTIFICATE` labels, so callers must also be prepared
569/// to trim trailing `X509_AUX` bytes from decoded certificate sections.
570enum NormalizedPem {
571    /// PEM contents that already used ordinary `CERTIFICATE` labels.
572    Standard(String),
573    /// PEM contents rewritten from OpenSSL `TRUSTED CERTIFICATE` labels to `CERTIFICATE`.
574    TrustedCertificate(String),
575}
576
577impl NormalizedPem {
578    /// Normalizes PEM text from a CA bundle into the label shape this module expects.
579    ///
580    /// Codex only needs certificate DER bytes to seed `reqwest`'s root store, but operators may
581    /// point it at CA files that came from OpenSSL tooling rather than from a minimal certificate
582    /// bundle. OpenSSL's `TRUSTED CERTIFICATE` form is one such variant: it is still certificate
583    /// material, but it uses a different PEM label and may carry auxiliary trust metadata that
584    /// this crate does not consume. This constructor rewrites only the PEM labels so the mixed-
585    /// section parser can keep treating the file as certificate input. The rustls ecosystem does
586    /// not currently accept `TRUSTED CERTIFICATE` as a standard certificate label upstream, so
587    /// this remains a local compatibility shim rather than behavior delegated to
588    /// `rustls-pki-types`.
589    ///
590    /// See also:
591    /// - rustls/pemfile issue #52, closed as not planned, documenting that
592    ///   `BEGIN TRUSTED CERTIFICATE` blocks are ignored upstream:
593    ///   <https://github.com/rustls/pemfile/issues/52>
594    /// - OpenSSL `x509 -trustout`, which emits `TRUSTED CERTIFICATE` PEM blocks:
595    ///   <https://docs.openssl.org/master/man1/openssl-x509/>
596    /// - OpenSSL PEM readers, which document that plain `PEM_read_bio_X509()` discards auxiliary
597    ///   trust settings:
598    ///   <https://docs.openssl.org/master/man3/PEM_read_bio_PrivateKey/>
599    /// - `openssl s_server`, a real OpenSSL-based server/test tool that operates in this
600    ///   ecosystem:
601    ///   <https://docs.openssl.org/master/man1/openssl-s_server/>
602    fn from_pem_data(source_env: &'static str, path: &Path, pem_data: &[u8]) -> Self {
603        let pem = String::from_utf8_lossy(pem_data);
604        if pem.contains("TRUSTED CERTIFICATE") {
605            info!(
606                source_env,
607                ca_path = %path.display(),
608                "normalizing OpenSSL TRUSTED CERTIFICATE labels in custom CA bundle"
609            );
610            Self::TrustedCertificate(
611                pem.replace("BEGIN TRUSTED CERTIFICATE", "BEGIN CERTIFICATE")
612                    .replace("END TRUSTED CERTIFICATE", "END CERTIFICATE"),
613            )
614        } else {
615            Self::Standard(pem.into_owned())
616        }
617    }
618
619    /// Returns the normalized PEM contents regardless of the label shape that produced them.
620    fn contents(&self) -> &str {
621        match self {
622            Self::Standard(contents) | Self::TrustedCertificate(contents) => contents,
623        }
624    }
625
626    /// Iterates over every recognized PEM section in this normalized PEM text.
627    ///
628    /// `rustls-pki-types` exposes mixed-section parsing through a `PemObject` implementation on the
629    /// `(SectionKind, Vec<u8>)` tuple. Keeping that type-directed API here lets callers iterate in
630    /// terms of normalized sections rather than trait plumbing.
631    fn sections(&self) -> impl Iterator<Item = Result<PemSection, pem::Error>> + '_ {
632        PemSection::pem_slice_iter(self.contents().as_bytes())
633    }
634
635    /// Returns the certificate DER bytes for one parsed PEM certificate section.
636    ///
637    /// Standard PEM certificates already decode to the exact DER bytes `reqwest` wants. OpenSSL
638    /// `TRUSTED CERTIFICATE` sections may append `X509_AUX` bytes after the certificate, so those
639    /// sections need to be trimmed down to their first DER object before registration.
640    fn certificate_der<'a>(&self, der: &'a [u8]) -> Option<&'a [u8]> {
641        match self {
642            Self::Standard(_) => Some(der),
643            Self::TrustedCertificate(_) => first_der_item(der),
644        }
645    }
646}
647
648/// Returns the first DER-encoded ASN.1 object in `der`, ignoring any trailing OpenSSL metadata.
649///
650/// A PEM `CERTIFICATE` block usually decodes to exactly one DER blob: the certificate itself.
651/// OpenSSL's `TRUSTED CERTIFICATE` variant is different. It starts with that same certificate
652/// blob, but may append extra `X509_AUX` bytes after it to describe OpenSSL-specific trust
653/// settings. `reqwest::Certificate::from_der` only understands the certificate object, not those
654/// trailing OpenSSL extensions.
655///
656/// This helper therefore asks a narrower question than "is this a valid certificate?": where does
657/// the first top-level DER object end? If that boundary can be found, the caller keeps only that
658/// prefix and discards the trailing trust metadata. If it cannot be found, the input is treated as
659/// malformed CA data.
660fn first_der_item(der: &[u8]) -> Option<&[u8]> {
661    der_item_length(der).map(|length| &der[..length])
662}
663
664/// Returns the byte length of the first DER item in `der`.
665///
666/// DER is a binary encoding for ASN.1 objects. Each object begins with:
667///
668/// - a tag byte describing what kind of object follows
669/// - one or more length bytes describing how many content bytes belong to that object
670/// - the content bytes themselves
671///
672/// For this module, the important fact is that a certificate is stored as one complete top-level
673/// DER object. Once we know that object's declared length, we know exactly where the certificate
674/// ends and where any trailing OpenSSL `X509_AUX` data begins.
675///
676/// This helper intentionally parses only that outer length field. It does not validate the inner
677/// certificate structure, the meaning of the tag, or every nested ASN.1 value. That narrower scope
678/// is deliberate: the caller only needs a safe slice boundary for the leading certificate object
679/// before handing those bytes to `reqwest`, which performs the real certificate parsing.
680///
681/// The implementation supports the DER length forms needed here:
682///
683/// - short form, where the length is stored directly in the second byte
684/// - long form, where the second byte says how many following bytes make up the length value
685///
686/// Indefinite lengths are rejected because DER does not permit them, and any declared length that
687/// would run past the end of the input is treated as malformed.
688fn der_item_length(der: &[u8]) -> Option<usize> {
689    let &length_octet = der.get(1)?;
690    if length_octet & 0x80 == 0 {
691        return Some(2 + usize::from(length_octet)).filter(|length| *length <= der.len());
692    }
693
694    let length_octets = usize::from(length_octet & 0x7f);
695    if length_octets == 0 {
696        return None;
697    }
698
699    let length_start = 2usize;
700    let length_end = length_start.checked_add(length_octets)?;
701    let length_bytes = der.get(length_start..length_end)?;
702    let mut content_length = 0usize;
703    for &byte in length_bytes {
704        content_length = content_length
705            .checked_mul(256)?
706            .checked_add(usize::from(byte))?;
707    }
708
709    length_end
710        .checked_add(content_length)
711        .filter(|length| *length <= der.len())
712}
713
714#[cfg(test)]
715mod tests {
716    use std::collections::HashMap;
717    use std::fs;
718    use std::path::PathBuf;
719
720    use pretty_assertions::assert_eq;
721    use tempfile::TempDir;
722
723    use super::BuildCustomCaTransportError;
724    use super::CODEX_CA_CERT_ENV;
725    use super::EnvSource;
726    use super::SSL_CERT_FILE_ENV;
727    use super::maybe_build_rustls_client_config_with_env;
728
729    const TEST_CERT: &str = include_str!("../tests/fixtures/test-ca.pem");
730
731    struct MapEnv {
732        values: HashMap<String, String>,
733    }
734
735    impl EnvSource for MapEnv {
736        fn var(&self, key: &str) -> Option<String> {
737            self.values.get(key).cloned()
738        }
739    }
740
741    fn map_env(pairs: &[(&str, &str)]) -> MapEnv {
742        MapEnv {
743            values: pairs
744                .iter()
745                .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
746                .collect(),
747        }
748    }
749
750    fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> PathBuf {
751        let path = temp_dir.path().join(name);
752        fs::write(&path, contents).unwrap_or_else(|error| {
753            panic!("write cert fixture failed for {}: {error}", path.display())
754        });
755        path
756    }
757
758    #[test]
759    fn ca_path_prefers_codex_env() {
760        let env = map_env(&[
761            (CODEX_CA_CERT_ENV, "/tmp/codex.pem"),
762            (SSL_CERT_FILE_ENV, "/tmp/fallback.pem"),
763        ]);
764
765        assert_eq!(
766            env.configured_ca_bundle().map(|bundle| bundle.path),
767            Some(PathBuf::from("/tmp/codex.pem"))
768        );
769    }
770
771    #[test]
772    fn ca_path_falls_back_to_ssl_cert_file() {
773        let env = map_env(&[(SSL_CERT_FILE_ENV, "/tmp/fallback.pem")]);
774
775        assert_eq!(
776            env.configured_ca_bundle().map(|bundle| bundle.path),
777            Some(PathBuf::from("/tmp/fallback.pem"))
778        );
779    }
780
781    #[test]
782    fn ca_path_ignores_empty_values() {
783        let env = map_env(&[
784            (CODEX_CA_CERT_ENV, ""),
785            (SSL_CERT_FILE_ENV, "/tmp/fallback.pem"),
786        ]);
787
788        assert_eq!(
789            env.configured_ca_bundle().map(|bundle| bundle.path),
790            Some(PathBuf::from("/tmp/fallback.pem"))
791        );
792    }
793
794    #[test]
795    fn rustls_config_uses_custom_ca_bundle_when_configured() {
796        let temp_dir = TempDir::new().expect("tempdir");
797        let cert_path = write_cert_file(&temp_dir, "ca.pem", TEST_CERT);
798        let env = map_env(&[(CODEX_CA_CERT_ENV, cert_path.to_string_lossy().as_ref())]);
799
800        let config = maybe_build_rustls_client_config_with_env(&env)
801            .expect("rustls config")
802            .expect("custom CA config should be present");
803
804        assert!(config.enable_sni);
805    }
806
807    #[test]
808    fn rustls_config_reports_invalid_ca_file() {
809        let temp_dir = TempDir::new().expect("tempdir");
810        let cert_path = write_cert_file(&temp_dir, "empty.pem", "");
811        let env = map_env(&[(CODEX_CA_CERT_ENV, cert_path.to_string_lossy().as_ref())]);
812
813        let error = maybe_build_rustls_client_config_with_env(&env).expect_err("invalid CA");
814
815        assert!(matches!(
816            error,
817            BuildCustomCaTransportError::InvalidCaFile { .. }
818        ));
819    }
820}