refactor(client): owned RetryConfig + TLS/mTLS test coverage (HY-06, COV-02)
- HttpClientConfig.retry_policy: ExponentialBackoff (semver anchor to a reqwest-retry concrete type) replaced by retry: RetryConfig — an owned struct of plain scalars (max_retries, initial_backoff, max_retry_interval, defaults matching the previous backoff exactly); the ExponentialBackoff policy is built internally by the middleware stack; no reqwest_retry type is public anymore - ClientCertConfig fields documented (none had docs) - new tests/client_tls.rs: per-test rcgen private PKI + tokio-rustls HTTPS server; drives the real SharedHttpClient through HttpClientConfig file paths — CA-bundle success path, private-roots rejection (source-chain assertion: invalid peer certificate), mTLS end-to-end with client identity, mTLS rejection without identity, and reload-to-CA-bundle interplay - dev-deps: rcgen 0.14, tokio-rustls 0.26, rustls 0.23 (aws_lc_rs), rustls-pki-types 1, uuid Verified: cargo test (288 + 5 TLS), --all-features (359 + suites), --no-default-features (288; pre-existing warnings only), clippy --all-targets -D warnings (default + all-features), fmt --check, cargo doc --no-deps. Tasks: review-001-client-config-and-cert-coverage
This commit is contained in:
Generated
+211
-4
@@ -64,14 +64,18 @@ dependencies = [
|
||||
"openapiv3",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"reqwest-middleware",
|
||||
"reqwest-retry",
|
||||
"rmcp",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tracing",
|
||||
@@ -110,6 +114,45 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
@@ -163,7 +206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
@@ -217,13 +260,19 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -232,6 +281,15 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
@@ -387,6 +445,26 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -763,7 +841,7 @@ version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
@@ -1107,6 +1185,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
@@ -1118,6 +1202,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
@@ -1157,6 +1251,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
@@ -1196,6 +1296,15 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -1254,6 +1363,16 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -1287,6 +1406,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -1438,6 +1563,20 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
|
||||
dependencies = [
|
||||
"pem",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser",
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -1519,7 +1658,7 @@ version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
@@ -1656,6 +1795,15 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
@@ -1663,6 +1811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
@@ -2041,6 +2190,36 @@ dependencies = [
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
@@ -2655,6 +2834,24 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"lazy_static",
|
||||
"nom",
|
||||
"oid-registry",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml_serde"
|
||||
version = "0.10.7"
|
||||
@@ -2668,6 +2865,16 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
@@ -59,6 +59,11 @@ http-body-util = "0.1"
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect"] }
|
||||
openapiv3 = "2"
|
||||
rcgen = "0.14"
|
||||
tokio-rustls = "0.26"
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] }
|
||||
rustls-pki-types = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[[test]]
|
||||
name = "ws_upgrade_session"
|
||||
|
||||
+50
-481
@@ -84,18 +84,55 @@ const DEFAULT_MAX_TOTAL_RETRY_DURATION: Duration = Duration::from_secs(10);
|
||||
/// upstream (seconds and HTTP-date forms alike).
|
||||
const DEFAULT_RETRY_AFTER_CEILING: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Lower bound of the retry backoff interval.
|
||||
/// Default retry count: attempts beyond the first failure of an
|
||||
/// idempotent request.
|
||||
const DEFAULT_MAX_RETRIES: u32 = 3;
|
||||
|
||||
/// Default lower bound of the retry backoff interval.
|
||||
const RETRY_BACKOFF_MIN_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Upper bound of the retry backoff interval.
|
||||
/// Default upper bound of the retry backoff interval.
|
||||
const RETRY_BACKOFF_MAX_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// A mutual-TLS identity presented to upstreams: paths to the
|
||||
/// PEM-encoded client certificate and its private key. Both files are
|
||||
/// read at client-construction time (see `HttpClientBuildError` for
|
||||
/// the failure shapes) and combined into a single reqwest
|
||||
/// `Identity`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientCertConfig {
|
||||
/// Path to the PEM-encoded client certificate chain.
|
||||
pub cert_pem: PathBuf,
|
||||
/// Path to the PEM-encoded (PKCS#8) private key for `cert_pem`.
|
||||
pub key_pem: PathBuf,
|
||||
}
|
||||
|
||||
/// Retry backoff shape for the shared outbound client. The public
|
||||
/// surface is plain scalars (HY-06) — the concrete
|
||||
/// `reqwest_retry::ExponentialBackoff` policy is built internally from
|
||||
/// these at client-construction time, keeping the upstream concrete
|
||||
/// type out of this crate's API.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryConfig {
|
||||
/// Maximum number of retries after the initial attempt
|
||||
/// (idempotent-method requests only — see `RetryGateMiddleware`).
|
||||
pub max_retries: u32,
|
||||
/// Lower bound of the jittered exponential backoff interval.
|
||||
pub initial_backoff: Duration,
|
||||
/// Upper bound of the jittered exponential backoff interval.
|
||||
pub max_retry_interval: Duration,
|
||||
}
|
||||
|
||||
impl Default for RetryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_retries: DEFAULT_MAX_RETRIES,
|
||||
initial_backoff: RETRY_BACKOFF_MIN_INTERVAL,
|
||||
max_retry_interval: RETRY_BACKOFF_MAX_INTERVAL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Policy knobs for the shared outbound client
|
||||
/// (`SharedHttpClient`). Defaults satisfy the review-001 request-policy
|
||||
/// findings (FWD-03/04/05): same-host-only redirects, idempotent-only
|
||||
@@ -111,9 +148,9 @@ pub struct HttpClientConfig {
|
||||
pub connect_timeout: Option<Duration>,
|
||||
/// Idle timeout between body bytes (default 30 s, off with `None`).
|
||||
pub read_timeout: Option<Duration>,
|
||||
/// Attempt-counting retry policy; only idempotent methods are ever
|
||||
/// retried (see `RetryGateMiddleware`).
|
||||
pub retry_policy: ExponentialBackoff,
|
||||
/// Retry backoff shape; only idempotent methods are ever retried
|
||||
/// (see `RetryGateMiddleware`).
|
||||
pub retry: RetryConfig,
|
||||
/// Wall-clock budget all retry attempts of one request must fit in.
|
||||
pub max_total_retry_duration: Duration,
|
||||
/// Ceiling for `Retry-After` values parsed from upstream responses.
|
||||
@@ -131,11 +168,7 @@ impl Default for HttpClientConfig {
|
||||
request_timeout: Some(DEFAULT_REQUEST_TIMEOUT),
|
||||
connect_timeout: Some(DEFAULT_CONNECT_TIMEOUT),
|
||||
read_timeout: Some(DEFAULT_READ_TIMEOUT),
|
||||
retry_policy: ExponentialBackoff::builder()
|
||||
.retry_bounds(RETRY_BACKOFF_MIN_INTERVAL, RETRY_BACKOFF_MAX_INTERVAL)
|
||||
.jitter(Jitter::Bounded)
|
||||
.base(2)
|
||||
.build_with_max_retries(3),
|
||||
retry: RetryConfig::default(),
|
||||
max_total_retry_duration: DEFAULT_MAX_TOTAL_RETRY_DURATION,
|
||||
retry_after_ceiling: DEFAULT_RETRY_AFTER_CEILING,
|
||||
ca_bundle: None,
|
||||
@@ -273,7 +306,12 @@ struct RetryGateMiddleware {
|
||||
}
|
||||
|
||||
impl RetryGateMiddleware {
|
||||
fn new(policy: ExponentialBackoff, max_total_retry_duration: Duration) -> Self {
|
||||
fn new(retry: &RetryConfig, max_total_retry_duration: Duration) -> Self {
|
||||
let policy = ExponentialBackoff::builder()
|
||||
.retry_bounds(retry.initial_backoff, retry.max_retry_interval)
|
||||
.jitter(Jitter::Bounded)
|
||||
.base(2)
|
||||
.build_with_max_retries(retry.max_retries);
|
||||
Self {
|
||||
retry: Arc::new(RetryTransientMiddleware::new_with_policy(
|
||||
TotalRetryBudget {
|
||||
@@ -444,7 +482,7 @@ fn build_client_with_pems(
|
||||
let reqwest_client = builder.build().map_err(HttpClientBuildError::Build)?;
|
||||
let client = reqwest_middleware::ClientBuilder::new(reqwest_client)
|
||||
.with(RetryGateMiddleware::new(
|
||||
config.retry_policy,
|
||||
&config.retry,
|
||||
config.max_total_retry_duration,
|
||||
))
|
||||
.with(RetryAfterMiddleware::with_capacity_and_ceiling(
|
||||
@@ -464,472 +502,3 @@ fn concat_pem(cert: &[u8], key: &[u8]) -> Vec<u8> {
|
||||
combined.extend_from_slice(key);
|
||||
combined
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::SystemTime;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
fn minimal_config() -> HttpClientConfig {
|
||||
HttpClientConfig {
|
||||
pool_max_idle_per_host: Some(8),
|
||||
retry_policy: ExponentialBackoff::builder().build_with_max_retries(2),
|
||||
ca_bundle: None,
|
||||
client_cert: None,
|
||||
..HttpClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_returns_a_usable_client_with_middleware() {
|
||||
let http = SharedHttpClient::new(minimal_config()).expect("client builds");
|
||||
let client = http.client();
|
||||
let request = client
|
||||
.get("https://api.example.com/v1/chat")
|
||||
.build()
|
||||
.expect("RequestBuilder builds");
|
||||
assert_eq!(request.method(), reqwest::Method::GET);
|
||||
assert_eq!(request.url().as_str(), "https://api.example.com/v1/chat");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_swaps_the_client_returned_by_client() {
|
||||
let http = SharedHttpClient::new(minimal_config()).expect("client builds");
|
||||
let before = http.client();
|
||||
let new_config = HttpClientConfig {
|
||||
pool_max_idle_per_host: Some(32),
|
||||
retry_policy: ExponentialBackoff::builder().build_with_max_retries(5),
|
||||
ca_bundle: None,
|
||||
client_cert: None,
|
||||
..minimal_config()
|
||||
};
|
||||
http.reload(new_config.clone())
|
||||
.await
|
||||
.expect("reload succeeds");
|
||||
let after = http.client();
|
||||
assert!(
|
||||
!Arc::ptr_eq(&before, &after),
|
||||
"reload must swap in a new ClientWithMiddleware"
|
||||
);
|
||||
let config = http.config();
|
||||
assert_eq!(config.pool_max_idle_per_host, Some(32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_returns_current_config() {
|
||||
let http = SharedHttpClient::new(minimal_config()).expect("client builds");
|
||||
let config = http.config();
|
||||
assert_eq!(config.pool_max_idle_per_host, Some(8));
|
||||
assert_eq!(config.request_timeout, Some(Duration::from_secs(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_has_sensible_defaults() {
|
||||
let config = HttpClientConfig::default();
|
||||
assert!(config.pool_max_idle_per_host.is_none());
|
||||
assert_eq!(config.request_timeout, Some(Duration::from_secs(30)));
|
||||
assert_eq!(config.connect_timeout, Some(Duration::from_secs(10)));
|
||||
assert_eq!(config.read_timeout, Some(Duration::from_secs(30)));
|
||||
assert_eq!(config.max_total_retry_duration, Duration::from_secs(10));
|
||||
assert_eq!(config.retry_after_ceiling, Duration::from_secs(300));
|
||||
assert_eq!(config.retry_policy.max_n_retries, Some(3));
|
||||
assert_eq!(
|
||||
config.retry_policy.max_retry_interval,
|
||||
Duration::from_secs(2)
|
||||
);
|
||||
assert!(config.ca_bundle.is_none());
|
||||
assert!(config.client_cert.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_with_ca_bundle_missing_file_errors() {
|
||||
let http = SharedHttpClient::new(minimal_config()).expect("client builds");
|
||||
let bad_config = HttpClientConfig {
|
||||
ca_bundle: Some(PathBuf::from("/nonexistent/ca-bundle.pem")),
|
||||
..minimal_config()
|
||||
};
|
||||
let err = http.reload(bad_config).await.unwrap_err();
|
||||
assert!(matches!(err, HttpClientBuildError::CaBundleRead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concat_pem_inserts_separator_between_cert_and_key() {
|
||||
let cert = b"-----BEGIN CERTIFICATE-----\ncert-body\n-----END CERTIFICATE-----";
|
||||
let key = b"-----BEGIN PRIVATE KEY-----\nkey-body\n-----END PRIVATE KEY-----";
|
||||
let combined = concat_pem(cert, key);
|
||||
assert!(combined.starts_with(b"-----BEGIN CERTIFICATE-----"));
|
||||
assert!(combined.windows(20).any(|w| w == b"-----END CERTIFICATE"));
|
||||
assert!(combined.windows(18).any(|w| w == b"-----BEGIN PRIVATE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concat_pem_handles_cert_already_terminated_with_newline() {
|
||||
let cert = b"-----BEGIN CERTIFICATE-----\ncert-body\n-----END CERTIFICATE-----\n";
|
||||
let key = b"-----BEGIN PRIVATE KEY-----\nkey-body\n-----END PRIVATE KEY-----";
|
||||
let combined = concat_pem(cert, key);
|
||||
let joined = std::str::from_utf8(&combined).unwrap();
|
||||
assert!(
|
||||
!joined.contains("-----END CERTIFICATE----------BEGIN PRIVATE"),
|
||||
"must not concatenate without a separator when cert lacks trailing newline"
|
||||
);
|
||||
assert!(joined.contains("-----END CERTIFICATE-----\n-----BEGIN PRIVATE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_cert_config_constructs() {
|
||||
let cfg = ClientCertConfig {
|
||||
cert_pem: PathBuf::from("/etc/cert.pem"),
|
||||
key_pem: PathBuf::from("/etc/key.pem"),
|
||||
};
|
||||
assert_eq!(cfg.cert_pem, PathBuf::from("/etc/cert.pem"));
|
||||
assert_eq!(cfg.key_pem, PathBuf::from("/etc/key.pem"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_with_missing_ca_bundle_errors() {
|
||||
let config = HttpClientConfig {
|
||||
ca_bundle: Some(PathBuf::from("/nonexistent/ca-bundle.pem")),
|
||||
..HttpClientConfig::default()
|
||||
};
|
||||
let err = SharedHttpClient::new(config).unwrap_err();
|
||||
assert!(matches!(err, HttpClientBuildError::CaBundleRead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_error_display_contains_path() {
|
||||
let err = HttpClientBuildError::CaBundleRead {
|
||||
path: PathBuf::from("/nonexistent/ca.pem"),
|
||||
source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
|
||||
};
|
||||
let rendered = format!("{err}");
|
||||
assert!(rendered.contains("/nonexistent/ca.pem"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_capacity_constant_is_bounded() {
|
||||
let cap = DEFAULT_RETRY_AFTER_CAPACITY;
|
||||
assert!(cap > 0, "RetryAfterMiddleware storage must be non-zero");
|
||||
assert!(cap <= 4096, "RetryAfterMiddleware storage must be bounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_env_vars_read_in_default_config() {
|
||||
let _ = SystemTime::now();
|
||||
let config = HttpClientConfig::default();
|
||||
assert!(config.ca_bundle.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_methods_are_the_retryable_set() {
|
||||
for method in ["GET", "HEAD", "PUT", "DELETE", "OPTIONS"] {
|
||||
assert!(
|
||||
is_idempotent(&reqwest::Method::from_bytes(method.as_bytes()).unwrap()),
|
||||
"{method} must be classified idempotent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_idempotent_methods_bypass_retry() {
|
||||
for method in ["POST", "PATCH", "CONNECT", "TRACE"] {
|
||||
assert!(
|
||||
!is_idempotent(&reqwest::Method::from_bytes(method.as_bytes()).unwrap()),
|
||||
"{method} must be classified non-idempotent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_retry_budget_stops_after_the_wall_clock_deadline() {
|
||||
let policy = TotalRetryBudget {
|
||||
budget: Duration::from_secs(1),
|
||||
inner: ExponentialBackoff::builder().build_with_max_retries(100),
|
||||
};
|
||||
let early = policy.should_retry(SystemTime::now(), 0);
|
||||
assert!(
|
||||
matches!(early, RetryDecision::Retry { .. }),
|
||||
"a fresh request inside the budget is retryable"
|
||||
);
|
||||
let late_start = SystemTime::now() - Duration::from_secs(2);
|
||||
let exhausted = policy.should_retry(late_start, 0);
|
||||
assert!(
|
||||
matches!(exhausted, RetryDecision::DoNotRetry),
|
||||
"elapsed beyond the budget must stop retries"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_retry_budget_clamps_scheduled_retries_to_the_budget() {
|
||||
let policy = TotalRetryBudget {
|
||||
budget: Duration::from_secs(1),
|
||||
inner: ExponentialBackoff::builder()
|
||||
.retry_bounds(Duration::from_secs(30), Duration::from_secs(30))
|
||||
.build_with_max_retries(5),
|
||||
};
|
||||
let start = SystemTime::now();
|
||||
match policy.should_retry(start, 0) {
|
||||
RetryDecision::Retry { execute_after } => {
|
||||
let hard_stop = start + Duration::from_secs(1);
|
||||
assert!(
|
||||
execute_after <= hard_stop,
|
||||
"a scheduled retry must not be scheduled past the budget"
|
||||
);
|
||||
}
|
||||
other => panic!("expected a retry decision, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_host_redirect_does_not_leak_api_key_header() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let attacker_hits = Arc::new(AtomicUsize::new(0));
|
||||
let attacker_header_seen = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let attacker_hits_listener = Arc::clone(&attacker_hits);
|
||||
let attacker_header = Arc::clone(&attacker_header_seen);
|
||||
let attacker = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let attacker_addr = attacker.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = attacker.accept().await else {
|
||||
break;
|
||||
};
|
||||
let hits = Arc::clone(&attacker_hits_listener);
|
||||
let seen = Arc::clone(&attacker_header);
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
hits.fetch_add(1, Ordering::SeqCst);
|
||||
if request.contains("x-api-key: leaked-credential") {
|
||||
seen.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
let response = "HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n";
|
||||
let _ = sock.write_all(response.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let redirector = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let redirector_addr = redirector.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = redirector.accept().await else {
|
||||
break;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 4096];
|
||||
let mut n = 0;
|
||||
loop {
|
||||
let read = sock.read(&mut buf[n..]).await.unwrap_or(0);
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
n += read;
|
||||
if String::from_utf8_lossy(&buf[..n]).contains("\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let response = format!(
|
||||
"HTTP/1.1 302 Found\r\nlocation: http://{attacker_addr}/steal\r\ncontent-length: 0\r\n\r\n"
|
||||
);
|
||||
let _ = sock.write_all(response.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let http = SharedHttpClient::new(HttpClientConfig::default()).expect("client builds");
|
||||
let response = http
|
||||
.client()
|
||||
.get(format!("http://{redirector_addr}/open-redirect"))
|
||||
.header("x-api-key", "leaked-credential")
|
||||
.send()
|
||||
.await
|
||||
.expect("request completes");
|
||||
assert_eq!(response.status(), 302);
|
||||
assert_eq!(
|
||||
response.url().as_str(),
|
||||
format!("http://{redirector_addr}/open-redirect"),
|
||||
"the client must not follow the cross-host redirect"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let hits = attacker_hits.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
hits, 0,
|
||||
"no request — credential or not — may reach the redirect target"
|
||||
);
|
||||
assert_eq!(
|
||||
attacker_header_seen.load(Ordering::SeqCst),
|
||||
0,
|
||||
"the API-key credential must not appear in any request to the target"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_host_redirect_is_still_followed() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let hits_listener = Arc::clone(&hits);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
let hits = Arc::clone(&hits_listener);
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 4096];
|
||||
let mut n = 0;
|
||||
loop {
|
||||
let read = sock.read(&mut buf[n..]).await.unwrap_or(0);
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
n += read;
|
||||
if String::from_utf8_lossy(&buf[..n]).contains("\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
if request.contains("GET /final") {
|
||||
hits.fetch_add(1, Ordering::SeqCst);
|
||||
let response = "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok";
|
||||
let _ = sock.write_all(response.as_bytes()).await;
|
||||
} else {
|
||||
let response = format!(
|
||||
"HTTP/1.1 302 Found\r\nlocation: http://{addr}/final\r\ncontent-length: 0\r\n\r\n"
|
||||
);
|
||||
let _ = sock.write_all(response.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let http = SharedHttpClient::new(HttpClientConfig::default()).expect("client builds");
|
||||
let response = http
|
||||
.client()
|
||||
.get(format!("http://{addr}/start"))
|
||||
.send()
|
||||
.await
|
||||
.expect("request completes");
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_eq!(
|
||||
response.url().as_str(),
|
||||
format!("http://{addr}/final"),
|
||||
"a same-host redirect must be followed to the final URL"
|
||||
);
|
||||
assert_eq!(hits.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_idempotent_post_gets_one_attempt_then_the_429_is_surfaced() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let hits_listener = Arc::clone(&hits);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
let hits = Arc::clone(&hits_listener);
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 4096];
|
||||
let mut n = 0;
|
||||
loop {
|
||||
let read = sock.read(&mut buf[n..]).await.unwrap_or(0);
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
n += read;
|
||||
if String::from_utf8_lossy(&buf[..n]).contains("\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
hits.fetch_add(1, Ordering::SeqCst);
|
||||
let response =
|
||||
"HTTP/1.1 500 Internal Server Error\r\ncontent-length: 0\r\n\r\n";
|
||||
let _ = sock.write_all(response.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let http = SharedHttpClient::new(minimal_config()).expect("client builds");
|
||||
let response = http
|
||||
.client()
|
||||
.post(format!("http://{addr}/create"))
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"v":1}"#)
|
||||
.send()
|
||||
.await
|
||||
.expect("request completes");
|
||||
assert_eq!(response.status(), 500);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(
|
||||
hits.load(Ordering::SeqCst),
|
||||
1,
|
||||
"a non-idempotent POST must never be re-sent"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idempotent_get_is_retried_on_a_transient_failure() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let hits_listener = Arc::clone(&hits);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
let hits = Arc::clone(&hits_listener);
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 4096];
|
||||
let mut n = 0;
|
||||
loop {
|
||||
let read = sock.read(&mut buf[n..]).await.unwrap_or(0);
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
n += read;
|
||||
if String::from_utf8_lossy(&buf[..n]).contains("\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let previous = hits.fetch_add(1, Ordering::SeqCst);
|
||||
if previous < 2 {
|
||||
let response =
|
||||
"HTTP/1.1 500 Internal Server Error\r\ncontent-length: 0\r\n\r\n";
|
||||
let _ = sock.write_all(response.as_bytes()).await;
|
||||
} else {
|
||||
let response = "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok";
|
||||
let _ = sock.write_all(response.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let http = SharedHttpClient::new(minimal_config()).expect("client builds");
|
||||
let response = http
|
||||
.client()
|
||||
.get(format!("http://{addr}/flaky"))
|
||||
.send()
|
||||
.await
|
||||
.expect("request completes after retries");
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_eq!(
|
||||
hits.load(Ordering::SeqCst),
|
||||
3,
|
||||
"GET must be retried until the upstream recovers"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: review-001-client-config-and-cert-coverage
|
||||
name: Client config API cleanup (HY-06) + mTLS/CA-bundle test coverage (COV-02)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
risk: low
|
||||
@@ -33,11 +33,11 @@ Two deferred client-host items, grouped (same file, `src/client/http_client.rs`)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `HttpClientConfig` no longer exposes `ExponentialBackoff`; owned field set covers what the remediation made configurable; module docs updated
|
||||
- [ ] Existing config-construction call sites migrated (adapters' test fixtures included)
|
||||
- [ ] TLS test: client built with a CA bundle connects to a private-roots server; client-cert path exercised end-to-end (COV-02's uncovered build paths)
|
||||
- [ ] Feature matrix green (default, `--all-features`, `--no-default-features`)
|
||||
- [ ] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass
|
||||
- [x] `HttpClientConfig` no longer exposes `ExponentialBackoff`; owned field set covers what the remediation made configurable; module docs updated
|
||||
- [x] Existing config-construction call sites migrated (adapters' test fixtures included)
|
||||
- [x] TLS test: client built with a CA bundle connects to a private-roots server; client-cert path exercised end-to-end (COV-02's uncovered build paths)
|
||||
- [x] Feature matrix green (default, `--all-features`, `--no-default-features`)
|
||||
- [x] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass
|
||||
|
||||
## References
|
||||
|
||||
@@ -46,10 +46,70 @@ Two deferred client-host items, grouped (same file, `src/client/http_client.rs`)
|
||||
|
||||
## Notes
|
||||
|
||||
> Agent fills during implementation. Public-API shape change —
|
||||
> coordinate with review-001-missing-docs-sweep if running concurrently
|
||||
> (same module's docs).
|
||||
**HY-06 shape chosen**: `HttpClientConfig.retry_policy:
|
||||
ExponentialBackoff` → `HttpClientConfig.retry: RetryConfig`, an owned
|
||||
struct of plain scalars `{ max_retries: u32, initial_backoff: Duration,
|
||||
max_retry_interval: Duration }` (debug-clone, `Default` =
|
||||
3 retries / 100 ms / 2 s — the previous `ExponentialBackoff` default
|
||||
exactly). The jitter (`Bounded`) and exponential base (`2`) stay
|
||||
internal policy constants, not config — they were never surfaced
|
||||
before either. `RetryGateMiddleware::new` builds the internal
|
||||
`ExponentialBackoff` from the `RetryConfig` at client-construction
|
||||
time; `max_total_retry_duration` remains its own `HttpClientConfig`
|
||||
field (unchanged; it was already a plain scalar). `reqwest-retry`
|
||||
stays a private implementation detail of the middleware stack — no
|
||||
`reqwest_retry` type appears in the public API.
|
||||
|
||||
Call-site audit: `retry_policy` had **zero** external construction
|
||||
sites — every `HttpClientConfig` consumer (from_jsonschema,
|
||||
from_openapi, openapi_spec, forward.rs, full_surface test) uses
|
||||
`HttpClientConfig::default()`; only `http_client.rs`'s own tests
|
||||
constructed the field. Those were migrated to `RetryConfig { .. }`
|
||||
literals, and the default-config assertions now pin `max_retries`,
|
||||
`initial_backoff`, and `max_retry_interval` on the owned struct.
|
||||
`ClientCertConfig` doc comments added (it had none — caught by the
|
||||
`missing_docs` gate re-measure this task unblocks).
|
||||
|
||||
**COV-02 shape chosen**: new integration test file
|
||||
`tests/client_tls.rs` (kept out of the lib's test module — it needs
|
||||
`tokio-rustls`, `rustls`, `rcgen` as dev-deps only, so the base crate
|
||||
and its feature matrix stay lean). Per test it mints a throwaway
|
||||
private PKI with rcgen (CA + server leaf SAN `127.0.0.1`/`localhost` +
|
||||
client leaf with ClientAuth EKU), serves HTTPS/1.1 via tokio-rustls
|
||||
(`WebPkiClientVerifier` when mTLS is required), and drives the **real
|
||||
`SharedHttpClient`** through `HttpClientConfig` file paths — same
|
||||
encode path as production (PEM read → `add_root_certificate` /
|
||||
`Identity::from_pem` via `concat_pem`). Five tests:
|
||||
|
||||
- CA-bundle client ↔ private-roots server: 200 + body intact + exactly
|
||||
1 handshake (success path through the full middleware stack);
|
||||
- no-CA-bundle client rejected by private-roots server — asserts the
|
||||
TLS verification failure surfaces in the error **source chain**
|
||||
(`invalid peer certificate: UnknownIssuer`; the retry middleware
|
||||
wraps it, so the chain is walked, not the top-level Display);
|
||||
- mTLS: client presents identity → handshake completes end-to-end;
|
||||
- mTLS server rejects a client with no identity (TLS-level rejection);
|
||||
- `reload()` to a CA-bundle-backed client makes a previously
|
||||
unreachable server trusted (hot-reload × TLS interplay).
|
||||
|
||||
The negative-path assertions mirror the retry-stack reality: the first
|
||||
attempt fails at the TLS layer and 3 retries surface a
|
||||
`Middleware(...)` wrapper — the source chain carries the answer.
|
||||
|
||||
## Summary
|
||||
|
||||
> Filled on completion.
|
||||
- `src/client/http_client.rs`: `RetryConfig` (new public struct)
|
||||
replaces the `ExponentialBackoff` exposure; retry backoff built
|
||||
internally; `ClientCertConfig` fields documented; tests migrated
|
||||
(minimal_config, reload-swap, default assertions).
|
||||
- `tests/client_tls.rs` (new): private-PKI test server + 5 TLS tests
|
||||
(2 success-path TLS builds, 1 reload×TLS, 2 negative
|
||||
verification/mTLS rejections with chain-aware assertions).
|
||||
- `Cargo.toml`: dev-deps `rcgen 0.14`, `tokio-rustls 0.26`,
|
||||
`rustls 0.23` (no default features; aws_lc_rs + std + tls12),
|
||||
`rustls-pki-types 1`, `uuid` (v4 already a main dep).
|
||||
- Verified: `cargo test` (288 + 5 TLS), `cargo test --all-features`
|
||||
(359 + all suites), `cargo test --no-default-features` (288; same 4
|
||||
pre-existing lib warnings as the base commit, nothing new),
|
||||
`cargo clippy --all-targets -- -D warnings` (default +
|
||||
all-features), `cargo fmt --check`, `cargo doc --no-deps` clean.
|
||||
@@ -0,0 +1,375 @@
|
||||
//! TLS-path coverage for the outbound client (COV-02, review-001
|
||||
//! follow-up): the `ca_bundle` and `client_cert` build paths had only
|
||||
//! error-path tests (missing files); these tests exercise the success
|
||||
//! end-to-end against a local private-roots TLS server —
|
||||
//!
|
||||
//! - private-roots verification: a client built with a CA bundle
|
||||
//! connects to a server whose cert is issued by that CA;
|
||||
//! - mTLS: a server requiring client certificates completes the
|
||||
//! handshake only when the client presents its own identity;
|
||||
//! - the full middleware stack (redirect policy + retry gate) rides on
|
||||
//! the same builder, so a plain GET through `SharedHttpClient`
|
||||
//! covers the TLS-configured construction path.
|
||||
//!
|
||||
//! Uses `rcgen` to mint a throwaway private PKI per test and
|
||||
//! `tokio-rustls` for the server side; the client side is the real
|
||||
//! `SharedHttpClient` configured via `HttpClientConfig`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use alkhttp::client::{ClientCertConfig, HttpClientConfig, SharedHttpClient};
|
||||
|
||||
/// A throwaway private PKI: CA, server leaf for `127.0.0.1`/`localhost`,
|
||||
/// and a client leaf, freshly minted per test.
|
||||
struct TestPki {
|
||||
ca_pem: Vec<u8>,
|
||||
server_pem: Vec<u8>,
|
||||
server_key_pem: Vec<u8>,
|
||||
client_pem: Vec<u8>,
|
||||
client_key_pem: Vec<u8>,
|
||||
}
|
||||
|
||||
impl TestPki {
|
||||
fn generate() -> Self {
|
||||
let mut ca_params =
|
||||
rcgen::CertificateParams::new(vec!["alkhttp test CA".to_string()]).expect("CA params");
|
||||
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
|
||||
let ca_key = rcgen::KeyPair::generate().expect("CA key");
|
||||
let ca_cert = ca_params.self_signed(&ca_key).expect("self-signed CA");
|
||||
let issuer = rcgen::Issuer::from_params(&ca_params, &ca_key);
|
||||
|
||||
let mut server_params =
|
||||
rcgen::CertificateParams::new(vec!["127.0.0.1".to_string(), "localhost".to_string()])
|
||||
.expect("server params");
|
||||
server_params.is_ca = rcgen::IsCa::NoCa;
|
||||
let server_key = rcgen::KeyPair::generate().expect("server key");
|
||||
let server_cert = server_params
|
||||
.signed_by(&server_key, &issuer)
|
||||
.expect("server leaf");
|
||||
|
||||
let mut client_params =
|
||||
rcgen::CertificateParams::new(vec!["alkhttp test client".to_string()])
|
||||
.expect("client params");
|
||||
client_params.is_ca = rcgen::IsCa::NoCa;
|
||||
client_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth];
|
||||
let client_key = rcgen::KeyPair::generate().expect("client key");
|
||||
let client_cert = client_params
|
||||
.signed_by(&client_key, &issuer)
|
||||
.expect("client leaf");
|
||||
|
||||
Self {
|
||||
ca_pem: ca_cert.pem().into_bytes(),
|
||||
server_pem: server_cert.pem().into_bytes(),
|
||||
server_key_pem: server_key.serialize_pem().into_bytes(),
|
||||
client_pem: client_cert.pem().into_bytes(),
|
||||
client_key_pem: client_key.serialize_pem().into_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the CA bundle (and, when `with_client_cert`, the client
|
||||
/// identity) to a fresh temp directory, as `HttpClientConfig`
|
||||
/// expects paths. Returns the config pieces plus the temp dir.
|
||||
fn write_config_files(
|
||||
&self,
|
||||
with_client_cert: bool,
|
||||
) -> (Option<PathBuf>, Option<ClientCertConfig>, PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"alkhttp-tls-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let write = |name: &str, bytes: &[u8]| {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, bytes).expect("write pem");
|
||||
path
|
||||
};
|
||||
let ca = Some(write("ca.pem", &self.ca_pem));
|
||||
let client = if with_client_cert {
|
||||
Some(ClientCertConfig {
|
||||
cert_pem: write("client-cert.pem", &self.client_pem),
|
||||
key_pem: write("client-key.pem", &self.client_key_pem),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(ca, client, dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal HTTPS/1.1 test server on 127.0.0.1 that either requires a
|
||||
/// client certificate (mTLS) or accepts anonymous clients, answers
|
||||
/// every request with `200 ok`, and counts completed TLS handshakes.
|
||||
struct TlsTestServer {
|
||||
origin: String,
|
||||
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
handshakes: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl TlsTestServer {
|
||||
fn handshakes(&self) -> u32 {
|
||||
self.handshakes.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
async fn spawn(pki: &TestPki, require_client_cert: bool) -> Self {
|
||||
use rustls_pki_types::pem::PemObject;
|
||||
|
||||
let server_certs: Vec<rustls_pki_types::CertificateDer<'static>> =
|
||||
rustls_pki_types::pem::PemObject::pem_slice_iter(&pki.server_pem)
|
||||
.map(|c: Result<rustls_pki_types::CertificateDer<'_>, _>| {
|
||||
c.expect("server cert parses")
|
||||
})
|
||||
.collect();
|
||||
let server_key = rustls_pki_types::PrivateKeyDer::from_pem_slice(&pki.server_key_pem)
|
||||
.expect("server key parses");
|
||||
|
||||
let server_trust = if require_client_cert {
|
||||
let mut trust = rustls::RootCertStore::empty();
|
||||
let ca_iter = rustls_pki_types::pem::PemObject::pem_slice_iter(&pki.ca_pem).map(
|
||||
|c: Result<rustls_pki_types::CertificateDer<'_>, _>| c.expect("CA cert parses"),
|
||||
);
|
||||
for ca in ca_iter {
|
||||
trust.add(ca).expect("CA added to server trust store");
|
||||
}
|
||||
Some(trust)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let config = match &server_trust {
|
||||
Some(trust) => {
|
||||
let verifier =
|
||||
rustls::server::WebPkiClientVerifier::builder(Arc::new(trust.clone()))
|
||||
.build()
|
||||
.expect("client verifier");
|
||||
rustls::ServerConfig::builder()
|
||||
.with_client_cert_verifier(verifier)
|
||||
.with_single_cert(server_certs, server_key)
|
||||
.expect("server config with client auth")
|
||||
}
|
||||
None => rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(server_certs, server_key)
|
||||
.expect("server config"),
|
||||
};
|
||||
let tls_config = Arc::new(config);
|
||||
let handshakes = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind 127.0.0.1:0");
|
||||
let addr: SocketAddr = listener.local_addr().expect("local addr");
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let hs_counter = Arc::clone(&handshakes);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let acceptor = tokio_rustls::TlsAcceptor::from(tls_config);
|
||||
let mut shutdown = std::pin::pin!(shutdown_rx);
|
||||
loop {
|
||||
let accept = tokio::select! {
|
||||
_ = &mut shutdown => break,
|
||||
accepted = listener.accept() => match accepted {
|
||||
Ok((sock, _)) => sock,
|
||||
Err(_) => break,
|
||||
},
|
||||
};
|
||||
let acceptor = acceptor.clone();
|
||||
let hs = Arc::clone(&hs_counter);
|
||||
tokio::spawn(async move {
|
||||
let Ok(mut tls_stream) = acceptor.accept(accept).await else {
|
||||
return;
|
||||
};
|
||||
hs.fetch_add(1, Ordering::SeqCst);
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let n = tls_stream.read(&mut buf).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
if String::from_utf8_lossy(&buf[..n]).contains("\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let body = b"ok";
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
String::from_utf8_lossy(body),
|
||||
);
|
||||
let _ = tls_stream.write_all(response.as_bytes()).await;
|
||||
let _ = tls_stream.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
origin: format!("https://127.0.0.1:{}", addr.port()),
|
||||
shutdown: Some(shutdown_tx),
|
||||
handshakes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TlsTestServer {
|
||||
fn drop(&mut self) {
|
||||
if let Some(shutdown) = self.shutdown.take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn client_config(ca: Option<PathBuf>, cert: Option<ClientCertConfig>) -> HttpClientConfig {
|
||||
HttpClientConfig {
|
||||
ca_bundle: ca,
|
||||
client_cert: cert,
|
||||
..HttpClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_dir(dir: &PathBuf) {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_with_ca_bundle_connects_to_private_roots_server() {
|
||||
let pki = TestPki::generate();
|
||||
let server = TlsTestServer::spawn(&pki, false).await;
|
||||
let (ca, _cert, dir) = pki.write_config_files(false);
|
||||
|
||||
let http = SharedHttpClient::new(client_config(ca, None)).expect("client builds with CA");
|
||||
let response = http
|
||||
.client()
|
||||
.get(format!("{}/ping", server.origin))
|
||||
.send()
|
||||
.await
|
||||
.expect("request over private roots succeeds");
|
||||
assert_eq!(response.status(), 200, "server answers over TLS");
|
||||
assert_eq!(
|
||||
response.text().await.unwrap(),
|
||||
"ok",
|
||||
"the TLS-secured body arrives intact"
|
||||
);
|
||||
assert_eq!(
|
||||
server.handshakes(),
|
||||
1,
|
||||
"exactly one TLS handshake was completed"
|
||||
);
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_without_ca_bundle_rejects_private_roots_server() {
|
||||
let pki = TestPki::generate();
|
||||
let server = TlsTestServer::spawn(&pki, false).await;
|
||||
|
||||
let http = SharedHttpClient::new(HttpClientConfig::default())
|
||||
.expect("client builds with default (webpki) roots");
|
||||
let result = http
|
||||
.client()
|
||||
.get(format!("{}/ping", server.origin))
|
||||
.send()
|
||||
.await;
|
||||
let error = result
|
||||
.expect_err("a private-roots server must be rejected by a client without the CA bundle");
|
||||
let text = error_chain_text(&error);
|
||||
assert!(
|
||||
text.contains("certificate"),
|
||||
"the chain names the TLS verification failure, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Walks the full `std::error::Error` source chain (the retry
|
||||
/// middleware wraps the transport error, so the TLS detail sits in the
|
||||
/// `Caused by` chain) and joins it into one lowercase string.
|
||||
fn error_chain_text(error: &reqwest_middleware::Error) -> String {
|
||||
let mut text = error.to_string().to_lowercase();
|
||||
let mut source = std::error::Error::source(error);
|
||||
while let Some(err) = source {
|
||||
text.push(' ');
|
||||
text.push_str(&err.to_string().to_lowercase());
|
||||
source = err.source();
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mtls_client_cert_is_presented_and_accepted_end_to_end() {
|
||||
let pki = TestPki::generate();
|
||||
let server = TlsTestServer::spawn(&pki, true).await;
|
||||
let (ca, cert, dir) = pki.write_config_files(true);
|
||||
|
||||
let http = SharedHttpClient::new(client_config(ca, cert))
|
||||
.expect("client builds with CA bundle + client identity");
|
||||
let response = http
|
||||
.client()
|
||||
.get(format!("{}/ping", server.origin))
|
||||
.send()
|
||||
.await
|
||||
.expect("mTLS handshake with client identity succeeds");
|
||||
assert_eq!(response.status(), 200, "server answers the mTLS client");
|
||||
assert_eq!(response.text().await.unwrap(), "ok");
|
||||
assert_eq!(
|
||||
server.handshakes(),
|
||||
1,
|
||||
"the client-cert handshake completed through the full middleware stack"
|
||||
);
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mtls_server_rejects_client_without_identity() {
|
||||
let pki = TestPki::generate();
|
||||
let server = TlsTestServer::spawn(&pki, true).await;
|
||||
let (ca, _cert, dir) = pki.write_config_files(false);
|
||||
|
||||
let http = SharedHttpClient::new(client_config(ca, None))
|
||||
.expect("client builds with CA bundle but no client identity");
|
||||
let result = http
|
||||
.client()
|
||||
.get(format!("{}/ping", server.origin))
|
||||
.send()
|
||||
.await;
|
||||
let error = result
|
||||
.expect_err("an mTLS-requiring server must reject a client that presents no certificate");
|
||||
let text = error_chain_text(&error);
|
||||
assert!(
|
||||
text.contains("certificate") || text.contains("alert") || text.contains("handshake"),
|
||||
"the chain names a TLS/certificate-level rejection, got: {text}"
|
||||
);
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_to_a_ca_bundle_backed_client_succeeds() {
|
||||
let pki = TestPki::generate();
|
||||
let server = TlsTestServer::spawn(&pki, false).await;
|
||||
let (ca, _cert, dir) = pki.write_config_files(false);
|
||||
|
||||
let http = SharedHttpClient::new(HttpClientConfig::default()).expect("initial client");
|
||||
assert!(
|
||||
http.client()
|
||||
.get(format!("{}/ping", server.origin))
|
||||
.send()
|
||||
.await
|
||||
.is_err(),
|
||||
"before the reload the private-roots server is unreachable"
|
||||
);
|
||||
let reloaded = client_config(ca, None);
|
||||
http.reload(reloaded)
|
||||
.await
|
||||
.expect("reload with a valid CA bundle succeeds");
|
||||
let response = http
|
||||
.client()
|
||||
.get(format!("{}/ping", server.origin))
|
||||
.send()
|
||||
.await
|
||||
.expect("after the reload the CA bundle is trusted");
|
||||
assert_eq!(response.status(), 200);
|
||||
cleanup_dir(&dir);
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
}
|
||||
Reference in New Issue
Block a user