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() {
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
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
355/// Removes the staged file on drop unless disarmed. This guarantees cleanup
356/// on every early-return failure path after the file is created, not just
357/// the one case that used to check for it.
358struct 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    /// Call once the file has been successfully renamed into place, so drop
369    /// does not try to remove a path that is now the live binary (or that no
370    /// longer exists).
371    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
384/// The url comes from the release payload; a hijacked payload must not be
385/// able to downgrade the transport used to fetch the binary. `require_https`
386/// is false only for the `BB_UPDATE_API_BASE` test override, so production
387/// use (the default `https://api.github.com`) always enforces this.
388fn 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
397/// Downloads, verifies, unpacks and atomically replaces the running binary.
398/// Nothing is written next to the binary until the digest matches.
399async 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    // Entropy in the name defeats a pre-planted symlink at a fixed path and
453    // avoids two concurrent `bb update` runs colliding on the same file.
454    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        // Do not let tar decide the node type. A symlink or hard-link entry
474        // named `bb` must never be followed: `Entry::unpack` skips link
475        // validation when given an explicit destination with no
476        // `target_base`, which lets a malicious archive chmod or overwrite
477        // an arbitrary file outside the install directory. Only a plain
478        // regular file is accepted; anything else is treated as "not found".
479        if !entry.header().entry_type().is_file() {
480            continue;
481        }
482
483        // create_new(true) fails if the path already exists, which also
484        // closes the pre-planted-file/symlink hole at the staged path.
485        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        // Same-directory rename is atomic, so an interrupted update can
509        // never leave a truncated `bb` behind.
510        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    /// `brew upgrade bb` alone does not refresh the tap, so a freshly
541    /// published formula stays invisible; the hint must run `brew update`
542    /// first. This exercises the same literal `run()` delegates to for a
543    /// Homebrew install.
544    #[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    /// An unparseable remote tag must never be treated as an upgrade — that
592    /// would download and install an arbitrary asset on a malformed response.
593    #[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    /// A negative epoch must never render a pre-1970 clock.
617    #[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}