use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tracing::{debug, info, warn};
pub(crate) async fn fetch_latest_tag(repo: &str, timeout: Duration) -> Result<String, String> {
use crate::util::http::build_download_client;
let url = format!("https://github.com/{repo}/releases/latest");
let client =
build_download_client(timeout).map_err(|e| format!("release check client failed: {e}"))?;
let response = client
.get(&url)
.send()
.await
.map_err(|e| format!("release check request failed: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"release check got HTTP {} from {url}",
response.status()
));
}
let tag = response
.url()
.path_segments()
.and_then(|mut segments| segments.next_back().map(str::to_string))
.unwrap_or_default();
if tag.is_empty() {
return Err("release redirect resolved to an empty tag".to_string());
}
Ok(tag)
}
#[must_use]
pub(crate) fn parse_sha256_sidecar(body: &str) -> Option<(String, String)> {
let mut tokens = body.split_whitespace();
let hash = tokens.next()?;
let filename = tokens.next()?.to_string();
let valid_hash = hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit());
valid_hash.then(|| (hash.to_ascii_lowercase(), filename))
}
#[must_use]
pub(crate) fn parse_tag_version(s: &str, prefix: &str) -> Option<semver::Version> {
semver::Version::parse(s.strip_prefix(prefix).unwrap_or(s)).ok()
}
pub(crate) fn swap_binary_in_place(fresh: &Path, dest: &Path) -> Result<(), String> {
let tmp = dest.with_extension("mahbot_tmp");
let _ = fs::remove_file(&tmp);
fs::copy(fresh, &tmp).map_err(|e| {
format!(
"failed to copy {} to {}: {e}",
fresh.display(),
tmp.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(dest).map_or(0o755, |m| m.permissions().mode() & 0o777);
fs::set_permissions(&tmp, fs::Permissions::from_mode(mode))
.map_err(|e| format!("failed to set permissions on {}: {e}", tmp.display()))?;
}
if cfg!(target_os = "windows") && dest.exists() {
rename_aside_swap(&tmp, dest)
} else {
fs::rename(&tmp, dest).map_err(|e| {
format!(
"failed to rename {} to {}: {e}",
tmp.display(),
dest.display()
)
})
}
}
fn rename_aside_swap(tmp: &Path, dest: &Path) -> Result<(), String> {
let aside = dest.with_extension("old");
let _ = fs::remove_file(&aside);
fs::rename(dest, &aside)
.map_err(|e| format!("failed to move {} aside: {e}", dest.display()))?;
if let Err(e) = fs::rename(tmp, dest) {
if let Err(restore) = fs::rename(&aside, dest) {
return Err(format!(
"failed to rename {} to {}: {e}; the restore also failed ({restore}) — \
{} is missing and the managed binary must be reinstalled",
tmp.display(),
dest.display(),
dest.display()
));
}
return Err(format!(
"failed to rename {} to {}: {e}",
tmp.display(),
dest.display()
));
}
let _ = fs::remove_file(&aside);
Ok(())
}
pub(crate) fn extract_single_file_tar_gz(
archive: &Path,
dir: &Path,
file_name: &str,
) -> Result<PathBuf, String> {
let file = fs::File::open(archive)
.map_err(|e| format!("failed to open archive {}: {e}", archive.display()))?;
let mut tar_archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
let out_path = dir.join(file_name);
let mut unpacked = false;
let entries = tar_archive
.entries()
.map_err(|e| format!("failed to read archive {}: {e}", archive.display()))?;
for entry in entries {
let mut entry = entry.map_err(|e| format!("failed to read archive entry: {e}"))?;
let path = entry
.path()
.map_err(|e| format!("failed to read archive entry path: {e}"))?
.into_owned();
if !entry.header().entry_type().is_file() {
continue;
}
if path.file_name().is_some_and(|n| n == OsStr::new(file_name)) {
entry
.unpack(&out_path)
.map_err(|e| format!("failed to extract {}: {e}", out_path.display()))?;
unpacked = true;
break;
}
}
if !unpacked {
return Err(format!("archive contains no {file_name} binary"));
}
set_executable(&out_path)?;
Ok(out_path)
}
pub(crate) fn extract_single_file_zip(
archive: &Path,
dir: &Path,
file_name: &str,
) -> Result<PathBuf, String> {
let file = fs::File::open(archive)
.map_err(|e| format!("failed to open archive {}: {e}", archive.display()))?;
let mut zip = zip::ZipArchive::new(file)
.map_err(|e| format!("failed to read archive {}: {e}", archive.display()))?;
let out_path = dir.join(file_name);
let mut unpacked = false;
for i in 0..zip.len() {
let mut entry = zip
.by_index(i)
.map_err(|e| format!("failed to read archive entry: {e}"))?;
if !entry.is_file() {
continue;
}
if Path::new(entry.name())
.file_name()
.is_some_and(|n| n == OsStr::new(file_name))
{
let mut out_file = fs::File::create(&out_path)
.map_err(|e| format!("failed to create {}: {e}", out_path.display()))?;
std::io::copy(&mut entry, &mut out_file)
.map_err(|e| format!("failed to extract {}: {e}", out_path.display()))?;
unpacked = true;
break;
}
}
if !unpacked {
return Err(format!("archive contains no {file_name} binary"));
}
set_executable(&out_path)?;
Ok(out_path)
}
#[cfg(unix)]
pub(crate) fn set_executable(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
.map_err(|e| format!("failed to set executable bit on {}: {e}", path.display()))
}
#[cfg(not(unix))]
pub(crate) fn set_executable(_path: &Path) -> Result<(), String> {
Ok(())
}
pub(crate) fn host_os_arch() -> Result<(&'static str, &'static str), String> {
let os = if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else {
return Err(format!("unsupported platform: {}", std::env::consts::OS));
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
return Err(format!("unsupported arch: {}", std::env::consts::ARCH));
};
Ok((os, arch))
}
#[must_use]
pub(crate) fn linux_host_is_musl() -> bool {
Path::new("/lib/ld-musl-x86_64.so.1").exists()
|| Path::new("/lib/ld-musl-aarch64.so.1").exists()
}
#[must_use]
pub(crate) fn bun_bin_dir() -> Option<PathBuf> {
directories::UserDirs::new().map(|d| d.home_dir().join(".bun").join("bin"))
}
#[must_use]
pub(crate) fn storage_bin_dir() -> Option<PathBuf> {
crate::config::CONFIG
.try_storage_root()
.or_else(|| crate::config::default_config_dir().ok())
.map(|r| r.join("bin"))
}
#[cfg(unix)]
fn managed_shell_dirs() -> Vec<PathBuf> {
[storage_bin_dir(), bun_bin_dir()]
.into_iter()
.flatten()
.collect()
}
pub(crate) fn ensure_rc_path_block() {
#[cfg(unix)]
ensure_unix_rc_path_block();
}
#[cfg(unix)]
fn ensure_unix_rc_path_block() {
let dirs = managed_shell_dirs();
if dirs.is_empty() {
debug!("managed-bin PATH block skipped: no managed bin dirs resolved");
return;
}
let path_entry = dirs
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(":");
let block = format!(
"\n# >>> mahbot managed binaries >>>\nexport PATH=\"$PATH:{path_entry}\"\n# <<< mahbot managed binaries <<<\n"
);
let Some(home) = directories::UserDirs::new().map(|d| d.home_dir().to_path_buf()) else {
debug!("managed-bin PATH block skipped: user home not resolvable");
return;
};
let shell = login_shell_name();
for (name, create_allowed) in [
(".zshrc", shell.as_deref().is_none_or(|s| s == "zsh")),
(".bashrc", shell.as_deref().is_none_or(|s| s == "bash")),
] {
let path = home.join(name);
match append_rc_block(&path, &block, create_allowed) {
Ok(true) => {
info!(
"added mahbot managed-binaries PATH block to {}",
path.display()
);
}
Ok(false) => {
debug!(
"mahbot managed-binaries PATH block already present in {}",
path.display()
);
}
Err(e) => {
warn!(
"failed to ensure managed-binaries PATH block in {}: {e}",
path.display()
);
}
}
}
}
#[cfg(unix)]
fn append_rc_block(path: &Path, block: &str, create_allowed: bool) -> Result<bool, String> {
use std::fs::OpenOptions;
use std::io::Write;
match fs::read_to_string(path) {
Ok(content) => {
if content.contains("# >>> mahbot managed binaries >>>") {
Ok(false)
} else {
let mut f = OpenOptions::new()
.append(true)
.open(path)
.map_err(|e| format!("failed to append to {}: {e}", path.display()))?;
f.write_all(block.as_bytes())
.map_err(|e| format!("failed to append to {}: {e}", path.display()))?;
Ok(true)
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if create_allowed {
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| format!("failed to create {}: {e}", path.display()))?;
f.write_all(block.as_bytes())
.map_err(|e| format!("failed to write {}: {e}", path.display()))?;
Ok(true)
} else {
Ok(false)
}
}
Err(e) => Err(format!("failed to read {}: {e}", path.display())),
}
}
#[cfg(unix)]
#[must_use]
fn login_shell_name() -> Option<String> {
std::env::var("SHELL")
.ok()
.filter(|s| !s.is_empty())
.and_then(|s| {
Path::new(&s)
.file_name()
.map(|f| f.to_string_lossy().into_owned())
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[test]
fn release_version_parsing_strips_prefix() {
assert_eq!(
parse_tag_version("v1.5.100", "v"),
Some(semver::Version::new(1, 5, 100))
);
assert_eq!(
parse_tag_version("1.5.100", "v"),
Some(semver::Version::new(1, 5, 100))
);
assert_eq!(
parse_tag_version("bun-v1.2.3", "bun-v"),
Some(semver::Version::new(1, 2, 3))
);
assert_eq!(parse_tag_version("latest", "v"), None);
assert_eq!(parse_tag_version("", "v"), None);
}
#[test]
fn sha256_sidecar_parses_hash_and_filename() {
let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
assert_eq!(
parse_sha256_sidecar(&format!("{hash} bun-linux-x64.zip")),
Some((hash.to_string(), "bun-linux-x64.zip".to_string()))
);
assert_eq!(
parse_sha256_sidecar(&format!(" {hash} bun-darwin-arm64.zip\n")),
Some((hash.to_string(), "bun-darwin-arm64.zip".to_string()))
);
assert_eq!(
parse_sha256_sidecar(&format!("{} x.tar.gz", hash.to_uppercase())),
Some((hash.to_string(), "x.tar.gz".to_string()))
);
assert_eq!(parse_sha256_sidecar(hash), None);
assert_eq!(parse_sha256_sidecar("abcd x.tar.gz"), None);
assert_eq!(
parse_sha256_sidecar(&format!("{} x.tar.gz", "g".repeat(64))),
None
);
assert_eq!(parse_sha256_sidecar(""), None);
}
#[cfg(unix)]
#[tokio::test]
async fn swap_binary_in_place_preserves_permissions_and_leaves_no_temp() {
let dir = tempfile::tempdir().expect("create temp dir");
let dest = dir.path().join("bun");
let fresh = dir.path().join("fresh");
fs::write(&dest, "old").expect("write old dest");
fs::set_permissions(&dest, fs::Permissions::from_mode(0o755)).expect("chmod dest");
fs::write(&fresh, "new").expect("write fresh");
fs::set_permissions(&fresh, fs::Permissions::from_mode(0o755)).expect("chmod fresh");
swap_binary_in_place(&fresh, &dest).expect("swap");
assert_eq!(fs::read(&dest).expect("read dest"), b"new".as_slice());
assert_eq!(
fs::metadata(&dest).expect("stat dest").permissions().mode() & 0o777,
0o755
);
assert!(
!dest.with_extension("mahbot_tmp").exists(),
"no temp sibling left"
);
fs::set_permissions(&dest, fs::Permissions::from_mode(0o700)).expect("chmod dest");
fs::write(&fresh, "newer").expect("write fresh 2");
swap_binary_in_place(&fresh, &dest).expect("swap 2");
assert_eq!(fs::read(&dest).expect("read dest"), b"newer".as_slice());
assert_eq!(
fs::metadata(&dest).expect("stat dest").permissions().mode() & 0o777,
0o700
);
assert!(
!dest.with_extension("mahbot_tmp").exists(),
"no temp sibling left"
);
}
#[test]
fn rename_aside_swap_replaces_and_cleans_up() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("bun");
let tmp = dir.path().join("prepared");
fs::write(&dest, "old").expect("write dest");
fs::write(&tmp, "new").expect("write tmp");
rename_aside_swap(&tmp, &dest).expect("swap");
assert_eq!(fs::read(&dest).expect("read dest"), b"new");
assert!(!dest.with_extension("old").exists());
assert!(!tmp.exists());
}
#[test]
fn rename_aside_swap_restores_dest_when_the_final_rename_fails() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("bun");
let tmp = dir.path().join("missing");
fs::write(&dest, "old").expect("write dest");
assert!(rename_aside_swap(&tmp, &dest).is_err());
assert_eq!(fs::read(&dest).expect("read dest"), b"old");
assert!(!dest.with_extension("old").exists(), "aside was moved back");
}
fn write_test_archive(dir: &std::path::Path, entries: &[(&str, &[u8])]) -> fs::File {
let enc = flate2::write::GzEncoder::new(
fs::File::create(dir.join("pkg.tar.gz")).expect("create archive"),
flate2::Compression::default(),
);
let mut builder = tar::Builder::new(enc);
for (path, contents) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_mode(0o644);
header.set_entry_type(tar::EntryType::Regular);
builder
.append_data(&mut header, path, *contents)
.expect("append entry");
}
builder
.into_inner()
.expect("finish archive")
.finish()
.expect("finish gzip")
}
#[test]
fn extract_single_file_tar_gz_lands_at_the_dir_root() {
let dir = tempfile::tempdir().expect("tempdir");
write_test_archive(dir.path(), &[("bun", b"BIN" as &[u8])]);
let out = extract_single_file_tar_gz(&dir.path().join("pkg.tar.gz"), dir.path(), "bun")
.expect("extract");
assert_eq!(out, dir.path().join("bun"));
assert_eq!(fs::read(&out).expect("read extracted"), b"BIN");
#[cfg(unix)]
{
assert_ne!(
fs::metadata(&out)
.expect("stat extracted")
.permissions()
.mode()
& 0o111,
0
);
}
}
#[test]
fn extract_single_file_tar_gz_handles_a_nested_layout() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = "pkg/bin/bun";
write_test_archive(dir.path(), &[(entry, b"NESTED" as &[u8])]);
let out = extract_single_file_tar_gz(&dir.path().join("pkg.tar.gz"), dir.path(), "bun")
.expect("extract");
assert_eq!(out, dir.path().join("bun"));
assert_eq!(fs::read(&out).expect("read extracted"), b"NESTED");
}
#[test]
fn extract_single_file_tar_gz_errors_without_the_binary() {
let dir = tempfile::tempdir().expect("tempdir");
write_test_archive(dir.path(), &[("readme.txt", b"no bin" as &[u8])]);
assert!(
extract_single_file_tar_gz(&dir.path().join("pkg.tar.gz"), dir.path(), "bun").is_err()
);
}
fn write_test_zip(dir: &std::path::Path, entries: &[(&str, &[u8])]) -> fs::File {
let file = fs::File::create(dir.join("pkg.zip")).expect("create archive");
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (path, contents) in entries {
writer.start_file(path, options).expect("start entry");
writer.write_all(contents).expect("write entry");
}
writer.finish().expect("finish zip")
}
#[test]
fn extract_single_file_zip_lands_at_the_dir_root() {
let dir = tempfile::tempdir().expect("tempdir");
write_test_zip(dir.path(), &[("bun", b"BIN" as &[u8])]);
let out = extract_single_file_zip(&dir.path().join("pkg.zip"), dir.path(), "bun")
.expect("extract");
assert_eq!(out, dir.path().join("bun"));
assert_eq!(fs::read(&out).expect("read extracted"), b"BIN");
#[cfg(unix)]
{
assert_ne!(
fs::metadata(&out)
.expect("stat extracted")
.permissions()
.mode()
& 0o111,
0
);
}
}
#[test]
fn extract_single_file_zip_handles_a_nested_layout() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = "bun-darwin-x64/bun";
write_test_zip(dir.path(), &[(entry, b"NESTED" as &[u8])]);
let out = extract_single_file_zip(&dir.path().join("pkg.zip"), dir.path(), "bun")
.expect("extract");
assert_eq!(out, dir.path().join("bun"));
assert_eq!(fs::read(&out).expect("read extracted"), b"NESTED");
}
#[test]
fn extract_single_file_zip_errors_without_the_binary() {
let dir = tempfile::tempdir().expect("tempdir");
write_test_zip(dir.path(), &[("readme.txt", b"no bin" as &[u8])]);
assert!(extract_single_file_zip(&dir.path().join("pkg.zip"), dir.path(), "bun").is_err());
}
#[cfg(unix)]
#[test]
fn append_rc_block_is_idempotent() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(".zshrc");
let block = "\n# >>> mahbot managed binaries >>>\nexport PATH=\"/x:$PATH\"\n# <<< mahbot managed binaries <<<\n";
assert!(append_rc_block(&path, block, true).expect("first append"));
let content = fs::read_to_string(&path).expect("read back");
assert_eq!(
content.matches("# >>> mahbot managed binaries >>>").count(),
1
);
assert!(content.contains("export PATH=\"/x:$PATH\""));
assert!(!append_rc_block(&path, block, true).expect("second append"));
let content = fs::read_to_string(&path).expect("read back");
assert_eq!(
content.matches("# >>> mahbot managed binaries >>>").count(),
1
);
}
#[cfg(unix)]
#[test]
fn append_rc_block_creates_when_allowed() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(".bashrc");
let block = "\n# >>> mahbot managed binaries >>>\nexport PATH=\"/y:$PATH\"\n# <<< mahbot managed binaries <<<\n";
assert!(append_rc_block(&path, block, true).expect("create"));
assert_eq!(
fs::read_to_string(&path).expect("read back"),
block.to_string()
);
}
#[cfg(unix)]
#[test]
fn append_rc_block_skips_creation_when_not_allowed() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(".bashrc");
let block = "\n# >>> mahbot managed binaries >>>\nexport PATH=\"/y:$PATH\"\n# <<< mahbot managed binaries <<<\n";
assert!(!append_rc_block(&path, block, false).expect("no create"));
assert!(!path.exists(), "no file written");
}
}