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}