cf-gears-toolkit-http 0.6.6

ToolKit HTTP client library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
//! TLS utilities for the HTTP client.
//!
//! This gear provides cached loading of native root certificates to avoid
//! repeated OS certificate store lookups (which can be slow on some platforms).

use crate::config::{ClientAuthConfig, TlsConfig, TlsVersion};
use rustls_pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
use std::sync::{Arc, OnceLock};

/// Cached native root certificates.
/// Always stores Ok; empty vec means no certs found (warned, not errored).
static NATIVE_ROOTS_CACHE: OnceLock<Vec<CertificateDer<'static>>> = OnceLock::new();

/// Counter for test verification that the loader only runs once.
#[cfg(test)]
static LOAD_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);

/// Load native root certificates from the OS certificate store.
///
/// This function is called once and the result is cached for subsequent calls.
/// Returns Ok with potentially empty vec; missing certs are warned, not errored.
fn load_native_certs_inner() -> Vec<CertificateDer<'static>> {
    #[cfg(test)]
    LOAD_COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst);

    let result = rustls_native_certs::load_native_certs();

    // Log any errors encountered during loading
    if !result.errors.is_empty() {
        for err in &result.errors {
            tracing::warn!(error = %err, "error loading native root certificate");
        }
    }

    let certs: Vec<CertificateDer<'static>> = result.certs;

    if certs.is_empty() {
        tracing::warn!("no native root CA certificates found");
    } else {
        tracing::debug!(count = certs.len(), "loaded native root certificates");
    }

    certs
}

/// Get cached native root certificates.
///
/// Returns a reference to the cached certificates (may be empty).
/// The certificates are loaded lazily on first call and cached for all subsequent calls.
pub fn native_root_certs() -> &'static [CertificateDer<'static>] {
    NATIVE_ROOTS_CACHE
        .get_or_init(load_native_certs_inner)
        .as_slice()
}

/// Get the crypto provider for TLS connections.
///
/// This function follows the reqwest pattern:
/// 1. Check if a default provider is already installed globally
/// 2. If yes, use that (respects user configuration)
/// 3. If no, create a new aws-lc-rs / corecrypto / AWS-LC FIPS provider
///    **without** installing it globally
///
/// This avoids global state mutation and is safe to call from multiple
/// threads.
///
/// ## Two-providers caveat (non-FIPS only)
///
/// The fallback at step (3) does **not** call `install_default()`. If
/// `toolkit::bootstrap::init_crypto_provider` (the canonical install
/// site) has not yet run, every call into `get_crypto_provider()` will
/// rebuild a provider — and `CryptoProvider::get_default()` continues
/// to observe the absence. In practice this is benign because each
/// build returns an `Arc` over the same gear-level statics inside
/// the underlying provider crate (corecrypto's `default_provider()`
/// itself caches a process-wide `Arc<CryptoProvider>`, so the
/// "concurrent providers" are byte-identical handles), but it is **not
/// the same** as having a global default installed: code paths that
/// later call `get_default()` (e.g. rustls internals that re-detect)
/// will still see `None`.
///
/// **The canonical entry point is `toolkit::bootstrap::init_crypto_provider`**.
/// Callers outside the bootstrap path (probe binaries, ad-hoc tests)
/// should invoke it first. The fallback here is a safety net for code
/// that genuinely cannot run bootstrap, not a substitute for it.
///
/// Under `--features fips`, `build_client_config` bypasses this fallback
/// entirely and instead returns [`TlsConfigError::NoCryptoProvider`] when
/// no global provider is installed — silently constructing an uninstalled
/// FIPS provider would mask a misconfigured bootstrap.
// Under `--features fips`, `build_client_config` no longer routes through this
// function — it goes straight to `CryptoProvider::get_default()` and fails
// closed when none is installed. The function still exists for the non-FIPS
// path; suppress the unused-function lint for the FIPS build.
#[cfg_attr(feature = "fips", allow(dead_code))]
pub fn get_crypto_provider() -> Arc<rustls::crypto::CryptoProvider> {
    rustls::crypto::CryptoProvider::get_default()
        .cloned()
        .unwrap_or_else(|| {
            // Provider selection mirrors `toolkit::bootstrap::init_crypto_provider`:
            //   - fips + macOS    → Apple corecrypto, TLS 1.3-only (via the
            //                       corecrypto-provider's own `fips` feature
            //                       forwarded by toolkit-http's `fips`).
            //                       `default_provider()` under `feature = "fips"`
            //                       is aliased to `fips_provider()` semantics by
            //                       the corecrypto crate itself — same single-
            //                       entry-point pattern as `rustls-cng-crypto`.
            //   - fips + Windows  → Windows CNG (FIPS-Approved set).
            //                       No `fips::enabled()` re-check here: that
            //                       gate is owned by `init_crypto_provider`
            //                       and runs once at startup.
            //   - fips + other    → AWS-LC FIPS.
            //   - non-fips        → AWS-LC default.
            #[cfg(all(feature = "fips", target_os = "macos"))]
            {
                Arc::new(rustls_corecrypto_provider::default_provider())
            }
            #[cfg(all(feature = "fips", target_os = "windows"))]
            {
                let provider = rustls_cng_crypto::fips_provider();
                assert!(
                    !provider.cipher_suites.is_empty(),
                    "Windows is not in FIPS mode (FipsAlgorithmPolicy != 1). \
                     Enable system-wide FIPS via Group Policy and reboot, \
                     or call toolkit::bootstrap::init_crypto_provider() first \
                     for the canonical fail-closed path."
                );
                Arc::new(provider)
            }
            #[cfg(all(feature = "fips", not(any(target_os = "macos", target_os = "windows"))))]
            {
                Arc::new(rustls::crypto::default_fips_provider())
            }
            #[cfg(not(feature = "fips"))]
            {
                Arc::new(rustls::crypto::aws_lc_rs::default_provider())
            }
        })
}

