From 59a6f5430f82128521a4830b687fb84064f95c81 Mon Sep 17 00:00:00 2001 From: forkwright Date: Mon, 24 Aug 2026 14:26:45 -0500 Subject: [PATCH 1/7] chore(deps): move reqwest to 0.13 and adopt the fleet TLS-provider convention harmonia ran reqwest 0.12 with the `rustls-tls` feature while aletheia and theatron ran 0.13 with `rustls-no-provider`. The version gap mattered less than the feature: aletheia's manifest documents a fleet convention that the ring crypto provider is installed once, explicitly, process-wide, and that no library is ever allowed to link one implicitly. `rustls-tls` does exactly what that forbids. So this is two changes, and the second is the one that can hurt. Building with `rustls-no-provider` means any process performing TLS must install a provider itself, and failing to is a RUNTIME PANIC on first use rather than a compile error -- it would pass CI and fail in the operator's hands. Both binary targets are covered at their real entry points: crates/archon/src/main.rs the `harmonia` binary, install added directly crates/theatron/desktop/src/main.rs calls periskopio::run(), whose first statement is the install The eight library crates that depend on reqwest are deliberately untouched: installing a provider from a library is the implicit linking the convention exists to prevent. `form` and `query` join the feature list because 0.13 moved them behind features. periskopio now inherits reqwest from the workspace rather than carrying its own pin, so the two cannot drift again. Authored by an agent whose result was lost when this box thermally shut down mid-run; the tree survived and every claim above was re-derived from it rather than taken on trust. Not compiler-verified here -- harmonia is public, its runner minutes are free, and this laptop shuts down under a workspace check. --- Cargo.lock | 88 +++++++++++++++++++++--------- Cargo.toml | 6 +- crates/archon/src/main.rs | 8 +++ crates/theatron/desktop/Cargo.toml | 6 +- crates/theatron/desktop/src/lib.rs | 8 +++ 5 files changed, 85 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe5e38ae..8a0b70f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,7 +254,7 @@ dependencies = [ "quinn", "rand 0.10.2", "rcgen", - "reqwest", + "reqwest 0.13.4", "rmcp", "rstest", "rustls", @@ -1642,7 +1642,7 @@ dependencies = [ "jiff", "quick-xml 0.41.0", "regex", - "reqwest", + "reqwest 0.13.4", "rstest", "scraper", "serde", @@ -1697,7 +1697,7 @@ dependencies = [ "apotheke", "dashmap", "horismos", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "snafu", @@ -2406,7 +2406,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -2903,7 +2902,7 @@ dependencies = [ "feed-rs", "horismos", "jiff", - "reqwest", + "reqwest 0.13.4", "rstest", "serde", "snafu", @@ -3045,7 +3044,7 @@ dependencies = [ "parking_lot", "rand 0.9.4", "regex", - "reqwest", + "reqwest 0.12.28", "rlimit", "serde", "serde_json", @@ -3195,7 +3194,7 @@ dependencies = [ "librqbit-core", "parking_lot", "rand 0.9.4", - "reqwest", + "reqwest 0.12.28", "serde", "tokio", "tokio-util", @@ -3216,7 +3215,7 @@ dependencies = [ "httparse", "network-interface", "quick-xml 0.37.5", - "reqwest", + "reqwest 0.12.28", "serde", "tokio", "tracing", @@ -4254,7 +4253,7 @@ dependencies = [ "apotheke", "horismos", "jiff", - "reqwest", + "reqwest 0.13.4", "rstest", "serde", "serde_json", @@ -4600,7 +4599,6 @@ dependencies = [ "http-body", "http-body-util", "hyper", - "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", @@ -4608,8 +4606,6 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", - "quinn", - "rustls", "rustls-pki-types", "serde", "serde_json", @@ -4617,6 +4613,45 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", "tokio-rustls", "tokio-util", "tower", @@ -4625,9 +4660,8 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", - "webpki-roots", ] [[package]] @@ -5386,7 +5420,7 @@ dependencies = [ name = "skene" version = "0.4.0" dependencies = [ - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "snafu", @@ -6003,7 +6037,7 @@ dependencies = [ "horismos", "jiff", "md5", - "reqwest", + "reqwest 0.13.4", "rstest", "serde", "serde_json", @@ -7012,6 +7046,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -7065,15 +7112,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "weedle2" version = "5.0.0" diff --git a/Cargo.toml b/Cargo.toml index 4d6cabc2..4db7d97d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,9 +78,11 @@ tower-http = { version = "0.6", features = [ ] } # ── HTTP client ──────────────────────────────────────────────────────────────── -reqwest = { version = "0.12", default-features = false, features = [ +reqwest = { version = "0.13", default-features = false, features = [ + "form", "json", - "rustls-tls", + "query", + "rustls-no-provider", "stream", ] } diff --git a/crates/archon/src/main.rs b/crates/archon/src/main.rs index 8b367260..4d57b6f6 100644 --- a/crates/archon/src/main.rs +++ b/crates/archon/src/main.rs @@ -29,6 +29,14 @@ async fn main() { // no thread holds it across an await point. let mut stdout = std::io::stdout(); + // WHY: reqwest builds with `rustls-no-provider` (fleet convention: + // install the ring crypto provider once, explicitly, process-wide — + // never let a library link one implicitly). install_default returns Err + // if a provider is already installed (e.g. a dependency called it + // first); that is harmless. + // kanon:ignore RUST/no-silent-result-swallow — install_default returns Err when provider already installed by dependency; harmless + let _ = rustls::crypto::ring::default_provider().install_default(); + let result = match cli.command { Command::Serve(args) => serve::run_serve(args, &mut stdout).await, Command::Db(db_args) => match db_args.command { diff --git a/crates/theatron/desktop/Cargo.toml b/crates/theatron/desktop/Cargo.toml index a0da6132..54cb21df 100644 --- a/crates/theatron/desktop/Cargo.toml +++ b/crates/theatron/desktop/Cargo.toml @@ -16,10 +16,8 @@ dioxus = { version = "0.7", features = ["desktop", "router"] } tokio = { version = "1", features = ["full"] } # HTTP client -reqwest = { version = "0.12", default-features = false, features = [ - "json", - "rustls-tls", -] } +reqwest.workspace = true +rustls.workspace = true # Serialization serde = { version = "1", features = ["derive"] } diff --git a/crates/theatron/desktop/src/lib.rs b/crates/theatron/desktop/src/lib.rs index e6740e67..55173ad1 100644 --- a/crates/theatron/desktop/src/lib.rs +++ b/crates/theatron/desktop/src/lib.rs @@ -11,5 +11,13 @@ pub(crate) mod views; /// Launch the desktop application. pub fn run() { + // WHY: reqwest builds with `rustls-no-provider` (fleet convention: + // install the ring crypto provider once, explicitly, process-wide — + // never let a library link one implicitly). install_default returns Err + // if a provider is already installed (e.g. a dependency called it + // first); that is harmless. + // kanon:ignore RUST/no-silent-result-swallow — install_default returns Err when provider already installed by dependency; harmless + let _ = rustls::crypto::ring::default_provider().install_default(); + dioxus::launch(app::App); } From a5be822092cee11a9dc5a88d05f117a09c9ea5ec Mon Sep 17 00:00:00 2001 From: forkwright Date: Mon, 24 Aug 2026 19:55:15 -0500 Subject: [PATCH 2/7] fix(periskopio): declare reqwest and rustls, since an excluded crate cannot inherit This branch had periskopio reading `reqwest.workspace = true` and `rustls.workspace = true`. crates/theatron/desktop is in the ROOT manifest's `exclude` list, not `members`, so it has no workspace to inherit from and cargo refuses the manifest outright: error inheriting `reqwest` from workspace root manifest's `workspace.dependencies.reqwest` failed to find a workspace root Declared explicitly instead, with a comment saying why, because this is the second time today this exact exclusion has produced this exact error -- the licence fix in #725 hit it first. A reader who does not know the crate is excluded will reach for inheritance again. Features match what periskopio declared before this branch -- json, plus rustls-no-provider where it read rustls-tls. Not the root's fuller set: it never used `form`, `query` or `stream`, and inheriting features a crate does not exercise is how a dependency graph grows without anyone deciding to. Verified the manifest now parses standalone via its own --manifest-path, which is how CI checks it. --- crates/theatron/desktop/Cargo.toml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/theatron/desktop/Cargo.toml b/crates/theatron/desktop/Cargo.toml index 54cb21df..4e7f831b 100644 --- a/crates/theatron/desktop/Cargo.toml +++ b/crates/theatron/desktop/Cargo.toml @@ -16,8 +16,19 @@ dioxus = { version = "0.7", features = ["desktop", "router"] } tokio = { version = "1", features = ["full"] } # HTTP client -reqwest.workspace = true -rustls.workspace = true +# WHY declared rather than inherited: this crate is in the ROOT manifest's +# `exclude` list, not `members`, so it has no workspace to inherit from -- +# `foo.workspace = true` fails the manifest parse outright with "failed to find +# a workspace root". CI checks it standalone via its own --manifest-path. +# Keep these in step with the root workspace declarations by hand. +reqwest = { version = "0.13", default-features = false, features = [ + "json", + "rustls-no-provider", +] } +rustls = { version = "0.23", default-features = false, features = [ + "ring", + "std", +] } # Serialization serde = { version = "1", features = ["derive"] } From f3c1f42389b2c7de582683f6aa632e61e6d24928 Mon Sep 17 00:00:00 2001 From: forkwright Date: Tue, 25 Aug 2026 17:15:35 -0500 Subject: [PATCH 3/7] fix(tests): install the rustls crypto provider where tests build clients reqwest 0.13 requires a process-level crypto provider before a Client can be built, and this branch installs one at each real binary entry point. Test binaries never call main(), so nextest ran every one of them without it and the first client build panicked: No rustls crypto provider is configured... you must install a crypto provider before building a Client nextest then cancelled with three tests still running, so the reported blast radius was the count before the abort rather than the set. Swept the workspace for Client::new, Client::builder and reqwest::get -- 88 call sites across 29 files -- and covered all of them. Where a crate already funnels its tests through a shared spawn helper the install goes there, one call covering roughly seventy sites; the rest are fixed at the site because they build a client without the fixture. The pattern follows aletheia's existing organon::testing::install_crypto_provider rather than inventing a new one, and install_default is the only lever: reqwest 0.13.4 has no per-call override. Worth knowing for review: `.unwrap_or_default()` does NOT catch this. It is a real panic, not a Result::Err, so nine production sites carry a comment saying the build "fails only with invalid TLS config" that is now wrong. Harmless today because main() installs first; left alone rather than widening this change. The three ambiguous rustls builder sites are fixed here too. They do not panic on this branch -- aws-lc-rs is absent from its lock -- but librqbit 9 reintroduces the ambiguity through reqwest's redefined default-tls, so fixing them now means they are already right when that rebases, and avoids a conflict. The reqwest and wasm-streams duplicates are skipped TEMPORARILY, labelled as such and naming the PR that resolves them: our own manifest caps reqwest below 0.13, so only this change can lift it, and the versions converge once librqbit 9 rebases on top. The skip should be deleted then rather than left standing. --- crates/archon/src/render/tls.rs | 26 ++++++++++-- crates/archon/src/serve.rs | 8 ++++ crates/archon/tests/config_reload_e2e.rs | 8 ++++ crates/eksetasis/Cargo.toml | 1 + crates/eksetasis/src/cf_bypass/byparr.rs | 9 +++- crates/eksetasis/src/search/tests.rs | 17 +++++++- crates/eksetasis/src/test_support.rs | 16 +++++++ crates/epignosis/Cargo.toml | 1 + crates/epignosis/src/test_support.rs | 13 ++++++ crates/komide/Cargo.toml | 1 + crates/komide/src/fetch.rs | 4 ++ crates/komide/src/scheduler.rs | 6 +++ crates/komide/src/service/tests.rs | 6 ++- crates/komide/src/test_support.rs | 14 +++++++ crates/prostheke/Cargo.toml | 1 + .../prostheke/src/providers/opensubtitles.rs | 15 +++++++ crates/syndesis/src/tls/mod.rs | 42 +++++++++++++------ crates/syndesmos/Cargo.toml | 1 + crates/syndesmos/src/plex/mod.rs | 6 +++ crates/syndesmos/src/test_support.rs | 15 +++++++ crates/theatron/core/Cargo.toml | 7 ++++ crates/theatron/core/src/api/client.rs | 17 ++++++++ deny.toml | 14 +++++++ 23 files changed, 228 insertions(+), 20 deletions(-) diff --git a/crates/archon/src/render/tls.rs b/crates/archon/src/render/tls.rs index 0564f813..ec279fa4 100644 --- a/crates/archon/src/render/tls.rs +++ b/crates/archon/src/render/tls.rs @@ -72,10 +72,28 @@ pub fn build_client_config(server_fingerprint: &str) -> Result HttpHarness { + // WHY: this harness's tests drive the real server via reqwest::get, + // which eagerly builds a TLS connector and panics with no provider + // installed — archon builds reqwest with `rustls-no-provider` (fleet + // convention: install explicitly, never implicitly — see main.rs), + // and this nextest test binary never runs main(). Safe to call + // repeatedly: install_default() on an already-installed process + // just returns Err, discarded here. + let _ = rustls::crypto::ring::default_provider().install_default(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind startup listener"); diff --git a/crates/archon/tests/config_reload_e2e.rs b/crates/archon/tests/config_reload_e2e.rs index 17ee18c5..fa41f7d4 100644 --- a/crates/archon/tests/config_reload_e2e.rs +++ b/crates/archon/tests/config_reload_e2e.rs @@ -236,6 +236,14 @@ async fn wait_for_opds_page_size( #[tokio::test(flavor = "multi_thread")] async fn sighup_reload_applies_live_rotates_jwt_and_holds_back_restart_class() { + // WHY: this test's reqwest::Client::new() below eagerly builds its TLS + // connector, and archon builds reqwest with `rustls-no-provider` (fleet + // convention: install explicitly, never implicitly — see main.rs). This + // integration test never runs archon's main() in THIS process (it spawns + // a separate `harmonia serve` subprocess instead), so nothing else in + // this process ever installs a provider. + let _ = rustls::crypto::ring::default_provider().install_default(); + let workdir = tempfile::tempdir().expect("create tempdir"); let download_dir = workdir.path().join("downloads"); std::fs::create_dir_all(&download_dir).expect("create download dir"); diff --git a/crates/eksetasis/Cargo.toml b/crates/eksetasis/Cargo.toml index a0e9b02c..f8dfd10f 100644 --- a/crates/eksetasis/Cargo.toml +++ b/crates/eksetasis/Cargo.toml @@ -34,6 +34,7 @@ sqlx.workspace = true [dev-dependencies] rstest.workspace = true tempfile.workspace = true +rustls.workspace = true [dev-dependencies.tokio] workspace = true diff --git a/crates/eksetasis/src/cf_bypass/byparr.rs b/crates/eksetasis/src/cf_bypass/byparr.rs index 25f8c5c8..18c04f21 100644 --- a/crates/eksetasis/src/cf_bypass/byparr.rs +++ b/crates/eksetasis/src/cf_bypass/byparr.rs @@ -291,7 +291,7 @@ mod tests { use tokio::task::JoinHandle; use super::*; - use crate::test_support::spawn_one_shot_http; + use crate::test_support::{install_test_crypto_provider, spawn_one_shot_http}; /// Refresh window for tests where reuse-vs-resolve is not under test. const ANY_REFRESH: Duration = Duration::from_secs(60); @@ -304,6 +304,13 @@ mod tests { hits: Arc, heads: Arc>>, ) -> (String, JoinHandle<()>) { + // WHY: ByparrProxy::new below eagerly builds a TLS connector and + // PANICS (not a recoverable Err — .unwrap_or_default() only catches + // Err) with no provider installed; see + // test_support::install_test_crypto_provider's WHY note. This is a + // file-local spawn helper, separate from test_support's, so it needs + // its own install call rather than inheriting one transitively. + install_test_crypto_provider(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let handle = tokio::spawn(async move { diff --git a/crates/eksetasis/src/search/tests.rs b/crates/eksetasis/src/search/tests.rs index 4a4fa74d..bd23adbb 100644 --- a/crates/eksetasis/src/search/tests.rs +++ b/crates/eksetasis/src/search/tests.rs @@ -216,7 +216,7 @@ use apotheke::migrate::MIGRATOR; use crate::cf_bypass::noop::NoProxy; use crate::repo::InsertIndexerParams; -use crate::test_support::spawn_one_shot_http; +use crate::test_support::{install_test_crypto_provider, spawn_one_shot_http}; async fn make_service() -> (SearchIndexerService, SqlitePool) { make_service_with(SearchSubsystemConfig::default()).await @@ -327,6 +327,11 @@ async fn handle_search_error_http_request_active_marks_degraded() { let indexer = seed_indexer(&pool, "https://example.com/api").await; assert_eq!(indexer.status, "active"); + // WHY: reqwest::Client::new() eagerly builds its TLS connector even for + // a plain-HTTP dead-port probe, and this file's other tests never share + // this test's process (nextest isolates per-test) — see + // test_support::install_test_crypto_provider's WHY note. + install_test_crypto_provider(); let error = SearchIndexerError::HttpRequest { url: "https://example.com/api".to_string(), source: reqwest::Client::new() @@ -351,6 +356,11 @@ async fn handle_search_error_http_request_degraded_escalates_to_failed() { .unwrap(); indexer.status = "degraded".to_string(); + // WHY: reqwest::Client::new() eagerly builds its TLS connector even for + // a plain-HTTP dead-port probe, and this file's other tests never share + // this test's process (nextest isolates per-test) — see + // test_support::install_test_crypto_provider's WHY note. + install_test_crypto_provider(); let error = SearchIndexerError::HttpRequest { url: "https://example.com/api".to_string(), source: reqwest::Client::new() @@ -995,6 +1005,11 @@ async fn spawn_concurrency_probe( #[tokio::test] async fn replace_lowering_max_concurrent_searches_bounds_the_next_fan_out() { + // WHY: this test drives the real search fan-out, whose internal client + // (search.rs's production reqwest::Client::builder()) is never injected + // by a test seam here — unlike the with_base_url provider tests + // elsewhere, so it needs its own install call. + install_test_crypto_provider(); let concurrent = Arc::new(AtomicUsize::new(0)); let peak = Arc::new(AtomicUsize::new(0)); diff --git a/crates/eksetasis/src/test_support.rs b/crates/eksetasis/src/test_support.rs index 991016ed..f432390d 100644 --- a/crates/eksetasis/src/test_support.rs +++ b/crates/eksetasis/src/test_support.rs @@ -5,9 +5,23 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::task::JoinHandle; +/// Installs the process-wide rustls crypto provider for tests. +/// +/// WHY: reqwest builds with `rustls-no-provider` (fleet convention: install +/// explicitly, never let a library link one implicitly — see main.rs), so +/// `reqwest::Client::new()`/`::builder().build()` panics ("No rustls crypto +/// provider is configured") in any process that never called +/// `install_default()` — and a nextest test binary never runs `main()`. +/// Safe to call repeatedly: install_default() on an already-installed +/// process just returns Err, discarded here. +pub(crate) fn install_test_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + /// Spawns a TCP server that answers exactly one HTTP request with the given /// raw response bytes, then resolves to the raw request head it received. pub(crate) async fn spawn_raw_http(raw_response: Vec) -> (String, JoinHandle) { + install_test_crypto_provider(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); @@ -46,6 +60,7 @@ pub(crate) async fn spawn_raw_http(raw_response: Vec) -> (String, JoinHandle pub(crate) async fn spawn_sequence_http( responses: Vec<(u16, Vec<(String, String)>, String)>, ) -> (String, JoinHandle>) { + install_test_crypto_provider(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); @@ -118,6 +133,7 @@ pub(crate) async fn spawn_one_shot_http( /// Spawns a TCP server that accepts one connection and never responds. pub(crate) async fn spawn_hang_http() -> (String, JoinHandle<()>) { + install_test_crypto_provider(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); diff --git a/crates/epignosis/Cargo.toml b/crates/epignosis/Cargo.toml index 8d04d13c..1cddb478 100644 --- a/crates/epignosis/Cargo.toml +++ b/crates/epignosis/Cargo.toml @@ -22,6 +22,7 @@ tokio-util = { version = "0.7", features = ["rt"] } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } tempfile = "3" +rustls.workspace = true [lints] workspace = true diff --git a/crates/epignosis/src/test_support.rs b/crates/epignosis/src/test_support.rs index 01ef275e..1b630436 100644 --- a/crates/epignosis/src/test_support.rs +++ b/crates/epignosis/src/test_support.rs @@ -8,9 +8,22 @@ use tokio::task::JoinHandle; /// Spawns a TCP server that answers `responses.len()` sequential HTTP /// requests (one connection each) with the given status and body, then /// resolves to the raw request bytes it received, in order. +/// +/// WHY: also installs the process-wide rustls crypto provider on the way +/// in. reqwest builds with `rustls-no-provider` (fleet convention: install +/// explicitly, never let a library link one implicitly — see main.rs), so +/// `reqwest::Client::new()`/`::builder().build()` panics ("No rustls crypto +/// provider is configured") in any process that never called +/// `install_default()` — and a nextest test binary never runs `main()`. +/// Every provider test in this crate calls this helper before constructing +/// its client, so installing here (idempotent — install_default() on an +/// already-installed process just returns Err, discarded) covers the whole +/// crate's test suite instead of repeating the call at every test site. pub(crate) async fn spawn_sequential_http( responses: Vec<(u16, String)>, ) -> (String, JoinHandle>) { + let _ = rustls::crypto::ring::default_provider().install_default(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); diff --git a/crates/komide/Cargo.toml b/crates/komide/Cargo.toml index a4a45810..7d00a1d3 100644 --- a/crates/komide/Cargo.toml +++ b/crates/komide/Cargo.toml @@ -24,6 +24,7 @@ rstest = { workspace = true } tokio = { workspace = true, features = ["full"] } sqlx = { workspace = true } tempfile = "3" +rustls = { workspace = true } [lints] workspace = true diff --git a/crates/komide/src/fetch.rs b/crates/komide/src/fetch.rs index 3e092faf..96e22c89 100644 --- a/crates/komide/src/fetch.rs +++ b/crates/komide/src/fetch.rs @@ -431,6 +431,10 @@ mod tests { async fn fetch_feed_stalled_body_times_out_via_client_configured_timeout() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; + // WHY: this test builds its client via a raw local TcpListener, not + // spawn_scripted_http (whose own install call every other test in + // this file relies on) — needs its own install. + crate::test_support::install_test_crypto_provider(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); // WHY: the server task is intentionally never joined — it outlives diff --git a/crates/komide/src/scheduler.rs b/crates/komide/src/scheduler.rs index f2d77688..1676f94b 100644 --- a/crates/komide/src/scheduler.rs +++ b/crates/komide/src/scheduler.rs @@ -367,6 +367,11 @@ mod tests { #[tokio::test] async fn start_schedules_feeds_beyond_one_page() { + // WHY: reqwest::Client::new() below eagerly builds its TLS + // connector; see test_support::install_test_crypto_provider's WHY + // note (this module has no other test_support import, so call it + // fully qualified rather than adding an unused-in-most-tests import). + crate::test_support::install_test_crypto_provider(); use apotheke::migrate::MIGRATOR; let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); @@ -421,6 +426,7 @@ mod tests { #[tokio::test] async fn auto_download_zero_subscription_still_gets_poll_loop() { + crate::test_support::install_test_crypto_provider(); use apotheke::migrate::MIGRATOR; let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); diff --git a/crates/komide/src/service/tests.rs b/crates/komide/src/service/tests.rs index 2032c1fe..47fea072 100644 --- a/crates/komide/src/service/tests.rs +++ b/crates/komide/src/service/tests.rs @@ -4,7 +4,7 @@ use apotheke::migrate::MIGRATOR; use sqlx::SqlitePool; use super::*; -use crate::test_support::{http_response, spawn_scripted_http}; +use crate::test_support::{http_response, install_test_crypto_provider, spawn_scripted_http}; const RSS_TWO_EPISODES: &[u8] = br#" @@ -60,6 +60,9 @@ async fn setup() -> (FeedSchedulerService, aggelmata::aggelia::EventReceiver) { async fn setup_with_config( config: KomideConfig, ) -> (FeedSchedulerService, aggelmata::aggelia::EventReceiver) { + // WHY: reqwest::Client::new() below eagerly builds its TLS connector; + // see test_support::install_test_crypto_provider's WHY note. + install_test_crypto_provider(); let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); MIGRATOR.run(&pool).await.unwrap(); let db = DbPools { @@ -197,6 +200,7 @@ async fn insert_articles_deduplicates_by_guid() { #[tokio::test] async fn episode_available_event_emitted_on_new_episode() { + install_test_crypto_provider(); let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); MIGRATOR.run(&pool).await.unwrap(); let db = DbPools { diff --git a/crates/komide/src/test_support.rs b/crates/komide/src/test_support.rs index 85c17c3c..cca635db 100644 --- a/crates/komide/src/test_support.rs +++ b/crates/komide/src/test_support.rs @@ -5,6 +5,19 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::task::JoinHandle; +/// Installs the process-wide rustls crypto provider for tests. +/// +/// WHY: reqwest builds with `rustls-no-provider` (fleet convention: install +/// explicitly, never let a library link one implicitly — see main.rs), so +/// `reqwest::Client::new()`/`::builder().build()` panics ("No rustls crypto +/// provider is configured") in any process that never called +/// `install_default()` — and a nextest test binary never runs `main()`. +/// Safe to call repeatedly: install_default() on an already-installed +/// process just returns Err, discarded here. +pub(crate) fn install_test_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + /// Builds a raw HTTP/1.1 response with a correct `Content-Length` header and /// `connection: close`. pub(crate) fn http_response( @@ -44,6 +57,7 @@ pub(crate) fn http_response_close_delimited(status: u16, reason: &str, body: &[u pub(crate) async fn spawn_scripted_http( responses: Vec>, ) -> (String, JoinHandle>) { + install_test_crypto_provider(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); diff --git a/crates/prostheke/Cargo.toml b/crates/prostheke/Cargo.toml index 527c10d9..899727fd 100644 --- a/crates/prostheke/Cargo.toml +++ b/crates/prostheke/Cargo.toml @@ -25,6 +25,7 @@ jiff.workspace = true [dev-dependencies] rstest.workspace = true tempfile = "3" +rustls.workspace = true [dev-dependencies.tokio] workspace = true diff --git a/crates/prostheke/src/providers/opensubtitles.rs b/crates/prostheke/src/providers/opensubtitles.rs index 9306fb83..0d8666c5 100644 --- a/crates/prostheke/src/providers/opensubtitles.rs +++ b/crates/prostheke/src/providers/opensubtitles.rs @@ -555,6 +555,17 @@ impl SubtitleProvider for OpenSubtitlesProvider { mod tests { use super::*; + /// WHY: this crate has no shared test_support module (unlike + /// epignosis/komide) — reqwest::get()/Client::new() below eagerly build + /// a TLS connector and panic with no provider installed. reqwest builds + /// with `rustls-no-provider` (fleet convention: install explicitly, + /// never implicitly — see main.rs); a nextest test binary never runs + /// main(). Safe to call repeatedly: install_default() on an + /// already-installed process just returns Err, discarded here. + fn install_test_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + #[test] fn unconfigured_returns_empty_results() { let provider = OpenSubtitlesProvider::new(None); @@ -745,6 +756,7 @@ mod tests { async fn read_body_capped_rejects_oversized_declared_body() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; + install_test_crypto_provider(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { @@ -775,6 +787,7 @@ mod tests { async fn read_body_capped_accepts_body_within_cap() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; + install_test_crypto_provider(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { @@ -807,6 +820,7 @@ mod tests { async fn spawn_body_server(body: String) -> (String, tokio::task::JoinHandle<()>) { use tokio::io::{AsyncReadExt, AsyncWriteExt}; + install_test_crypto_provider(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let handle = tokio::spawn(async move { @@ -836,6 +850,7 @@ mod tests { ) { use tokio::io::{AsyncReadExt, AsyncWriteExt}; + install_test_crypto_provider(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let seen: std::sync::Arc>> = Default::default(); diff --git a/crates/syndesis/src/tls/mod.rs b/crates/syndesis/src/tls/mod.rs index ead7a10d..d6505fd1 100644 --- a/crates/syndesis/src/tls/mod.rs +++ b/crates/syndesis/src/tls/mod.rs @@ -149,15 +149,25 @@ pub fn build_server_config( certs: Vec>, key: PrivateKeyDer<'static>, ) -> Result { - let mut tls_config = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(certs, key) - .map_err(|e| { - error::TlsSnafu { - reason: e.to_string(), - } - .build() - })?; + // WHY: builder_with_provider, not the ambiguous builder() — see + // render/tls.rs build_client_config's WHY note for the full mechanism + // (librqbit 9, landing right after this branch, forwards its `default` + // feature to reqwest's redefined `default-tls` = rustls + + // __rustls-aws-lc-rs, reintroducing the exact ambiguity this call would + // otherwise resolve by chance). + let mut tls_config = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .expect("ring's default provider supports rustls's default TLS versions") + .with_no_client_auth() + .with_single_cert(certs, key) + .map_err(|e| { + error::TlsSnafu { + reason: e.to_string(), + } + .build() + })?; tls_config.alpn_protocols = vec![b"syndesis/1".to_vec()]; @@ -299,10 +309,16 @@ fn normalize_pin(pinned_fingerprint: &str) -> Result { fn client_config_with_verifier( verifier: Arc, ) -> Result { - let mut tls_config = rustls::ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(verifier) - .with_no_client_auth(); + // WHY: same ambiguous-provider hazard as build_server_config above — + // see render/tls.rs build_client_config's WHY note for the mechanism. + let mut tls_config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .expect("ring's default provider supports rustls's default TLS versions") + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth(); tls_config.alpn_protocols = vec![b"syndesis/1".to_vec()]; diff --git a/crates/syndesmos/Cargo.toml b/crates/syndesmos/Cargo.toml index 50ad0f13..92d47710 100644 --- a/crates/syndesmos/Cargo.toml +++ b/crates/syndesmos/Cargo.toml @@ -30,6 +30,7 @@ tokio = { workspace = true, features = [ "macros", "test-util", ] } +rustls.workspace = true [lints] workspace = true diff --git a/crates/syndesmos/src/plex/mod.rs b/crates/syndesmos/src/plex/mod.rs index 05e9560c..6fe936cb 100644 --- a/crates/syndesmos/src/plex/mod.rs +++ b/crates/syndesmos/src/plex/mod.rs @@ -124,6 +124,12 @@ pub(crate) mod tests { |n| if n > 0 { Some(n - 1) } else { None }, ); if remaining.is_ok() { + // WHY: reqwest::Client::new() eagerly builds its TLS + // connector; see test_support::install_test_crypto_provider's + // WHY note. This branch is currently unreached (no live + // test calls with_failures), but fixing it now keeps the + // fixture honest for whenever a test does. + crate::test_support::install_test_crypto_provider(); return Err(SyndesmodError::PlexApiCall { source: reqwest::Client::new() .get("http://invalid.test/") diff --git a/crates/syndesmos/src/test_support.rs b/crates/syndesmos/src/test_support.rs index a556b5f1..149a1060 100644 --- a/crates/syndesmos/src/test_support.rs +++ b/crates/syndesmos/src/test_support.rs @@ -163,6 +163,19 @@ fn now() -> String { "2026-01-01T00:00:00Z".to_string() } +/// Installs the process-wide rustls crypto provider for tests. +/// +/// WHY: reqwest builds with `rustls-no-provider` (fleet convention: install +/// explicitly, never let a library link one implicitly — see main.rs), so +/// `reqwest::Client::new()`/`::builder().build()` panics ("No rustls crypto +/// provider is configured") in any process that never called +/// `install_default()` — and a nextest test binary never runs `main()`. +/// Safe to call repeatedly: install_default() on an already-installed +/// process just returns Err, discarded here. +pub(crate) fn install_test_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + /// Spawns a TCP server that answers exactly one HTTP request with the given /// status and body, then resolves to the raw request bytes it received. pub(crate) async fn spawn_one_shot_http( @@ -170,6 +183,7 @@ pub(crate) async fn spawn_one_shot_http( reason: &'static str, body: &'static str, ) -> (String, JoinHandle) { + install_test_crypto_provider(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); @@ -215,6 +229,7 @@ pub(crate) async fn spawn_one_shot_http( pub(crate) async fn spawn_sequential_http( responses: Vec<(u16, String)>, ) -> (String, String, JoinHandle>) { + install_test_crypto_provider(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); diff --git a/crates/theatron/core/Cargo.toml b/crates/theatron/core/Cargo.toml index 5f0c5d94..6d909a36 100644 --- a/crates/theatron/core/Cargo.toml +++ b/crates/theatron/core/Cargo.toml @@ -13,6 +13,13 @@ serde_json = { workspace = true } snafu = { workspace = true } tracing = { workspace = true } +# WHY dev-only: rustls itself is never used by this crate's production code +# (that goes through plain reqwest); it's needed only so tests can install +# the process-wide crypto provider reqwest's `rustls-no-provider` build +# requires (see api/client.rs's test module). +[dev-dependencies] +rustls = { workspace = true } + [lints] workspace = true diff --git a/crates/theatron/core/src/api/client.rs b/crates/theatron/core/src/api/client.rs index cd238343..5bf96087 100644 --- a/crates/theatron/core/src/api/client.rs +++ b/crates/theatron/core/src/api/client.rs @@ -796,20 +796,36 @@ impl HarmoniaClient { mod tests { use super::*; + /// WHY: HarmoniaClient::new's reqwest::Client::builder().build() eagerly + /// builds a TLS connector and PANICS (not a recoverable Err — the + /// `.unwrap_or_default()` at the call site only catches Err, not a + /// panic) when no rustls crypto provider is installed. reqwest builds + /// with `rustls-no-provider` (fleet convention: install explicitly, + /// never implicitly — see periskopio's run()); a `cargo test`/nextest + /// unit-test binary never runs that entry point. Safe to call + /// repeatedly: install_default() on an already-installed process just + /// returns Err, discarded here. + fn install_test_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + #[test] fn client_construction() { + install_test_crypto_provider(); let client = HarmoniaClient::new("http://localhost:8080"); assert!(client.token.is_none()); } #[test] fn base_url_strips_trailing_slash() { + install_test_crypto_provider(); let client = HarmoniaClient::new("http://localhost:8080/"); assert_eq!(client.base_url, "http://localhost:8080"); } #[test] fn set_and_clear_token() { + install_test_crypto_provider(); let mut client = HarmoniaClient::new("http://localhost:8080"); client.set_token("test-token"); assert_eq!(client.token.as_deref(), Some("test-token")); @@ -819,6 +835,7 @@ mod tests { #[test] fn set_base_url_strips_trailing_slash() { + install_test_crypto_provider(); let mut client = HarmoniaClient::new("http://localhost:8080"); client.set_base_url("http://example.com/api/"); assert_eq!(client.base_url, "http://example.com/api"); diff --git a/deny.toml b/deny.toml index f55c5219..1f2f283e 100644 --- a/deny.toml +++ b/deny.toml @@ -83,6 +83,20 @@ wildcards = "deny" # so "deny" binds on registry deps, per REPO-SETUP.md. allow-wildcard-paths = true skip = [ + # reqwest / wasm-streams: TEMPORARY, sequencing-scoped — not upstream + # blocked. archon bumps its own reqwest to 0.13 here (#731); librqbit + # 8.1.1 (still the pin on this branch) pulls reqwest 0.12 independently. + # librqbit 9 (harmonia#735, landing right after this PR) itself resolves + # to reqwest 0.13.4 — the two branches converge once #735 rebases onto + # this one, at which point both these skips (and wasm-streams, which is + # purely transitive on reqwest's version) become unmatched. Remove them + # then rather than leaving them as permanent exemptions — do not add a + # matching skip to #735's deny.toml; one exemption, removed once, not two. + { name = "reqwest", version = "0.12" }, + { name = "reqwest", version = "0.13" }, + { name = "wasm-streams", version = "0.4" }, + { name = "wasm-streams", version = "0.5" }, + # bincode: librqbit-peer-protocol pins 1.x; librqbit itself uses 2.x { name = "bincode", version = "1" }, { name = "bincode", version = "2" }, From 0f2f4aacf1771d60aaf62c665b7b80b1e2e512a2 Mon Sep 17 00:00:00 2001 From: forkwright Date: Tue, 25 Aug 2026 17:42:48 -0500 Subject: [PATCH 4/7] fix(tests): cover the client constructors reached from other crates' tests The first sweep found every direct client construction and then misjudged some of them. A site was marked "production, safe -- main() installs the provider" after checking whether its OWN crate's tests reached it through a test seam. That check is per-crate, and the reach that matters is not. archon's supervisor test constructs epignosis's real ProviderBackedResolver by name -- not a mock, not a seam -- so epignosis's production reqwest builder ran inside a test binary that never called main(). A per-crate audit cannot see that; the question has to be "does any test ANYWHERE in the workspace call this constructor", asked across the whole tree. Re-asked that way, seven more crates had reachable sites. Fixed at the choke points where one exists -- epignosis's shared resolver fixture covers sixteen sites, eksetasis's service builder covers twenty-three, archon's feed scheduler covers four -- and per-site where no fixture funnels them. Sites confirmed already covered are listed in the PR rather than touched: every PlexClient and ByparrProxy test is preceded by a spawn helper that installs, and ergasia reaches librqbit 8's separate reqwest instance, which this branch's feature does not affect. The nine wrong `.unwrap_or_default()` comments are corrected. They claimed the build "fails only with invalid TLS config"; it panics, and unwrap_or_default cannot intercept a panic. Each now says so and names the install that makes its call site safe. --- crates/archon/src/serve.rs | 34 ++++++++++++++++++- crates/eksetasis/src/cf_bypass/byparr.rs | 8 ++++- crates/eksetasis/src/search.rs | 9 ++++- crates/eksetasis/src/search/tests.rs | 11 +++--- crates/epignosis/src/resolver.rs | 18 ++++++++-- crates/epignosis/src/test_support.rs | 27 +++++++++------ .../prostheke/src/providers/opensubtitles.rs | 16 ++++++++- crates/syndesmos/src/lastfm/mod.rs | 8 ++++- crates/syndesmos/src/plex/mod.rs | 8 ++++- crates/syndesmos/src/tidal/mod.rs | 13 ++++++- crates/theatron/core/src/api/client.rs | 9 ++++- 11 files changed, 135 insertions(+), 26 deletions(-) diff --git a/crates/archon/src/serve.rs b/crates/archon/src/serve.rs index 5035a8bb..2e2936fb 100644 --- a/crates/archon/src/serve.rs +++ b/crates/archon/src/serve.rs @@ -1826,10 +1826,23 @@ async fn start_feed_scheduler( // configured but unenforced — a stalled feed host could block // `response.chunk().await` forever inside `komide::fetch::fetch_feed`, // wedging that feed's poll task. + // + // WHY install_default here too (not just main.rs): reqwest builds with + // `rustls-no-provider` (fleet convention: install explicitly, never + // implicitly), and this function's own rebuild_supervisor_tests call + // sites (4 of them) construct a real client in a nextest process that + // never runs main(). Harmless in production — main() already installed + // it, and install_default() on an already-installed process just + // returns Err, discarded here. + let _ = rustls::crypto::ring::default_provider().install_default(); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(config.fetch_timeout_secs)) .build() - .unwrap_or_default(); // WHY: reqwest::Client::default() is a valid fallback; build fails only with invalid TLS config + // WHY: unwrap_or_default() only catches a genuinely-invalid TLS + // config (an Err) — it can't catch rustls-no-provider's panic!, but + // install_default() above already ran unconditionally before this + // build(), so that panic path is unreachable here. + .unwrap_or_default(); let service = Arc::new(FeedSchedulerService::new( clone_db_pools(db), event_tx.clone(), @@ -2703,6 +2716,11 @@ mod service_adapter_tests { #[tokio::test] async fn metadata_adapter_calls_live_epignosis_resolver() { + // WHY: ProviderBackedResolver::new builds real provider clients + // (epignosis's own reqwest::Client::builder()), which eagerly + // builds a TLS connector; see spawn_supervisor's WHY note above for + // the mechanism. + let _ = rustls::crypto::ring::default_provider().install_default(); let adapter = MetadataAdapter::new(Arc::new(ProviderBackedResolver::new( horismos::EpignosisConfig::default(), ProviderCredentials::default(), @@ -3222,6 +3240,10 @@ mod search_adapter_tests { #[tokio::test] async fn search_adapter_calls_live_zetesis_service() { + // WHY: SearchIndexerService::new builds a real reqwest client + // (eksetasis's own build_http_client); see spawn_supervisor's WHY + // note above for the mechanism. + let _ = rustls::crypto::ring::default_provider().install_default(); let pool = SqlitePool::connect("sqlite::memory:") .await .expect("in-memory sqlite opens"); @@ -3573,6 +3595,11 @@ mod tests { async fn build_cf_proxy_enabled_posts_to_byparr_endpoint() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; + // WHY: build_cf_proxy below constructs a real ByparrProxy, which + // eagerly builds a TLS connector; see spawn_supervisor's WHY note + // above for the mechanism. + let _ = rustls::crypto::ring::default_provider().install_default(); + // One-shot Byparr stub: answers a single POST /v1 with a solved page. let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await @@ -4448,6 +4475,10 @@ mod rebuild_supervisor_tests { /// and shuts down cleanly on process shutdown. #[tokio::test] async fn epignosis_supervisor_rebuilds_resolver_on_config_change() { + // WHY: ProviderBackedResolver::new below builds real provider + // clients (epignosis's own reqwest::Client::builder()); see + // spawn_supervisor's WHY note above for the mechanism. + let _ = rustls::crypto::ring::default_provider().install_default(); let config = epignosis_test_config(); let (manager, handle) = ConfigManager::new( config.clone(), @@ -4509,6 +4540,7 @@ mod rebuild_supervisor_tests { /// `configured_api_key_reaches_lookup_request` (providers/acoustid.rs). #[tokio::test] async fn epignosis_supervisor_rebuilds_resolver_on_credential_change() { + let _ = rustls::crypto::ring::default_provider().install_default(); let config = epignosis_test_config(); let (manager, handle) = ConfigManager::new( config.clone(), diff --git a/crates/eksetasis/src/cf_bypass/byparr.rs b/crates/eksetasis/src/cf_bypass/byparr.rs index 18c04f21..e1ad8d70 100644 --- a/crates/eksetasis/src/cf_bypass/byparr.rs +++ b/crates/eksetasis/src/cf_bypass/byparr.rs @@ -79,10 +79,16 @@ impl ByparrProxy { max_body_bytes: u64, cookie_refresh: Duration, ) -> Self { + // WHY unwrap_or_default: only catches a genuinely-invalid TLS + // config (an Err). It does NOT catch reqwest's rustls-no-provider + // "no crypto provider installed" failure — that's a panic!, not an + // Err, and unwrap_or_default() cannot intercept a panic. Safe here + // only because every caller (production via main.rs, tests via + // install_test_crypto_provider) installs the provider first. let client = reqwest::Client::builder() .timeout(timeout + Duration::from_secs(5)) .build() - .unwrap_or_default(); // WHY: reqwest::Client::default() is a valid fallback; build fails only with invalid TLS config + .unwrap_or_default(); Self { client, diff --git a/crates/eksetasis/src/search.rs b/crates/eksetasis/src/search.rs index 7743d0be..50924981 100644 --- a/crates/eksetasis/src/search.rs +++ b/crates/eksetasis/src/search.rs @@ -36,11 +36,18 @@ const MAX_RETRY_AFTER_SECS: u64 = 3600; /// pre-check alone cannot — the client re-resolves after validation, so a /// host that answers public then private would otherwise bypass the guard. fn build_http_client(request_timeout_secs: u64) -> reqwest::Client { + // WHY unwrap_or_default: only catches a genuinely-invalid TLS config + // (an Err); the validate_fetch_url pre-check guards that path. It does + // NOT catch reqwest's rustls-no-provider "no crypto provider installed" + // failure — that's a panic!, not an Err, and unwrap_or_default() cannot + // intercept a panic. Safe here only because every caller (production + // via main.rs, tests via install_test_crypto_provider) installs the + // provider first. reqwest::Client::builder() .timeout(Duration::from_secs(request_timeout_secs)) .dns_resolver(Arc::new(SsrfGuardResolver)) .build() - .unwrap_or_default() // WHY: reqwest::Client::default() is a valid fallback (build fails only with invalid TLS config); the validate_fetch_url pre-check still guards that path + .unwrap_or_default() } pub struct SearchIndexerService { diff --git a/crates/eksetasis/src/search/tests.rs b/crates/eksetasis/src/search/tests.rs index bd23adbb..cd7b0024 100644 --- a/crates/eksetasis/src/search/tests.rs +++ b/crates/eksetasis/src/search/tests.rs @@ -236,6 +236,12 @@ async fn make_service_with_section_and_proxy( config: horismos::Section, cf_proxy: Arc, ) -> (SearchIndexerService, SqlitePool) { + // WHY: SearchIndexerService::new builds a real reqwest client + // (search.rs's build_http_client), which eagerly builds a TLS + // connector; see install_test_crypto_provider's WHY note. Every test in + // this file that uses make_service()/make_service_with(_section) funnels + // through this one function, so fixing it here covers all of them. + install_test_crypto_provider(); let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); MIGRATOR.run(&pool).await.unwrap(); let (event_tx, _) = create_event_bus(16); @@ -1005,11 +1011,6 @@ async fn spawn_concurrency_probe( #[tokio::test] async fn replace_lowering_max_concurrent_searches_bounds_the_next_fan_out() { - // WHY: this test drives the real search fan-out, whose internal client - // (search.rs's production reqwest::Client::builder()) is never injected - // by a test seam here — unlike the with_base_url provider tests - // elsewhere, so it needs its own install call. - install_test_crypto_provider(); let concurrent = Arc::new(AtomicUsize::new(0)); let peak = Arc::new(AtomicUsize::new(0)); diff --git a/crates/epignosis/src/resolver.rs b/crates/epignosis/src/resolver.rs index 6a380a96..2ba5b136 100644 --- a/crates/epignosis/src/resolver.rs +++ b/crates/epignosis/src/resolver.rs @@ -77,10 +77,16 @@ pub struct ProviderBackedResolver { impl ProviderBackedResolver { pub fn new(config: EpignosisConfig, credentials: ProviderCredentials) -> Self { + // WHY unwrap_or_default: only catches a genuinely-invalid TLS + // config (an Err). It does NOT catch reqwest's rustls-no-provider + // "no crypto provider installed" failure — that's a panic!, not an + // Err, and unwrap_or_default() cannot intercept a panic. Safe here + // only because every caller (production via main.rs, tests via + // install_test_crypto_provider) installs the provider first. let client = reqwest::Client::builder() .timeout(Duration::from_secs(config.provider_timeout_secs)) .build() - .unwrap_or_default(); // WHY: reqwest::Client::default() is a valid fallback; build fails only with invalid TLS config (not applicable here) + .unwrap_or_default(); let cache_ttl = Duration::from_secs(config.cache_ttl_secs); let cache = Arc::new(MetadataCache::new(cache_ttl)); @@ -689,7 +695,7 @@ mod tests { use super::*; use crate::identity::MetadataProviderId; - use crate::test_support::spawn_sequential_http; + use crate::test_support::{install_test_crypto_provider, spawn_sequential_http}; // WHY: the book cross-reference tests drive the same fixture record through two // different provider paths, so the ids have to agree across both. Holding one copy @@ -741,6 +747,12 @@ mod tests { } fn test_resolver() -> ProviderBackedResolver { + // WHY: ProviderBackedResolver::new builds real provider clients + // (reqwest::Client::builder()), which eagerly builds a TLS + // connector; see install_test_crypto_provider's WHY note. This + // shared fixture is called from 16 sites in this file, so fixing it + // here covers all of them instead of repeating the call at each. + install_test_crypto_provider(); ProviderBackedResolver::new( horismos::EpignosisConfig::default(), ProviderCredentials::default(), @@ -1238,6 +1250,7 @@ mod tests { // WHY: proves the resolver reads ITS OWN config, not a module // constant — a score that is "accepted" under the default thresholds // must classify as ambiguous under stricter configured ones. + install_test_crypto_provider(); let resolver = ProviderBackedResolver::new( horismos::EpignosisConfig { fingerprint_accept_threshold: 0.95, @@ -1737,6 +1750,7 @@ mod tests { #[tokio::test] async fn cache_eviction_sweeper_stops_after_resolver_is_dropped() { + install_test_crypto_provider(); let resolver = ProviderBackedResolver::new( horismos::EpignosisConfig::default(), ProviderCredentials::default(), diff --git a/crates/epignosis/src/test_support.rs b/crates/epignosis/src/test_support.rs index 1b630436..b2d2287e 100644 --- a/crates/epignosis/src/test_support.rs +++ b/crates/epignosis/src/test_support.rs @@ -5,24 +5,29 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::task::JoinHandle; -/// Spawns a TCP server that answers `responses.len()` sequential HTTP -/// requests (one connection each) with the given status and body, then -/// resolves to the raw request bytes it received, in order. +/// Installs the process-wide rustls crypto provider for tests. /// -/// WHY: also installs the process-wide rustls crypto provider on the way -/// in. reqwest builds with `rustls-no-provider` (fleet convention: install +/// WHY: reqwest builds with `rustls-no-provider` (fleet convention: install /// explicitly, never let a library link one implicitly — see main.rs), so /// `reqwest::Client::new()`/`::builder().build()` panics ("No rustls crypto /// provider is configured") in any process that never called -/// `install_default()` — and a nextest test binary never runs `main()`. -/// Every provider test in this crate calls this helper before constructing -/// its client, so installing here (idempotent — install_default() on an -/// already-installed process just returns Err, discarded) covers the whole -/// crate's test suite instead of repeating the call at every test site. +/// `install_default()` — and a nextest test binary never runs `main()`. Also +/// needed by any test (or shared fixture, like resolver.rs's test_resolver()) +/// that constructs a real ProviderBackedResolver / provider client directly, +/// not just tests that go through spawn_sequential_http. Safe to call +/// repeatedly: install_default() on an already-installed process just +/// returns Err, discarded here. +pub(crate) fn install_test_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +/// Spawns a TCP server that answers `responses.len()` sequential HTTP +/// requests (one connection each) with the given status and body, then +/// resolves to the raw request bytes it received, in order. pub(crate) async fn spawn_sequential_http( responses: Vec<(u16, String)>, ) -> (String, JoinHandle>) { - let _ = rustls::crypto::ring::default_provider().install_default(); + install_test_crypto_provider(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); diff --git a/crates/prostheke/src/providers/opensubtitles.rs b/crates/prostheke/src/providers/opensubtitles.rs index 0d8666c5..7e4f0a64 100644 --- a/crates/prostheke/src/providers/opensubtitles.rs +++ b/crates/prostheke/src/providers/opensubtitles.rs @@ -97,11 +97,18 @@ pub struct OpenSubtitlesProvider { impl OpenSubtitlesProvider { pub fn new(config: Option) -> Self { + // WHY unwrap_or_default: only catches a genuinely-invalid TLS + // config (an Err). It does NOT catch reqwest's rustls-no-provider + // "no crypto provider installed" failure — that's a panic!, not an + // Err, and unwrap_or_default() cannot intercept a panic. Safe here + // only because every caller (production via main.rs, tests via + // this module's install_test_crypto_provider) installs the + // provider first. let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .user_agent(USER_AGENT) .build() - .unwrap_or_default(); // WHY: reqwest::Client::default() is a valid fallback; build fails only with invalid TLS config + .unwrap_or_default(); let rate_limiter = config .as_ref() .map(|c| RateLimiter::new(c.rate_limit_per_second)); @@ -568,6 +575,7 @@ mod tests { #[test] fn unconfigured_returns_empty_results() { + install_test_crypto_provider(); let provider = OpenSubtitlesProvider::new(None); assert!(provider.api_key().is_none()); } @@ -578,12 +586,14 @@ mod tests { api_key: String::new(), ..OpenSubtitlesConfig::default() }; + install_test_crypto_provider(); let provider = OpenSubtitlesProvider::new(Some(config)); assert_eq!(provider.api_key(), Some("")); } #[tokio::test(start_paused = true)] async fn unconfigured_provider_never_throttles() { + install_test_crypto_provider(); let provider = OpenSubtitlesProvider::new(None); let start = tokio::time::Instant::now(); provider.throttle().await; @@ -597,6 +607,7 @@ mod tests { /// but goes through `OpenSubtitlesProvider::new` + `throttle()`. #[tokio::test(start_paused = true)] async fn configured_provider_throttles_to_the_configured_rate() { + install_test_crypto_provider(); let provider = OpenSubtitlesProvider::new(Some(OpenSubtitlesConfig { api_key: "key".to_string(), rate_limit_per_second: 5, // 200ms interval @@ -731,6 +742,7 @@ mod tests { #[tokio::test] async fn download_rejects_non_numeric_provider_id_before_any_request() { + install_test_crypto_provider(); let provider = OpenSubtitlesProvider::new(Some(OpenSubtitlesConfig { api_key: "key".to_string(), ..OpenSubtitlesConfig::default() @@ -927,6 +939,7 @@ mod tests { #[test] fn empty_or_partial_credentials_are_treated_as_absent() { + install_test_crypto_provider(); for (username, password) in [ (None, None), (Some("user".to_string()), None), @@ -1161,6 +1174,7 @@ mod tests { #[tokio::test] async fn unconfigured_search_returns_empty_not_error() { + install_test_crypto_provider(); let provider = OpenSubtitlesProvider::new(None); let media_id = aggelmata::MediaId::new(); let result = provider diff --git a/crates/syndesmos/src/lastfm/mod.rs b/crates/syndesmos/src/lastfm/mod.rs index 35b9e095..6d1da962 100644 --- a/crates/syndesmos/src/lastfm/mod.rs +++ b/crates/syndesmos/src/lastfm/mod.rs @@ -51,10 +51,16 @@ impl LastfmClient { } pub fn with_base_url(config: LastfmConfig, base_url: String) -> Self { + // WHY unwrap_or_default: only catches a genuinely-invalid TLS + // config (an Err). It does NOT catch reqwest's rustls-no-provider + // "no crypto provider installed" failure — that's a panic!, not an + // Err, and unwrap_or_default() cannot intercept a panic. Safe here + // only because every caller (production via main.rs, tests via + // install_test_crypto_provider) installs the provider first. let http = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() - .unwrap_or_default(); // WHY: reqwest::Client::default() is a valid fallback; build fails only with invalid TLS config + .unwrap_or_default(); Self { http, config, diff --git a/crates/syndesmos/src/plex/mod.rs b/crates/syndesmos/src/plex/mod.rs index 6fe936cb..7cdf202f 100644 --- a/crates/syndesmos/src/plex/mod.rs +++ b/crates/syndesmos/src/plex/mod.rs @@ -29,10 +29,16 @@ pub struct PlexClient { impl PlexClient { pub fn new(config: PlexConfig) -> Self { + // WHY unwrap_or_default: only catches a genuinely-invalid TLS + // config (an Err). It does NOT catch reqwest's rustls-no-provider + // "no crypto provider installed" failure — that's a panic!, not an + // Err, and unwrap_or_default() cannot intercept a panic. Safe here + // only because every caller (production via main.rs, tests via + // install_test_crypto_provider) installs the provider first. let http = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() - .unwrap_or_default(); // WHY: reqwest::Client::default() is a valid fallback; build fails only with invalid TLS config + .unwrap_or_default(); Self { http, config } } } diff --git a/crates/syndesmos/src/tidal/mod.rs b/crates/syndesmos/src/tidal/mod.rs index e1eedbcd..e2c0636e 100644 --- a/crates/syndesmos/src/tidal/mod.rs +++ b/crates/syndesmos/src/tidal/mod.rs @@ -89,10 +89,16 @@ impl TidalClient { } pub fn with_base_urls(config: TidalConfig, base_url: String, auth_base_url: String) -> Self { + // WHY unwrap_or_default: only catches a genuinely-invalid TLS + // config (an Err). It does NOT catch reqwest's rustls-no-provider + // "no crypto provider installed" failure — that's a panic!, not an + // Err, and unwrap_or_default() cannot intercept a panic. Safe here + // only because every caller (production via main.rs, tests via + // install_test_crypto_provider) installs the provider first. let http = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .build() - .unwrap_or_default(); // WHY: reqwest::Client::default() is a valid fallback; build fails only with invalid TLS config + .unwrap_or_default(); let seeded = config.access_token.as_ref().map(|token| CachedToken { access_token: token.clone(), valid_until: None, @@ -369,6 +375,11 @@ pub(crate) mod tests { #[tokio::test] async fn fetch_favorites_returns_empty_when_no_token_configured() { + // WHY: TidalClient::new builds a real reqwest client (unlike + // with_base_url elsewhere in this file, this test never spawns a + // mock server first); see test_support::install_test_crypto_provider's + // WHY note. + crate::test_support::install_test_crypto_provider(); let client = TidalClient::new(TidalConfig::default()); let favorites = client.fetch_favorites().await.unwrap(); diff --git a/crates/theatron/core/src/api/client.rs b/crates/theatron/core/src/api/client.rs index 5bf96087..3f263789 100644 --- a/crates/theatron/core/src/api/client.rs +++ b/crates/theatron/core/src/api/client.rs @@ -45,10 +45,17 @@ impl std::fmt::Debug for HarmoniaClient { impl HarmoniaClient { /// Create a new client pointed at the given server. pub fn new(base_url: impl Into) -> Self { + // WHY unwrap_or_default: only catches a genuinely-invalid TLS + // config (an Err). It does NOT catch reqwest's rustls-no-provider + // "no crypto provider installed" failure — that's a panic!, not an + // Err, and unwrap_or_default() cannot intercept a panic. Safe here + // only because every caller (production via periskopio's run(), + // tests via this module's install_test_crypto_provider) installs + // the provider first. let inner = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() - .unwrap_or_default(); // WHY: reqwest::Client::default() is a valid fallback; build fails only when TLS backend is unavailable at compile time + .unwrap_or_default(); Self { inner, From cfab512d690eb8af860b865f34a6857a4cd1da8b Mon Sep 17 00:00:00 2001 From: forkwright Date: Tue, 25 Aug 2026 18:08:49 -0500 Subject: [PATCH 5/7] test(archon): install the provider in the last test that reaches a live client Completes the sweep by enumeration rather than by waiting for CI to name the next one. serve.rs has five test modules, not one; all 46 tests are now accounted for individually -- constructor called, whether it can reach a client build, and why not where it cannot. Ten reach a client and were already covered. The eleventh, zetesis_supervisor_tick_refreshes_stale_caps_and_skips_fresh, constructs eksetasis::SearchIndexerService::new directly, at a second call site of a constructor already fixed at its first. That is the whole miss, and it is worth naming: the previous method -- grep each named constructor across the workspace -- was right and did surface this line. It was not followed to the end of its own results. Every constructor fixed in this branch has now had its full grep result count reconciled. Crates confirmed unable to reach a client are recorded rather than assumed: kritike, kathodos, paroche and syntaxis declare no reqwest dependency, and ergasia reaches librqbit 8's separate reqwest instance, which this branch's feature does not affect. --- crates/archon/src/serve.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/archon/src/serve.rs b/crates/archon/src/serve.rs index 2e2936fb..3c0e4c0f 100644 --- a/crates/archon/src/serve.rs +++ b/crates/archon/src/serve.rs @@ -4630,6 +4630,10 @@ mod rebuild_supervisor_tests { /// the staleness threshold itself comes from the live config section. #[tokio::test] async fn zetesis_supervisor_tick_refreshes_stale_caps_and_skips_fresh() { + // WHY: SearchIndexerService::new below builds a real reqwest client + // (eksetasis's own build_http_client); see spawn_supervisor's WHY + // note above for the mechanism. + let _ = rustls::crypto::ring::default_provider().install_default(); let pool = sqlx::SqlitePool::connect("sqlite::memory:") .await .expect("in-memory sqlite"); From 8081eb3e717af3ac60e2ae0103280e3a14ae1841 Mon Sep 17 00:00:00 2001 From: forkwright Date: Tue, 25 Aug 2026 18:40:25 -0500 Subject: [PATCH 6/7] test(eksetasis): install the provider on the paths that bypass the shared helper Enumerated every test in all eight crates that depend on reqwest directly -- the eighth, theatron/desktop, had never been checked and turns out to construct no client at all. The failure shape is not what the earlier rounds assumed. It is not that a test forgot to spawn a mock server. It is that a shared local helper funnels most tests through a safe path while a minority in the same file construct at the site, bypassing the precondition entirely. cardigann has 52 tests behind a three-tier wrapper and two that go straight to CardigannClient::new; the wrapper is fixed once and both direct sites individually. newznab and torznab each funnel every test through one client() helper, so those are one call each. ByparrProxy::new is hardened at the constructor as defence-in-depth even though its tests were already safe. Worth recording because it nearly repeated the miss: syndesmos's plex/collections and plex/stats contain ZERO literal "reqwest" and still construct a real PlexClient at eight sites. A keyword sweep calls those files safe. All eight are preceded by an installing spawn helper in the prior statement, so they need no change -- but only reading each body establishes that. --- crates/eksetasis/src/cf_bypass/byparr.rs | 12 ++++++++--- .../eksetasis/src/client/cardigann/tests.rs | 20 +++++++++++++++++++ crates/eksetasis/src/client/newznab.rs | 7 +++++++ crates/eksetasis/src/client/torznab.rs | 8 ++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/eksetasis/src/cf_bypass/byparr.rs b/crates/eksetasis/src/cf_bypass/byparr.rs index e1ad8d70..080e8022 100644 --- a/crates/eksetasis/src/cf_bypass/byparr.rs +++ b/crates/eksetasis/src/cf_bypass/byparr.rs @@ -82,9 +82,15 @@ impl ByparrProxy { // WHY unwrap_or_default: only catches a genuinely-invalid TLS // config (an Err). It does NOT catch reqwest's rustls-no-provider // "no crypto provider installed" failure — that's a panic!, not an - // Err, and unwrap_or_default() cannot intercept a panic. Safe here - // only because every caller (production via main.rs, tests via - // install_test_crypto_provider) installs the provider first. + // Err, and unwrap_or_default() cannot intercept a panic. Production + // callers are covered by main.rs. Test callers currently always + // spawn a mock server (which installs the provider) before calling + // this — but that precondition is easy for a future test to skip + // without noticing (exactly what happened in newznab.rs, torznab.rs, + // and cardigann/tests.rs), so install it here too rather than trust + // every future caller to remember. + #[cfg(test)] + crate::test_support::install_test_crypto_provider(); let client = reqwest::Client::builder() .timeout(timeout + Duration::from_secs(5)) .build() diff --git a/crates/eksetasis/src/client/cardigann/tests.rs b/crates/eksetasis/src/client/cardigann/tests.rs index cfab130e..3d73e572 100644 --- a/crates/eksetasis/src/client/cardigann/tests.rs +++ b/crates/eksetasis/src/client/cardigann/tests.rs @@ -131,6 +131,16 @@ fn client_with_sessions( settings: BTreeMap, sessions: Arc, ) -> Result { + // WHY: reqwest::Client::new() below is an eagerly-evaluated argument — + // it builds a real TLS connector and panics with no provider installed + // BEFORE CardigannClient::new's own construction-time validation runs, + // even for tests (like absolute_off_host_login_path_refused_at_construction + // and base_url_prefers_http_indexer_url_and_falls_back_to_links) that + // expect that validation to fail with no server ever spawned. Every test + // in this module funnels through client()/client_with_settings() into + // this one function, so fixing it here covers all of them — see + // test_support::install_test_crypto_provider's WHY note. + crate::test_support::install_test_crypto_provider(); CardigannClient::new( Arc::new(SearchSubsystemConfig::default()), reqwest::Client::new(), @@ -262,6 +272,12 @@ async fn post_search_sends_form_body_and_parses_rows() { fn cf_bypass_with_post_search_unsupported_at_construction() { // WHY: the bypass proxy is GET-only; a POST search body cannot be // delivered through it, so construction must fail loudly. + // + // WHY install_test_crypto_provider: this test constructs + // CardigannClient::new directly (not via client_with_sessions above), + // and reqwest::Client::new() below still panics with no provider + // installed even though this test expects construction to fail first. + crate::test_support::install_test_crypto_provider(); let post_def = SAMPLE_DEF.replace( " - path: /browse\n", " - path: /browse\n method: post\n", @@ -1459,6 +1475,10 @@ async fn login_test_selector_absent_marks_unhealthy() { #[test] fn cf_bypass_with_form_login_unsupported_at_construction() { + // WHY: this test constructs CardigannClient::new directly (not via + // client_with_sessions above); see + // cf_bypass_with_post_search_unsupported_at_construction's WHY note. + crate::test_support::install_test_crypto_provider(); let err = CardigannClient::new( Arc::new(SearchSubsystemConfig::default()), reqwest::Client::new(), diff --git a/crates/eksetasis/src/client/newznab.rs b/crates/eksetasis/src/client/newznab.rs index 965d8284..8f410535 100644 --- a/crates/eksetasis/src/client/newznab.rs +++ b/crates/eksetasis/src/client/newznab.rs @@ -222,6 +222,13 @@ mod tests { "#; fn client(url: String, api_key: Option<&str>, max_body_bytes: u64) -> NewznabClient { + // WHY: reqwest::Client::new() below eagerly builds a TLS connector + // and panics with no provider installed. Every test in this module + // calls this helper (some, like download_rejects_ssrf_url, with a + // hardcoded URL and no preceding spawn_*_http call), so this is the + // one point that actually covers all of them — see + // test_support::install_test_crypto_provider's WHY note. + crate::test_support::install_test_crypto_provider(); NewznabClient::new( IndexerConfig { id: 2, diff --git a/crates/eksetasis/src/client/torznab.rs b/crates/eksetasis/src/client/torznab.rs index b4fe84a0..3be181b0 100644 --- a/crates/eksetasis/src/client/torznab.rs +++ b/crates/eksetasis/src/client/torznab.rs @@ -236,6 +236,14 @@ mod tests { "#; fn client(url: String, api_key: Option<&str>, max_body_bytes: u64) -> TorznabClient { + // WHY: reqwest::Client::new() below eagerly builds a TLS connector + // and panics with no provider installed. Every test in this module + // calls this helper (some, like download_rejects_ssrf_url and + // download_magnet_uri_short_circuits_without_network, with a + // hardcoded URL and no preceding spawn_*_http call), so this is the + // one point that actually covers all of them — see + // test_support::install_test_crypto_provider's WHY note. + crate::test_support::install_test_crypto_provider(); TorznabClient::new( IndexerConfig { id: 1, From 91f57fd4a7ecb77d19ee8e77d31b922cb321025c Mon Sep 17 00:00:00 2001 From: forkwright Date: Tue, 25 Aug 2026 18:56:19 -0500 Subject: [PATCH 7/7] test(eksetasis): install the crypto provider once per test binary Five CI rounds established that per-site installation does not converge here. Each round the sweep was keyed on something -- a call pattern, a constructor name, a crate boundary -- and each round some path did not use that key. The last one reached a client through a registry entry point rather than CardigannClient::new, so a careful count of that constructor was correct and still missed it. A constructor that runs at binary load removes the precondition instead of satisfying it repeatedly. There is no longer a question of whether a given test's construction path was preceded by the right call. Every per-site install is deleted rather than left alongside it, including one in search/tests.rs from an earlier round. Keeping both would leave a reader unable to tell which is load-bearing, and the dead one rots. The attribute form is read from the crate's live documentation, not recalled: ctor 1.0 made `unsafe` mandatory inside the attribute, so bare `#[ctor]` is rejected. The function body stays safe; the unsafe is on the attribute. Scoped to this crate deliberately. `#[cfg(test)]` code is compiled per crate, so this protects eksetasis's own tests and nothing else -- a constructor here would never load into another crate's test binary. syndesmos, the only other crate that has produced this bypass in the wild, is tracked separately in #737. --- Cargo.toml | 1 + crates/eksetasis/Cargo.toml | 1 + crates/eksetasis/src/cf_bypass/byparr.rs | 20 +++---------- .../eksetasis/src/client/cardigann/tests.rs | 20 ------------- crates/eksetasis/src/client/newznab.rs | 7 ----- crates/eksetasis/src/client/torznab.rs | 8 ----- crates/eksetasis/src/search.rs | 2 +- crates/eksetasis/src/search/tests.rs | 18 +----------- crates/eksetasis/src/test_support.rs | 29 +++++++++++-------- 9 files changed, 25 insertions(+), 81 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4db7d97d..f315f1ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -227,6 +227,7 @@ regex = "1" # ── Test ─────────────────────────────────────────────────────────────────────── rstest = "0.26" tempfile = "3" +ctor = "1.0" [profile.dev.package."*"] opt-level = 2 # WHY: optimize deps for faster runtime during development diff --git a/crates/eksetasis/Cargo.toml b/crates/eksetasis/Cargo.toml index f8dfd10f..da7d5bca 100644 --- a/crates/eksetasis/Cargo.toml +++ b/crates/eksetasis/Cargo.toml @@ -35,6 +35,7 @@ sqlx.workspace = true rstest.workspace = true tempfile.workspace = true rustls.workspace = true +ctor.workspace = true [dev-dependencies.tokio] workspace = true diff --git a/crates/eksetasis/src/cf_bypass/byparr.rs b/crates/eksetasis/src/cf_bypass/byparr.rs index 080e8022..a30b7788 100644 --- a/crates/eksetasis/src/cf_bypass/byparr.rs +++ b/crates/eksetasis/src/cf_bypass/byparr.rs @@ -83,14 +83,9 @@ impl ByparrProxy { // config (an Err). It does NOT catch reqwest's rustls-no-provider // "no crypto provider installed" failure — that's a panic!, not an // Err, and unwrap_or_default() cannot intercept a panic. Production - // callers are covered by main.rs. Test callers currently always - // spawn a mock server (which installs the provider) before calling - // this — but that precondition is easy for a future test to skip - // without noticing (exactly what happened in newznab.rs, torznab.rs, - // and cardigann/tests.rs), so install it here too rather than trust - // every future caller to remember. - #[cfg(test)] - crate::test_support::install_test_crypto_provider(); + // callers are covered by main.rs; test callers are covered by this + // crate's #[ctor] initializer (test_support::install_test_crypto_provider), + // which installs the provider once per process before any test runs. let client = reqwest::Client::builder() .timeout(timeout + Duration::from_secs(5)) .build() @@ -303,7 +298,7 @@ mod tests { use tokio::task::JoinHandle; use super::*; - use crate::test_support::{install_test_crypto_provider, spawn_one_shot_http}; + use crate::test_support::spawn_one_shot_http; /// Refresh window for tests where reuse-vs-resolve is not under test. const ANY_REFRESH: Duration = Duration::from_secs(60); @@ -316,13 +311,6 @@ mod tests { hits: Arc, heads: Arc>>, ) -> (String, JoinHandle<()>) { - // WHY: ByparrProxy::new below eagerly builds a TLS connector and - // PANICS (not a recoverable Err — .unwrap_or_default() only catches - // Err) with no provider installed; see - // test_support::install_test_crypto_provider's WHY note. This is a - // file-local spawn helper, separate from test_support's, so it needs - // its own install call rather than inheriting one transitively. - install_test_crypto_provider(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let handle = tokio::spawn(async move { diff --git a/crates/eksetasis/src/client/cardigann/tests.rs b/crates/eksetasis/src/client/cardigann/tests.rs index 3d73e572..cfab130e 100644 --- a/crates/eksetasis/src/client/cardigann/tests.rs +++ b/crates/eksetasis/src/client/cardigann/tests.rs @@ -131,16 +131,6 @@ fn client_with_sessions( settings: BTreeMap, sessions: Arc, ) -> Result { - // WHY: reqwest::Client::new() below is an eagerly-evaluated argument — - // it builds a real TLS connector and panics with no provider installed - // BEFORE CardigannClient::new's own construction-time validation runs, - // even for tests (like absolute_off_host_login_path_refused_at_construction - // and base_url_prefers_http_indexer_url_and_falls_back_to_links) that - // expect that validation to fail with no server ever spawned. Every test - // in this module funnels through client()/client_with_settings() into - // this one function, so fixing it here covers all of them — see - // test_support::install_test_crypto_provider's WHY note. - crate::test_support::install_test_crypto_provider(); CardigannClient::new( Arc::new(SearchSubsystemConfig::default()), reqwest::Client::new(), @@ -272,12 +262,6 @@ async fn post_search_sends_form_body_and_parses_rows() { fn cf_bypass_with_post_search_unsupported_at_construction() { // WHY: the bypass proxy is GET-only; a POST search body cannot be // delivered through it, so construction must fail loudly. - // - // WHY install_test_crypto_provider: this test constructs - // CardigannClient::new directly (not via client_with_sessions above), - // and reqwest::Client::new() below still panics with no provider - // installed even though this test expects construction to fail first. - crate::test_support::install_test_crypto_provider(); let post_def = SAMPLE_DEF.replace( " - path: /browse\n", " - path: /browse\n method: post\n", @@ -1475,10 +1459,6 @@ async fn login_test_selector_absent_marks_unhealthy() { #[test] fn cf_bypass_with_form_login_unsupported_at_construction() { - // WHY: this test constructs CardigannClient::new directly (not via - // client_with_sessions above); see - // cf_bypass_with_post_search_unsupported_at_construction's WHY note. - crate::test_support::install_test_crypto_provider(); let err = CardigannClient::new( Arc::new(SearchSubsystemConfig::default()), reqwest::Client::new(), diff --git a/crates/eksetasis/src/client/newznab.rs b/crates/eksetasis/src/client/newznab.rs index 8f410535..965d8284 100644 --- a/crates/eksetasis/src/client/newznab.rs +++ b/crates/eksetasis/src/client/newznab.rs @@ -222,13 +222,6 @@ mod tests { "#; fn client(url: String, api_key: Option<&str>, max_body_bytes: u64) -> NewznabClient { - // WHY: reqwest::Client::new() below eagerly builds a TLS connector - // and panics with no provider installed. Every test in this module - // calls this helper (some, like download_rejects_ssrf_url, with a - // hardcoded URL and no preceding spawn_*_http call), so this is the - // one point that actually covers all of them — see - // test_support::install_test_crypto_provider's WHY note. - crate::test_support::install_test_crypto_provider(); NewznabClient::new( IndexerConfig { id: 2, diff --git a/crates/eksetasis/src/client/torznab.rs b/crates/eksetasis/src/client/torznab.rs index 3be181b0..b4fe84a0 100644 --- a/crates/eksetasis/src/client/torznab.rs +++ b/crates/eksetasis/src/client/torznab.rs @@ -236,14 +236,6 @@ mod tests { "#; fn client(url: String, api_key: Option<&str>, max_body_bytes: u64) -> TorznabClient { - // WHY: reqwest::Client::new() below eagerly builds a TLS connector - // and panics with no provider installed. Every test in this module - // calls this helper (some, like download_rejects_ssrf_url and - // download_magnet_uri_short_circuits_without_network, with a - // hardcoded URL and no preceding spawn_*_http call), so this is the - // one point that actually covers all of them — see - // test_support::install_test_crypto_provider's WHY note. - crate::test_support::install_test_crypto_provider(); TorznabClient::new( IndexerConfig { id: 1, diff --git a/crates/eksetasis/src/search.rs b/crates/eksetasis/src/search.rs index 50924981..4277a276 100644 --- a/crates/eksetasis/src/search.rs +++ b/crates/eksetasis/src/search.rs @@ -41,7 +41,7 @@ fn build_http_client(request_timeout_secs: u64) -> reqwest::Client { // NOT catch reqwest's rustls-no-provider "no crypto provider installed" // failure — that's a panic!, not an Err, and unwrap_or_default() cannot // intercept a panic. Safe here only because every caller (production - // via main.rs, tests via install_test_crypto_provider) installs the + // via main.rs, tests via this crate's #[ctor] initializer) installs the // provider first. reqwest::Client::builder() .timeout(Duration::from_secs(request_timeout_secs)) diff --git a/crates/eksetasis/src/search/tests.rs b/crates/eksetasis/src/search/tests.rs index cd7b0024..4a4fa74d 100644 --- a/crates/eksetasis/src/search/tests.rs +++ b/crates/eksetasis/src/search/tests.rs @@ -216,7 +216,7 @@ use apotheke::migrate::MIGRATOR; use crate::cf_bypass::noop::NoProxy; use crate::repo::InsertIndexerParams; -use crate::test_support::{install_test_crypto_provider, spawn_one_shot_http}; +use crate::test_support::spawn_one_shot_http; async fn make_service() -> (SearchIndexerService, SqlitePool) { make_service_with(SearchSubsystemConfig::default()).await @@ -236,12 +236,6 @@ async fn make_service_with_section_and_proxy( config: horismos::Section, cf_proxy: Arc, ) -> (SearchIndexerService, SqlitePool) { - // WHY: SearchIndexerService::new builds a real reqwest client - // (search.rs's build_http_client), which eagerly builds a TLS - // connector; see install_test_crypto_provider's WHY note. Every test in - // this file that uses make_service()/make_service_with(_section) funnels - // through this one function, so fixing it here covers all of them. - install_test_crypto_provider(); let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); MIGRATOR.run(&pool).await.unwrap(); let (event_tx, _) = create_event_bus(16); @@ -333,11 +327,6 @@ async fn handle_search_error_http_request_active_marks_degraded() { let indexer = seed_indexer(&pool, "https://example.com/api").await; assert_eq!(indexer.status, "active"); - // WHY: reqwest::Client::new() eagerly builds its TLS connector even for - // a plain-HTTP dead-port probe, and this file's other tests never share - // this test's process (nextest isolates per-test) — see - // test_support::install_test_crypto_provider's WHY note. - install_test_crypto_provider(); let error = SearchIndexerError::HttpRequest { url: "https://example.com/api".to_string(), source: reqwest::Client::new() @@ -362,11 +351,6 @@ async fn handle_search_error_http_request_degraded_escalates_to_failed() { .unwrap(); indexer.status = "degraded".to_string(); - // WHY: reqwest::Client::new() eagerly builds its TLS connector even for - // a plain-HTTP dead-port probe, and this file's other tests never share - // this test's process (nextest isolates per-test) — see - // test_support::install_test_crypto_provider's WHY note. - install_test_crypto_provider(); let error = SearchIndexerError::HttpRequest { url: "https://example.com/api".to_string(), source: reqwest::Client::new() diff --git a/crates/eksetasis/src/test_support.rs b/crates/eksetasis/src/test_support.rs index f432390d..fda58f4f 100644 --- a/crates/eksetasis/src/test_support.rs +++ b/crates/eksetasis/src/test_support.rs @@ -5,23 +5,30 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::task::JoinHandle; -/// Installs the process-wide rustls crypto provider for tests. +/// Installs the process-wide rustls crypto provider before any test in this +/// binary runs. /// -/// WHY: reqwest builds with `rustls-no-provider` (fleet convention: install -/// explicitly, never let a library link one implicitly — see main.rs), so -/// `reqwest::Client::new()`/`::builder().build()` panics ("No rustls crypto -/// provider is configured") in any process that never called -/// `install_default()` — and a nextest test binary never runs `main()`. -/// Safe to call repeatedly: install_default() on an already-installed -/// process just returns Err, discarded here. -pub(crate) fn install_test_crypto_provider() { +/// WHY #[ctor]: reqwest builds with `rustls-no-provider` (fleet convention: +/// install explicitly, never let a library link one implicitly — see +/// main.rs), so `reqwest::Client::new()`/`::builder().build()` panics ("No +/// rustls crypto provider is configured") in any process that never called +/// `install_default()` — and a nextest test binary never runs `main()`. A +/// per-site or per-helper install call only covers the construction paths +/// someone remembered to wire it into — this crate found a new one on every +/// sweep (newznab/torznab's shared `client()` helper, cardigann's +/// three-tier wrapper, two direct construction sites bypassing it, and a +/// registry/factory entry point bypassing all of the above). `#[ctor]` runs +/// once at process load, before any `#[test]`/`#[tokio::test]` function in +/// this binary executes, so every construction path is covered +/// unconditionally rather than by convention. +#[ctor::ctor(unsafe)] +fn install_test_crypto_provider() { let _ = rustls::crypto::ring::default_provider().install_default(); } /// Spawns a TCP server that answers exactly one HTTP request with the given /// raw response bytes, then resolves to the raw request head it received. pub(crate) async fn spawn_raw_http(raw_response: Vec) -> (String, JoinHandle) { - install_test_crypto_provider(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); @@ -60,7 +67,6 @@ pub(crate) async fn spawn_raw_http(raw_response: Vec) -> (String, JoinHandle pub(crate) async fn spawn_sequence_http( responses: Vec<(u16, Vec<(String, String)>, String)>, ) -> (String, JoinHandle>) { - install_test_crypto_provider(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); @@ -133,7 +139,6 @@ pub(crate) async fn spawn_one_shot_http( /// Spawns a TCP server that accepts one connection and never responds. pub(crate) async fn spawn_hang_http() -> (String, JoinHandle<()>) { - install_test_crypto_provider(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap());