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
82const HOMEBREW_UPDATE_HINT: &str = "brew update && brew upgrade bb";
86
87#[derive(Debug, Deserialize)]
88struct ReleaseAsset {
89 name: String,
90 browser_download_url: String,
91}
92
93#[derive(Debug, Deserialize)]
94struct Release {
95 tag_name: String,
96 #[serde(default)]
97 assets: Vec<ReleaseAsset>,
98}
99
100fn release_client() -> Result<reqwest::Client> {
110 Ok(reqwest::Client::builder()
111 .connect_timeout(Duration::from_secs(10))
112 .timeout(Duration::from_secs(120))
113 .user_agent(concat!("bbcloud/", env!("CARGO_PKG_VERSION")))
114 .build()?)
115}
116
117const MAX_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024;
122const MAX_UNPACKED_BYTES: u64 = 200 * 1024 * 1024;
125const MAX_CHECKSUM_BYTES: u64 = 4 * 1024;
128const MAX_RELEASE_JSON_BYTES: u64 = 1024 * 1024;
132
133async fn fetch_bounded(
138 http: &reqwest::Client,
139 url: String,
140 limit: u64,
141 what: &str,
142) -> Result<Vec<u8>> {
143 let response = http.get(url).send().await?;
144 bound_body(response, limit, what).await
145}
146
147async fn bound_body(mut response: reqwest::Response, limit: u64, what: &str) -> Result<Vec<u8>> {
151 if let Some(len) = response.content_length() {
152 if len > limit {
153 return Err(BbError::Config(format!(
154 "{what} reports {len} bytes, larger than the {limit} byte limit"
155 )));
156 }
157 }
158 let mut buf = Vec::new();
159 while let Some(chunk) = response.chunk().await? {
160 buf.extend_from_slice(&chunk);
161 if buf.len() as u64 > limit {
162 return Err(BbError::Config(format!(
163 "{what} exceeded the {limit} byte limit"
164 )));
165 }
166 }
167 Ok(buf)
168}
169
170pub fn release_api_base() -> String {
171 std::env::var("BB_UPDATE_API_BASE").unwrap_or_else(|_| DEFAULT_RELEASE_API.to_string())
172}
173
174fn header_str<'a>(response: &'a reqwest::Response, name: &str) -> Option<&'a str> {
178 response.headers().get(name)?.to_str().ok()
179}
180
181fn retry_time(response: &reqwest::Response) -> Option<String> {
185 let epoch: i64 = header_str(response, "x-ratelimit-reset")?.parse().ok()?;
186 format_epoch_local(epoch)
187}
188
189fn format_epoch_local(epoch: i64) -> Option<String> {
192 if epoch < 0 {
193 return None;
194 }
195 let utc = chrono::DateTime::from_timestamp(epoch, 0)?;
196 Some(
197 utc.with_timezone(&chrono::Local)
198 .format("%H:%M")
199 .to_string(),
200 )
201}
202
203fn release_error(response: &reqwest::Response) -> BbError {
208 let status = response.status();
209 let remaining = header_str(response, "x-ratelimit-remaining");
210 let is_rate_limited = matches!(status.as_u16(), 403 | 429) && remaining == Some("0");
211
212 let message = if is_rate_limited {
213 match retry_time(response) {
214 Some(time) => format!(
215 "github api rate limit reached — 60 requests per hour for unauthenticated access, retry after {time}"
216 ),
217 None => "github api rate limit reached — 60 requests per hour for unauthenticated access".to_string(),
218 }
219 } else {
220 status
221 .canonical_reason()
222 .unwrap_or("unknown error")
223 .to_string()
224 };
225
226 BbError::Release {
227 status: status.as_u16(),
228 message,
229 }
230}
231
232pub async fn run(format: Format, base_url: &str) -> Result<()> {
233 let current = env!("CARGO_PKG_VERSION");
234 let http = release_client()?;
235 let url = format!(
236 "{}/repos/biokraft/bbcloud/releases/latest",
237 base_url.trim_end_matches('/')
238 );
239 let response = http.get(&url).send().await?;
240 if !response.status().is_success() {
241 return Err(release_error(&response));
242 }
243 let body = bound_body(response, MAX_RELEASE_JSON_BYTES, "release metadata").await?;
244 let release: Release = serde_json::from_slice(&body)?;
245 let latest = release.tag_name.clone();
246
247 let (action, up_to_date) = if !is_newer(&latest, current) {
248 ("none", true)
249 } else {
250 let exe = std::env::current_exe().map_err(BbError::Io)?;
251 let action = match classify_install(&exe) {
252 InstallKind::Homebrew => HOMEBREW_UPDATE_HINT,
253 InstallKind::Cargo => "cargo install bbcloud --locked --force",
254 InstallKind::Standalone => {
255 let require_https = base_url.starts_with("https://");
262 self_update(&http, &release, &exe, require_https).await?;
263 "self-updated"
264 }
265 };
266 (action, false)
267 };
268
269 let skill_outcomes = match crate::skill::refresh_tracked() {
274 Ok(outcomes) => outcomes,
275 Err(err) => {
278 output::warn(&format!("could not refresh agent skills: {err}"));
279 Vec::new()
280 }
281 };
282
283 report(
284 format,
285 current,
286 &latest,
287 up_to_date,
288 action,
289 &skill_outcomes,
290 )
291}
292
293fn report(
294 format: Format,
295 current: &str,
296 latest: &str,
297 up_to_date: bool,
298 action: &str,
299 skill_outcomes: &[crate::skill::Outcome],
300) -> Result<()> {
301 let refreshed: Vec<&crate::skill::Outcome> = skill_outcomes
302 .iter()
303 .filter(|o| o.action == crate::skill::Action::Refreshed)
304 .collect();
305 let skipped: Vec<&crate::skill::Outcome> = skill_outcomes
306 .iter()
307 .filter(|o| o.action == crate::skill::Action::SkippedModified)
308 .collect();
309
310 match format {
311 Format::Json => {
312 let mut payload = serde_json::json!({
313 "current": current,
314 "latest": latest,
315 "up_to_date": up_to_date,
316 "action": action,
317 });
318 if !skill_outcomes.is_empty() {
319 payload["skills"] = serde_json::json!({
320 "refreshed": refreshed.len(),
321 "skipped_modified": skipped.iter().map(|o| &o.path).collect::<Vec<_>>(),
322 });
323 }
324 output::print_json(&payload)
325 }
326 Format::Human => {
327 if up_to_date {
328 output::success(&format!("bb {current} is up to date"));
329 } else {
330 output::info(&format!("{current} -> {latest}"));
331 if action == "self-updated" {
332 output::success("updated in place");
333 } else {
334 output::info(&format!("this install is managed elsewhere; run: {action}"));
335 }
336 }
337 if !refreshed.is_empty() {
338 output::success(&format!(
339 "refreshed {} tracked agent skill{}",
340 refreshed.len(),
341 if refreshed.len() == 1 { "" } else { "s" }
342 ));
343 }
344 for outcome in &skipped {
345 output::info(&format!(
346 "skipped modified skill (customized locally): {}",
347 outcome.path.display()
348 ));
349 }
350 Ok(())
351 }
352 }
353}
354
355struct StagedGuard {
359 path: std::path::PathBuf,
360 armed: bool,
361}
362
363impl StagedGuard {
364 fn new(path: std::path::PathBuf) -> Self {
365 Self { path, armed: true }
366 }
367
368 fn disarm(mut self) {
372 self.armed = false;
373 }
374}
375
376impl Drop for StagedGuard {
377 fn drop(&mut self) {
378 if self.armed {
379 let _ = std::fs::remove_file(&self.path);
380 }
381 }
382}
383
384fn checked_asset_url(name: &str, url: String, require_https: bool) -> Result<String> {
389 if require_https && !url.starts_with("https://") {
390 return Err(BbError::Config(format!(
391 "release asset {name} has a non-https download url"
392 )));
393 }
394 Ok(url)
395}
396
397async fn self_update(
400 http: &reqwest::Client,
401 release: &Release,
402 exe: &Path,
403 require_https: bool,
404) -> Result<()> {
405 let triple = current_triple()
406 .ok_or_else(|| BbError::Config("no published binary for this platform".into()))?;
407 let (archive_name, checksum_name) = asset_names(&release.tag_name, triple);
408
409 let find = |name: &str| -> Result<String> {
410 let url = release
411 .assets
412 .iter()
413 .find(|a| a.name == name)
414 .map(|a| a.browser_download_url.clone())
415 .ok_or_else(|| BbError::Config(format!("release asset {name} is missing")))?;
416 checked_asset_url(name, url, require_https)
417 };
418
419 let archive_bytes = fetch_bounded(
420 http,
421 find(&archive_name)?,
422 MAX_ARCHIVE_BYTES,
423 "release archive",
424 )
425 .await?;
426 let checksum_bytes = fetch_bounded(
427 http,
428 find(&checksum_name)?,
429 MAX_CHECKSUM_BYTES,
430 "checksum file",
431 )
432 .await?;
433 let expected = String::from_utf8_lossy(&checksum_bytes);
434 let expected = expected
435 .split_whitespace()
436 .next()
437 .unwrap_or_default()
438 .to_lowercase();
439
440 use sha2::{Digest, Sha256};
441 let actual = format!("{:x}", Sha256::digest(&archive_bytes));
442 if actual != expected {
443 return Err(BbError::Config(
444 "checksum mismatch — refusing to install this download".into(),
445 ));
446 }
447
448 let parent = exe
449 .parent()
450 .ok_or_else(|| BbError::Config("cannot determine the install directory".into()))?;
451
452 let now = std::time::SystemTime::now()
455 .duration_since(std::time::UNIX_EPOCH)
456 .map(|d| d.as_nanos())
457 .unwrap_or_default();
458 let staged = parent.join(format!(".bb-update-staged-{}-{now}", std::process::id()));
459
460 let mut found = false;
461 let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive_bytes[..]));
462 let mut archive = tar::Archive::new(decoder);
463 for entry in archive.entries()? {
464 let mut entry = entry?;
465 let is_bb = entry
466 .path()?
467 .file_name()
468 .map(|n| n == std::ffi::OsStr::new("bb"))
469 .unwrap_or(false);
470 if !is_bb {
471 continue;
472 }
473 if !entry.header().entry_type().is_file() {
480 continue;
481 }
482
483 let mut out = std::fs::OpenOptions::new()
486 .write(true)
487 .create_new(true)
488 .open(&staged)
489 .map_err(BbError::Io)?;
490 let guard = StagedGuard::new(staged.clone());
491
492 let mut limited = std::io::Read::take(&mut entry, MAX_UNPACKED_BYTES);
493 let copied = std::io::copy(&mut limited, &mut out).map_err(BbError::Io)?;
494 drop(out);
495 if copied >= MAX_UNPACKED_BYTES {
496 return Err(BbError::Config(format!(
497 "unpacked bb binary exceeds the {MAX_UNPACKED_BYTES} byte limit"
498 )));
499 }
500
501 #[cfg(unix)]
502 {
503 use std::os::unix::fs::PermissionsExt;
504 std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
505 .map_err(BbError::Io)?;
506 }
507
508 std::fs::rename(&staged, exe).map_err(BbError::Io)?;
511 guard.disarm();
512 found = true;
513 break;
514 }
515 if !found {
516 return Err(BbError::Config(
517 "archive contains no regular-file bb binary".into(),
518 ));
519 }
520 Ok(())
521}
522
523#[cfg(test)]
524#[allow(clippy::unwrap_used)]
525mod tests {
526 use super::*;
527 use std::path::Path;
528
529 #[test]
530 fn homebrew_paths_are_detected() {
531 for p in [
532 "/opt/homebrew/bin/bb",
533 "/usr/local/Cellar/bb/1.0.0/bin/bb",
534 "/home/linuxbrew/.linuxbrew/bin/bb",
535 ] {
536 assert_eq!(classify_install(Path::new(p)), InstallKind::Homebrew, "{p}");
537 }
538 }
539
540 #[test]
545 fn homebrew_hint_refreshes_the_tap_before_upgrading() {
546 assert_eq!(HOMEBREW_UPDATE_HINT, "brew update && brew upgrade bb");
547 }
548
549 #[test]
550 fn cargo_bin_is_detected() {
551 assert_eq!(
552 classify_install(Path::new("/Users/dev/.cargo/bin/bb")),
553 InstallKind::Cargo
554 );
555 }
556
557 #[test]
558 fn anything_else_is_standalone() {
559 for p in ["/usr/local/bin/bb", "/home/dev/.local/bin/bb", "./bb"] {
560 assert_eq!(
561 classify_install(Path::new(p)),
562 InstallKind::Standalone,
563 "{p}"
564 );
565 }
566 }
567
568 #[test]
569 fn versions_parse_with_and_without_a_v_prefix() {
570 assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
571 assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
572 assert_eq!(parse_version("v10.0.1"), Some((10, 0, 1)));
573 }
574
575 #[test]
576 fn malformed_versions_are_rejected_rather_than_panicking() {
577 for bad in ["", "v", "1.2", "1.2.x", "latest", "v1.2.3.4"] {
578 assert_eq!(parse_version(bad), None, "{bad}");
579 }
580 }
581
582 #[test]
583 fn is_newer_compares_each_component() {
584 assert!(is_newer("v1.0.1", "1.0.0"));
585 assert!(is_newer("v1.1.0", "1.0.9"));
586 assert!(is_newer("v2.0.0", "1.9.9"));
587 assert!(!is_newer("v1.0.0", "1.0.0"));
588 assert!(!is_newer("v0.9.0", "1.0.0"));
589 }
590
591 #[test]
594 fn unparseable_remote_tag_is_not_newer() {
595 assert!(!is_newer("garbage", "1.0.0"));
596 assert!(!is_newer("", "1.0.0"));
597 }
598
599 #[test]
600 fn https_asset_urls_are_required_when_enforced() {
601 assert!(
602 checked_asset_url("bb.tar.gz", "http://evil.example/bb.tar.gz".into(), true).is_err()
603 );
604 assert!(
605 checked_asset_url("bb.tar.gz", "https://example.com/bb.tar.gz".into(), true).is_ok()
606 );
607 }
608
609 #[test]
610 fn https_enforcement_is_skipped_for_the_test_override() {
611 assert!(
612 checked_asset_url("bb.tar.gz", "http://127.0.0.1:1234/bb.tar.gz".into(), false).is_ok()
613 );
614 }
615
616 #[test]
618 fn negative_epoch_is_rejected() {
619 assert_eq!(format_epoch_local(-1), None);
620 assert_eq!(format_epoch_local(-1_000_000), None);
621 }
622
623 #[test]
624 fn a_valid_epoch_still_formats() {
625 assert!(format_epoch_local(1_786_452_151).is_some());
626 }
627
628 #[test]
629 fn asset_names_follow_the_release_workflow_convention() {
630 let (archive, checksum) = asset_names("v1.0.0", "x86_64-apple-darwin");
631 assert_eq!(archive, "bbcloud-v1.0.0-x86_64-apple-darwin.tar.gz");
632 assert_eq!(checksum, "bbcloud-v1.0.0-x86_64-apple-darwin.sha256");
633 }
634}