use std::net::IpAddr;
use std::time::Duration;
use reqwest::Url;
use reqwest::redirect::{Attempt, Policy};
use sha2::{Digest, Sha256};
use super::hex;
const MAX_ARCHIVE: u64 = 64 * 1024 * 1024;
const MAX_REDIRECTS: usize = 5;
const WHOLE_DOWNLOAD: Duration = Duration::from_secs(120);
fn allowed(url: &Url) -> Result<(), String> {
if !url.username().is_empty() || url.password().is_some() {
return Err(format!(
"{} carries user info",
url.host_str().unwrap_or("a URL")
));
}
match url.scheme() {
"https" => Ok(()),
"http" if loopback(url) => Ok(()),
_ => Err(format!("{url} is not https")),
}
}
fn loopback(url: &Url) -> bool {
let Some(host) = url.host_str() else {
return false;
};
let bare = host.trim_start_matches('[').trim_end_matches(']');
match bare.parse::<IpAddr>() {
Ok(ip) => ip.is_loopback(),
Err(_) => bare.eq_ignore_ascii_case("localhost"),
}
}
fn redirect(attempt: Attempt<'_>) -> reqwest::redirect::Action {
if attempt.previous().len() > MAX_REDIRECTS {
return attempt.error(format!("more than {MAX_REDIRECTS} redirects"));
}
match allowed(attempt.url()) {
Ok(()) => attempt.follow(),
Err(why) => {
let why = format!("redirected: {why}");
attempt.error(why)
}
}
}
fn chain(e: &dyn std::error::Error) -> String {
let mut out = e.to_string();
let mut source = e.source();
while let Some(s) = source {
let text = s.to_string();
if !out.contains(&text) {
out.push_str(": ");
out.push_str(&text);
}
source = s.source();
}
out
}
pub(crate) async fn archive(url: &str, archive_sha256: &str) -> Result<Vec<u8>, String> {
let fail = |why: String| format!("cannot download {url}: {why}; nothing was cached");
let parsed = Url::parse(url).map_err(|e| fail(e.to_string()))?;
allowed(&parsed).map_err(fail)?;
let mut builder = reqwest::Client::builder();
if loopback(&parsed) {
builder = builder.no_proxy();
}
let client = builder
.no_gzip()
.no_brotli()
.no_deflate()
.no_zstd()
.redirect(Policy::custom(redirect))
.timeout(WHOLE_DOWNLOAD)
.build()
.map_err(|e| fail(chain(&e)))?;
let mut response = client
.get(parsed)
.header(reqwest::header::ACCEPT_ENCODING, "identity")
.send()
.await
.map_err(|e| fail(chain(&e)))?;
if !response.status().is_success() {
return Err(fail(response.status().to_string()));
}
let over = || fail("the archive is over 64 MiB".into());
if response.content_length().is_some_and(|n| n > MAX_ARCHIVE) {
return Err(over());
}
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|e| fail(chain(&e)))? {
if (body.len() + chunk.len()) as u64 > MAX_ARCHIVE {
return Err(over());
}
body.extend_from_slice(&chunk);
}
let digest = hex(&Sha256::digest(&body));
if digest != archive_sha256 {
return Err(fail(format!(
"the archive does not match its pinned SHA-256 (got {digest}, pinned {archive_sha256})"
)));
}
Ok(body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_https_or_loopback_http_without_user_info() {
let ok = |u: &str| allowed(&Url::parse(u).unwrap()).is_ok();
assert!(ok("https://megacode-harness.vercel.app/dl"));
assert!(ok("http://127.0.0.1:8080/dl"));
assert!(ok("http://127.8.0.1/dl"));
assert!(ok("http://[::1]:80/dl"));
assert!(ok("http://localhost/dl"));
assert!(!ok("http://example.com/dl"));
assert!(!ok("http://10.0.0.1/dl"));
assert!(!ok("ftp://127.0.0.1/dl"));
assert!(!ok("https://user@example.com/dl"));
assert!(!ok("https://:pw@example.com/dl"));
}
}