/// Error type returned by the fallible TLS-config builders in this gear.
///
/// The `Other` variant carries a boxed `dyn Error` so the source-error chain
/// from rustls (and any future foreign error) is preserved end-to-end —
/// downstream `HttpError::Tls(_)` wraps this and reports a proper `.source()`.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TlsConfigError {
    /// `rustls::crypto::CryptoProvider::get_default()` returned `None` when a
    /// FIPS-mode TLS config was requested. Under `--features fips` the
    /// crypto provider must be installed up-front via
    /// `toolkit::bootstrap::init_crypto_provider`; falling back to an
    /// uninstalled provider here would silently bypass the canonical
    /// install path and yield a config whose FIPS-validation status is
    /// indeterminate.
    #[error(
        "rustls CryptoProvider has not been installed; call \
         toolkit::bootstrap::init_crypto_provider() before building any TLS \
         config under --features fips"
    )]
    NoCryptoProvider,

    /// `apply_fips_hardening` rejected the freshly-built `ClientConfig`
    /// because `ClientConfig::fips()` reported `false`. Carries the
    /// human-readable diagnostic. Under normal bootstrap flow the
    /// provider-level witness is already asserted by
    /// `init_crypto_provider`; this error surfaces per-config issues
    /// (e.g. missing `require_ems`) or a missed `init_crypto_provider()`
    /// call.
    #[error("{0}")]
    FipsHardeningFailed(String),

    /// Catch-all for foreign errors (`rustls::Error`, etc.) propagated
    /// via `?`. Marked `#[error(transparent)]` so the `Display` and
    /// `source()` impls forward to the inner error unchanged.
    #[error(transparent)]
    Other(#[from] Box<dyn std::error::Error + Send + Sync + 'static>),
}

/// Test-only auto-install of the platform-appropriate FIPS crypto provider.
///
/// Gated on `cfg(all(test, feature = "fips"))` — `cfg(test)` is only set when
/// compiling the crate's own unit-test target, NOT when compiling the lib for
/// use by integration tests under `tests/`. The regression test in
/// `tests/no_crypto_provider_fips.rs` therefore sees a clean process state and
/// can still assert `TlsConfigError::NoCryptoProvider`.
///
/// Forced from inside `build_client_config` so every TLS-config-building
/// entry point (`webpki_roots_client_config`, `native_roots_client_config`,
/// and `HttpClientBuilder::build`) auto-installs uniformly. nextest's default
/// "process-per-test" execution means a builder-level `LazyLock` would not
/// cover tests that bypass the builder.
#[cfg(all(test, feature = "fips"))]
mod fips_test_provider {
    use std::sync::LazyLock;

