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}