1use 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
44fn 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
140pub 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
149pub 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
158pub 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
180pub 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
199pub 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
238pub 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 Asset {
279 name: "rootle-gitlab-0.1.0-x86_64-apple-darwin.tar.gz".into(),
280 browser_download_url: "u3".into(),
281 },
282 Asset {
283 name: "rootle-gitlab-0.1.0-aarch64-apple-darwin.tar.gz".into(),
284 browser_download_url: "u4".into(),
285 },
286 ],
287 };
288 assert_eq!(
289 pick_asset(&release, "aarch64-apple-darwin")
290 .unwrap()
291 .browser_download_url,
292 "u4"
293 );
294 assert!(pick_asset(&release, "i686-unknown-linux-gnu").is_err());
295 }
296}