mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
http-client: extract shared HTTP transport
This commit is contained in:
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
use crate::error::TransportError;
|
||||
use codex_http_client::TransportError;
|
||||
use http::StatusCode;
|
||||
use std::time::Duration;
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
Reference in New Issue
Block a user