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(crate::skill::MissingPolicy::Restore) {
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 let pruned: Vec<&crate::skill::Outcome> = skill_outcomes
310 .iter()
311 .filter(|o| o.action == crate::skill::Action::Pruned)
312 .collect();
313 let failed: Vec<&crate::skill::Outcome> = skill_outcomes
314 .iter()
315 .filter(|o| o.action == crate::skill::Action::Failed)
316 .collect();
317
318 match format {
319 Format::Json => {
320 let mut payload = serde_json::json!({
321 "current": current,
322 "latest": latest,
323 "up_to_date": up_to_date,
324 "action": action,
325 });
326 if !skill_outcomes.is_empty() {
327 payload["skills"] = serde_json::json!({
328 "refreshed": refreshed.len(),
329 "skipped_modified": skipped.iter().map(|o| &o.path).collect::<Vec<_>>(),
330 "pruned": pruned.iter().map(|o| &o.path).collect::<Vec<_>>(),
331 "failed": failed.iter().map(|o| &o.path).collect::<Vec<_>>(),
332 });
333 }
334 output::print_json(&payload)
335 }
336 Format::Human => {
337 if up_to_date {
338 output::success(&format!("bb {current} is up to date"));
339 } else {
340 output::info(&format!("{current} -> {latest}"));
341 if action == "self-updated" {
342 output::success("updated in place");
343 } else {
344 output::info(&format!("this install is managed elsewhere; run: {action}"));
345 }
346 }
347 if !refreshed.is_empty() {
348 output::success(&format!(
349 "refreshed {} tracked agent skill{}",
350 refreshed.len(),
351 if refreshed.len() == 1 { "" } else { "s" }
352 ));
353 }
354 for outcome in &skipped {
355 output::info(&format!(
356 "skipped modified skill (customized locally): {}",
357 outcome.path.display()
358 ));
359 }
360 for outcome in &pruned {
361 output::info(&format!(
362 "forgot {} (directory no longer exists)",
363 outcome.path.display()
364 ));
365 }
366 for outcome in &failed {
367 output::warn(&format!(
368 "could not refresh {}: write failed",
369 outcome.path.display()
370 ));
371 }
372 Ok(())
373 }
374 }
375}
376
377struct StagedGuard {
381 path: std::path::PathBuf,
382 armed: bool,
383}
384
385impl StagedGuard {
386 fn new(path: std::path::PathBuf) -> Self {
387 Self { path, armed: true }
388 }
389
390 fn disarm(mut self) {
394 self.armed = false;
395 }
396}
397
398impl Drop for StagedGuard {
399 fn drop(&mut self) {
400 if self.armed {
401 let _ = std::fs::remove_file(&self.path);
402 }
403 }
404}
405
406fn checked_asset_url(name: &str, url: String, require_https: bool) -> Result<String> {
411 if require_https && !url.starts_with("https://") {
412 return Err(BbError::Config(format!(
413 "release asset {name} has a non-https download url"
414 )));
415 }
416 Ok(url)
417}
418
419async fn self_update(
422 http: &reqwest::Client,
423 release: &Release,
424 exe: &Path,
425 require_https: bool,
426) -> Result<()> {
427 let triple = current_triple()
428 .ok_or_else(|| BbError::Config("no published binary for this platform".into()))?;
429 let (archive_name, checksum_name) = asset_names(&release.tag_name, triple);
430
431 let find = |name: &str| -> Result<String> {
432 let url = release
433 .assets
434 .iter()
435 .find(|a| a.name == name)
436 .map(|a| a.browser_download_url.clone())
437 .ok_or_else(|| BbError::Config(format!("release asset {name} is missing")))?;
438 checked_asset_url(name, url, require_https)
439 };
440
441 let archive_bytes = fetch_bounded(
442 http,
443 find(&archive_name)?,
444 MAX_ARCHIVE_BYTES,
445 "release archive",
446 )
447 .await?;
448 let checksum_bytes = fetch_bounded(
449 http,
450 find(&checksum_name)?,
451 MAX_CHECKSUM_BYTES,
452 "checksum file",
453 )
454 .await?;
455 let expected = String::from_utf8_lossy(&checksum_bytes);
456 let expected = expected
457 .split_whitespace()
458 .next()
459 .unwrap_or_default()
460 .to_lowercase();
461
462 use sha2::{Digest, Sha256};
463 let actual = format!("{:x}", Sha256::digest(&archive_bytes));
464 if actual != expected {
465 return Err(BbError::Config(
466 "checksum mismatch — refusing to install this download".into(),
467 ));
468 }
469
470 let parent = exe
471 .parent()
472 .ok_or_else(|| BbError::Config("cannot determine the install directory".into()))?;
473
474 let now = std::time::SystemTime::now()
477 .duration_since(std::time::UNIX_EPOCH)
478 .map(|d| d.as_nanos())
479 .unwrap_or_default();
480 let staged = parent.join(format!(".bb-update-staged-{}-{now}", std::process::id()));
481
482 let mut found = false;
483 let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive_bytes[..]));
484 let mut archive = tar::Archive::new(decoder);
485 for entry in archive.entries()? {
486 let mut entry = entry?;
487 let is_bb = entry
488 .path()?
489 .file_name()
490 .map(|n| n == std::ffi::OsStr::new("bb"))
491 .unwrap_or(false);
492 if !is_bb {
493 continue;
494 }
495 if !entry.header().entry_type().is_file() {
502 continue;
503 }
504
505 let mut out = std::fs::OpenOptions::new()
508 .write(true)
509 .create_new(true)
510 .open(&staged)
511 .map_err(BbError::Io)?;
512 let guard = StagedGuard::new(staged.clone());
513
514 let mut limited = std::io::Read::take(&mut entry, MAX_UNPACKED_BYTES);
515 let copied = std::io::copy(&mut limited, &mut out).map_err(BbError::Io)?;
516 drop(out);
517 if copied >= MAX_UNPACKED_BYTES {
518 return Err(BbError::Config(format!(
519 "unpacked bb binary exceeds the {MAX_UNPACKED_BYTES} byte limit"
520 )));
521 }
522
523 #[cfg(unix)]
524 {
525 use std::os::unix::fs::PermissionsExt;
526 std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
527 .map_err(BbError::Io)?;
528 }
529
530 std::fs::rename(&staged, exe).map_err(BbError::Io)?;
533 guard.disarm();
534 found = true;
535 break;
536 }
537 if !found {
538 return Err(BbError::Config(
539 "archive contains no regular-file bb binary".into(),
540 ));
541 }
542 Ok(())
543}
544
545#[cfg(test)]
546#[allow(clippy::unwrap_used)]
547mod tests {
548 use super::*;
549 use std::path::Path;
550
551 #[test]
552 fn homebrew_paths_are_detected() {
553 for p in [
554 "/opt/homebrew/bin/bb",
555 "/usr/local/Cellar/bb/1.0.0/bin/bb",
556 "/home/linuxbrew/.linuxbrew/bin/bb",
557 ] {
558 assert_eq!(classify_install(Path::new(p)), InstallKind::Homebrew, "{p}");
559 }
560 }
561
562 #[test]
567 fn homebrew_hint_refreshes_the_tap_before_upgrading() {
568 assert_eq!(HOMEBREW_UPDATE_HINT, "brew update && brew upgrade bb");
569 }
570
571 #[test]
572 fn cargo_bin_is_detected() {
573 assert_eq!(
574 classify_install(Path::new("/Users/dev/.cargo/bin/bb")),
575 InstallKind::Cargo
576 );
577 }
578
579 #[test]
580 fn anything_else_is_standalone() {
581 for p in ["/usr/local/bin/bb", "/home/dev/.local/bin/bb", "./bb"] {
582 assert_eq!(
583 classify_install(Path::new(p)),
584 InstallKind::Standalone,
585 "{p}"
586 );
587 }
588 }
589
590 #[test]
591 fn versions_parse_with_and_without_a_v_prefix() {
592 assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
593 assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
594 assert_eq!(parse_version("v10.0.1"), Some((10, 0, 1)));
595 }
596
597 #[test]
598 fn malformed_versions_are_rejected_rather_than_panicking() {
599 for bad in ["", "v", "1.2", "1.2.x", "latest", "v1.2.3.4"] {
600 assert_eq!(parse_version(bad), None, "{bad}");
601 }
602 }
603
604 #[test]
605 fn is_newer_compares_each_component() {
606 assert!(is_newer("v1.0.1", "1.0.0"));
607 assert!(is_newer("v1.1.0", "1.0.9"));
608 assert!(is_newer("v2.0.0", "1.9.9"));
609 assert!(!is_newer("v1.0.0", "1.0.0"));
610 assert!(!is_newer("v0.9.0", "1.0.0"));
611 }
612
613 #[test]
616 fn unparseable_remote_tag_is_not_newer() {
617 assert!(!is_newer("garbage", "1.0.0"));
618 assert!(!is_newer("", "1.0.0"));
619 }
620
621 #[test]
622 fn https_asset_urls_are_required_when_enforced() {
623 assert!(
624 checked_asset_url("bb.tar.gz", "http://evil.example/bb.tar.gz".into(), true).is_err()
625 );
626 assert!(
627 checked_asset_url("bb.tar.gz", "https://example.com/bb.tar.gz".into(), true).is_ok()
628 );
629 }
630
631 #[test]
632 fn https_enforcement_is_skipped_for_the_test_override() {
633 assert!(
634 checked_asset_url("bb.tar.gz", "http://127.0.0.1:1234/bb.tar.gz".into(), false).is_ok()
635 );
636 }
637
638 #[test]
640 fn negative_epoch_is_rejected() {
641 assert_eq!(format_epoch_local(-1), None);
642 assert_eq!(format_epoch_local(-1_000_000), None);
643 }
644
645 #[test]
646 fn a_valid_epoch_still_formats() {
647 assert!(format_epoch_local(1_786_452_151).is_some());
648 }
649
650 #[test]
651 fn asset_names_follow_the_release_workflow_convention() {
652 let (archive, checksum) = asset_names("v1.0.0", "x86_64-apple-darwin");
653 assert_eq!(archive, "bbcloud-v1.0.0-x86_64-apple-darwin.tar.gz");
654 assert_eq!(checksum, "bbcloud-v1.0.0-x86_64-apple-darwin.sha256");
655 }
656}