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