use crate::backend::backend_type::BackendType;
use crate::backend::options::VersionOrder;
use crate::cli::args::BackendArg;
use crate::config::Settings;
use crate::http::HTTP;
use crate::toolset::{RawBackendOptions, ToolVersionOptions};
use crate::ui::multi_progress_report::MultiProgressReport;
use crate::{dirs, file};
use eyre::{Context, Result, bail, ensure};
use heck::ToShoutySnakeCase;
use indexmap::IndexMap;
use serde::Serialize as _;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::env;
use std::env::consts::OS;
use std::fmt::Display;
use std::fs::File;
use std::io::Read;
use std::iter::Iterator;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock as Lazy, Mutex};
use std::time::Duration;
use strum::IntoEnumIterator;
use url::Url;
static BAKED_REGISTRY: Registry = include!(concat!(env!("OUT_DIR"), "/registry.rs"));
#[cfg(any(test, debug_assertions))]
pub(crate) fn baked_registry() -> &'static Registry {
&BAKED_REGISTRY
}
pub(crate) static REGISTRY: Lazy<&'static Registry> = Lazy::new(|| {
if !Settings::get().registry_floating {
return &BAKED_REGISTRY;
}
if !registry_cache_path().exists() {
return &BAKED_REGISTRY;
}
match load_cached_floating_registry() {
Ok(registry) if !registry.missing_version_order => Box::leak(Box::new(registry)),
Ok(_) => {
warn!(
"cached floating mise registry predates version-order metadata, using baked-in registry"
);
&BAKED_REGISTRY
}
Err(err) => {
warn!("failed to load floating mise registry, using baked-in registry: {err:#}");
&BAKED_REGISTRY
}
}
});
const MISE_REGISTRY_ARCHIVE_URL: &str = "https://mise.jdx.dev/registry/latest.tar.zst";
const MAX_REGISTRY_ARCHIVE_ENTRIES: usize = 4096;
const MAX_REGISTRY_ARCHIVE_ENTRY_SIZE: u64 = 1024 * 1024;
const MAX_REGISTRY_ARCHIVE_SIZE: u64 = 16 * 1024 * 1024;
pub(crate) struct Registry {
entries: &'static [(&'static str, RegistryTool)],
lookup: RegistryLookup,
missing_version_order: bool,
}
enum RegistryLookup {
Static(phf::Map<&'static str, usize>),
Dynamic(HashMap<&'static str, usize>),
}
impl Registry {
pub(crate) fn get(&self, name: &str) -> Option<&'static RegistryTool> {
self.lookup.get(name).map(|index| &self.entries[*index].1)
}
pub(crate) fn contains_key(&self, name: &str) -> bool {
self.lookup.get(name).is_some()
}
pub(crate) fn iter(&self) -> impl Iterator<Item = (&'static str, &'static RegistryTool)> {
self.entries.iter().map(|(name, tool)| (*name, tool))
}
pub(crate) fn keys(&self) -> impl Iterator<Item = &'static str> {
self.entries.iter().map(|(name, _)| *name)
}
pub(crate) fn values(&self) -> impl Iterator<Item = &'static RegistryTool> {
self.entries.iter().map(|(_, tool)| tool)
}
fn dynamic(entries: BTreeMap<String, RegistryTool>, missing_version_order: bool) -> Self {
let entries = entries
.into_iter()
.map(|(name, tool)| (leak_string(name), tool))
.collect::<Vec<_>>();
let entries = leak_vec(entries);
let lookup = entries
.iter()
.enumerate()
.map(|(index, (name, _))| (*name, index))
.collect();
Self {
entries,
lookup: RegistryLookup::Dynamic(lookup),
missing_version_order,
}
}
}
impl RegistryLookup {
fn get(&self, name: &str) -> Option<&usize> {
match self {
Self::Static(lookup) => lookup.get(name),
Self::Dynamic(lookup) => lookup.get(name),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct RegistryTool {
pub short: &'static str,
pub description: Option<&'static str>,
pub(crate) version_order: VersionOrder,
pub backends: &'static [RegistryBackend],
pub bins: &'static [&'static str],
#[allow(unused)]
pub aliases: &'static [&'static str],
pub overrides: &'static [&'static str],
pub test: &'static Option<RegistryToolTest>,
pub os: &'static [&'static str],
pub idiomatic_files: &'static [RegistryIdiomaticFile],
pub detect: &'static [&'static str],
}
#[derive(Debug, Clone)]
pub(crate) struct RegistryIdiomaticFile {
pub path: &'static str,
pub version_regex: Option<&'static str>,
pub version_json_path: Option<&'static str>,
pub version_expr: Option<&'static str>,
pub deprecated: Option<&'static str>,
}
impl RegistryIdiomaticFile {
pub(crate) fn has_parser(&self) -> bool {
self.version_regex.is_some()
|| self.version_json_path.is_some()
|| self.version_expr.is_some()
}
}
#[derive(Debug, Clone)]
pub(crate) struct RegistryToolTest {
pub cmd: &'static str,
pub expected: &'static str,
pub tools: &'static [&'static str],
}
#[derive(Debug, Clone)]
pub(crate) struct RegistryBackend {
pub full: &'static str,
pub platforms: &'static [&'static str],
pub min_version: Option<&'static str>,
pub options: &'static [(&'static str, &'static str)],
}
impl RegistryBackend {
fn supports_version(&self, request: &str) -> bool {
let Some(minimum) = self.min_version else {
return true;
};
let minimum = semver::Version::parse(minimum).expect("validated registry min_version");
let request = request.strip_prefix("prefix:").unwrap_or(request);
let request = request.trim_start_matches(['v', 'V']);
if let Ok(version) = semver::Version::parse(request) {
return !version.cmp_precedence(&minimum).is_lt();
}
let parts = request.split('.').collect::<Vec<_>>();
if !(1..=2).contains(&parts.len()) {
return true;
}
let Some(parts) = parts
.into_iter()
.map(|part| {
if part.is_empty()
|| !part.bytes().all(|c| c.is_ascii_digit())
|| (part.len() > 1 && part.starts_with('0'))
{
return None;
}
part.parse::<u64>().ok()
})
.collect::<Option<Vec<_>>>()
else {
return true;
};
let minimum_parts = [minimum.major, minimum.minor];
for (part, minimum) in parts.into_iter().zip(minimum_parts) {
match part.cmp(&minimum) {
std::cmp::Ordering::Less => return false,
std::cmp::Ordering::Greater => return true,
std::cmp::Ordering::Equal => {}
}
}
true
}
}
fn registry_cache_path() -> PathBuf {
dirs::CACHE.join("mise-registry").join("registry.tar.zst")
}
fn load_cached_floating_registry() -> Result<Registry> {
parse_registry_archive(®istry_cache_path())
.wrap_err("failed to load cached floating mise registry")
}
fn cache_is_fresh(path: &Path, ttl: Duration) -> bool {
path.metadata()
.and_then(|metadata| metadata.modified())
.and_then(|modified| modified.elapsed().map_err(std::io::Error::other))
.is_ok_and(|age| age < ttl)
}
pub(crate) async fn refresh() {
let settings = Settings::get();
if !settings.registry_floating || settings.prefer_offline() {
return;
}
let cache_path = registry_cache_path();
if cache_is_fresh(&cache_path, settings.registry_cache_ttl()) {
match parse_registry_archive(&cache_path) {
Ok(registry) if !registry.missing_version_order => return,
Ok(_) => warn!(
"cached floating mise registry predates version-order metadata; refreshing it"
),
Err(_) => warn!("cached floating mise registry is invalid; refreshing it"),
}
}
if let Err(err) = download_registry_archive(&cache_path).await {
warn!("failed to refresh floating mise registry: {err:#}");
}
}
async fn download_registry_archive(cache_path: &Path) -> Result<()> {
let download_path = cache_path.with_extension(format!("download-{}", std::process::id()));
let pr = MultiProgressReport::get().add_pre_backend("mise registry");
if let Err(err) = HTTP
.download_file(MISE_REGISTRY_ARCHIVE_URL, &download_path, Some(pr.as_ref()))
.await
{
let _ = file::remove_file(&download_path);
pr.abandon();
return Err(err);
}
let result = (|| {
parse_registry_archive(&download_path)
.wrap_err("downloaded mise registry archive is invalid")?;
replace_registry_cache(&download_path, cache_path)?;
Ok(())
})();
match result {
Ok(()) => {
pr.finish();
Ok(())
}
Err(err) => {
let _ = file::remove_file(&download_path);
pr.abandon();
Err(err)
}
}
}
#[cfg(not(windows))]
fn replace_registry_cache(download_path: &Path, cache_path: &Path) -> Result<()> {
file::rename(download_path, cache_path)
}
#[cfg(windows)]
fn replace_registry_cache(download_path: &Path, cache_path: &Path) -> Result<()> {
let backup_path = cache_path.with_extension(format!("backup-{}", std::process::id()));
let had_cache = cache_path.exists();
if backup_path.exists() {
file::remove_file(&backup_path)?;
}
if had_cache {
file::rename(cache_path, &backup_path)?;
}
if let Err(install_err) = file::rename(download_path, cache_path) {
if had_cache && let Err(restore_err) = file::rename(&backup_path, cache_path) {
return Err(install_err).wrap_err(format!(
"failed to install downloaded registry and restore cached registry: {restore_err:#}"
));
}
return Err(install_err).wrap_err("failed to install downloaded registry");
}
if had_cache {
file::remove_file(&backup_path)?;
}
Ok(())
}
fn parse_registry_archive(path: &Path) -> Result<Registry> {
let file = File::open(path)?;
let decoder = zstd::Decoder::new(file)?;
let mut archive = jdx_tar::Archive::new(decoder);
let mut sources = BTreeMap::new();
let mut archive_size = 0_u64;
for (index, entry) in archive.entries()?.enumerate() {
let mut entry = entry?;
track_registry_archive_entry(index, entry.size(), &mut archive_size)?;
if entry.entry_type() != jdx_tar::EntryType::File {
continue;
}
let path = entry.path()?;
let components = path
.components()
.map(|component| component.as_os_str())
.collect::<Vec<_>>();
if components.len() != 2 || components[0] != "registry" {
continue;
}
let file_path = PathBuf::from(components[1]);
if file_path
.extension()
.is_none_or(|extension| extension != "toml")
{
continue;
}
let short = file_path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| eyre::eyre!("invalid registry filename: {}", path.display()))?
.to_string();
let mut source = String::new();
entry.read_to_string(&mut source)?;
sources.insert(short, source);
}
ensure!(
!sources.is_empty(),
"archive does not contain registry entries"
);
registry_from_sources(sources)
}
fn track_registry_archive_entry(
index: usize,
entry_size: u64,
archive_size: &mut u64,
) -> Result<()> {
ensure!(
index < MAX_REGISTRY_ARCHIVE_ENTRIES,
"registry archive contains too many entries"
);
ensure!(
entry_size <= MAX_REGISTRY_ARCHIVE_ENTRY_SIZE,
"registry archive entry is too large"
);
*archive_size = archive_size
.checked_add(entry_size)
.ok_or_else(|| eyre::eyre!("registry archive size overflow"))?;
ensure!(
*archive_size <= MAX_REGISTRY_ARCHIVE_SIZE,
"registry archive is too large"
);
Ok(())
}
fn registry_from_sources(sources: BTreeMap<String, String>) -> Result<Registry> {
let mut entries = BTreeMap::new();
let mut missing_version_order = false;
for (short, source) in sources {
let value: toml::Value = toml::from_str(&source)
.wrap_err_with(|| format!("failed to parse registry/{short}.toml"))?;
let (tool, tool_missing_version_order) = parse_registry_tool(&short, &value)
.wrap_err_with(|| format!("invalid registry/{short}.toml"))?;
missing_version_order |= tool_missing_version_order;
entries.insert(short, tool.clone());
for alias in tool.aliases {
entries.insert((*alias).to_string(), tool.clone());
}
}
Ok(Registry::dynamic(entries, missing_version_order))
}
fn parse_registry_tool(short: &str, value: &toml::Value) -> Result<(RegistryTool, bool)> {
let table = value
.as_table()
.ok_or_else(|| eyre::eyre!("registry tool must be a TOML table"))?;
let backends = table
.get("backends")
.and_then(toml::Value::as_array)
.ok_or_else(|| eyre::eyre!("backends must be an array"))?
.iter()
.map(parse_registry_backend)
.collect::<Result<Vec<_>>>()?;
ensure!(!backends.is_empty(), "backends must not be empty");
let missing_version_order = !table.contains_key("version_order");
let version_order = match table.get("version_order").and_then(toml::Value::as_str) {
Some("source") => VersionOrder::Source,
Some("semver") => VersionOrder::Semver,
Some(_) => bail!("version_order must be \"source\" or \"semver\""),
None => VersionOrder::Source,
};
ensure!(
version_order == VersionOrder::Semver || backends.iter().all(|b| b.min_version.is_none()),
"backend min_version requires version_order = \"semver\""
);
let aliases = string_array(table.get("aliases"), "aliases")?;
let bins = if table.contains_key("bins") {
string_array(table.get("bins"), "bins")?
} else {
BAKED_REGISTRY
.get(short)
.map(|tool| tool.bins.to_vec())
.unwrap_or_default()
};
let overrides = string_array(table.get("overrides"), "overrides")?;
let os = string_array(table.get("os"), "os")?;
let idiomatic_files = parse_registry_idiomatic_files(table.get("idiomatic_files"))?;
let detect = string_array(table.get("detect"), "detect")?;
let description = table
.get("description")
.map(|value| {
value
.as_str()
.map(|value| leak_string(value.to_string()))
.ok_or_else(|| eyre::eyre!("description must be a string"))
})
.transpose()?;
let test = table.get("test").map(parse_registry_test).transpose()?;
let tool = RegistryTool {
short: leak_string(short.to_string()),
description,
version_order,
backends: leak_vec(backends),
bins: leak_vec(bins),
aliases: leak_vec(aliases),
overrides: leak_vec(overrides),
test: Box::leak(Box::new(test)),
os: leak_vec(os),
idiomatic_files: leak_vec(idiomatic_files),
detect: leak_vec(detect),
};
Ok((tool, missing_version_order))
}
fn parse_registry_idiomatic_files(
value: Option<&toml::Value>,
) -> Result<Vec<RegistryIdiomaticFile>> {
value
.map(|value| {
value
.as_array()
.ok_or_else(|| eyre::eyre!("idiomatic_files must be an array"))?
.iter()
.map(parse_registry_idiomatic_file)
.collect()
})
.transpose()
.map(Option::unwrap_or_default)
}
fn parse_registry_idiomatic_file(value: &toml::Value) -> Result<RegistryIdiomaticFile> {
match value {
toml::Value::String(path) => Ok(RegistryIdiomaticFile {
path: leak_string(path.clone()),
version_regex: None,
version_json_path: None,
version_expr: None,
deprecated: None,
}),
toml::Value::Table(table) => {
for key in table.keys() {
ensure!(
matches!(
key.as_str(),
"path"
| "version_regex"
| "version_json_path"
| "version_expr"
| "deprecated"
),
"unknown idiomatic file field: {key}"
);
}
let string = |key: &str| -> Result<Option<&'static str>> {
table
.get(key)
.map(|value| {
value
.as_str()
.map(|value| leak_string(value.to_string()))
.ok_or_else(|| eyre::eyre!("idiomatic_files.{key} must be a string"))
})
.transpose()
};
let path = string("path")?
.ok_or_else(|| eyre::eyre!("idiomatic_files.path must be a string"))?;
Ok(RegistryIdiomaticFile {
path,
version_regex: string("version_regex")?,
version_json_path: string("version_json_path")?,
version_expr: string("version_expr")?,
deprecated: string("deprecated")?,
})
}
_ => Err(eyre::eyre!(
"idiomatic_files entries must be strings or tables"
)),
}
}
fn parse_registry_backend(value: &toml::Value) -> Result<RegistryBackend> {
match value {
toml::Value::String(full) => Ok(RegistryBackend {
full: leak_string(full.clone()),
platforms: &[],
min_version: None,
options: &[],
}),
toml::Value::Table(table) => {
let full = table
.get("full")
.and_then(toml::Value::as_str)
.ok_or_else(|| eyre::eyre!("backend full must be a string"))?;
let platforms = string_array(table.get("platforms"), "backend platforms")?;
let min_version = table
.get("min_version")
.map(|value| {
let value = value
.as_str()
.ok_or_else(|| eyre::eyre!("backend min_version must be a string"))?;
semver::Version::parse(value)
.wrap_err("backend min_version must be a semantic version")?;
Ok::<_, eyre::Report>(leak_string(value.to_string()))
})
.transpose()?;
let options = table
.get("options")
.and_then(toml::Value::as_table)
.map(|options| {
options
.iter()
.map(|(key, value)| {
let mut serialized = String::new();
value.serialize(toml::ser::ValueSerializer::new(&mut serialized))?;
Ok((leak_string(key.clone()), leak_string(serialized)))
})
.collect::<Result<Vec<_>>>()
})
.transpose()?
.unwrap_or_default();
Ok(RegistryBackend {
full: leak_string(full.to_string()),
platforms: leak_vec(platforms),
min_version,
options: leak_vec(options),
})
}
_ => bail!("backend must be a string or table"),
}
}
fn parse_registry_test(value: &toml::Value) -> Result<RegistryToolTest> {
let table = value
.as_table()
.ok_or_else(|| eyre::eyre!("test must be a table"))?;
let cmd = table
.get("cmd")
.and_then(toml::Value::as_str)
.ok_or_else(|| eyre::eyre!("test.cmd must be a string"))?;
let expected = table
.get("expected")
.and_then(toml::Value::as_str)
.ok_or_else(|| eyre::eyre!("test.expected must be a string"))?;
let tools = string_array(table.get("tools"), "test.tools")?;
Ok(RegistryToolTest {
cmd: leak_string(cmd.to_string()),
expected: leak_string(expected.to_string()),
tools: leak_vec(tools),
})
}
fn string_array(value: Option<&toml::Value>, name: &str) -> Result<Vec<&'static str>> {
value
.map(|value| {
value
.as_array()
.ok_or_else(|| eyre::eyre!("{name} must be an array"))?
.iter()
.map(|value| {
value
.as_str()
.map(|value| leak_string(value.to_string()))
.ok_or_else(|| eyre::eyre!("{name} must contain only strings"))
})
.collect()
})
.transpose()
.map(Option::unwrap_or_default)
}
fn leak_string(value: String) -> &'static str {
Box::leak(value.into_boxed_str())
}
fn leak_vec<T>(value: Vec<T>) -> &'static [T] {
Box::leak(value.into_boxed_slice())
}
static ENV_BACKENDS: Lazy<Mutex<HashMap<String, &'static str>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
impl RegistryTool {
pub(crate) fn provides_bin(&self, bin_name: &str) -> bool {
let exe_suffix = std::env::consts::EXE_SUFFIX;
let bin_name = if exe_suffix.is_empty() {
bin_name
} else {
let suffix_start = bin_name.len().saturating_sub(exe_suffix.len());
match (bin_name.get(..suffix_start), bin_name.get(suffix_start..)) {
(Some(name), Some(suffix)) if suffix.eq_ignore_ascii_case(exe_suffix) => name,
_ => bin_name,
}
};
self.bins.iter().any(|bin| {
if cfg!(windows) {
bin.eq_ignore_ascii_case(bin_name)
} else {
*bin == bin_name
}
})
}
pub(crate) fn backends(&self) -> Vec<&'static str> {
let env_key = format!("MISE_BACKENDS_{}", self.short.to_shouty_snake_case());
{
let cache = ENV_BACKENDS.lock().unwrap();
if let Some(&backend) = cache.get(&env_key) {
return vec![backend];
}
}
if let Ok(env_value) = env::var(&env_key) {
let leaked = Box::leak(env_value.into_boxed_str());
let mut cache = ENV_BACKENDS.lock().unwrap();
cache.insert(env_key.clone(), leaked);
return vec![leaked];
}
static BACKEND_TYPES: Lazy<HashSet<String>> = Lazy::new(|| {
let mut backend_types = BackendType::iter()
.map(|b| b.to_string())
.collect::<HashSet<_>>();
time!("disable_backends");
for backend in &Settings::get().disable_backends {
backend_types.remove(backend);
}
time!("disable_backends");
if cfg!(windows) {
backend_types.remove("asdf");
}
backend_types
});
let settings = Settings::get();
let experimental = settings.experimental;
self.backends
.iter()
.filter(|rb| backend_matches_platform(rb.platforms, &settings))
.map(|rb| rb.full)
.filter(|full| {
full.split(':')
.next()
.is_some_and(|b| BACKEND_TYPES.contains(b))
})
.filter(|full| {
if experimental {
return true;
}
let backend_type = BackendType::guess(full);
!backend_type.is_experimental()
})
.collect()
}
pub(crate) fn backends_for_version(&self, version: Option<&str>) -> Vec<&'static str> {
self.backends()
.into_iter()
.filter(|full| version.is_none_or(|v| self.backend_supports_version(full, v)))
.collect()
}
pub(crate) fn backend_supports_version(&self, full: &str, version: &str) -> bool {
self.get_backend(full)
.is_none_or(|backend| backend.supports_version(version))
}
pub(crate) fn is_supported_os(&self) -> bool {
self.os.is_empty() || self.os.contains(&OS)
}
pub(crate) fn ba(&self) -> Option<BackendArg> {
self.backends()
.first()
.map(|f| BackendArg::new(self.short.to_string(), Some(f.to_string())))
}
pub(crate) fn get_backend(&self, full: &str) -> Option<&RegistryBackend> {
self.backends.iter().find(|rb| rb.full == full)
}
pub(crate) fn backend_options(&self, full: &str) -> ToolVersionOptions {
let mut opts = IndexMap::new();
if let Some(backend) = self.get_backend(full) {
for (k, v) in backend.options {
let value = v.parse::<toml::Value>().unwrap_or_else(|e| {
panic!("failed to parse registry option {k} as a TOML value: {e}")
});
opts.insert(k.to_string(), value);
}
}
ToolVersionOptions {
opts: RawBackendOptions::from(opts),
..Default::default()
}
}
pub(crate) fn version_order(&self, full: &str) -> Option<VersionOrder> {
matches!(
BackendType::guess(full),
BackendType::Aqua
| BackendType::Forgejo
| BackendType::Github
| BackendType::Gitlab
| BackendType::Http
)
.then_some(self.version_order)
}
}
fn backend_matches_platform(platforms: &[&str], settings: &Settings) -> bool {
let os = settings.os();
let arch = settings.arch();
let platform = format!("{os}-{arch}");
platforms.is_empty()
|| platforms.contains(&os)
|| platforms.contains(&arch)
|| platforms.contains(&platform.as_str())
|| (os == "windows"
&& arch == "arm64"
&& (platforms.contains(&"x64") || platforms.contains(&"windows-x64")))
}
pub(crate) fn shorts_for_full(full: &str) -> &'static Vec<&'static str> {
static EMPTY: Vec<&'static str> = vec![];
static FULL_TO_SHORT: Lazy<HashMap<&'static str, Vec<&'static str>>> = Lazy::new(|| {
let mut map: HashMap<&'static str, Vec<&'static str>> = HashMap::new();
for (short, rt) in REGISTRY.iter() {
for full in rt.backends() {
map.entry(full).or_default().push(short);
}
}
map
});
FULL_TO_SHORT.get(full).unwrap_or(&EMPTY)
}
pub(crate) fn is_trusted_plugin(name: &str, remote: &str) -> bool {
let Ok(normalized_url) = normalize_remote(remote) else {
return false;
};
if normalized_url.starts_with("github.com/mise-plugins/") {
return true;
}
let official_registry_plugin_remotes = || {
static REMOTES: Lazy<HashSet<String>> = Lazy::new(|| {
REGISTRY
.values()
.flat_map(|tool| tool.backends.iter().map(|backend| backend.full))
.filter(|full| full.starts_with("asdf:") || full.starts_with("vfox:"))
.filter_map(|full| normalize_remote(&full_to_url(full)).ok())
.collect()
});
&*REMOTES
};
let name_matches_official_remote = REGISTRY.get(name).is_some_and(|tool| {
tool.backends
.iter()
.map(|backend| backend.full)
.filter(|full| full.starts_with("asdf:") || full.starts_with("vfox:"))
.filter_map(|full| normalize_remote(&full_to_url(full)).ok())
.any(|official_remote| official_remote == normalized_url)
});
name_matches_official_remote || official_registry_plugin_remotes().contains(&normalized_url)
}
pub(crate) fn normalize_remote(remote: &str) -> eyre::Result<String> {
let url = Url::parse(remote)?;
let host = url
.host_str()
.ok_or_else(|| eyre::eyre!("URL has no host: {remote}"))?;
let path = url.path().trim_end_matches(".git");
Ok(format!("{host}{path}"))
}
pub(crate) fn full_to_url(full: &str) -> String {
if let Some(source) = full.strip_prefix("vfox:packslip:") {
return format!("packslip:{source}");
}
if full.starts_with("packslip:") {
return full.to_string();
}
if url_like(full) {
return full.to_string();
}
let (_backend, url) = full.split_once(':').unwrap_or(("", full));
if url_like(url) {
url.to_string()
} else {
format!("https://github.com/{url}.git")
}
}
pub(crate) fn url_like(s: &str) -> bool {
s.starts_with("https://")
|| s.starts_with("http://")
|| s.starts_with("git@")
|| s.starts_with("ssh://")
|| s.starts_with("git://")
}
impl Display for RegistryTool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.short)
}
}
pub(crate) fn tool_enabled<T: Ord>(
enable_tools: Option<&BTreeSet<T>>,
disable_tools: &BTreeSet<T>,
name: &T,
) -> bool {
match enable_tools {
Some(enable_tools) => enable_tools.contains(name),
None => !disable_tools.contains(name),
}
}
#[cfg(test)]
mod tests {
use super::{BTreeMap, baked_registry, registry_from_sources};
use crate::config::Config;
#[test]
fn registry_min_version_boundaries() {
let backend = super::RegistryBackend {
full: "packslip:github.com/example/tool",
platforms: &[],
min_version: Some("1.58.1"),
options: &[],
};
for request in [
"0",
"1.5",
"1.57",
"prefix:1.57",
"1.58.0",
"v1.58.0",
"V1.58.0",
"prefix:V1.57",
"1.58.1-rc.1",
] {
assert!(!backend.supports_version(request), "{request}");
}
for request in [
"1",
"1.58",
"prefix:1.58",
"1.58.1",
"V1.58.1",
"1.58.1+build.2",
"2.0.0",
"latest",
"nightly",
"ref:main",
"lts/iron",
"0.nightly",
"1.58.0.2",
"01.57",
"",
] {
assert!(backend.supports_version(request), "{request}");
}
}
#[test]
fn registry_min_version_parsing_and_validation() {
use super::*;
let parse = |order: &str, minimum: &str| {
let source = format!(
r#"
version_order = "{order}"
backends = [
{{ full = "packslip:github.com/example/tool", min_version = {minimum} }},
"aqua:example/tool",
]
"#
);
parse_registry_tool("example", &toml::from_str::<toml::Value>(&source).unwrap())
};
let (tool, _) = parse("semver", r#""1.58.1""#).unwrap();
assert_eq!(tool.backends[0].min_version, Some("1.58.1"));
assert_eq!(tool.backends[1].min_version, None);
assert_eq!(
tool.backends_for_version(Some("1.57")),
["aqua:example/tool"]
);
assert_eq!(
tool.backends_for_version(Some("latest")),
["packslip:github.com/example/tool", "aqua:example/tool"]
);
for minimum in [
r#""latest""#,
r#""1.58""#,
r#""01.58.1""#,
r#""1.0.0-01""#,
r#""1.0.0-a..b""#,
r#""1.0.0+a..b""#,
"true",
"12",
] {
assert!(parse("semver", minimum).is_err(), "{minimum}");
}
assert!(parse("source", r#""1.58.1""#).is_err());
}
#[test]
fn registry_min_version_schema_matches_semver_identifiers() {
let schema: serde_json::Value =
serde_json::from_str(include_str!("../schema/mise-registry-tool.json")).unwrap();
let pattern = schema["properties"]["backends"]["items"]["oneOf"][1]
["properties"]["min_version"]["pattern"].as_str().unwrap();
let pattern = regex::Regex::new(pattern).unwrap();
for (version, valid) in [
("1.58.1", true),
("0.0.0", true),
("1.0.0-0", true),
("1.0.0-0alpha.1+build.01", true),
("1.0.0+01", true),
("1.0.0-a..b", false),
("1.0.0-01", false),
("1.0.0-alpha.01", false),
("1.0.0+a..b", false),
("1.0.0+", false),
("1.0.0-", false),
("01.0.0", false),
] {
assert_eq!(pattern.is_match(version), valid, "{version}");
assert_eq!(semver::Version::parse(version).is_ok(), valid, "{version}");
}
}
#[test]
fn baked_registry_infers_bins_from_preferred_aqua_backend() {
let tool = baked_registry().get("jq").unwrap();
assert_eq!(tool.bins, &["jq"]);
}
#[test]
fn floating_registry_reuses_baked_inferred_bins() {
let registry = registry_from_sources(BTreeMap::from([(
"jq".to_string(),
r#"
backends = ["aqua:jqlang/jq"]
version_order = "source"
"#
.to_string(),
)]))
.unwrap();
assert_eq!(registry.get("jq").unwrap().bins, &["jq"]);
}
fn registry_archive(entries: &[(&str, &str)]) -> tempfile::NamedTempFile {
use std::io::Cursor;
let file = tempfile::NamedTempFile::new().unwrap();
let encoder = zstd::Encoder::new(file.reopen().unwrap(), 0).unwrap();
let mut archive = jdx_tar::Builder::new(encoder);
for (path, contents) in entries {
let mut header = jdx_tar::Header::new_gnu(jdx_tar::EntryType::File);
header.set_size(contents.len() as u64);
header.set_mode(0o644);
archive
.append_data(&mut header, path, Cursor::new(contents.as_bytes()))
.unwrap();
}
archive.into_inner().unwrap().finish().unwrap();
file
}
#[test]
fn test_dynamic_registry_parses_tools_aliases_and_options() {
use super::*;
let registry = registry_from_sources(BTreeMap::from([(
"example".to_string(),
r#"
aliases = ["example-alias"]
description = "Example tool"
version_order = "semver"
bins = ["example", "example-helper"]
backends = [
"aqua:example/tool",
{ full = "github:example/tool", platforms = ["linux-x64"], options = { bin = "example" } },
]
idiomatic_files = [
".example-version",
{ path = "example.json", version_json_path = ".tool.version" },
{ path = "example.txt", version_regex = 'version=(\S+)', version_expr = "versions[0]" },
{ path = "example.conf", version_regex = 'minimum=(\S+)', deprecated = "it declares a minimum." },
]
test = { cmd = "example --version", expected = "{{version}}", tools = ["node"] }
"#
.to_string(),
)]))
.unwrap();
let tool = registry.get("example-alias").unwrap();
assert_eq!(tool.short, "example");
assert_eq!(tool.description, Some("Example tool"));
assert_eq!(tool.bins, &["example", "example-helper"]);
assert!(tool.provides_bin("example"));
assert!(!tool.provides_bin("other"));
if cfg!(windows) {
assert!(tool.provides_bin("EXAMPLE.EXE"));
}
assert_eq!(tool.backends[0].full, "aqua:example/tool");
assert_eq!(tool.backends[1].platforms, &["linux-x64"]);
assert_eq!(
tool.backend_options("github:example/tool").get("bin"),
Some("example")
);
assert_eq!(
tool.version_order("aqua:example/tool"),
Some(VersionOrder::Semver)
);
assert_eq!(tool.idiomatic_files[0].path, ".example-version");
assert!(!tool.idiomatic_files[0].has_parser());
assert_eq!(tool.idiomatic_files[1].path, "example.json");
assert_eq!(
tool.idiomatic_files[1].version_json_path,
Some(".tool.version")
);
assert_eq!(
tool.idiomatic_files[2].version_regex,
Some(r"version=(\S+)")
);
assert_eq!(tool.idiomatic_files[2].version_expr, Some("versions[0]"));
assert_eq!(tool.idiomatic_files[2].deprecated, None);
assert_eq!(tool.idiomatic_files[3].path, "example.conf");
assert_eq!(
tool.idiomatic_files[3].deprecated,
Some("it declares a minimum.")
);
assert_eq!(tool.test.as_ref().unwrap().tools, &["node"]);
assert!(!registry.missing_version_order);
}
#[test]
fn test_dynamic_registry_defaults_missing_version_order_to_source() {
use super::*;
let registry = registry_from_sources(BTreeMap::from([(
"example".to_string(),
"backends = [\"aqua:example/tool\"]".to_string(),
)]))
.unwrap();
assert_eq!(
registry
.get("example")
.unwrap()
.version_order("aqua:example/tool"),
Some(VersionOrder::Source)
);
assert!(registry.missing_version_order);
}
#[test]
fn test_dynamic_registry_rejects_unknown_idiomatic_file_fields() {
use super::*;
let err = registry_from_sources(BTreeMap::from([(
"example".to_string(),
r#"
backends = ["aqua:example/tool"]
version_order = "source"
idiomatic_files = [{ path = ".example-version", parser = "shell" }]
"#
.to_string(),
)]))
.err()
.unwrap();
assert!(
format!("{err:#}").contains("unknown idiomatic file field: parser"),
"{err:#}"
);
}
#[test]
fn test_registry_archive_only_reads_top_level_registry_directory() {
use super::*;
let archive = registry_archive(&[
(
"registry/example.toml",
"backends = [\"aqua:good/tool\"]\nversion_order = \"source\"",
),
(
"e2e/registry/example.toml",
"backends = [\"aqua:wrong/tool\"]",
),
]);
let registry = parse_registry_archive(archive.path()).unwrap();
assert_eq!(
registry.get("example").unwrap().backends[0].full,
"aqua:good/tool"
);
}
#[test]
fn test_registry_archive_rejects_nested_registry_directory() {
use super::*;
let archive = registry_archive(&[(
"e2e/registry/example.toml",
"backends = [\"aqua:wrong/tool\"]",
)]);
assert!(parse_registry_archive(archive.path()).is_err());
}
#[test]
fn test_registry_archive_limits() {
use super::*;
let mut size = 0;
assert!(
track_registry_archive_entry(MAX_REGISTRY_ARCHIVE_ENTRIES, 0, &mut size)
.unwrap_err()
.to_string()
.contains("too many entries")
);
assert!(
track_registry_archive_entry(0, MAX_REGISTRY_ARCHIVE_ENTRY_SIZE + 1, &mut size)
.unwrap_err()
.to_string()
.contains("entry is too large")
);
size = MAX_REGISTRY_ARCHIVE_SIZE;
assert!(
track_registry_archive_entry(0, 1, &mut size)
.unwrap_err()
.to_string()
.contains("archive is too large")
);
}
#[test]
fn test_tool_disabled() {
use super::*;
let name = "cargo";
assert!(tool_enabled(None, &BTreeSet::new(), &name));
assert!(!tool_enabled(
Some(&BTreeSet::new()),
&BTreeSet::new(),
&name
));
assert!(tool_enabled(
Some(&BTreeSet::from(["cargo"])),
&BTreeSet::new(),
&name
));
assert!(!tool_enabled(None, &BTreeSet::from(["cargo"]), &name));
assert!(tool_enabled(
Some(&BTreeSet::from(["cargo"])),
&BTreeSet::from(["cargo"]),
&name
));
}
#[test]
fn test_registry_iteration_is_sorted() {
use super::*;
let keys = REGISTRY.keys().collect::<Vec<_>>();
let mut sorted = keys.clone();
sorted.sort_unstable();
assert!(!keys.is_empty());
assert_eq!(keys, sorted);
}
#[test]
fn test_backend_platform_matching_normalizes_settings() {
use super::*;
for (raw_os, raw_arch, selector) in [
("windows", "x86_64", "windows-x64"),
("windows", "amd64", "x64"),
("linux", "aarch64", "linux-arm64"),
("darwin", "x86_64", "macos-x64"),
] {
let settings = Settings {
os: Some(raw_os.to_string()),
arch: Some(raw_arch.to_string()),
..Default::default()
};
assert!(
backend_matches_platform(&[selector], &settings),
"{raw_os}-{raw_arch} should match normalized selector {selector}"
);
}
}
#[cfg(windows)]
#[test]
fn tools_that_run_on_windows_are_not_restricted_away_from_it() {
use super::*;
for short in [
"entireio-cli",
"gitsign",
"go-swagger",
"grpc-health-probe",
"httpie-go",
"acli",
"mimirtool",
"specstory",
] {
let rt = BAKED_REGISTRY.get(short).unwrap();
assert!(rt.is_supported_os(), "{short}: os = {:?}", rt.os);
}
for short in ["docker-slim", "kpt"] {
let rt = BAKED_REGISTRY.get(short).unwrap();
assert!(!rt.is_supported_os(), "{short}: os = {:?}", rt.os);
}
}
fn settings_for(os: &str, arch: &str) -> crate::config::Settings {
crate::config::Settings {
os: Some(os.to_string()),
arch: Some(arch.to_string()),
..Default::default()
}
}
#[test]
fn windows_arm64_matches_x64_selectors_the_way_aqua_does() {
use super::*;
let settings = settings_for("windows", "arm64");
for selector in ["windows-x64", "x64"] {
assert!(
backend_matches_platform(&[selector], &settings),
"windows-arm64 should reach the {selector} backend under emulation"
);
}
}
#[test]
fn the_x64_fallback_is_confined_to_windows_arm64() {
use super::*;
for (os, arch, selector) in [
("linux", "arm64", "linux-x64"),
("macos", "arm64", "macos-x64"),
("windows", "arm64", "linux-x64"),
("windows", "arm64", "windows-amd64"),
("windows", "arm64", "amd64"),
("windows", "x64", "windows-arm64"),
] {
let settings = settings_for(os, arch);
assert!(
!backend_matches_platform(&[selector], &settings),
"{os}-{arch} should not match {selector}"
);
}
}
#[test]
fn android_cli_serves_windows_arm64_the_x64_download() {
use super::*;
let rt = BAKED_REGISTRY.get("android-cli").unwrap();
let opts = rt.backend_options("http:android-cli");
for key in ["url", "checksum_url", "bin"] {
let arm64 = opts.get_nested_string(&format!("platforms.windows-arm64.{key}"));
let x64 = opts.get_nested_string(&format!("platforms.windows-x64.{key}"));
assert!(arm64.is_some(), "platforms.windows-arm64.{key} is missing");
assert_eq!(arm64, x64, "windows-arm64 {key} should be the x64 one");
}
}
#[cfg(windows)]
#[test]
fn pre_commit_falls_through_to_pipx_on_windows() {
use super::*;
assert!(env::var("MISE_BACKENDS_PRE_COMMIT").is_err());
let backends = BAKED_REGISTRY.get("pre-commit").unwrap().backends();
assert_eq!(
backends.first().copied(),
Some("pipx:pre-commit"),
"{backends:?}"
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn pre_commit_keeps_the_aqua_backend_off_windows() {
use super::*;
assert!(env::var("MISE_BACKENDS_PRE_COMMIT").is_err());
let backends = BAKED_REGISTRY.get("pre-commit").unwrap().backends();
assert_eq!(
backends.first().copied(),
Some("aqua:pre-commit/pre-commit"),
"{backends:?}"
);
}
#[test]
fn test_backend_platform_matching_preserves_os_only_and_order() {
use super::*;
let settings = Settings {
os: Some("darwin".to_string()),
arch: Some("amd64".to_string()),
..Default::default()
};
let backends = [
RegistryBackend {
full: "aqua:first/tool",
platforms: &["macos"],
min_version: None,
options: &[],
},
RegistryBackend {
full: "github:second/tool",
platforms: &["macos-x64"],
min_version: None,
options: &[],
},
RegistryBackend {
full: "cargo:third-tool",
platforms: &[],
min_version: None,
options: &[],
},
RegistryBackend {
full: "npm:excluded-tool",
platforms: &["linux"],
min_version: None,
options: &[],
},
];
let matching = backends
.iter()
.filter(|backend| backend_matches_platform(backend.platforms, &settings))
.map(|backend| backend.full)
.collect::<Vec<_>>();
assert_eq!(
matching,
["aqua:first/tool", "github:second/tool", "cargo:third-tool"]
);
let alias_selector = RegistryBackend {
full: "github:owner/repo",
platforms: &["darwin-amd64"],
min_version: None,
options: &[],
};
assert!(!backend_matches_platform(
alias_selector.platforms,
&settings
));
}
#[test]
fn test_backend_options_parse_toml_values() {
use super::*;
static OPTIONS: &[(&str, &str)] = &[
("bin", r#""rg""#),
("prerelease", "true"),
("strip_components", "1"),
(
"targets",
r#"["x86_64-unknown-linux-gnu", "aarch64-apple-darwin"]"#,
),
(
"platforms",
r#"{ linux-x64 = { asset_pattern = "tool-linux.tar.gz" } }"#,
),
];
static BACKENDS: &[RegistryBackend] = &[RegistryBackend {
full: "github:owner/repo",
platforms: &[],
min_version: None,
options: OPTIONS,
}];
let tool = RegistryTool {
short: "test",
description: None,
version_order: VersionOrder::Source,
backends: BACKENDS,
bins: &[],
aliases: &[],
overrides: &[],
test: &None,
os: &[],
idiomatic_files: &[],
detect: &[],
};
let opts = tool.backend_options("github:owner/repo");
assert_eq!(opts.get("bin"), Some("rg"));
assert_eq!(
opts.opts.get("prerelease"),
Some(&toml::Value::Boolean(true))
);
assert_eq!(
opts.opts.get("strip_components"),
Some(&toml::Value::Integer(1))
);
assert!(opts.opts.get("targets").is_some_and(toml::Value::is_array));
assert_eq!(
opts.get_nested_string("platforms.linux-x64.asset_pattern"),
Some("tool-linux.tar.gz".to_string())
);
}
#[test]
fn test_semver_registry_order_only_applies_to_supported_backends() {
use super::*;
static BACKENDS: &[RegistryBackend] = &[
RegistryBackend {
full: "aqua:owner/repo",
platforms: &[],
min_version: None,
options: &[],
},
RegistryBackend {
full: "npm:package",
platforms: &[],
min_version: None,
options: &[],
},
];
let tool = RegistryTool {
short: "test",
description: None,
version_order: VersionOrder::Semver,
backends: BACKENDS,
bins: &[],
aliases: &[],
overrides: &[],
test: &None,
os: &[],
idiomatic_files: &[],
detect: &[],
};
assert_eq!(
tool.version_order("aqua:owner/repo"),
Some(VersionOrder::Semver)
);
assert_eq!(tool.version_order("npm:package"), None);
}
#[tokio::test]
async fn test_backend_env_override() {
let _config = Config::get().await.unwrap();
use super::*;
ENV_BACKENDS.lock().unwrap().clear();
if let Some(tool) = REGISTRY.get("node") {
let default_backends = tool.backends();
assert!(!default_backends.is_empty());
unsafe {
env::set_var("MISE_BACKENDS_NODE", "test:backend");
}
let overridden_backends = tool.backends();
assert_eq!(overridden_backends.len(), 1);
assert_eq!(overridden_backends[0], "test:backend");
unsafe {
env::remove_var("MISE_BACKENDS_NODE");
}
ENV_BACKENDS.lock().unwrap().clear();
}
}
#[test]
fn test_normalize_remote() {
use super::*;
let result = normalize_remote("https://github.com/mise-plugins/vfox-node.git");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "github.com/mise-plugins/vfox-node");
let result = normalize_remote("file:///path/to/repo");
assert!(result.is_err());
let result = normalize_remote("not-a-url");
assert!(result.is_err());
}
#[test]
fn test_is_trusted_plugin_rejects_non_normalizable_remote() {
use super::*;
assert!(!is_trusted_plugin("cmake", "not-a-url"));
}
#[test]
fn test_is_trusted_plugin_rejects_non_registry_plugin_url() {
use super::*;
assert!(!is_trusted_plugin(
"vfox-attacker-evil",
"https://github.com/attacker/evil.git"
));
}
#[test]
fn test_is_trusted_plugin_accepts_official_registry_plugin_url() {
use super::*;
assert!(is_trusted_plugin(
"cmake",
"https://github.com/mise-plugins/vfox-cmake.git"
));
assert!(is_trusted_plugin(
"vfox-jdx-vfox-mongod",
"https://github.com/jdx/vfox-mongod.git"
));
}
#[test]
fn test_is_trusted_plugin_rejects_shorthand_mismatch() {
use super::*;
assert!(!is_trusted_plugin(
"cmake",
"https://github.com/attacker/vfox-cmake.git"
));
}
}