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 biokraft/tap/bb";
91
92pub fn upgrade_hint(kind: InstallKind) -> &'static str {
97 match kind {
98 InstallKind::Homebrew => HOMEBREW_UPDATE_HINT,
99 InstallKind::Cargo => "cargo install bbcloud --locked --force",
100 InstallKind::Standalone => "bb update",
101 }
102}
103
104pub async fn latest_tag(http: &reqwest::Client, base_url: &str) -> Result<String> {
111 Ok(latest_release(http, base_url).await?.tag_name)
112}
113
114async fn latest_release(http: &reqwest::Client, base_url: &str) -> Result<Release> {
115 let url = format!(
116 "{}/repos/biokraft/bbcloud/releases/latest",
117 base_url.trim_end_matches('/')
118 );
119 let response = http.get(&url).send().await?;
120 if !response.status().is_success() {
121 return Err(release_error(&response));
122 }
123 let body = bound_body(response, MAX_RELEASE_JSON_BYTES, "release metadata").await?;
124 Ok(serde_json::from_slice(&body)?)
125}
126
127#[derive(Debug, Deserialize)]
128struct ReleaseAsset {
129 name: String,
130 browser_download_url: String,
131}
132
133#[derive(Debug, Deserialize)]
134struct Release {
135 tag_name: String,
136 #[serde(default)]
137 assets: Vec<ReleaseAsset>,
138}
139
140fn release_client() -> Result<reqwest::Client> {
150 Ok(reqwest::Client::builder()
151 .connect_timeout(Duration::from_secs(10))
152 .timeout(Duration::from_secs(120))
153 .user_agent(concat!("bbcloud/", env!("CARGO_PKG_VERSION")))
154 .build()?)
155}
156
157const MAX_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024;
162const MAX_UNPACKED_BYTES: u64 = 200 * 1024 * 1024;
165const MAX_CHECKSUM_BYTES: u64 = 4 * 1024;
168const MAX_RELEASE_JSON_BYTES: u64 = 1024 * 1024;
172
173async fn fetch_bounded(
178 http: &reqwest::Client,
179 url: String,
180 limit: u64,
181 what: &str,
182) -> Result<Vec<u8>> {
183 let response = http.get(url).send().await?;
184 bound_body(response, limit, what).await
185}
186
187async fn bound_body(mut response: reqwest::Response, limit: u64, what: &str) -> Result<Vec<u8>> {
191 if let Some(len) = response.content_length() {
192 if len > limit {
193 return Err(BbError::Config(format!(
194 "{what} reports {len} bytes, larger than the {limit} byte limit"
195 )));
196 }
197 }
198 let mut buf = Vec::new();
199 while let Some(chunk) = response.chunk().await? {
200 buf.extend_from_slice(&chunk);
201 if buf.len() as u64 > limit {
202 return Err(BbError::Config(format!(
203 "{what} exceeded the {limit} byte limit"
204 )));
205 }
206 }
207 Ok(buf)
208}
209
210pub fn release_api_base() -> String {
211 std::env::var("BB_UPDATE_API_BASE").unwrap_or_else(|_| DEFAULT_RELEASE_API.to_string())
212}
213
214fn header_str<'a>(response: &'a reqwest::Response, name: &str) -> Option<&'a str> {
218 response.headers().get(name)?.to_str().ok()
219}
220
221fn retry_time(response: &reqwest::Response) -> Option<String> {
225 let epoch: i64 = header_str(response, "x-ratelimit-reset")?.parse().ok()?;
226 format_epoch_local(epoch)
227}
228
229fn format_epoch_local(epoch: i64) -> Option<String> {
232 if epoch < 0 {
233 return None;
234 }
235 let utc = chrono::DateTime::from_timestamp(epoch, 0)?;
236 Some(
237 utc.with_timezone(&chrono::Local)
238 .format("%H:%M")
239 .to_string(),
240 )
241}
242
243fn release_error(response: &reqwest::Response) -> BbError {
248 let status = response.status();
249 let remaining = header_str(response, "x-ratelimit-remaining");
250 let is_rate_limited = matches!(status.as_u16(), 403 | 429) && remaining == Some("0");
251
252 let message = if is_rate_limited {
253 match retry_time(response) {
254 Some(time) => format!(
255 "github api rate limit reached — 60 requests per hour for unauthenticated access, retry after {time}"
256 ),
257 None => "github api rate limit reached — 60 requests per hour for unauthenticated access".to_string(),
258 }
259 } else {
260 status
261 .canonical_reason()
262 .unwrap_or("unknown error")
263 .to_string()
264 };
265
266 BbError::Release {
267 status: status.as_u16(),
268 message,
269 }
270}
271
272pub async fn run(format: Format, base_url: &str) -> Result<()> {
273 let current = env!("CARGO_PKG_VERSION");
274 let http = release_client()?;
275 let release = latest_release(&http, base_url).await?;
276 let latest = release.tag_name.clone();
277
278 let (action, up_to_date) = if !is_newer(&latest, current) {
279 ("none", true)
280 } else {
281 let exe = std::env::current_exe().map_err(BbError::Io)?;
282 let kind = classify_install(&exe);
283 let action = match kind {
284 InstallKind::Homebrew | InstallKind::Cargo => upgrade_hint(kind),
285 InstallKind::Standalone => {
286 let require_https = base_url.starts_with("https://");
293 self_update(&http, &release, &exe, require_https).await?;
294 "self-updated"
295 }
296 };
297 (action, false)
298 };
299
300 let skill_outcomes = match crate::skill::refresh_tracked(crate::skill::MissingPolicy::Restore) {
305 Ok(outcomes) => outcomes,
306 Err(err) => {
309 output::warn(&format!("could not refresh agent skills: {err}"));
310 Vec::new()
311 }
312 };
313
314 report(
315 format,
316 current,
317 &latest,
318 up_to_date,
319 action,
320 &skill_outcomes,
321 )
322}
323
324fn report(
325 format: Format,
326 current: &str,
327 latest: &str,
328 up_to_date: bool,
329 action: &str,
330 skill_outcomes: &[crate::skill::Outcome],
331) -> Result<()> {
332 let refreshed: Vec<&crate::skill::Outcome> = skill_outcomes
333 .iter()
334 .filter(|o| o.action == crate::skill::Action::Refreshed)
335 .collect();
336 let skipped: Vec<&crate::skill::Outcome> = skill_outcomes
337 .iter()
338 .filter(|o| o.action == crate::skill::Action::SkippedModified)
339 .collect();
340 let pruned: Vec<&crate::skill::Outcome> = skill_outcomes
341 .iter()
342 .filter(|o| o.action == crate::skill::Action::Pruned)
343 .collect();
344 let failed: Vec<&crate::skill::Outcome> = skill_outcomes
345 .iter()
346 .filter(|o| o.action == crate::skill::Action::Failed)
347 .collect();
348
349 match format {
350 Format::Json => {
351 let mut payload = serde_json::json!({
352 "current": current,
353 "latest": latest,
354 "up_to_date": up_to_date,
355 "action": action,
356 });
357 if !skill_outcomes.is_empty() {
358 payload["skills"] = serde_json::json!({
359 "refreshed": refreshed.len(),
360 "skipped_modified": skipped.iter().map(|o| &o.path).collect::<Vec<_>>(),
361 "pruned": pruned.iter().map(|o| &o.path).collect::<Vec<_>>(),
362 "failed": failed.iter().map(|o| &o.path).collect::<Vec<_>>(),
363 });
364 }
365 output::print_json(&payload)
366 }
367 Format::Human => {
368 if up_to_date {
369 output::success(&format!("bb {current} is up to date"));
370 } else {
371 output::info(&format!("{current} -> {latest}"));
372 if action == "self-updated" {
373 output::success("updated in place");
374 } else {
375 output::info(&format!("this install is managed elsewhere; run: {action}"));
376 }
377 }
378 if !refreshed.is_empty() {
379 output::success(&format!(
380 "refreshed {} tracked agent skill{}",
381 refreshed.len(),
382 if refreshed.len() == 1 { "" } else { "s" }
383 ));
384 }
385 for outcome in &skipped {
386 output::info(&format!(
387 "skipped modified skill (customized locally): {}",
388 outcome.path.display()
389 ));
390 }
391 if !skipped.is_empty() {
397 output::info(
398 "your edits are kept; run `bb skill install --force` to take the new version",
399 );
400 }
401 for outcome in &pruned {
402 output::info(&format!(
403 "forgot {} (directory no longer exists)",
404 outcome.path.display()
405 ));
406 }
407 for outcome in &failed {
408 output::warn(&format!(
409 "could not refresh {}: write failed",
410 outcome.path.display()
411 ));
412 }
413 Ok(())
414 }
415 }
416}
417
418struct StagedGuard {
422 path: std::path::PathBuf,
423 armed: bool,
424}
425
426impl StagedGuard {
427 fn new(path: std::path::PathBuf) -> Self {
428 Self { path, armed: true }
429 }
430
431 fn disarm(mut self) {
435 self.armed = false;
436 }
437}
438
439impl Drop for StagedGuard {
440 fn drop(&mut self) {
441 if self.armed {
442 let _ = std::fs::remove_file(&self.path);
443 }
444 }
445}
446
447fn checked_asset_url(name: &str, url: String, require_https: bool) -> Result<String> {
452 if require_https && !url.starts_with("https://") {
453 return Err(BbError::Config(format!(
454 "release asset {name} has a non-https download url"
455 )));
456 }
457 Ok(url)
458}
459
460async fn self_update(
463 http: &reqwest::Client,
464 release: &Release,
465 exe: &Path,
466 require_https: bool,
467) -> Result<()> {
468 let triple = current_triple()
469 .ok_or_else(|| BbError::Config("no published binary for this platform".into()))?;
470 let (archive_name, checksum_name) = asset_names(&release.tag_name, triple);
471
472 let find = |name: &str| -> Result<String> {
473 let url = release
474 .assets
475 .iter()
476 .find(|a| a.name == name)
477 .map(|a| a.browser_download_url.clone())
478 .ok_or_else(|| BbError::Config(format!("release asset {name} is missing")))?;
479 checked_asset_url(name, url, require_https)
480 };
481
482 let archive_bytes = fetch_bounded(
483 http,
484 find(&archive_name)?,
485 MAX_ARCHIVE_BYTES,
486 "release archive",
487 )
488 .await?;
489 let checksum_bytes = fetch_bounded(
490 http,
491 find(&checksum_name)?,
492 MAX_CHECKSUM_BYTES,
493 "checksum file",
494 )
495 .await?;
496 let expected = String::from_utf8_lossy(&checksum_bytes);
497 let expected = expected
498 .split_whitespace()
499 .next()
500 .unwrap_or_default()
501 .to_lowercase();
502
503 use sha2::{Digest, Sha256};
504 let actual = format!("{:x}", Sha256::digest(&archive_bytes));
505 if actual != expected {
506 return Err(BbError::Config(
507 "checksum mismatch — refusing to install this download".into(),
508 ));
509 }
510
511 let parent = exe
512 .parent()
513 .ok_or_else(|| BbError::Config("cannot determine the install directory".into()))?;
514
515 let now = std::time::SystemTime::now()
518 .duration_since(std::time::UNIX_EPOCH)
519 .map(|d| d.as_nanos())
520 .unwrap_or_default();
521 let staged = parent.join(format!(".bb-update-staged-{}-{now}", std::process::id()));
522
523 let mut found = false;
524 let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive_bytes[..]));
525 let mut archive = tar::Archive::new(decoder);
526 for entry in archive.entries()? {
527 let mut entry = entry?;
528 let is_bb = entry
529 .path()?
530 .file_name()
531 .map(|n| n == std::ffi::OsStr::new("bb"))
532 .unwrap_or(false);
533 if !is_bb {
534 continue;
535 }
536 if !entry.header().entry_type().is_file() {
543 continue;
544 }
545
546 let mut out = std::fs::OpenOptions::new()
549 .write(true)
550 .create_new(true)
551 .open(&staged)
552 .map_err(BbError::Io)?;
553 let guard = StagedGuard::new(staged.clone());
554
555 let mut limited = std::io::Read::take(&mut entry, MAX_UNPACKED_BYTES);
556 let copied = std::io::copy(&mut limited, &mut out).map_err(BbError::Io)?;
557 drop(out);
558 if copied >= MAX_UNPACKED_BYTES {
559 return Err(BbError::Config(format!(
560 "unpacked bb binary exceeds the {MAX_UNPACKED_BYTES} byte limit"
561 )));
562 }
563
564 #[cfg(unix)]
565 {
566 use std::os::unix::fs::PermissionsExt;
567 std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
568 .map_err(BbError::Io)?;
569 }
570
571 std::fs::rename(&staged, exe).map_err(BbError::Io)?;
574 guard.disarm();
575 found = true;
576 break;
577 }
578 if !found {
579 return Err(BbError::Config(
580 "archive contains no regular-file bb binary".into(),
581 ));
582 }
583 Ok(())
584}
585
586#[cfg(test)]
587#[allow(clippy::unwrap_used)]
588mod tests {
589 use super::*;
590 use std::path::Path;
591
592 #[test]
593 fn homebrew_paths_are_detected() {
594 for p in [
595 "/opt/homebrew/bin/bb",
596 "/usr/local/Cellar/bb/1.0.0/bin/bb",
597 "/home/linuxbrew/.linuxbrew/bin/bb",
598 ] {
599 assert_eq!(classify_install(Path::new(p)), InstallKind::Homebrew, "{p}");
600 }
601 }
602
603 #[test]
608 fn homebrew_hint_refreshes_the_tap_before_upgrading() {
609 assert_eq!(
610 HOMEBREW_UPDATE_HINT,
611 "brew update && brew upgrade biokraft/tap/bb"
612 );
613 }
614
615 #[test]
620 fn homebrew_hint_names_the_tap_so_the_formula_is_unambiguous() {
621 assert!(
622 HOMEBREW_UPDATE_HINT.contains("biokraft/tap/bb"),
623 "hint must fully qualify the formula: {HOMEBREW_UPDATE_HINT}"
624 );
625 assert!(
626 !HOMEBREW_UPDATE_HINT.contains("upgrade bb"),
627 "hint must not upgrade an unqualified `bb`: {HOMEBREW_UPDATE_HINT}"
628 );
629 }
630
631 #[test]
632 fn cargo_bin_is_detected() {
633 assert_eq!(
634 classify_install(Path::new("/Users/dev/.cargo/bin/bb")),
635 InstallKind::Cargo
636 );
637 }
638
639 #[test]
640 fn anything_else_is_standalone() {
641 for p in ["/usr/local/bin/bb", "/home/dev/.local/bin/bb", "./bb"] {
642 assert_eq!(
643 classify_install(Path::new(p)),
644 InstallKind::Standalone,
645 "{p}"
646 );
647 }
648 }
649
650 #[test]
651 fn versions_parse_with_and_without_a_v_prefix() {
652 assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
653 assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
654 assert_eq!(parse_version("v10.0.1"), Some((10, 0, 1)));
655 }
656
657 #[test]
658 fn malformed_versions_are_rejected_rather_than_panicking() {
659 for bad in ["", "v", "1.2", "1.2.x", "latest", "v1.2.3.4"] {
660 assert_eq!(parse_version(bad), None, "{bad}");
661 }
662 }
663
664 #[test]
665 fn is_newer_compares_each_component() {
666 assert!(is_newer("v1.0.1", "1.0.0"));
667 assert!(is_newer("v1.1.0", "1.0.9"));
668 assert!(is_newer("v2.0.0", "1.9.9"));
669 assert!(!is_newer("v1.0.0", "1.0.0"));
670 assert!(!is_newer("v0.9.0", "1.0.0"));
671 }
672
673 #[test]
676 fn unparseable_remote_tag_is_not_newer() {
677 assert!(!is_newer("garbage", "1.0.0"));
678 assert!(!is_newer("", "1.0.0"));
679 }
680
681 #[test]
682 fn https_asset_urls_are_required_when_enforced() {
683 assert!(
684 checked_asset_url("bb.tar.gz", "http://evil.example/bb.tar.gz".into(), true).is_err()
685 );
686 assert!(
687 checked_asset_url("bb.tar.gz", "https://example.com/bb.tar.gz".into(), true).is_ok()
688 );
689 }
690
691 #[test]
692 fn https_enforcement_is_skipped_for_the_test_override() {
693 assert!(
694 checked_asset_url("bb.tar.gz", "http://127.0.0.1:1234/bb.tar.gz".into(), false).is_ok()
695 );
696 }
697
698 #[test]
700 fn negative_epoch_is_rejected() {
701 assert_eq!(format_epoch_local(-1), None);
702 assert_eq!(format_epoch_local(-1_000_000), None);
703 }
704
705 #[test]
706 fn a_valid_epoch_still_formats() {
707 assert!(format_epoch_local(1_786_452_151).is_some());
708 }
709
710 #[test]
711 fn asset_names_follow_the_release_workflow_convention() {
712 let (archive, checksum) = asset_names("v1.0.0", "x86_64-apple-darwin");
713 assert_eq!(archive, "bbcloud-v1.0.0-x86_64-apple-darwin.tar.gz");
714 assert_eq!(checksum, "bbcloud-v1.0.0-x86_64-apple-darwin.sha256");
715 }
716}