use std::path::{Path, PathBuf};
const MAX_BYTES: u64 = 256 * 1024 * 1024;
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
pub async fn fetch_to_file(url: &str, job_dir: &Path) -> anyhow::Result<PathBuf> {
let dir = job_dir.join("fetched");
std::fs::create_dir_all(&dir)
.map_err(|e| anyhow::anyhow!("creating {}: {e}", dir.display()))?;
let target = dir.join(file_name_for(url));
if target.exists() {
return Ok(target);
}
let client = reqwest::Client::builder()
.timeout(TIMEOUT)
.user_agent(concat!("cuttlefish/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| anyhow::anyhow!("building the HTTP client: {e}"))?;
let response = client
.get(url)
.send()
.await
.map_err(|e| anyhow::anyhow!("fetching {url}: {e}"))?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("fetching {url}: server returned {status}");
}
let mut written: u64 = 0;
let mut out = std::fs::File::create(&target)
.map_err(|e| anyhow::anyhow!("creating {}: {e}", target.display()))?;
let mut stream = response;
loop {
let chunk = match stream.chunk().await {
Ok(Some(c)) => c,
Ok(None) => break,
Err(e) => {
let _ = std::fs::remove_file(&target);
anyhow::bail!("reading the response for {url}: {e}");
}
};
written += chunk.len() as u64;
if written > MAX_BYTES {
let _ = std::fs::remove_file(&target);
anyhow::bail!(
"{url} exceeded the {MAX_BYTES}-byte fetch ceiling; \
it was stopped mid-download rather than filling the disk"
);
}
use std::io::Write as _;
out.write_all(&chunk)
.map_err(|e| anyhow::anyhow!("writing {}: {e}", target.display()))?;
}
Ok(target)
}
fn file_name_for(url: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
let digest = hasher.finalize();
let short: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
let extension = url
.rsplit('/')
.next()
.and_then(|last| last.split(['?', '#']).next())
.and_then(|last| last.rsplit_once('.'))
.map(|(_, ext)| ext)
.filter(|ext| ext.len() <= 8 && ext.chars().all(|c| c.is_ascii_alphanumeric()))
.unwrap_or("");
if extension.is_empty() {
short
} else {
format!("{short}.{extension}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_name_is_stable_and_keeps_a_useful_suffix() {
let a = file_name_for("https://x.org/docs/report.pdf");
assert_eq!(a, file_name_for("https://x.org/docs/report.pdf"));
assert!(a.ends_with(".pdf"), "{a}");
}
#[test]
fn urls_sharing_a_last_segment_do_not_collide() {
let a = file_name_for("https://x.org/2024/data.json");
let b = file_name_for("https://x.org/2025/data.json");
assert_ne!(a, b);
}
#[test]
fn a_query_string_is_part_of_the_identity() {
assert_ne!(
file_name_for("https://x.org/list?page=1"),
file_name_for("https://x.org/list?page=2"),
);
}
#[test]
fn a_url_with_no_sensible_suffix_still_gets_a_name() {
let n = file_name_for("https://x.org/transmittals");
assert!(!n.is_empty() && !n.contains('/'), "{n}");
assert!(!file_name_for("https://x.org/a.verylongextension").contains('.'));
}
}