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) {
74 let base = format!("bbcloud-{tag}-{triple}");
75 let archive = format!("{base}.tar.gz");
76 let checksum = format!("{base}.sha256");
77 (archive, checksum)
78}
79
80pub const DEFAULT_RELEASE_API: &str = "https://api.github.com";
81
82#[derive(Debug, Deserialize)]
83struct ReleaseAsset {
84 name: String,
85 browser_download_url: String,
86}
87
88#[derive(Debug, Deserialize)]
89struct Release {
90 tag_name: String,
91 #[serde(default)]
92 assets: Vec<ReleaseAsset>,
93}
94
95fn release_client() -> Result<reqwest::Client> {
105 Ok(reqwest::Client::builder()
106 .connect_timeout(Duration::from_secs(10))
107 .timeout(Duration::from_secs(120))
108 .user_agent(concat!("bbcloud/", env!("CARGO_PKG_VERSION")))
109 .build()?)
110}
111
112const MAX_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024;
117const MAX_UNPACKED_BYTES: u64 = 200 * 1024 * 1024;
120const MAX_CHECKSUM_BYTES: u64 = 4 * 1024;
123const MAX_RELEASE_JSON_BYTES: u64 = 1024 * 1024;
127
128async fn fetch_bounded(
133 http: &reqwest::Client,
134 url: String,
135 limit: u64,
136 what: &str,
137) -> Result<Vec<u8>> {
138 let response = http.get(url).send().await?;
139 bound_body(response, limit, what).await
140}
141
142async fn bound_body(mut response: reqwest::Response, limit: u64, what: &str) -> Result<Vec<u8>> {
146 if let Some(len) = response.content_length() {
147 if len > limit {
148 return Err(BbError::Config(format!(
149 "{what} reports {len} bytes, larger than the {limit} byte limit"
150 )));
151 }
152 }
153 let mut buf = Vec::new();
154 while let Some(chunk) = response.chunk().await? {
155 buf.extend_from_slice(&chunk);
156 if buf.len() as u64 > limit {
157 return Err(BbError::Config(format!(
158 "{what} exceeded the {limit} byte limit"
159 )));
160 }
161 }
162 Ok(buf)
163}
164
165pub fn release_api_base() -> String {
166 std::env::var("BB_UPDATE_API_BASE").unwrap_or_else(|_| DEFAULT_RELEASE_API.to_string())
167}
168
169pub async fn run(format: Format, base_url: &str) -> Result<()> {
170 let current = env!("CARGO_PKG_VERSION");
171 let http = release_client()?;
172 let url = format!(
173 "{}/repos/biokraft/bbcloud/releases/latest",
174 base_url.trim_end_matches('/')
175 );
176 let response = http.get(&url).send().await?;
177 if !response.status().is_success() {
178 return Err(BbError::Api {
179 status: response.status().as_u16(),
180 message: "cannot reach the release api".into(),
181 });
182 }
183 let body = bound_body(response, MAX_RELEASE_JSON_BYTES, "release metadata").await?;
184 let release: Release = serde_json::from_slice(&body)?;
185 let latest = release.tag_name.clone();
186
187 if !is_newer(&latest, current) {
188 return report(format, current, &latest, true, "none");
189 }
190
191 let exe = std::env::current_exe().map_err(BbError::Io)?;
192 let action = match classify_install(&exe) {
193 InstallKind::Homebrew => "brew upgrade bb",
194 InstallKind::Cargo => "cargo install bbcloud --locked --force",
195 InstallKind::Standalone => {
196 let require_https = base_url.starts_with("https://");
203 self_update(&http, &release, &exe, require_https).await?;
204 "self-updated"
205 }
206 };
207 report(format, current, &latest, false, action)
208}
209
210fn report(
211 format: Format,
212 current: &str,
213 latest: &str,
214 up_to_date: bool,
215 action: &str,
216) -> Result<()> {
217 match format {
218 Format::Json => output::print_json(&serde_json::json!({
219 "current": current,
220 "latest": latest,
221 "up_to_date": up_to_date,
222 "action": action,
223 })),
224 Format::Human => {
225 if up_to_date {
226 output::success(&format!("bb {current} is up to date"));
227 } else {
228 output::info(&format!("{current} -> {latest}"));
229 if action == "self-updated" {
230 output::success("updated in place");
231 } else {
232 output::info(&format!("this install is managed elsewhere; run: {action}"));
233 }
234 }
235 Ok(())
236 }
237 }
238}
239
240struct StagedGuard {
244 path: std::path::PathBuf,
245 armed: bool,
246}
247
248impl StagedGuard {
249 fn new(path: std::path::PathBuf) -> Self {
250 Self { path, armed: true }
251 }
252
253 fn disarm(mut self) {
257 self.armed = false;
258 }
259}
260
261impl Drop for StagedGuard {
262 fn drop(&mut self) {
263 if self.armed {
264 let _ = std::fs::remove_file(&self.path);
265 }
266 }
267}
268
269fn checked_asset_url(name: &str, url: String, require_https: bool) -> Result<String> {
274 if require_https && !url.starts_with("https://") {
275 return Err(BbError::Config(format!(
276 "release asset {name} has a non-https download url"
277 )));
278 }
279 Ok(url)
280}
281
282async fn self_update(
285 http: &reqwest::Client,
286 release: &Release,
287 exe: &Path,
288 require_https: bool,
289) -> Result<()> {
290 let triple = current_triple()
291 .ok_or_else(|| BbError::Config("no published binary for this platform".into()))?;
292 let (archive_name, checksum_name) = asset_names(&release.tag_name, triple);
293
294 let find = |name: &str| -> Result<String> {
295 let url = release
296 .assets
297 .iter()
298 .find(|a| a.name == name)
299 .map(|a| a.browser_download_url.clone())
300 .ok_or_else(|| BbError::Config(format!("release asset {name} is missing")))?;
301 checked_asset_url(name, url, require_https)
302 };
303
304 let archive_bytes = fetch_bounded(
305 http,
306 find(&archive_name)?,
307 MAX_ARCHIVE_BYTES,
308 "release archive",
309 )
310 .await?;
311 let checksum_bytes = fetch_bounded(
312 http,
313 find(&checksum_name)?,
314 MAX_CHECKSUM_BYTES,
315 "checksum file",
316 )
317 .await?;
318 let expected = String::from_utf8_lossy(&checksum_bytes);
319 let expected = expected
320 .split_whitespace()
321 .next()
322 .unwrap_or_default()
323 .to_lowercase();
324
325 use sha2::{Digest, Sha256};
326 let actual = format!("{:x}", Sha256::digest(&archive_bytes));
327 if actual != expected {
328 return Err(BbError::Config(
329 "checksum mismatch — refusing to install this download".into(),
330 ));
331 }
332
333 let parent = exe
334 .parent()
335 .ok_or_else(|| BbError::Config("cannot determine the install directory".into()))?;
336
337 let now = std::time::SystemTime::now()
340 .duration_since(std::time::UNIX_EPOCH)
341 .map(|d| d.as_nanos())
342 .unwrap_or_default();
343 let staged = parent.join(format!(".bb-update-staged-{}-{now}", std::process::id()));
344
345 let mut found = false;
346 let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive_bytes[..]));
347 let mut archive = tar::Archive::new(decoder);
348 for entry in archive.entries()? {
349 let mut entry = entry?;
350 let is_bb = entry
351 .path()?
352 .file_name()
353 .map(|n| n == std::ffi::OsStr::new("bb"))
354 .unwrap_or(false);
355 if !is_bb {
356 continue;
357 }
358 if !entry.header().entry_type().is_file() {
365 continue;
366 }
367
368 let mut out = std::fs::OpenOptions::new()
371 .write(true)
372 .create_new(true)
373 .open(&staged)
374 .map_err(BbError::Io)?;
375 let guard = StagedGuard::new(staged.clone());
376
377 let mut limited = std::io::Read::take(&mut entry, MAX_UNPACKED_BYTES);
378 let copied = std::io::copy(&mut limited, &mut out).map_err(BbError::Io)?;
379 drop(out);
380 if copied >= MAX_UNPACKED_BYTES {
381 return Err(BbError::Config(format!(
382 "unpacked bb binary exceeds the {MAX_UNPACKED_BYTES} byte limit"
383 )));
384 }
385
386 #[cfg(unix)]
387 {
388 use std::os::unix::fs::PermissionsExt;
389 std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
390 .map_err(BbError::Io)?;
391 }
392
393 std::fs::rename(&staged, exe).map_err(BbError::Io)?;
396 guard.disarm();
397 found = true;
398 break;
399 }
400 if !found {
401 return Err(BbError::Config(
402 "archive contains no regular-file bb binary".into(),
403 ));
404 }
405 Ok(())
406}
407
408#[cfg(test)]
409#[allow(clippy::unwrap_used)]
410mod tests {
411 use super::*;
412 use std::path::Path;
413
414 #[test]
415 fn homebrew_paths_are_detected() {
416 for p in [
417 "/opt/homebrew/bin/bb",
418 "/usr/local/Cellar/bb/1.0.0/bin/bb",
419 "/home/linuxbrew/.linuxbrew/bin/bb",
420 ] {
421 assert_eq!(classify_install(Path::new(p)), InstallKind::Homebrew, "{p}");
422 }
423 }
424
425 #[test]
426 fn cargo_bin_is_detected() {
427 assert_eq!(
428 classify_install(Path::new("/Users/dev/.cargo/bin/bb")),
429 InstallKind::Cargo
430 );
431 }
432
433 #[test]
434 fn anything_else_is_standalone() {
435 for p in ["/usr/local/bin/bb", "/home/dev/.local/bin/bb", "./bb"] {
436 assert_eq!(
437 classify_install(Path::new(p)),
438 InstallKind::Standalone,
439 "{p}"
440 );
441 }
442 }
443
444 #[test]
445 fn versions_parse_with_and_without_a_v_prefix() {
446 assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
447 assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
448 assert_eq!(parse_version("v10.0.1"), Some((10, 0, 1)));
449 }
450
451 #[test]
452 fn malformed_versions_are_rejected_rather_than_panicking() {
453 for bad in ["", "v", "1.2", "1.2.x", "latest", "v1.2.3.4"] {
454 assert_eq!(parse_version(bad), None, "{bad}");
455 }
456 }
457
458 #[test]
459 fn is_newer_compares_each_component() {
460 assert!(is_newer("v1.0.1", "1.0.0"));
461 assert!(is_newer("v1.1.0", "1.0.9"));
462 assert!(is_newer("v2.0.0", "1.9.9"));
463 assert!(!is_newer("v1.0.0", "1.0.0"));
464 assert!(!is_newer("v0.9.0", "1.0.0"));
465 }
466
467 #[test]
470 fn unparseable_remote_tag_is_not_newer() {
471 assert!(!is_newer("garbage", "1.0.0"));
472 assert!(!is_newer("", "1.0.0"));
473 }
474
475 #[test]
476 fn https_asset_urls_are_required_when_enforced() {
477 assert!(
478 checked_asset_url("bb.tar.gz", "http://evil.example/bb.tar.gz".into(), true).is_err()
479 );
480 assert!(
481 checked_asset_url("bb.tar.gz", "https://example.com/bb.tar.gz".into(), true).is_ok()
482 );
483 }
484
485 #[test]
486 fn https_enforcement_is_skipped_for_the_test_override() {
487 assert!(
488 checked_asset_url("bb.tar.gz", "http://127.0.0.1:1234/bb.tar.gz".into(), false).is_ok()
489 );
490 }
491
492 #[test]
493 fn asset_names_follow_the_release_workflow_convention() {
494 let (archive, checksum) = asset_names("v1.0.0", "x86_64-apple-darwin");
495 assert_eq!(archive, "bbcloud-v1.0.0-x86_64-apple-darwin.tar.gz");
496 assert_eq!(checksum, "bbcloud-v1.0.0-x86_64-apple-darwin.sha256");
497 }
498}