Skip to main content

hyperdb_bootstrap/
download.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Downloads the `hyperd` release archive to a temp file and verifies its
5//! SHA-256 digest.
6//!
7//! The download itself is performed by a `curl` subprocess rather than an
8//! in-process HTTP client — see the inline comment on
9//! [`crate::download::download_and_verify`] for the reason.
10
11use sha2::{Digest, Sha256};
12use std::fs::File;
13use std::io::Read;
14use std::path::Path;
15use std::process::Command;
16
17use crate::Error;
18
19const HASH_CHUNK: usize = 64 * 1024;
20
21/// Downloads `url` to `dest` via `curl` and, if `expected_sha256` is
22/// supplied, verifies that the downloaded bytes match.
23///
24/// When `expected_sha256` is `None`, the digest is still computed and
25/// logged at WARN level so that humans running ad-hoc bootstraps see what
26/// they got even though no pin was checked.
27///
28/// # Errors
29///
30/// - [`Error::Io`] if `curl` cannot be spawned or the output file cannot
31///   be hashed.
32/// - [`Error::CurlFailed`] if `curl` exits with a non-zero status.
33/// - [`Error::ChecksumMismatch`] if `expected_sha256` is supplied and the
34///   digest of the downloaded bytes differs.
35pub fn download_and_verify(
36    url: &str,
37    expected_sha256: Option<&str>,
38    dest: &Path,
39) -> Result<(), Error> {
40    tracing::info!(url, dest = %dest.display(), "downloading");
41
42    // Use curl rather than reqwest: Akamai's bot-protection layer on
43    // downloads.tableau.com blocks GitHub-hosted runner IPs when approached
44    // with non-browser TLS stacks, but allows curl's well-known client hello.
45    let status = Command::new("curl")
46        .args([
47            "--fail",
48            "--silent",
49            "--show-error",
50            "--location",
51            "--output",
52        ])
53        .arg(dest)
54        .arg(url)
55        .status()
56        .map_err(|source| Error::io("spawning curl", source))?;
57
58    if !status.success() {
59        return Err(Error::curl_failed(url, status.code().unwrap_or(-1)));
60    }
61
62    let actual = hash_file(dest)?;
63    match expected_sha256 {
64        Some(expected) => {
65            if !actual.eq_ignore_ascii_case(expected) {
66                return Err(Error::checksum_mismatch(expected, actual));
67            }
68            tracing::info!(sha256 = %actual, "sha256 verified");
69        }
70        None => {
71            tracing::warn!(
72                sha256 = %actual,
73                "sha256 verification skipped (no expected hash supplied)"
74            );
75        }
76    }
77    Ok(())
78}
79
80#[expect(
81    clippy::format_collect,
82    reason = "readable hex/string formatting loop; refactoring to fold! obscures intent"
83)]
84fn hash_file(path: &Path) -> Result<String, Error> {
85    let mut file = File::open(path)
86        .map_err(|source| Error::io(format!("opening {} for hashing", path.display()), source))?;
87    let mut hasher = Sha256::new();
88    let mut buf = vec![0u8; HASH_CHUNK];
89    loop {
90        let n = file
91            .read(&mut buf)
92            .map_err(|source| Error::io(format!("reading {}", path.display()), source))?;
93        if n == 0 {
94            break;
95        }
96        hasher.update(&buf[..n]);
97    }
98    // sha2 0.11 returns `Array<u8, _>` from `finalize()`, which (unlike
99    // the previous `GenericArray`) does not implement `LowerHex`. Iterate
100    // over the byte slice and lower-hex each byte ourselves.
101    let digest = hasher.finalize();
102    Ok(digest.iter().map(|b| format!("{b:02x}")).collect())
103}