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..f315f1ab 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", ] } @@ -225,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/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/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"); @@ -4440,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(), @@ -4501,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(), @@ -4590,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"); 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..da7d5bca 100644 --- a/crates/eksetasis/Cargo.toml +++ b/crates/eksetasis/Cargo.toml @@ -34,6 +34,8 @@ sqlx.workspace = true [dev-dependencies] 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 25f8c5c8..a30b7788 100644 --- a/crates/eksetasis/src/cf_bypass/byparr.rs +++ b/crates/eksetasis/src/cf_bypass/byparr.rs @@ -79,10 +79,17 @@ 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. Production + // 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() - .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..4277a276 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 this crate's #[ctor] initializer) 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/test_support.rs b/crates/eksetasis/src/test_support.rs index 991016ed..fda58f4f 100644 --- a/crates/eksetasis/src/test_support.rs +++ b/crates/eksetasis/src/test_support.rs @@ -5,6 +5,27 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::task::JoinHandle; +/// Installs the process-wide rustls crypto provider before any test in this +/// binary runs. +/// +/// 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) { 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/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 01ef275e..b2d2287e 100644 --- a/crates/epignosis/src/test_support.rs +++ b/crates/epignosis/src/test_support.rs @@ -5,12 +5,30 @@ 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()`. 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>) { + 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/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..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)); @@ -555,8 +562,20 @@ 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() { + install_test_crypto_provider(); let provider = OpenSubtitlesProvider::new(None); assert!(provider.api_key().is_none()); } @@ -567,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; @@ -586,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 @@ -720,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() @@ -745,6 +768,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 +799,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 +832,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 +862,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(); @@ -912,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), @@ -1146,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/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/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 05e9560c..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 } } } @@ -124,6 +130,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/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/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..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, @@ -796,20 +803,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 +842,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/crates/theatron/desktop/Cargo.toml b/crates/theatron/desktop/Cargo.toml index a0da6132..4e7f831b 100644 --- a/crates/theatron/desktop/Cargo.toml +++ b/crates/theatron/desktop/Cargo.toml @@ -16,9 +16,18 @@ dioxus = { version = "0.7", features = ["desktop", "router"] } tokio = { version = "1", features = ["full"] } # HTTP client -reqwest = { version = "0.12", default-features = false, features = [ +# 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-tls", + "rustls-no-provider", +] } +rustls = { version = "0.23", default-features = false, features = [ + "ring", + "std", ] } # Serialization 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); } 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" },