http-client: extract shared HTTP transport

This commit is contained in:
Michael Bolin
2026-07-06 17:27:53 -07:00
parent 3f61570044
commit e1c44cd34e
49 changed files with 233 additions and 173 deletions
-1
View File
@@ -2,6 +2,5 @@ load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "codex-client",
compile_data = glob(["tests/fixtures/**"]),
crate_name = "codex_client",
)
+1 -33
View File
@@ -5,47 +5,15 @@ name = "codex-client"
version.workspace = true
[dependencies]
bytes = { workspace = true }
codex-http-client = { workspace = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }
http = { workspace = true }
opentelemetry = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] }
rustls = { workspace = true }
rustls-native-certs = { workspace = true }
rustls-pki-types = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt", "time", "sync"] }
tracing = { workspace = true }
tracing-opentelemetry = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
zstd = { workspace = true }
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
sha2 = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
system-configuration = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = [
"Win32_Foundation",
"Win32_Networking_WinHttp",
] }
[lints]
workspace = true
[dev-dependencies]
codex-utils-cargo-bin = { workspace = true }
opentelemetry_sdk = { workspace = true }
pretty_assertions = { workspace = true }
rcgen = { workspace = true }
tempfile = { workspace = true }
tracing-subscriber = { workspace = true }
[lib]
doctest = false
+3 -3
View File
@@ -1,8 +1,8 @@
# codex-client
Generic transport layer that wraps HTTP requests, retries, and streaming primitives without any Codex/OpenAI awareness.
Higher-level request policy layered on `codex-http-client` without any Codex/OpenAI API awareness.
- Defines `HttpTransport` and a default `ReqwestTransport` plus thin `Request`/`Response` types.
- Provides retry utilities (`RetryPolicy`, `RetryOn`, `run_with_retry`, `backoff`) that callers plug into for unary and streaming calls.
- Supplies the `sse_stream` helper to turn byte streams into raw SSE `data:` frames with idle timeouts and surfaced stream errors.
- Consumed by higher-level crates like `codex-api`; it stays neutral on endpoints, headers, or API-specific error shapes.
- Defines the request telemetry callback used by higher-level clients.
- Re-exports the low-level HTTP types temporarily so consumers can migrate to `codex-http-client` incrementally.
@@ -1,100 +0,0 @@
//! Helper binary for exercising shared custom CA environment handling in tests.
//!
//! The shared reqwest client honors `CODEX_CA_CERTIFICATE` and `SSL_CERT_FILE`, but those
//! environment variables are process-global and unsafe to mutate in parallel test execution. This
//! probe keeps the behavior under test while letting integration tests (`tests/ca_env.rs`) set
//! env vars per-process, proving:
//!
//! - env precedence is respected,
//! - multi-cert PEM bundles load,
//! - error messages guide users when CA files are invalid.
//! - optional HTTPS probes can complete a request through the constructed client.
//!
//! The detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`.
//! This binary exists so the tests can exercise
//! [`codex_client::build_reqwest_client_for_subprocess_tests`] in a separate process without
//! duplicating client-construction logic.
use std::env;
use std::process;
use std::time::Duration;
const PROBE_TLS13_ENV: &str = "CODEX_CUSTOM_CA_PROBE_TLS13";
const PROBE_PROXY_ENV: &str = "CODEX_CUSTOM_CA_PROBE_PROXY";
const PROBE_URL_ENV: &str = "CODEX_CUSTOM_CA_PROBE_URL";
fn main() {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
eprintln!("failed to create probe runtime: {error}");
process::exit(1);
}
};
match runtime.block_on(run_probe()) {
Ok(()) => println!("ok"),
Err(error) => {
eprintln!("{error}");
process::exit(1);
}
}
}
async fn run_probe() -> Result<(), String> {
let proxy_url = env::var(PROBE_PROXY_ENV).ok();
let target_url = env::var(PROBE_URL_ENV).ok();
let mut builder = reqwest::Client::builder();
if target_url.is_some() {
builder = builder.timeout(Duration::from_secs(5));
}
if env::var_os(PROBE_TLS13_ENV).is_some() {
builder = builder.min_tls_version(reqwest::tls::Version::TLS_1_3);
}
let client = build_probe_client(builder, proxy_url.as_deref())?;
if let Some(url) = target_url {
post_probe_request(&client, &url).await?;
}
Ok(())
}
fn build_probe_client(
builder: reqwest::ClientBuilder,
proxy_url: Option<&str>,
) -> Result<reqwest::Client, String> {
if let Some(proxy_url) = proxy_url {
let proxy = reqwest::Proxy::https(proxy_url)
.map_err(|error| format!("failed to configure probe proxy {proxy_url}: {error}"))?;
return codex_client::build_reqwest_client_with_custom_ca(builder.proxy(proxy))
.map_err(|error| error.to_string());
}
codex_client::build_reqwest_client_for_subprocess_tests(builder)
.map_err(|error| error.to_string())
}
async fn post_probe_request(client: &reqwest::Client, url: &str) -> Result<(), String> {
let response = client
.post(url)
.header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=authorization_code&code=test")
.send()
.await
.map_err(|error| format!("probe request failed: {error:?}"))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|error| format!("failed to read probe response body: {error}"))?;
if !status.is_success() {
return Err(format!("probe request returned {status}: {body}"));
}
if body != "ok" {
return Err(format!("probe response body mismatch: {body}"));
}
Ok(())
}
@@ -1,266 +0,0 @@
use std::sync::Arc;
use std::sync::LazyLock;
use reqwest::cookie::CookieStore;
use reqwest::cookie::Jar;
use reqwest::header::HeaderValue;
use crate::chatgpt_hosts::is_allowed_chatgpt_host;
// WARNING: this store is process-global and may be shared across auth contexts.
// It must only ever contain Cloudflare infrastructure cookies. Never extend this
// store to persist ChatGPT account, session, auth, or other user-specific cookie
// data.
static SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE: LazyLock<Arc<ChatGptCloudflareCookieStore>> =
LazyLock::new(|| Arc::new(ChatGptCloudflareCookieStore::default()));
#[derive(Debug, Default)]
struct ChatGptCloudflareCookieStore {
jar: Jar,
}
impl CookieStore for ChatGptCloudflareCookieStore {
fn set_cookies(
&self,
cookie_headers: &mut dyn Iterator<Item = &HeaderValue>,
url: &reqwest::Url,
) {
if !is_chatgpt_cookie_url(url) {
return;
}
let mut cloudflare_cookie_headers =
cookie_headers.filter(|header| is_allowed_cloudflare_set_cookie_header(header));
self.jar.set_cookies(&mut cloudflare_cookie_headers, url);
}
fn cookies(&self, url: &reqwest::Url) -> Option<HeaderValue> {
if is_chatgpt_cookie_url(url) {
self.jar.cookies(url).and_then(only_cloudflare_cookies)
} else {
None
}
}
}
/// Adds the process-local ChatGPT Cloudflare cookie jar used by Codex HTTP clients.
///
/// WARNING: this jar is global within the process. It is only acceptable because it hardcodes a
/// small allowlist of Cloudflare cookie names and refuses all other ChatGPT cookies. Do not store
/// ChatGPT account, session, auth, or other user-specific cookies here. If a future caller needs
/// those cookies, the store must be scoped to the auth/session owner instead of shared globally.
pub fn with_chatgpt_cloudflare_cookie_store(
builder: reqwest::ClientBuilder,
) -> reqwest::ClientBuilder {
builder.cookie_provider(Arc::clone(&SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE))
}
fn is_chatgpt_cookie_url(url: &reqwest::Url) -> bool {
match url.scheme() {
"https" => {}
_ => return false,
}
let Some(host) = url.host_str() else {
return false;
};
is_allowed_chatgpt_host(host)
}
fn is_allowed_cloudflare_set_cookie_header(header: &HeaderValue) -> bool {
header
.to_str()
.ok()
.and_then(set_cookie_name)
.is_some_and(is_allowed_cloudflare_cookie_name)
}
fn set_cookie_name(header: &str) -> Option<&str> {
let (name, _) = header.split_once('=')?;
let name = name.trim();
(!name.is_empty()).then_some(name)
}
fn only_cloudflare_cookies(header: HeaderValue) -> Option<HeaderValue> {
let header = header.to_str().ok()?;
let cookies = header
.split(';')
.filter_map(|cookie| {
let cookie = cookie.trim();
let name = cookie.split_once('=')?.0.trim();
is_allowed_cloudflare_cookie_name(name).then_some(cookie)
})
.collect::<Vec<_>>()
.join("; ");
if cookies.is_empty() {
None
} else {
HeaderValue::from_str(&cookies).ok()
}
}
fn is_allowed_cloudflare_cookie_name(name: &str) -> bool {
// Keep this allowlist aligned with Cloudflare's documented service cookies:
// https://developers.cloudflare.com/fundamentals/reference/policies-compliances/cloudflare-cookies/
matches!(
name,
"__cf_bm"
| "__cflb"
| "__cfruid"
| "__cfseq"
| "__cfwaitingroom"
| "_cfuvid"
| "cf_clearance"
| "cf_ob_info"
| "cf_use_ob"
) || name.starts_with("cf_chl_")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use reqwest::cookie::CookieStore;
#[test]
fn stores_and_returns_cloudflare_cookies_for_chatgpt_hosts() {
let store = ChatGptCloudflareCookieStore::default();
let url = reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses").unwrap();
let load_balancer = HeaderValue::from_static("__cflb=west; Path=/; Secure; HttpOnly");
let cfuvid = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly");
let clearance =
HeaderValue::from_static("cf_clearance=clearance; Path=/; Secure; HttpOnly");
store.set_cookies(&mut [&load_balancer, &cfuvid, &clearance].into_iter(), &url);
let mut cookies = store
.cookies(&url)
.and_then(|value| value.to_str().ok().map(str::to_string))
.map(|header| {
header
.split("; ")
.map(str::to_string)
.collect::<Vec<String>>()
})
.unwrap_or_default();
cookies.sort();
assert_eq!(
cookies,
vec![
"__cflb=west".to_string(),
"_cfuvid=visitor".to_string(),
"cf_clearance=clearance".to_string()
]
);
}
#[test]
fn ignores_non_chatgpt_cookies() {
let store = ChatGptCloudflareCookieStore::default();
let url = reqwest::Url::parse("https://api.openai.com/v1/responses").unwrap();
let set_cookie = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly");
store.set_cookies(&mut std::iter::once(&set_cookie), &url);
assert_eq!(store.cookies(&url), None);
}
#[test]
fn ignores_non_cloudflare_cookies_for_chatgpt_hosts() {
let store = ChatGptCloudflareCookieStore::default();
let url = reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses").unwrap();
let set_cookie = HeaderValue::from_static(
"__Secure-next-auth.session-token=secret; Path=/; Secure; HttpOnly",
);
store.set_cookies(&mut std::iter::once(&set_cookie), &url);
assert_eq!(store.cookies(&url), None);
}
#[test]
fn ignores_mixed_non_cloudflare_cookies_for_chatgpt_hosts() {
let store = ChatGptCloudflareCookieStore::default();
let url = reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses").unwrap();
let cfuvid = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly");
let account_cookie =
HeaderValue::from_static("chatgpt_session=secret; Path=/; Secure; HttpOnly");
store.set_cookies(&mut [&cfuvid, &account_cookie].into_iter(), &url);
assert_eq!(
store
.cookies(&url)
.and_then(|value| value.to_str().ok().map(str::to_string)),
Some("_cfuvid=visitor".to_string())
);
}
#[test]
fn does_not_return_chatgpt_cloudflare_cookies_for_other_hosts() {
let store = ChatGptCloudflareCookieStore::default();
let chatgpt_url =
reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses").unwrap();
let api_url = reqwest::Url::parse("https://api.openai.com/v1/responses").unwrap();
let set_cookie = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly");
store.set_cookies(&mut std::iter::once(&set_cookie), &chatgpt_url);
assert_eq!(store.cookies(&api_url), None);
}
#[test]
fn rejects_plain_http_chatgpt_cookie_urls() {
let store = ChatGptCloudflareCookieStore::default();
let http_url = reqwest::Url::parse("http://chatgpt.com/backend-api/codex/responses")
.expect("URL should parse");
let https_url = reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses")
.expect("URL should parse");
let set_cookie = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly");
store.set_cookies(&mut std::iter::once(&set_cookie), &http_url);
assert_eq!(store.cookies(&http_url), None);
assert_eq!(store.cookies(&https_url), None);
}
#[test]
fn only_allows_https_urls() {
let url = reqwest::Url::parse("http://chatgpt.com/backend-api/codex/responses").unwrap();
assert!(!is_chatgpt_cookie_url(&url));
let url = reqwest::Url::parse("wss://chatgpt.com/backend-api/codex/responses").unwrap();
assert!(!is_chatgpt_cookie_url(&url));
}
#[test]
fn allows_only_known_cloudflare_cookie_names() {
for name in [
"__cf_bm",
"__cflb",
"__cfruid",
"__cfseq",
"__cfwaitingroom",
"_cfuvid",
"cf_clearance",
"cf_ob_info",
"cf_use_ob",
"cf_chl_rc_i",
] {
assert!(is_allowed_cloudflare_cookie_name(name));
}
for name in [
"__Secure-next-auth.session-token",
"chatgpt_session",
"oai-auth-token",
"not_cf_clearance",
] {
assert!(!is_allowed_cloudflare_cookie_name(name));
}
}
}
@@ -1,39 +0,0 @@
/// Returns whether `host` is one of the ChatGPT hosts Codex is allowed to treat
/// as first-party ChatGPT traffic.
pub fn is_allowed_chatgpt_host(host: &str) -> bool {
const EXACT_HOSTS: &[&str] = &["chatgpt.com", "chat.openai.com", "chatgpt-staging.com"];
const SUBDOMAIN_SUFFIXES: &[&str] = &[".chatgpt.com", ".chatgpt-staging.com"];
EXACT_HOSTS.contains(&host)
|| SUBDOMAIN_SUFFIXES
.iter()
.any(|suffix| host.ends_with(suffix))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognizes_chatgpt_hosts_without_suffix_tricks() {
for host in [
"chatgpt.com",
"foo.chatgpt.com",
"staging.chatgpt.com",
"chat.openai.com",
"chatgpt-staging.com",
"api.chatgpt-staging.com",
] {
assert!(is_allowed_chatgpt_host(host));
}
for host in [
"evilchatgpt.com",
"chatgpt.com.evil.example",
"api.openai.com",
"foo.chat.openai.com",
] {
assert!(!is_allowed_chatgpt_host(host));
}
}
}
-795
View File
@@ -1,795 +0,0 @@
//! Custom CA handling for Codex outbound HTTP and websocket clients.
//!
//! Codex constructs outbound reqwest clients and secure websocket connections in a few crates, but
//! they all need the same trust-store policy when enterprise proxies or gateways intercept TLS.
//! This module centralizes that policy so callers can start from an ordinary
//! `reqwest::ClientBuilder` or rustls client config, layer in custom CA support, and either get
//! back a configured transport or a user-facing error that explains how to fix a misconfigured CA
//! bundle.
//!
//! The module intentionally has a narrow responsibility:
//!
//! - read CA material from `CODEX_CA_CERTIFICATE`, falling back to `SSL_CERT_FILE`
//! - normalize PEM variants that show up in real deployments, including OpenSSL-style
//! `TRUSTED CERTIFICATE` labels and bundles that also contain CRLs
//! - return user-facing errors that explain how to fix misconfigured CA files
//!
//! Its production contract is narrow: produce a transport configuration whose root store contains
//! every parseable certificate block from the configured PEM bundle, or fail early with a precise
//! error before the caller starts network traffic.
//!
//! In this module's test setup, a hermetic test is one whose result depends only on the CA file
//! and environment variables that the test chose for itself. That matters here because the normal
//! reqwest client-construction path is not hermetic enough for environment-sensitive tests:
//!
//! - on macOS seatbelt runs, `reqwest::Client::builder().build()` can panic inside
//! `system-configuration` while probing platform proxy settings, which means the process can die
//! before the custom-CA code reports success or a structured error. That matters in practice
//! because Codex itself commonly runs spawned test processes under seatbelt, so this is not just
//! a hypothetical CI edge case.
//! - child processes inherit CA-related environment variables by default, which lets developer
//! shell state or CI configuration affect a test unless the test scrubs those variables first
//!
//! The tests in this crate therefore stay split across two layers:
//!
//! - unit tests in this module cover env-selection logic without constructing a real client
//! - subprocess integration tests under `tests/` cover real client construction through
//! [`build_reqwest_client_for_subprocess_tests`], which disables reqwest proxy autodetection so
//! the tests can observe custom-CA success and failure directly, including one TLS handshake
//! through a local HTTPS server
//! - those subprocess tests also scrub inherited CA environment variables before launch so their
//! result depends only on the test fixtures and env vars set by the test itself
use std::env;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
use rustls::ClientConfig;
use rustls::RootCertStore;
use rustls_pki_types::CertificateDer;
use rustls_pki_types::pem::PemObject;
use rustls_pki_types::pem::SectionKind;
use rustls_pki_types::pem::{self};
use thiserror::Error;
use tracing::info;
use tracing::warn;
pub const CODEX_CA_CERT_ENV: &str = "CODEX_CA_CERTIFICATE";
pub const SSL_CERT_FILE_ENV: &str = "SSL_CERT_FILE";
const 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.";
type PemSection = (SectionKind, Vec<u8>);
/// Describes why a transport using shared custom CA support could not be constructed.
///
/// These failure modes apply to both reqwest client construction and websocket TLS
/// configuration. A build can fail because the configured CA file could not be read, could not be
/// parsed as certificates, contained certs that the target TLS stack refused to register, or
/// because the final reqwest client builder failed. Callers that do not care about the
/// distinction can rely on the `From<BuildCustomCaTransportError> for io::Error` conversion.
#[derive(Debug, Error)]
pub enum BuildCustomCaTransportError {
/// Reading the selected CA file from disk failed before any PEM parsing could happen.
#[error(
"Failed to read CA certificate file {} selected by {}: {source}. {hint}",
path.display(),
source_env,
hint = CA_CERT_HINT
)]
ReadCaFile {
source_env: &'static str,
path: PathBuf,
source: io::Error,
},
/// The selected CA file was readable, but did not produce usable certificate material.
#[error(
"Failed to load CA certificates from {} selected by {}: {detail}. {hint}",
path.display(),
source_env,
hint = CA_CERT_HINT
)]
InvalidCaFile {
source_env: &'static str,
path: PathBuf,
detail: String,
},
/// One parsed certificate block could not be registered with the reqwest client builder.
#[error(
"Failed to parse certificate #{certificate_index} from {} selected by {}: {source}. {hint}",
path.display(),
source_env,
hint = CA_CERT_HINT
)]
RegisterCertificate {
source_env: &'static str,
path: PathBuf,
certificate_index: usize,
source: reqwest::Error,
},
/// Reqwest rejected the final client configuration after a custom CA bundle was loaded.
#[error(
"Failed to build HTTP client while using CA bundle from {} ({}): {source}",
source_env,
path.display()
)]
BuildClientWithCustomCa {
source_env: &'static str,
path: PathBuf,
#[source]
source: reqwest::Error,
},
/// Reqwest rejected the final client configuration while using only system roots.
#[error("Failed to build HTTP client while using system root certificates: {0}")]
BuildClientWithSystemRoots(#[source] reqwest::Error),
/// One parsed certificate block could not be registered with the websocket TLS root store.
#[error(
"Failed to register certificate #{certificate_index} from {} selected by {} in rustls root store: {source}. {hint}",
path.display(),
source_env,
hint = CA_CERT_HINT
)]
RegisterRustlsCertificate {
source_env: &'static str,
path: PathBuf,
certificate_index: usize,
source: rustls::Error,
},
}
impl From<BuildCustomCaTransportError> for io::Error {
fn from(error: BuildCustomCaTransportError) -> Self {
match error {
BuildCustomCaTransportError::ReadCaFile { ref source, .. } => {
io::Error::new(source.kind(), error)
}
BuildCustomCaTransportError::InvalidCaFile { .. }
| BuildCustomCaTransportError::RegisterCertificate { .. }
| BuildCustomCaTransportError::RegisterRustlsCertificate { .. } => {
io::Error::new(io::ErrorKind::InvalidData, error)
}
BuildCustomCaTransportError::BuildClientWithCustomCa { .. }
| BuildCustomCaTransportError::BuildClientWithSystemRoots(_) => io::Error::other(error),
}
}
}
/// Builds a reqwest client that honors Codex custom CA environment variables.
///
/// Callers supply the baseline builder configuration they need, and this helper layers in custom
/// CA handling before finally constructing the client. `CODEX_CA_CERTIFICATE` takes precedence
/// over `SSL_CERT_FILE`, and empty values for either are treated as unset so callers do not
/// accidentally turn `VAR=""` into a bogus path lookup.
///
/// Callers that build a raw `reqwest::Client` directly bypass this policy entirely. That is an
/// easy mistake to make when adding a new outbound Codex HTTP path, and the resulting bug only
/// shows up in environments where a proxy or gateway requires a custom root CA.
///
/// # Errors
///
/// Returns a [`BuildCustomCaTransportError`] when the configured CA file is unreadable,
/// malformed, or contains a certificate block that `reqwest` cannot register as a root.
pub fn build_reqwest_client_with_custom_ca(
builder: reqwest::ClientBuilder,
) -> Result<reqwest::Client, BuildCustomCaTransportError> {
build_reqwest_client_with_env(&ProcessEnv, builder)
}
/// Builds a rustls client config when a Codex custom CA bundle is configured.
///
/// This is the websocket-facing sibling of [`build_reqwest_client_with_custom_ca`]. When
/// `CODEX_CA_CERTIFICATE` or `SSL_CERT_FILE` selects a CA bundle, the returned config starts from
/// the platform native roots and then adds the configured custom CA certificates. When no custom
/// CA env var is set, this returns `Ok(None)` so websocket callers can keep using their ordinary
/// default connector path.
///
/// Callers that let tungstenite build its default TLS connector directly bypass this policy
/// entirely. That bug only shows up in environments where secure websocket traffic needs the same
/// enterprise root CA bundle as HTTPS traffic.
pub fn maybe_build_rustls_client_config_with_custom_ca()
-> Result<Option<Arc<ClientConfig>>, BuildCustomCaTransportError> {
maybe_build_rustls_client_config_with_env(&ProcessEnv)
}
/// Builds a reqwest client for spawned subprocess tests that exercise CA behavior.
///
/// This is the test-only client-construction path used by the subprocess coverage in `tests/`.
/// The module-level docs explain the hermeticity problem in full; this helper only addresses the
/// reqwest proxy-discovery panic side of that problem by disabling proxy autodetection. The tests
/// still scrub inherited CA environment variables themselves. Normal production callers should use
/// [`build_reqwest_client_with_custom_ca`] so test-only proxy behavior does not leak into
/// ordinary client construction.
pub fn build_reqwest_client_for_subprocess_tests(
builder: reqwest::ClientBuilder,
) -> Result<reqwest::Client, BuildCustomCaTransportError> {
build_reqwest_client_with_env(&ProcessEnv, builder.no_proxy())
}
fn maybe_build_rustls_client_config_with_env(
env_source: &dyn EnvSource,
) -> Result<Option<Arc<ClientConfig>>, BuildCustomCaTransportError> {
let Some(bundle) = env_source.configured_ca_bundle() else {
return Ok(None);
};
ensure_rustls_crypto_provider();
// Start from the platform roots so websocket callers keep the same baseline trust behavior
// they would get from tungstenite's default rustls connector, then layer in the Codex custom
// CA bundle on top when configured.
let mut root_store = RootCertStore::empty();
let rustls_native_certs::CertificateResult { certs, errors, .. } =
rustls_native_certs::load_native_certs();
if !errors.is_empty() {
warn!(
native_root_error_count = errors.len(),
"encountered errors while loading native root certificates"
);
}
let _ = root_store.add_parsable_certificates(certs);
let certificates = bundle.load_certificates()?;
for (idx, cert) in certificates.into_iter().enumerate() {
if let Err(source) = root_store.add(cert) {
warn!(
source_env = bundle.source_env,
ca_path = %bundle.path.display(),
certificate_index = idx + 1,
error = %source,
"failed to register CA certificate in rustls root store"
);
return Err(BuildCustomCaTransportError::RegisterRustlsCertificate {
source_env: bundle.source_env,
path: bundle.path.clone(),
certificate_index: idx + 1,
source,
});
}
}
Ok(Some(Arc::new(
ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth(),
)))
}
/// Builds a reqwest client using an injected environment source and reqwest builder.
///
/// This exists so tests can exercise precedence behavior deterministically without mutating the
/// real process environment. It selects the CA bundle, delegates file parsing to
/// [`ConfiguredCaBundle::load_certificates`], preserves the caller's chosen `reqwest` builder
/// configuration, forces rustls when a custom CA is configured, and finally registers each parsed
/// certificate with that builder.
fn build_reqwest_client_with_env(
env_source: &dyn EnvSource,
mut builder: reqwest::ClientBuilder,
) -> Result<reqwest::Client, BuildCustomCaTransportError> {
if let Some(bundle) = env_source.configured_ca_bundle() {
ensure_rustls_crypto_provider();
info!(
source_env = bundle.source_env,
ca_path = %bundle.path.display(),
"building HTTP client with rustls backend for custom CA bundle"
);
builder = builder.use_rustls_tls();
let certificates = bundle.load_certificates()?;
for (idx, cert) in certificates.iter().enumerate() {
let certificate = match reqwest::Certificate::from_der(cert.as_ref()) {
Ok(certificate) => certificate,
Err(source) => {
warn!(
source_env = bundle.source_env,
ca_path = %bundle.path.display(),
certificate_index = idx + 1,
error = %source,
"failed to register CA certificate"
);
return Err(BuildCustomCaTransportError::RegisterCertificate {
source_env: bundle.source_env,
path: bundle.path.clone(),
certificate_index: idx + 1,
source,
});
}
};
builder = builder.add_root_certificate(certificate);
}
return match builder.build() {
Ok(client) => Ok(client),
Err(source) => {
warn!(
source_env = bundle.source_env,
ca_path = %bundle.path.display(),
error = %source,
"failed to build client after loading custom CA bundle"
);
Err(BuildCustomCaTransportError::BuildClientWithCustomCa {
source_env: bundle.source_env,
path: bundle.path.clone(),
source,
})
}
};
}
info!(
codex_ca_certificate_configured = false,
ssl_cert_file_configured = false,
"using system root certificates because no CA override environment variable was selected"
);
match builder.build() {
Ok(client) => Ok(client),
Err(source) => {
warn!(
error = %source,
"failed to build client while using system root certificates"
);
Err(BuildCustomCaTransportError::BuildClientWithSystemRoots(
source,
))
}
}
}
/// Abstracts environment access so tests can cover precedence rules without mutating process-wide
/// variables.
trait EnvSource {
/// Returns the environment variable value for `key`, if this source considers it set.
///
/// Implementations should return `None` for absent values and may also collapse unreadable
/// process-environment states into `None`, because the custom CA logic treats both cases as
/// "no override configured". Callers build precedence and empty-string handling on top of this
/// method, so implementations should not trim or normalize the returned string.
fn var(&self, key: &str) -> Option<String>;
/// Returns a non-empty environment variable value interpreted as a filesystem path.
///
/// Empty strings are treated as unset because presence here acts as a boolean "custom CA
/// override requested" signal. This keeps the precedence logic from treating `VAR=""` as an
/// attempt to open the current working directory or some other platform-specific oddity once
/// it is converted into a path.
fn non_empty_path(&self, key: &str) -> Option<PathBuf> {
self.var(key)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
/// Returns the configured CA bundle and which environment variable selected it.
///
/// `CODEX_CA_CERTIFICATE` wins over `SSL_CERT_FILE` because it is the Codex-specific override.
/// Keeping the winning variable name with the path lets later logging explain not only which
/// file was used but also why that file was chosen.
fn configured_ca_bundle(&self) -> Option<ConfiguredCaBundle> {
self.non_empty_path(CODEX_CA_CERT_ENV)
.map(|path| ConfiguredCaBundle {
source_env: CODEX_CA_CERT_ENV,
path,
})
.or_else(|| {
self.non_empty_path(SSL_CERT_FILE_ENV)
.map(|path| ConfiguredCaBundle {
source_env: SSL_CERT_FILE_ENV,
path,
})
})
}
}
/// Reads CA configuration from the real process environment.
///
/// This is the production `EnvSource` implementation used by
/// [`build_reqwest_client_with_custom_ca`]. Tests substitute in-memory env maps so they can
/// exercise precedence and empty-value behavior without mutating process-global variables.
struct ProcessEnv;
impl EnvSource for ProcessEnv {
fn var(&self, key: &str) -> Option<String> {
env::var(key).ok()
}
}
/// Identifies the CA bundle selected for a client and the policy decision that selected it.
///
/// This is the concrete output of the environment-precedence logic. Callers use `source_env` for
/// logging and diagnostics, while `path` is the bundle that will actually be loaded.
struct ConfiguredCaBundle {
/// The environment variable that won the precedence check for this bundle.
source_env: &'static str,
/// The filesystem path that should be read as PEM certificate input.
path: PathBuf,
}
impl ConfiguredCaBundle {
/// Loads certificates from this selected CA bundle.
///
/// The bundle already represents the output of environment-precedence selection, so this is
/// the natural point where the file-loading phase begins. The method owns the high-level
/// success/failure logs for that phase and keeps the source env and path together for lower-
/// level parsing and error shaping.
fn load_certificates(
&self,
) -> Result<Vec<CertificateDer<'static>>, BuildCustomCaTransportError> {
match self.parse_certificates() {
Ok(certificates) => {
info!(
source_env = self.source_env,
ca_path = %self.path.display(),
certificate_count = certificates.len(),
"loaded certificates from custom CA bundle"
);
Ok(certificates)
}
Err(error) => {
warn!(
source_env = self.source_env,
ca_path = %self.path.display(),
error = %error,
"failed to load custom CA bundle"
);
Err(error)
}
}
}
/// Loads every certificate block from a PEM file intended for Codex CA overrides.
///
/// This accepts a few common real-world variants so Codex behaves like other CA-aware tooling:
/// leading comments are preserved, `TRUSTED CERTIFICATE` labels are normalized to standard
/// certificate labels, and embedded CRLs are ignored when they are well-formed enough for the
/// section iterator to classify them.
fn parse_certificates(
&self,
) -> Result<Vec<CertificateDer<'static>>, BuildCustomCaTransportError> {
let pem_data = self.read_pem_data()?;
let normalized_pem = NormalizedPem::from_pem_data(self.source_env, &self.path, &pem_data);
let mut certificates = Vec::new();
let mut logged_crl_presence = false;
for section_result in normalized_pem.sections() {
// Known limitation: if `rustls-pki-types` fails while parsing a malformed CRL section,
// that error is reported here before we can classify the block as ignorable. A bundle
// containing valid certificates plus a malformed `X509 CRL` therefore still fails to
// load today, even though well-formed CRLs are ignored.
let (section_kind, der) = match section_result {
Ok(section) => section,
Err(error) => return Err(self.pem_parse_error(&error)),
};
match section_kind {
SectionKind::Certificate => {
// Standard CERTIFICATE blocks already decode to the exact DER bytes reqwest
// wants. Only OpenSSL TRUSTED CERTIFICATE blocks need trimming to drop any
// trailing X509_AUX trust metadata before registration.
let cert_der = normalized_pem.certificate_der(&der).ok_or_else(|| {
self.invalid_ca_file(
"failed to extract certificate data from TRUSTED CERTIFICATE: invalid DER length",
)
})?;
certificates.push(CertificateDer::from(cert_der.to_vec()));
}
SectionKind::Crl if !logged_crl_presence => {
info!(
source_env = self.source_env,
ca_path = %self.path.display(),
"ignoring X509 CRL entries found in custom CA bundle"
);
logged_crl_presence = true;
}
_ => {}
}
}
if certificates.is_empty() {
return Err(self.pem_parse_error(&pem::Error::NoItemsFound));
}
Ok(certificates)
}
/// Reads the CA bundle bytes while preserving the original filesystem error kind.
///
/// The caller wants a user-facing error that includes the bundle path and remediation hint, but
/// higher-level surfaces still benefit from distinguishing "not found" from other I/O
/// failures. This helper keeps both pieces together.
fn read_pem_data(&self) -> Result<Vec<u8>, BuildCustomCaTransportError> {
fs::read(&self.path).map_err(|source| BuildCustomCaTransportError::ReadCaFile {
source_env: self.source_env,
path: self.path.clone(),
source,
})
}
/// Rewrites PEM parsing failures into user-facing configuration errors.
///
/// The underlying parser knows whether the file was empty, malformed, or contained unsupported
/// PEM content, but callers need a message that also points them back to the relevant
/// environment variables and the expected remediation.
fn pem_parse_error(&self, error: &pem::Error) -> BuildCustomCaTransportError {
let detail = match error {
pem::Error::NoItemsFound => "no certificates found in PEM file".to_string(),
_ => format!("failed to parse PEM file: {error}"),
};
self.invalid_ca_file(detail)
}
/// Creates an invalid-CA error tied to this file path.
///
/// Most parse-time failures in this module eventually collapse to "the configured CA bundle is
/// not usable", but the detailed reason still matters for operator debugging. Centralizing that
/// formatting keeps the path and hint text consistent across the different parser branches.
fn invalid_ca_file(&self, detail: impl std::fmt::Display) -> BuildCustomCaTransportError {
BuildCustomCaTransportError::InvalidCaFile {
source_env: self.source_env,
path: self.path.clone(),
detail: detail.to_string(),
}
}
}
/// The PEM text shape after OpenSSL compatibility normalization.
///
/// `Standard` means the input already used ordinary PEM certificate labels. `TrustedCertificate`
/// means the input used OpenSSL's `TRUSTED CERTIFICATE` labels, so callers must also be prepared
/// to trim trailing `X509_AUX` bytes from decoded certificate sections.
enum NormalizedPem {
/// PEM contents that already used ordinary `CERTIFICATE` labels.
Standard(String),
/// PEM contents rewritten from OpenSSL `TRUSTED CERTIFICATE` labels to `CERTIFICATE`.
TrustedCertificate(String),
}
impl NormalizedPem {
/// Normalizes PEM text from a CA bundle into the label shape this module expects.
///
/// Codex only needs certificate DER bytes to seed `reqwest`'s root store, but operators may
/// point it at CA files that came from OpenSSL tooling rather than from a minimal certificate
/// bundle. OpenSSL's `TRUSTED CERTIFICATE` form is one such variant: it is still certificate
/// material, but it uses a different PEM label and may carry auxiliary trust metadata that
/// this crate does not consume. This constructor rewrites only the PEM labels so the mixed-
/// section parser can keep treating the file as certificate input. The rustls ecosystem does
/// not currently accept `TRUSTED CERTIFICATE` as a standard certificate label upstream, so
/// this remains a local compatibility shim rather than behavior delegated to
/// `rustls-pki-types`.
///
/// See also:
/// - rustls/pemfile issue #52, closed as not planned, documenting that
/// `BEGIN TRUSTED CERTIFICATE` blocks are ignored upstream:
/// <https://github.com/rustls/pemfile/issues/52>
/// - OpenSSL `x509 -trustout`, which emits `TRUSTED CERTIFICATE` PEM blocks:
/// <https://docs.openssl.org/master/man1/openssl-x509/>
/// - OpenSSL PEM readers, which document that plain `PEM_read_bio_X509()` discards auxiliary
/// trust settings:
/// <https://docs.openssl.org/master/man3/PEM_read_bio_PrivateKey/>
/// - `openssl s_server`, a real OpenSSL-based server/test tool that operates in this
/// ecosystem:
/// <https://docs.openssl.org/master/man1/openssl-s_server/>
fn from_pem_data(source_env: &'static str, path: &Path, pem_data: &[u8]) -> Self {
let pem = String::from_utf8_lossy(pem_data);
if pem.contains("TRUSTED CERTIFICATE") {
info!(
source_env,
ca_path = %path.display(),
"normalizing OpenSSL TRUSTED CERTIFICATE labels in custom CA bundle"
);
Self::TrustedCertificate(
pem.replace("BEGIN TRUSTED CERTIFICATE", "BEGIN CERTIFICATE")
.replace("END TRUSTED CERTIFICATE", "END CERTIFICATE"),
)
} else {
Self::Standard(pem.into_owned())
}
}
/// Returns the normalized PEM contents regardless of the label shape that produced them.
fn contents(&self) -> &str {
match self {
Self::Standard(contents) | Self::TrustedCertificate(contents) => contents,
}
}
/// Iterates over every recognized PEM section in this normalized PEM text.
///
/// `rustls-pki-types` exposes mixed-section parsing through a `PemObject` implementation on the
/// `(SectionKind, Vec<u8>)` tuple. Keeping that type-directed API here lets callers iterate in
/// terms of normalized sections rather than trait plumbing.
fn sections(&self) -> impl Iterator<Item = Result<PemSection, pem::Error>> + '_ {
PemSection::pem_slice_iter(self.contents().as_bytes())
}
/// Returns the certificate DER bytes for one parsed PEM certificate section.
///
/// Standard PEM certificates already decode to the exact DER bytes `reqwest` wants. OpenSSL
/// `TRUSTED CERTIFICATE` sections may append `X509_AUX` bytes after the certificate, so those
/// sections need to be trimmed down to their first DER object before registration.
fn certificate_der<'a>(&self, der: &'a [u8]) -> Option<&'a [u8]> {
match self {
Self::Standard(_) => Some(der),
Self::TrustedCertificate(_) => first_der_item(der),
}
}
}
/// Returns the first DER-encoded ASN.1 object in `der`, ignoring any trailing OpenSSL metadata.
///
/// A PEM `CERTIFICATE` block usually decodes to exactly one DER blob: the certificate itself.
/// OpenSSL's `TRUSTED CERTIFICATE` variant is different. It starts with that same certificate
/// blob, but may append extra `X509_AUX` bytes after it to describe OpenSSL-specific trust
/// settings. `reqwest::Certificate::from_der` only understands the certificate object, not those
/// trailing OpenSSL extensions.
///
/// This helper therefore asks a narrower question than "is this a valid certificate?": where does
/// the first top-level DER object end? If that boundary can be found, the caller keeps only that
/// prefix and discards the trailing trust metadata. If it cannot be found, the input is treated as
/// malformed CA data.
fn first_der_item(der: &[u8]) -> Option<&[u8]> {
der_item_length(der).map(|length| &der[..length])
}
/// Returns the byte length of the first DER item in `der`.
///
/// DER is a binary encoding for ASN.1 objects. Each object begins with:
///
/// - a tag byte describing what kind of object follows
/// - one or more length bytes describing how many content bytes belong to that object
/// - the content bytes themselves
///
/// For this module, the important fact is that a certificate is stored as one complete top-level
/// DER object. Once we know that object's declared length, we know exactly where the certificate
/// ends and where any trailing OpenSSL `X509_AUX` data begins.
///
/// This helper intentionally parses only that outer length field. It does not validate the inner
/// certificate structure, the meaning of the tag, or every nested ASN.1 value. That narrower scope
/// is deliberate: the caller only needs a safe slice boundary for the leading certificate object
/// before handing those bytes to `reqwest`, which performs the real certificate parsing.
///
/// The implementation supports the DER length forms needed here:
///
/// - short form, where the length is stored directly in the second byte
/// - long form, where the second byte says how many following bytes make up the length value
///
/// Indefinite lengths are rejected because DER does not permit them, and any declared length that
/// would run past the end of the input is treated as malformed.
fn der_item_length(der: &[u8]) -> Option<usize> {
let &length_octet = der.get(1)?;
if length_octet & 0x80 == 0 {
return Some(2 + usize::from(length_octet)).filter(|length| *length <= der.len());
}
let length_octets = usize::from(length_octet & 0x7f);
if length_octets == 0 {
return None;
}
let length_start = 2usize;
let length_end = length_start.checked_add(length_octets)?;
let length_bytes = der.get(length_start..length_end)?;
let mut content_length = 0usize;
for &byte in length_bytes {
content_length = content_length
.checked_mul(256)?
.checked_add(usize::from(byte))?;
}
length_end
.checked_add(content_length)
.filter(|length| *length <= der.len())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use super::BuildCustomCaTransportError;
use super::CODEX_CA_CERT_ENV;
use super::EnvSource;
use super::SSL_CERT_FILE_ENV;
use super::maybe_build_rustls_client_config_with_env;
const TEST_CERT: &str = include_str!("../tests/fixtures/test-ca.pem");
struct MapEnv {
values: HashMap<String, String>,
}
impl EnvSource for MapEnv {
fn var(&self, key: &str) -> Option<String> {
self.values.get(key).cloned()
}
}
fn map_env(pairs: &[(&str, &str)]) -> MapEnv {
MapEnv {
values: pairs
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect(),
}
}
fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> PathBuf {
let path = temp_dir.path().join(name);
fs::write(&path, contents).unwrap_or_else(|error| {
panic!("write cert fixture failed for {}: {error}", path.display())
});
path
}
#[test]
fn ca_path_prefers_codex_env() {
let env = map_env(&[
(CODEX_CA_CERT_ENV, "/tmp/codex.pem"),
(SSL_CERT_FILE_ENV, "/tmp/fallback.pem"),
]);
assert_eq!(
env.configured_ca_bundle().map(|bundle| bundle.path),
Some(PathBuf::from("/tmp/codex.pem"))
);
}
#[test]
fn ca_path_falls_back_to_ssl_cert_file() {
let env = map_env(&[(SSL_CERT_FILE_ENV, "/tmp/fallback.pem")]);
assert_eq!(
env.configured_ca_bundle().map(|bundle| bundle.path),
Some(PathBuf::from("/tmp/fallback.pem"))
);
}
#[test]
fn ca_path_ignores_empty_values() {
let env = map_env(&[
(CODEX_CA_CERT_ENV, ""),
(SSL_CERT_FILE_ENV, "/tmp/fallback.pem"),
]);
assert_eq!(
env.configured_ca_bundle().map(|bundle| bundle.path),
Some(PathBuf::from("/tmp/fallback.pem"))
);
}
#[test]
fn rustls_config_uses_custom_ca_bundle_when_configured() {
let temp_dir = TempDir::new().expect("tempdir");
let cert_path = write_cert_file(&temp_dir, "ca.pem", TEST_CERT);
let env = map_env(&[(CODEX_CA_CERT_ENV, cert_path.to_string_lossy().as_ref())]);
let config = maybe_build_rustls_client_config_with_env(&env)
.expect("rustls config")
.expect("custom CA config should be present");
assert!(config.enable_sni);
}
#[test]
fn rustls_config_reports_invalid_ca_file() {
let temp_dir = TempDir::new().expect("tempdir");
let cert_path = write_cert_file(&temp_dir, "empty.pem", "");
let env = map_env(&[(CODEX_CA_CERT_ENV, cert_path.to_string_lossy().as_ref())]);
let error = maybe_build_rustls_client_config_with_env(&env).expect_err("invalid CA");
assert!(matches!(
error,
BuildCustomCaTransportError::InvalidCaFile { .. }
));
}
}
-218
View File
@@ -1,218 +0,0 @@
use http::Error as HttpError;
use http::HeaderMap;
use http::HeaderName;
use http::HeaderValue;
use opentelemetry::global;
use opentelemetry::propagation::Injector;
use reqwest::IntoUrl;
use reqwest::Method;
use reqwest::Response;
use serde::Serialize;
use std::fmt::Display;
use std::time::Duration;
use tracing::Span;
use tracing_opentelemetry::OpenTelemetrySpanExt;
#[derive(Clone, Debug)]
pub struct CodexHttpClient {
inner: reqwest::Client,
}
impl CodexHttpClient {
pub fn new(inner: reqwest::Client) -> Self {
Self { inner }
}
pub fn get<U>(&self, url: U) -> CodexRequestBuilder
where
U: IntoUrl,
{
self.request(Method::GET, url)
}
pub fn post<U>(&self, url: U) -> CodexRequestBuilder
where
U: IntoUrl,
{
self.request(Method::POST, url)
}
pub fn request<U>(&self, method: Method, url: U) -> CodexRequestBuilder
where
U: IntoUrl,
{
let url_str = url.as_str().to_string();
CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str)
}
}
#[must_use = "requests are not sent unless `send` is awaited"]
#[derive(Debug)]
pub struct CodexRequestBuilder {
builder: reqwest::RequestBuilder,
method: Method,
url: String,
}
impl CodexRequestBuilder {
fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self {
Self {
builder,
method,
url,
}
}
fn map(self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self {
Self {
builder: f(self.builder),
method: self.method,
url: self.url,
}
}
pub fn headers(self, headers: HeaderMap) -> Self {
self.map(|builder| builder.headers(headers))
}
pub fn header<K, V>(self, key: K, value: V) -> Self
where
HeaderName: TryFrom<K>,
<HeaderName as TryFrom<K>>::Error: Into<HttpError>,
HeaderValue: TryFrom<V>,
<HeaderValue as TryFrom<V>>::Error: Into<HttpError>,
{
self.map(|builder| builder.header(key, value))
}
pub fn bearer_auth<T>(self, token: T) -> Self
where
T: Display,
{
self.map(|builder| builder.bearer_auth(token))
}
pub fn timeout(self, timeout: Duration) -> Self {
self.map(|builder| builder.timeout(timeout))
}
pub fn json<T>(self, value: &T) -> Self
where
T: ?Sized + Serialize,
{
self.map(|builder| builder.json(value))
}
pub fn body<B>(self, body: B) -> Self
where
B: Into<reqwest::Body>,
{
self.map(|builder| builder.body(body))
}
pub async fn send(self) -> Result<Response, reqwest::Error> {
let headers = trace_headers();
match self.builder.headers(headers).send().await {
Ok(response) => {
tracing::debug!(
method = %self.method,
url = %self.url,
status = %response.status(),
headers = ?response.headers(),
version = ?response.version(),
"Request completed"
);
Ok(response)
}
Err(error) => {
let status = error.status();
tracing::debug!(
method = %self.method,
url = %self.url,
status = status.map(|s| s.as_u16()),
error = %error,
"Request failed"
);
Err(error)
}
}
}
}
struct HeaderMapInjector<'a>(&'a mut HeaderMap);
impl<'a> Injector for HeaderMapInjector<'a> {
fn set(&mut self, key: &str, value: String) {
if let (Ok(name), Ok(val)) = (
HeaderName::from_bytes(key.as_bytes()),
HeaderValue::from_str(&value),
) {
self.0.insert(name, val);
}
}
}
fn trace_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
global::get_text_map_propagator(|prop| {
prop.inject_context(
&Span::current().context(),
&mut HeaderMapInjector(&mut headers),
);
});
headers
}
#[cfg(test)]
mod tests {
use super::*;
use opentelemetry::propagation::Extractor;
use opentelemetry::propagation::TextMapPropagator;
use opentelemetry::trace::TraceContextExt;
use opentelemetry::trace::TracerProvider;
use opentelemetry_sdk::propagation::TraceContextPropagator;
use opentelemetry_sdk::trace::SdkTracerProvider;
use tracing::trace_span;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
#[test]
fn inject_trace_headers_uses_current_span_context() {
global::set_text_map_propagator(TraceContextPropagator::new());
let provider = SdkTracerProvider::builder().build();
let tracer = provider.tracer("test-tracer");
let subscriber =
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer));
let _guard = subscriber.set_default();
let span = trace_span!("client_request");
let _entered = span.enter();
let span_context = span.context().span().span_context().clone();
let headers = trace_headers();
let extractor = HeaderMapExtractor(&headers);
let extracted = TraceContextPropagator::new().extract(&extractor);
let extracted_span = extracted.span();
let extracted_context = extracted_span.span_context();
assert!(extracted_context.is_valid());
assert_eq!(extracted_context.trace_id(), span_context.trace_id());
assert_eq!(extracted_context.span_id(), span_context.span_id());
}
struct HeaderMapExtractor<'a>(&'a HeaderMap);
impl<'a> Extractor for HeaderMapExtractor<'a> {
fn get(&self, key: &str) -> Option<&str> {
self.0.get(key).and_then(|value| value.to_str().ok())
}
fn keys(&self) -> Vec<&str> {
self.0.keys().map(HeaderName::as_str).collect()
}
}
}
-30
View File
@@ -1,30 +0,0 @@
use http::HeaderMap;
use http::StatusCode;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TransportError {
#[error("http {status}: {body:?}")]
Http {
status: StatusCode,
url: Option<String>,
headers: Option<HeaderMap>,
body: Option<String>,
},
#[error("retry limit reached")]
RetryLimit,
#[error("timeout")]
Timeout,
#[error("network error: {0}")]
Network(String),
#[error("request build error: {0}")]
Build(String),
}
#[derive(Debug, Error)]
pub enum StreamError {
#[error("stream failed: {0}")]
Stream(String),
#[error("timeout")]
Timeout,
}
+3 -39
View File
@@ -1,49 +1,13 @@
mod chatgpt_cloudflare_cookies;
mod chatgpt_hosts;
mod custom_ca;
mod default_client;
mod error;
mod outbound_proxy;
mod request;
mod retry;
mod sse;
mod telemetry;
mod transport;
pub use crate::chatgpt_cloudflare_cookies::with_chatgpt_cloudflare_cookie_store;
pub use crate::chatgpt_hosts::is_allowed_chatgpt_host;
pub use crate::custom_ca::BuildCustomCaTransportError;
/// Test-only subprocess hook for custom CA coverage.
///
/// This stays public only so the `custom_ca_probe` binary target can reuse the shared helper. It
/// is hidden from normal docs because ordinary callers should use
/// [`build_reqwest_client_with_custom_ca`] instead.
#[doc(hidden)]
pub use crate::custom_ca::build_reqwest_client_for_subprocess_tests;
pub use crate::custom_ca::build_reqwest_client_with_custom_ca;
pub use crate::custom_ca::maybe_build_rustls_client_config_with_custom_ca;
pub use crate::default_client::CodexHttpClient;
pub use crate::default_client::CodexRequestBuilder;
pub use crate::error::StreamError;
pub use crate::error::TransportError;
pub use crate::outbound_proxy::BuildRouteAwareHttpClientError;
pub use crate::outbound_proxy::ClientRouteClass;
pub use crate::outbound_proxy::OutboundProxyConfig;
pub use crate::outbound_proxy::RouteFailureClass;
pub use crate::outbound_proxy::build_reqwest_client_for_route;
pub use crate::request::EncodedJsonBody;
pub use crate::request::PreparedRequestBody;
pub use crate::request::Request;
pub use crate::request::RequestBody;
pub use crate::request::RequestCompression;
pub use crate::request::Response;
pub use crate::retry::RetryOn;
pub use crate::retry::RetryPolicy;
pub use crate::retry::backoff;
pub use crate::retry::run_with_retry;
pub use crate::sse::sse_stream;
pub use crate::telemetry::RequestTelemetry;
pub use crate::transport::ByteStream;
pub use crate::transport::HttpTransport;
pub use crate::transport::ReqwestTransport;
pub use crate::transport::StreamResponse;
pub use codex_http_client::HttpClient as CodexHttpClient;
pub use codex_http_client::RequestBuilder as CodexRequestBuilder;
pub use codex_http_client::*;
-556
View File
@@ -1,556 +0,0 @@
//! Conservative outbound proxy selection for resolver-aware clients.
//!
//! When enabled, platform system discovery is tried first, explicit environment
//! proxies are the fallback, and the final fallback is a direct connection.
//! When disabled, callers retain the existing reqwest builder behavior.
use std::collections::HashMap;
use std::fmt;
use std::io;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::time::Duration;
use std::time::Instant;
use crate::custom_ca::BuildCustomCaTransportError;
use crate::custom_ca::build_reqwest_client_with_custom_ca;
#[cfg(any(target_os = "windows", target_os = "macos"))]
use sha2::Digest;
#[cfg(any(target_os = "windows", target_os = "macos"))]
use sha2::Sha256;
use thiserror::Error;
const SYSTEM_PROXY_SUCCESS_CACHE_TTL: Duration = Duration::from_secs(60);
const SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL: Duration = Duration::from_secs(5);
const SYSTEM_PROXY_CACHE_MAX_ENTRIES: usize = 256;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
/// Coarse semantic bucket for the HTTP or WebSocket client being constructed.
///
/// This is not the selected proxy route or a concrete endpoint. It labels the
/// product path that owns the client so proxy-resolution diagnostics can
/// distinguish auth, API, WebSocket, and miscellaneous traffic without exposing
/// endpoint details.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientRouteClass {
/// Login, token refresh/revoke, PAT, and agent identity auth traffic.
Auth,
/// First-party API traffic that is not part of the auth flow.
Api,
/// WebSocket traffic.
WebSocket,
/// Call sites without a more specific route class.
Other,
}
impl fmt::Display for ClientRouteClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Auth => "auth",
Self::Api => "api",
Self::WebSocket => "wss",
Self::Other => "other",
})
}
}
/// Coarse failure class for route selection errors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteFailureClass {
ProxyResolutionUnavailable,
ConnectTimeout,
ProxyAuthenticationRequired,
TlsError,
InvalidProxyConfig,
UnsupportedProxyScheme,
ResolverError,
}
impl fmt::Display for RouteFailureClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::ProxyResolutionUnavailable => "proxy_resolution_unavailable",
Self::ConnectTimeout => "connect_timeout",
Self::ProxyAuthenticationRequired => "proxy_407",
Self::TlsError => "tls_error",
Self::InvalidProxyConfig => "invalid_proxy_config",
Self::UnsupportedProxyScheme => "unsupported_proxy_scheme",
Self::ResolverError => "resolver_error",
})
}
}
/// Marker enabling fixed system/PAC/WPAD, environment, then direct routing.
/// Resolved endpoints and platform details remain internal to the client builder.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutboundProxyConfig;
impl OutboundProxyConfig {
pub const fn respect_system_proxy() -> Self {
Self
}
}
/// Error while building a resolver-aware reqwest client.
#[derive(Debug, Error)]
pub enum BuildRouteAwareHttpClientError {
#[error(transparent)]
CustomCa(#[from] BuildCustomCaTransportError),
#[error("Failed to configure outbound proxy selected for {route_class}")]
InvalidProxyConfig { route_class: ClientRouteClass },
}
impl From<BuildRouteAwareHttpClientError> for io::Error {
fn from(error: BuildRouteAwareHttpClientError) -> Self {
match error {
BuildRouteAwareHttpClientError::CustomCa(error) => error.into(),
BuildRouteAwareHttpClientError::InvalidProxyConfig { .. } => io::Error::other(error),
}
}
}
/// Builds a reqwest client with conservative route selection and shared CA handling.
///
/// Unavailable platform resolution falls back to environment proxies and then direct. Errors after
/// a route is selected are returned without trying another route. Ordered PAC candidates are
/// currently collapsed to one route on both Windows and macOS; later proxy or `DIRECT` candidates
/// are not retried after a connection failure.
pub fn build_reqwest_client_for_route(
builder: reqwest::ClientBuilder,
request_url: &str,
route_class: ClientRouteClass,
config: Option<&OutboundProxyConfig>,
) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
let builder = configure_proxy_for_route(
&ProcessEnv,
builder,
request_url,
route_class,
config,
resolve_system_proxy,
)?;
build_reqwest_client_with_custom_ca(builder).map_err(Into::into)
}
fn configure_proxy_for_route(
env: &dyn EnvSource,
builder: reqwest::ClientBuilder,
request_url: &str,
route_class: ClientRouteClass,
config: Option<&OutboundProxyConfig>,
resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision,
) -> Result<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
if config.is_none() {
return Ok(builder);
}
let origin = RequestOrigin::parse(request_url);
let Some(origin) = origin.as_ref() else {
return configure_env_proxy_handling(env, builder, /*origin*/ None, route_class);
};
match resolve_system_proxy(request_url, origin) {
SystemProxyDecision::Direct => Ok(builder.no_proxy()),
SystemProxyDecision::Proxy { url } => {
configure_concrete_proxy(builder, route_class, &url, /*no_proxy*/ None)
}
SystemProxyDecision::Unavailable { .. } => {
configure_env_proxy_handling(env, builder, Some(origin), route_class)
}
}
}
fn configure_concrete_proxy(
builder: reqwest::ClientBuilder,
route_class: ClientRouteClass,
proxy_url: &str,
no_proxy: Option<reqwest::NoProxy>,
) -> Result<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
let proxy = match reqwest::Proxy::all(proxy_url) {
Ok(proxy) => proxy,
Err(_source) => {
return Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { route_class });
}
};
Ok(builder.proxy(proxy.no_proxy(no_proxy)))
}
fn configure_env_proxy_handling(
env: &dyn EnvSource,
builder: reqwest::ClientBuilder,
origin: Option<&RequestOrigin>,
route_class: ClientRouteClass,
) -> Result<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
if let Some(origin) = origin {
let proxy_url = match origin.scheme.as_str() {
"https" => {
proxy_env_value(env, "HTTPS_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY"))
}
"http" => {
proxy_env_value(env, "HTTP_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY"))
}
_ => proxy_env_value(env, "ALL_PROXY"),
};
if let Some(proxy_url) = proxy_url {
let no_proxy = proxy_env_value(env, "NO_PROXY")
.and_then(|value| reqwest::NoProxy::from_string(&value));
return configure_concrete_proxy(builder, route_class, &proxy_url, no_proxy);
}
}
Ok(builder.no_proxy())
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)]
struct RequestOrigin {
scheme: String,
host: String,
port: u16,
}
impl RequestOrigin {
fn parse(request_url: &str) -> Option<Self> {
let uri = request_url.parse::<http::Uri>().ok()?;
let scheme = uri.scheme_str()?.to_ascii_lowercase();
let host = uri.host()?.trim_matches(['[', ']']).to_ascii_lowercase();
let port = uri.port_u16().or(match scheme.as_str() {
"http" => Some(80),
"https" => Some(443),
_ => None,
})?;
Some(Self { scheme, host, port })
}
}
#[cfg_attr(
not(any(target_os = "windows", target_os = "macos")),
allow(
dead_code,
reason = "Direct and Proxy are constructed only by platform-specific resolvers"
)
)]
#[derive(Debug, Clone, PartialEq, Eq)]
enum SystemProxyDecision {
Direct,
Proxy { url: String },
Unavailable { failure: RouteFailureClass },
}
fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
if let Some(decision) = cached_system_proxy_decision(request_url) {
return decision;
}
let decision = resolve_platform_system_proxy(request_url, origin);
cache_system_proxy_decision(request_url, decision.clone());
decision
}
#[cfg(target_os = "macos")]
fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
macos::resolve(request_url, origin)
}
#[cfg(target_os = "windows")]
fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
windows::resolve(request_url, origin)
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn resolve_platform_system_proxy(
_request_url: &str,
_origin: &RequestOrigin,
) -> SystemProxyDecision {
SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
}
}
#[derive(Debug, Clone)]
struct CachedSystemProxyDecision {
decision: SystemProxyDecision,
expires_at: Instant,
}
static SYSTEM_PROXY_CACHE: OnceLock<Mutex<HashMap<String, CachedSystemProxyDecision>>> =
OnceLock::new();
fn cached_system_proxy_decision(request_url: &str) -> Option<SystemProxyDecision> {
let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache.lock().ok()?;
let key = system_proxy_cache_key(request_url);
let cached = cache.get(&key)?;
if cached.expires_at > Instant::now() {
return Some(cached.decision.clone());
}
cache.remove(&key);
None
}
fn cache_system_proxy_decision(request_url: &str, decision: SystemProxyDecision) {
let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(mut cache) = cache.lock() {
let cache_key = system_proxy_cache_key(request_url);
insert_system_proxy_cache_entry(&mut cache, &cache_key, decision, Instant::now());
}
}
fn insert_system_proxy_cache_entry(
cache: &mut HashMap<String, CachedSystemProxyDecision>,
cache_key: &str,
decision: SystemProxyDecision,
now: Instant,
) {
let ttl = match &decision {
SystemProxyDecision::Direct | SystemProxyDecision::Proxy { .. } => {
SYSTEM_PROXY_SUCCESS_CACHE_TTL
}
SystemProxyDecision::Unavailable { .. } => SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL,
};
cache.retain(|_, cached| cached.expires_at > now);
if cache.len() >= SYSTEM_PROXY_CACHE_MAX_ENTRIES
&& !cache.contains_key(cache_key)
&& let Some(cache_key_to_evict) = cache
.iter()
.min_by_key(|(_, cached)| cached.expires_at)
.map(|(cache_key, _)| cache_key.clone())
{
cache.remove(&cache_key_to_evict);
}
cache.insert(
cache_key.to_string(),
CachedSystemProxyDecision {
decision,
expires_at: now + ttl,
},
);
}
fn system_proxy_cache_key(request_url: &str) -> String {
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
// Keep URL-specific PAC decisions without retaining the raw routed URL.
let mut hasher = Sha256::new();
hasher.update(b"system-proxy-cache-v1\0");
hasher.update(request_url.as_bytes());
format!("{:x}", hasher.finalize())
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
request_url.to_string()
}
#[cfg(any(test, target_os = "windows"))]
fn no_proxy_matches_origin(no_proxy: &str, origin: &RequestOrigin) -> bool {
no_proxy
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.any(|entry| no_proxy_entry_matches_origin(entry, origin))
}
#[cfg(any(test, target_os = "windows"))]
fn no_proxy_entry_matches_origin(entry: &str, origin: &RequestOrigin) -> bool {
if entry == "*" {
return true;
}
let mut entry = entry
.strip_prefix("http://")
.or_else(|| entry.strip_prefix("https://"))
.unwrap_or(entry)
.trim_matches(['[', ']'])
.to_ascii_lowercase();
let mut port = None;
let parsed_host_port = entry.rsplit_once(':').and_then(|(host, candidate_port)| {
if host.contains(':') {
return None;
}
candidate_port
.parse::<u16>()
.ok()
.map(|parsed_port| (host.to_string(), parsed_port))
});
if let Some((host, parsed_port)) = parsed_host_port {
entry = host;
port = Some(parsed_port);
}
if port.is_some_and(|port| port != origin.port) {
return false;
}
if let Some(suffix) = entry.strip_prefix('.') {
return origin.host == suffix || origin.host.ends_with(&format!(".{suffix}"));
}
if entry.contains('*') {
return wildcard_host_match(&entry, &origin.host);
}
origin.host == entry
}
#[cfg(any(test, target_os = "windows"))]
fn wildcard_host_match(pattern: &str, host: &str) -> bool {
let mut remaining = host;
let mut first = true;
for part in pattern.split('*') {
if part.is_empty() {
continue;
}
if first && !pattern.starts_with('*') {
let Some(stripped) = remaining.strip_prefix(part) else {
return false;
};
remaining = stripped;
} else {
let Some(index) = remaining.find(part) else {
return false;
};
remaining = &remaining[index + part.len()..];
}
first = false;
}
pattern.ends_with('*') || remaining.is_empty()
}
#[cfg(any(test, target_os = "windows"))]
#[derive(Debug, Clone, PartialEq, Eq)]
enum ParsedProxyListDecision {
Direct,
Proxy(String),
UnsupportedScheme,
Unavailable,
}
#[cfg(any(test, target_os = "windows"))]
fn parse_proxy_list(input: &str, target_scheme: &str) -> ParsedProxyListDecision {
let mut saw_unsupported = false;
{
let mut process_token = |token: &str| {
let decision = parse_proxy_token(token, target_scheme);
match decision {
ParsedProxyListDecision::Direct => Some(ParsedProxyListDecision::Direct),
ParsedProxyListDecision::Proxy(url) => Some(ParsedProxyListDecision::Proxy(url)),
ParsedProxyListDecision::UnsupportedScheme => {
saw_unsupported = true;
None
}
ParsedProxyListDecision::Unavailable => None,
}
};
for segment in input
.split(';')
.map(str::trim)
.filter(|segment| !segment.is_empty())
{
let mut parts = segment.split_whitespace();
let directive = parts.next();
let hostport = parts.next();
let extra = parts.next();
let is_proxy_directive = matches!(
directive.map(str::to_ascii_lowercase).as_deref(),
Some("proxy" | "http" | "https" | "socks" | "socks4" | "socks5")
) && hostport.is_some()
&& extra.is_none();
if is_proxy_directive {
if let Some(decision) = process_token(segment) {
return decision;
}
} else {
for token in segment.split_whitespace() {
if let Some(decision) = process_token(token) {
return decision;
}
}
}
}
}
if saw_unsupported {
ParsedProxyListDecision::UnsupportedScheme
} else {
ParsedProxyListDecision::Unavailable
}
}
#[cfg(any(test, target_os = "windows"))]
fn parse_proxy_token(token: &str, target_scheme: &str) -> ParsedProxyListDecision {
if token.eq_ignore_ascii_case("DIRECT") {
return ParsedProxyListDecision::Direct;
}
if let Some(decision) = parse_proxy_key_token(token, target_scheme) {
return decision;
}
if token.contains('=') {
return ParsedProxyListDecision::Unavailable;
}
let mut parts = token.split_whitespace();
let directive = parts.next();
let hostport = parts.next();
if let (Some(directive), Some(hostport), None) = (directive, hostport, parts.next()) {
return match directive.to_ascii_lowercase().as_str() {
"proxy" | "http" => proxy_url_from_hostport("http", hostport),
"https" => proxy_url_from_hostport("https", hostport),
"socks" | "socks4" | "socks5" => ParsedProxyListDecision::UnsupportedScheme,
_ => ParsedProxyListDecision::Unavailable,
};
}
proxy_url_from_hostport("http", token)
}
#[cfg(any(test, target_os = "windows"))]
fn parse_proxy_key_token(token: &str, target_scheme: &str) -> Option<ParsedProxyListDecision> {
let (key, value) = token.split_once('=')?;
if key.trim().eq_ignore_ascii_case(target_scheme) {
Some(proxy_url_from_hostport("http", value.trim()))
} else {
Some(ParsedProxyListDecision::Unavailable)
}
}
#[cfg(any(test, target_os = "windows"))]
fn proxy_url_from_hostport(proxy_scheme: &str, hostport: &str) -> ParsedProxyListDecision {
if hostport.is_empty() {
return ParsedProxyListDecision::Unavailable;
}
if hostport.contains("://") {
return ParsedProxyListDecision::Proxy(hostport.to_string());
}
ParsedProxyListDecision::Proxy(format!("{proxy_scheme}://{hostport}"))
}
trait EnvSource {
fn var(&self, key: &str) -> Option<String>;
}
struct ProcessEnv;
impl EnvSource for ProcessEnv {
fn var(&self, key: &str) -> Option<String> {
std::env::var(key).ok()
}
}
fn proxy_env_value(env: &dyn EnvSource, upper: &str) -> Option<String> {
let lower = upper.to_ascii_lowercase();
env.var(upper)
.or_else(|| env.var(&lower))
.filter(|value| !value.is_empty())
}
#[cfg(test)]
#[path = "outbound_proxy_tests.rs"]
mod tests;
@@ -1,382 +0,0 @@
use std::ffi::c_void;
use std::ptr;
use std::time::Duration;
use std::time::Instant;
use super::RequestOrigin;
use super::RouteFailureClass;
use super::SystemProxyDecision;
use system_configuration::core_foundation::array::CFArray;
use system_configuration::core_foundation::array::CFArrayRef;
use system_configuration::core_foundation::base::CFEqual;
use system_configuration::core_foundation::base::CFGetTypeID;
use system_configuration::core_foundation::base::CFIndex;
use system_configuration::core_foundation::base::CFType;
use system_configuration::core_foundation::base::CFTypeRef;
use system_configuration::core_foundation::base::TCFType;
use system_configuration::core_foundation::base::kCFAllocatorDefault;
use system_configuration::core_foundation::dictionary::CFDictionary;
use system_configuration::core_foundation::dictionary::CFDictionaryRef;
use system_configuration::core_foundation::error::CFErrorRef;
use system_configuration::core_foundation::number::CFNumber;
use system_configuration::core_foundation::runloop::CFRunLoop;
use system_configuration::core_foundation::runloop::CFRunLoopSource;
use system_configuration::core_foundation::runloop::CFRunLoopSourceInvalidate;
use system_configuration::core_foundation::runloop::CFRunLoopSourceRef;
use system_configuration::core_foundation::runloop::kCFRunLoopDefaultMode;
use system_configuration::core_foundation::string::CFString;
use system_configuration::core_foundation::string::CFStringRef;
use system_configuration::core_foundation::url::CFURL;
use system_configuration::core_foundation::url::CFURLCreateWithString;
use system_configuration::core_foundation::url::CFURLGetTypeID;
use system_configuration::core_foundation::url::CFURLRef;
use system_configuration::dynamic_store::SCDynamicStoreBuilder;
const PAC_EXECUTION_TIMEOUT: Duration = Duration::from_secs(5);
type ProxyDictionary = CFDictionary<CFString, CFType>;
type ProxyArray = CFArray<ProxyDictionary>;
#[repr(C)]
struct CFStreamClientContext {
version: CFIndex,
info: *mut c_void,
retain: Option<unsafe extern "C" fn(*mut c_void) -> *mut c_void>,
release: Option<unsafe extern "C" fn(*mut c_void)>,
copy_description: Option<unsafe extern "C" fn(*mut c_void) -> CFStringRef>,
}
type CFProxyAutoConfigurationResultCallback =
unsafe extern "C" fn(*mut c_void, CFArrayRef, CFErrorRef);
#[link(name = "CFNetwork", kind = "framework")]
unsafe extern "C" {
static kCFProxyTypeKey: CFStringRef;
static kCFProxyHostNameKey: CFStringRef;
static kCFProxyPortNumberKey: CFStringRef;
static kCFProxyAutoConfigurationURLKey: CFStringRef;
static kCFProxyAutoConfigurationJavaScriptKey: CFStringRef;
static kCFProxyTypeNone: CFStringRef;
static kCFProxyTypeHTTP: CFStringRef;
static kCFProxyTypeHTTPS: CFStringRef;
static kCFProxyTypeSOCKS: CFStringRef;
static kCFProxyTypeAutoConfigurationURL: CFStringRef;
static kCFProxyTypeAutoConfigurationJavaScript: CFStringRef;
fn CFNetworkCopyProxiesForURL(url: CFURLRef, proxy_settings: CFDictionaryRef) -> CFArrayRef;
fn CFNetworkExecuteProxyAutoConfigurationURL(
proxy_auto_config_url: CFURLRef,
target_url: CFURLRef,
callback: CFProxyAutoConfigurationResultCallback,
client_context: *mut CFStreamClientContext,
) -> CFRunLoopSourceRef;
fn CFNetworkExecuteProxyAutoConfigurationScript(
proxy_auto_config_script: CFStringRef,
target_url: CFURLRef,
callback: CFProxyAutoConfigurationResultCallback,
client_context: *mut CFStreamClientContext,
) -> CFRunLoopSourceRef;
}
pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
let Some(target_url) = cf_url(request_url) else {
return SystemProxyDecision::Unavailable {
failure: RouteFailureClass::InvalidProxyConfig,
};
};
let Some(settings) = system_proxy_settings() else {
return SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
};
};
let Some(proxies) = copy_proxies_for_url(&target_url, &settings) else {
return SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
};
};
proxy_array_decision(&proxies, &target_url, origin)
}
fn system_proxy_settings() -> Option<CFDictionary<CFString, CFType>> {
let store = SCDynamicStoreBuilder::new("Codex").build()?;
store.get_proxies()
}
fn copy_proxies_for_url(
target_url: &CFURL,
settings: &CFDictionary<CFString, CFType>,
) -> Option<ProxyArray> {
let proxies = unsafe {
CFNetworkCopyProxiesForURL(
target_url.as_concrete_TypeRef(),
settings.as_concrete_TypeRef(),
)
};
if proxies.is_null() {
None
} else {
Some(unsafe { ProxyArray::wrap_under_create_rule(proxies) })
}
}
fn proxy_array_decision(
proxies: &ProxyArray,
target_url: &CFURL,
origin: &RequestOrigin,
) -> SystemProxyDecision {
let mut saw_unsupported = false;
let mut saw_unavailable = false;
// CFNetwork returns candidates in failover order, but the shared resolver currently carries
// only one route. This matches the Windows limitation; cross-platform retry requires request
// replay semantics and is intentionally deferred.
for proxy in proxies {
match proxy_entry_decision(&proxy, target_url, origin) {
ProxyEntryDecision::Direct => return SystemProxyDecision::Direct,
ProxyEntryDecision::Proxy { url } => return SystemProxyDecision::Proxy { url },
ProxyEntryDecision::UnsupportedScheme => saw_unsupported = true,
ProxyEntryDecision::Unavailable => saw_unavailable = true,
}
}
if saw_unsupported {
SystemProxyDecision::Unavailable {
failure: RouteFailureClass::UnsupportedProxyScheme,
}
} else if saw_unavailable {
SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
}
} else {
SystemProxyDecision::Direct
}
}
fn proxy_entry_decision(
proxy: &ProxyDictionary,
target_url: &CFURL,
origin: &RequestOrigin,
) -> ProxyEntryDecision {
let Some(proxy_type) = cf_string_value(proxy, unsafe { kCFProxyTypeKey }) else {
return ProxyEntryDecision::Unavailable;
};
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeNone }) {
return ProxyEntryDecision::Direct;
}
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTP }) {
return concrete_proxy_entry(proxy, "http");
}
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTPS }) {
// CFNetwork's HTTPS proxy type is a tunneling proxy for HTTPS destinations; it does not
// preserve an explicit TLS-to-proxy transport. See https://developer.apple.com/documentation/cfnetwork/kcfproxytypehttps.
return concrete_proxy_entry(proxy, "http");
}
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeSOCKS }) {
return ProxyEntryDecision::UnsupportedScheme;
}
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeAutoConfigurationURL }) {
let Some(pac_url) = cf_url_value(proxy, unsafe { kCFProxyAutoConfigurationURLKey }) else {
return ProxyEntryDecision::Unavailable;
};
return pac_decision(execute_pac_url(&pac_url, target_url), target_url, origin);
}
if cf_string_equals(&proxy_type, unsafe {
kCFProxyTypeAutoConfigurationJavaScript
}) {
let Some(script) =
cf_string_value(proxy, unsafe { kCFProxyAutoConfigurationJavaScriptKey })
else {
return ProxyEntryDecision::Unavailable;
};
return pac_decision(
execute_pac(|callback, context| unsafe {
CFNetworkExecuteProxyAutoConfigurationScript(
script.as_concrete_TypeRef(),
target_url.as_concrete_TypeRef(),
callback,
context,
)
}),
target_url,
origin,
);
}
ProxyEntryDecision::Unavailable
}
fn pac_decision(
result: Result<ProxyArray, RouteFailureClass>,
target_url: &CFURL,
origin: &RequestOrigin,
) -> ProxyEntryDecision {
let proxies = match result {
Ok(proxies) => proxies,
Err(RouteFailureClass::UnsupportedProxyScheme) => {
return ProxyEntryDecision::UnsupportedScheme;
}
Err(_) => return ProxyEntryDecision::Unavailable,
};
match proxy_array_decision(&proxies, target_url, origin) {
SystemProxyDecision::Direct => ProxyEntryDecision::Direct,
SystemProxyDecision::Proxy { url } => ProxyEntryDecision::Proxy { url },
SystemProxyDecision::Unavailable {
failure: RouteFailureClass::UnsupportedProxyScheme,
} => ProxyEntryDecision::UnsupportedScheme,
SystemProxyDecision::Unavailable { failure: _ } => ProxyEntryDecision::Unavailable,
}
}
fn execute_pac_url(pac_url: &CFURL, target_url: &CFURL) -> Result<ProxyArray, RouteFailureClass> {
execute_pac(|callback, context| unsafe {
CFNetworkExecuteProxyAutoConfigurationURL(
pac_url.as_concrete_TypeRef(),
target_url.as_concrete_TypeRef(),
callback,
context,
)
})
}
fn execute_pac(
create_source: impl FnOnce(
CFProxyAutoConfigurationResultCallback,
*mut CFStreamClientContext,
) -> CFRunLoopSourceRef,
) -> Result<ProxyArray, RouteFailureClass> {
let mut state = PacRunLoopState { result: None };
let mut context = CFStreamClientContext {
version: 0,
info: (&mut state as *mut PacRunLoopState).cast::<c_void>(),
retain: None,
release: None,
copy_description: None,
};
let source = create_source(pac_result_callback, &mut context);
if source.is_null() {
return Err(RouteFailureClass::ProxyResolutionUnavailable);
}
let source = unsafe { CFRunLoopSource::wrap_under_create_rule(source) };
let run_loop = CFRunLoop::get_current();
let mode = unsafe { kCFRunLoopDefaultMode };
run_loop.add_source(&source, mode);
let started_at = Instant::now();
while state.result.is_none() && started_at.elapsed() < PAC_EXECUTION_TIMEOUT {
CFRunLoop::run_in_mode(mode, Duration::from_millis(50), true);
}
if state.result.is_none() {
unsafe { CFRunLoopSourceInvalidate(source.as_concrete_TypeRef()) };
}
run_loop.remove_source(&source, mode);
state
.result
.unwrap_or(Err(RouteFailureClass::ConnectTimeout))
}
unsafe extern "C" fn pac_result_callback(
client: *mut c_void,
proxies: CFArrayRef,
error: CFErrorRef,
) {
let state = unsafe { &mut *client.cast::<PacRunLoopState>() };
state.result = if !error.is_null() || proxies.is_null() {
Some(Err(RouteFailureClass::ProxyResolutionUnavailable))
} else {
Some(Ok(unsafe { ProxyArray::wrap_under_get_rule(proxies) }))
};
CFRunLoop::get_current().stop();
}
struct PacRunLoopState {
result: Option<Result<ProxyArray, RouteFailureClass>>,
}
fn concrete_proxy_entry(proxy: &ProxyDictionary, proxy_scheme: &str) -> ProxyEntryDecision {
let Some(host) = cf_string_value(proxy, unsafe { kCFProxyHostNameKey })
.map(|host| host.to_string())
.filter(|host| !host.is_empty())
else {
return ProxyEntryDecision::Unavailable;
};
let host = bracket_ipv6_host(&host);
let url = match cf_i32_value(proxy, unsafe { kCFProxyPortNumberKey }) {
Some(port) if port > 0 => format!("{proxy_scheme}://{host}:{port}"),
_ => format!("{proxy_scheme}://{host}"),
};
ProxyEntryDecision::Proxy { url }
}
fn bracket_ipv6_host(host: &str) -> String {
if host.contains(':') && !host.starts_with('[') {
format!("[{host}]")
} else {
host.to_string()
}
}
fn cf_string_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option<CFString> {
proxy
.find(key)
.and_then(|value| value.downcast::<CFString>())
}
fn cf_i32_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option<i32> {
proxy
.find(key)
.and_then(|value| value.downcast::<CFNumber>())
.and_then(|value| value.to_i32())
}
fn cf_url_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option<CFURL> {
proxy.find(key).and_then(|value| {
if unsafe { CFGetTypeID(value.as_CFTypeRef()) == CFURLGetTypeID() } {
Some(unsafe { CFURL::wrap_under_get_rule(value.as_CFTypeRef() as CFURLRef) })
} else {
value
.downcast::<CFString>()
.and_then(|value| cf_url(value.to_string().as_str()))
}
})
}
fn cf_string_equals(value: &CFString, expected: CFStringRef) -> bool {
unsafe { CFEqual(value.as_CFTypeRef(), expected as CFTypeRef) != 0 }
}
fn cf_url(value: &str) -> Option<CFURL> {
let value = CFString::new(value);
let url = unsafe {
CFURLCreateWithString(
kCFAllocatorDefault,
value.as_concrete_TypeRef(),
ptr::null(),
)
};
if url.is_null() {
None
} else {
Some(unsafe { CFURL::wrap_under_create_rule(url) })
}
}
enum ProxyEntryDecision {
Direct,
Proxy { url: String },
UnsupportedScheme,
Unavailable,
}
@@ -1,362 +0,0 @@
use std::ffi::c_void;
use std::ptr;
use super::ParsedProxyListDecision;
use super::RequestOrigin;
use super::RouteFailureClass;
use super::SystemProxyDecision;
use super::no_proxy_matches_origin;
use super::parse_proxy_list;
use windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND;
use windows_sys::Win32::Foundation::FALSE;
use windows_sys::Win32::Foundation::GetLastError;
use windows_sys::Win32::Foundation::GlobalFree;
use windows_sys::Win32::Foundation::TRUE;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_AUTODETECTION_FAILED;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_BAD_AUTO_PROXY_SCRIPT;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_CANNOT_CONNECT;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_CONNECTION_ERROR;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_INVALID_URL;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_LOGIN_FAILURE;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_NAME_NOT_RESOLVED;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SCRIPT_EXECUTION_ERROR;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_CN_INVALID;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_DATE_INVALID;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_REV_FAILED;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_REVOKED;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_WRONG_USAGE;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CHANNEL_ERROR;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_FAILURE;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_INVALID_CA;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_INVALID_CERT;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_TIMEOUT;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNHANDLED_SCRIPT_TYPE;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNRECOGNIZED_SCHEME;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_ACCESS_TYPE_NAMED_PROXY;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_ACCESS_TYPE_NO_PROXY;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTO_DETECT_TYPE_DHCP;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTO_DETECT_TYPE_DNS_A;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_AUTO_DETECT;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_CONFIG_URL;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_OPTIONS;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_CURRENT_USER_IE_PROXY_CONFIG;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_PROXY_INFO;
use windows_sys::Win32::Networking::WinHttp::WinHttpCloseHandle;
use windows_sys::Win32::Networking::WinHttp::WinHttpGetIEProxyConfigForCurrentUser;
use windows_sys::Win32::Networking::WinHttp::WinHttpGetProxyForUrl;
use windows_sys::Win32::Networking::WinHttp::WinHttpOpen;
use windows_sys::core::PWSTR;
pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
let ie_config = match current_user_ie_proxy_config() {
Ok(config) => config,
Err(failure) => {
return SystemProxyDecision::Unavailable { failure };
}
};
if let Some(pac_url) = ie_config.auto_config_url.as_deref() {
let decision = resolve_with_pac_url(request_url, origin, pac_url);
if !matches!(decision, SystemProxyDecision::Unavailable { .. }) {
return decision;
}
}
if ie_config.auto_detect {
let decision = resolve_with_auto_detect(request_url, origin);
if !matches!(decision, SystemProxyDecision::Unavailable { .. }) {
return decision;
}
}
if let Some(proxy) = ie_config.static_proxy.as_deref() {
if ie_config
.proxy_bypass
.as_deref()
.is_some_and(|bypass| proxy_bypass_matches_origin(bypass, origin))
{
return SystemProxyDecision::Direct;
}
return proxy_list_decision(proxy, origin);
}
if ie_config.auto_config_url.is_some() || ie_config.auto_detect {
SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
}
} else {
SystemProxyDecision::Direct
}
}
fn resolve_with_pac_url(
request_url: &str,
origin: &RequestOrigin,
pac_url: &str,
) -> SystemProxyDecision {
let pac_url = wide_null(pac_url);
let options = WINHTTP_AUTOPROXY_OPTIONS {
dwFlags: WINHTTP_AUTOPROXY_CONFIG_URL,
dwAutoDetectFlags: 0,
lpszAutoConfigUrl: pac_url.as_ptr(),
lpvReserved: ptr::null_mut(),
dwReserved: 0,
fAutoLogonIfChallenged: TRUE,
};
resolve_with_winhttp_options(request_url, origin, options)
}
fn resolve_with_auto_detect(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
let options = WINHTTP_AUTOPROXY_OPTIONS {
dwFlags: WINHTTP_AUTOPROXY_AUTO_DETECT,
dwAutoDetectFlags: WINHTTP_AUTO_DETECT_TYPE_DHCP | WINHTTP_AUTO_DETECT_TYPE_DNS_A,
lpszAutoConfigUrl: ptr::null(),
lpvReserved: ptr::null_mut(),
dwReserved: 0,
fAutoLogonIfChallenged: FALSE,
};
resolve_with_winhttp_options(request_url, origin, options)
}
fn resolve_with_winhttp_options(
request_url: &str,
origin: &RequestOrigin,
mut options: WINHTTP_AUTOPROXY_OPTIONS,
) -> SystemProxyDecision {
let Some(session) = WinHttpSession::open() else {
return SystemProxyDecision::Unavailable {
failure: classify_winhttp_error(last_error()),
};
};
let request_url = wide_null(request_url);
let mut proxy_info = WINHTTP_PROXY_INFO {
dwAccessType: WINHTTP_ACCESS_TYPE_NO_PROXY,
lpszProxy: ptr::null_mut(),
lpszProxyBypass: ptr::null_mut(),
};
let ok = unsafe {
WinHttpGetProxyForUrl(
session.0,
request_url.as_ptr(),
&mut options,
&mut proxy_info,
)
};
if ok == FALSE {
return SystemProxyDecision::Unavailable {
failure: classify_winhttp_error(last_error()),
};
}
let proxy_info = ProxyInfo::from_raw(proxy_info);
if proxy_info.access_type == WINHTTP_ACCESS_TYPE_NO_PROXY {
return SystemProxyDecision::Direct;
}
if proxy_info.access_type != WINHTTP_ACCESS_TYPE_NAMED_PROXY {
return SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
};
}
let Some(proxy) = proxy_info.proxy.as_deref() else {
return SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
};
};
proxy_list_decision(proxy, origin)
}
fn proxy_list_decision(proxy_list: &str, origin: &RequestOrigin) -> SystemProxyDecision {
match parse_proxy_list(proxy_list, &origin.scheme) {
ParsedProxyListDecision::Direct => SystemProxyDecision::Direct,
ParsedProxyListDecision::Proxy(url) => SystemProxyDecision::Proxy { url },
ParsedProxyListDecision::UnsupportedScheme => SystemProxyDecision::Unavailable {
failure: RouteFailureClass::UnsupportedProxyScheme,
},
ParsedProxyListDecision::Unavailable => SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
},
}
}
fn current_user_ie_proxy_config() -> Result<IeProxyConfig, RouteFailureClass> {
let mut raw = WINHTTP_CURRENT_USER_IE_PROXY_CONFIG {
fAutoDetect: FALSE,
lpszAutoConfigUrl: ptr::null_mut(),
lpszProxy: ptr::null_mut(),
lpszProxyBypass: ptr::null_mut(),
};
let ok = unsafe { WinHttpGetIEProxyConfigForCurrentUser(&mut raw) };
if ok == FALSE {
let error = last_error();
if error == ERROR_FILE_NOT_FOUND {
// Match WinHTTP's fallback by attempting WPAD when no IE proxy settings exist.
return Ok(IeProxyConfig {
auto_detect: true,
..Default::default()
});
}
return Err(classify_winhttp_error(error));
}
let auto_config_url = GlobalWideString::from_raw(raw.lpszAutoConfigUrl).into_string();
let static_proxy = GlobalWideString::from_raw(raw.lpszProxy).into_string();
let proxy_bypass = GlobalWideString::from_raw(raw.lpszProxyBypass).into_string();
Ok(IeProxyConfig {
auto_detect: raw.fAutoDetect != FALSE,
auto_config_url,
static_proxy,
proxy_bypass,
})
}
#[derive(Debug, Default)]
struct IeProxyConfig {
auto_detect: bool,
auto_config_url: Option<String>,
static_proxy: Option<String>,
proxy_bypass: Option<String>,
}
struct ProxyInfo {
access_type: u32,
proxy: Option<String>,
_proxy_bypass: Option<String>,
}
impl ProxyInfo {
fn from_raw(raw: WINHTTP_PROXY_INFO) -> Self {
Self {
access_type: raw.dwAccessType,
proxy: GlobalWideString::from_raw(raw.lpszProxy).into_string(),
_proxy_bypass: GlobalWideString::from_raw(raw.lpszProxyBypass).into_string(),
}
}
}
struct GlobalWideString(PWSTR);
impl GlobalWideString {
fn from_raw(ptr: PWSTR) -> Self {
Self(ptr)
}
fn into_string(self) -> Option<String> {
if self.0.is_null() {
return None;
}
let string = unsafe { wide_ptr_to_string(self.0) };
if string.is_empty() {
None
} else {
Some(string)
}
}
}
impl Drop for GlobalWideString {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe {
GlobalFree(self.0.cast::<c_void>());
}
}
}
}
struct WinHttpSession(*mut c_void);
impl WinHttpSession {
fn open() -> Option<Self> {
let agent = wide_null("Codex");
let handle = unsafe {
WinHttpOpen(
agent.as_ptr(),
WINHTTP_ACCESS_TYPE_NO_PROXY,
ptr::null(),
ptr::null(),
0,
)
};
if handle.is_null() {
None
} else {
Some(Self(handle))
}
}
}
impl Drop for WinHttpSession {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe {
WinHttpCloseHandle(self.0);
}
}
}
}
fn proxy_bypass_matches_origin(proxy_bypass: &str, origin: &RequestOrigin) -> bool {
proxy_bypass
.split(|ch: char| ch == ';' || ch == ',' || ch.is_whitespace())
.map(str::trim)
.filter(|entry| !entry.is_empty())
.any(|entry| {
if entry.eq_ignore_ascii_case("<local>") {
!origin.host.contains('.')
} else {
no_proxy_matches_origin(entry, origin)
}
})
}
#[cfg(test)]
#[path = "windows_tests.rs"]
mod tests;
fn wide_null(value: &str) -> Vec<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}
unsafe fn wide_ptr_to_string(ptr: PWSTR) -> String {
let mut len = 0;
while unsafe { *ptr.add(len) } != 0 {
len += 1;
}
let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
String::from_utf16_lossy(slice)
}
fn last_error() -> u32 {
unsafe { GetLastError() }
}
fn classify_winhttp_error(code: u32) -> RouteFailureClass {
match code {
ERROR_WINHTTP_TIMEOUT => RouteFailureClass::ConnectTimeout,
ERROR_WINHTTP_LOGIN_FAILURE => RouteFailureClass::ProxyAuthenticationRequired,
ERROR_WINHTTP_AUTODETECTION_FAILED
| ERROR_WINHTTP_BAD_AUTO_PROXY_SCRIPT
| ERROR_WINHTTP_SCRIPT_EXECUTION_ERROR
| ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT
| ERROR_WINHTTP_UNHANDLED_SCRIPT_TYPE => RouteFailureClass::ProxyResolutionUnavailable,
ERROR_WINHTTP_SECURE_CERT_CN_INVALID
| ERROR_WINHTTP_SECURE_CERT_DATE_INVALID
| ERROR_WINHTTP_SECURE_CERT_REVOKED
| ERROR_WINHTTP_SECURE_CERT_REV_FAILED
| ERROR_WINHTTP_SECURE_CERT_WRONG_USAGE
| ERROR_WINHTTP_SECURE_CHANNEL_ERROR
| ERROR_WINHTTP_SECURE_FAILURE
| ERROR_WINHTTP_SECURE_INVALID_CA
| ERROR_WINHTTP_SECURE_INVALID_CERT => RouteFailureClass::TlsError,
ERROR_WINHTTP_INVALID_URL | ERROR_WINHTTP_UNRECOGNIZED_SCHEME => {
RouteFailureClass::InvalidProxyConfig
}
ERROR_WINHTTP_CANNOT_CONNECT
| ERROR_WINHTTP_CONNECTION_ERROR
| ERROR_WINHTTP_NAME_NOT_RESOLVED => RouteFailureClass::ResolverError,
_ => RouteFailureClass::ResolverError,
}
}
@@ -1,18 +0,0 @@
use super::*;
#[test]
fn proxy_bypass_matches_whitespace_separated_winhttp_entries() {
let local_origin = RequestOrigin {
scheme: "https".to_string(),
host: "intranet".to_string(),
port: 443,
};
assert!(proxy_bypass_matches_origin("<local> *.corp", &local_origin));
let corp_origin = RequestOrigin {
scheme: "https".to_string(),
host: "service.corp".to_string(),
port: 443,
};
assert!(proxy_bypass_matches_origin("<local> *.corp", &corp_origin));
}
@@ -1,212 +0,0 @@
use super::*;
use pretty_assertions::assert_eq;
use std::io::Read;
use std::io::Write;
struct MapEnv {
values: HashMap<String, String>,
}
impl EnvSource for MapEnv {
fn var(&self, key: &str) -> Option<String> {
self.values.get(key).cloned()
}
}
#[test]
fn proxy_env_value_matches_reqwest_casing_precedence() {
let env = MapEnv {
values: HashMap::from([
("HTTPS_PROXY".to_string(), "upper".to_string()),
("https_proxy".to_string(), "lower".to_string()),
("http_proxy".to_string(), "lower-only".to_string()),
("ALL_PROXY".to_string(), String::new()),
("all_proxy".to_string(), "masked".to_string()),
]),
};
assert_eq!(
proxy_env_value(&env, "HTTPS_PROXY"),
Some("upper".to_string())
);
assert_eq!(
proxy_env_value(&env, "HTTP_PROXY"),
Some("lower-only".to_string())
);
assert_eq!(proxy_env_value(&env, "ALL_PROXY"), None);
}
#[test]
fn environment_fallback_reads_injected_proxy_environment() {
let env = MapEnv {
values: HashMap::from([("HTTPS_PROXY".to_string(), "://invalid".to_string())]),
};
let origin = RequestOrigin::parse("https://auth.openai.com/oauth/token").expect("valid URL");
let result = configure_env_proxy_handling(
&env,
reqwest::Client::builder(),
Some(&origin),
ClientRouteClass::Auth,
);
assert!(matches!(
result,
Err(BuildRouteAwareHttpClientError::InvalidProxyConfig {
route_class: ClientRouteClass::Auth,
})
));
}
#[tokio::test]
async fn enabled_environment_proxy_routes_request_through_proxy() {
let listener =
std::net::TcpListener::bind(("127.0.0.1", 0)).expect("local proxy listener should bind");
let proxy_addr = listener
.local_addr()
.expect("local proxy listener should have an address");
let proxy_thread = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("proxy should accept a request");
let mut buffer = [0_u8; 4096];
let size = stream.read(&mut buffer).expect("proxy should read request");
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
.expect("proxy should write response");
String::from_utf8_lossy(&buffer[..size]).into_owned()
});
let env = MapEnv {
values: HashMap::from([("HTTP_PROXY".to_string(), format!("http://{proxy_addr}"))]),
};
let request_url = "http://enabled-proxy.test/proxy-check";
let config = OutboundProxyConfig::respect_system_proxy();
let builder = configure_proxy_for_route(
&env,
reqwest::Client::builder().timeout(Duration::from_secs(2)),
request_url,
ClientRouteClass::Auth,
Some(&config),
|_, _| SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
},
)
.expect("enabled proxy route should configure");
let response = builder
.build()
.expect("proxy client should build")
.get(request_url)
.send()
.await
.expect("request should use local proxy");
let proxy_request = proxy_thread.join().expect("proxy thread should finish");
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(
proxy_request.lines().next(),
Some("GET http://enabled-proxy.test/proxy-check HTTP/1.1")
);
}
#[test]
fn parses_pac_proxy_tokens() {
assert_eq!(
parse_proxy_list("PROXY proxy.internal:8080; DIRECT", "https"),
ParsedProxyListDecision::Proxy("http://proxy.internal:8080".to_string())
);
assert_eq!(
parse_proxy_list("HTTPS proxy.internal:8443", "https"),
ParsedProxyListDecision::Proxy("https://proxy.internal:8443".to_string())
);
}
#[test]
fn unavailable_system_proxy_decision_is_cached() {
let request_url = "https://unavailable-cache.test/oauth/token";
let decision = SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
};
cache_system_proxy_decision(request_url, decision.clone());
assert_eq!(cached_system_proxy_decision(request_url), Some(decision));
}
#[test]
fn system_proxy_cache_is_bounded() {
let mut cache = HashMap::new();
let now = Instant::now();
for index in 0..=SYSTEM_PROXY_CACHE_MAX_ENTRIES {
insert_system_proxy_cache_entry(
&mut cache,
&format!("https://bounded-cache.test/{index}"),
SystemProxyDecision::Direct,
now,
);
}
assert_eq!(cache.len(), SYSTEM_PROXY_CACHE_MAX_ENTRIES);
}
#[test]
fn parses_static_winhttp_proxy_entries_for_target_scheme() {
assert_eq!(
parse_proxy_list("http=web-proxy:8080;https=secure-proxy:8443", "https"),
ParsedProxyListDecision::Proxy("http://secure-proxy:8443".to_string())
);
assert_eq!(
parse_proxy_list("http=web-proxy:8080 https=secure-proxy:8443", "https"),
ParsedProxyListDecision::Proxy("http://secure-proxy:8443".to_string())
);
assert_eq!(
parse_proxy_list("http=web-proxy:8080", "https"),
ParsedProxyListDecision::Unavailable
);
assert_eq!(
parse_proxy_list("proxy.internal:8080", "https"),
ParsedProxyListDecision::Proxy("http://proxy.internal:8080".to_string())
);
}
#[test]
fn reports_direct_and_unsupported_proxy_tokens() {
assert_eq!(
parse_proxy_list("DIRECT; PROXY proxy.internal:8080", "https"),
ParsedProxyListDecision::Direct
);
assert_eq!(
parse_proxy_list("DIRECT", "https"),
ParsedProxyListDecision::Direct
);
assert_eq!(
parse_proxy_list("SOCKS proxy.internal:1080", "https"),
ParsedProxyListDecision::UnsupportedScheme
);
}
#[test]
fn no_proxy_matches_exact_suffix_wildcard_and_port() {
let origin = RequestOrigin {
scheme: "https".to_string(),
host: "auth.openai.com".to_string(),
port: 443,
};
assert!(no_proxy_matches_origin("auth.openai.com", &origin));
assert!(!no_proxy_matches_origin("openai.com", &origin));
assert!(no_proxy_matches_origin(".openai.com", &origin));
assert!(no_proxy_matches_origin("*.openai.com", &origin));
assert!(no_proxy_matches_origin("auth.openai.com:443", &origin));
assert!(!no_proxy_matches_origin("auth.openai.com:8443", &origin));
}
#[test]
fn system_proxy_cache_key_preserves_url_specific_pac_decisions() {
let request_url = "https://auth.openai.com/oauth/token?access_token=secret";
let cache_key = system_proxy_cache_key(request_url);
assert_ne!(
cache_key,
system_proxy_cache_key("https://auth.openai.com/oauth/revoke")
);
#[cfg(any(target_os = "windows", target_os = "macos"))]
assert!(!cache_key.contains(request_url));
}
-329
View File
@@ -1,329 +0,0 @@
use bytes::Bytes;
use http::Method;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderValue;
use serde::Serialize;
use serde_json::Value;
use std::time::Duration;
/// A JSON request body serialized once into reference-counted bytes.
///
/// Clones share the encoded allocation. Internally, the body can also hold the
/// final compressed wire bytes while retaining the original JSON only when
/// request-body trace logging is enabled.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncodedJsonBody {
bytes: Bytes,
trace_bytes: Option<Bytes>,
prepared: bool,
}
impl EncodedJsonBody {
/// Serializes `value` into a reusable JSON body.
pub fn encode<T: Serialize + ?Sized>(value: &T) -> Result<Self, serde_json::Error> {
serde_json::to_vec(value).map(|bytes| Self {
bytes: Bytes::from(bytes),
trace_bytes: None,
prepared: false,
})
}
/// Returns the encoded bytes currently stored by this body.
pub fn as_bytes(&self) -> &[u8] {
&self.bytes
}
pub(crate) fn trace_bytes(&self) -> &[u8] {
self.trace_bytes.as_ref().unwrap_or(&self.bytes)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum RequestCompression {
#[default]
None,
Zstd,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequestBody {
Json(Value),
EncodedJson(EncodedJsonBody),
Raw(Bytes),
}
impl RequestBody {
pub fn json(&self) -> Option<&Value> {
match self {
Self::Json(value) => Some(value),
Self::EncodedJson(_) | Self::Raw(_) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedRequestBody {
pub headers: HeaderMap,
pub body: Option<Bytes>,
}
impl PreparedRequestBody {
pub fn body_bytes(&self) -> Bytes {
self.body.clone().unwrap_or_default()
}
}
#[derive(Debug, Clone)]
pub struct Request {
pub method: Method,
pub url: String,
pub headers: HeaderMap,
pub body: Option<RequestBody>,
pub compression: RequestCompression,
pub timeout: Option<Duration>,
}
impl Request {
pub fn new(method: Method, url: String) -> Self {
Self {
method,
url,
headers: HeaderMap::new(),
body: None,
compression: RequestCompression::None,
timeout: None,
}
}
pub fn with_json<T: Serialize>(mut self, body: &T) -> Self {
self.body = serde_json::to_value(body).ok().map(RequestBody::Json);
self
}
pub fn with_raw_body(mut self, body: impl Into<Bytes>) -> Self {
self.body = Some(RequestBody::Raw(body.into()));
self
}
pub fn with_compression(mut self, compression: RequestCompression) -> Self {
self.compression = compression;
self
}
/// Prepares the body once and stores the exact bytes that will be sent.
///
/// Cloning the returned request shares the body bytes, so retry attempts do
/// not repeat JSON serialization or compression. Request-signing auth also
/// sees the same final headers and bytes that the transport will send.
pub fn into_prepared(mut self) -> Result<Self, String> {
let is_json = matches!(
self.body,
Some(RequestBody::Json(_) | RequestBody::EncodedJson(_))
);
let trace_bytes = if self.compression != RequestCompression::None
&& tracing::enabled!(target: "codex_client::transport", tracing::Level::TRACE)
{
match self.body.as_ref() {
Some(RequestBody::Json(body)) => Some(Bytes::from(
serde_json::to_vec(body).map_err(|err| err.to_string())?,
)),
Some(RequestBody::EncodedJson(body)) => Some(body.bytes.clone()),
Some(RequestBody::Raw(_)) | None => None,
}
} else {
None
};
let prepared = self.prepare_body_for_send()?;
self.headers = prepared.headers;
self.body = match (is_json, prepared.body) {
(true, Some(bytes)) => Some(RequestBody::EncodedJson(EncodedJsonBody {
bytes,
trace_bytes,
prepared: true,
})),
(false, Some(body)) => Some(RequestBody::Raw(body)),
(_, None) => None,
};
self.compression = RequestCompression::None;
Ok(self)
}
/// Convert the request body into the exact bytes that will be sent.
///
/// Auth schemes such as AWS SigV4 need to sign the final body bytes, including
/// compression and content headers. Calling this method does not mutate the
/// request.
pub fn prepare_body_for_send(&self) -> Result<PreparedRequestBody, String> {
let headers = self.headers.clone();
match self.body.as_ref() {
Some(RequestBody::Raw(raw_body)) => {
if self.compression != RequestCompression::None {
return Err("request compression cannot be used with raw bodies".to_string());
}
Ok(PreparedRequestBody {
headers,
body: Some(raw_body.clone()),
})
}
Some(RequestBody::Json(body)) => {
let body = EncodedJsonBody::encode(body).map_err(|err| err.to_string())?;
self.prepare_encoded_json(headers, &body)
}
Some(RequestBody::EncodedJson(body)) => self.prepare_encoded_json(headers, body),
None => Ok(PreparedRequestBody {
headers,
body: None,
}),
}
}
fn prepare_encoded_json(
&self,
mut headers: HeaderMap,
body: &EncodedJsonBody,
) -> Result<PreparedRequestBody, String> {
if body.prepared {
return Ok(PreparedRequestBody {
headers,
body: Some(body.bytes.clone()),
});
}
let bytes = if self.compression != RequestCompression::None {
if headers.contains_key(http::header::CONTENT_ENCODING) {
return Err(
"request compression was requested but content-encoding is already set"
.to_string(),
);
}
let pre_compression_bytes = body.bytes.len();
let compression_start = std::time::Instant::now();
let (compressed, content_encoding) = match self.compression {
RequestCompression::None => unreachable!("guarded by compression != None"),
RequestCompression::Zstd => (
zstd::stream::encode_all(std::io::Cursor::new(body.as_bytes()), 3)
.map_err(|err| err.to_string())?,
HeaderValue::from_static("zstd"),
),
};
let post_compression_bytes = compressed.len();
let compression_duration = compression_start.elapsed();
headers.insert(http::header::CONTENT_ENCODING, content_encoding);
tracing::debug!(
pre_compression_bytes,
post_compression_bytes,
compression_duration_ms = compression_duration.as_millis(),
"Compressed request body with zstd"
);
Bytes::from(compressed)
} else {
body.bytes.clone()
};
if !headers.contains_key(http::header::CONTENT_TYPE) {
headers.insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
}
Ok(PreparedRequestBody {
headers,
body: Some(bytes),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use http::HeaderValue;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn prepare_body_for_send_serializes_json_and_sets_content_type() {
let request = Request::new(Method::POST, "https://example.com/v1/responses".to_string())
.with_json(&json!({"model": "test-model"}));
let prepared = request
.prepare_body_for_send()
.expect("body should prepare");
assert_eq!(
prepared.body,
Some(Bytes::from_static(br#"{"model":"test-model"}"#))
);
assert_eq!(
prepared
.headers
.get(http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("application/json")
);
assert_eq!(
request.body,
Some(RequestBody::Json(json!({"model": "test-model"})))
);
assert_eq!(request.compression, RequestCompression::None);
}
#[test]
fn prepare_body_for_send_rejects_existing_content_encoding_when_compressing() {
let mut request =
Request::new(Method::POST, "https://example.com/v1/responses".to_string())
.with_json(&json!({"model": "test-model"}))
.with_compression(RequestCompression::Zstd);
request.headers.insert(
http::header::CONTENT_ENCODING,
HeaderValue::from_static("gzip"),
);
let err = request
.prepare_body_for_send()
.expect_err("conflicting content-encoding should fail");
assert_eq!(
err,
"request compression was requested but content-encoding is already set"
);
}
#[test]
fn into_prepared_stores_compressed_body_for_reuse() {
let body =
EncodedJsonBody::encode(&json!({"model": "test-model"})).expect("JSON should encode");
let mut request =
Request::new(Method::POST, "https://example.com/v1/responses".to_string())
.with_compression(RequestCompression::Zstd);
request.body = Some(RequestBody::EncodedJson(body));
let request = request.into_prepared().expect("body should prepare");
let Some(RequestBody::EncodedJson(body)) = request.body.as_ref() else {
panic!("expected an encoded JSON body");
};
let decompressed = zstd::stream::decode_all(std::io::Cursor::new(body.as_bytes()))
.expect("body should decompress");
assert_eq!(decompressed, br#"{"model":"test-model"}"#);
assert_eq!(request.compression, RequestCompression::None);
assert_eq!(
request.headers.get(http::header::CONTENT_ENCODING),
Some(&HeaderValue::from_static("zstd"))
);
assert_eq!(
request.headers.get(http::header::CONTENT_TYPE),
Some(&HeaderValue::from_static("application/json"))
);
}
}
#[derive(Debug, Clone)]
pub struct Response {
pub status: http::StatusCode,
pub headers: HeaderMap,
pub body: Bytes,
}
+2 -2
View File
@@ -1,5 +1,5 @@
use crate::error::TransportError;
use crate::request::Request;
use codex_http_client::Request;
use codex_http_client::TransportError;
use rand::Rng;
use std::future::Future;
use std::time::Duration;
+2 -2
View File
@@ -1,5 +1,5 @@
use crate::error::StreamError;
use crate::transport::ByteStream;
use codex_http_client::ByteStream;
use codex_http_client::StreamError;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use tokio::sync::mpsc;
+1 -1
View File
@@ -1,4 +1,4 @@
use crate::error::TransportError;
use codex_http_client::TransportError;
use http::StatusCode;
use std::time::Duration;
-162
View File
@@ -1,162 +0,0 @@
use crate::default_client::CodexHttpClient;
use crate::default_client::CodexRequestBuilder;
use crate::error::TransportError;
use crate::request::Request;
use crate::request::RequestBody;
use crate::request::Response;
use bytes::Bytes;
use futures::StreamExt;
use futures::stream::BoxStream;
use http::HeaderMap;
use http::Method;
use http::StatusCode;
use tracing::Level;
use tracing::enabled;
use tracing::trace;
pub type ByteStream = BoxStream<'static, Result<Bytes, TransportError>>;
pub struct StreamResponse {
pub status: StatusCode,
pub headers: HeaderMap,
pub bytes: ByteStream,
}
pub trait HttpTransport: Send + Sync {
fn execute(
&self,
req: Request,
) -> impl std::future::Future<Output = Result<Response, TransportError>> + Send;
fn stream(
&self,
req: Request,
) -> impl std::future::Future<Output = Result<StreamResponse, TransportError>> + Send;
}
#[derive(Clone, Debug)]
pub struct ReqwestTransport {
client: CodexHttpClient,
}
impl ReqwestTransport {
pub fn new(client: reqwest::Client) -> Self {
Self {
client: CodexHttpClient::new(client),
}
}
fn build(&self, req: Request) -> Result<CodexRequestBuilder, TransportError> {
let prepared = req.prepare_body_for_send().map_err(TransportError::Build)?;
let Request {
method,
url,
headers: _,
body: _,
compression: _,
timeout,
} = req;
let mut builder = self.client.request(
Method::from_bytes(method.as_str().as_bytes()).unwrap_or(Method::GET),
&url,
);
if let Some(timeout) = timeout {
builder = builder.timeout(timeout);
}
builder = builder.headers(prepared.headers);
if let Some(body) = prepared.body {
builder = builder.body(body);
}
Ok(builder)
}
fn map_error(err: reqwest::Error) -> TransportError {
if err.is_timeout() {
TransportError::Timeout
} else {
TransportError::Network(err.to_string())
}
}
}
fn request_body_for_trace(req: &Request) -> String {
match req.body.as_ref() {
Some(RequestBody::Json(body)) => body.to_string(),
Some(RequestBody::EncodedJson(body)) => {
String::from_utf8_lossy(body.trace_bytes()).into_owned()
}
Some(RequestBody::Raw(body)) => format!("<raw body: {} bytes>", body.len()),
None => String::new(),
}
}
impl HttpTransport for ReqwestTransport {
async fn execute(&self, req: Request) -> Result<Response, TransportError> {
if enabled!(Level::TRACE) {
trace!(
"{} to {}: {}",
req.method,
req.url,
request_body_for_trace(&req)
);
}
let url = req.url.clone();
let builder = self.build(req)?;
let resp = builder.send().await.map_err(Self::map_error)?;
let status = resp.status();
let headers = resp.headers().clone();
let bytes = resp.bytes().await.map_err(Self::map_error)?;
if !status.is_success() {
let body = String::from_utf8(bytes.to_vec()).ok();
return Err(TransportError::Http {
status,
url: Some(url),
headers: Some(headers),
body,
});
}
Ok(Response {
status,
headers,
body: bytes,
})
}
async fn stream(&self, req: Request) -> Result<StreamResponse, TransportError> {
if enabled!(Level::TRACE) {
trace!(
"{} to {}: {}",
req.method,
req.url,
request_body_for_trace(&req)
);
}
let url = req.url.clone();
let builder = self.build(req)?;
let resp = builder.send().await.map_err(Self::map_error)?;
let status = resp.status();
let headers = resp.headers().clone();
if !status.is_success() {
let body = resp.text().await.ok();
return Err(TransportError::Http {
status,
url: Some(url),
headers: Some(headers),
body,
});
}
let stream = resp
.bytes_stream()
.map(|result| result.map_err(Self::map_error));
Ok(StreamResponse {
status,
headers,
bytes: Box::pin(stream),
})
}
}
-540
View File
@@ -1,540 +0,0 @@
#![allow(clippy::expect_used)]
//! Subprocess coverage for custom CA behavior that must build a real reqwest client.
//!
//! These tests intentionally run through `custom_ca_probe` and
//! `build_reqwest_client_for_subprocess_tests` instead of calling the helper in-process. The
//! detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`; these
//! tests add the process-level half of that contract by scrubbing inherited CA environment
//! variables before each subprocess launch. Most assertions here cover CA file selection, PEM
//! parsing, and user-facing errors. The HTTPS probes go further and perform real POSTs against
//! locally generated certificates, including through a TLS-intercepting CONNECT proxy.
use codex_utils_cargo_bin::cargo_bin;
use rcgen::BasicConstraints;
use rcgen::CertificateParams;
use rcgen::CertifiedIssuer;
use rcgen::DistinguishedName;
use rcgen::DnType;
use rcgen::ExtendedKeyUsagePurpose;
use rcgen::IsCa;
use rcgen::KeyPair;
use rcgen::KeyUsagePurpose;
use rcgen::PKCS_ECDSA_P256_SHA256;
use rustls_pki_types::CertificateDer;
use rustls_pki_types::PrivateKeyDer;
use std::fs;
use std::io;
use std::io::Read;
use std::io::Write;
use std::net::TcpListener;
use std::net::TcpStream;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use tempfile::TempDir;
const CODEX_CA_CERT_ENV: &str = "CODEX_CA_CERTIFICATE";
const PROBE_PROXY_ENV: &str = "CODEX_CUSTOM_CA_PROBE_PROXY";
const PROBE_TLS13_ENV: &str = "CODEX_CUSTOM_CA_PROBE_TLS13";
const PROBE_URL_ENV: &str = "CODEX_CUSTOM_CA_PROBE_URL";
const SSL_CERT_FILE_ENV: &str = "SSL_CERT_FILE";
const PROXY_ENV_VARS: &[&str] = &[
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
"NO_PROXY",
"no_proxy",
];
const TEST_CERT_1: &str = include_str!("fixtures/test-ca.pem");
const TEST_CERT_2: &str = include_str!("fixtures/test-intermediate.pem");
const TRUSTED_TEST_CERT: &str = include_str!("fixtures/test-ca-trusted.pem");
struct Tls13Material {
ca_cert_pem: String,
server_cert: CertificateDer<'static>,
server_key: PrivateKeyDer<'static>,
}
struct Tls13TestServer {
ca_cert_pem: String,
request_rx: mpsc::Receiver<Result<String, String>>,
url: String,
}
struct PlainHttpOrigin {
request_rx: mpsc::Receiver<Result<String, String>>,
url: String,
}
struct TlsInterceptingProxy {
ca_cert_pem: String,
request_rx: mpsc::Receiver<Result<String, String>>,
url: String,
}
fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> PathBuf {
let path = temp_dir.path().join(name);
fs::write(&path, contents).expect("certificate fixture should be writable");
path
}
fn probe_command() -> Command {
let mut cmd = Command::new(
cargo_bin("custom_ca_probe").expect("custom_ca_probe binary should be available"),
);
// `Command` inherits the parent environment by default, so scrub CA-related variables first or
// these tests can accidentally pass/fail based on the developer shell or CI runner.
cmd.env_remove(CODEX_CA_CERT_ENV);
cmd.env_remove(PROBE_PROXY_ENV);
cmd.env_remove(PROBE_TLS13_ENV);
cmd.env_remove(PROBE_URL_ENV);
cmd.env_remove(SSL_CERT_FILE_ENV);
for env_var in PROXY_ENV_VARS {
cmd.env_remove(env_var);
}
cmd
}
fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output {
let mut cmd = probe_command();
for (key, value) in envs {
cmd.env(key, value);
}
cmd.output().expect("custom_ca_probe should run")
}
fn run_probe_posting_to_tls13_server(envs: &[(&str, &Path)], url: &str) -> std::process::Output {
let mut cmd = probe_command();
for (key, value) in envs {
cmd.env(key, value);
}
cmd.env(PROBE_TLS13_ENV, "1");
cmd.env(PROBE_URL_ENV, url);
cmd.output().expect("custom_ca_probe should run")
}
fn run_probe_posting_through_tls_intercepting_proxy(
envs: &[(&str, &Path)],
url: &str,
proxy_url: &str,
) -> std::process::Output {
let mut cmd = probe_command();
for (key, value) in envs {
cmd.env(key, value);
}
cmd.env(PROBE_PROXY_ENV, proxy_url);
cmd.env(PROBE_TLS13_ENV, "1");
cmd.env(PROBE_URL_ENV, url);
cmd.output().expect("custom_ca_probe should run")
}
fn spawn_tls13_test_server() -> Tls13TestServer {
codex_utils_rustls_provider::ensure_rustls_crypto_provider();
let material = generate_tls13_material();
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("TLS test server should bind");
listener
.set_nonblocking(true)
.expect("TLS test server should become nonblocking");
let port = listener
.local_addr()
.expect("TLS test server should have a local address")
.port();
let config = Arc::new(
rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
.with_no_client_auth()
.with_single_cert(vec![material.server_cert], material.server_key)
.expect("TLS 1.3 server config should be valid"),
);
let (request_tx, request_rx) = mpsc::channel();
thread::spawn(move || {
let result = accept_tls13_request(listener, config);
let _ = request_tx.send(result.map_err(|error| error.to_string()));
});
Tls13TestServer {
ca_cert_pem: material.ca_cert_pem,
request_rx,
url: format!("https://127.0.0.1:{port}/oauth/token"),
}
}
fn spawn_plain_http_origin() -> PlainHttpOrigin {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("plain HTTP origin should bind");
listener
.set_nonblocking(true)
.expect("plain HTTP origin should become nonblocking");
let port = listener
.local_addr()
.expect("plain HTTP origin should have a local address")
.port();
let (request_tx, request_rx) = mpsc::channel();
thread::spawn(move || {
let result = accept_plain_http_origin_request(listener);
let _ = request_tx.send(result.map_err(|error| error.to_string()));
});
PlainHttpOrigin {
request_rx,
url: format!("https://127.0.0.1:{port}/oauth/token"),
}
}
fn spawn_tls_intercepting_proxy() -> TlsInterceptingProxy {
codex_utils_rustls_provider::ensure_rustls_crypto_provider();
let material = generate_tls13_material();
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("TLS intercepting proxy should bind");
listener
.set_nonblocking(true)
.expect("TLS intercepting proxy should become nonblocking");
let port = listener
.local_addr()
.expect("TLS intercepting proxy should have a local address")
.port();
let config = Arc::new(
rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
.with_no_client_auth()
.with_single_cert(vec![material.server_cert], material.server_key)
.expect("TLS intercepting proxy config should be valid"),
);
let (request_tx, request_rx) = mpsc::channel();
thread::spawn(move || {
let result = accept_tls_intercepting_proxy_request(listener, config);
let _ = request_tx.send(result.map_err(|error| error.to_string()));
});
TlsInterceptingProxy {
ca_cert_pem: material.ca_cert_pem,
request_rx,
url: format!("http://127.0.0.1:{port}"),
}
}
fn generate_tls13_material() -> Tls13Material {
let mut ca_params = CertificateParams::default();
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let mut ca_distinguished_name = DistinguishedName::new();
ca_distinguished_name.push(DnType::CommonName, "codex test CA");
ca_params.distinguished_name = ca_distinguished_name;
let ca_key_pair =
KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("test CA key pair should generate");
let ca = CertifiedIssuer::self_signed(ca_params, ca_key_pair)
.expect("test CA certificate should generate");
let mut server_params =
CertificateParams::new(vec!["localhost".to_string(), "127.0.0.1".to_string()])
.expect("test server certificate params should be valid");
server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
server_params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
let server_key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)
.expect("test server key pair should generate");
let server_cert = server_params
.signed_by(&server_key_pair, &ca)
.expect("test server certificate should generate");
Tls13Material {
ca_cert_pem: ca.pem(),
server_cert: server_cert.der().clone(),
server_key: PrivateKeyDer::from(server_key_pair),
}
}
fn accept_plain_http_origin_request(listener: TcpListener) -> io::Result<String> {
let mut stream = accept_with_timeout(listener, Duration::from_secs(5))?;
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let request = read_http_message(&mut stream)?;
stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?;
stream.flush()?;
Ok(request)
}
fn accept_tls13_request(
listener: TcpListener,
config: Arc<rustls::ServerConfig>,
) -> io::Result<String> {
let stream = accept_with_timeout(listener, Duration::from_secs(5))?;
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let connection = rustls::ServerConnection::new(config).map_err(io::Error::other)?;
let mut tls = rustls::StreamOwned::new(connection, stream);
let request = read_http_message(&mut tls)?;
tls.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?;
tls.flush()?;
Ok(request)
}
fn accept_tls_intercepting_proxy_request(
listener: TcpListener,
config: Arc<rustls::ServerConfig>,
) -> io::Result<String> {
let mut stream = accept_with_timeout(listener, Duration::from_secs(5))?;
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let connect_request = read_http_message(&mut stream)?;
let origin_authority = connect_authority_from_request(&connect_request)?;
stream.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?;
stream.flush()?;
let connection = rustls::ServerConnection::new(config).map_err(io::Error::other)?;
let mut tls = rustls::StreamOwned::new(connection, stream);
let request = read_http_message(&mut tls)?;
let mut origin = TcpStream::connect(origin_authority.as_str())?;
origin.set_read_timeout(Some(Duration::from_secs(5)))?;
origin.set_write_timeout(Some(Duration::from_secs(5)))?;
origin.write_all(request.as_bytes())?;
origin.flush()?;
let response = read_http_message(&mut origin)?;
tls.write_all(response.as_bytes())?;
tls.flush()?;
Ok(request)
}
fn connect_authority_from_request(request: &str) -> io::Result<String> {
let request_line = request
.lines()
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty CONNECT request"))?;
let mut parts = request_line.split_whitespace();
match (parts.next(), parts.next(), parts.next()) {
(Some("CONNECT"), Some(authority), Some(_version)) => Ok(authority.to_string()),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid CONNECT request line: {request_line}"),
)),
}
}
fn accept_with_timeout(listener: TcpListener, timeout: Duration) -> io::Result<TcpStream> {
let deadline = Instant::now() + timeout;
loop {
match listener.accept() {
Ok((stream, _)) => return Ok(stream),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"timed out waiting for TLS test client",
));
}
thread::sleep(Duration::from_millis(10));
}
Err(error) => return Err(error),
}
}
}
fn read_http_message(stream: &mut impl Read) -> io::Result<String> {
let mut buffer = Vec::new();
let mut chunk = [0; 1024];
loop {
let bytes_read = stream.read(&mut chunk)?;
if bytes_read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..bytes_read]);
if let Some(header_end) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
let body_start = header_end + 4;
let headers = String::from_utf8_lossy(&buffer[..body_start]);
let content_length = headers
.lines()
.filter_map(|line| line.split_once(':'))
.find_map(|(name, value)| {
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
if buffer.len() >= body_start + content_length {
break;
}
}
}
Ok(String::from_utf8_lossy(&buffer).into_owned())
}
fn assert_token_exchange_request(request: &str) {
assert!(
request.starts_with("POST /oauth/token HTTP/1.1"),
"unexpected request:\n{request}"
);
assert!(
request.contains("grant_type=authorization_code&code=test"),
"unexpected request body:\n{request}"
);
}
#[test]
fn uses_codex_ca_cert_env() {
let temp_dir = TempDir::new().expect("tempdir");
let cert_path = write_cert_file(&temp_dir, "ca.pem", TEST_CERT_1);
let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]);
assert!(output.status.success());
}
#[test]
fn falls_back_to_ssl_cert_file() {
let temp_dir = TempDir::new().expect("tempdir");
let cert_path = write_cert_file(&temp_dir, "ssl.pem", TEST_CERT_1);
let output = run_probe(&[(SSL_CERT_FILE_ENV, cert_path.as_path())]);
assert!(output.status.success());
}
#[test]
fn prefers_codex_ca_cert_over_ssl_cert_file() {
let temp_dir = TempDir::new().expect("tempdir");
let cert_path = write_cert_file(&temp_dir, "ca.pem", TEST_CERT_1);
let bad_path = write_cert_file(&temp_dir, "bad.pem", "");
let output = run_probe(&[
(CODEX_CA_CERT_ENV, cert_path.as_path()),
(SSL_CERT_FILE_ENV, bad_path.as_path()),
]);
assert!(output.status.success());
}
#[test]
fn handles_multi_certificate_bundle() {
let temp_dir = TempDir::new().expect("tempdir");
let bundle = format!("{TEST_CERT_1}\n{TEST_CERT_2}");
let cert_path = write_cert_file(&temp_dir, "bundle.pem", &bundle);
let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]);
assert!(output.status.success());
}
#[test]
fn posts_to_tls13_server_using_custom_ca_bundle() {
let temp_dir = TempDir::new().expect("tempdir");
let server = spawn_tls13_test_server();
let cert_path = write_cert_file(&temp_dir, "tls-ca.pem", &server.ca_cert_pem);
let output =
run_probe_posting_to_tls13_server(&[(CODEX_CA_CERT_ENV, cert_path.as_path())], &server.url);
let server_result = server.request_rx.recv_timeout(Duration::from_secs(5));
assert!(
output.status.success(),
"custom_ca_probe failed\nstdout:\n{}\nstderr:\n{}\nserver:\n{server_result:?}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let request = server_result
.expect("TLS test server should report a request")
.expect("TLS test server should accept the probe request");
assert_token_exchange_request(&request);
}
#[test]
fn posts_to_token_origin_through_tls_intercepting_proxy_with_custom_ca_bundle() {
let temp_dir = TempDir::new().expect("tempdir");
let origin = spawn_plain_http_origin();
let proxy = spawn_tls_intercepting_proxy();
let cert_path = write_cert_file(&temp_dir, "proxy-ca.pem", &proxy.ca_cert_pem);
let output = run_probe_posting_through_tls_intercepting_proxy(
&[(CODEX_CA_CERT_ENV, cert_path.as_path())],
&origin.url,
&proxy.url,
);
let proxy_result = proxy.request_rx.recv_timeout(Duration::from_secs(5));
let origin_result = origin.request_rx.recv_timeout(Duration::from_secs(5));
assert!(
output.status.success(),
"custom_ca_probe failed\nstdout:\n{}\nstderr:\n{}\nproxy:\n{proxy_result:?}\norigin:\n{origin_result:?}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let proxy_request = proxy_result
.expect("TLS intercepting proxy should report a request")
.expect("TLS intercepting proxy should accept the probe request");
let origin_request = origin_result
.expect("plain HTTP origin should report a request")
.expect("plain HTTP origin should accept the forwarded request");
assert_token_exchange_request(&proxy_request);
assert_token_exchange_request(&origin_request);
}
#[test]
fn rejects_empty_pem_file_with_hint() {
let temp_dir = TempDir::new().expect("tempdir");
let cert_path = write_cert_file(&temp_dir, "empty.pem", "");
let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("no certificates found in PEM file"));
assert!(stderr.contains("CODEX_CA_CERTIFICATE"));
assert!(stderr.contains("SSL_CERT_FILE"));
}
#[test]
fn rejects_malformed_pem_with_hint() {
let temp_dir = TempDir::new().expect("tempdir");
let cert_path = write_cert_file(
&temp_dir,
"malformed.pem",
"-----BEGIN CERTIFICATE-----\nMIIBroken",
);
let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("failed to parse PEM file"));
assert!(stderr.contains("CODEX_CA_CERTIFICATE"));
assert!(stderr.contains("SSL_CERT_FILE"));
}
#[test]
fn accepts_openssl_trusted_certificate() {
let temp_dir = TempDir::new().expect("tempdir");
let cert_path = write_cert_file(&temp_dir, "trusted.pem", TRUSTED_TEST_CERT);
let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]);
assert!(output.status.success());
}
#[test]
fn accepts_bundle_with_crl() {
let temp_dir = TempDir::new().expect("tempdir");
let crl = "-----BEGIN X509 CRL-----\nMIIC\n-----END X509 CRL-----";
let bundle = format!("{TEST_CERT_1}\n{crl}");
let cert_path = write_cert_file(&temp_dir, "bundle_crl.pem", &bundle);
let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]);
assert!(output.status.success());
}
@@ -1,25 +0,0 @@
# Test-only OpenSSL trusted-certificate fixture generated from test-ca.pem with
# `openssl x509 -addtrust serverAuth -trustout`.
# The extra trailing bytes model the OpenSSL X509_AUX data that follows the
# certificate DER in real TRUSTED CERTIFICATE bundles.
# This fixture exists to validate the X509_AUX trimming path against a real
# OpenSSL-generated artifact, not just label normalization.
-----BEGIN TRUSTED CERTIFICATE-----
MIIDBTCCAe2gAwIBAgIUZYhGvBUG7SucNzYh9VIeZ7b9zHowDQYJKoZIhvcNAQEL
BQAwEjEQMA4GA1UEAwwHdGVzdC1jYTAeFw0yNTEyMTEyMzEyNTFaFw0zNTEyMDky
MzEyNTFaMBIxEDAOBgNVBAMMB3Rlc3QtY2EwggEiMA0GCSqGSIb3DQEBAQUAA4IB
DwAwggEKAoIBAQC+NJRZAdn15FFBN8eR1HTAe+LMVpO19kKtiCsQjyqHONfhfHcF
7zQfwmH6MqeNpC/5k5m8V1uSIhyHBskQm83Jv8/vHlffNxE/hl0Na/Yd1bc+2kxH
twIAsF32GKnSKnFva/iGczV81+/ETgG6RXfTfy/Xs6fXL8On8SRRmTcMw0bEfwko
ziid87VOHg2JfdRKN5QpS9lvQ8q4q2M3jMftolpUTpwlR0u8j9OXnZfn+ja33X0l
kjkoCbXE2fVbAzO/jhUHQX1H5RbTGGUnrrCWAj84Rq/E80KK1nrRF91K+vgZmilM
gOZosLMMI1PeqTakwg1yIRngpTyk0eJP+haxAgMBAAGjUzBRMB0GA1UdDgQWBBT6
sqvfjMIl0DFZkeu8LU577YqMVDAfBgNVHSMEGDAWgBT6sqvfjMIl0DFZkeu8LU57
7YqMVDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBQ1sYs2RvB
TZ+xSBglLwH/S7zXVJIDwQ23Rlj11dgnVvcilSJCX24Rr+pfIVLpYNDdZzc/DIJd
S1dt2JuLnvXnle29rU7cxuzYUkUkRtaeY2Sj210vsE3lqUFyIy8XCc/lteb+FiJ7
zo/gPk7P+y4ihK9Mm6SBqkDVEYSFSn9bgoemK+0e93jGe2182PyuTwfTmZgENSBO
2f9dSuay4C7e5UO8bhVccQJg6f4d70zUNG0oPHrnVxJLjwCd++jx25Gh4U7+ek13
CW57pxJrpPMDWb2YK64rT2juHMKF73YuplW92SInd+QLpI2ekTLc+bRw8JvqzXg+
SprtRUBjlWzjMAwwCgYIKwYBBQUHAwE=
-----END TRUSTED CERTIFICATE-----
-21
View File
@@ -1,21 +0,0 @@
# Test-only self-signed CA fixture used for single-certificate loading.
# These tests only verify PEM parsing and root-certificate registration, not a TLS handshake.
-----BEGIN CERTIFICATE-----
MIIDBTCCAe2gAwIBAgIUZYhGvBUG7SucNzYh9VIeZ7b9zHowDQYJKoZIhvcNAQEL
BQAwEjEQMA4GA1UEAwwHdGVzdC1jYTAeFw0yNTEyMTEyMzEyNTFaFw0zNTEyMDky
MzEyNTFaMBIxEDAOBgNVBAMMB3Rlc3QtY2EwggEiMA0GCSqGSIb3DQEBAQUAA4IB
DwAwggEKAoIBAQC+NJRZAdn15FFBN8eR1HTAe+LMVpO19kKtiCsQjyqHONfhfHcF
7zQfwmH6MqeNpC/5k5m8V1uSIhyHBskQm83Jv8/vHlffNxE/hl0Na/Yd1bc+2kxH
twIAsF32GKnSKnFva/iGczV81+/ETgG6RXfTfy/Xs6fXL8On8SRRmTcMw0bEfwko
ziid87VOHg2JfdRKN5QpS9lvQ8q4q2M3jMftolpUTpwlR0u8j9OXnZfn+ja33X0l
kjkoCbXE2fVbAzO/jhUHQX1H5RbTGGUnrrCWAj84Rq/E80KK1nrRF91K+vgZmilM
gOZosLMMI1PeqTakwg1yIRngpTyk0eJP+haxAgMBAAGjUzBRMB0GA1UdDgQWBBT6
sqvfjMIl0DFZkeu8LU577YqMVDAfBgNVHSMEGDAWgBT6sqvfjMIl0DFZkeu8LU57
7YqMVDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBQ1sYs2RvB
TZ+xSBglLwH/S7zXVJIDwQ23Rlj11dgnVvcilSJCX24Rr+pfIVLpYNDdZzc/DIJd
S1dt2JuLnvXnle29rU7cxuzYUkUkRtaeY2Sj210vsE3lqUFyIy8XCc/lteb+FiJ7
zo/gPk7P+y4ihK9Mm6SBqkDVEYSFSn9bgoemK+0e93jGe2182PyuTwfTmZgENSBO
2f9dSuay4C7e5UO8bhVccQJg6f4d70zUNG0oPHrnVxJLjwCd++jx25Gh4U7+ek13
CW57pxJrpPMDWb2YK64rT2juHMKF73YuplW92SInd+QLpI2ekTLc+bRw8JvqzXg+
SprtRUBjlWzj
-----END CERTIFICATE-----
@@ -1,21 +0,0 @@
# Second valid test-only certificate used for multi-certificate bundle coverage.
# It is intentionally distinct from test-ca.pem; chain validation is not part of these tests.
-----BEGIN CERTIFICATE-----
MIIDGTCCAgGgAwIBAgIUWxlcvHzwITWAHWHbKMFUTgeDmjwwDQYJKoZIhvcNAQEL
BQAwHDEaMBgGA1UEAwwRdGVzdC1pbnRlcm1lZGlhdGUwHhcNMjUxMTE5MTU1MDIz
WhcNMjYxMTE5MTU1MDIzWjAcMRowGAYDVQQDDBF0ZXN0LWludGVybWVkaWF0ZTCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANq7xbeYpC2GaXANqD1nLk0t
j9j2sOk6e7DqTapxnIUijS7z4DF0Vo1xHM07wK1m+wsB/t9CubNYRvtn6hrIzx7K
jjlmvxo4/YluwO1EDMQWZAXkaY2O28ESKVx7QLfBPYAc4bf/5B4Nmt6KX5sQyyyH
2qTfzVBUCAl3sI+Ydd3mx7NOye1yNNkCNqyK3Hj45F1JuH8NZxcb4OlKssZhMlD+
EQx4G46AzKE9Ho8AqlQvg/tiWrMHRluw7zolMJ/AXzedAXedNIrX4fCOmZwcTkA1
a8eLPP8oM9VFrr67a7on6p4zPqugUEQ4fawp7A5KqSjUAVCt1FXmn2V8N8V6W/sC
AwEAAaNTMFEwHQYDVR0OBBYEFBEwRwW0gm3IjhLw1U3eOAvR0r6SMB8GA1UdIwQY
MBaAFBEwRwW0gm3IjhLw1U3eOAvR0r6SMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
hvcNAQELBQADggEBAB2fjAlpevK42Odv8XUEgV6VWlEP9HAmkRvugW9hjhzx1Iz9
Vh/l9VcxL7PcqdpyGH+BIRvQIMokcYF5TXzf/KV1T2y56U8AWaSd2/xSjYNWwkgE
TLE5V+H/YDKzvTe58UrOaxa5N3URscQL9f+ZKworODmfMlkJ1mlREK130ZMlBexB
p9w5wo1M1fjx76Rqzq9MkpwBSbIO2zx/8+qy4BAH23MPGW+9OOnnq2DiIX3qUu1v
hnjYOxYpCB28MZEJmqsjFJQQ9RF+Te4U2/oknVcf8lZIMJ2ZBOwt2zg8RqCtM52/
IbATwYj77wg3CFLFKcDYs3tdUqpiniabKcf6zAs=
-----END CERTIFICATE-----