1use std::sync::OnceLock;
4use std::time::Duration;
5use ureq::tls::{RootCerts, TlsConfig};
6
7#[derive(Clone, Copy, Debug)]
9pub struct Timeouts {
10 pub connect: Option<Duration>,
12 pub global: Option<Duration>,
15}
16
17impl Default for Timeouts {
18 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 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
50pub fn set_timeouts(timeouts: Timeouts) {
54 let _ = TIMEOUTS.set(timeouts);
55}
56
57fn timeouts() -> Timeouts {
58 TIMEOUTS.get().copied().unwrap_or_default()
59}
60
61pub fn fetch(url: &str) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
74 fetch_with_accept(url, None)
75}
76
77pub 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
133pub 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 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 let unset = Timeouts::from_cli(None, false);
160 assert_eq!((unset.connect, unset.global), (d.connect, d.global));
161 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 let off = Timeouts::from_cli(Some(5), true);
167 assert_eq!((off.connect, off.global), (None, None));
168 }
169}