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}