rootle_manager/
release.rs1use super::{ManagerError, Result};
5use serde::Deserialize;
6use std::time::Duration;
7
8pub 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#[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
48pub 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
62pub 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
76pub 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
98pub 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
125pub 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
164pub 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}