    pub(super) static INSTALL: LazyLock<()> = LazyLock::new(|| {
        // `install_default()` returns `Err` if a provider is already installed
        // (benign here); drop the result. `drop` rather than `let _ =`
        // satisfies clippy::let_underscore_must_use.
        #[cfg(target_os = "macos")]
        drop(rustls_corecrypto_provider::default_provider().install_default());
        #[cfg(target_os = "windows")]
        drop(rustls_cng_crypto::fips_provider().install_default());
        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
        drop(rustls::crypto::default_fips_provider().install_default());
    });
}

/// Build a rustls `ClientConfig` from the given root store and [`TlsConfig`].
///
/// `tls.min_version` selects the advertised protocol versions (see
/// [`protocol_versions`]) and `tls.client_auth`, when set, installs a
/// mutual-TLS client certificate (see [`load_client_auth`]).
///
/// Under the `fips` feature, applies [`apply_fips_hardening`] which:
///   1. forces `require_ems = true` (NIST SP 800-52 Rev. 2 §3.5), and
///   2. verifies `config.fips() == true` and returns `Err` otherwise.
///
/// The bootstrap `assert!` in `init_crypto_provider` is the primary
/// guard against a non-FIPS provider; this TLS-layer check is
/// defence-in-depth for per-config settings (`require_ems`, protocol
/// versions) that also affect `ClientConfig::fips()`.
fn build_client_config(
    root_store: rustls::RootCertStore,
    tls: &TlsConfig,
) -> Result<rustls::ClientConfig, TlsConfigError> {
    // Test-only: ensure the platform-appropriate FIPS crypto provider is
    // installed before this funnel runs. The fail-closed path below requires
    // `toolkit::bootstrap::init_crypto_provider` to have run in production;
    // in-crate tests that don't go through bootstrap (most of them) would
    // otherwise hit `NoCryptoProvider`. Placed here rather than in the
    // builder so it covers every TLS-config-building entry point uniformly
    // (`webpki_roots_client_config`, `native_roots_client_config`, and any
    // future addition). `cfg(test)` is set only for in-crate unit tests, NOT
    // for integration tests under `tests/` — so
    // `tests/no_crypto_provider_fips.rs` still sees a clean process state.
    #[cfg(all(test, feature = "fips"))]
    std::sync::LazyLock::force(&fips_test_provider::INSTALL);

    // Under `--features fips` the crypto provider MUST have been installed
    // up-front via `toolkit::bootstrap::init_crypto_provider` — otherwise
    // `get_crypto_provider()`'s fallback would mint an *uninstalled* provider
    // whose FIPS-validation status is indeterminate from the rustls global's
    // point of view. Fail closed instead.
    //
    // The non-FIPS branch preserves the historical infallible fallback
    // (see `get_crypto_provider` docstring).
    #[cfg(feature = "fips")]
    let provider = rustls::crypto::CryptoProvider::get_default()
        .cloned()
        .ok_or(TlsConfigError::NoCryptoProvider)?;
    #[cfg(not(feature = "fips"))]
    let provider = get_crypto_provider();

    let roots_builder = rustls::ClientConfig::builder_with_provider(provider)
        .with_protocol_versions(protocol_versions(tls.min_version))
        .map_err(|e| TlsConfigError::Other(Box::new(e)))?
        .with_root_certificates(root_store);

    // Plumb optional mutual-TLS client identity. PEM material is read and
    // parsed here (not at config-construction time) so IO/parse failures
    // surface as a recoverable `TlsConfigError` at client-build time.
    #[allow(unused_mut)]
    let mut config = match &tls.client_auth {
        Some(auth) => {
            let (cert_chain, key) = load_client_auth(auth)?;
            roots_builder
                .with_client_auth_cert(cert_chain, key)
                .map_err(|e| TlsConfigError::Other(Box::new(e)))?
        }
        None => roots_builder.with_no_client_auth(),
    };

    #[cfg(feature = "fips")]
    {
        apply_fips_hardening(&mut config)?;
    }

    Ok(config)
}

