Skip to main content

bb_cli/commands/
update.rs

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
14/// Decides who owns the binary at `exe` from its path alone. Overwriting a
15/// package-manager-owned file would leave brew or cargo believing it manages
16/// a file it no longer controls, so those cases delegate instead.
17pub 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
28/// Parses `1.2.3` or `v1.2.3`. Returns `None` for anything else, including
29/// four-component versions and pre-release suffixes.
30pub 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
42/// True only when both versions parse and `latest` is strictly greater. An
43/// unparseable remote tag is never an upgrade, so a malformed API response
44/// cannot trigger a download.
45pub 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
52/// The target triple of the running binary, or `None` on a platform this
53/// project does not publish binaries for.
54pub 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
64/// Archive and checksum asset names for a tag and triple. Must match the
65/// `archive:` pattern in `.github/workflows/release.yml`, which is
66/// `bbcloud-$tag-$target`; cargo-binstall's default templates key off the
67/// crate name, which is why the prefix is `bbcloud` and not the binary name.
68///
69/// The checksum asset is named `<base>.sha256`, where `<base>` is the
70/// archive name *without* its `.tar.gz` extension — not
71/// `<archive>.tar.gz.sha256`. That's how `taiki-e/upload-rust-binary-action`
72/// actually publishes it; verified against the real v0.9.0 release assets.
73pub 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/// `brew upgrade bb` alone never fetches the tap, so a freshly published
83/// formula stays invisible and reports "already installed" even when a
84/// newer release exists. `brew update` (no arguments) is what refreshes it.
85const 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
100/// A client with no credentials attached. The Bitbucket `api::Client` always
101/// sends the Basic auth header, and that token must never reach another host.
102///
103/// This client deliberately diverges from two of `api::Client`'s rules, and
104/// both divergences are safe only because no credential is ever attached
105/// here: redirects are followed (default policy) because GitHub asset
106/// download URLs redirect to `objects.githubusercontent.com`, and the total
107/// timeout is longer than the API client's because this transfers a compiled
108/// binary rather than a JSON page.
109fn 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
117/// Archive downloads are capped generously for this binary but not
118/// unbounded, so a checksum-valid bomb can't exhaust memory or disk. Enforced
119/// while the body streams in (`fetch_bounded`), not after it is fully
120/// buffered, since `Content-Length` is attacker-controlled and may be absent.
121const MAX_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024;
122/// Decompressed output is capped separately: gzip can amplify a small
123/// archive into a much larger stream.
124const MAX_UNPACKED_BYTES: u64 = 200 * 1024 * 1024;
125/// The `.sha256` body is a hex digest and a filename; a few KB is generous
126/// and keeps a hostile checksum-file response from being buffered unbounded.
127const MAX_CHECKSUM_BYTES: u64 = 4 * 1024;
128/// The release metadata JSON (tag name plus asset list) is a few KB in
129/// practice; 1 MiB is generous headroom while still keeping a hostile or
130/// malformed response from being buffered unbounded via `response.json()`.
131const MAX_RELEASE_JSON_BYTES: u64 = 1024 * 1024;
132
133/// Downloads `url`'s body, rejecting it the moment the accumulated length
134/// would exceed `limit` rather than after buffering the whole thing. A
135/// `Content-Length` over the limit is rejected as a fast path, but is not
136/// relied on alone since it is attacker-controlled and may be absent.
137async 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
147/// The bounded-read half of `fetch_bounded`, split out so callers that must
148/// inspect the response (e.g. its status code) before deciding to buffer the
149/// body can still get the same length enforcement.
150async 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
174/// Header lookup that treats every header as optional: they only exist on
175/// GitHub's responses, so any other host (or a malformed/missing header)
176/// must fall through cleanly rather than panicking.
177fn header_str<'a>(response: &'a reqwest::Response, name: &str) -> Option<&'a str> {
178    response.headers().get(name)?.to_str().ok()
179}
180
181/// Renders a GitHub `x-ratelimit-reset` epoch as a local wall-clock
182/// `HH:MM`. Returns `None` for a missing or unparseable header rather than
183/// falling back to the Unix epoch (`1970-01-01`), which would be a lie.
184fn 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
189/// Renders a Unix epoch as a local `HH:MM`, rejecting negative values rather
190/// than letting them render as a pre-1970 clock.
191fn 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
203/// Maps a non-success release-api response to an honest error: a GitHub
204/// unauthenticated rate limit is named as such (with a retry time when the
205/// header allows one), and anything else is reported as a plain release-api
206/// error rather than a false "cannot reach" / "bitbucket" claim.
207fn 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                // The https-only requirement below is scoped to real usage: it
256                // only applies when the release api itself is https (the
257                // production default). The `BB_UPDATE_API_BASE` test override
258                // that points at a local http wiremock server also relaxes the
259                // asset-url check, so integration tests can exercise the
260                // download-and-unpack path without standing up TLS.
261                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    // `brew upgrade bb` and `cargo install` never run our code, so this is the
270    // only moment we can bring skill files up to date with the running binary.
271    // Refreshing runs on every path `run()` can take, including up-to-date,
272    // since that is the only path most Homebrew/Cargo users ever hit.
273    let skill_outcomes = match crate::skill::refresh_tracked(crate::skill::MissingPolicy::Restore) {
274        Ok(outcomes) => outcomes,
275        // The binary upgrade already succeeded and is what the user actually
276        // wanted; a filesystem problem here is a warning, not an exit code.
277        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
377/// Removes the staged file on drop unless disarmed. This guarantees cleanup
378/// on every early-return failure path after the file is created, not just
379/// the one case that used to check for it.
380struct 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    /// Call once the file has been successfully renamed into place, so drop
391    /// does not try to remove a path that is now the live binary (or that no
392    /// longer exists).
393    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
406/// The url comes from the release payload; a hijacked payload must not be
407/// able to downgrade the transport used to fetch the binary. `require_https`
408/// is false only for the `BB_UPDATE_API_BASE` test override, so production
409/// use (the default `https://api.github.com`) always enforces this.
410fn 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
419/// Downloads, verifies, unpacks and atomically replaces the running binary.
420/// Nothing is written next to the binary until the digest matches.
421async 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    // Entropy in the name defeats a pre-planted symlink at a fixed path and
475    // avoids two concurrent `bb update` runs colliding on the same file.
476    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        // Do not let tar decide the node type. A symlink or hard-link entry
496        // named `bb` must never be followed: `Entry::unpack` skips link
497        // validation when given an explicit destination with no
498        // `target_base`, which lets a malicious archive chmod or overwrite
499        // an arbitrary file outside the install directory. Only a plain
500        // regular file is accepted; anything else is treated as "not found".
501        if !entry.header().entry_type().is_file() {
502            continue;
503        }
504
505        // create_new(true) fails if the path already exists, which also
506        // closes the pre-planted-file/symlink hole at the staged path.
507        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        // Same-directory rename is atomic, so an interrupted update can
531        // never leave a truncated `bb` behind.
532        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    /// `brew upgrade bb` alone does not refresh the tap, so a freshly
563    /// published formula stays invisible; the hint must run `brew update`
564    /// first. This exercises the same literal `run()` delegates to for a
565    /// Homebrew install.
566    #[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    /// An unparseable remote tag must never be treated as an upgrade — that
614    /// would download and install an arbitrary asset on a malformed response.
615    #[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    /// A negative epoch must never render a pre-1970 clock.
639    #[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}