use std::fs;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use tracing::{debug, info, warn};
const BUN_RELEASE_REPO: &str = "oven-sh/bun";
const BUN_TAG_PREFIX: &str = "bun-v";
const BUN_RELEASE_TIMEOUT: Duration = Duration::from_secs(30);
const BUN_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
const BUN_CLI_TIMEOUT: Duration = Duration::from_secs(8);
const fn bun_file_name() -> &'static str {
if cfg!(target_os = "windows") {
"bun.exe"
} else {
"bun"
}
}
fn bun_binary_path() -> Option<PathBuf> {
crate::util::managed_bin::bun_bin_dir()
.map(|d| d.join(bun_file_name()))
.filter(|p| crate::util::is_executable(p))
}
#[must_use]
fn bun_asset_target(os: &str, arch: &str, musl: bool, avx2: bool) -> Option<String> {
let base = match (os, arch, musl) {
("macos", "x86_64", _) => "darwin-x64",
("macos", "aarch64", _) => "darwin-aarch64",
("linux", "x86_64", false) => "linux-x64",
("linux", "x86_64", true) => "linux-x64-musl",
("linux", "aarch64", false) => "linux-aarch64",
("linux", "aarch64", true) => "linux-aarch64-musl",
("windows", "x86_64", _) => "windows-x64",
_ => return None,
};
let asset = if !avx2 && arch == "x86_64" {
format!("{base}-baseline")
} else {
base.to_string()
};
Some(asset)
}
async fn bun_asset_name() -> Result<String, String> {
let (os, arch) = crate::util::managed_bin::host_os_arch()?;
let musl = os == "linux" && crate::util::managed_bin::linux_host_is_musl();
bun_asset_target(os, arch, musl, host_has_avx2().await)
.ok_or_else(|| format!("bun has no release asset for {os}-{arch}"))
}
async fn host_has_avx2() -> bool {
if !cfg!(target_arch = "x86_64") {
return true;
}
#[cfg(target_os = "linux")]
{
let Ok(cpuinfo) = tokio::fs::read_to_string("/proc/cpuinfo").await else {
return false;
};
cpuinfo_has_avx2(&cpuinfo)
}
#[cfg(target_os = "macos")]
{
macos_sysctl_has_avx2().await
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
true
}
}
#[cfg(any(target_os = "linux", test))]
#[must_use]
fn cpuinfo_has_avx2(cpuinfo: &str) -> bool {
cpuinfo
.lines()
.any(|l| l.starts_with("flags") && l.split_whitespace().any(|f| f == "avx2"))
}
#[cfg(any(target_os = "macos", test))]
#[must_use]
fn sysctl_has_avx2(sysctl_out: &str) -> bool {
sysctl_out
.lines()
.any(|l| l.contains("machdep.cpu") && l.contains("AVX2"))
}
#[cfg(target_os = "macos")]
async fn macos_sysctl_has_avx2() -> bool {
let mut cmd = Command::new("sysctl");
cmd.arg("-a")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
let Ok(out) = tokio::time::timeout(BUN_CLI_TIMEOUT, cmd.output()).await else {
return false;
};
let Ok(out) = out else {
return false;
};
if !out.status.success() {
return false;
}
sysctl_has_avx2(&String::from_utf8_lossy(&out.stdout))
}
async fn bun_cli_version(path: &Path) -> Option<semver::Version> {
let mut cmd = Command::new(path);
cmd.arg("--version")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
let out = tokio::time::timeout(BUN_CLI_TIMEOUT, cmd.output())
.await
.ok()?
.ok()?;
if !out.status.success() {
return None;
}
crate::util::managed_bin::parse_tag_version(
String::from_utf8_lossy(&out.stdout).trim(),
BUN_TAG_PREFIX,
)
}
fn shasums256_hash_for(body: &str, asset: &str) -> Result<String, String> {
for line in body.lines() {
if let Some((hash, name)) = crate::util::managed_bin::parse_sha256_sidecar(line)
&& name == asset
{
return Ok(hash);
}
}
Err(format!("SHASUMS256.txt has no entry for {asset}"))
}
async fn shasums256_hash(
client: &reqwest::Client,
tag: &str,
asset: &str,
) -> Result<String, String> {
let url =
format!("https://github.com/{BUN_RELEASE_REPO}/releases/download/{tag}/SHASUMS256.txt");
let response = client
.get(&url)
.send()
.await
.map_err(|e| format!("failed to fetch SHASUMS256.txt {url}: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"failed to fetch SHASUMS256.txt {url}: HTTP {}",
response.status()
));
}
let body = response
.text()
.await
.map_err(|e| format!("failed to read SHASUMS256.txt {url}: {e}"))?;
shasums256_hash_for(&body, asset)
}
async fn install_tag(tag: &str) -> Result<PathBuf, String> {
use crate::util::http::{DownloadSizeCheck, build_download_client, download_verified};
let asset = format!("bun-{}.zip", bun_asset_name().await?);
let url = format!("https://github.com/{BUN_RELEASE_REPO}/releases/download/{tag}/{asset}");
let client = build_download_client(BUN_DOWNLOAD_TIMEOUT)
.map_err(|e| format!("failed to build download client: {e}"))?;
let hash = shasums256_hash(&client, tag, &asset).await?;
let dir = tempfile::tempdir().map_err(|e| format!("failed to create temp dir: {e}"))?;
let archive_path = dir.path().join("bun.zip");
download_verified(
&client,
&url,
&archive_path,
&hash,
None,
DownloadSizeCheck::None,
|_, _| {},
)
.await
.map_err(|e| format!("failed to download {url}: {e}"))?;
let fresh = crate::util::managed_bin::extract_single_file_zip(
&archive_path,
dir.path(),
bun_file_name(),
)?;
let dest = crate::util::managed_bin::bun_bin_dir()
.ok_or_else(|| "bun install dir unavailable (home not resolvable)".to_string())?
.join(bun_file_name());
let parent = dest
.parent()
.ok_or_else(|| format!("invalid bun install path {}", dest.display()))?;
fs::create_dir_all(parent)
.map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
crate::util::managed_bin::swap_binary_in_place(&fresh, &dest)?;
crate::util::managed_bin::set_executable(&dest)?;
Ok(dest)
}
async fn install_latest() -> Result<PathBuf, String> {
let tag =
crate::util::managed_bin::fetch_latest_tag(BUN_RELEASE_REPO, BUN_RELEASE_TIMEOUT).await?;
install_tag(&tag).await
}
async fn run_update_check() {
let Some(path) = bun_binary_path() else {
debug!("bun not installed — update check skipped (never first-installs)");
return;
};
crate::util::managed_bin::ensure_rc_path_block();
let local = bun_cli_version(&path).await;
let tag =
match crate::util::managed_bin::fetch_latest_tag(BUN_RELEASE_REPO, BUN_RELEASE_TIMEOUT)
.await
{
Ok(tag) => tag,
Err(e) => {
debug!("bun auto-update skipped: release check failed: {e}");
return;
}
};
let Some(latest) = crate::util::managed_bin::parse_tag_version(&tag, BUN_TAG_PREFIX) else {
info!("bun auto-update: latest tag '{tag}' is not a semver version; giving up");
return;
};
if let Some(local) = local.as_ref() {
if local >= &latest {
debug!("bun is up to date ({local})");
return;
}
} else {
info!(
"bun binary at {} reports no usable version — self-healing reinstall",
path.display()
);
}
match install_tag(&tag).await {
Ok(dest) => info!("bun auto-updated to {latest} ({})", dest.display()),
Err(e) => info!(
"bun auto-update failed: {}",
crate::util::truncate(&e, 1024)
),
}
}
pub async fn run_bun_management() {
if bun_binary_path().is_none() {
match install_latest().await {
Ok(path) => {
info!("bun installed to {}", path.display());
crate::util::managed_bin::ensure_rc_path_block();
return;
}
Err(e) => {
warn!(
"bun auto-install failed (non-fatal, retried on next boot): {}",
crate::util::truncate(&e, 1024)
);
return;
}
}
}
tokio::time::sleep(Duration::from_mins(5)).await;
run_update_check().await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bun_asset_target_maps_supported_combos() {
assert_eq!(
bun_asset_target("macos", "x86_64", false, true).as_deref(),
Some("darwin-x64")
);
assert_eq!(
bun_asset_target("macos", "aarch64", false, true).as_deref(),
Some("darwin-aarch64")
);
assert_eq!(
bun_asset_target("linux", "x86_64", false, true).as_deref(),
Some("linux-x64")
);
assert_eq!(
bun_asset_target("linux", "x86_64", true, true).as_deref(),
Some("linux-x64-musl")
);
assert_eq!(
bun_asset_target("linux", "aarch64", false, true).as_deref(),
Some("linux-aarch64")
);
assert_eq!(
bun_asset_target("linux", "aarch64", true, true).as_deref(),
Some("linux-aarch64-musl")
);
assert_eq!(
bun_asset_target("windows", "x86_64", false, true).as_deref(),
Some("windows-x64")
);
assert_eq!(bun_asset_target("freebsd", "x86_64", false, true), None);
assert_eq!(bun_asset_target("linux", "arm", false, true), None);
}
#[test]
fn bun_asset_target_appends_baseline_for_x64_without_avx2() {
assert_eq!(
bun_asset_target("linux", "x86_64", false, false).as_deref(),
Some("linux-x64-baseline")
);
assert_eq!(
bun_asset_target("macos", "x86_64", false, false).as_deref(),
Some("darwin-x64-baseline")
);
assert_eq!(
bun_asset_target("linux", "aarch64", false, false).as_deref(),
Some("linux-aarch64")
);
}
#[test]
fn cpuinfo_avx2_flags_line_is_detected() {
let cpuinfo = "processor : 0\nflags\t\t: fpu vme avx2 sse4_1\n";
assert!(cpuinfo_has_avx2(cpuinfo));
let cpuinfo = "processor : 0\nflags\t\t: fpu vme sse4_1\n";
assert!(!cpuinfo_has_avx2(cpuinfo));
assert!(!cpuinfo_has_avx2("Features\t\t: avx2\n"));
}
#[test]
fn sysctl_avx2_line_is_detected() {
let out = "machdep.cpu.features: FPU VME AVX2\n";
assert!(sysctl_has_avx2(out));
let out = "machdep.cpu.features: FPU VME\n";
assert!(!sysctl_has_avx2(out));
assert!(!sysctl_has_avx2("machdep.cpu.features: avx2\n"));
}
#[test]
fn shasums256_line_matching_finds_the_requested_asset() {
let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let body = format!(
"{hash} bun-darwin-aarch64.zip\n\
1111111111111111111111111111111111111111111111111111111111111111 bun-linux-x64.zip\n\
2222222222222222222222222222222222222222222222222222222222222222 SHASUMS256.txt\n"
);
assert_eq!(
shasums256_hash_for(&body, "bun-linux-x64.zip").as_deref(),
Ok("1111111111111111111111111111111111111111111111111111111111111111")
);
assert_eq!(
shasums256_hash_for(&body, "bun-linux-x64-baseline.zip")
.expect_err("missing asset must be an Err"),
"SHASUMS256.txt has no entry for bun-linux-x64-baseline.zip"
);
}
}