Skip to main content

npm_utils/
download.rs

1//! HTTP download helpers.
2
3use std::sync::OnceLock;
4use std::time::Duration;
5use ureq::tls::{RootCerts, TlsConfig};
6
7/// HTTP timeouts for downloads; `None` disables a bound.
8#[derive(Clone, Copy, Debug)]
9pub struct Timeouts {
10    /// Cap on establishing the connection.
11    pub connect: Option<Duration>,
12    /// Cap on a single request, connect through transfer — applied per fetch, not across the run
13    /// (ureq's per-call `timeout_global`).
14    pub global: Option<Duration>,
15}
16
17impl Default for Timeouts {
18    /// 30 s to connect, 120 s per request — enough for a large tarball on a slow link, while a
19    /// stalled peer can't hang the build.
20    fn default() -> Self {
21        Self {
22            connect: Some(Duration::from_secs(30)),
23            global: Some(Duration::from_secs(120)),
24        }
25    }
26}
27
28impl Timeouts {
29    /// Build from the CLI flags: `--no-timeout` removes every bound; `--timeout <secs>` sets the
30    /// per-request timeout (connect stays at the default); neither keeps the default.
31    pub fn from_cli(timeout_secs: Option<u64>, no_timeout: bool) -> Timeouts {
32        if no_timeout {
33            Timeouts {
34                connect: None,
35                global: None,
36            }
37        } else if let Some(secs) = timeout_secs {
38            Timeouts {
39                global: Some(Duration::from_secs(secs)),
40                ..Timeouts::default()
41            }
42        } else {
43            Timeouts::default()
44        }
45    }
46}
47
48static TIMEOUTS: OnceLock<Timeouts> = OnceLock::new();
49
50/// Override the process-wide download timeouts. Intended to be called once at startup (the CLI
51/// derives them from `--timeout` / `--no-timeout`); the library default applies if never set, and a
52/// later call is ignored.
53pub fn set_timeouts(timeouts: Timeouts) {
54    let _ = TIMEOUTS.set(timeouts);
55}
56
57fn timeouts() -> Timeouts {
58    TIMEOUTS.get().copied().unwrap_or_default()
59}
60
61/// Download an `https://` URL into memory (100 MB cap), retrying once on transient failure.
62///
63/// Only `https` is fetched: a non-https URL is refused up front. The tarball URL is advertised
64/// by the registry, so this keeps a hostile or redirecting registry from steering us at a
65/// plain-http or internal endpoint (the downloaded bytes are sha512-verified regardless — this
66/// is defense-in-depth). Per-request connect and transfer timeouts are set so a stalled peer
67/// can't hang the build; the 100 MB cap bounds size, the timeouts bound time.
68///
69/// Some hosts (GitHub in particular) occasionally drop a connection
70/// mid-transfer — observed as `io: Peer disconnected` on CI — and the same URL
71/// has not been seen to fail twice in a row, so one retry after a short pause is
72/// enough.
73pub fn fetch(url: &str) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
74    fetch_with_accept(url, None)
75}
76
77/// Like [`fetch`], but sends an `Accept` header — used to request the npm registry's abbreviated
78/// packument (`application/vnd.npm.install-v1+json`), which is far smaller than the full document.
79pub fn fetch_with_accept(
80    url: &str,
81    accept: Option<&str>,
82) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
83    if !url.starts_with("https://") {
84        return Err(format!(
85            "refusing to fetch non-https URL {url:?}: npm-utils downloads over https only"
86        )
87        .into());
88    }
89    let t = timeouts();
90    let agent = ureq::Agent::new_with_config(
91        ureq::Agent::config_builder()
92            .tls_config(
93                TlsConfig::builder()
94                    .root_certs(RootCerts::PlatformVerifier)
95                    .build(),
96            )
97            .timeout_connect(t.connect)
98            .timeout_global(t.global)
99            .build(),
100    );
101
102    let attempts = 2;
103    for attempt in 1..=attempts {
104        match try_fetch(&agent, url, accept) {
105            Ok(body) => return Ok(body),
106            Err(e) if attempt < attempts => {
107                eprintln!(
108                    "npm-utils: download attempt {attempt}/{attempts} failed for {url}: {e}; \
109                     retrying in 500ms"
110                );
111                std::thread::sleep(Duration::from_millis(500));
112            }
113            Err(e) => return Err(e),
114        }
115    }
116    unreachable!()
117}
118
119fn try_fetch(
120    agent: &ureq::Agent,
121    url: &str,
122    accept: Option<&str>,
123) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
124    let request = match accept {
125        Some(accept) => agent.get(url).header("Accept", accept),
126        None => agent.get(url),
127    };
128    let mut response = request.call()?;
129    let body = response.body_mut();
130    Ok(body.with_config().limit(100 * 1024 * 1024).read_to_vec()?)
131}
132
133/// URL for a GitHub repository archive (zip) at a ref (branch, tag, or commit).
134pub fn github_archive_url(owner: &str, repo: &str, git_ref: &str) -> String {
135    format!("https://github.com/{owner}/{repo}/archive/{git_ref}.zip")
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn fetch_refuses_non_https() {
144        // The scheme guard rejects before any network request, so this is offline.
145        for url in [
146            "http://registry.npmjs.org/x",
147            "file:///etc/passwd",
148            "ftp://example.com/x",
149            "registry.npmjs.org/x",
150        ] {
151            assert!(fetch(url).is_err(), "{url:?} must be refused");
152        }
153    }
154
155    #[test]
156    fn timeouts_from_cli_flags() {
157        let d = Timeouts::default();
158        // Neither flag → the library default.
159        let unset = Timeouts::from_cli(None, false);
160        assert_eq!((unset.connect, unset.global), (d.connect, d.global));
161        // --timeout sets the per-request timeout, keeping the default connect.
162        let t = Timeouts::from_cli(Some(5), false);
163        assert_eq!(t.global, Some(Duration::from_secs(5)));
164        assert_eq!(t.connect, d.connect);
165        // --no-timeout removes every bound and wins over --timeout.
166        let off = Timeouts::from_cli(Some(5), true);
167        assert_eq!((off.connect, off.global), (None, None));
168    }
169}