Skip to main content

git_ark/
release.rs

1//! Fetch the matching prebuilt host binary from the GitHub release.
2//!
3//! `host add` and `upgrade` need a git-ark binary built for the *host's* triple.
4//! Rather than make the operator cross-compile one or download it by hand, we
5//! pull the asset for the client's own version straight from the release,
6//! verify its SHA-256 against the release's `SHA256SUMS`, and stream it to the
7//! host — so the host never needs a toolchain, and host and client always run
8//! the same version. Release assets are public, so no token is involved.
9
10use anyhow::{anyhow, bail, Context, Result};
11use sha2::{Digest, Sha256};
12use std::io::Read;
13
14/// `owner/repo` the release assets are published under. Overridable via
15/// `GIT_ARK_RELEASE_REPO` (forks / testing); defaults to the canonical repo.
16const DEFAULT_RELEASE_REPO: &str = "boomctl/git-ark";
17
18/// This client's version — the release tag we fetch host binaries from.
19pub fn version() -> &'static str {
20    env!("CARGO_PKG_VERSION")
21}
22
23fn release_repo() -> String {
24    std::env::var("GIT_ARK_RELEASE_REPO").unwrap_or_else(|_| DEFAULT_RELEASE_REPO.to_string())
25}
26
27/// Release asset file name for a host triple, e.g.
28/// `git-ark-x86_64-unknown-linux-musl`.
29pub fn asset_name(triple: &str) -> String {
30    format!("git-ark-{triple}")
31}
32
33/// Base download URL for a release, e.g.
34/// `https://github.com/boomctl/git-ark/releases/download/v0.2.0`.
35fn release_base(repo: &str, version: &str) -> String {
36    format!("https://github.com/{repo}/releases/download/v{version}")
37}
38
39/// The expected SHA-256 (lowercase hex) for `asset` from a `SHA256SUMS` body.
40/// Each line is `<hex>␠␠<name>` (or single-space); we match the file name and
41/// return its hash. Returns `None` if the asset isn't listed.
42pub fn expected_sha256(sums: &str, asset: &str) -> Option<String> {
43    sums.lines().find_map(|line| {
44        let mut it = line.split_whitespace();
45        let hash = it.next()?;
46        let name = it.next()?;
47        (name == asset).then(|| hash.to_ascii_lowercase())
48    })
49}
50
51/// Lowercase-hex SHA-256 of `bytes`.
52pub fn sha256_hex(bytes: &[u8]) -> String {
53    let mut h = Sha256::new();
54    h.update(bytes);
55    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
56}
57
58fn http_get_bytes(url: &str) -> Result<Vec<u8>> {
59    let resp = ureq::get(url).call().map_err(|e| match e {
60        ureq::Error::Status(404, _) => anyhow!("not found (404)"),
61        other => anyhow!("{other}"),
62    })?;
63    let mut buf = Vec::new();
64    resp.into_reader()
65        .read_to_end(&mut buf)
66        .with_context(|| format!("reading response body from {url}"))?;
67    Ok(buf)
68}
69
70/// Download the release binary for `triple`, verified against the release's
71/// `SHA256SUMS`, and return its bytes. Errors name the URL tried and point at
72/// `--binary` as the escape hatch when no matching release asset exists.
73pub fn fetch_host_binary(triple: &str) -> Result<Vec<u8>> {
74    let repo = release_repo();
75    let version = version();
76    let base = release_base(&repo, version);
77    let asset = asset_name(triple);
78
79    let bin_url = format!("{base}/{asset}");
80    let sums_url = format!("{base}/SHA256SUMS");
81
82    let bytes = http_get_bytes(&bin_url).map_err(|e| {
83        anyhow!(
84            "couldn't fetch the host binary for {triple}: {e} ({bin_url}). \
85             If there's no v{version} release for this platform, pass \
86             --binary <path> to supply one."
87        )
88    })?;
89    let sums =
90        String::from_utf8(http_get_bytes(&sums_url).context("fetching the release SHA256SUMS")?)
91            .context("release SHA256SUMS is not valid UTF-8")?;
92
93    let want = expected_sha256(&sums, &asset)
94        .ok_or_else(|| anyhow!("no checksum for {asset} in the release SHA256SUMS"))?;
95    let got = sha256_hex(&bytes);
96    if got != want {
97        bail!("checksum mismatch for {asset}: expected {want}, got {got}");
98    }
99    Ok(bytes)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn asset_name_is_git_ark_dash_triple() {
108        assert_eq!(
109            asset_name("x86_64-unknown-linux-musl"),
110            "git-ark-x86_64-unknown-linux-musl"
111        );
112    }
113
114    #[test]
115    fn release_base_pins_the_client_version_tag() {
116        assert_eq!(
117            release_base("acme/git-ark", "1.2.3"),
118            "https://github.com/acme/git-ark/releases/download/v1.2.3"
119        );
120    }
121
122    #[test]
123    fn expected_sha256_matches_by_trailing_filename() {
124        // Real SHA256SUMS use two spaces between hash and name.
125        let sums = "\
126aaaa1111  git-ark-x86_64-unknown-linux-musl
127BBBB2222  git-ark-aarch64-unknown-linux-musl
128cccc3333  git-ark-x86_64-pc-windows-msvc.exe
129";
130        assert_eq!(
131            expected_sha256(sums, "git-ark-aarch64-unknown-linux-musl").as_deref(),
132            Some("bbbb2222"), // lowercased
133        );
134        assert_eq!(
135            expected_sha256(sums, "git-ark-x86_64-unknown-linux-musl").as_deref(),
136            Some("aaaa1111"),
137        );
138    }
139
140    #[test]
141    fn expected_sha256_none_for_absent_asset() {
142        let sums = "aaaa1111  git-ark-x86_64-unknown-linux-musl\n";
143        assert_eq!(expected_sha256(sums, "git-ark-nonesuch"), None);
144    }
145
146    #[test]
147    fn expected_sha256_does_not_match_a_name_substring() {
148        // "git-ark-x86_64-apple-darwin" must not match "…-apple-darwin"-suffixed
149        // rows for a different arch, nor a prefix — exact file-name match only.
150        let sums = "aaaa1111  git-ark-x86_64-apple-darwin\n";
151        assert_eq!(expected_sha256(sums, "git-ark-x86_64-apple"), None);
152        assert_eq!(expected_sha256(sums, "apple-darwin"), None);
153    }
154
155    #[test]
156    fn sha256_hex_known_vector() {
157        // SHA-256("") — the canonical empty-input digest.
158        assert_eq!(
159            sha256_hex(b""),
160            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
161        );
162        // SHA-256("abc").
163        assert_eq!(
164            sha256_hex(b"abc"),
165            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
166        );
167    }
168}