Skip to main content

hyperdb_bootstrap/
verify.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! HEAD each supported platform's download URL to confirm the pinned
5//! release is still reachable on Tableau's CDN. Used by the `verify`
6//! CLI subcommand and by CI workflows that guard against silent yanks
7//! or URL-scheme changes.
8
9use std::process::Command;
10
11use crate::platform::Platform;
12use crate::release::PinnedRelease;
13use crate::url::build_download_url;
14use crate::Error;
15
16const PLATFORMS: &[Platform] = &[
17    Platform::MacosArm64,
18    Platform::MacosX86_64,
19    Platform::LinuxX86_64,
20    Platform::WindowsX86_64,
21];
22
23/// Result of a single platform reachability probe performed by
24/// [`verify_release`].
25#[derive(Debug, Clone)]
26pub struct VerifyOutcome {
27    /// Platform the probe targeted.
28    pub platform: Platform,
29    /// URL that was probed.
30    pub url: String,
31    /// HTTP status returned by the CDN, or `None` if the probe itself failed.
32    pub status: Option<u16>,
33    /// Error message when `status` is `None` (spawn failure, parse failure,
34    /// stderr from a failed `curl` invocation).
35    pub error: Option<String>,
36}
37
38impl VerifyOutcome {
39    /// Returns `true` when the probe observed a 2xx/3xx HTTP status, which
40    /// is what `curl --head --location` returns when the CDN serves the
41    /// release.
42    #[must_use]
43    pub fn ok(&self) -> bool {
44        matches!(self.status, Some(s) if (200..400).contains(&s))
45    }
46}
47
48/// HEAD every supported platform URL for `release`. Returns one outcome
49/// per platform; callers decide how to surface failures.
50///
51/// Uses `curl --head` rather than reqwest so that Akamai's bot-protection
52/// layer (which blocks reqwest's TLS fingerprint from GitHub-hosted runner
53/// IPs) does not cause false 403 failures.
54///
55/// # Errors
56///
57/// Currently always returns `Ok(_)` — individual platform failures are
58/// surfaced through [`VerifyOutcome::error`]. The `Result` wrapper is
59/// kept for forward compatibility if a top-level failure mode is added.
60pub fn verify_release(release: &PinnedRelease) -> Result<Vec<VerifyOutcome>, Error> {
61    let mut out = Vec::with_capacity(PLATFORMS.len());
62    for &platform in PLATFORMS {
63        let url = build_download_url(release, platform);
64        let outcome = curl_head(&url);
65        out.push(VerifyOutcome {
66            platform,
67            url,
68            status: outcome.0,
69            error: outcome.1,
70        });
71    }
72    Ok(out)
73}
74
75/// Run `curl --head --silent --show-error --location <url>` and parse the
76/// HTTP status from the first status line. Returns `(Some(status), None)`
77/// on success and `(None, Some(error))` on spawn/parse failure.
78fn curl_head(url: &str) -> (Option<u16>, Option<String>) {
79    let result = Command::new("curl")
80        .args(["--head", "--silent", "--show-error", "--location"])
81        .arg(url)
82        .output();
83
84    match result {
85        Err(e) => (None, Some(format!("failed to spawn curl: {e}"))),
86        Ok(output) => {
87            if !output.status.success() && output.stdout.is_empty() {
88                let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
89                return (None, Some(stderr));
90            }
91            // Parse the last "HTTP/x.x NNN" status line (curl --location may
92            // produce multiple status lines when following redirects).
93            let stdout = String::from_utf8_lossy(&output.stdout);
94            let status = stdout
95                .lines()
96                .filter_map(|line| {
97                    let line = line.trim();
98                    // Matches "HTTP/1.1 200 OK", "HTTP/2 403", etc.
99                    if line.starts_with("HTTP/") {
100                        line.split_whitespace().nth(1)?.parse::<u16>().ok()
101                    } else {
102                        None
103                    }
104                })
105                .next_back();
106            match status {
107                Some(s) => (Some(s), None),
108                None => (
109                    None,
110                    Some(format!(
111                        "could not parse HTTP status from curl output: {}",
112                        stdout.chars().take(200).collect::<String>()
113                    )),
114                ),
115            }
116        }
117    }
118}