Skip to main content

npm_utils/
download.rs

1//! HTTP download helpers.
2
3use serde_json::Value;
4use std::sync::OnceLock;
5use std::time::Duration;
6use ureq::tls::{RootCerts, TlsConfig};
7
8/// HTTP timeouts for downloads; `None` disables a bound.
9#[derive(Clone, Copy, Debug)]
10pub struct Timeouts {
11    /// Cap on establishing the connection.
12    pub connect: Option<Duration>,
13    /// Cap on a single request, connect through transfer — applied per fetch, not across the run
14    /// (ureq's per-call `timeout_global`).
15    pub global: Option<Duration>,
16}
17
18impl Default for Timeouts {
19    /// 30 s to connect, 120 s per request — enough for a large tarball on a slow link, while a
20    /// stalled peer can't hang the build.
21    fn default() -> Self {
22        Self {
23            connect: Some(Duration::from_secs(30)),
24            global: Some(Duration::from_secs(120)),
25        }
26    }
27}
28
29impl Timeouts {
30    /// Build from the CLI flags: `--no-timeout` removes every bound; `--timeout <secs>` sets the
31    /// per-request timeout (connect stays at the default); neither keeps the default.
32    pub fn from_cli(timeout_secs: Option<u64>, no_timeout: bool) -> Timeouts {
33        if no_timeout {
34            Timeouts {
35                connect: None,
36                global: None,
37            }
38        } else if let Some(secs) = timeout_secs {
39            Timeouts {
40                global: Some(Duration::from_secs(secs)),
41                ..Timeouts::default()
42            }
43        } else {
44            Timeouts::default()
45        }
46    }
47}
48
49static TIMEOUTS: OnceLock<Timeouts> = OnceLock::new();
50
51/// Override the process-wide download timeouts. Intended to be called once at startup (the CLI
52/// derives them from `--timeout` / `--no-timeout`); the library default applies if never set, and a
53/// later call is ignored. The shared agent captures the timeouts when the first download builds it,
54/// so call this before any fetch — set after that, the values are inert.
55pub fn set_timeouts(timeouts: Timeouts) {
56    let _ = TIMEOUTS.set(timeouts);
57}
58
59fn timeouts() -> Timeouts {
60    TIMEOUTS.get().copied().unwrap_or_default()
61}
62
63/// The process-wide HTTP agent, built once on first use — ureq's `Agent` is an `Arc`-backed cheap
64/// clone sharing one connection pool, so every fetch in the process reuses warm TCP+TLS
65/// connections instead of re-handshaking per request. Every request helper here
66/// ([`fetch_with_accept`], [`post_json`]) goes through it, sharing one TLS/timeout policy that
67/// honours `--timeout` / `--no-timeout`.
68static AGENT: OnceLock<ureq::Agent> = OnceLock::new();
69
70fn agent() -> ureq::Agent {
71    AGENT
72        .get_or_init(|| ureq::Agent::new_with_config(agent_config(timeouts())))
73        .clone()
74}
75
76/// The shared agent's configuration: platform-verified TLS, the process-wide timeouts, an idle
77/// pool sized for the resolver's 8-wide packument prefetch, and https on **every** request —
78/// redirects included.
79fn agent_config(t: Timeouts) -> ureq::config::Config {
80    ureq::Agent::config_builder()
81        .tls_config(
82            TlsConfig::builder()
83                .root_certs(RootCerts::PlatformVerifier)
84                .build(),
85        )
86        .timeout_connect(t.connect)
87        .timeout_global(t.global)
88        // The resolver prefetches packuments 8-wide against a single registry host
89        // (`registry`'s PACKUMENT_CONCURRENCY); ureq's idle-pool defaults (3 per
90        // host, 10 total) would drop and re-handshake most of those connections
91        // between rounds.
92        .max_idle_connections_per_host(8)
93        .max_idle_connections(16)
94        // The scheme guard in `fetch_with_accept` checks only the initial URL; without
95        // this, a redirect could still steer the fetch to plain http.
96        .https_only(true)
97        .build()
98}
99
100/// Download an `https://` URL into memory (100 MB cap), retrying once on transient failure.
101///
102/// Only `https` is fetched: a non-https URL is refused up front, and the agent refuses a
103/// redirect to one (`https_only`). The tarball URL is advertised by the registry, so this keeps
104/// a hostile or redirecting registry from steering us at a plain-http or internal endpoint (the
105/// downloaded bytes are sha512-verified regardless — this is defense-in-depth). Per-request
106/// connect and transfer timeouts are set so a stalled peer can't hang the build; the 100 MB cap
107/// bounds size, the timeouts bound time.
108///
109/// Some hosts (GitHub in particular) occasionally drop a connection
110/// mid-transfer — observed as `io: Peer disconnected` on CI — and the same URL
111/// has not been seen to fail twice in a row, so one retry after a short pause is
112/// enough.
113pub fn fetch(url: &str) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
114    fetch_with_accept(url, None)
115}
116
117/// Like [`fetch`], but sends an `Accept` header — used to request the npm registry's abbreviated
118/// packument (`application/vnd.npm.install-v1+json`), which is far smaller than the full document.
119pub fn fetch_with_accept(
120    url: &str,
121    accept: Option<&str>,
122) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
123    if !url.starts_with("https://") {
124        return Err(format!(
125            "refusing to fetch non-https URL {url:?}: npm-utils downloads over https only"
126        )
127        .into());
128    }
129    let agent = agent();
130
131    let attempts = 2;
132    for attempt in 1..=attempts {
133        match try_fetch(&agent, url, accept) {
134            Ok(body) => return Ok(body),
135            Err(e) if attempt < attempts => {
136                crate::warn::warn(&format!(
137                    "download attempt {attempt}/{attempts} failed for {url}: {e}; \
138                     retrying in 500ms"
139                ));
140                std::thread::sleep(Duration::from_millis(500));
141            }
142            Err(e) => return Err(e),
143        }
144    }
145    unreachable!()
146}
147
148fn try_fetch(
149    agent: &ureq::Agent,
150    url: &str,
151    accept: Option<&str>,
152) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
153    let request = match accept {
154        Some(accept) => agent.get(url).header("Accept", accept),
155        None => agent.get(url),
156    };
157    let mut response = request.call()?;
158    let body = response.body_mut();
159    Ok(body.with_config().limit(100 * 1024 * 1024).read_to_vec()?)
160}
161
162/// POST `body` to an `https://` URL and return the parsed JSON response, or `None` on **any**
163/// failure — a non-https URL, a network error, a non-2xx status, or an unparseable body.
164///
165/// The single-attempt, error-swallowing contract is deliberate: the audit advisory sources read
166/// `None` as "no advisories", so an unreachable endpoint, a 410 (npm's retired legacy paths), or a
167/// flaky link degrades to an empty result instead of failing the run — matching `npm audit` /
168/// `pnpm audit`, which exit 0 when the advisory endpoint can't be reached.
169///
170/// `content_encoding` sets the `Content-Encoding` header (`Some("gzip")` when `body` is
171/// gzip-compressed, as npm's bulk-advisory endpoint requires); `accept` overrides the `Accept`
172/// header (default `application/json`). `Content-Type` is always `application/json`.
173pub fn post_json(
174    url: &str,
175    body: &[u8],
176    content_encoding: Option<&str>,
177    accept: Option<&str>,
178) -> Option<Value> {
179    if !url.starts_with("https://") {
180        return None;
181    }
182    let request = agent()
183        .post(url)
184        .header("Content-Type", "application/json")
185        .header("Accept", accept.unwrap_or("application/json"));
186    let request = match content_encoding {
187        Some(enc) => request.header("Content-Encoding", enc),
188        None => request,
189    };
190    let mut response = request.send(body).ok()?;
191    let bytes = response
192        .body_mut()
193        .with_config()
194        .limit(100 * 1024 * 1024)
195        .read_to_vec()
196        .ok()?;
197    serde_json::from_slice::<Value>(&bytes).ok()
198}
199
200/// URL for a GitHub repository archive (zip) at a ref (branch, tag, or commit).
201pub fn github_archive_url(owner: &str, repo: &str, git_ref: &str) -> String {
202    format!("https://github.com/{owner}/{repo}/archive/{git_ref}.zip")
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn fetch_refuses_non_https() {
211        // The scheme guard rejects before any network request, so this is offline.
212        for url in [
213            "http://registry.npmjs.org/x",
214            "file:///etc/passwd",
215            "ftp://example.com/x",
216            "registry.npmjs.org/x",
217        ] {
218            assert!(fetch(url).is_err(), "{url:?} must be refused");
219        }
220    }
221
222    #[test]
223    fn post_json_refuses_non_https() {
224        // The scheme guard rejects before any network request, so this is offline.
225        for url in [
226            "http://api.example.com/x",
227            "ftp://example.com/x",
228            "api.example.com/x",
229        ] {
230            assert!(
231                post_json(url, b"{}", None, None).is_none(),
232                "{url:?} must be refused"
233            );
234        }
235    }
236
237    #[test]
238    fn the_agent_refuses_redirects_off_https() {
239        // `https_only` applies the scheme guard to every request in a redirect chain, not just
240        // the initial URL `fetch_with_accept` checks.
241        assert!(agent_config(Timeouts::default()).https_only());
242    }
243
244    #[test]
245    fn timeouts_from_cli_flags() {
246        let d = Timeouts::default();
247        // Neither flag → the library default.
248        let unset = Timeouts::from_cli(None, false);
249        assert_eq!((unset.connect, unset.global), (d.connect, d.global));
250        // --timeout sets the per-request timeout, keeping the default connect.
251        let t = Timeouts::from_cli(Some(5), false);
252        assert_eq!(t.global, Some(Duration::from_secs(5)));
253        assert_eq!(t.connect, d.connect);
254        // --no-timeout removes every bound and wins over --timeout.
255        let off = Timeouts::from_cli(Some(5), true);
256        assert_eq!((off.connect, off.global), (None, None));
257    }
258}