/// Map a [`TlsVersion`] floor onto the rustls protocol-version slice.
///
/// `Tls12` advertises both TLS 1.2 and TLS 1.3 (equivalent to the previous
/// `with_safe_default_protocol_versions()`); `Tls13` advertises TLS 1.3 only.
/// The returned slices are `const` items and therefore inherently `'static` —
/// every element is a reference to a `rustls::version::*` static.
fn protocol_versions(min: TlsVersion) -> &'static [&'static rustls::SupportedProtocolVersion] {
    const TLS12_AND_13: &[&rustls::SupportedProtocolVersion] =
        &[&rustls::version::TLS13, &rustls::version::TLS12];
    const TLS13_ONLY: &[&rustls::SupportedProtocolVersion] = &[&rustls::version::TLS13];
    match min {
        TlsVersion::Tls12 => TLS12_AND_13,
        TlsVersion::Tls13 => TLS13_ONLY,
    }
}

/// Load a PEM-encoded client certificate chain and private key for mutual TLS.
///
/// Reads both files referenced by [`ClientAuthConfig`], returning a
/// [`TlsConfigError::Other`] (boxed, with path context) on any IO or parse
/// failure or when the certificate chain is empty.
fn load_client_auth(
    auth: &ClientAuthConfig,
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>), TlsConfigError> {
    // `pem_file_iter` reports file-open errors eagerly and per-section parse
    // errors lazily from the iterator; map both to the same path-context error.
    let cert_err = |e: rustls_pki_types::pem::Error| {
        TlsConfigError::Other(
            format!(
                "failed to load client certificate chain from {}: {e}",
                auth.cert_chain.display()
            )
            .into(),
        )
    };
    let cert_chain: Vec<CertificateDer<'static>> = CertificateDer::pem_file_iter(&auth.cert_chain)
        .map_err(cert_err)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(cert_err)?;

    if cert_chain.is_empty() {
        return Err(TlsConfigError::Other(
            format!(
                "client certificate chain at {} contained no certificates",
                auth.cert_chain.display()
            )
            .into(),
        ));
    }

    // Deliberately do NOT interpolate the underlying parse error here: it is
    // produced from private-key bytes and must never reach logs. The path is
    // safe to include and is sufficient to diagnose IO/format problems.
    let key = PrivateKeyDer::from_pem_file(&auth.key).map_err(|_| {
        TlsConfigError::Other(
            format!(
                "failed to load client private key from {} (IO or PEM parse error)",
                auth.key.display()
            )
            .into(),
        )
    })?;

    Ok((cert_chain, key))
}

/// Apply FIPS-mode hardening to a freshly-built `ClientConfig`:
///   * set `require_ems = true` (NIST SP 800-52 Rev. 2 §3.5 — required for
///     `ClientConfig::fips()` to consider TLS 1.2 sessions FIPS-compliant
///     when the EMS extension is honoured by the peer).
///   * verify the full FIPS chain reports `fips() == true`. If not, return
///     `Err` — the bootstrap `assert!` in `init_crypto_provider` already
///     guarantees the provider reports `fips() == true`, so a failure here
///     indicates a per-config issue (e.g. missing `require_ems` or
///     restricted protocol versions) rather than a provider-level problem.
#[cfg(feature = "fips")]
fn apply_fips_hardening(cfg: &mut rustls::ClientConfig) -> Result<(), TlsConfigError> {
    cfg.require_ems = true;
    if !cfg.fips() {
        return Err(TlsConfigError::FipsHardeningFailed(
            "TLS ClientConfig does not advertise FIPS after enabling require_ems. \
             The bootstrap assert in init_crypto_provider should have caught a \
             non-FIPS provider at startup; if you see this error the provider is \
             FIPS-OK but a per-config setting (protocol versions, require_ems) is \
             preventing ClientConfig::fips() from reporting true. \
             If init_crypto_provider was not called, call it before building any \
             TLS config."
                .to_owned(),
        ));
    }
    Ok(())
}

