spin_loader/
http.rs

1use std::path::Path;
2
3use anyhow::{ensure, Context, Result};
4use sha2::Digest;
5use tokio::io::AsyncWriteExt;
6
7/// Describes the naming convention that `verified_download` is permitted
8/// to assume in the directory it saves downloads to.
9///
10/// Consumers (direct or indirect) of `verified_download` are expected to check
11/// if the file is already downloaded before calling it. This enum exists
12/// to address race conditions when the same blob is downloaded several times
13/// concurrently.
14///
15/// The significance of this is for when the destination file turns out to already
16/// exist after all (that is, has been created since the caller originally checked
17/// existence). In the ContentIndexed case, the name already existing guarantees that
18/// the file matches the download. If a caller uses `verified_download` for a
19/// *non*-content-indexed case then they must provide and handle a new variant
20/// of the enum.
21pub enum DestinationConvention {
22    /// The download destination is content-indexed; therefore, in the event
23    /// of a race, the loser of the race can be safely discarded.
24    ContentIndexed,
25}
26
27/// Downloads content from `url` which will be verified to match `digest` and
28/// then moved to `dest`.
29pub 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    // Prepare tempfile destination
38    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    // Begin download
45    let mut resp = reqwest::get(url).await?.error_for_status()?;
46
47    // Hash as we write to the tempfile
48    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    // Check the digest
59    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    // Move to final destination
66    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}