use std::io::{self, Read, Write};
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use sha2::{Digest, Sha256};
use crate::manifest::{GithubMethod, InstallMethod, ToolSpec};
use crate::store::{self, ChecksumSidecar, RevSidecar};
#[derive(Debug, thiserror::Error)]
pub enum InstallError {
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
#[error("checksum mismatch: expected {expected}, got {actual}")]
ChecksumMismatch { expected: String, actual: String },
#[error("missing checksum for triple {0}")]
MissingChecksum(String),
#[error("unsupported triple: {0}")]
UnsupportedTriple(String),
#[error("archive error: {0}")]
Archive(String),
#[error("path escape detected: {0}")]
PathEscape(String),
#[error("unsupported install method: {0}")]
UnsupportedMethod(&'static str),
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("xdg: {0}")]
Xdg(#[from] hjkl_xdg::Error),
#[error("store: {0}")]
Store(#[from] store::StoreError),
#[error("subprocess failed: {cmd}: {stderr}")]
Subprocess { cmd: String, stderr: String },
#[error("binary not found in installed package: {0}")]
BinNotFound(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallStatus {
Queued,
Downloading {
bytes_downloaded: u64,
total: Option<u64>,
},
Verifying,
TofuRecorded {
triple: String,
sha256: String,
},
Extracting,
Installing,
Done {
bin_path: PathBuf,
},
Failed(String),
}
pub trait Install {
fn install(
&self,
name: &str,
spec: &ToolSpec,
progress: &dyn Fn(InstallStatus),
) -> Result<PathBuf, InstallError>;
}
pub fn install_blocking(
name: &str,
spec: &ToolSpec,
progress: &dyn Fn(InstallStatus),
) -> Result<PathBuf, InstallError> {
match &spec.method {
InstallMethod::Github(_) => GithubInstaller.install(name, spec, progress),
InstallMethod::Cargo(_) => CargoInstaller.install(name, spec, progress),
InstallMethod::Npm(_) => NpmInstaller.install(name, spec, progress),
InstallMethod::Pip(_) => PipInstaller.install(name, spec, progress),
InstallMethod::GoInstall(_) => GoInstaller.install(name, spec, progress),
InstallMethod::Script(_) => Err(InstallError::UnsupportedMethod("script")),
}
}
pub fn safe_join(root: &Path, entry: &Path) -> Result<PathBuf, InstallError> {
let mut out = root.to_path_buf();
for comp in entry.components() {
match comp {
Component::Normal(c) => out.push(c),
Component::CurDir => {} Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(InstallError::PathEscape(entry.display().to_string()));
}
}
}
Ok(out)
}
pub fn host_triple() -> Result<&'static str, InstallError> {
use std::env::consts::{ARCH, OS};
Ok(match (OS, ARCH) {
("linux", "x86_64") => "x86_64-unknown-linux-gnu",
("linux", "aarch64") => "aarch64-unknown-linux-gnu",
("macos", "x86_64") => "x86_64-apple-darwin",
("macos", "aarch64") => "aarch64-apple-darwin",
("windows", "x86_64") => "x86_64-pc-windows-msvc",
(os, arch) => return Err(InstallError::UnsupportedTriple(format!("{os}/{arch}"))),
})
}
fn asset_ext(asset_name: &str) -> &'static str {
let name = asset_name.to_ascii_lowercase();
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
"tar.gz"
} else if name.ends_with(".gz") {
"gz"
} else if name.ends_with(".zip") {
"zip"
} else {
""
}
}
fn find_bin(dir: &Path, bin_name: &str) -> Option<PathBuf> {
for entry in walkdir(dir) {
if entry.file_name().is_some_and(|fname| fname == bin_name) {
return entry.strip_prefix(dir).ok().map(|p| p.to_path_buf());
}
}
None
}
fn walkdir(root: &Path) -> Vec<PathBuf> {
let mut result = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else {
result.push(path);
}
}
}
result
}
#[cfg_attr(windows, allow(dead_code))]
fn move_file_cross_device(src: &Path, dst: &Path) -> io::Result<()> {
match std::fs::rename(src, dst) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::CrossesDevices => {
std::fs::copy(src, dst)?;
std::fs::remove_file(src)?;
Ok(())
}
Err(e) => Err(e),
}
}
fn move_dir_cross_device(src: &Path, dst: &Path) -> io::Result<()> {
match std::fs::rename(src, dst) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::CrossesDevices => {
let mut stack = vec![src.to_path_buf()];
while let Some(dir) = stack.pop() {
let rel = dir
.strip_prefix(src)
.map_err(|_| io::Error::other("strip_prefix failed"))?;
let dst_dir = dst.join(rel);
std::fs::create_dir_all(&dst_dir)?;
for entry in std::fs::read_dir(&dir)?.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else {
let rel_file = path
.strip_prefix(src)
.map_err(|_| io::Error::other("strip_prefix failed"))?;
std::fs::copy(&path, dst.join(rel_file))?;
}
}
}
std::fs::remove_dir_all(src)?;
Ok(())
}
Err(e) => Err(e),
}
}
fn atomic_symlink(link_path: &Path, target: &Path) -> Result<(), InstallError> {
let tmp = link_path.with_extension("tmp");
let _ = std::fs::remove_file(&tmp);
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, &tmp)?;
move_file_cross_device(&tmp, link_path)?;
Ok(())
}
#[cfg(not(unix))]
{
let _ = target; Err(InstallError::Archive(
"symlinks not supported on this platform; TODO: implement copy fallback".to_string(),
))
}
}
fn make_executable(path: &Path) -> Result<(), InstallError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms)?;
}
#[cfg(not(unix))]
{
let _ = path;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExpectedSha {
Pinned(String),
Cached(String),
Tofu,
}
pub fn resolve_expected_sha(
gh: &GithubMethod,
tool: &str,
version: &str,
triple: &str,
) -> Result<ExpectedSha, InstallError> {
if let Some(manifest_sha) = gh.sha256.get(triple)
&& !manifest_sha.is_empty()
{
return Ok(ExpectedSha::Pinned(manifest_sha.clone()));
}
if let Some(sidecar) = ChecksumSidecar::read(tool)?
&& let Some(cached) = sidecar.get(version, triple)
{
return Ok(ExpectedSha::Cached(cached.to_string()));
}
Ok(ExpectedSha::Tofu)
}
pub struct GithubInstaller;
impl Install for GithubInstaller {
fn install(
&self,
name: &str,
spec: &ToolSpec,
progress: &dyn Fn(InstallStatus),
) -> Result<PathBuf, InstallError> {
install_github_inner(name, spec, real_download, progress)
}
}
fn real_download(
url: &str,
dest: &Path,
progress: &dyn Fn(InstallStatus),
) -> Result<(), InstallError> {
let response = reqwest::blocking::get(url)?;
let total = response.content_length();
let mut downloaded: u64 = 0;
let mut reader = response;
let mut file = std::fs::File::create(dest)?;
let mut buf = [0u8; 65536];
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
file.write_all(&buf[..n])?;
downloaded += n as u64;
progress(InstallStatus::Downloading {
bytes_downloaded: downloaded,
total,
});
}
Ok(())
}
pub fn install_github_inner(
name: &str,
spec: &ToolSpec,
download: impl Fn(&str, &Path, &dyn Fn(InstallStatus)) -> Result<(), InstallError>,
progress: &dyn Fn(InstallStatus),
) -> Result<PathBuf, InstallError> {
let InstallMethod::Github(ref gh) = spec.method else {
return Err(InstallError::UnsupportedMethod("not a github method"));
};
let triple = host_triple()?;
let expected = resolve_expected_sha(gh, name, &spec.version, triple)?;
let asset = gh
.asset_pattern
.replace("{triple}", triple)
.replace("{version}", &spec.version);
let url = format!(
"https://github.com/{}/releases/download/{}/{}",
gh.repo, spec.version, asset
);
let cache = store::cache_root()?;
let staging_dir = cache.join("staging").join(name);
std::fs::create_dir_all(&staging_dir)?;
let ext = asset_ext(&asset);
let dl_name = format!("{name}.{ext}");
let dl_path = staging_dir.join(&dl_name);
download(&url, &dl_path, progress)?;
progress(InstallStatus::Verifying);
let actual_sha = sha256_file(&dl_path)?;
let recorded_sha = match &expected {
ExpectedSha::Pinned(expected_hash) | ExpectedSha::Cached(expected_hash) => {
if &actual_sha != expected_hash {
return Err(InstallError::ChecksumMismatch {
expected: expected_hash.clone(),
actual: actual_sha,
});
}
actual_sha.clone()
}
ExpectedSha::Tofu => {
let mut sidecar = ChecksumSidecar::read(name)?.unwrap_or_default();
sidecar.set(&spec.version, triple, &actual_sha);
sidecar.write(name)?;
progress(InstallStatus::TofuRecorded {
triple: triple.to_string(),
sha256: actual_sha.clone(),
});
actual_sha.clone()
}
};
progress(InstallStatus::Extracting);
let extract_dir = staging_dir.join("extract");
std::fs::create_dir_all(&extract_dir)?;
extract_archive(&dl_path, ext, &extract_dir, &spec.bin)?;
let rel_bin = find_bin(&extract_dir, &spec.bin)
.ok_or_else(|| InstallError::BinNotFound(spec.bin.clone()))?;
let bin_in_extract = extract_dir.join(&rel_bin);
make_executable(&bin_in_extract)?;
progress(InstallStatus::Installing);
let final_pkg = store::package_dir(name)?;
if let Some(parent) = final_pkg.parent() {
std::fs::create_dir_all(parent)?;
}
let bak = final_pkg.with_extension("bak");
if final_pkg.exists() {
let _ = std::fs::remove_dir_all(&bak); move_dir_cross_device(&final_pkg, &bak)?;
}
match move_dir_cross_device(&extract_dir, &final_pkg) {
Ok(()) => {
if bak.exists() {
let _ = std::fs::remove_dir_all(&bak);
}
}
Err(e) => {
if bak.exists() {
let _ = move_dir_cross_device(&bak, &final_pkg);
}
return Err(InstallError::Io(e));
}
}
let bin_dir = store::bin_dir()?;
std::fs::create_dir_all(&bin_dir)?;
let link = bin_dir.join(&spec.bin);
let bin_abs = final_pkg.join(&rel_bin);
atomic_symlink(&link, &bin_abs)?;
let rev = RevSidecar {
version: spec.version.clone(),
sha256: recorded_sha,
};
store::write_rev(name, &rev)?;
let bin_path = bin_abs;
progress(InstallStatus::Done {
bin_path: bin_path.clone(),
});
Ok(bin_path)
}
fn sha256_file(path: &Path) -> Result<String, InstallError> {
let mut f = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 65536];
loop {
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
fn extract_archive(
dl_path: &Path,
ext: &str,
dest_dir: &Path,
bin_name: &str,
) -> Result<(), InstallError> {
match ext {
"tar.gz" | "tgz" => {
let f = std::fs::File::open(dl_path)?;
let gz = flate2::read::GzDecoder::new(f);
let mut archive = tar::Archive::new(gz);
for entry in archive
.entries()
.map_err(|e| InstallError::Archive(e.to_string()))?
{
let mut entry = entry.map_err(|e| InstallError::Archive(e.to_string()))?;
let entry_path = entry
.path()
.map_err(|e| InstallError::Archive(e.to_string()))?
.to_path_buf();
let out = safe_join(dest_dir, &entry_path)?;
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
if entry.header().entry_type().is_file() {
let mut dest_file = std::fs::File::create(&out)?;
io::copy(&mut entry, &mut dest_file)?;
} else if entry.header().entry_type() == tar::EntryType::Directory {
std::fs::create_dir_all(&out)?;
}
}
}
"gz" => {
let f = std::fs::File::open(dl_path)?;
let mut gz = flate2::read::GzDecoder::new(f);
let out = dest_dir.join(bin_name);
let mut dest_file = std::fs::File::create(&out)?;
io::copy(&mut gz, &mut dest_file)?;
}
"zip" => {
let f = std::fs::File::open(dl_path)?;
let mut archive =
zip::ZipArchive::new(f).map_err(|e| InstallError::Archive(e.to_string()))?;
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| InstallError::Archive(e.to_string()))?;
let entry_path = PathBuf::from(entry.name());
let out = safe_join(dest_dir, &entry_path)?;
if entry.is_dir() {
std::fs::create_dir_all(&out)?;
} else {
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
let mut dest_file = std::fs::File::create(&out)?;
io::copy(&mut entry, &mut dest_file)?;
}
}
}
_ => {
let out = dest_dir.join(bin_name);
std::fs::copy(dl_path, &out)?;
}
}
Ok(())
}
fn run_subprocess(
cmd: &mut Command,
cmd_label: &str,
progress: &dyn Fn(InstallStatus),
) -> Result<(), InstallError> {
let output = cmd.output().map_err(|e| InstallError::Subprocess {
cmd: cmd_label.to_string(),
stderr: e.to_string(),
})?;
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
let truncated = if line.len() > 200 { &line[..200] } else { line };
progress(InstallStatus::Installing);
let _ = truncated; }
if !output.status.success() {
return Err(InstallError::Subprocess {
cmd: cmd_label.to_string(),
stderr: stderr.into_owned(),
});
}
Ok(())
}
fn finalize_install(
name: &str,
spec: &ToolSpec,
bin_path_in_pkg: &Path,
sha: &str,
progress: &dyn Fn(InstallStatus),
) -> Result<PathBuf, InstallError> {
let bin_dir = store::bin_dir()?;
std::fs::create_dir_all(&bin_dir)?;
let link = bin_dir.join(&spec.bin);
atomic_symlink(&link, bin_path_in_pkg)?;
let rev = RevSidecar {
version: spec.version.clone(),
sha256: sha.to_string(),
};
store::write_rev(name, &rev)?;
let bin_path = bin_path_in_pkg.to_path_buf();
progress(InstallStatus::Done {
bin_path: bin_path.clone(),
});
Ok(bin_path)
}
pub struct CargoInstaller;
impl Install for CargoInstaller {
fn install(
&self,
name: &str,
spec: &ToolSpec,
progress: &dyn Fn(InstallStatus),
) -> Result<PathBuf, InstallError> {
let InstallMethod::Cargo(ref cargo) = spec.method else {
return Err(InstallError::UnsupportedMethod("not a cargo method"));
};
let install_root = store::package_dir(name)?;
std::fs::create_dir_all(&install_root)?;
progress(InstallStatus::Installing);
let result = run_cargo_install(
&cargo.crate_name,
&spec.version,
&install_root,
true, progress,
);
if result.is_err() {
run_cargo_install(
&cargo.crate_name,
&spec.version,
&install_root,
false,
progress,
)?;
}
let bin_path = install_root.join("bin").join(&spec.bin);
if !bin_path.exists() {
return Err(InstallError::BinNotFound(spec.bin.clone()));
}
finalize_install(name, spec, &bin_path, "", progress)
}
}
fn run_cargo_install(
crate_name: &str,
version: &str,
root: &Path,
locked: bool,
progress: &dyn Fn(InstallStatus),
) -> Result<(), InstallError> {
let mut cmd = Command::new("cargo");
cmd.arg("install")
.arg(crate_name)
.arg("--version")
.arg(version)
.arg("--root")
.arg(root);
if locked {
cmd.arg("--locked");
}
let label = format!(
"cargo install {crate_name} --version {version}{}",
if locked { " --locked" } else { "" }
);
run_subprocess(&mut cmd, &label, progress)
}
pub struct NpmInstaller;
pub fn build_npm_argv(pkg: &str, version: &str, prefix: &Path) -> Vec<String> {
vec![
"install".to_string(),
"--prefix".to_string(),
prefix.display().to_string(),
format!("{pkg}@{version}"),
"--no-audit".to_string(),
"--no-fund".to_string(),
"--silent".to_string(),
]
}
impl Install for NpmInstaller {
fn install(
&self,
name: &str,
spec: &ToolSpec,
progress: &dyn Fn(InstallStatus),
) -> Result<PathBuf, InstallError> {
let InstallMethod::Npm(ref npm) = spec.method else {
return Err(InstallError::UnsupportedMethod("not an npm method"));
};
let pkg_dir = store::package_dir(name)?;
let node_modules_bin = pkg_dir.join("node_modules").join(".bin");
std::fs::create_dir_all(&node_modules_bin)?;
progress(InstallStatus::Installing);
let argv = build_npm_argv(&npm.package, &spec.version, &pkg_dir);
let mut cmd = Command::new("npm");
for arg in &argv {
cmd.arg(arg);
}
run_subprocess(
&mut cmd,
&format!("npm install {}@{}", npm.package, spec.version),
progress,
)?;
let bin_in_pkg = node_modules_bin.join(&spec.bin);
if !bin_in_pkg.exists() {
return Err(InstallError::BinNotFound(spec.bin.clone()));
}
finalize_install(name, spec, &bin_in_pkg, "", progress)
}
}
pub struct PipInstaller;
pub fn build_pip_argv(pkg: &str, version: &str) -> Vec<String> {
vec![
"install".to_string(),
"--upgrade".to_string(),
format!("{pkg}=={version}"),
]
}
impl Install for PipInstaller {
fn install(
&self,
name: &str,
spec: &ToolSpec,
progress: &dyn Fn(InstallStatus),
) -> Result<PathBuf, InstallError> {
let InstallMethod::Pip(ref pip) = spec.method else {
return Err(InstallError::UnsupportedMethod("not a pip method"));
};
let pkg_dir = store::package_dir(name)?;
let venv_dir = pkg_dir.join("venv");
std::fs::create_dir_all(&pkg_dir)?;
progress(InstallStatus::Installing);
let mut venv_cmd = Command::new("python3");
venv_cmd.args(["-m", "venv"]).arg(&venv_dir);
run_subprocess(
&mut venv_cmd,
&format!("python3 -m venv {}", venv_dir.display()),
progress,
)?;
let pip_bin = venv_dir.join("bin").join("pip");
let pip_argv = build_pip_argv(&pip.package, &spec.version);
let mut pip_cmd = Command::new(&pip_bin);
for arg in &pip_argv {
pip_cmd.arg(arg);
}
run_subprocess(
&mut pip_cmd,
&format!("pip install {}=={}", pip.package, spec.version),
progress,
)?;
let bin_in_venv = venv_dir.join("bin").join(&spec.bin);
if !bin_in_venv.exists() {
return Err(InstallError::BinNotFound(spec.bin.clone()));
}
finalize_install(name, spec, &bin_in_venv, "", progress)
}
}
pub struct GoInstaller;
pub fn build_go_argv(module: &str, version: &str) -> Vec<String> {
vec!["install".to_string(), format!("{module}@{version}")]
}
impl Install for GoInstaller {
fn install(
&self,
name: &str,
spec: &ToolSpec,
progress: &dyn Fn(InstallStatus),
) -> Result<PathBuf, InstallError> {
let InstallMethod::GoInstall(ref go) = spec.method else {
return Err(InstallError::UnsupportedMethod("not a goinstall method"));
};
let pkg_dir = store::package_dir(name)?;
let gobin_dir = pkg_dir.join("bin");
std::fs::create_dir_all(&gobin_dir)?;
progress(InstallStatus::Installing);
let argv = build_go_argv(&go.module, &spec.version);
let mut cmd = Command::new("go");
for arg in &argv {
cmd.arg(arg);
}
cmd.env("GOBIN", &gobin_dir);
run_subprocess(
&mut cmd,
&format!("go install {}@{}", go.module, spec.version),
progress,
)?;
let bin_in_pkg = gobin_dir.join(&spec.bin);
if !bin_in_pkg.exists() {
return Err(InstallError::BinNotFound(spec.bin.clone()));
}
finalize_install(name, spec, &bin_in_pkg, "", progress)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::sync::Mutex;
use crate::manifest::{GithubMethod, ToolCategory};
static ENV_LOCK: Mutex<()> = Mutex::new(());
const HELLO_TAR_GZ_SHA: &str =
"9dae51f8d23ea48e988bc08ec10b7e8488a7b4f4634e5197ea165bf4e5361295";
#[allow(dead_code)]
const HELLO_ZIP_SHA: &str = "bcff8654881e86bc7600365fa43f4487ae184ad9487053af0ffbae204f137218";
#[allow(dead_code)]
const HELLO_GZ_SHA: &str = "64bc750ede7af4dfed2964cf51af3e7447557fda5b2848b817aa41049d8bf7a1";
#[allow(dead_code)]
const HELLO_RAW_SHA: &str = "bfdeaeb08cffb6a36438bcd12dda25417e3cdd36f1e7e482a2849d539225288b";
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(name)
}
fn fixture_bytes(name: &str) -> Vec<u8> {
std::fs::read(fixture_path(name)).expect("fixture must exist")
}
fn github_spec(triple: &str, sha: &str, asset_pattern: &str, bin: &str) -> ToolSpec {
let mut sha256 = BTreeMap::new();
sha256.insert(triple.to_string(), sha.to_string());
ToolSpec {
category: ToolCategory::Lsp,
description: "test tool".to_string(),
version: "v1.0".to_string(),
bin: bin.to_string(),
method: InstallMethod::Github(GithubMethod {
repo: "owner/repo".to_string(),
asset_pattern: asset_pattern.to_string(),
sha256,
}),
}
}
fn stub_download(
fixture_name: &str,
) -> impl Fn(&str, &Path, &dyn Fn(InstallStatus)) -> Result<(), InstallError> + '_ {
move |_url, dest, progress| {
let bytes = fixture_bytes(fixture_name);
std::fs::write(dest, &bytes)?;
progress(InstallStatus::Downloading {
bytes_downloaded: bytes.len() as u64,
total: Some(bytes.len() as u64),
});
Ok(())
}
}
#[test]
fn safe_join_normal_path() {
let root = PathBuf::from("/tmp/root");
let entry = PathBuf::from("bin/hello");
let result = safe_join(&root, &entry).unwrap();
assert_eq!(result, PathBuf::from("/tmp/root/bin/hello"));
}
#[test]
fn safe_join_rejects_parent_traversal() {
let root = PathBuf::from("/tmp/root");
let evil = PathBuf::from("../../etc/passwd");
assert!(matches!(
safe_join(&root, &evil),
Err(InstallError::PathEscape(_))
));
}
#[test]
fn safe_join_skips_cur_dir() {
let root = PathBuf::from("/tmp/root");
let p = PathBuf::from("./bin/hello");
let result = safe_join(&root, &p).unwrap();
assert_eq!(result, PathBuf::from("/tmp/root/bin/hello"));
}
#[test]
fn sha256_file_matches_known_fixture() {
let path = fixture_path("hello.tar.gz");
let hash = sha256_file(&path).unwrap();
assert_eq!(hash, HELLO_TAR_GZ_SHA);
}
#[test]
fn extract_tar_gz_into_staging_finds_bin() {
let tmp = tempfile::tempdir().unwrap();
let dl = tmp.path().join("hello.tar.gz");
std::fs::write(&dl, fixture_bytes("hello.tar.gz")).unwrap();
extract_archive(&dl, "tar.gz", tmp.path(), "hello").unwrap();
let bin = tmp.path().join("bin").join("hello");
assert!(bin.exists(), "bin/hello must be extracted");
}
#[test]
fn extract_zip_into_staging_finds_bin() {
let tmp = tempfile::tempdir().unwrap();
let dl = tmp.path().join("hello.zip");
std::fs::write(&dl, fixture_bytes("hello.zip")).unwrap();
extract_archive(&dl, "zip", tmp.path(), "hello").unwrap();
let bin = tmp.path().join("bin").join("hello");
assert!(bin.exists(), "bin/hello must be extracted from zip");
}
#[test]
fn extract_gz_writes_single_file() {
let tmp = tempfile::tempdir().unwrap();
let dl = tmp.path().join("hello.gz");
std::fs::write(&dl, fixture_bytes("hello.gz")).unwrap();
extract_archive(&dl, "gz", tmp.path(), "hello").unwrap();
let bin = tmp.path().join("hello");
assert!(bin.exists(), "hello must be written for single-file gz");
let content = std::fs::read_to_string(&bin).unwrap();
assert!(content.contains("hello"), "content should say hello");
}
#[test]
fn extract_raw_copies_to_bin_path() {
let tmp = tempfile::tempdir().unwrap();
let dl = tmp.path().join("hello-raw");
std::fs::write(&dl, fixture_bytes("hello-raw")).unwrap();
extract_archive(&dl, "", tmp.path(), "hello").unwrap();
let bin = tmp.path().join("hello");
assert!(bin.exists(), "raw binary must be copied as 'hello'");
}
#[test]
fn path_traversal_in_tar_is_rejected_with_path_escape() {
let tmp = tempfile::tempdir().unwrap();
let dl = tmp.path().join("evil.tar.gz");
std::fs::write(&dl, fixture_bytes("evil-traversal.tar.gz")).unwrap();
let err = extract_archive(&dl, "tar.gz", tmp.path(), "hello").unwrap_err();
assert!(
matches!(err, InstallError::PathEscape(_)),
"expected PathEscape, got: {err:?}"
);
}
#[test]
fn sha256_mismatch_returns_checksum_mismatch() {
let tmp = tempfile::tempdir().unwrap();
let triple = host_triple().unwrap();
let spec = github_spec(triple, "deadbeefdeadbeef", "hello-{triple}.tar.gz", "hello");
let result = install_github_inner(
"hello",
&spec,
|_url, dest, progress| {
let bytes = fixture_bytes("hello.tar.gz");
std::fs::write(dest, &bytes)?;
progress(InstallStatus::Downloading {
bytes_downloaded: bytes.len() as u64,
total: None,
});
Ok(())
},
&|_| {},
);
assert!(
matches!(result, Err(InstallError::ChecksumMismatch { .. })),
"got: {result:?}"
);
let _ = tmp;
}
#[test]
fn missing_triple_falls_through_to_tofu() {
let mut sha256 = BTreeMap::new();
sha256.insert("nonexistent-triple".to_string(), "abc".to_string());
let spec = ToolSpec {
category: ToolCategory::Lsp,
description: "test".to_string(),
version: "v1.0".to_string(),
bin: "noop-tofu-test-bin".to_string(),
method: InstallMethod::Github(GithubMethod {
repo: "owner/repo".to_string(),
asset_pattern: "noop-tofu-test-{triple}.tar.gz".to_string(),
sha256,
}),
};
let result = install_github_inner(
"noop-tofu-test-tool-unique",
&spec,
|_, _, _| Ok(()),
&|_| {},
);
assert!(
matches!(
result,
Err(InstallError::UnsupportedTriple(_))
| Err(InstallError::Io(_))
| Err(InstallError::Store(_))
),
"got: {result:?}"
);
}
#[test]
fn bin_not_found_in_archive_returns_bin_not_found() {
let triple = match host_triple() {
Ok(t) => t,
Err(_) => return, };
let spec = github_spec(
triple,
HELLO_TAR_GZ_SHA,
"hello-{triple}.tar.gz",
"nonexistent",
);
let result =
install_github_inner("nonexistent", &spec, stub_download("hello.tar.gz"), &|_| {});
assert!(
matches!(result, Err(InstallError::BinNotFound(_))),
"got: {result:?}"
);
}
#[test]
fn host_triple_returns_known_triple_or_unsupported() {
match host_triple() {
Ok(t) => {
let known = [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
];
assert!(known.contains(&t), "unexpected triple: {t}");
}
Err(InstallError::UnsupportedTriple(_)) => {} Err(e) => panic!("unexpected error: {e}"),
}
}
#[cfg(unix)]
#[test]
fn full_github_pipeline_tar_gz() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let data_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("XDG_DATA_HOME", data_dir.path());
std::env::set_var("XDG_CACHE_HOME", cache_dir.path());
}
let triple = host_triple().unwrap();
let spec = github_spec(triple, HELLO_TAR_GZ_SHA, "hello-{triple}.tar.gz", "hello");
let statuses: std::sync::Mutex<Vec<InstallStatus>> = std::sync::Mutex::new(Vec::new());
let result = install_github_inner("hello", &spec, stub_download("hello.tar.gz"), &|s| {
statuses.lock().unwrap().push(s.clone())
});
unsafe {
std::env::remove_var("XDG_DATA_HOME");
std::env::remove_var("XDG_CACHE_HOME");
}
let bin_path = result.expect("full pipeline must succeed");
assert!(bin_path.exists(), "installed binary must exist");
let statuses = statuses.into_inner().unwrap();
assert!(
statuses
.iter()
.any(|s| matches!(s, InstallStatus::Done { .. }))
);
}
#[cfg(unix)]
#[test]
fn full_github_pipeline_zip() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let data_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("XDG_DATA_HOME", data_dir.path());
std::env::set_var("XDG_CACHE_HOME", cache_dir.path());
}
let triple = host_triple().unwrap();
let spec = github_spec(triple, HELLO_ZIP_SHA, "hello-{triple}.zip", "hello");
let result = install_github_inner("hello", &spec, stub_download("hello.zip"), &|_| {});
unsafe {
std::env::remove_var("XDG_DATA_HOME");
std::env::remove_var("XDG_CACHE_HOME");
}
let bin_path = result.expect("zip pipeline must succeed");
assert!(bin_path.exists());
}
#[test]
fn install_blocking_script_returns_unsupported() {
use crate::manifest::ScriptMethod;
let spec = ToolSpec {
category: ToolCategory::Lsp,
description: "test".to_string(),
version: "1.0".to_string(),
bin: "bin".to_string(),
method: InstallMethod::Script(ScriptMethod {
url: "https://example.com/install.tar.gz".to_string(),
sha256: "deadbeef".to_string(),
exec: "./install.sh".to_string(),
}),
};
let err = install_blocking("tool", &spec, &|_| {}).unwrap_err();
assert!(matches!(err, InstallError::UnsupportedMethod("script")));
}
#[test]
fn cargo_installer_skips_checksum() {
let rev = RevSidecar {
version: "0.9.3".to_string(),
sha256: String::new(),
};
assert_eq!(rev.sha256, "");
}
#[test]
fn npm_argv_contains_pkg_at_version() {
let prefix = PathBuf::from("/tmp/pkg");
let argv = build_npm_argv("pyright", "1.1.395", &prefix);
assert_eq!(argv[0], "install");
assert!(
argv.contains(&"pyright@1.1.395".to_string()),
"expected pyright@1.1.395 in argv, got: {argv:?}"
);
let prefix_idx = argv
.iter()
.position(|a| a == "--prefix")
.expect("--prefix missing");
assert_eq!(argv[prefix_idx + 1], prefix.display().to_string());
assert!(argv.contains(&"--no-audit".to_string()));
assert!(argv.contains(&"--no-fund".to_string()));
assert!(argv.contains(&"--silent".to_string()));
}
#[test]
fn pip_argv_uses_double_equals_pinning() {
let argv = build_pip_argv("black", "24.0.0");
assert_eq!(argv[0], "install");
assert!(
argv.contains(&"black==24.0.0".to_string()),
"expected black==24.0.0 in argv, got: {argv:?}"
);
}
#[test]
fn go_argv_uses_at_version() {
let argv = build_go_argv("golang.org/x/tools/gopls", "v0.17.1");
assert_eq!(argv[0], "install");
assert!(
argv.contains(&"golang.org/x/tools/gopls@v0.17.1".to_string()),
"expected golang.org/x/tools/gopls@v0.17.1 in argv, got: {argv:?}"
);
}
#[allow(dead_code)]
fn make_cargo_spec(bin: &str) -> ToolSpec {
use crate::manifest::{CargoMethod, ToolCategory};
ToolSpec {
category: ToolCategory::Lsp,
description: "test".to_string(),
version: "0.9.3".to_string(),
bin: bin.to_string(),
method: InstallMethod::Cargo(CargoMethod {
crate_name: bin.to_string(),
}),
}
}
#[cfg(unix)]
#[test]
fn finalize_install_creates_symlink_and_rev() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let data_dir = tempfile::tempdir().unwrap();
let _cache_dir = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("XDG_DATA_HOME", data_dir.path());
}
let pkg_dir = data_dir.path().join("anvil").join("packages").join("taplo");
std::fs::create_dir_all(&pkg_dir).unwrap();
let fake_bin = pkg_dir.join("bin").join("taplo");
std::fs::create_dir_all(fake_bin.parent().unwrap()).unwrap();
std::fs::write(&fake_bin, b"#!/bin/sh\necho hi\n").unwrap();
let spec = make_cargo_spec("taplo");
let statuses: std::sync::Mutex<Vec<InstallStatus>> = std::sync::Mutex::new(Vec::new());
let result = finalize_install("taplo", &spec, &fake_bin, "", &|s| {
statuses.lock().unwrap().push(s.clone());
});
unsafe {
std::env::remove_var("XDG_DATA_HOME");
}
let bin_path = result.expect("finalize_install must succeed");
assert_eq!(bin_path, fake_bin);
let link = data_dir.path().join("anvil").join("bin").join("taplo");
assert!(link.exists(), "symlink must exist at {}", link.display());
let rev_path = pkg_dir.join(".rev");
let rev_content = std::fs::read_to_string(&rev_path).unwrap();
assert!(
rev_content.starts_with("0.9.3:"),
"rev must start with version, got: {rev_content:?}"
);
let statuses = statuses.into_inner().unwrap();
assert!(
statuses
.iter()
.any(|s| matches!(s, InstallStatus::Done { .. })),
"Done status missing"
);
}
#[cfg(unix)]
#[test]
fn finalize_install_overwrites_stale_symlink() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let data_dir = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("XDG_DATA_HOME", data_dir.path());
}
let bin_dir = data_dir.path().join("anvil").join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let stale_link = bin_dir.join("taplo");
#[cfg(unix)]
std::os::unix::fs::symlink("/nonexistent/old/taplo", &stale_link).unwrap();
let pkg_dir = data_dir.path().join("anvil").join("packages").join("taplo");
let fake_bin = pkg_dir.join("bin").join("taplo");
std::fs::create_dir_all(fake_bin.parent().unwrap()).unwrap();
std::fs::write(&fake_bin, b"#!/bin/sh\necho hi\n").unwrap();
let spec = make_cargo_spec("taplo");
let result = finalize_install("taplo", &spec, &fake_bin, "", &|_| {});
unsafe {
std::env::remove_var("XDG_DATA_HOME");
}
result.expect("finalize_install must succeed over stale link");
let resolved = std::fs::read_link(&stale_link).unwrap();
assert_eq!(resolved, fake_bin);
}
fn tofu_github_spec(triple: &str, asset_pattern: &str, bin: &str) -> ToolSpec {
let mut sha256 = BTreeMap::new();
sha256.insert(triple.to_string(), String::new());
ToolSpec {
category: ToolCategory::Lsp,
description: "test tool".to_string(),
version: "v1.0".to_string(),
bin: bin.to_string(),
method: InstallMethod::Github(GithubMethod {
repo: "owner/repo".to_string(),
asset_pattern: asset_pattern.to_string(),
sha256,
}),
}
}
#[cfg(unix)]
#[test]
fn tofu_first_install_records_sha_to_sidecar() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
use crate::store::ChecksumSidecar;
let data_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("XDG_DATA_HOME", data_dir.path());
std::env::set_var("XDG_CACHE_HOME", cache_dir.path());
}
let triple = host_triple().unwrap();
let spec = tofu_github_spec(triple, "hello-{triple}.tar.gz", "hello");
let recorded_tofu = std::cell::Cell::new(false);
let result = install_github_inner("hello", &spec, stub_download("hello.tar.gz"), &|s| {
if matches!(s, InstallStatus::TofuRecorded { .. }) {
recorded_tofu.set(true);
}
});
unsafe {
std::env::remove_var("XDG_DATA_HOME");
std::env::remove_var("XDG_CACHE_HOME");
}
result.expect("TOFU first install must succeed");
assert!(
recorded_tofu.get(),
"TofuRecorded status must have been emitted"
);
let sidecar_path = data_dir
.path()
.join("anvil")
.join("checksums")
.join("hello.toml");
assert!(
sidecar_path.exists(),
"checksum sidecar must exist after TOFU install"
);
let content = std::fs::read_to_string(&sidecar_path).unwrap();
let sidecar = ChecksumSidecar::from_toml_pub(&content).unwrap();
let recorded_hash = sidecar.get("v1.0", triple);
assert!(
recorded_hash.is_some(),
"sidecar must contain hash for (v1.0, {triple})"
);
assert_eq!(
recorded_hash.unwrap(),
HELLO_TAR_GZ_SHA,
"recorded hash must match fixture sha"
);
}
#[test]
fn tofu_second_install_enforces_cached_sha() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
use crate::store::ChecksumSidecar;
let data_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("XDG_DATA_HOME", data_dir.path());
std::env::set_var("XDG_CACHE_HOME", cache_dir.path());
}
let triple = host_triple().unwrap();
let stale_hash = "aaaa000000000000000000000000000000000000000000000000000000000001";
let mut sidecar = ChecksumSidecar::default();
sidecar.set("v1.0", triple, stale_hash);
sidecar.write("hello").unwrap();
let spec = tofu_github_spec(triple, "hello-{triple}.tar.gz", "hello");
let result = install_github_inner("hello", &spec, stub_download("hello.tar.gz"), &|_| {});
unsafe {
std::env::remove_var("XDG_DATA_HOME");
std::env::remove_var("XDG_CACHE_HOME");
}
assert!(
matches!(result, Err(InstallError::ChecksumMismatch { .. })),
"expected ChecksumMismatch when sidecar hash mismatches download; got: {result:?}"
);
}
#[cfg(unix)]
#[test]
fn pinned_manifest_sha_overrides_sidecar() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
use crate::store::ChecksumSidecar;
let data_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("XDG_DATA_HOME", data_dir.path());
std::env::set_var("XDG_CACHE_HOME", cache_dir.path());
}
let triple = host_triple().unwrap();
let stale_hash = "bbbb000000000000000000000000000000000000000000000000000000000002";
let mut sidecar = ChecksumSidecar::default();
sidecar.set("v1.0", triple, stale_hash);
sidecar.write("hello").unwrap();
let spec = github_spec(triple, HELLO_TAR_GZ_SHA, "hello-{triple}.tar.gz", "hello");
let result = install_github_inner("hello", &spec, stub_download("hello.tar.gz"), &|_| {});
unsafe {
std::env::remove_var("XDG_DATA_HOME");
std::env::remove_var("XDG_CACHE_HOME");
}
result.expect("pinned manifest hash must override stale sidecar and succeed");
}
}