1use crate::error::{BbError, Result};
2use crate::output::{self, Format};
3use serde::Deserialize;
4use std::path::Path;
5use std::time::Duration;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum InstallKind {
9 Homebrew,
10 Cargo,
11 Standalone,
12}
13
14pub fn classify_install(exe: &Path) -> InstallKind {
18 let path = exe.to_string_lossy();
19 if path.contains("/homebrew/") || path.contains("/Cellar/") || path.contains("/linuxbrew/") {
20 return InstallKind::Homebrew;
21 }
22 if path.contains("/.cargo/bin/") {
23 return InstallKind::Cargo;
24 }
25 InstallKind::Standalone
26}
27
28pub fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
31 let trimmed = text.trim().trim_start_matches('v');
32 let mut parts = trimmed.split('.');
33 let major = parts.next()?.parse().ok()?;
34 let minor = parts.next()?.parse().ok()?;
35 let patch = parts.next()?.parse().ok()?;
36 if parts.next().is_some() {
37 return None;
38 }
39 Some((major, minor, patch))
40}
41
42pub fn is_newer(latest: &str, current: &str) -> bool {
46 match (parse_version(latest), parse_version(current)) {
47 (Some(l), Some(c)) => l > c,
48 _ => false,
49 }
50}
51
52pub fn current_triple() -> Option<&'static str> {
55 match (std::env::consts::OS, std::env::consts::ARCH) {
56 ("macos", "aarch64") => Some("aarch64-apple-darwin"),
57 ("macos", "x86_64") => Some("x86_64-apple-darwin"),
58 ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"),
59 ("linux", "aarch64") => Some("aarch64-unknown-linux-gnu"),
60 _ => None,
61 }
62}
63
64pub fn asset_names(tag: &str, triple: &str) -> (String, String) {
69 let archive = format!("bbcloud-{tag}-{triple}.tar.gz");
70 let checksum = format!("{archive}.sha256");
71 (archive, checksum)
72}
73
74pub const DEFAULT_RELEASE_API: &str = "https://api.github.com";
75
76#[derive(Debug, Deserialize)]
77struct ReleaseAsset {
78 name: String,
79 browser_download_url: String,
80}
81
82#[derive(Debug, Deserialize)]
83struct Release {
84 tag_name: String,
85 #[serde(default)]
86 assets: Vec<ReleaseAsset>,
87}
88
89fn release_client() -> Result<reqwest::Client> {
99 Ok(reqwest::Client::builder()
100 .connect_timeout(Duration::from_secs(10))
101 .timeout(Duration::from_secs(120))
102 .user_agent(concat!("bbcloud/", env!("CARGO_PKG_VERSION")))
103 .build()?)
104}
105
106const MAX_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024;
111const MAX_UNPACKED_BYTES: u64 = 200 * 1024 * 1024;
114const MAX_CHECKSUM_BYTES: u64 = 4 * 1024;
117const MAX_RELEASE_JSON_BYTES: u64 = 1024 * 1024;
121
122async fn fetch_bounded(
127 http: &reqwest::Client,
128 url: String,
129 limit: u64,
130 what: &str,
131) -> Result<Vec<u8>> {
132 let response = http.get(url).send().await?;
133 bound_body(response, limit, what).await
134}
135
136async fn bound_body(mut response: reqwest::Response, limit: u64, what: &str) -> Result<Vec<u8>> {
140 if let Some(len) = response.content_length() {
141 if len > limit {
142 return Err(BbError::Config(format!(
143 "{what} reports {len} bytes, larger than the {limit} byte limit"
144 )));
145 }
146 }
147 let mut buf = Vec::new();
148 while let Some(chunk) = response.chunk().await? {
149 buf.extend_from_slice(&chunk);
150 if buf.len() as u64 > limit {
151 return Err(BbError::Config(format!(
152 "{what} exceeded the {limit} byte limit"
153 )));
154 }
155 }
156 Ok(buf)
157}
158
159pub fn release_api_base() -> String {
160 std::env::var("BB_UPDATE_API_BASE").unwrap_or_else(|_| DEFAULT_RELEASE_API.to_string())
161}
162
163pub async fn run(format: Format, base_url: &str) -> Result<()> {
164 let current = env!("CARGO_PKG_VERSION");
165 let http = release_client()?;
166 let url = format!(
167 "{}/repos/biokraft/bbcloud/releases/latest",
168 base_url.trim_end_matches('/')
169 );
170 let response = http.get(&url).send().await?;
171 if !response.status().is_success() {
172 return Err(BbError::Api {
173 status: response.status().as_u16(),
174 message: "cannot reach the release api".into(),
175 });
176 }
177 let body = bound_body(response, MAX_RELEASE_JSON_BYTES, "release metadata").await?;
178 let release: Release = serde_json::from_slice(&body)?;
179 let latest = release.tag_name.clone();
180
181 if !is_newer(&latest, current) {
182 return report(format, current, &latest, true, "none");
183 }
184
185 let exe = std::env::current_exe().map_err(BbError::Io)?;
186 let action = match classify_install(&exe) {
187 InstallKind::Homebrew => "brew upgrade bb",
188 InstallKind::Cargo => "cargo install bbcloud --locked --force",
189 InstallKind::Standalone => {
190 let require_https = base_url.starts_with("https://");
197 self_update(&http, &release, &exe, require_https).await?;
198 "self-updated"
199 }
200 };
201 report(format, current, &latest, false, action)
202}
203
204fn report(
205 format: Format,
206 current: &str,
207 latest: &str,
208 up_to_date: bool,
209 action: &str,
210) -> Result<()> {
211 match format {
212 Format::Json => output::print_json(&serde_json::json!({
213 "current": current,
214 "latest": latest,
215 "up_to_date": up_to_date,
216 "action": action,
217 })),
218 Format::Human => {
219 if up_to_date {
220 output::success(&format!("bb {current} is up to date"));
221 } else {
222 output::info(&format!("{current} -> {latest}"));
223 if action == "self-updated" {
224 output::success("updated in place");
225 } else {
226 output::info(&format!("this install is managed elsewhere; run: {action}"));
227 }
228 }
229 Ok(())
230 }
231 }
232}
233
234struct StagedGuard {
238 path: std::path::PathBuf,
239 armed: bool,
240}
241
242impl StagedGuard {
243 fn new(path: std::path::PathBuf) -> Self {
244 Self { path, armed: true }
245 }
246
247 fn disarm(mut self) {
251 self.armed = false;
252 }
253}
254
255impl Drop for StagedGuard {
256 fn drop(&mut self) {
257 if self.armed {
258 let _ = std::fs::remove_file(&self.path);
259 }
260 }
261}
262
263fn checked_asset_url(name: &str, url: String, require_https: bool) -> Result<String> {
268 if require_https && !url.starts_with("https://") {
269 return Err(BbError::Config(format!(
270 "release asset {name} has a non-https download url"
271 )));
272 }
273 Ok(url)
274}
275
276async fn self_update(
279 http: &reqwest::Client,
280 release: &Release,
281 exe: &Path,
282 require_https: bool,
283) -> Result<()> {
284 let triple = current_triple()
285 .ok_or_else(|| BbError::Config("no published binary for this platform".into()))?;
286 let (archive_name, checksum_name) = asset_names(&release.tag_name, triple);
287
288 let find = |name: &str| -> Result<String> {
289 let url = release
290 .assets
291 .iter()
292 .find(|a| a.name == name)
293 .map(|a| a.browser_download_url.clone())
294 .ok_or_else(|| BbError::Config(format!("release asset {name} is missing")))?;
295 checked_asset_url(name, url, require_https)
296 };
297
298 let archive_bytes = fetch_bounded(
299 http,
300 find(&archive_name)?,
301 MAX_ARCHIVE_BYTES,
302 "release archive",
303 )
304 .await?;
305 let checksum_bytes = fetch_bounded(
306 http,
307 find(&checksum_name)?,
308 MAX_CHECKSUM_BYTES,
309 "checksum file",
310 )
311 .await?;
312 let expected = String::from_utf8_lossy(&checksum_bytes);
313 let expected = expected
314 .split_whitespace()
315 .next()
316 .unwrap_or_default()
317 .to_lowercase();
318
319 use sha2::{Digest, Sha256};
320 let actual = format!("{:x}", Sha256::digest(&archive_bytes));
321 if actual != expected {
322 return Err(BbError::Config(
323 "checksum mismatch — refusing to install this download".into(),
324 ));
325 }
326
327 let parent = exe
328 .parent()
329 .ok_or_else(|| BbError::Config("cannot determine the install directory".into()))?;
330
331 let now = std::time::SystemTime::now()
334 .duration_since(std::time::UNIX_EPOCH)
335 .map(|d| d.as_nanos())
336 .unwrap_or_default();
337 let staged = parent.join(format!(".bb-update-staged-{}-{now}", std::process::id()));
338
339 let mut found = false;
340 let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive_bytes[..]));
341 let mut archive = tar::Archive::new(decoder);
342 for entry in archive.entries()? {
343 let mut entry = entry?;
344 let is_bb = entry
345 .path()?
346 .file_name()
347 .map(|n| n == std::ffi::OsStr::new("bb"))
348 .unwrap_or(false);
349 if !is_bb {
350 continue;
351 }
352 if !entry.header().entry_type().is_file() {
359 continue;
360 }
361
362 let mut out = std::fs::OpenOptions::new()
365 .write(true)
366 .create_new(true)
367 .open(&staged)
368 .map_err(BbError::Io)?;
369 let guard = StagedGuard::new(staged.clone());
370
371 let mut limited = std::io::Read::take(&mut entry, MAX_UNPACKED_BYTES);
372 let copied = std::io::copy(&mut limited, &mut out).map_err(BbError::Io)?;
373 drop(out);
374 if copied >= MAX_UNPACKED_BYTES {
375 return Err(BbError::Config(format!(
376 "unpacked bb binary exceeds the {MAX_UNPACKED_BYTES} byte limit"
377 )));
378 }
379
380 #[cfg(unix)]
381 {
382 use std::os::unix::fs::PermissionsExt;
383 std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
384 .map_err(BbError::Io)?;
385 }
386
387 std::fs::rename(&staged, exe).map_err(BbError::Io)?;
390 guard.disarm();
391 found = true;
392 break;
393 }
394 if !found {
395 return Err(BbError::Config(
396 "archive contains no regular-file bb binary".into(),
397 ));
398 }
399 Ok(())
400}
401
402#[cfg(test)]
403#[allow(clippy::unwrap_used)]
404mod tests {
405 use super::*;
406 use std::path::Path;
407
408 #[test]
409 fn homebrew_paths_are_detected() {
410 for p in [
411 "/opt/homebrew/bin/bb",
412 "/usr/local/Cellar/bb/1.0.0/bin/bb",
413 "/home/linuxbrew/.linuxbrew/bin/bb",
414 ] {
415 assert_eq!(classify_install(Path::new(p)), InstallKind::Homebrew, "{p}");
416 }
417 }
418
419 #[test]
420 fn cargo_bin_is_detected() {
421 assert_eq!(
422 classify_install(Path::new("/Users/dev/.cargo/bin/bb")),
423 InstallKind::Cargo
424 );
425 }
426
427 #[test]
428 fn anything_else_is_standalone() {
429 for p in ["/usr/local/bin/bb", "/home/dev/.local/bin/bb", "./bb"] {
430 assert_eq!(
431 classify_install(Path::new(p)),
432 InstallKind::Standalone,
433 "{p}"
434 );
435 }
436 }
437
438 #[test]
439 fn versions_parse_with_and_without_a_v_prefix() {
440 assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
441 assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
442 assert_eq!(parse_version("v10.0.1"), Some((10, 0, 1)));
443 }
444
445 #[test]
446 fn malformed_versions_are_rejected_rather_than_panicking() {
447 for bad in ["", "v", "1.2", "1.2.x", "latest", "v1.2.3.4"] {
448 assert_eq!(parse_version(bad), None, "{bad}");
449 }
450 }
451
452 #[test]
453 fn is_newer_compares_each_component() {
454 assert!(is_newer("v1.0.1", "1.0.0"));
455 assert!(is_newer("v1.1.0", "1.0.9"));
456 assert!(is_newer("v2.0.0", "1.9.9"));
457 assert!(!is_newer("v1.0.0", "1.0.0"));
458 assert!(!is_newer("v0.9.0", "1.0.0"));
459 }
460
461 #[test]
464 fn unparseable_remote_tag_is_not_newer() {
465 assert!(!is_newer("garbage", "1.0.0"));
466 assert!(!is_newer("", "1.0.0"));
467 }
468
469 #[test]
470 fn https_asset_urls_are_required_when_enforced() {
471 assert!(
472 checked_asset_url("bb.tar.gz", "http://evil.example/bb.tar.gz".into(), true).is_err()
473 );
474 assert!(
475 checked_asset_url("bb.tar.gz", "https://example.com/bb.tar.gz".into(), true).is_ok()
476 );
477 }
478
479 #[test]
480 fn https_enforcement_is_skipped_for_the_test_override() {
481 assert!(
482 checked_asset_url("bb.tar.gz", "http://127.0.0.1:1234/bb.tar.gz".into(), false).is_ok()
483 );
484 }
485
486 #[test]
487 fn asset_names_follow_the_release_workflow_convention() {
488 let (archive, checksum) = asset_names("v1.0.0", "x86_64-apple-darwin");
489 assert_eq!(archive, "bbcloud-v1.0.0-x86_64-apple-darwin.tar.gz");
490 assert_eq!(checksum, "bbcloud-v1.0.0-x86_64-apple-darwin.tar.gz.sha256");
491 }
492}