Skip to main content

rootle_manager/
release.rs

1//! GitHub release assets: the API calls, the platform matrix, tarball
2//! extraction, and the mandatory sha256 sidecar verification.
3
4use super::{ManagerError, Result};
5use serde::Deserialize;
6use std::time::Duration;
7
8/// The GitHub release this rootle downloads for (the 4-target matrix).
9pub fn platform_target() -> &'static str {
10    if cfg!(target_os = "linux") && cfg!(target_arch = "aarch64") {
11        "aarch64-unknown-linux-musl"
12    } else if cfg!(target_os = "linux") {
13        "x86_64-unknown-linux-musl"
14    } else if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") {
15        "aarch64-apple-darwin"
16    } else if cfg!(target_os = "macos") {
17        "x86_64-apple-darwin"
18    } else {
19        "x86_64-unknown-linux-musl"
20    }
21}
22
23/// One release asset, as the GitHub API reports it.
24#[derive(Debug, Deserialize)]
25pub struct Asset {
26    pub name: String,
27    pub browser_download_url: String,
28}
29
30#[derive(Debug, Deserialize)]
31pub struct Release {
32    pub tag_name: String,
33    pub(crate) assets: Vec<Asset>,
34}
35
36fn http() -> reqwest::blocking::Client {
37    reqwest::blocking::Client::builder()
38        .timeout(Duration::from_secs(120))
39        .user_agent(format!("rootle/{}", env!("CARGO_PKG_VERSION")))
40        .build()
41        .expect("http client")
42}
43
44pub fn latest_release(repo: &str) -> Result<Release> {
45    latest_release_at("https://api.github.com", repo)
46}
47
48/// Tests point this at a loopback host.
49pub fn latest_release_at(api: &str, repo: &str) -> Result<Release> {
50    let url = format!("{api}/repos/{repo}/releases/latest");
51    http()
52        .get(&url)
53        .header("Accept", "application/vnd.github+json")
54        .send()
55        .map_err(|e| ManagerError::Network(e.to_string()))?
56        .error_for_status()
57        .map_err(|e| ManagerError::Network(format!("github api: {e}")))?
58        .json()
59        .map_err(|e| ManagerError::Network(format!("github api decode: {e}")))
60}
61
62/// Tests point this at a loopback host.
63pub fn release_by_tag_at(api: &str, repo: &str, tag: &str) -> Result<Release> {
64    let url = format!("{api}/repos/{repo}/releases/tags/{tag}");
65    http()
66        .get(&url)
67        .header("Accept", "application/vnd.github+json")
68        .send()
69        .map_err(|e| ManagerError::Network(e.to_string()))?
70        .error_for_status()
71        .map_err(|e| ManagerError::Network(format!("github api: {e}")))?
72        .json()
73        .map_err(|e| ManagerError::Network(format!("github api decode: {e}")))
74}
75
76/// Pick the asset for this platform: `<anything>-<target>.tar.gz`,
77/// suffix-matched (gh extension's model — tolerate version prefixes
78/// and name variants).
79pub fn pick_asset<'a>(release: &'a Release, target: &str) -> Result<&'a Asset> {
80    release
81        .assets
82        .iter()
83        .find(|a| a.name.ends_with(&format!("-{target}.tar.gz")))
84        .ok_or_else(|| {
85            ManagerError::User(format!(
86                "release {} has no {target} tarball (assets: {})",
87                release.tag_name,
88                release
89                    .assets
90                    .iter()
91                    .map(|a| a.name.as_str())
92                    .collect::<Vec<_>>()
93                    .join(", ")
94            ))
95        })
96}
97
98/// The `.sha256` sidecar for `asset` — no sidecar, no install.
99pub fn checksum_sidecar<'a>(release: &'a Release, asset: &Asset) -> Result<&'a Asset> {
100    release
101        .assets
102        .iter()
103        .find(|a| a.name == format!("{}.sha256", asset.name))
104        .ok_or_else(|| {
105            ManagerError::User(format!(
106                "release {} has no checksum sidecar for {} — refusing to install \
107                 without verification",
108                release.tag_name, asset.name
109            ))
110        })
111}
112
113pub fn download_bytes(url: &str) -> Result<Vec<u8>> {
114    http()
115        .get(url)
116        .send()
117        .map_err(|e| ManagerError::Network(e.to_string()))?
118        .error_for_status()
119        .map_err(|e| ManagerError::Network(format!("download: {e}")))?
120        .bytes()
121        .map(|b| b.to_vec())
122        .map_err(|e| ManagerError::Network(e.to_string()))
123}
124
125/// Extract the binary from the tarball: find the single executable
126/// file, return its bytes (our tarballs contain `<dir>/<binary>`).
127pub fn extract_binary(tarball: &[u8], binary_name: &str) -> Result<Vec<u8>> {
128    use flate2::read::GzDecoder;
129    use tar::Archive;
130    let decoder = GzDecoder::new(tarball);
131    let mut archive = Archive::new(decoder);
132    for entry in archive
133        .entries()
134        .map_err(|e| ManagerError::User(format!("tarball: {e}")))?
135    {
136        let mut entry = entry.map_err(|e| ManagerError::User(format!("tarball: {e}")))?;
137        let path = entry
138            .path()
139            .map_err(|e| ManagerError::User(format!("tarball: {e}")))?
140            .to_string_lossy()
141            .into_owned();
142        if path.ends_with(binary_name) && entry.header().entry_type().is_file() {
143            let mut bytes = Vec::new();
144            use std::io::Read;
145            entry
146                .read_to_end(&mut bytes)
147                .map_err(|e| ManagerError::User(format!("tarball: {e}")))?;
148            return Ok(bytes);
149        }
150    }
151    Err(ManagerError::User(format!(
152        "no {binary_name} inside the tarball"
153    )))
154}
155
156pub fn sha256_hex(bytes: &[u8]) -> String {
157    use sha2::{Digest, Sha256};
158    let mut hasher = Sha256::new();
159    hasher.update(bytes);
160    let digest = hasher.finalize();
161    digest.iter().map(|b| format!("{b:02x}")).collect()
162}
163
164/// Verify against the `.sha256` sidecar asset — mandatory (krew rule:
165/// a missing checksum is a failed install, not a warning).
166pub fn verify_checksum(tarball: &[u8], sidecar_url: &str) -> Result<()> {
167    let sidecar = download_bytes(sidecar_url)?;
168    let text = String::from_utf8_lossy(&sidecar);
169    let expected = text
170        .split_whitespace()
171        .next()
172        .unwrap_or_default()
173        .to_lowercase();
174    let got = sha256_hex(tarball);
175    if got != expected {
176        return Err(ManagerError::User(format!(
177            "checksum mismatch: expected {expected}, got {got}"
178        )));
179    }
180    Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn asset_picking_matches_the_matrix() {
189        let release = Release {
190            tag_name: "v0.1.0".into(),
191            assets: vec![
192                Asset {
193                    name: "rootle-gitlab-0.1.0-x86_64-unknown-linux-musl.tar.gz".into(),
194                    browser_download_url: "u1".into(),
195                },
196                Asset {
197                    name: "rootle-gitlab-0.1.0-aarch64-unknown-linux-musl.tar.gz".into(),
198                    browser_download_url: "u2".into(),
199                },
200                Asset {
201                    name: "rootle-gitlab-0.1.0-x86_64-apple-darwin.tar.gz".into(),
202                    browser_download_url: "u3".into(),
203                },
204                Asset {
205                    name: "rootle-gitlab-0.1.0-aarch64-apple-darwin.tar.gz".into(),
206                    browser_download_url: "u4".into(),
207                },
208            ],
209        };
210        assert_eq!(
211            pick_asset(&release, "aarch64-apple-darwin")
212                .unwrap()
213                .browser_download_url,
214            "u4"
215        );
216        assert!(pick_asset(&release, "i686-unknown-linux-gnu").is_err());
217    }
218}