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#[cfg(not(all(
9    target_os = "linux",
10    any(target_arch = "x86_64", target_arch = "aarch64")
11)))]
12compile_error!("rootle supports Linux x86_64 and aarch64 only");
13
14/// The native Linux release target; GNU hosts use the static musl artifact.
15pub fn platform_target() -> &'static str {
16    if cfg!(target_arch = "aarch64") {
17        "aarch64-unknown-linux-musl"
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
44/// One request/body boundary for release metadata and artifacts. Payloads stay
45/// in memory for their consumer; diagnostics retain only safe endpoint metadata.
46fn fetch<T>(
47    url: &str,
48    release_metadata: bool,
49    decode: impl FnOnce(&[u8]) -> Result<T>,
50) -> Result<T> {
51    let request_id = rootle_trace::operation_id();
52    let started = rootle_trace::enabled().then(std::time::Instant::now);
53    rootle_trace::record_with(rootle_trace::EventKind::HttpRequest, || {
54        let endpoint = reqwest::Url::parse(url).ok();
55        serde_json::json!({
56            "client":"manager", "request_id":request_id, "method":"GET",
57            "resource":if release_metadata { "release_metadata" } else { "artifact" },
58            "scheme":endpoint.as_ref().map(reqwest::Url::scheme),
59            "host":endpoint.as_ref().and_then(reqwest::Url::host_str),
60            "port":endpoint.as_ref().and_then(reqwest::Url::port),
61        })
62    });
63    let mut request = http().get(url);
64    if release_metadata {
65        request = request.header("Accept", "application/vnd.github+json");
66    }
67    let response = request.send().map_err(|error| {
68        record_http(request_id, started, None, None, http_error_kind(&error));
69        ManagerError::Network(error.to_string())
70    })?;
71    let status = response.status().as_u16();
72    let response = response.error_for_status().map_err(|error| {
73        record_http(request_id, started, Some(status), None, "http_error");
74        let source = if release_metadata {
75            "github api"
76        } else {
77            "download"
78        };
79        ManagerError::Network(format!("{source}: {error}"))
80    })?;
81    let bytes = response.bytes().map_err(|error| {
82        record_http(
83            request_id,
84            started,
85            Some(status),
86            None,
87            http_error_kind(&error),
88        );
89        ManagerError::Network(error.to_string())
90    })?;
91    let result = decode(&bytes);
92    record_http(
93        request_id,
94        started,
95        Some(status),
96        Some(bytes.len()),
97        if result.is_ok() {
98            "success"
99        } else {
100            "decode_error"
101        },
102    );
103    result
104}
105
106fn record_http(
107    request_id: Option<rootle_trace::OperationId>,
108    started: Option<std::time::Instant>,
109    status: Option<u16>,
110    bytes: Option<usize>,
111    outcome: &'static str,
112) {
113    rootle_trace::record_with(rootle_trace::EventKind::HttpResponse, || {
114        serde_json::json!({"client":"manager", "request_id":request_id, "status":status,
115            "body_bytes":bytes, "outcome":outcome,
116            "duration_us":started.map(|started|started.elapsed().as_micros())})
117    });
118}
119
120fn http_error_kind(error: &reqwest::Error) -> &'static str {
121    if error.is_timeout() {
122        "timeout"
123    } else if error.is_connect() {
124        "connect"
125    } else if error.is_builder() {
126        "request"
127    } else if error.is_body() {
128        "body"
129    } else if error.is_decode() {
130        "decode"
131    } else {
132        "transport"
133    }
134}
135
136pub fn latest_release(repo: &str) -> Result<Release> {
137    latest_release_at("https://api.github.com", repo)
138}
139
140/// Tests point this at a loopback host.
141pub fn latest_release_at(api: &str, repo: &str) -> Result<Release> {
142    let url = format!("{api}/repos/{repo}/releases/latest");
143    fetch(&url, true, |bytes| {
144        serde_json::from_slice(bytes)
145            .map_err(|error| ManagerError::Network(format!("github api decode: {error}")))
146    })
147}
148
149/// Tests point this at a loopback host.
150pub fn release_by_tag_at(api: &str, repo: &str, tag: &str) -> Result<Release> {
151    let url = format!("{api}/repos/{repo}/releases/tags/{tag}");
152    fetch(&url, true, |bytes| {
153        serde_json::from_slice(bytes)
154            .map_err(|error| ManagerError::Network(format!("github api decode: {error}")))
155    })
156}
157
158/// Pick the asset for this platform: `<anything>-<target>.tar.gz`,
159/// suffix-matched (gh extension's model — tolerate version prefixes
160/// and name variants).
161pub fn pick_asset<'a>(release: &'a Release, target: &str) -> Result<&'a Asset> {
162    release
163        .assets
164        .iter()
165        .find(|a| a.name.ends_with(&format!("-{target}.tar.gz")))
166        .ok_or_else(|| {
167            ManagerError::User(format!(
168                "release {} has no {target} tarball (assets: {})",
169                release.tag_name,
170                release
171                    .assets
172                    .iter()
173                    .map(|a| a.name.as_str())
174                    .collect::<Vec<_>>()
175                    .join(", ")
176            ))
177        })
178}
179
180/// The `.sha256` sidecar for `asset` — no sidecar, no install.
181pub fn checksum_sidecar<'a>(release: &'a Release, asset: &Asset) -> Result<&'a Asset> {
182    release
183        .assets
184        .iter()
185        .find(|a| a.name == format!("{}.sha256", asset.name))
186        .ok_or_else(|| {
187            ManagerError::User(format!(
188                "release {} has no checksum sidecar for {} — refusing to install \
189                 without verification",
190                release.tag_name, asset.name
191            ))
192        })
193}
194
195pub fn download_bytes(url: &str) -> Result<Vec<u8>> {
196    fetch(url, false, |bytes| Ok(bytes.to_vec()))
197}
198
199/// Extract the binary from the tarball: find the single executable
200/// file, return its bytes (our tarballs contain `<dir>/<binary>`).
201pub fn extract_binary(tarball: &[u8], binary_name: &str) -> Result<Vec<u8>> {
202    use flate2::read::GzDecoder;
203    use tar::Archive;
204    let decoder = GzDecoder::new(tarball);
205    let mut archive = Archive::new(decoder);
206    for entry in archive
207        .entries()
208        .map_err(|e| ManagerError::User(format!("tarball: {e}")))?
209    {
210        let mut entry = entry.map_err(|e| ManagerError::User(format!("tarball: {e}")))?;
211        let path = entry
212            .path()
213            .map_err(|e| ManagerError::User(format!("tarball: {e}")))?
214            .to_string_lossy()
215            .into_owned();
216        if path.ends_with(binary_name) && entry.header().entry_type().is_file() {
217            let mut bytes = Vec::new();
218            use std::io::Read;
219            entry
220                .read_to_end(&mut bytes)
221                .map_err(|e| ManagerError::User(format!("tarball: {e}")))?;
222            return Ok(bytes);
223        }
224    }
225    Err(ManagerError::User(format!(
226        "no {binary_name} inside the tarball"
227    )))
228}
229
230pub fn sha256_hex(bytes: &[u8]) -> String {
231    use sha2::{Digest, Sha256};
232    let mut hasher = Sha256::new();
233    hasher.update(bytes);
234    let digest = hasher.finalize();
235    digest.iter().map(|b| format!("{b:02x}")).collect()
236}
237
238/// Verify against the `.sha256` sidecar asset — mandatory (krew rule:
239/// a missing checksum is a failed install, not a warning).
240pub fn verify_checksum(tarball: &[u8], sidecar_url: &str) -> Result<()> {
241    let sidecar = download_bytes(sidecar_url)?;
242    let text = String::from_utf8_lossy(&sidecar);
243    let expected = text
244        .split_whitespace()
245        .next()
246        .unwrap_or_default()
247        .to_lowercase();
248    let got = sha256_hex(tarball);
249    rootle_trace::record_with(
250        rootle_trace::EventKind::ExternalCommand,
251        || serde_json::json!({"operation":"checksum_verify", "bytes":tarball.len(), "matched":got == expected}),
252    );
253    if got != expected {
254        return Err(ManagerError::User(format!(
255            "checksum mismatch: expected {expected}, got {got}"
256        )));
257    }
258    Ok(())
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn asset_picking_matches_the_matrix() {
267        let release = Release {
268            tag_name: "v0.1.0".into(),
269            assets: vec![
270                Asset {
271                    name: "rootle-gitlab-0.1.0-x86_64-unknown-linux-musl.tar.gz".into(),
272                    browser_download_url: "u1".into(),
273                },
274                Asset {
275                    name: "rootle-gitlab-0.1.0-aarch64-unknown-linux-musl.tar.gz".into(),
276                    browser_download_url: "u2".into(),
277                },
278            ],
279        };
280        assert_eq!(
281            pick_asset(&release, "aarch64-unknown-linux-musl")
282                .unwrap()
283                .browser_download_url,
284            "u2"
285        );
286        assert!(pick_asset(&release, "i686-unknown-linux-gnu").is_err());
287    }
288}