use std::sync::OnceLock;
use std::time::Duration;
use ureq::tls::{RootCerts, TlsConfig};
#[derive(Clone, Copy, Debug)]
pub struct Timeouts {
pub connect: Option<Duration>,
pub global: Option<Duration>,
}
impl Default for Timeouts {
fn default() -> Self {
Self {
connect: Some(Duration::from_secs(30)),
global: Some(Duration::from_secs(120)),
}
}
}
impl Timeouts {
pub fn from_cli(timeout_secs: Option<u64>, no_timeout: bool) -> Timeouts {
if no_timeout {
Timeouts {
connect: None,
global: None,
}
} else if let Some(secs) = timeout_secs {
Timeouts {
global: Some(Duration::from_secs(secs)),
..Timeouts::default()
}
} else {
Timeouts::default()
}
}
}
static TIMEOUTS: OnceLock<Timeouts> = OnceLock::new();
pub fn set_timeouts(timeouts: Timeouts) {
let _ = TIMEOUTS.set(timeouts);
}
fn timeouts() -> Timeouts {
TIMEOUTS.get().copied().unwrap_or_default()
}
pub fn fetch(url: &str) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
fetch_with_accept(url, None)
}
pub fn fetch_with_accept(
url: &str,
accept: Option<&str>,
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
if !url.starts_with("https://") {
return Err(format!(
"refusing to fetch non-https URL {url:?}: npm-utils downloads over https only"
)
.into());
}
let t = timeouts();
let agent = ureq::Agent::new_with_config(
ureq::Agent::config_builder()
.tls_config(
TlsConfig::builder()
.root_certs(RootCerts::PlatformVerifier)
.build(),
)
.timeout_connect(t.connect)
.timeout_global(t.global)
.build(),
);
let attempts = 2;
for attempt in 1..=attempts {
match try_fetch(&agent, url, accept) {
Ok(body) => return Ok(body),
Err(e) if attempt < attempts => {
eprintln!(
"npm-utils: download attempt {attempt}/{attempts} failed for {url}: {e}; \
retrying in 500ms"
);
std::thread::sleep(Duration::from_millis(500));
}
Err(e) => return Err(e),
}
}
unreachable!()
}
fn try_fetch(
agent: &ureq::Agent,
url: &str,
accept: Option<&str>,
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
let request = match accept {
Some(accept) => agent.get(url).header("Accept", accept),
None => agent.get(url),
};
let mut response = request.call()?;
let body = response.body_mut();
Ok(body.with_config().limit(100 * 1024 * 1024).read_to_vec()?)
}
pub fn github_archive_url(owner: &str, repo: &str, git_ref: &str) -> String {
format!("https://github.com/{owner}/{repo}/archive/{git_ref}.zip")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fetch_refuses_non_https() {
for url in [
"http://registry.npmjs.org/x",
"file:///etc/passwd",
"ftp://example.com/x",
"registry.npmjs.org/x",
] {
assert!(fetch(url).is_err(), "{url:?} must be refused");
}
}
#[test]
fn timeouts_from_cli_flags() {
let d = Timeouts::default();
let unset = Timeouts::from_cli(None, false);
assert_eq!((unset.connect, unset.global), (d.connect, d.global));
let t = Timeouts::from_cli(Some(5), false);
assert_eq!(t.global, Some(Duration::from_secs(5)));
assert_eq!(t.connect, d.connect);
let off = Timeouts::from_cli(Some(5), true);
assert_eq!((off.connect, off.global), (None, None));
}
}