use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
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(|_| "the release check's own client could not be built".to_string())?;
let response = client
.get(&url)
.send()
.await
.map_err(|e| format!("release check request failed: {}", request_reason(&e)))?;
if !response.status().is_success() {
return Err(format!("release check got HTTP {}", 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)
}
pub(crate) fn request_reason(error: &reqwest::Error) -> String {
if let Some(status) = error.status() {
return format!("HTTP {status}");
}
let kind = if error.is_timeout() {
"timeout"
} else if error.is_connect() {
"connect"
} else if error.is_request() {
"request"
} else if error.is_body() || error.is_decode() {
"body"
} else {
"transport"
};
format!("{kind} failure")
}
pub(crate) fn download_reason(error: &anyhow::Error) -> String {
for cause in error.chain() {
if let Some(rejected) = cause.downcast_ref::<crate::util::http::ChecksumMismatch>() {
return rejected.to_string();
}
if let Some(reqwest) = cause.downcast_ref::<reqwest::Error>() {
return request_reason(reqwest);
}
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
return format!("download failed ({})", io.kind());
}
}
"the release download could not be completed".to_string()
}
#[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_version_token(s: &str) -> Option<semver::Version> {
semver::Version::parse(s.strip_prefix('v').unwrap_or(s)).ok()
}
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!(
"the staged copy for the swap could not be prepared ({})",
e.kind()
)
})?;
#[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!(
"the staged copy's permissions could not be set ({})",
e.kind()
)
})?;
}
if cfg!(target_os = "windows") && dest.exists() {
rename_aside_swap(&tmp, dest)
} else {
fs::rename(&tmp, dest)
.map_err(|e| format!("the new copy could not be moved into place ({})", e.kind()))
}
}
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!("the installed copy could not be moved aside ({})", e.kind()))?;
if let Err(e) = fs::rename(tmp, dest) {
if let Err(restore) = fs::rename(&aside, dest) {
return Err(format!(
"the new copy could not be moved into place ({}); the restore also failed ({}) — \
the file that was there is gone and must be put back by hand",
e.kind(),
restore.kind()
));
}
return Err(format!(
"the new copy could not be moved into place ({})",
e.kind()
));
}
let _ = fs::remove_file(&aside);
Ok(())
}
pub(crate) fn place_extracted(fresh: &Path, dest: &Path) -> Result<(), String> {
let Some(dir) = dest.parent() else {
return Err("the directory it lives in could not be resolved".to_string());
};
fs::create_dir_all(dir).map_err(|e| {
format!(
"the directory it lives in could not be created ({})",
e.kind()
)
})?;
swap_binary_in_place(fresh, dest)?;
set_executable(dest)
}
const REFRESH_DELAY: Duration = Duration::from_mins(5);
pub(crate) async fn install_on_start(
tool: &str,
present: impl Fn() -> bool,
install: impl std::future::Future<Output = Result<PathBuf, String>>,
) {
let missing = !present();
if !missing {
tokio::time::sleep(REFRESH_DELAY).await;
}
match install.await {
Ok(path) if missing => tracing::info!("{tool} installed at {}", path.display()),
Ok(path) => tracing::debug!("{tool} brought up to date at {}", path.display()),
Err(reason) => {
let reason = crate::util::truncate(&reason, 1024);
let state = match (present(), missing) {
(true, true) => "was installed but not completely",
(true, false) => "could not be brought fully up to date",
(false, true) => "could not be installed",
(false, false) => "left the host without a working copy",
};
tracing::warn!("{tool} {state} (retried on the next start): {reason}");
}
}
}
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!("the release archive could not be opened ({})", e.kind()))?;
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!("the release archive could not be read ({})", e.kind()))?;
for entry in entries {
let mut entry = entry
.map_err(|e| format!("a release archive entry could not be read ({})", e.kind()))?;
let path = entry
.path()
.map_err(|e| {
format!(
"a release archive entry path could not be read ({})",
e.kind()
)
})?
.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!(
"{file_name} could not be extracted from the release archive ({})",
e.kind()
)
})?;
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!("the release archive could not be opened ({})", e.kind()))?;
let mut zip = zip::ZipArchive::new(file)
.map_err(|e| format!("the release archive could not be read ({})", zip_reason(&e)))?;
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!(
"a release archive entry could not be read ({})",
zip_reason(&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!("{file_name} could not be written ({})", e.kind()))?;
std::io::copy(&mut entry, &mut out_file).map_err(|e| {
format!(
"{file_name} could not be extracted from the release archive ({})",
e.kind()
)
})?;
unpacked = true;
break;
}
}
if !unpacked {
return Err(format!("archive contains no {file_name} binary"));
}
set_executable(&out_path)?;
Ok(out_path)
}
#[must_use]
fn zip_reason(error: &zip::result::ZipError) -> String {
match error {
zip::result::ZipError::Io(error) => error.kind().to_string(),
other => other.to_string(),
}
}
#[cfg(unix)]
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!(
"the executable bit could not be set on the installed copy ({})",
e.kind()
)
})
}
#[cfg(not(unix))]
#[expect(clippy::unnecessary_wraps)]
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]
fn user_programs_dir() -> Option<PathBuf> {
#[cfg(unix)]
{
directories::UserDirs::new().map(|d| d.home_dir().join(".local").join("bin"))
}
#[cfg(not(unix))]
{
directories::BaseDirs::new().map(|d| d.data_local_dir().join("Programs"))
}
}
#[must_use]
pub(crate) fn mahbot_install_dir() -> Option<PathBuf> {
#[cfg(unix)]
{
user_programs_dir()
}
#[cfg(not(unix))]
{
user_programs_dir().map(|dir| dir.join("MahBot"))
}
}
#[must_use]
pub(crate) fn product_file_name() -> &'static str {
if cfg!(windows) {
"mahbot.exe"
} else {
"mahbot"
}
}
#[must_use]
pub(crate) fn chrome_use_user_bin_dir() -> Option<PathBuf> {
#[cfg(unix)]
{
user_programs_dir()
}
#[cfg(not(unix))]
{
user_programs_dir().map(|dir| dir.join("chrome-use"))
}
}
#[cfg(unix)]
#[must_use]
pub(crate) fn chrome_use_bin_path(file_name: &str) -> Option<PathBuf> {
let system = Path::new(SYSTEM_BIN_DIR);
let dir = chrome_use_dir(
crate::util::is_executable(&system.join(file_name)),
dir_is_writable(system),
chrome_use_user_bin_dir(),
)?;
Some(dir.join(file_name))
}
#[cfg(not(unix))]
#[must_use]
pub(crate) fn chrome_use_bin_path(file_name: &str) -> Option<PathBuf> {
Some(chrome_use_user_bin_dir()?.join(file_name))
}
#[cfg(unix)]
#[must_use]
fn chrome_use_dir(
system_copy: bool,
system_writable: bool,
user: Option<PathBuf>,
) -> Option<PathBuf> {
(system_copy || system_writable)
.then(|| PathBuf::from(SYSTEM_BIN_DIR))
.or(user)
}
#[cfg(unix)]
#[must_use]
fn dir_is_writable(dir: &Path) -> bool {
use std::os::unix::ffi::OsStrExt as _;
let Ok(path) = std::ffi::CString::new(dir.as_os_str().as_bytes()) else {
return false;
};
unsafe { libc::access(path.as_ptr(), libc::W_OK) == 0 }
}
#[cfg(unix)]
const SYSTEM_BIN_DIR: &str = "/usr/local/bin";
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
#[test]
fn the_helpers_directory_is_the_system_one_whenever_it_holds_a_copy() {
assert_eq!(
chrome_use_dir(true, false, None),
Some(PathBuf::from(SYSTEM_BIN_DIR))
);
assert_eq!(
chrome_use_dir(false, true, None),
Some(PathBuf::from(SYSTEM_BIN_DIR))
);
assert_eq!(chrome_use_dir(false, false, None), None);
let user = PathBuf::from("/home/o/.local/bin");
assert_eq!(
chrome_use_dir(false, false, Some(user.clone())),
Some(user),
"the per-user directory is the fallback, never the preference"
);
}
#[test]
fn version_banner_tokens_parse_with_or_without_a_v_prefix() {
assert_eq!(
parse_version_token("v1.5.100"),
Some(semver::Version::new(1, 5, 100))
);
assert_eq!(
parse_version_token("1.5.100"),
Some(semver::Version::new(1, 5, 100))
);
assert_eq!(parse_version_token("latest"), None);
assert_eq!(parse_version_token(""), 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());
}
}