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 {
57            context: "spawning curl".to_string(),
58            source,
59        })?;
60
61    if !status.success() {
62        return Err(Error::CurlFailed {
63            url: url.to_string(),
64            code: status.code().unwrap_or(-1),
65        });
66    }
67
68    let actual = hash_file(dest)?;
69    match expected_sha256 {
70        Some(expected) => {
71            if !actual.eq_ignore_ascii_case(expected) {
72                return Err(Error::ChecksumMismatch {
73                    expected: expected.to_string(),
74                    actual,
75                });
76            }
77            tracing::info!(sha256 = %actual, "sha256 verified");
78        }
79        None => {
80            tracing::warn!(
81                sha256 = %actual,
82                "sha256 verification skipped (no expected hash supplied)"
83            );
84        }
85    }
86    Ok(())
87}
88
89fn hash_file(path: &Path) -> Result<String, Error> {
90    let mut file = File::open(path).map_err(|source| Error::Io {
91        context: format!("opening {} for hashing", path.display()),
92        source,
93    })?;
94    let mut hasher = Sha256::new();
95    let mut buf = vec![0u8; HASH_CHUNK];
96    loop {
97        let n = file.read(&mut buf).map_err(|source| Error::Io {
98            context: format!("reading {}", path.display()),
99            source,
100        })?;
101        if n == 0 {
102            break;
103        }
104        hasher.update(&buf[..n]);
105    }
106    Ok(format!("{:x}", hasher.finalize()))
107}