use indexmap::IndexMap;
use itertools::Itertools;
use reqwest::Url;
use reqwest::header::HeaderMap;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{Arc, mpsc};
use tempfile::TempDir;
use xx::file;
use crate::error::Result;
use crate::hooks::available::AvailableVersion;
use crate::hooks::backend_exec_env::BackendExecEnvContext;
use crate::hooks::backend_install::BackendInstallContext;
use crate::hooks::backend_list_versions::BackendListVersionsContext;
use crate::hooks::env_keys::{EnvKey, EnvKeysContext};
use crate::hooks::mise_env::{MiseEnvContext, MiseEnvResult};
use crate::hooks::mise_path::MisePathContext;
use crate::hooks::package::{
PackageActionContext, PackageActionResponse, PackageInstalledContext, PackageInstalledResponse,
PackageUninstallContext,
};
use crate::hooks::parse_legacy_file::ParseLegacyFileResponse;
use crate::hooks::post_install::PostInstallContext;
use crate::hooks::pre_install::{PreInstall, PreInstallAttestation, VerifiedAttestation};
use crate::hooks::pre_uninstall::PreUninstallContext;
use crate::http::{CLIENT, HttpHeadersResolver, retry_async};
use crate::metadata::Metadata;
use crate::plugin::Plugin;
use crate::registry;
use crate::sdk_info::SdkInfo;
#[derive(Debug, Default)]
pub struct InstallResult {
pub sha256: Option<String>,
pub verified_attestation: Option<VerifiedAttestation>,
pub checksum_verified: bool,
}
pub struct Vfox {
pub runtime_version: String,
pub install_dir: PathBuf,
pub plugin_dir: PathBuf,
pub cache_dir: PathBuf,
pub download_dir: PathBuf,
pub skip_verification: bool,
pub cmd_env: Option<IndexMap<String, String>>,
pub default_inline_shell: Option<Vec<String>>,
pub github_token: Option<String>,
pub github_token_resolver: Option<Arc<dyn Fn() -> Option<String> + Send + Sync>>,
pub runtime_env_type: Option<String>,
url_rewriter: Option<UrlRewriter>,
http_headers_resolver: Option<HttpHeadersResolver>,
log_tx: Option<mpsc::Sender<String>>,
}
pub(crate) type UrlRewriter = Arc<dyn Fn(&mut Url) + Send + Sync>;
impl std::fmt::Debug for Vfox {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Vfox")
.field("runtime_version", &self.runtime_version)
.field("install_dir", &self.install_dir)
.field("plugin_dir", &self.plugin_dir)
.field("cache_dir", &self.cache_dir)
.field("download_dir", &self.download_dir)
.field("skip_verification", &self.skip_verification)
.field("cmd_env", &self.cmd_env)
.field("github_token", &self.github_token.as_deref().map(|_| "***"))
.field(
"github_token_resolver",
&self.github_token_resolver.as_ref().map(|_| "<closure>"),
)
.field("runtime_env_type", &self.runtime_env_type)
.field(
"url_rewriter",
&self.url_rewriter.as_ref().map(|_| "<closure>"),
)
.field(
"http_headers_resolver",
&self.http_headers_resolver.as_ref().map(|_| "<closure>"),
)
.finish_non_exhaustive()
}
}
impl Vfox {
pub fn new() -> Self {
Self::default()
}
pub fn log_subscribe(&mut self) -> mpsc::Receiver<String> {
let (tx, rx) = mpsc::channel();
self.log_tx = Some(tx);
rx
}
pub fn set_url_rewriter<F>(&mut self, rewriter: F)
where
F: Fn(&mut Url) + Send + Sync + 'static,
{
self.url_rewriter = Some(Arc::new(rewriter));
}
pub fn set_http_headers_resolver<F>(&mut self, resolver: F)
where
F: Fn(&Url) -> HeaderMap + Send + Sync + 'static,
{
self.http_headers_resolver = Some(Arc::new(resolver));
}
fn rewrite_url(&self, url: &mut Url) {
if let Some(rewriter) = &self.url_rewriter {
rewriter(url);
}
}
fn log_emit(&self, msg: String) {
if let Some(tx) = &self.log_tx {
let _ = tx.send(msg);
}
}
pub fn list_available_sdks() -> &'static BTreeMap<String, Url> {
registry::list_sdks()
}
pub async fn list_available_versions(&self, sdk: &str) -> Result<Vec<AvailableVersion>> {
let sdk = self.get_sdk_with_env(sdk)?;
sdk.available_async().await
}
pub fn list_installed_versions(&self, sdk: &str) -> Result<Vec<SdkInfo>> {
let path = self.install_dir.join(sdk);
if !path.exists() {
return Ok(Default::default());
}
let sdk = self.get_sdk(sdk)?;
let versions = xx::file::ls(&path)?;
versions
.into_iter()
.filter_map(|p| {
p.file_name()
.and_then(|f| f.to_str())
.map(|s| s.to_string())
})
.sorted()
.map(|version| {
let path = path.join(&version);
sdk.sdk_info(version, path)
})
.collect::<Result<_>>()
}
pub fn list_sdks(&self) -> Result<Vec<Plugin>> {
if !self.plugin_dir.exists() {
return Ok(Default::default());
}
let plugins = xx::file::ls(&self.plugin_dir)?;
plugins
.into_iter()
.filter_map(|p| {
p.file_name()
.and_then(|f| f.to_str())
.map(|s| s.to_string())
})
.sorted()
.map(|name| self.get_sdk(&name))
.collect()
}
pub fn get_sdk(&self, name: &str) -> Result<Plugin> {
let mut plugin = Plugin::from_name_or_dir(name, &self.plugin_dir.join(name))?;
plugin.runtime_env_type = self.runtime_env_type.clone();
self.set_cmd_shell(&plugin)?;
if let Some(rewriter) = &self.url_rewriter {
plugin.set_url_rewriter(rewriter.clone())?;
}
if let Some(resolver) = &self.http_headers_resolver {
plugin.set_http_headers_resolver(resolver.clone())?;
}
Ok(plugin)
}
fn get_sdk_with_env(&self, name: &str) -> Result<Plugin> {
let plugin = self.get_sdk(name)?;
if let Some(env) = &self.cmd_env {
plugin.set_cmd_env(env)?;
}
self.set_github_token(&plugin)?;
Ok(plugin)
}
fn set_cmd_shell(&self, plugin: &Plugin) -> Result<()> {
if let Some(shell) = &self.default_inline_shell {
plugin.set_cmd_shell(shell)?;
}
Ok(())
}
fn set_github_token(&self, plugin: &Plugin) -> Result<()> {
if let Some(token) = &self.github_token {
plugin.set_github_token(token)?;
}
if let Some(resolver) = &self.github_token_resolver {
plugin.set_github_token_resolver(resolver.clone())?;
}
Ok(())
}
pub fn install_plugin(&self, sdk: &str) -> Result<Plugin> {
let plugin_dir = self.plugin_dir.join(sdk);
if plugin_dir.exists() {
let mut plugin = Plugin::from_dir(&plugin_dir)?;
plugin.runtime_env_type = self.runtime_env_type.clone();
return Ok(plugin);
}
if let Some(embedded) = crate::embedded_plugins::get_embedded_plugin(sdk) {
let mut plugin = Plugin::from_embedded(sdk, embedded)?;
plugin.runtime_env_type = self.runtime_env_type.clone();
return Ok(plugin);
}
let url = registry::sdk_url(sdk).ok_or_else(|| format!("Unknown SDK: {sdk}"))?;
self.install_plugin_from_url(url)
}
pub fn install_plugin_from_url(&self, url: &Url) -> Result<Plugin> {
let sdk = url
.path_segments()
.and_then(|mut s| {
let filename = s.next_back().unwrap();
filename
.strip_prefix("vfox-")
.map(|s| s.to_string())
.or_else(|| Some(filename.to_string()))
})
.ok_or("No filename in URL")?;
let plugin_dir = self.plugin_dir.join(&sdk);
if !plugin_dir.exists() {
debug!("Installing plugin {sdk}");
xx::git::clone(url.as_ref(), &plugin_dir, &Default::default())?;
}
let mut plugin = Plugin::from_dir(&plugin_dir)?;
plugin.runtime_env_type = self.runtime_env_type.clone();
Ok(plugin)
}
pub fn uninstall_plugin(&self, sdk: &str) -> Result<()> {
let plugin_dir = self.plugin_dir.join(sdk);
if plugin_dir.exists() {
file::remove_dir_all(&plugin_dir)?;
}
Ok(())
}
pub async fn install<ID: AsRef<Path>>(
&self,
sdk: &str,
version: &str,
install_dir: ID,
) -> Result<InstallResult> {
self.install_with_download_dir(sdk, version, install_dir, &self.download_dir)
.await
}
pub async fn install_with_download_dir<ID: AsRef<Path>, DD: AsRef<Path>>(
&self,
sdk: &str,
version: &str,
install_dir: ID,
download_dir: DD,
) -> Result<InstallResult> {
self.install_with_download_dir_and_options(
sdk,
version,
install_dir,
download_dir,
Default::default(),
)
.await
}
pub async fn install_with_download_dir_and_options<ID: AsRef<Path>, DD: AsRef<Path>>(
&self,
sdk: &str,
version: &str,
install_dir: ID,
download_dir: DD,
options: IndexMap<String, toml::Value>,
) -> Result<InstallResult> {
self.install_plugin(sdk)?;
let sdk = self.get_sdk_with_env(sdk)?;
let pre_install = sdk
.pre_install_with_options(version, options.clone())
.await?;
let install_dir = install_dir.as_ref();
let download_dir = download_dir.as_ref();
trace!("{pre_install:?}");
let mut verified_attestation = None;
let mut checksum_verified = false;
if let Some(url) = pre_install.url.as_ref().map(|s| Url::from_str(s)) {
let file = self.download(&url?, &sdk, version, download_dir).await?;
verified_attestation = self.verify(&pre_install, &file).await?;
self.extract(&file, install_dir)?;
checksum_verified = pre_install.sha256.is_some() || pre_install.sha512.is_some();
}
if sdk.get_metadata()?.hooks.contains("post_install") {
let sdk_info = sdk.sdk_info(version.to_string(), install_dir.to_path_buf())?;
sdk.post_install(PostInstallContext {
root_path: install_dir.to_path_buf(),
runtime_version: version.to_string(),
sdk_info: BTreeMap::from([(sdk_info.name.clone(), sdk_info)]),
options,
})
.await?;
}
Ok(InstallResult {
sha256: pre_install.sha256,
verified_attestation,
checksum_verified,
})
}
pub async fn pre_uninstall<ID: AsRef<Path>>(
&self,
sdk: &str,
version: &str,
install_dir: ID,
) -> Result<()> {
let sdk = self.get_sdk_with_env(sdk)?;
if sdk.get_metadata()?.hooks.contains("pre_uninstall") {
let sdk_info = sdk.sdk_info(version.to_string(), install_dir.as_ref().to_path_buf())?;
sdk.pre_uninstall(PreUninstallContext {
main: sdk_info.clone(),
sdk_info: BTreeMap::from([(sdk_info.name.clone(), sdk_info)]),
})
.await?;
}
Ok(())
}
pub fn uninstall(&self, sdk: &str, version: &str) -> Result<()> {
let path = self.install_dir.join(sdk).join(version);
file::remove_dir_all(&path)?;
Ok(())
}
pub async fn pre_install_for_platform(
&self,
sdk: &str,
version: &str,
os: &str,
arch: &str,
) -> Result<PreInstall> {
self.pre_install_for_platform_with_options(sdk, version, os, arch, Default::default())
.await
}
pub async fn pre_install_for_platform_with_options(
&self,
sdk: &str,
version: &str,
os: &str,
arch: &str,
options: IndexMap<String, toml::Value>,
) -> Result<PreInstall> {
let sdk = self.get_sdk_with_env(sdk)?;
sdk.pre_install_for_platform_with_options(version, os, arch, options)
.await
}
pub async fn pre_install_provenance_for_platform(
&self,
sdk: &str,
version: &str,
os: &str,
arch: &str,
) -> Result<(Option<String>, Option<VerifiedAttestation>)> {
self.pre_install_provenance_for_platform_with_options(
sdk,
version,
os,
arch,
Default::default(),
)
.await
}
pub async fn pre_install_provenance_for_platform_with_options(
&self,
sdk: &str,
version: &str,
os: &str,
arch: &str,
options: IndexMap<String, toml::Value>,
) -> Result<(Option<String>, Option<VerifiedAttestation>)> {
let pre = self
.pre_install_for_platform_with_options(sdk, version, os, arch, options)
.await?;
let att = pre.attestation.and_then(attestation_to_verified);
Ok((pre.url, att))
}
pub async fn metadata(&self, sdk: &str) -> Result<Metadata> {
self.get_sdk(sdk)?.get_metadata()
}
pub async fn env_keys<T: serde::Serialize>(
&self,
sdk: &str,
version: &str,
options: T,
) -> Result<Vec<EnvKey>> {
debug!("Getting env keys for {sdk} version {version}");
let sdk = self.get_sdk_with_env(sdk)?;
let install_dir = self.install_dir.join(&sdk.name).join(version);
self.env_keys_for_sdk_install_dir(sdk, version, install_dir, options)
.await
}
pub async fn env_keys_for_install_dir<T: serde::Serialize>(
&self,
sdk: &str,
version: &str,
install_dir: impl AsRef<Path>,
options: T,
) -> Result<Vec<EnvKey>> {
debug!("Getting env keys for {sdk} version {version}");
let sdk = self.get_sdk_with_env(sdk)?;
self.env_keys_for_sdk_install_dir(sdk, version, install_dir, options)
.await
}
async fn env_keys_for_sdk_install_dir<T: serde::Serialize>(
&self,
sdk: Plugin,
version: &str,
install_dir: impl AsRef<Path>,
options: T,
) -> Result<Vec<EnvKey>> {
let sdk_info = sdk.sdk_info(version.to_string(), install_dir.as_ref().to_path_buf())?;
let ctx = EnvKeysContext {
args: vec![],
version: version.to_string(),
path: sdk_info.path.clone(),
sdk_info: BTreeMap::from([(sdk_info.name.clone(), sdk_info.clone())]),
main: sdk_info,
options,
};
sdk.env_keys(ctx).await
}
pub async fn mise_env<T: serde::Serialize>(
&self,
sdk: &str,
opts: T,
env: &indexmap::IndexMap<String, String>,
config_root: Option<&str>,
) -> Result<MiseEnvResult> {
let plugin = self.get_sdk(sdk)?;
if !plugin.get_metadata()?.hooks.contains("mise_env") {
return Ok(MiseEnvResult::default());
}
if log::log_enabled!(log::Level::Trace) {
if let Some(path) = env.get("PATH") {
trace!("[vfox:{sdk}] mise_env PATH: {path}");
} else {
trace!("[vfox:{sdk}] mise_env: no PATH in env");
}
}
plugin.set_cmd_env(env)?;
self.set_github_token(&plugin)?;
let ctx = MiseEnvContext {
args: vec![],
options: opts,
config_root: config_root.map(|s| s.to_string()),
};
plugin.mise_env(ctx).await
}
pub async fn backend_list_versions(
&self,
sdk: &str,
tool: &str,
options: IndexMap<String, toml::Value>,
) -> Result<Vec<String>> {
let plugin = self.get_sdk_with_env(sdk)?;
let ctx = BackendListVersionsContext {
tool: tool.to_string(),
options,
};
plugin.backend_list_versions(ctx).await.map(|r| r.versions)
}
pub async fn backend_install(
&self,
sdk: &str,
tool: &str,
version: &str,
install_path: PathBuf,
download_path: PathBuf,
options: IndexMap<String, toml::Value>,
) -> Result<()> {
let plugin = self.get_sdk_with_env(sdk)?;
let ctx = BackendInstallContext {
tool: tool.to_string(),
version: version.to_string(),
install_path,
download_path,
options,
};
plugin.backend_install(ctx).await?;
Ok(())
}
pub async fn backend_exec_env(
&self,
sdk: &str,
tool: &str,
version: &str,
install_path: PathBuf,
options: IndexMap<String, toml::Value>,
) -> Result<Vec<EnvKey>> {
let plugin = self.get_sdk_with_env(sdk)?;
let ctx = BackendExecEnvContext {
tool: tool.to_string(),
version: version.to_string(),
install_path,
options,
};
plugin.backend_exec_env(ctx).await.map(|r| r.env_vars)
}
pub async fn package_installed(
&self,
sdk: &str,
ctx: PackageInstalledContext,
) -> Result<PackageInstalledResponse> {
self.get_sdk_with_env(sdk)?.package_installed(ctx).await
}
pub async fn package_install(
&self,
sdk: &str,
ctx: PackageActionContext,
) -> Result<PackageActionResponse> {
self.get_sdk_with_env(sdk)?.package_install(ctx).await
}
pub async fn package_upgrade(
&self,
sdk: &str,
ctx: PackageActionContext,
) -> Result<PackageActionResponse> {
self.get_sdk_with_env(sdk)?.package_upgrade(ctx).await
}
pub async fn package_uninstall(
&self,
sdk: &str,
ctx: PackageUninstallContext,
) -> Result<PackageActionResponse> {
self.get_sdk_with_env(sdk)?.package_uninstall(ctx).await
}
pub async fn mise_path<T: serde::Serialize>(
&self,
sdk: &str,
opts: T,
env: &indexmap::IndexMap<String, String>,
config_root: Option<&str>,
) -> Result<Vec<String>> {
let plugin = self.get_sdk(sdk)?;
if !plugin.get_metadata()?.hooks.contains("mise_path") {
return Ok(vec![]);
}
plugin.set_cmd_env(env)?;
self.set_github_token(&plugin)?;
let ctx = MisePathContext {
args: vec![],
options: opts,
config_root: config_root.map(|s| s.to_string()),
};
plugin.mise_path(ctx).await
}
pub async fn parse_legacy_file(
&self,
sdk: &str,
file: &Path,
) -> Result<ParseLegacyFileResponse> {
let sdk = self.get_sdk(sdk)?;
sdk.parse_legacy_file(file).await
}
async fn download(
&self,
url: &Url,
sdk: &Plugin,
version: &str,
download_dir: &Path,
) -> Result<PathBuf> {
let path = Self::download_path_for(download_dir, &sdk.name, version, url)?;
let mut request_url = url.clone();
self.rewrite_url(&mut request_url);
self.log_emit(format!("Downloading {request_url}"));
let url_str = request_url.to_string();
let bytes = retry_async(&url_str, || async {
let mut request = CLIENT.get(request_url.clone());
if let Some(resolver) = &self.http_headers_resolver {
request = request.headers(resolver(&request_url));
}
let resp = request.send().await?;
let resp = resp.error_for_status()?;
resp.bytes().await
})
.await?;
file::mkdirp(path.parent().unwrap())?;
let mut file = tokio::fs::File::create(&path).await?;
tokio::io::AsyncWriteExt::write_all(&mut file, &bytes).await?;
file.sync_all().await?;
Ok(path)
}
fn download_path_for(
download_dir: &Path,
sdk: &str,
version: &str,
url: &Url,
) -> Result<PathBuf> {
let filename = url
.path_segments()
.and_then(|mut s| s.next_back())
.ok_or("No filename in URL")?;
Ok(download_dir.join(format!("{sdk}-{version}")).join(filename))
}
async fn verify(
&self,
pre_install: &PreInstall,
file: &Path,
) -> Result<Option<VerifiedAttestation>> {
self.log_emit(format!("Verifying {file:?} checksum"));
if let Some(sha256) = &pre_install.sha256 {
xx::hash::ensure_checksum_sha256(file, sha256)?;
}
if let Some(sha512) = &pre_install.sha512 {
xx::hash::ensure_checksum_sha512(file, sha512)?;
}
if let Some(sha1) = &pre_install.sha1 {
ensure_checksum(file, "sha1", sha1, &xx::hash::file_hash_sha1(file)?)?;
}
if let Some(md5) = &pre_install.md5 {
ensure_checksum(file, "md5", md5, &xx::hash::file_hash_md5(file)?)?;
}
let mut verified: Option<VerifiedAttestation> = None;
let has_checksum = pre_install.sha256.is_some() || pre_install.sha512.is_some();
if let Some(attestation) = &pre_install.attestation
&& !(self.skip_verification && has_checksum)
{
self.log_emit(format!("Verify {file:?} attestation"));
if let Some(owner) = &attestation.github_owner
&& let Some(repo) = &attestation.github_repo
{
let token = std::env::var("MISE_GITHUB_TOKEN")
.or_else(|_| std::env::var("GITHUB_TOKEN"))
.or(Err("GitHub artifact attestation verification requires either the MISE_GITHUB_TOKEN or GITHUB_TOKEN environment variable set"))?;
mise_sigstore::verify_github_attestation(
file,
owner.as_str(),
repo.as_str(),
Some(token.as_str()),
attestation.github_signer_workflow.as_deref(),
crate::http::sigstore_retry_config(),
)
.await?;
verified = Some(VerifiedAttestation::GithubAttestations {
owner: owner.clone(),
repo: repo.clone(),
signer_workflow: attestation.github_signer_workflow.clone(),
});
}
if let Some(sig_or_bundle_path) = &attestation.cosign_sig_or_bundle_path {
if let Some(public_key_path) = &attestation.cosign_public_key_path {
mise_sigstore::verify_cosign_signature_with_key(
file,
sig_or_bundle_path,
public_key_path,
)
.await?;
} else {
mise_sigstore::verify_cosign_signature(file, sig_or_bundle_path).await?;
}
if verified.is_none() {
verified = Some(VerifiedAttestation::Cosign {
sig_or_bundle_path: sig_or_bundle_path.clone(),
public_key_path: attestation.cosign_public_key_path.clone(),
});
}
}
if let Some(provenance_path) = &attestation.slsa_provenance_path {
let min_level = attestation.slsa_min_level.unwrap_or(1u8);
mise_sigstore::verify_slsa_provenance(file, provenance_path, min_level).await?;
if !matches!(
verified,
Some(VerifiedAttestation::GithubAttestations { .. })
) {
verified = Some(VerifiedAttestation::Slsa {
provenance_path: provenance_path.clone(),
});
}
}
}
Ok(verified)
}
fn extract(&self, file: &Path, install_dir: &Path) -> Result<()> {
self.log_emit(format!("Extracting {file:?} to {install_dir:?}"));
let filename = file.file_name().unwrap().to_string_lossy().to_string();
let parent = install_dir.parent().unwrap();
file::mkdirp(parent)?;
let tmp = TempDir::with_prefix_in(&filename, parent)?;
file::remove_dir_all(install_dir)?;
let move_to_install = || {
let subdirs = file::ls(tmp.path())?;
if subdirs.len() == 1 && subdirs.first().unwrap().is_dir() {
let subdir = subdirs.first().unwrap();
file::mv(subdir, install_dir)?;
} else {
file::mv(tmp.path(), install_dir)?;
}
Result::Ok(())
};
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
xx::archive::untar_gz(file, tmp.path())?;
move_to_install()?;
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
xx::archive::untar_xz(file, tmp.path())?;
move_to_install()?;
} else if filename.ends_with(".tar.bz2")
|| filename.ends_with(".tbz2")
|| filename.ends_with(".tbz")
{
xx::archive::untar_bz2(file, tmp.path())?;
move_to_install()?;
} else if filename.ends_with(".zip") {
xx::archive::unzip(file, tmp.path())?;
move_to_install()?;
} else {
file::mv(file, install_dir.join(&filename))?;
#[cfg(unix)]
file::make_executable(install_dir.join(&filename))?;
}
Ok(())
}
}
fn attestation_to_verified(att: PreInstallAttestation) -> Option<VerifiedAttestation> {
if let Some(owner) = att.github_owner
&& let Some(repo) = att.github_repo
{
return Some(VerifiedAttestation::GithubAttestations {
owner,
repo,
signer_workflow: att.github_signer_workflow,
});
}
if let Some(provenance_path) = att.slsa_provenance_path {
return Some(VerifiedAttestation::Slsa { provenance_path });
}
if let Some(sig_or_bundle_path) = att.cosign_sig_or_bundle_path {
return Some(VerifiedAttestation::Cosign {
sig_or_bundle_path,
public_key_path: att.cosign_public_key_path,
});
}
None
}
impl Default for Vfox {
fn default() -> Self {
Self {
runtime_version: "1.0.0".to_string(),
plugin_dir: home().join(".version-fox/plugin"),
cache_dir: home().join(".version-fox/cache"),
download_dir: home().join(".version-fox/downloads"),
install_dir: home().join(".version-fox/installs"),
skip_verification: false,
cmd_env: None,
default_inline_shell: None,
github_token: None,
github_token_resolver: None,
runtime_env_type: None,
url_rewriter: None,
http_headers_resolver: None,
log_tx: None,
}
}
}
fn home() -> PathBuf {
homedir::my_home()
.ok()
.flatten()
.unwrap_or_else(|| PathBuf::from("/"))
}
fn ensure_checksum(file: &Path, algo: &str, expected: &str, actual: &str) -> Result<()> {
let expected = expected.to_lowercase();
if actual != expected {
return Err(format!(
"Checksum mismatch for file {}:\nExpected: {algo}:{expected}\nActual: {algo}:{actual}",
file.display()
)
.into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
impl Vfox {
pub fn test() -> Self {
Self {
runtime_version: "1.0.0".to_string(),
plugin_dir: PathBuf::from("plugins"),
cache_dir: PathBuf::from("test/cache"),
download_dir: PathBuf::from("test/downloads"),
install_dir: PathBuf::from("test/installs"),
skip_verification: false,
cmd_env: None,
default_inline_shell: None,
github_token: None,
github_token_resolver: None,
runtime_env_type: None,
url_rewriter: None,
http_headers_resolver: None,
log_tx: None,
}
}
}
const ABC: &[u8] = b"abc";
const ABC_SHA1: &str = "a9993e364706816aba3e25717850c26c9cd0d89d";
const ABC_MD5: &str = "900150983cd24fb0d6963f7d28e17f72";
fn pre_install_with(sha1: Option<&str>, md5: Option<&str>) -> PreInstall {
PreInstall {
version: "1.0.0".to_string(),
url: None,
note: None,
sha256: None,
md5: md5.map(str::to_string),
sha1: sha1.map(str::to_string),
sha512: None,
attestation: None,
}
}
async fn verify_abc(pre_install: PreInstall) -> Result<()> {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("artifact.bin");
std::fs::write(&file, ABC).unwrap();
let vfox = Vfox::test();
vfox.verify(&pre_install, &file).await.map(|_| ())
}
#[test]
fn url_rewriter_defaults_to_noop_and_can_be_set() {
let mut vfox = Vfox::test();
let original = Url::parse("https://upstream.example/tool.tar.gz").unwrap();
let mut url = original.clone();
vfox.rewrite_url(&mut url);
assert_eq!(url, original);
vfox.set_url_rewriter(|url| {
url.set_host(Some("mirror.example")).unwrap();
});
vfox.rewrite_url(&mut url);
assert_eq!(url.as_str(), "https://mirror.example/tool.tar.gz");
}
#[tokio::test]
async fn verify_accepts_sha1_and_md5_checksums() {
verify_abc(pre_install_with(Some(ABC_SHA1), Some(ABC_MD5)))
.await
.unwrap();
verify_abc(pre_install_with(Some(&ABC_SHA1.to_uppercase()), None))
.await
.unwrap();
}
#[tokio::test]
async fn verify_rejects_a_mismatched_sha1() {
let err = verify_abc(pre_install_with(Some(&"0".repeat(40)), None))
.await
.unwrap_err()
.to_string();
assert!(err.contains("Checksum mismatch"), "{err}");
assert!(err.contains(&format!("sha1:{ABC_SHA1}")), "{err}");
}
#[tokio::test]
async fn verify_rejects_a_mismatched_md5() {
let err = verify_abc(pre_install_with(None, Some(&"0".repeat(32))))
.await
.unwrap_err()
.to_string();
assert!(err.contains("Checksum mismatch"), "{err}");
assert!(err.contains(&format!("md5:{ABC_MD5}")), "{err}");
}
#[tokio::test]
async fn test_env_keys() {
let vfox = Vfox::test();
let keys = vfox
.env_keys(
"dummy",
"1.0.0",
serde_json::Value::Object(Default::default()),
)
.await
.unwrap();
let install_dir = vfox.install_dir.join("dummy").join("1.0.0");
let expected = if cfg!(windows) {
install_dir
} else {
install_dir.join("bin")
};
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].key, "PATH");
assert_eq!(keys[0].value, expected.to_string_lossy().into_owned());
}
#[tokio::test]
async fn test_env_keys_for_install_dir() {
let vfox = Vfox::test();
let install_dir = PathBuf::from("custom/installs/dummy/1.0.0");
let keys = vfox
.env_keys_for_install_dir(
"dummy",
"1.0.0",
&install_dir,
serde_json::Value::Object(Default::default()),
)
.await
.unwrap();
let expected = if cfg!(windows) {
install_dir
} else {
install_dir.join("bin")
};
assert_eq!(keys[0].value, expected.to_string_lossy().into_owned());
}
#[test]
fn test_download_path_for_uses_download_dir() {
let url = Url::parse("https://example.com/releases/tool.tar.gz").unwrap();
let download_dir = PathBuf::from("custom/downloads/vfox-dummy/1.0.0");
let path = Vfox::download_path_for(&download_dir, "dummy", "1.0.0", &url).unwrap();
assert_eq!(
path,
PathBuf::from("custom/downloads/vfox-dummy/1.0.0/dummy-1.0.0/tool.tar.gz")
);
}
#[tokio::test]
async fn test_download_resolves_headers_after_url_rewrite() {
use reqwest::header::{AUTHORIZATION, HeaderValue};
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/mirror/tool.tar.gz"))
.and(header("Authorization", "Basic bWlycm9yOnNlY3JldA=="))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"artifact"))
.expect(1)
.mount(&server)
.await;
let temp = TempDir::new().unwrap();
let plugin_dir = temp.path().join("dummy");
std::fs::create_dir_all(&plugin_dir).unwrap();
let plugin = Plugin::from_dir(&plugin_dir).unwrap();
let mut vfox = Vfox::test();
let mirror_url = Url::parse(&format!("{}/mirror/tool.tar.gz", server.uri())).unwrap();
vfox.set_url_rewriter({
let mirror_url = mirror_url.clone();
move |url| *url = mirror_url.clone()
});
vfox.set_http_headers_resolver(move |url| {
assert_eq!(url, &mirror_url);
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Basic bWlycm9yOnNlY3JldA=="),
);
headers
});
let original_url = Url::parse("https://upstream.invalid/tool.tar.gz").unwrap();
let downloaded = vfox
.download(&original_url, &plugin, "1.0.0", temp.path())
.await
.unwrap();
assert_eq!(std::fs::read(downloaded).unwrap(), b"artifact");
}
#[tokio::test]
async fn test_install_plugin() {
let vfox = Vfox::test();
assert!(vfox.plugin_dir.join("dummy").exists());
let plugin = Plugin::from_dir(&vfox.plugin_dir.join("dummy")).unwrap();
assert_eq!(plugin.name, "dummy");
}
#[tokio::test]
async fn test_install() {
let vfox = Vfox::test();
let install_dir = vfox.install_dir.join("dummy").join("1.0.0");
vfox.install("dummy", "1.0.0", &install_dir).await.unwrap();
assert!(vfox.install_dir.join("dummy").join("1.0.0").exists());
assert_eq!(
file::read_to_string(vfox.install_dir.join("dummy").join("1.0.0").join("VERSION"))
.unwrap(),
"1.0.0"
);
vfox.uninstall("dummy", "1.0.0").unwrap();
assert!(!vfox.install_dir.join("dummy").join("1.0.0").exists());
file::remove_dir_all(vfox.install_dir).unwrap();
file::remove_dir_all(vfox.download_dir).unwrap();
}
#[tokio::test]
async fn test_github_token_resolver_not_called_for_local_hooks() {
use std::sync::atomic::{AtomicUsize, Ordering};
let temp_dir = tempfile::tempdir().unwrap();
let mut vfox = Vfox::test();
vfox.install_dir = temp_dir.path().join("installs");
let calls = Arc::new(AtomicUsize::new(0));
let calls_inner = calls.clone();
vfox.github_token_resolver = Some(Arc::new(move || {
calls_inner.fetch_add(1, Ordering::SeqCst);
None
}));
vfox.env_keys(
"dummy",
"1.0.0",
serde_json::Value::Object(Default::default()),
)
.await
.unwrap();
let install_dir = vfox.install_dir.join("dummy").join("1.0.0");
std::fs::create_dir_all(&install_dir).unwrap();
vfox.pre_uninstall("dummy", "1.0.0", &install_dir)
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn test_pre_uninstall() {
let temp_dir = tempfile::tempdir().unwrap();
let mut vfox = Vfox::test();
vfox.install_dir = temp_dir.path().join("installs");
let install_dir = vfox.install_dir.join("dummy").join("1.0.0");
std::fs::create_dir_all(&install_dir).unwrap();
vfox.pre_uninstall("dummy", "1.0.0", &install_dir)
.await
.unwrap();
let marker = std::fs::read_to_string(install_dir.join("pre_uninstall_marker")).unwrap();
assert_eq!(
marker,
format!(
"dummy:1.0.0:{}",
install_dir.to_string_lossy().replace('\\', "/")
)
);
}
#[tokio::test]
#[ignore] async fn test_install_cmake() {
let vfox = Vfox::test();
vfox.install_plugin("cmake").unwrap();
let install_dir = vfox.install_dir.join("cmake").join("3.21.0");
vfox.install("cmake", "3.21.0", &install_dir).await.unwrap();
if cfg!(target_os = "linux") {
assert!(
vfox.install_dir
.join("cmake")
.join("3.21.0")
.join("bin")
.join("cmake")
.exists()
);
} else if cfg!(target_os = "macos") {
assert!(
vfox.install_dir
.join("cmake")
.join("3.21.0")
.join("CMake.app")
.join("Contents")
.join("bin")
.join("cmake")
.exists()
);
} else if cfg!(target_os = "windows") {
assert!(
vfox.install_dir
.join("cmake")
.join("3.21.0")
.join("bin")
.join("cmake.exe")
.exists()
);
}
vfox.uninstall_plugin("cmake").unwrap();
assert!(!vfox.plugin_dir.join("cmake").exists());
vfox.uninstall("cmake", "3.21.0").unwrap();
assert!(!vfox.install_dir.join("cmake").join("3.21.0").exists());
file::remove_dir_all(vfox.plugin_dir.join("cmake")).unwrap();
file::remove_dir_all(vfox.install_dir).unwrap();
file::remove_dir_all(vfox.download_dir).unwrap();
}
#[tokio::test]
async fn test_metadata() {
let vfox = Vfox::test();
let metadata = vfox.metadata("dummy").await.unwrap();
let out = format!("{metadata:?}");
assert_snapshot!(out);
}
#[cfg(unix)]
#[tokio::test]
async fn test_backend_list_versions_with_cmd_env() {
let mut vfox = Vfox::test();
let mut env = IndexMap::new();
env.insert("MY_TEST_VAR".to_string(), "hello".to_string());
env.insert(
"PATH".to_string(),
std::env::var("PATH").unwrap_or_default(),
);
vfox.cmd_env = Some(env);
let versions = vfox
.backend_list_versions("dummy-backend", "test-tool", IndexMap::new())
.await
.unwrap();
assert_eq!(versions, vec!["hello".to_string()]);
}
#[tokio::test]
async fn test_backend_list_versions_without_cmd_env() {
let vfox = Vfox::test();
let versions = vfox
.backend_list_versions("dummy-backend", "test-tool", IndexMap::new())
.await
.unwrap();
assert_eq!(versions, vec!["fallback".to_string()]);
}
}