/// Build a rustls `ClientConfig` using the cached native root certificates.
///
/// # Errors
///
/// Returns an error if no valid root certificates are available:
/// - OS certificate store is empty
/// - All certificates failed to parse
///
/// This fail-fast behavior ensures TLS configuration errors are caught at client
/// construction time rather than failing later during TLS handshakes.
pub fn native_roots_client_config(tls: &TlsConfig) -> Result<rustls::ClientConfig, TlsConfigError> {
    let certs = native_root_certs();

    let mut root_store = rustls::RootCertStore::empty();

    if certs.is_empty() {
        return Err(TlsConfigError::Other(
            "no native root CA certificates found in OS certificate store".into(),
        ));
    }

    let (added, ignored) = root_store.add_parsable_certificates(certs.iter().cloned());

    if ignored > 0 {
        tracing::warn!(
            added = added,
            ignored = ignored,
            "some native root certificates could not be parsed"
        );
    }

    if added == 0 {
        return Err(TlsConfigError::Other(
            format!(
                "no valid native root CA certificates parsed (found {}, all {} failed to parse)",
                certs.len(),
                ignored
            )
            .into(),
        ));
    }

    build_client_config(root_store, tls)
}

/// Build a rustls `ClientConfig` using Mozilla's webpki-roots trust anchors.
///
/// Under the `fips` feature, `require_ems` is forced on (see
/// [`build_client_config`]). This is the FIPS-conformant counterpart to the
/// `hyper_rustls::HttpsConnectorBuilder::with_provider_and_webpki_roots`
/// one-liner — we must build the config ourselves so we can flip the EMS bit.
pub fn webpki_roots_client_config(tls: &TlsConfig) -> Result<rustls::ClientConfig, TlsConfigError> {
    let mut root_store = rustls::RootCertStore::empty();
    root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
    build_client_config(root_store, tls)
}

