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#[derive(Debug, Deserialize)]
83struct ReleaseAsset {
84    name: String,
85    browser_download_url: String,
86}
87
88#[derive(Debug, Deserialize)]
89struct Release {
90    tag_name: String,
91    #[serde(default)]
92    assets: Vec<ReleaseAsset>,
93}
94
95/// A client with no credentials attached. The Bitbucket `api::Client` always
96/// sends the Basic auth header, and that token must never reach another host.
97///
98/// This client deliberately diverges from two of `api::Client`'s rules, and
99/// both divergences are safe only because no credential is ever attached
100/// here: redirects are followed (default policy) because GitHub asset
101/// download URLs redirect to `objects.githubusercontent.com`, and the total
102/// timeout is longer than the API client's because this transfers a compiled
103/// binary rather than a JSON page.
104fn release_client() -> Result<reqwest::Client> {
105    Ok(reqwest::Client::builder()
106        .connect_timeout(Duration::from_secs(10))
107        .timeout(Duration::from_secs(120))
108        .user_agent(concat!("bbcloud/", env!("CARGO_PKG_VERSION")))
109        .build()?)
110}
111
112/// Archive downloads are capped generously for this binary but not
113/// unbounded, so a checksum-valid bomb can't exhaust memory or disk. Enforced
114/// while the body streams in (`fetch_bounded`), not after it is fully
115/// buffered, since `Content-Length` is attacker-controlled and may be absent.
116const MAX_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024;
117/// Decompressed output is capped separately: gzip can amplify a small
118/// archive into a much larger stream.
119const MAX_UNPACKED_BYTES: u64 = 200 * 1024 * 1024;
120/// The `.sha256` body is a hex digest and a filename; a few KB is generous
121/// and keeps a hostile checksum-file response from being buffered unbounded.
122const MAX_CHECKSUM_BYTES: u64 = 4 * 1024;
123/// The release metadata JSON (tag name plus asset list) is a few KB in
124/// practice; 1 MiB is generous headroom while still keeping a hostile or
125/// malformed response from being buffered unbounded via `response.json()`.
126const MAX_RELEASE_JSON_BYTES: u64 = 1024 * 1024;
127
128/// Downloads `url`'s body, rejecting it the moment the accumulated length
129/// would exceed `limit` rather than after buffering the whole thing. A
130/// `Content-Length` over the limit is rejected as a fast path, but is not
131/// relied on alone since it is attacker-controlled and may be absent.
132async fn fetch_bounded(
133    http: &reqwest::Client,
134    url: String,
135    limit: u64,
136    what: &str,
137) -> Result<Vec<u8>> {
138    let response = http.get(url).send().await?;
139    bound_body(response, limit, what).await
140}
141
142/// The bounded-read half of `fetch_bounded`, split out so callers that must
143/// inspect the response (e.g. its status code) before deciding to buffer the
144/// body can still get the same length enforcement.
145async fn bound_body(mut response: reqwest::Response, limit: u64, what: &str) -> Result<Vec<u8>> {
146    if let Some(len) = response.content_length() {
147        if len > limit {
148            return Err(BbError::Config(format!(
149                "{what} reports {len} bytes, larger than the {limit} byte limit"
150            )));
151        }
152    }
153    let mut buf = Vec::new();
154    while let Some(chunk) = response.chunk().await? {
155        buf.extend_from_slice(&chunk);
156        if buf.len() as u64 > limit {
157            return Err(BbError::Config(format!(
158                "{what} exceeded the {limit} byte limit"
159            )));
160        }
161    }
162    Ok(buf)
163}
164
165pub fn release_api_base() -> String {
166    std::env::var("BB_UPDATE_API_BASE").unwrap_or_else(|_| DEFAULT_RELEASE_API.to_string())
167}
168
169pub async fn run(format: Format, base_url: &str) -> Result<()> {
170    let current = env!("CARGO_PKG_VERSION");
171    let http = release_client()?;
172    let url = format!(
173        "{}/repos/biokraft/bbcloud/releases/latest",
174        base_url.trim_end_matches('/')
175    );
176    let response = http.get(&url).send().await?;
177    if !response.status().is_success() {
178        return Err(BbError::Api {
179            status: response.status().as_u16(),
180            message: "cannot reach the release api".into(),
181        });
182    }
183    let body = bound_body(response, MAX_RELEASE_JSON_BYTES, "release metadata").await?;
184    let release: Release = serde_json::from_slice(&body)?;
185    let latest = release.tag_name.clone();
186
187    if !is_newer(&latest, current) {
188        return report(format, current, &latest, true, "none");
189    }
190
191    let exe = std::env::current_exe().map_err(BbError::Io)?;
192    let action = match classify_install(&exe) {
193        InstallKind::Homebrew => "brew upgrade bb",
194        InstallKind::Cargo => "cargo install bbcloud --locked --force",
195        InstallKind::Standalone => {
196            // The https-only requirement below is scoped to real usage: it
197            // only applies when the release api itself is https (the
198            // production default). The `BB_UPDATE_API_BASE` test override
199            // that points at a local http wiremock server also relaxes the
200            // asset-url check, so integration tests can exercise the
201            // download-and-unpack path without standing up TLS.
202            let require_https = base_url.starts_with("https://");
203            self_update(&http, &release, &exe, require_https).await?;
204            "self-updated"
205        }
206    };
207    report(format, current, &latest, false, action)
208}
209
210fn report(
211    format: Format,
212    current: &str,
213    latest: &str,
214    up_to_date: bool,
215    action: &str,
216) -> Result<()> {
217    match format {
218        Format::Json => output::print_json(&serde_json::json!({
219            "current": current,
220            "latest": latest,
221            "up_to_date": up_to_date,
222            "action": action,
223        })),
224        Format::Human => {
225            if up_to_date {
226                output::success(&format!("bb {current} is up to date"));
227            } else {
228                output::info(&format!("{current} -> {latest}"));
229                if action == "self-updated" {
230                    output::success("updated in place");
231                } else {
232                    output::info(&format!("this install is managed elsewhere; run: {action}"));
233                }
234            }
235            Ok(())
236        }
237    }
238}
239
240/// Removes the staged file on drop unless disarmed. This guarantees cleanup
241/// on every early-return failure path after the file is created, not just
242/// the one case that used to check for it.
243struct StagedGuard {
244    path: std::path::PathBuf,
245    armed: bool,
246}
247
248impl StagedGuard {
249    fn new(path: std::path::PathBuf) -> Self {
250        Self { path, armed: true }
251    }
252
253    /// Call once the file has been successfully renamed into place, so drop
254    /// does not try to remove a path that is now the live binary (or that no
255    /// longer exists).
256    fn disarm(mut self) {
257        self.armed = false;
258    }
259}
260
261impl Drop for StagedGuard {
262    fn drop(&mut self) {
263        if self.armed {
264            let _ = std::fs::remove_file(&self.path);
265        }
266    }
267}
268
269/// The url comes from the release payload; a hijacked payload must not be
270/// able to downgrade the transport used to fetch the binary. `require_https`
271/// is false only for the `BB_UPDATE_API_BASE` test override, so production
272/// use (the default `https://api.github.com`) always enforces this.
273fn checked_asset_url(name: &str, url: String, require_https: bool) -> Result<String> {
274    if require_https && !url.starts_with("https://") {
275        return Err(BbError::Config(format!(
276            "release asset {name} has a non-https download url"
277        )));
278    }
279    Ok(url)
280}
281
282/// Downloads, verifies, unpacks and atomically replaces the running binary.
283/// Nothing is written next to the binary until the digest matches.
284async fn self_update(
285    http: &reqwest::Client,
286    release: &Release,
287    exe: &Path,
288    require_https: bool,
289) -> Result<()> {
290    let triple = current_triple()
291        .ok_or_else(|| BbError::Config("no published binary for this platform".into()))?;
292    let (archive_name, checksum_name) = asset_names(&release.tag_name, triple);
293
294    let find = |name: &str| -> Result<String> {
295        let url = release
296            .assets
297            .iter()
298            .find(|a| a.name == name)
299            .map(|a| a.browser_download_url.clone())
300            .ok_or_else(|| BbError::Config(format!("release asset {name} is missing")))?;
301        checked_asset_url(name, url, require_https)
302    };
303
304    let archive_bytes = fetch_bounded(
305        http,
306        find(&archive_name)?,
307        MAX_ARCHIVE_BYTES,
308        "release archive",
309    )
310    .await?;
311    let checksum_bytes = fetch_bounded(
312        http,
313        find(&checksum_name)?,
314        MAX_CHECKSUM_BYTES,
315        "checksum file",
316    )
317    .await?;
318    let expected = String::from_utf8_lossy(&checksum_bytes);
319    let expected = expected
320        .split_whitespace()
321        .next()
322        .unwrap_or_default()
323        .to_lowercase();
324
325    use sha2::{Digest, Sha256};
326    let actual = format!("{:x}", Sha256::digest(&archive_bytes));
327    if actual != expected {
328        return Err(BbError::Config(
329            "checksum mismatch — refusing to install this download".into(),
330        ));
331    }
332
333    let parent = exe
334        .parent()
335        .ok_or_else(|| BbError::Config("cannot determine the install directory".into()))?;
336
337    // Entropy in the name defeats a pre-planted symlink at a fixed path and
338    // avoids two concurrent `bb update` runs colliding on the same file.
339    let now = std::time::SystemTime::now()
340        .duration_since(std::time::UNIX_EPOCH)
341        .map(|d| d.as_nanos())
342        .unwrap_or_default();
343    let staged = parent.join(format!(".bb-update-staged-{}-{now}", std::process::id()));
344
345    let mut found = false;
346    let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive_bytes[..]));
347    let mut archive = tar::Archive::new(decoder);
348    for entry in archive.entries()? {
349        let mut entry = entry?;
350        let is_bb = entry
351            .path()?
352            .file_name()
353            .map(|n| n == std::ffi::OsStr::new("bb"))
354            .unwrap_or(false);
355        if !is_bb {
356            continue;
357        }
358        // Do not let tar decide the node type. A symlink or hard-link entry
359        // named `bb` must never be followed: `Entry::unpack` skips link
360        // validation when given an explicit destination with no
361        // `target_base`, which lets a malicious archive chmod or overwrite
362        // an arbitrary file outside the install directory. Only a plain
363        // regular file is accepted; anything else is treated as "not found".
364        if !entry.header().entry_type().is_file() {
365            continue;
366        }
367
368        // create_new(true) fails if the path already exists, which also
369        // closes the pre-planted-file/symlink hole at the staged path.
370        let mut out = std::fs::OpenOptions::new()
371            .write(true)
372            .create_new(true)
373            .open(&staged)
374            .map_err(BbError::Io)?;
375        let guard = StagedGuard::new(staged.clone());
376
377        let mut limited = std::io::Read::take(&mut entry, MAX_UNPACKED_BYTES);
378        let copied = std::io::copy(&mut limited, &mut out).map_err(BbError::Io)?;
379        drop(out);
380        if copied >= MAX_UNPACKED_BYTES {
381            return Err(BbError::Config(format!(
382                "unpacked bb binary exceeds the {MAX_UNPACKED_BYTES} byte limit"
383            )));
384        }
385
386        #[cfg(unix)]
387        {
388            use std::os::unix::fs::PermissionsExt;
389            std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
390                .map_err(BbError::Io)?;
391        }
392
393        // Same-directory rename is atomic, so an interrupted update can
394        // never leave a truncated `bb` behind.
395        std::fs::rename(&staged, exe).map_err(BbError::Io)?;
396        guard.disarm();
397        found = true;
398        break;
399    }
400    if !found {
401        return Err(BbError::Config(
402            "archive contains no regular-file bb binary".into(),
403        ));
404    }
405    Ok(())
406}
407
408#[cfg(test)]
409#[allow(clippy::unwrap_used)]
410mod tests {
411    use super::*;
412    use std::path::Path;
413
414    #[test]
415    fn homebrew_paths_are_detected() {
416        for p in [
417            "/opt/homebrew/bin/bb",
418            "/usr/local/Cellar/bb/1.0.0/bin/bb",
419            "/home/linuxbrew/.linuxbrew/bin/bb",
420        ] {
421            assert_eq!(classify_install(Path::new(p)), InstallKind::Homebrew, "{p}");
422        }
423    }
424
425    #[test]
426    fn cargo_bin_is_detected() {
427        assert_eq!(
428            classify_install(Path::new("/Users/dev/.cargo/bin/bb")),
429            InstallKind::Cargo
430        );
431    }
432
433    #[test]
434    fn anything_else_is_standalone() {
435        for p in ["/usr/local/bin/bb", "/home/dev/.local/bin/bb", "./bb"] {
436            assert_eq!(
437                classify_install(Path::new(p)),
438                InstallKind::Standalone,
439                "{p}"
440            );
441        }
442    }
443
444    #[test]
445    fn versions_parse_with_and_without_a_v_prefix() {
446        assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
447        assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
448        assert_eq!(parse_version("v10.0.1"), Some((10, 0, 1)));
449    }
450
451    #[test]
452    fn malformed_versions_are_rejected_rather_than_panicking() {
453        for bad in ["", "v", "1.2", "1.2.x", "latest", "v1.2.3.4"] {
454            assert_eq!(parse_version(bad), None, "{bad}");
455        }
456    }
457
458    #[test]
459    fn is_newer_compares_each_component() {
460        assert!(is_newer("v1.0.1", "1.0.0"));
461        assert!(is_newer("v1.1.0", "1.0.9"));
462        assert!(is_newer("v2.0.0", "1.9.9"));
463        assert!(!is_newer("v1.0.0", "1.0.0"));
464        assert!(!is_newer("v0.9.0", "1.0.0"));
465    }
466
467    /// An unparseable remote tag must never be treated as an upgrade — that
468    /// would download and install an arbitrary asset on a malformed response.
469    #[test]
470    fn unparseable_remote_tag_is_not_newer() {
471        assert!(!is_newer("garbage", "1.0.0"));
472        assert!(!is_newer("", "1.0.0"));
473    }
474
475    #[test]
476    fn https_asset_urls_are_required_when_enforced() {
477        assert!(
478            checked_asset_url("bb.tar.gz", "http://evil.example/bb.tar.gz".into(), true).is_err()
479        );
480        assert!(
481            checked_asset_url("bb.tar.gz", "https://example.com/bb.tar.gz".into(), true).is_ok()
482        );
483    }
484
485    #[test]
486    fn https_enforcement_is_skipped_for_the_test_override() {
487        assert!(
488            checked_asset_url("bb.tar.gz", "http://127.0.0.1:1234/bb.tar.gz".into(), false).is_ok()
489        );
490    }
491
492    #[test]
493    fn asset_names_follow_the_release_workflow_convention() {
494        let (archive, checksum) = asset_names("v1.0.0", "x86_64-apple-darwin");
495        assert_eq!(archive, "bbcloud-v1.0.0-x86_64-apple-darwin.tar.gz");
496        assert_eq!(checksum, "bbcloud-v1.0.0-x86_64-apple-darwin.sha256");
497    }
498}