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.
85///
86/// The formula is named in full, `biokraft/tap/bb`, rather than as bare `bb`.
87/// Homebrew resolves an unqualified name against casks as well as formulae,
88/// and an unrelated cask called `bb` now exists — so `brew upgrade bb` fails
89/// with "Cask 'bb' is not installed" and never touches this install.
90const HOMEBREW_UPDATE_HINT: &str = "brew update && brew upgrade biokraft/tap/bb";
91
92/// The command that upgrades an install of this kind. A standalone install is
93/// the only one `bb` can replace itself, so it is the only one pointed back at
94/// `bb update`; the package-manager cases must go through their own manager or
95/// brew and cargo would keep believing they manage a file they no longer wrote.
96pub fn upgrade_hint(kind: InstallKind) -> &'static str {
97    match kind {
98        InstallKind::Homebrew => HOMEBREW_UPDATE_HINT,
99        InstallKind::Cargo => "cargo install bbcloud --locked --force",
100        InstallKind::Standalone => "bb update",
101    }
102}
103
104/// The tag of the newest published release, e.g. `v0.19.4`.
105///
106/// Split out of `run` so the passive update check can ask the same question
107/// with its own client — it needs a short timeout, since it runs ahead of a
108/// command the user actually asked for, while `run` needs a long one because
109/// it goes on to download a binary.
110pub async fn latest_tag(http: &reqwest::Client, base_url: &str) -> Result<String> {
111    Ok(latest_release(http, base_url).await?.tag_name)
112}
113
114async fn latest_release(http: &reqwest::Client, base_url: &str) -> Result<Release> {
115    let url = format!(
116        "{}/repos/biokraft/bbcloud/releases/latest",
117        base_url.trim_end_matches('/')
118    );
119    let response = http.get(&url).send().await?;
120    if !response.status().is_success() {
121        return Err(release_error(&response));
122    }
123    let body = bound_body(response, MAX_RELEASE_JSON_BYTES, "release metadata").await?;
124    Ok(serde_json::from_slice(&body)?)
125}
126
127#[derive(Debug, Deserialize)]
128struct ReleaseAsset {
129    name: String,
130    browser_download_url: String,
131}
132
133#[derive(Debug, Deserialize)]
134struct Release {
135    tag_name: String,
136    #[serde(default)]
137    assets: Vec<ReleaseAsset>,
138}
139
140/// A client with no credentials attached. The Bitbucket `api::Client` always
141/// sends the Basic auth header, and that token must never reach another host.
142///
143/// This client deliberately diverges from two of `api::Client`'s rules, and
144/// both divergences are safe only because no credential is ever attached
145/// here: redirects are followed (default policy) because GitHub asset
146/// download URLs redirect to `objects.githubusercontent.com`, and the total
147/// timeout is longer than the API client's because this transfers a compiled
148/// binary rather than a JSON page.
149fn release_client() -> Result<reqwest::Client> {
150    Ok(reqwest::Client::builder()
151        .connect_timeout(Duration::from_secs(10))
152        .timeout(Duration::from_secs(120))
153        .user_agent(concat!("bbcloud/", env!("CARGO_PKG_VERSION")))
154        .build()?)
155}
156
157/// Archive downloads are capped generously for this binary but not
158/// unbounded, so a checksum-valid bomb can't exhaust memory or disk. Enforced
159/// while the body streams in (`fetch_bounded`), not after it is fully
160/// buffered, since `Content-Length` is attacker-controlled and may be absent.
161const MAX_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024;
162/// Decompressed output is capped separately: gzip can amplify a small
163/// archive into a much larger stream.
164const MAX_UNPACKED_BYTES: u64 = 200 * 1024 * 1024;
165/// The `.sha256` body is a hex digest and a filename; a few KB is generous
166/// and keeps a hostile checksum-file response from being buffered unbounded.
167const MAX_CHECKSUM_BYTES: u64 = 4 * 1024;
168/// The release metadata JSON (tag name plus asset list) is a few KB in
169/// practice; 1 MiB is generous headroom while still keeping a hostile or
170/// malformed response from being buffered unbounded via `response.json()`.
171const MAX_RELEASE_JSON_BYTES: u64 = 1024 * 1024;
172
173/// Downloads `url`'s body, rejecting it the moment the accumulated length
174/// would exceed `limit` rather than after buffering the whole thing. A
175/// `Content-Length` over the limit is rejected as a fast path, but is not
176/// relied on alone since it is attacker-controlled and may be absent.
177async fn fetch_bounded(
178    http: &reqwest::Client,
179    url: String,
180    limit: u64,
181    what: &str,
182) -> Result<Vec<u8>> {
183    let response = http.get(url).send().await?;
184    bound_body(response, limit, what).await
185}
186
187/// The bounded-read half of `fetch_bounded`, split out so callers that must
188/// inspect the response (e.g. its status code) before deciding to buffer the
189/// body can still get the same length enforcement.
190async fn bound_body(mut response: reqwest::Response, limit: u64, what: &str) -> Result<Vec<u8>> {
191    if let Some(len) = response.content_length() {
192        if len > limit {
193            return Err(BbError::Config(format!(
194                "{what} reports {len} bytes, larger than the {limit} byte limit"
195            )));
196        }
197    }
198    let mut buf = Vec::new();
199    while let Some(chunk) = response.chunk().await? {
200        buf.extend_from_slice(&chunk);
201        if buf.len() as u64 > limit {
202            return Err(BbError::Config(format!(
203                "{what} exceeded the {limit} byte limit"
204            )));
205        }
206    }
207    Ok(buf)
208}
209
210pub fn release_api_base() -> String {
211    std::env::var("BB_UPDATE_API_BASE").unwrap_or_else(|_| DEFAULT_RELEASE_API.to_string())
212}
213
214/// Header lookup that treats every header as optional: they only exist on
215/// GitHub's responses, so any other host (or a malformed/missing header)
216/// must fall through cleanly rather than panicking.
217fn header_str<'a>(response: &'a reqwest::Response, name: &str) -> Option<&'a str> {
218    response.headers().get(name)?.to_str().ok()
219}
220
221/// Renders a GitHub `x-ratelimit-reset` epoch as a local wall-clock
222/// `HH:MM`. Returns `None` for a missing or unparseable header rather than
223/// falling back to the Unix epoch (`1970-01-01`), which would be a lie.
224fn retry_time(response: &reqwest::Response) -> Option<String> {
225    let epoch: i64 = header_str(response, "x-ratelimit-reset")?.parse().ok()?;
226    format_epoch_local(epoch)
227}
228
229/// Renders a Unix epoch as a local `HH:MM`, rejecting negative values rather
230/// than letting them render as a pre-1970 clock.
231fn format_epoch_local(epoch: i64) -> Option<String> {
232    if epoch < 0 {
233        return None;
234    }
235    let utc = chrono::DateTime::from_timestamp(epoch, 0)?;
236    Some(
237        utc.with_timezone(&chrono::Local)
238            .format("%H:%M")
239            .to_string(),
240    )
241}
242
243/// Maps a non-success release-api response to an honest error: a GitHub
244/// unauthenticated rate limit is named as such (with a retry time when the
245/// header allows one), and anything else is reported as a plain release-api
246/// error rather than a false "cannot reach" / "bitbucket" claim.
247fn release_error(response: &reqwest::Response) -> BbError {
248    let status = response.status();
249    let remaining = header_str(response, "x-ratelimit-remaining");
250    let is_rate_limited = matches!(status.as_u16(), 403 | 429) && remaining == Some("0");
251
252    let message = if is_rate_limited {
253        match retry_time(response) {
254            Some(time) => format!(
255                "github api rate limit reached — 60 requests per hour for unauthenticated access, retry after {time}"
256            ),
257            None => "github api rate limit reached — 60 requests per hour for unauthenticated access".to_string(),
258        }
259    } else {
260        status
261            .canonical_reason()
262            .unwrap_or("unknown error")
263            .to_string()
264    };
265
266    BbError::Release {
267        status: status.as_u16(),
268        message,
269    }
270}
271
272pub async fn run(format: Format, base_url: &str) -> Result<()> {
273    let current = env!("CARGO_PKG_VERSION");
274    let http = release_client()?;
275    let release = latest_release(&http, base_url).await?;
276    let latest = release.tag_name.clone();
277
278    let (action, up_to_date) = if !is_newer(&latest, current) {
279        ("none", true)
280    } else {
281        let exe = std::env::current_exe().map_err(BbError::Io)?;
282        let kind = classify_install(&exe);
283        let action = match kind {
284            InstallKind::Homebrew | InstallKind::Cargo => upgrade_hint(kind),
285            InstallKind::Standalone => {
286                // The https-only requirement below is scoped to real usage: it
287                // only applies when the release api itself is https (the
288                // production default). The `BB_UPDATE_API_BASE` test override
289                // that points at a local http wiremock server also relaxes the
290                // asset-url check, so integration tests can exercise the
291                // download-and-unpack path without standing up TLS.
292                let require_https = base_url.starts_with("https://");
293                self_update(&http, &release, &exe, require_https).await?;
294                "self-updated"
295            }
296        };
297        (action, false)
298    };
299
300    // `brew upgrade bb` and `cargo install` never run our code, so this is the
301    // only moment we can bring skill files up to date with the running binary.
302    // Refreshing runs on every path `run()` can take, including up-to-date,
303    // since that is the only path most Homebrew/Cargo users ever hit.
304    let skill_outcomes = match crate::skill::refresh_tracked(crate::skill::MissingPolicy::Restore) {
305        Ok(outcomes) => outcomes,
306        // The binary upgrade already succeeded and is what the user actually
307        // wanted; a filesystem problem here is a warning, not an exit code.
308        Err(err) => {
309            output::warn(&format!("could not refresh agent skills: {err}"));
310            Vec::new()
311        }
312    };
313
314    report(
315        format,
316        current,
317        &latest,
318        up_to_date,
319        action,
320        &skill_outcomes,
321    )
322}
323
324fn report(
325    format: Format,
326    current: &str,
327    latest: &str,
328    up_to_date: bool,
329    action: &str,
330    skill_outcomes: &[crate::skill::Outcome],
331) -> Result<()> {
332    let refreshed: Vec<&crate::skill::Outcome> = skill_outcomes
333        .iter()
334        .filter(|o| o.action == crate::skill::Action::Refreshed)
335        .collect();
336    let skipped: Vec<&crate::skill::Outcome> = skill_outcomes
337        .iter()
338        .filter(|o| o.action == crate::skill::Action::SkippedModified)
339        .collect();
340    let pruned: Vec<&crate::skill::Outcome> = skill_outcomes
341        .iter()
342        .filter(|o| o.action == crate::skill::Action::Pruned)
343        .collect();
344    let failed: Vec<&crate::skill::Outcome> = skill_outcomes
345        .iter()
346        .filter(|o| o.action == crate::skill::Action::Failed)
347        .collect();
348
349    match format {
350        Format::Json => {
351            let mut payload = serde_json::json!({
352                "current": current,
353                "latest": latest,
354                "up_to_date": up_to_date,
355                "action": action,
356            });
357            if !skill_outcomes.is_empty() {
358                payload["skills"] = serde_json::json!({
359                    "refreshed": refreshed.len(),
360                    "skipped_modified": skipped.iter().map(|o| &o.path).collect::<Vec<_>>(),
361                    "pruned": pruned.iter().map(|o| &o.path).collect::<Vec<_>>(),
362                    "failed": failed.iter().map(|o| &o.path).collect::<Vec<_>>(),
363                });
364            }
365            output::print_json(&payload)
366        }
367        Format::Human => {
368            if up_to_date {
369                output::success(&format!("bb {current} is up to date"));
370            } else {
371                output::info(&format!("{current} -> {latest}"));
372                if action == "self-updated" {
373                    output::success("updated in place");
374                } else {
375                    output::info(&format!("this install is managed elsewhere; run: {action}"));
376                }
377            }
378            if !refreshed.is_empty() {
379                output::success(&format!(
380                    "refreshed {} tracked agent skill{}",
381                    refreshed.len(),
382                    if refreshed.len() == 1 { "" } else { "s" }
383                ));
384            }
385            for outcome in &skipped {
386                output::info(&format!(
387                    "skipped modified skill (customized locally): {}",
388                    outcome.path.display()
389                ));
390            }
391            // A skipped skill keeps the user's edits, which is the right
392            // default — but it also means a release that added commands leaves
393            // that agent describing a `bb` that no longer exists. Saying so
394            // once, with the escape hatch, is the difference between a
395            // protected file and a silently stale one.
396            if !skipped.is_empty() {
397                output::info(
398                    "your edits are kept; run `bb skill install --force` to take the new version",
399                );
400            }
401            for outcome in &pruned {
402                output::info(&format!(
403                    "forgot {} (directory no longer exists)",
404                    outcome.path.display()
405                ));
406            }
407            for outcome in &failed {
408                output::warn(&format!(
409                    "could not refresh {}: write failed",
410                    outcome.path.display()
411                ));
412            }
413            Ok(())
414        }
415    }
416}
417
418/// Removes the staged file on drop unless disarmed. This guarantees cleanup
419/// on every early-return failure path after the file is created, not just
420/// the one case that used to check for it.
421struct StagedGuard {
422    path: std::path::PathBuf,
423    armed: bool,
424}
425
426impl StagedGuard {
427    fn new(path: std::path::PathBuf) -> Self {
428        Self { path, armed: true }
429    }
430
431    /// Call once the file has been successfully renamed into place, so drop
432    /// does not try to remove a path that is now the live binary (or that no
433    /// longer exists).
434    fn disarm(mut self) {
435        self.armed = false;
436    }
437}
438
439impl Drop for StagedGuard {
440    fn drop(&mut self) {
441        if self.armed {
442            let _ = std::fs::remove_file(&self.path);
443        }
444    }
445}
446
447/// The url comes from the release payload; a hijacked payload must not be
448/// able to downgrade the transport used to fetch the binary. `require_https`
449/// is false only for the `BB_UPDATE_API_BASE` test override, so production
450/// use (the default `https://api.github.com`) always enforces this.
451fn checked_asset_url(name: &str, url: String, require_https: bool) -> Result<String> {
452    if require_https && !url.starts_with("https://") {
453        return Err(BbError::Config(format!(
454            "release asset {name} has a non-https download url"
455        )));
456    }
457    Ok(url)
458}
459
460/// Downloads, verifies, unpacks and atomically replaces the running binary.
461/// Nothing is written next to the binary until the digest matches.
462async fn self_update(
463    http: &reqwest::Client,
464    release: &Release,
465    exe: &Path,
466    require_https: bool,
467) -> Result<()> {
468    let triple = current_triple()
469        .ok_or_else(|| BbError::Config("no published binary for this platform".into()))?;
470    let (archive_name, checksum_name) = asset_names(&release.tag_name, triple);
471
472    let find = |name: &str| -> Result<String> {
473        let url = release
474            .assets
475            .iter()
476            .find(|a| a.name == name)
477            .map(|a| a.browser_download_url.clone())
478            .ok_or_else(|| BbError::Config(format!("release asset {name} is missing")))?;
479        checked_asset_url(name, url, require_https)
480    };
481
482    let archive_bytes = fetch_bounded(
483        http,
484        find(&archive_name)?,
485        MAX_ARCHIVE_BYTES,
486        "release archive",
487    )
488    .await?;
489    let checksum_bytes = fetch_bounded(
490        http,
491        find(&checksum_name)?,
492        MAX_CHECKSUM_BYTES,
493        "checksum file",
494    )
495    .await?;
496    let expected = String::from_utf8_lossy(&checksum_bytes);
497    let expected = expected
498        .split_whitespace()
499        .next()
500        .unwrap_or_default()
501        .to_lowercase();
502
503    use sha2::{Digest, Sha256};
504    let actual = format!("{:x}", Sha256::digest(&archive_bytes));
505    if actual != expected {
506        return Err(BbError::Config(
507            "checksum mismatch — refusing to install this download".into(),
508        ));
509    }
510
511    let parent = exe
512        .parent()
513        .ok_or_else(|| BbError::Config("cannot determine the install directory".into()))?;
514
515    // Entropy in the name defeats a pre-planted symlink at a fixed path and
516    // avoids two concurrent `bb update` runs colliding on the same file.
517    let now = std::time::SystemTime::now()
518        .duration_since(std::time::UNIX_EPOCH)
519        .map(|d| d.as_nanos())
520        .unwrap_or_default();
521    let staged = parent.join(format!(".bb-update-staged-{}-{now}", std::process::id()));
522
523    let mut found = false;
524    let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive_bytes[..]));
525    let mut archive = tar::Archive::new(decoder);
526    for entry in archive.entries()? {
527        let mut entry = entry?;
528        let is_bb = entry
529            .path()?
530            .file_name()
531            .map(|n| n == std::ffi::OsStr::new("bb"))
532            .unwrap_or(false);
533        if !is_bb {
534            continue;
535        }
536        // Do not let tar decide the node type. A symlink or hard-link entry
537        // named `bb` must never be followed: `Entry::unpack` skips link
538        // validation when given an explicit destination with no
539        // `target_base`, which lets a malicious archive chmod or overwrite
540        // an arbitrary file outside the install directory. Only a plain
541        // regular file is accepted; anything else is treated as "not found".
542        if !entry.header().entry_type().is_file() {
543            continue;
544        }
545
546        // create_new(true) fails if the path already exists, which also
547        // closes the pre-planted-file/symlink hole at the staged path.
548        let mut out = std::fs::OpenOptions::new()
549            .write(true)
550            .create_new(true)
551            .open(&staged)
552            .map_err(BbError::Io)?;
553        let guard = StagedGuard::new(staged.clone());
554
555        let mut limited = std::io::Read::take(&mut entry, MAX_UNPACKED_BYTES);
556        let copied = std::io::copy(&mut limited, &mut out).map_err(BbError::Io)?;
557        drop(out);
558        if copied >= MAX_UNPACKED_BYTES {
559            return Err(BbError::Config(format!(
560                "unpacked bb binary exceeds the {MAX_UNPACKED_BYTES} byte limit"
561            )));
562        }
563
564        #[cfg(unix)]
565        {
566            use std::os::unix::fs::PermissionsExt;
567            std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
568                .map_err(BbError::Io)?;
569        }
570
571        // Same-directory rename is atomic, so an interrupted update can
572        // never leave a truncated `bb` behind.
573        std::fs::rename(&staged, exe).map_err(BbError::Io)?;
574        guard.disarm();
575        found = true;
576        break;
577    }
578    if !found {
579        return Err(BbError::Config(
580            "archive contains no regular-file bb binary".into(),
581        ));
582    }
583    Ok(())
584}
585
586#[cfg(test)]
587#[allow(clippy::unwrap_used)]
588mod tests {
589    use super::*;
590    use std::path::Path;
591
592    #[test]
593    fn homebrew_paths_are_detected() {
594        for p in [
595            "/opt/homebrew/bin/bb",
596            "/usr/local/Cellar/bb/1.0.0/bin/bb",
597            "/home/linuxbrew/.linuxbrew/bin/bb",
598        ] {
599            assert_eq!(classify_install(Path::new(p)), InstallKind::Homebrew, "{p}");
600        }
601    }
602
603    /// `brew upgrade bb` alone does not refresh the tap, so a freshly
604    /// published formula stays invisible; the hint must run `brew update`
605    /// first. This exercises the same literal `run()` delegates to for a
606    /// Homebrew install.
607    #[test]
608    fn homebrew_hint_refreshes_the_tap_before_upgrading() {
609        assert_eq!(
610            HOMEBREW_UPDATE_HINT,
611            "brew update && brew upgrade biokraft/tap/bb"
612        );
613    }
614
615    /// An unqualified `bb` is ambiguous to Homebrew, which resolves it
616    /// against casks too and fails with "Cask 'bb' is not installed" — so the
617    /// hint must name the tap. This is the bug the hint shipped with: the
618    /// command it printed could not work.
619    #[test]
620    fn homebrew_hint_names_the_tap_so_the_formula_is_unambiguous() {
621        assert!(
622            HOMEBREW_UPDATE_HINT.contains("biokraft/tap/bb"),
623            "hint must fully qualify the formula: {HOMEBREW_UPDATE_HINT}"
624        );
625        assert!(
626            !HOMEBREW_UPDATE_HINT.contains("upgrade bb"),
627            "hint must not upgrade an unqualified `bb`: {HOMEBREW_UPDATE_HINT}"
628        );
629    }
630
631    #[test]
632    fn cargo_bin_is_detected() {
633        assert_eq!(
634            classify_install(Path::new("/Users/dev/.cargo/bin/bb")),
635            InstallKind::Cargo
636        );
637    }
638
639    #[test]
640    fn anything_else_is_standalone() {
641        for p in ["/usr/local/bin/bb", "/home/dev/.local/bin/bb", "./bb"] {
642            assert_eq!(
643                classify_install(Path::new(p)),
644                InstallKind::Standalone,
645                "{p}"
646            );
647        }
648    }
649
650    #[test]
651    fn versions_parse_with_and_without_a_v_prefix() {
652        assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
653        assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
654        assert_eq!(parse_version("v10.0.1"), Some((10, 0, 1)));
655    }
656
657    #[test]
658    fn malformed_versions_are_rejected_rather_than_panicking() {
659        for bad in ["", "v", "1.2", "1.2.x", "latest", "v1.2.3.4"] {
660            assert_eq!(parse_version(bad), None, "{bad}");
661        }
662    }
663
664    #[test]
665    fn is_newer_compares_each_component() {
666        assert!(is_newer("v1.0.1", "1.0.0"));
667        assert!(is_newer("v1.1.0", "1.0.9"));
668        assert!(is_newer("v2.0.0", "1.9.9"));
669        assert!(!is_newer("v1.0.0", "1.0.0"));
670        assert!(!is_newer("v0.9.0", "1.0.0"));
671    }
672
673    /// An unparseable remote tag must never be treated as an upgrade — that
674    /// would download and install an arbitrary asset on a malformed response.
675    #[test]
676    fn unparseable_remote_tag_is_not_newer() {
677        assert!(!is_newer("garbage", "1.0.0"));
678        assert!(!is_newer("", "1.0.0"));
679    }
680
681    #[test]
682    fn https_asset_urls_are_required_when_enforced() {
683        assert!(
684            checked_asset_url("bb.tar.gz", "http://evil.example/bb.tar.gz".into(), true).is_err()
685        );
686        assert!(
687            checked_asset_url("bb.tar.gz", "https://example.com/bb.tar.gz".into(), true).is_ok()
688        );
689    }
690
691    #[test]
692    fn https_enforcement_is_skipped_for_the_test_override() {
693        assert!(
694            checked_asset_url("bb.tar.gz", "http://127.0.0.1:1234/bb.tar.gz".into(), false).is_ok()
695        );
696    }
697
698    /// A negative epoch must never render a pre-1970 clock.
699    #[test]
700    fn negative_epoch_is_rejected() {
701        assert_eq!(format_epoch_local(-1), None);
702        assert_eq!(format_epoch_local(-1_000_000), None);
703    }
704
705    #[test]
706    fn a_valid_epoch_still_formats() {
707        assert!(format_epoch_local(1_786_452_151).is_some());
708    }
709
710    #[test]
711    fn asset_names_follow_the_release_workflow_convention() {
712        let (archive, checksum) = asset_names("v1.0.0", "x86_64-apple-darwin");
713        assert_eq!(archive, "bbcloud-v1.0.0-x86_64-apple-darwin.tar.gz");
714        assert_eq!(checksum, "bbcloud-v1.0.0-x86_64-apple-darwin.sha256");
715    }
716}