Skip to main content

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}