1use std::path::Path;
2
3use anyhow::{ensure, Context, Result};
4use sha2::Digest;
5use tokio::io::AsyncWriteExt;
6
7pub enum DestinationConvention {
22 ContentIndexed,
25}
26
27pub async fn verified_download(
30 url: &str,
31 digest: &str,
32 dest: &Path,
33 convention: DestinationConvention,
34) -> Result<()> {
35 tracing::debug!("Downloading content from {url:?}");
36
37 let prefix = format!("download-{}", digest.replace(':', "-"));
39 let dest_dir = dest.parent().context("invalid dest")?;
40 let (temp_file, temp_path) = tempfile::NamedTempFile::with_prefix_in(prefix, dest_dir)
41 .context("error creating download tempfile")?
42 .into_parts();
43
44 let mut resp = reqwest::get(url).await?.error_for_status()?;
46
47 let mut hasher = sha2::Sha256::new();
49 {
50 let mut temp_file = tokio::fs::File::from_std(temp_file);
51 while let Some(chunk) = resp.chunk().await? {
52 hasher.update(&chunk);
53 temp_file.write_all(&chunk).await?;
54 }
55 temp_file.flush().await?;
56 }
57
58 let actual_digest = format!("sha256:{:x}", hasher.finalize());
60 ensure!(
61 actual_digest == digest,
62 "invalid content digest; expected {digest}, downloaded {actual_digest}"
63 );
64
65 let persist_result = temp_path.persist_noclobber(dest);
67
68 persist_result.or_else(|e| {
69 let file_already_exists = e.error.kind() == std::io::ErrorKind::AlreadyExists;
70 if file_already_exists && matches!(convention, DestinationConvention::ContentIndexed) {
71 Ok(())
72 } else {
73 Err(e).with_context(|| {
74 format!("Failed to save download from {url} to {}", dest.display())
75 })
76 }
77 })
78}