use std::sync::OnceLock;
use tracing::info;
const MAX_DOWNLOAD_SIZE: usize = 10 * 1024 * 1024;
fn http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("failed to build HTTP client for remote registry")
})
}
#[derive(Debug)]
#[non_exhaustive]
pub enum FetchResult {
Ok {
size_bytes: usize,
content_type: Option<String>,
data: Vec<u8>,
},
Error(String),
}
pub async fn fetch_package(url: &str) -> FetchResult {
if !crate::server::ssrf::is_safe_url(url) {
return FetchResult::Error("URL rejected: private/internal network".into());
}
info!(url, "fetching remote tool package");
let response = match http_client().get(url).send().await {
Ok(r) => r,
Err(e) => return FetchResult::Error(format!("download failed: {e}")),
};
if !response.status().is_success() {
return FetchResult::Error(format!("HTTP {}", response.status()));
}
if let Some(len) = response.content_length()
&& len as usize > MAX_DOWNLOAD_SIZE
{
return FetchResult::Error(format!(
"package too large: {len} bytes (max {MAX_DOWNLOAD_SIZE})"
));
}
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
match response.bytes().await {
Ok(bytes) => {
if bytes.len() > MAX_DOWNLOAD_SIZE {
return FetchResult::Error(format!(
"package too large: {} bytes (max {MAX_DOWNLOAD_SIZE})",
bytes.len()
));
}
info!(
url,
size_bytes = bytes.len(),
"remote tool package downloaded"
);
FetchResult::Ok {
size_bytes: bytes.len(),
content_type,
data: bytes.to_vec(),
}
}
Err(e) => FetchResult::Error(format!("failed to read response body: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn max_download_size_is_reasonable() {
assert_eq!(MAX_DOWNLOAD_SIZE, 10 * 1024 * 1024);
}
#[tokio::test]
async fn fetch_rejects_private_url() {
let result = fetch_package("http://192.168.1.1/tool.wasm").await;
assert!(matches!(result, FetchResult::Error(ref e) if e.contains("private")));
}
#[tokio::test]
async fn fetch_rejects_localhost() {
let result = fetch_package("http://localhost:8080/tool.wasm").await;
assert!(matches!(result, FetchResult::Error(ref e) if e.contains("private")));
}
}