#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
    use super::*;
    use std::sync::atomic::Ordering;

    /// Test that native root certs are cached after the first load.
    ///
    /// NOTE: This test verifies "at most one load" rather than "exactly one load"
    /// because `LOAD_COUNT` is a global atomic shared across all tests. If another
    /// test (or parallel test) calls `native_root_certs()` before this test runs,
    /// the cache will already be initialized and `final_count - initial_count`
    /// will be 0. The assertion handles this correctly.
    #[test]
    fn test_native_roots_cached() {
        // Capture count before our calls (may be non-zero if cache already initialized)
        let initial_count = LOAD_COUNT.load(Ordering::SeqCst);

        // First call - loads if not cached, otherwise uses existing cache
        let result1 = native_root_certs();

        // Second call should use cache
        let result2 = native_root_certs();

        // Third call should also use cache
        let result3 = native_root_certs();

        // Verify loader was called at most once more than initial (0 if already cached, 1 if we triggered the load)
        let final_count = LOAD_COUNT.load(Ordering::SeqCst);
        assert!(
            final_count <= initial_count + 1,
            "loader should run at most once, but ran {} times since test start",
            final_count - initial_count
        );

        // Results should be consistent (same slice pointer)
        assert_eq!(result1.len(), result2.len());
        assert_eq!(result2.len(), result3.len());
        assert!(std::ptr::eq(result1, result2), "should return same slice");
        assert!(std::ptr::eq(result2, result3), "should return same slice");
    }

    #[test]
    fn test_native_roots_client_config() {
        // Building client config succeeds if native roots are available
        // (which they should be on most CI/dev systems)
        // On systems without native certs, this returns Err (expected behavior)
        let result = native_roots_client_config(&TlsConfig::default());

        // Log the result for debugging in CI
        match &result {
            Ok(_) => tracing::debug!("native_roots_client_config succeeded"),
            Err(e) => {
                tracing::debug!(error = %e, "native_roots_client_config failed (expected on minimal containers)");
            }
        }

        // We don't assert success because CI containers may not have OS certs.
        // The important thing is it doesn't panic.
    }

    /// `webpki_roots_client_config()` must always build successfully — the
    /// trust store comes from a static `webpki-roots::TLS_SERVER_ROOTS` slice
    /// that is non-empty on every supported platform. Catches a silent
    /// regression if the `webpki-roots` crate ever renames its constant or
    /// changes its `extend`-able item type.
    ///
    /// Asserts behavioural properties this crate actually controls:
    ///   1. The config carries the crypto provider returned by
    ///      `get_crypto_provider()` (non-empty cipher-suite list).
    ///   2. The provider's cipher-suite set is non-empty — proves we did
    ///      not accidentally build a config whose negotiation would fail
    ///      with `NoCipherSuitesInCommon` against every peer.
    ///   3. The provider's kx-group list is non-empty — same reasoning.
    ///
    /// (Asserting `alpn_protocols.len() == 0` was a rustls default we do
    /// not control; replaced.)
    #[test]
    fn test_webpki_roots_client_config_builds() {
        let cfg = webpki_roots_client_config(&TlsConfig::default())
            .expect("webpki roots must always build");
        let provider = cfg.crypto_provider();
        assert!(
            !provider.cipher_suites.is_empty(),
            "TLS client config must carry a non-empty cipher-suite list"
        );
        assert!(
            !provider.kx_groups.is_empty(),
            "TLS client config must carry a non-empty kx-group list"
        );
    }

    /// When built with `--features fips`, `build_client_config` MUST:
    ///   1. Set `require_ems = true` (NIST SP 800-52 Rev. 2 §3.5)
    ///   2. Make `config.fips()` return true (full FIPS chain)
    ///
    /// Without this, an Apple-corecrypto-backed FIPS build silently advertises
    /// `config.fips() == false` because rustls's stock `require_ems` default
    /// is gated on rustls's *own* `fips` feature — which we deliberately keep
    /// off on macOS to avoid pulling the AWS-LC FIPS gear.
    ///
    /// Exercised via the public `webpki_roots_client_config()` (which routes
    /// through `build_client_config`); calling it avoids a hard dependency on
    /// the OS keychain that `native_roots_client_config` carries.
    ///
    /// Run via `cargo test -p cf-gears-toolkit-http --features fips`.
    /// `build_client_config` auto-installs the platform FIPS provider in
    /// test mode (see `fips_test_provider` gear) so this test does not
    /// need its own explicit install.
    #[test]
    #[cfg(feature = "fips")]
    fn fips_client_config_requires_ems_and_advertises_fips() {
        let cfg = webpki_roots_client_config(&TlsConfig::default()).expect("build under fips");
        assert!(cfg.require_ems, "fips build must set require_ems = true");
        assert!(
            cfg.fips(),
            "fips build must yield ClientConfig::fips() == true (full provider chain)"
        );
    }

    /// `protocol_versions` maps the `min_version` knob onto the rustls
    /// protocol-version slice: `Tls12` advertises both 1.2 and 1.3 (the
    /// previous `with_safe_default_protocol_versions()` behaviour) and `Tls13`
    /// advertises 1.3 only.
    #[test]
    fn protocol_versions_maps_min_version() {
        let v12 = protocol_versions(TlsVersion::Tls12);
        assert_eq!(v12.len(), 2, "Tls12 floor must advertise TLS 1.2 and 1.3");
        assert!(
            v12.iter()
                .any(|v| v.version == rustls::ProtocolVersion::TLSv1_2)
        );
        assert!(
            v12.iter()
                .any(|v| v.version == rustls::ProtocolVersion::TLSv1_3)
        );

        let v13 = protocol_versions(TlsVersion::Tls13);
        assert_eq!(v13.len(), 1, "Tls13 floor must advertise TLS 1.3 only");
        assert_eq!(v13[0].version, rustls::ProtocolVersion::TLSv1_3);
    }

    /// A `min_version: Tls13` config still builds a valid `ClientConfig`
    /// through the public webpki entry point.
    #[test]
    fn webpki_client_config_builds_with_tls13_floor() {
        let tls = TlsConfig {
            min_version: TlsVersion::Tls13,
            ..TlsConfig::default()
        };
        let cfg = webpki_roots_client_config(&tls).expect("tls 1.3 floor must build");
        assert!(!cfg.crypto_provider().cipher_suites.is_empty());
    }

    /// A `client_auth` pointing at non-existent PEM files must fail closed at
    /// build time with a `TlsConfigError` whose message names the missing path
    /// — not panic, and not silently fall back to no-client-auth.
    #[test]
    fn client_auth_missing_files_errors() {
        let tls = TlsConfig {
            client_auth: Some(ClientAuthConfig::new(
                "/nonexistent/toolkit-http/cert.pem",
                "/nonexistent/toolkit-http/key.pem",
            )),
            ..TlsConfig::default()
        };
        let err =
            webpki_roots_client_config(&tls).expect_err("missing client-auth files must error");
        let msg = err.to_string();
        assert!(
            msg.contains("client certificate chain") && msg.contains("cert.pem"),
            "error should name the missing cert path, got: {msg}"
        );
    }

    /// Generate a self-signed ECDSA P-256 / SHA-256 identity and write it to a
    /// fresh temp directory as PEM. Returns the [`tempfile::TempDir`] (which the
    /// caller must keep alive — dropping it removes the files, even on panic)
    /// and a [`ClientAuthConfig`] pointing at the written cert-chain and key.
    ///
    /// ECDSA P-256 / SHA-256 is an explicitly FIPS-approved signature class, so
    /// the same identity drives both the non-FIPS and FIPS code paths.
    fn write_self_signed_identity() -> (tempfile::TempDir, ClientAuthConfig) {
        use std::io::Write;

        let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
            .expect("generate ECDSA P-256 key pair");
        let cert = rcgen::CertificateParams::new(vec!["client.test".to_owned()])
            .expect("certificate params")
            .self_signed(&key_pair)
            .expect("self-sign certificate");

        let dir = tempfile::tempdir().expect("create temp dir");
        let cert_path = dir.path().join("client_cert.pem");
        let key_path = dir.path().join("client_key.pem");

        std::fs::File::create(&cert_path)
            .and_then(|mut f| f.write_all(cert.pem().as_bytes()))
            .expect("write cert pem");
        std::fs::File::create(&key_path)
            .and_then(|mut f| f.write_all(key_pair.serialize_pem().as_bytes()))
            .expect("write key pem");

        (dir, ClientAuthConfig::new(cert_path, key_path))
    }

    /// Happy-path mutual TLS: a freshly generated self-signed cert + key load
    /// from PEM files and produce a `ClientConfig` carrying a client-auth
    /// resolver. Gated off under `fips`; the FIPS path is pinned separately by
    /// `client_auth_under_fips_fails_closed`.
    #[test]
    #[cfg(not(feature = "fips"))]
    fn client_auth_pem_round_trips() {
        // `_dir` keeps the temp directory (and its PEM files) alive for the
        // duration of the test; it is removed on drop, even if an assert panics.
        let (_dir, client_auth) = write_self_signed_identity();
        let tls = TlsConfig {
            client_auth: Some(client_auth),
            ..TlsConfig::default()
        };

        let result = webpki_roots_client_config(&tls);
        assert!(
            result.is_ok(),
            "client-auth config from valid PEM must build: {:?}",
            result.err()
        );
    }

    /// Mutual TLS under `--features fips` must fail *closed*, never panic. Both
    /// outcomes below are FIPS-correct and which one occurs is provider-
    /// dependent (macOS corecrypto / Windows CNG / Linux AWS-LC):
    ///   * `Ok(cfg)` with `cfg.fips() == true` — the active provider witnessed
    ///     the ECDSA P-256 client-auth signer as FIPS-approved; or
    ///   * `Err(TlsConfigError::FipsHardeningFailed(_))` — `apply_fips_hardening`
    ///     rejected a config it could not prove `ClientConfig::fips()` for.
    ///
    /// Any other result (panic, a different error variant, or `Ok` with
    /// `fips() == false`) is a regression in the FIPS mTLS path.
    #[test]
    #[cfg(feature = "fips")]
    fn client_auth_under_fips_fails_closed() {
        let (_dir, client_auth) = write_self_signed_identity();
        let tls = TlsConfig {
            client_auth: Some(client_auth),
            ..TlsConfig::default()
        };

        match webpki_roots_client_config(&tls) {
            Ok(cfg) => assert!(
                cfg.fips(),
                "fips build returned Ok but ClientConfig::fips() == false: \
                 an mTLS config slipped past the FIPS witness"
            ),
            // Acceptable: failed closed because fips() could not be proven.
            Err(TlsConfigError::FipsHardeningFailed(_)) => {}
            Err(other) => {
                panic!("unexpected non-fail-closed error on fips mTLS path: {other}")
            }
        }
    }
}