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(|| {
73 let t = timeouts();
74 ureq::Agent::new_with_config(
75 ureq::Agent::config_builder()
76 .tls_config(
77 TlsConfig::builder()
78 .root_certs(RootCerts::PlatformVerifier)
79 .build(),
80 )
81 .timeout_connect(t.connect)
82 .timeout_global(t.global)
83 // The resolver prefetches packuments 8-wide against a single registry host
84 // (`registry`'s PACKUMENT_CONCURRENCY); ureq's idle-pool defaults (3 per
85 // host, 10 total) would drop and re-handshake most of those connections
86 // between rounds.
87 .max_idle_connections_per_host(8)
88 .max_idle_connections(16)
89 .build(),
90 )
91 })
92 .clone()
93}
94
95/// Download an `https://` URL into memory (100 MB cap), retrying once on transient failure.
96///
97/// Only `https` is fetched: a non-https URL is refused up front. The tarball URL is advertised
98/// by the registry, so this keeps a hostile or redirecting registry from steering us at a
99/// plain-http or internal endpoint (the downloaded bytes are sha512-verified regardless — this
100/// is defense-in-depth). Per-request connect and transfer timeouts are set so a stalled peer
101/// can't hang the build; the 100 MB cap bounds size, the timeouts bound time.
102///
103/// Some hosts (GitHub in particular) occasionally drop a connection
104/// mid-transfer — observed as `io: Peer disconnected` on CI — and the same URL
105/// has not been seen to fail twice in a row, so one retry after a short pause is
106/// enough.
107pub fn fetch(url: &str) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
108 fetch_with_accept(url, None)
109}
110
111/// Like [`fetch`], but sends an `Accept` header — used to request the npm registry's abbreviated
112/// packument (`application/vnd.npm.install-v1+json`), which is far smaller than the full document.
113pub fn fetch_with_accept(
114 url: &str,
115 accept: Option<&str>,
116) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
117 if !url.starts_with("https://") {
118 return Err(format!(
119 "refusing to fetch non-https URL {url:?}: npm-utils downloads over https only"
120 )
121 .into());
122 }
123 let agent = agent();
124
125 let attempts = 2;
126 for attempt in 1..=attempts {
127 match try_fetch(&agent, url, accept) {
128 Ok(body) => return Ok(body),
129 Err(e) if attempt < attempts => {
130 crate::warn::warn(&format!(
131 "download attempt {attempt}/{attempts} failed for {url}: {e}; \
132 retrying in 500ms"
133 ));
134 std::thread::sleep(Duration::from_millis(500));
135 }
136 Err(e) => return Err(e),
137 }
138 }
139 unreachable!()
140}
141
142fn try_fetch(
143 agent: &ureq::Agent,
144 url: &str,
145 accept: Option<&str>,
146) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
147 let request = match accept {
148 Some(accept) => agent.get(url).header("Accept", accept),
149 None => agent.get(url),
150 };
151 let mut response = request.call()?;
152 let body = response.body_mut();
153 Ok(body.with_config().limit(100 * 1024 * 1024).read_to_vec()?)
154}
155
156/// POST `body` to an `https://` URL and return the parsed JSON response, or `None` on **any**
157/// failure — a non-https URL, a network error, a non-2xx status, or an unparseable body.
158///
159/// The single-attempt, error-swallowing contract is deliberate: the audit advisory sources read
160/// `None` as "no advisories", so an unreachable endpoint, a 410 (npm's retired legacy paths), or a
161/// flaky link degrades to an empty result instead of failing the run — matching `npm audit` /
162/// `pnpm audit`, which exit 0 when the advisory endpoint can't be reached.
163///
164/// `content_encoding` sets the `Content-Encoding` header (`Some("gzip")` when `body` is
165/// gzip-compressed, as npm's bulk-advisory endpoint requires); `accept` overrides the `Accept`
166/// header (default `application/json`). `Content-Type` is always `application/json`.
167pub fn post_json(
168 url: &str,
169 body: &[u8],
170 content_encoding: Option<&str>,
171 accept: Option<&str>,
172) -> Option<Value> {
173 if !url.starts_with("https://") {
174 return None;
175 }
176 let request = agent()
177 .post(url)
178 .header("Content-Type", "application/json")
179 .header("Accept", accept.unwrap_or("application/json"));
180 let request = match content_encoding {
181 Some(enc) => request.header("Content-Encoding", enc),
182 None => request,
183 };
184 let mut response = request.send(body).ok()?;
185 let bytes = response
186 .body_mut()
187 .with_config()
188 .limit(100 * 1024 * 1024)
189 .read_to_vec()
190 .ok()?;
191 serde_json::from_slice::<Value>(&bytes).ok()
192}
193
194/// URL for a GitHub repository archive (zip) at a ref (branch, tag, or commit).
195pub fn github_archive_url(owner: &str, repo: &str, git_ref: &str) -> String {
196 format!("https://github.com/{owner}/{repo}/archive/{git_ref}.zip")
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn fetch_refuses_non_https() {
205 // The scheme guard rejects before any network request, so this is offline.
206 for url in [
207 "http://registry.npmjs.org/x",
208 "file:///etc/passwd",
209 "ftp://example.com/x",
210 "registry.npmjs.org/x",
211 ] {
212 assert!(fetch(url).is_err(), "{url:?} must be refused");
213 }
214 }
215
216 #[test]
217 fn post_json_refuses_non_https() {
218 // The scheme guard rejects before any network request, so this is offline.
219 for url in [
220 "http://api.example.com/x",
221 "ftp://example.com/x",
222 "api.example.com/x",
223 ] {
224 assert!(
225 post_json(url, b"{}", None, None).is_none(),
226 "{url:?} must be refused"
227 );
228 }
229 }
230
231 #[test]
232 fn timeouts_from_cli_flags() {
233 let d = Timeouts::default();
234 // Neither flag → the library default.
235 let unset = Timeouts::from_cli(None, false);
236 assert_eq!((unset.connect, unset.global), (d.connect, d.global));
237 // --timeout sets the per-request timeout, keeping the default connect.
238 let t = Timeouts::from_cli(Some(5), false);
239 assert_eq!(t.global, Some(Duration::from_secs(5)));
240 assert_eq!(t.connect, d.connect);
241 // --no-timeout removes every bound and wins over --timeout.
242 let off = Timeouts::from_cli(Some(5), true);
243 assert_eq!((off.connect, off.global), (None, None));
244 }
245}