sphinx_ultra/intersphinx/fetch.rs
1//! The one place intersphinx touches the network, behind a trait so every
2//! test can stay offline.
3//!
4//! Sphinx routes its inventory downloads through `sphinx.util.requests.get`
5//! with the shared HTTP configuration group (`tls_verify`, `tls_cacerts`,
6//! `user_agent`) plus `intersphinx_timeout`
7//! (`sphinx/util/requests.py:20-45,96-111`, `ext/intersphinx/_load.py:388-421`;
8//! see the research spec §4). [`HttpConfig`] is that group, and
9//! [`InventoryFetcher`] is the seam: production uses [`UreqFetcher`], tests
10//! inject their own.
11
12use std::collections::BTreeMap;
13use std::time::Duration;
14
15use anyhow::{Context, Result};
16use serde::{Deserialize, Serialize};
17
18/// The exact `User-Agent` Sphinx 9.1.0 sends when `user_agent` is unset
19/// (`sphinx/util/requests.py:20-23`, an f-string over `sphinx.__version__`).
20///
21/// Emitted verbatim, Sphinx version and all: servers (notably some CDN and
22/// WAF configurations) gate inventory downloads on this exact string, and
23/// byte-compatibility with Sphinx is worth more than announcing ourselves
24/// here. Revisit at 1.0.
25pub const DEFAULT_USER_AGENT: &str =
26 "Mozilla/5.0 (X11; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0 Sphinx/9.1.0";
27
28/// `tls_cacerts` (`sphinx/config.py:287`), which Sphinx types
29/// `str | dict[str, str] | None`: a single CA bundle path, or a per-netloc
30/// map of them (`sphinx/util/requests.py:34-45`).
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(untagged)]
33pub enum TlsCacerts {
34 /// One CA bundle used for every host.
35 Bundle(String),
36 /// netloc (`host` or `host:port`, userinfo stripped) -> CA bundle path.
37 /// A host with no entry falls back to the default trust store.
38 PerHost(BTreeMap<String, String>),
39}
40
41/// Sphinx's shared HTTP configuration group plus `intersphinx_timeout`
42/// (`ext/intersphinx/_load.py:211-227`, `_InvConfig`).
43#[derive(Debug, Clone, PartialEq)]
44pub struct HttpConfig {
45 pub tls_verify: bool,
46 pub tls_cacerts: Option<TlsCacerts>,
47 pub user_agent: Option<String>,
48 /// Seconds. `None` is Sphinx's default and means *no* timeout — the
49 /// value is handed to `requests` as `timeout=None`
50 /// (`ext/intersphinx/__init__.py:70-72`).
51 pub timeout: Option<f64>,
52}
53
54/// Hand-written rather than derived: `bool::default()` is `false`, and a
55/// default that silently turns off certificate verification is not a default
56/// anyone should be able to reach by accident. Sphinx's `tls_verify` default
57/// is `True` (`config.py:286`), and so is this one.
58impl Default for HttpConfig {
59 fn default() -> Self {
60 Self {
61 tls_verify: true,
62 tls_cacerts: None,
63 user_agent: None,
64 timeout: None,
65 }
66 }
67}
68
69impl HttpConfig {
70 /// `headers.setdefault('User-Agent', _user_agent or _USER_AGENT)`
71 /// (`util/requests.py:96-97`): an empty configured value is falsy in
72 /// Python and so also falls back to the default.
73 pub fn user_agent(&self) -> &str {
74 match self.user_agent.as_deref() {
75 Some(agent) if !agent.is_empty() => agent,
76 _ => DEFAULT_USER_AGENT,
77 }
78 }
79
80 /// `timeout` as a [`Duration`], or `None` for "no timeout" — which is
81 /// both Sphinx's default and what an unusable value degrades to.
82 ///
83 /// `intersphinx_timeout` arrives from `conf.py` as an unvalidated
84 /// `f64`, and `Duration::from_secs_f64` *panics* on a negative, NaN or
85 /// overflowing one. `intersphinx_timeout = -1` is a natural thing to
86 /// write (`intersphinx_cache_limit = -1` is the documented Sphinx idiom
87 /// for "never expire"), and with `panic = "abort"` in the release
88 /// profile that panic is a bare SIGABRT naming neither the config key
89 /// nor the file. Sphinx wraps the whole fetch in `except Exception`
90 /// (`ext/intersphinx/_load.py:302-313`), so a bad value there is one
91 /// entry in `failures` and the build carries on; this degrades the same
92 /// way, and says which value it rejected.
93 pub fn timeout_duration(&self) -> Option<Duration> {
94 let seconds = self.timeout?;
95 // `Duration::MAX` is `u64::MAX` seconds; anything at or beyond
96 // `2^64` seconds overflows the conversion. The `i64::MAX` bound is
97 // far below that and still ~292 billion years.
98 if !seconds.is_finite() || seconds < 0.0 || seconds > i64::MAX as f64 {
99 log::warn!(
100 "ignoring unusable intersphinx_timeout {seconds}: \
101 the timeout must be a non-negative number of seconds"
102 );
103 return None;
104 }
105 Some(Duration::from_secs_f64(seconds))
106 }
107
108 /// `_get_tls_cacert(url, tls_cacerts)` (`util/requests.py:34-45`): a
109 /// plain string is the bundle for every URL; a mapping is keyed by the
110 /// URL's netloc with userinfo stripped, and a host it does not name
111 /// falls back to the default trust store.
112 pub fn ca_bundle_for(&self, url: &str) -> Option<&str> {
113 match self.tls_cacerts.as_ref()? {
114 TlsCacerts::Bundle(path) => Some(path.as_str()),
115 TlsCacerts::PerHost(map) => map.get(netloc(url)?).map(String::as_str),
116 }
117 }
118}
119
120/// The `netloc` of a URL with any `user:password@` prefix removed —
121/// `urlsplit(url).netloc.rsplit('@')[-1]` in `util/requests.py:41`.
122fn netloc(url: &str) -> Option<&str> {
123 let after_scheme = url.split_once("://")?.1;
124 let netloc = match after_scheme.find(['/', '?', '#']) {
125 Some(end) => &after_scheme[..end],
126 None => after_scheme,
127 };
128 Some(match netloc.rsplit_once('@') {
129 Some((_userinfo, host)) => host,
130 None => netloc,
131 })
132}
133
134/// Fetch one inventory over the network.
135///
136/// The whole of intersphinx's remote half lives behind this one method, so
137/// the test suite can exercise loading, caching, merging and resolution with
138/// an injected implementation and never open a socket.
139pub trait InventoryFetcher {
140 fn fetch(&self, url: &str, http: &HttpConfig) -> Result<Vec<u8>>;
141}
142
143/// The production fetcher.
144///
145/// **Not covered by any test**: every test in this crate injects its own
146/// fetcher or uses local-file inventory locations, exactly as Sphinx's own
147/// suite does (research spec §5). This type compiles and is wired into the
148/// builder, but nothing verifies it against a live server — treat changes
149/// here as unverified.
150pub struct UreqFetcher;
151
152/// Inventories are small (Python's is ~150 KB); this cap only exists so a
153/// misconfigured URL cannot stream unbounded data into memory.
154const MAX_INVENTORY_BYTES: u64 = 64 * 1024 * 1024;
155
156/// Every certificate in a PEM bundle, in file order.
157///
158/// A CA bundle — which is what `tls_cacerts` names — is a *concatenation* of
159/// PEM certificates: `openssl`'s `cert.pem`, a corporate root plus its
160/// intermediates, a trust store shipped by a distribution. `Certificate::from_pem`
161/// documents that it "picks the first certificate", so trusting its result
162/// alone would silently drop every root after the first and reject servers
163/// chaining to any of them.
164///
165/// Non-certificate PEM sections (a private key sitting in the same file, say)
166/// are skipped rather than rejected, mirroring how a trust store is read.
167fn root_certs_from_pem(pem: &[u8]) -> Result<Vec<ureq::tls::Certificate<'static>>> {
168 let mut certs = Vec::new();
169 for item in ureq::tls::parse_pem(pem) {
170 match item.map_err(|e| anyhow::anyhow!("{e}"))? {
171 ureq::tls::PemItem::Certificate(cert) => certs.push(cert),
172 _ => continue,
173 }
174 }
175 if certs.is_empty() {
176 anyhow::bail!("no PEM-encoded certificate found");
177 }
178 Ok(certs)
179}
180
181impl InventoryFetcher for UreqFetcher {
182 fn fetch(&self, url: &str, http: &HttpConfig) -> Result<Vec<u8>> {
183 let mut tls = ureq::tls::TlsConfig::builder().disable_verification(!http.tls_verify);
184 // `verify=verify and _get_tls_cacert(url, tls_cacerts)`: the bundle
185 // is only consulted when verification is on at all.
186 if http.tls_verify {
187 if let Some(bundle) = http.ca_bundle_for(url) {
188 let pem = std::fs::read(bundle)
189 .with_context(|| format!("cannot read tls_cacerts bundle {bundle}"))?;
190 let certs = root_certs_from_pem(&pem)
191 .map_err(|e| anyhow::anyhow!("invalid tls_cacerts bundle {bundle}: {e}"))?;
192 tls = tls.root_certs(ureq::tls::RootCerts::new_with_certs(&certs));
193 }
194 }
195
196 let config = ureq::Agent::config_builder()
197 .user_agent(http.user_agent().to_string())
198 // `timeout=None` (the default) means no timeout at all.
199 .timeout_global(http.timeout_duration())
200 .tls_config(tls.build())
201 .build();
202
203 let agent: ureq::Agent = config.into();
204 let mut response = agent.get(url).call()?;
205 let body = response
206 .body_mut()
207 .with_config()
208 .limit(MAX_INVENTORY_BYTES)
209 .read_to_vec()?;
210 Ok(body)
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 fn with_timeout(timeout: Option<f64>) -> HttpConfig {
219 HttpConfig {
220 timeout,
221 ..HttpConfig::default()
222 }
223 }
224
225 /// Every value `Duration::from_secs_f64` would panic on degrades to "no
226 /// timeout" instead of aborting the build. Sphinx turns a bad timeout
227 /// into one `failures` entry and keeps going; nothing here may be worse
228 /// than that.
229 #[test]
230 fn an_unusable_timeout_degrades_to_none() {
231 for unusable in [
232 -1.0,
233 -0.5,
234 f64::NAN,
235 f64::INFINITY,
236 f64::NEG_INFINITY,
237 1e30,
238 f64::MAX,
239 ] {
240 assert_eq!(
241 with_timeout(Some(unusable)).timeout_duration(),
242 None,
243 "intersphinx_timeout = {unusable}"
244 );
245 }
246 }
247
248 #[test]
249 fn a_usable_timeout_is_kept_and_no_timeout_stays_no_timeout() {
250 assert_eq!(
251 with_timeout(Some(2.5)).timeout_duration(),
252 Some(Duration::from_millis(2500))
253 );
254 assert_eq!(
255 with_timeout(Some(0.0)).timeout_duration(),
256 Some(Duration::ZERO),
257 "zero is a legal (if useless) timeout, not an error"
258 );
259 assert_eq!(with_timeout(None).timeout_duration(), None);
260 }
261
262 /// Two PEM sections, which is the shape of every real CA bundle. The
263 /// bodies are arbitrary: this is the PEM *framing* under test, and the
264 /// parser hands back one certificate per section.
265 const TWO_CERT_BUNDLE: &[u8] = b"\
266-----BEGIN CERTIFICATE-----
267AQID
268-----END CERTIFICATE-----
269-----BEGIN CERTIFICATE-----
270BAUG
271-----END CERTIFICATE-----
272";
273
274 #[test]
275 fn a_ca_bundle_keeps_every_certificate_in_it() {
276 let certs = root_certs_from_pem(TWO_CERT_BUNDLE).expect("a two-cert bundle parses");
277 assert_eq!(
278 certs.len(),
279 2,
280 "a bundle's later roots must not be dropped: `Certificate::from_pem` \
281 returns only the first, which would reject servers chaining to any other"
282 );
283 }
284
285 #[test]
286 fn a_bundle_with_no_certificate_in_it_is_an_error() {
287 assert!(root_certs_from_pem(b"not a pem file at all\n").is_err());
288 }
289
290 #[test]
291 fn the_default_configuration_verifies_certificates() {
292 assert!(
293 HttpConfig::default().tls_verify,
294 "a default that skips verification would be a trap"
295 );
296 }
297
298 #[test]
299 fn the_default_user_agent_is_sphinx_9_1_0s_verbatim() {
300 let config = HttpConfig::default();
301 assert_eq!(
302 config.user_agent(),
303 "Mozilla/5.0 (X11; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0 Sphinx/9.1.0"
304 );
305 // An empty string is falsy in Python, so it too falls back.
306 let empty = HttpConfig {
307 user_agent: Some(String::new()),
308 ..HttpConfig::default()
309 };
310 assert_eq!(empty.user_agent(), DEFAULT_USER_AGENT);
311 let custom = HttpConfig {
312 user_agent: Some("mine/1".to_string()),
313 ..HttpConfig::default()
314 };
315 assert_eq!(custom.user_agent(), "mine/1");
316 }
317
318 #[test]
319 fn tls_cacerts_resolve_per_url_for_the_mapping_form() {
320 let bundle = HttpConfig {
321 tls_cacerts: Some(TlsCacerts::Bundle("/etc/ca.pem".to_string())),
322 ..HttpConfig::default()
323 };
324 assert_eq!(
325 bundle.ca_bundle_for("https://anything.example/objects.inv"),
326 Some("/etc/ca.pem"),
327 "a plain string is the bundle for every URL"
328 );
329
330 let per_host = HttpConfig {
331 tls_cacerts: Some(TlsCacerts::PerHost(BTreeMap::from([(
332 "docs.example.org".to_string(),
333 "/etc/example.pem".to_string(),
334 )]))),
335 ..HttpConfig::default()
336 };
337 assert_eq!(
338 per_host.ca_bundle_for("https://user:pw@docs.example.org/v1/objects.inv"),
339 Some("/etc/example.pem"),
340 "the key is the netloc with userinfo stripped"
341 );
342 assert_eq!(
343 per_host.ca_bundle_for("https://other.example.org/objects.inv"),
344 None,
345 "an unnamed host falls back to the default trust store"
346 );
347 assert_eq!(HttpConfig::default().ca_bundle_for("https://x/y"), None);
348 }
349
350 #[test]
351 fn netloc_keeps_the_port_and_drops_userinfo_path_and_query() {
352 assert_eq!(
353 netloc("https://a.example:8443/x?y#z"),
354 Some("a.example:8443")
355 );
356 assert_eq!(
357 netloc("https://u:p@a.example:8443/x"),
358 Some("a.example:8443")
359 );
360 assert_eq!(netloc("https://a.example"), Some("a.example"));
361 assert_eq!(netloc("local.inv"), None);
362 }
363}