use std::fmt;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use eyre::{Context, Result, bail, eyre};
use indexmap::{IndexMap, IndexSet};
use serde::Deserialize;
use crate::config::Config;
use crate::http::HTTP;
use crate::ui::multi_progress_report::MultiProgressReport;
const RELEASE_BASE_URL: &str = "https://github.com/jdx/mise/releases/download";
#[derive(Clone, Debug, Default, Deserialize)]
pub struct RemoteTomlConfig {
pub source: Option<PathBuf>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub hosts: IndexMap<String, RemoteHostTomlConfig>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct RemoteHostTomlConfig {
pub host: String,
pub user: Option<String>,
pub port: Option<u16>,
pub identity_file: Option<PathBuf>,
pub source: Option<PathBuf>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub ssh_options: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
pub mise_bin: Option<PathBuf>,
pub remote_mise: Option<String>,
pub bootstrap_command: Option<String>,
}
#[derive(Clone, Debug)]
pub struct RemoteHost {
pub name: String,
pub host: String,
pub user: Option<String>,
pub port: Option<u16>,
pub identity_file: Option<PathBuf>,
pub source: PathBuf,
pub exclude: Vec<String>,
pub ssh_options: Vec<String>,
pub tags: IndexSet<String>,
pub mise_bin: Option<PathBuf>,
pub remote_mise: Option<String>,
pub bootstrap_command: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct RemoteOverrides {
pub source: Option<PathBuf>,
pub port: Option<u16>,
pub identity_file: Option<PathBuf>,
pub exclude: Vec<String>,
pub ssh_options: Vec<String>,
pub mise_bin: Option<PathBuf>,
pub remote_mise: Option<String>,
pub bootstrap_command: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct RemoteRunOptions {
pub dry_run: bool,
pub yes: bool,
pub update: bool,
pub prompt_secrets: bool,
pub force_dotfiles: bool,
pub skip: Vec<String>,
pub only: Vec<String>,
pub keep_staging: bool,
pub connect_timeout: u16,
}
#[derive(Default)]
pub struct RemoteArtifactResolver {
directory: Option<tempfile::TempDir>,
manifest: Option<ReleaseManifest>,
artifacts: IndexMap<String, PathBuf>,
official_local_verified: bool,
}
#[derive(Clone, Debug)]
struct ReleaseManifest {
checksums: std::collections::HashMap<String, String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct RemotePlatform {
os: String,
arch: String,
libc: Option<LibcFlavor>,
}
pub fn hosts_from_config(
config: &Config,
layered_excludes: &[String],
) -> Result<IndexMap<String, RemoteHost>> {
let mut hosts = IndexMap::new();
for cf in config.config_files.values() {
let Some(bootstrap) = cf.bootstrap_config() else {
continue;
};
let remote = bootstrap.remote;
let base = cf.get_path().parent().unwrap_or_else(|| Path::new("."));
let default_source = resolve_local_path(base, remote.source.as_deref())?;
for (name, host) in remote.hosts {
if hosts.contains_key(&name) {
continue;
}
let source = match host.source.as_deref() {
Some(source) => resolve_local_path(base, Some(source))?
.expect("a provided source resolves to a path"),
None => default_source.clone().unwrap_or(std::env::current_dir()?),
};
let identity_file = resolve_local_path(base, host.identity_file.as_deref())?;
let mise_bin = resolve_local_path(base, host.mise_bin.as_deref())?;
let mut exclude = default_excludes();
exclude.extend_from_slice(layered_excludes);
exclude.extend(host.exclude);
let host = RemoteHost {
name: name.clone(),
host: host.host,
user: host.user,
port: host.port,
identity_file,
source,
exclude: dedupe(exclude),
ssh_options: dedupe(host.ssh_options),
tags: host.tags.into_iter().collect(),
mise_bin,
remote_mise: host.remote_mise,
bootstrap_command: host.bootstrap_command,
};
hosts.insert(name, host);
}
}
Ok(hosts)
}
pub fn excludes_from_config(config: &Config) -> Vec<String> {
config
.config_files
.values()
.filter_map(|cf| cf.bootstrap_config())
.flat_map(|bootstrap| bootstrap.remote.exclude)
.collect()
}
pub fn ad_hoc_host(
destination: &str,
source: PathBuf,
config_excludes: &[String],
) -> Result<RemoteHost> {
let (user, host) = destination
.rsplit_once('@')
.map(|(user, host)| (Some(user.to_string()), host.to_string()))
.unwrap_or((None, destination.to_string()));
let target = RemoteHost {
name: destination.to_string(),
host,
user,
port: None,
identity_file: None,
source,
exclude: dedupe(
default_excludes()
.into_iter()
.chain(config_excludes.iter().cloned())
.collect(),
),
ssh_options: vec![],
tags: IndexSet::new(),
mise_bin: None,
remote_mise: None,
bootstrap_command: None,
};
target.validate()?;
Ok(target)
}
impl RemoteHost {
pub fn apply_overrides(&mut self, overrides: &RemoteOverrides) -> Result<()> {
if let Some(source) = &overrides.source {
self.source = absolutize(source)?;
}
if let Some(port) = overrides.port {
self.port = Some(port);
}
if let Some(identity_file) = &overrides.identity_file {
self.identity_file = Some(absolutize(&crate::file::replace_path(identity_file))?);
}
self.exclude.extend(overrides.exclude.clone());
self.exclude = dedupe(std::mem::take(&mut self.exclude));
self.ssh_options.extend(overrides.ssh_options.clone());
self.ssh_options = dedupe(std::mem::take(&mut self.ssh_options));
if overrides.mise_bin.is_some()
|| overrides.remote_mise.is_some()
|| overrides.bootstrap_command.is_some()
{
self.mise_bin = overrides
.mise_bin
.as_ref()
.map(|binary| absolutize(&crate::file::replace_path(binary)))
.transpose()?;
self.remote_mise.clone_from(&overrides.remote_mise);
self.bootstrap_command
.clone_from(&overrides.bootstrap_command);
}
self.validate()
}
pub fn destination(&self) -> String {
match &self.user {
Some(user) => format!("{user}@{}", self.host),
None => self.host.clone(),
}
}
fn validate(&self) -> Result<()> {
validate_ssh_atom("host", &self.host)?;
if let Some(user) = &self.user {
validate_ssh_atom("user", user)?;
}
if self.port == Some(0) {
bail!("remote host '{}' port must be greater than zero", self.name);
}
if !self.source.is_dir() {
bail!(
"remote host '{}' source is not a directory: {}",
self.name,
self.source.display()
);
}
if let Some(identity) = &self.identity_file
&& !identity.is_file()
{
bail!(
"remote host '{}' identity file does not exist: {}",
self.name,
identity.display()
);
}
if let Some(binary) = &self.mise_bin
&& !binary.is_file()
{
bail!(
"remote host '{}' mise binary does not exist: {}",
self.name,
binary.display()
);
}
let provisioning_strategies = [
self.mise_bin.is_some(),
self.remote_mise.is_some(),
self.bootstrap_command.is_some(),
]
.into_iter()
.filter(|configured| *configured)
.count();
if provisioning_strategies > 1 {
bail!(
"remote host '{}' must set at most one of mise_bin, remote_mise, or bootstrap_command",
self.name
);
}
for option in &self.ssh_options {
validate_value("SSH option", option)?;
}
for exclude in &self.exclude {
validate_value("archive exclude", exclude)?;
}
if let Some(remote_mise) = &self.remote_mise {
validate_remote_executable(remote_mise)?;
}
if self
.bootstrap_command
.as_ref()
.is_some_and(|command| command.contains('\0'))
{
bail!("remote host '{}' bootstrap command contains NUL", self.name);
}
Ok(())
}
}
pub async fn run(
host: &RemoteHost,
options: &RemoteRunOptions,
artifacts: &mut RemoteArtifactResolver,
) -> Result<()> {
let ssh = crate::file::which("ssh").ok_or_else(|| eyre!("required command 'ssh' not found"))?;
let tar = crate::file::which("tar").ok_or_else(|| eyre!("required command 'tar' not found"))?;
let control_directory = if cfg!(unix) {
Some(tempfile::tempdir()?)
} else {
None
};
let session = SshSession {
ssh,
host,
connect_timeout: options.connect_timeout,
control_path: control_directory
.as_ref()
.map(|directory| directory.path().join("control")),
};
info!("bootstrap remote {} ({})", host.name, host.destination());
let staging = session
.output(&["sh", "-c", staging_creation_script()])?
.trim()
.to_string();
validate_staging_path(&staging)?;
let mut result = run_staged(&session, &tar, &staging, options, artifacts).await;
if options.keep_staging {
warn!("remote staging retained on {}: {staging}", host.name);
} else if let Err(cleanup_error) = session.status(&["rm", "-rf", "--", &staging], false) {
if result.is_ok() {
result = Err(cleanup_error);
} else {
warn!(
"failed to clean remote staging on {}: {cleanup_error:#}",
host.name
);
}
}
result
}
fn staging_creation_script() -> &'static str {
r#"set -eu
staging=$(mktemp -d /tmp/mise-bootstrap.XXXXXXXXXX)
case "$staging" in
/tmp/mise-bootstrap.?*)
case "$staging" in
*[[:space:]]*) ;;
*) printf '%s\n' "$staging"; exit 0 ;;
esac
;;
esac
case "$staging" in /tmp/mise-bootstrap.?*) rmdir "$staging" 2>/dev/null || true ;; esac
printf 'mktemp returned an unsafe staging path: %s\n' "$staging" >&2
exit 1"#
}
async fn run_staged(
session: &SshSession<'_>,
tar: &Path,
staging: &str,
options: &RemoteRunOptions,
artifacts: &mut RemoteArtifactResolver,
) -> Result<()> {
let project = format!("{staging}/project");
session.status(&["mkdir", "-p", &project], false)?;
upload_source(session, tar, &project)?;
let mise = provision_mise(session, staging, &project, options.dry_run, artifacts).await?;
let mut argv = vec![
"env".to_string(),
format!("MISE_TRUSTED_CONFIG_PATHS={project}"),
mise,
"--cd".to_string(),
project,
"bootstrap".to_string(),
];
if options.dry_run {
argv.push("--dry-run".to_string());
}
if options.yes {
argv.push("--yes".to_string());
}
if options.update {
argv.push("--update".to_string());
}
if options.prompt_secrets {
argv.push("--prompt-secrets".to_string());
}
if options.force_dotfiles {
argv.push("--force-dotfiles".to_string());
}
for part in &options.skip {
argv.extend(["--skip".to_string(), part.clone()]);
}
for part in &options.only {
argv.extend(["--only".to_string(), part.clone()]);
}
let argv = argv.iter().map(String::as_str).collect::<Vec<_>>();
session.status(&argv, true)
}
fn upload_source(session: &SshSession<'_>, tar: &Path, project: &str) -> Result<()> {
let temporary = tempfile::tempdir()?;
let archive = temporary.path().join("source.tar.gz");
let mut command = Command::new(tar);
command.args(["-czf"]);
command.arg(&archive);
for exclude in &session.host.exclude {
command.arg(format!("--exclude={exclude}"));
}
let status = command
.args(["-C"])
.arg(&session.host.source)
.arg(".")
.status()?;
if !status.success() {
bail!("failed to archive remote source with {status}");
}
session.status_with_stdin(&["tar", "-xzf", "-", "-C", project], File::open(archive)?)
}
async fn provision_mise(
session: &SshSession<'_>,
staging: &str,
project: &str,
dry_run: bool,
artifacts: &mut RemoteArtifactResolver,
) -> Result<String> {
if let Some(remote_mise) = &session.host.remote_mise {
let remote_mise = resolve_configured_remote_mise(session, remote_mise, project)
.wrap_err_with(|| {
format!(
"remote host '{}' has an invalid remote_mise value",
session.host.name
)
})?;
session.status(&[&remote_mise, "version"], false)?;
return Ok(remote_mise);
}
if let Some(command) = &session.host.bootstrap_command {
if dry_run {
let mise = resolve_remote_mise(session).wrap_err_with(|| {
format!(
"remote host '{}' has no existing mise executable; --dry-run does not execute bootstrap_command, so install mise first or set remote_mise or mise_bin",
session.host.name
)
})?;
session.status(&[&mise, "version"], false)?;
return Ok(mise);
}
let before = discover_remote_mise_candidates(session)?;
let before_identities = remote_mise_candidate_identities(session, &before);
let candidates_file = format!("{staging}/mise-candidates");
let lookup = remote_mise_candidate_union_script();
let script = format!(
"set -e\n{command}\n{lookup} > {}",
shell_quote(&candidates_file),
);
session.status(&["sh", "-lc", &script], true)?;
let candidates = session.output(&["cat", &candidates_file])?;
let candidates = parse_remote_mise_candidates(&candidates)?;
let after_identities = remote_mise_candidate_identities(session, &candidates);
let mise =
select_bootstrapped_mise(&before, &before_identities, &candidates, &after_identities)?;
session.status(&[&mise, "version"], false)?;
return Ok(mise);
}
let binary = if let Some(binary) = &session.host.mise_bin {
binary.clone()
} else {
let platform = detect_remote_platform(session)?;
let local_os = normalize_os(std::env::consts::OS);
let local_arch = normalize_arch(std::env::consts::ARCH);
let local = std::env::current_exe()?;
let local_incompatibility = if platform.os != local_os || platform.arch != local_arch {
Some(format!(
"local mise is {local_os}/{local_arch}, while the remote target is {}",
platform.description()
))
} else {
validate_default_binary_compatibility(session, &local, &platform.os)
.err()
.map(|error| format!("{error:#}"))
};
if local_incompatibility.is_none() {
local
} else {
artifacts
.resolve(&platform, &local)
.await
.wrap_err_with(|| {
format!(
"local mise could not run on remote host '{}' ({}) because {}; official mise {} artifact fallback also failed",
session.host.name,
platform.description(),
local_incompatibility.expect("incompatibility was identified"),
env!("CARGO_PKG_VERSION"),
)
})?
}
};
let remote = format!("{staging}/mise");
session.upload_executable(&binary, &remote)?;
session.status(&[&remote, "version"], false).wrap_err_with(|| {
format!(
"uploaded local mise cannot run on remote host '{}'; set mise_bin, remote_mise, or bootstrap_command",
session.host.name
)
})?;
Ok(remote)
}
fn resolve_remote_mise(session: &SshSession<'_>) -> Result<String> {
let script = remote_mise_output_script();
let mise = session.output(&["sh", "-lc", &script])?;
validated_remote_command_output(&mise)
}
fn resolve_configured_remote_mise(
session: &SshSession<'_>,
command: &str,
project: &str,
) -> Result<String> {
validate_remote_executable(command)?;
if !command.contains('/') {
return resolve_login_path_executable(session, command);
}
let remote_home = if command.starts_with("~/") {
let output = session.output(&["sh", "-lc", "printf '%s\\n' \"$HOME\""])?;
Some(validated_absolute_remote_path_output(
&output,
"remote login home",
)?)
} else {
None
};
resolve_remote_mise_path(command, project, remote_home.as_deref())
}
fn resolve_login_path_executable(session: &SshSession<'_>, command: &str) -> Result<String> {
let script = format!(
r#"command_path=$(command -v {} 2>/dev/null || true)
case "$command_path" in
/*) printf '%s\n' "$command_path" ;;
*) printf 'executable not found on remote login PATH: %s\n' {} >&2; exit 127 ;;
esac"#,
shell_quote(command),
shell_quote(command),
);
let output = session.output(&["sh", "-lc", &script])?;
validated_absolute_remote_path_output(&output, "remote login executable")
}
fn resolve_remote_mise_path(
command: &str,
project: &str,
remote_home: Option<&str>,
) -> Result<String> {
if command.starts_with('/') {
return Ok(command.to_string());
}
if let Some(suffix) = command.strip_prefix("~/") {
if suffix.is_empty() {
bail!("remote mise path does not name an executable: {command:?}");
}
let home = remote_home.ok_or_else(|| eyre!("remote login home was not resolved"))?;
return Ok(format!("{}/{suffix}", home.trim_end_matches('/')));
}
let mut components = Vec::new();
for component in command.split('/') {
match component {
"" | "." => {}
".." => {
if components.pop().is_none() {
bail!("relative remote mise path escapes the staged project: {command:?}");
}
}
component => components.push(component),
}
}
if components.is_empty() {
bail!("relative remote mise path does not name an executable: {command:?}");
}
Ok(format!("{project}/{}", components.join("/")))
}
fn remote_mise_find_script() -> &'static str {
r#"mise_path=$(command -v mise 2>/dev/null || true)
case "$mise_path" in /*) ;; *) mise_path= ;; esac
if [ -z "$mise_path" ]; then
for candidate in "$HOME/.local/bin/mise" "$HOME/.local/share/mise/bin/mise" "$HOME/.cargo/bin/mise" /usr/local/bin/mise /opt/homebrew/bin/mise; do
if [ -x "$candidate" ]; then
mise_path=$candidate
break
fi
done
fi"#
}
fn remote_mise_output_script() -> String {
format!(
"{}\nif [ -z \"$mise_path\" ]; then\n echo \"mise executable not found after bootstrap_command\" >&2\n exit 127\nfi\nprintf '%s\\n' \"$mise_path\"",
remote_mise_find_script()
)
}
fn discover_remote_mise_candidates(session: &SshSession<'_>) -> Result<Vec<String>> {
let script = remote_mise_candidate_union_script();
let output = session.output(&["sh", "-lc", &script])?;
parse_remote_mise_candidates(&output)
}
fn remote_mise_candidate_union_script() -> String {
let lookup = remote_mise_candidates_script();
let fresh_login_lookup = shell_words::join(["sh", "-lc", lookup]);
format!("{{\n{lookup}\n{fresh_login_lookup}\n}}")
}
fn remote_mise_candidates_script() -> &'static str {
r#"set -f
emit_mise_candidate() {
case "$1" in
/*) if [ -x "$1" ]; then printf '%s\000' "$1"; fi ;;
esac
}
old_ifs=$IFS
IFS=:
for directory in $PATH; do
if [ -z "$directory" ]; then directory=.; fi
emit_mise_candidate "$directory/mise"
done
IFS=$old_ifs
for candidate in "$HOME/.local/bin/mise" "$HOME/.local/share/mise/bin/mise" "$HOME/.cargo/bin/mise" /usr/local/bin/mise /opt/homebrew/bin/mise; do
emit_mise_candidate "$candidate"
done"#
}
fn parse_remote_mise_candidates(output: &str) -> Result<Vec<String>> {
if !output.is_empty() && !output.ends_with('\0') {
bail!("remote mise candidate discovery returned a truncated response");
}
output
.split_terminator('\0')
.map(validated_remote_command)
.collect::<Result<IndexSet<_>>>()
.map(IndexSet::into_iter)
.map(Iterator::collect)
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct RemoteMiseIdentity {
version: String,
fingerprint: String,
}
fn remote_mise_candidate_identities(
session: &SshSession<'_>,
candidates: &[String],
) -> IndexMap<String, Option<RemoteMiseIdentity>> {
candidates
.iter()
.map(|candidate| {
let identity = session
.output(&[candidate, "version"])
.and_then(|version| {
remote_mise_fingerprint(session, candidate).map(|fingerprint| {
RemoteMiseIdentity {
version: version.trim().to_string(),
fingerprint,
}
})
})
.ok();
(candidate.clone(), identity)
})
.collect()
}
fn remote_mise_fingerprint(session: &SshSession<'_>, candidate: &str) -> Result<String> {
let candidate = shell_quote(candidate);
let script = format!(
"if command -v sha256sum >/dev/null 2>&1; then sha256sum {candidate}; elif command -v shasum >/dev/null 2>&1; then shasum -a 256 {candidate}; else cksum {candidate}; fi"
);
Ok(session.output(&["sh", "-c", &script])?.trim().to_string())
}
fn select_bootstrapped_mise(
before: &[String],
before_identities: &IndexMap<String, Option<RemoteMiseIdentity>>,
after: &[String],
after_identities: &IndexMap<String, Option<RemoteMiseIdentity>>,
) -> Result<String> {
let new_candidates = after
.iter()
.filter(|candidate| !before.contains(candidate))
.collect::<Vec<_>>();
if let Some(candidate) = select_unique_remote_mise_candidate(new_candidates, "new")? {
return Ok(candidate);
}
let changed_candidates = after
.iter()
.filter(|candidate| {
before_identities.get(*candidate) != after_identities.get(*candidate)
&& after_identities
.get(*candidate)
.is_some_and(Option::is_some)
})
.collect::<Vec<_>>();
if let Some(candidate) = select_unique_remote_mise_candidate(changed_candidates, "changed")? {
return Ok(candidate);
}
let version_candidates = after
.iter()
.filter(|candidate| {
after_identities
.get(*candidate)
.and_then(Option::as_ref)
.and_then(|identity| identity.version.split_whitespace().next())
.is_some_and(|version| {
version == env!("CARGO_PKG_VERSION")
|| version == concat!(env!("CARGO_PKG_VERSION"), "-DEBUG")
})
})
.collect::<Vec<_>>();
if let Some(candidate) =
select_unique_remote_mise_candidate(version_candidates, "version-matching")?
{
return Ok(candidate);
}
if let [candidate] = after {
return Ok(candidate.clone());
}
if after.is_empty() {
bail!("mise executable not found after bootstrap_command");
}
bail!(
"bootstrap_command left multiple unchanged mise executables and the installed path is ambiguous: {}; set remote_mise or mise_bin explicitly",
after.join(", ")
)
}
fn select_unique_remote_mise_candidate(
candidates: Vec<&String>,
kind: &str,
) -> Result<Option<String>> {
match candidates.as_slice() {
[] => Ok(None),
[candidate] => Ok(Some((*candidate).clone())),
_ => bail!(
"bootstrap_command left multiple {kind} mise executables and the installed path is ambiguous: {}; set remote_mise or mise_bin explicitly",
candidates
.iter()
.map(|candidate| candidate.as_str())
.collect::<Vec<_>>()
.join(", ")
),
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LibcFlavor {
Glibc,
Musl,
}
impl RemotePlatform {
fn description(&self) -> String {
match self.libc {
Some(libc) => format!("{}/{}/{libc}", self.os, self.arch),
None => format!("{}/{}", self.os, self.arch),
}
}
fn release_asset_name(&self) -> Result<String> {
release_asset_name(&self.os, &self.arch, self.libc)
}
}
impl fmt::Display for LibcFlavor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Glibc => f.write_str("glibc"),
Self::Musl => f.write_str("musl"),
}
}
}
impl ReleaseManifest {
fn verified(contents: &str, signature: &str) -> Result<Self> {
crate::minisign::verify(
&crate::minisign::MISE_PUB_KEY,
contents.as_bytes(),
signature,
)
.wrap_err("mise release checksum signature is invalid")?;
let checksums = crate::hash::parse_shasums(contents);
if checksums.is_empty() {
bail!("signed mise release checksum manifest is empty");
}
if checksums.values().any(|checksum| {
checksum.len() != 64 || !checksum.bytes().all(|byte| byte.is_ascii_hexdigit())
}) {
bail!("signed mise release checksum manifest contains an invalid SHA-256 checksum");
}
Ok(Self { checksums })
}
fn checksum(&self, asset: &str) -> Result<&str> {
self.checksums
.get(asset)
.or_else(|| self.checksums.get(&format!("./{asset}")))
.map(String::as_str)
.ok_or_else(|| eyre!("signed mise release manifest does not contain {asset}"))
}
}
impl RemoteArtifactResolver {
async fn resolve(&mut self, platform: &RemotePlatform, local: &Path) -> Result<PathBuf> {
let asset = platform.release_asset_name()?;
if let Some(path) = self.artifacts.get(&asset) {
return Ok(path.clone());
}
self.ensure_official_local(local).await?;
let checksum = self.manifest().await?.checksum(&asset)?.to_string();
if self.directory.is_none() {
self.directory = Some(tempfile::tempdir()?);
}
let path = self
.directory
.as_ref()
.expect("artifact directory was initialized")
.path()
.join(&asset);
let progress = MultiProgressReport::get().add("mise bootstrap");
progress.set_message(format!("downloading {asset}"));
if let Err(error) = HTTP
.download_file(release_url(&asset), &path, Some(progress.as_ref()))
.await
{
progress.abandon();
return Err(error).wrap_err_with(|| {
format!("failed to download official mise release artifact {asset}")
});
}
progress.set_message(format!("verifying {asset}"));
if let Err(error) =
crate::hash::ensure_checksum(&path, &checksum, Some(progress.as_ref()), "sha256")
{
progress.abandon();
return Err(error).wrap_err_with(|| {
format!("official mise release artifact {asset} failed verification")
});
}
progress.finish();
info!(
"using signed official mise {} artifact {asset}",
env!("CARGO_PKG_VERSION")
);
self.artifacts.insert(asset, path.clone());
Ok(path)
}
async fn ensure_official_local(&mut self, local: &Path) -> Result<()> {
if self.official_local_verified {
return Ok(());
}
if cfg!(debug_assertions) {
bail!(
"automatic cross-platform provisioning is unavailable from a debug mise build; set mise_bin, remote_mise, or bootstrap_command"
);
}
let local_os = normalize_os(std::env::consts::OS);
let local_arch = normalize_arch(std::env::consts::ARCH);
let candidates = official_release_assets(&local_os, &local_arch)?;
let actual = crate::hash::file_hash_sha256(local, None)?;
let manifest = self.manifest().await?;
let official = candidates.iter().any(|asset| {
manifest
.checksum(asset)
.is_ok_and(|expected| expected.eq_ignore_ascii_case(&actual))
});
if !official {
bail!(
"automatic cross-platform provisioning refuses to replace a custom mise build with an official binary because {} does not match the signed mise {} release checksums; set mise_bin, remote_mise, or bootstrap_command",
local.display(),
env!("CARGO_PKG_VERSION")
);
}
self.official_local_verified = true;
Ok(())
}
async fn manifest(&mut self) -> Result<&ReleaseManifest> {
if self.manifest.is_none() {
let manifest_url = release_url("SHASUMS256.txt");
let signature_url = release_url("SHASUMS256.txt.minisig");
let (contents, signature) = tokio::try_join!(
HTTP.get_text_cached(&manifest_url),
HTTP.get_text_cached(&signature_url)
)
.wrap_err_with(|| {
format!(
"failed to fetch signed mise {} release checksums",
env!("CARGO_PKG_VERSION")
)
})?;
self.manifest = Some(ReleaseManifest::verified(&contents, &signature)?);
}
Ok(self.manifest.as_ref().expect("release manifest was loaded"))
}
}
fn release_url(filename: &str) -> String {
let version = env!("CARGO_PKG_VERSION");
format!("{RELEASE_BASE_URL}/v{version}/{filename}")
}
fn release_asset_name(os: &str, arch: &str, libc: Option<LibcFlavor>) -> Result<String> {
let release_arch = match arch {
"x86_64" => "x64",
"aarch64" => "arm64",
"armv7" => "armv7",
_ => {
bail!(
"mise {} has no official precompiled artifact for {os}/{arch}; set mise_bin, remote_mise, or bootstrap_command",
env!("CARGO_PKG_VERSION")
)
}
};
let suffix = match os {
"macos" if matches!(arch, "x86_64" | "aarch64") => {
if libc.is_some() {
bail!("macOS release targets cannot declare a libc family");
}
format!("macos-{release_arch}")
}
"linux" if matches!(arch, "x86_64" | "aarch64" | "armv7") => match libc {
Some(LibcFlavor::Glibc) => format!("linux-{release_arch}"),
Some(LibcFlavor::Musl) => format!("linux-{release_arch}-musl"),
None => bail!("Linux release targets require a detected libc family"),
},
_ => {
bail!(
"mise {} has no official precompiled artifact for {os}/{arch}; set mise_bin, remote_mise, or bootstrap_command",
env!("CARGO_PKG_VERSION")
)
}
};
Ok(format!("mise-v{}-{suffix}", env!("CARGO_PKG_VERSION")))
}
fn official_release_assets(os: &str, arch: &str) -> Result<Vec<String>> {
match os {
"linux" => [LibcFlavor::Glibc, LibcFlavor::Musl]
.into_iter()
.map(|libc| release_asset_name(os, arch, Some(libc)))
.collect(),
"macos" => Ok(vec![release_asset_name(os, arch, None)?]),
"windows" => {
let release_arch = match arch {
"x86_64" => "x64",
"aarch64" => "arm64",
_ => {
bail!(
"mise {} has no official precompiled artifact for {os}/{arch}; set mise_bin, remote_mise, or bootstrap_command",
env!("CARGO_PKG_VERSION")
)
}
};
Ok(vec![format!(
"mise-v{}-windows-{release_arch}.exe",
env!("CARGO_PKG_VERSION")
)])
}
_ => bail!(
"mise {} has no official precompiled artifact for {os}/{arch}; set mise_bin, remote_mise, or bootstrap_command",
env!("CARGO_PKG_VERSION")
),
}
}
fn detect_remote_platform(session: &SshSession<'_>) -> Result<RemotePlatform> {
let output = session.output(&["sh", "-c", remote_platform_script()])?;
parse_remote_platform(&output).wrap_err_with(|| {
format!(
"could not detect the platform for remote host '{}'; set mise_bin, remote_mise, or bootstrap_command",
session.host.name
)
})
}
fn remote_platform_script() -> &'static str {
r#"os=$(uname -s)
arch=$(uname -m)
printf '%s\n%s\n' "$os" "$arch"
case "$os" in
Linux|linux)
libc=
if command -v getconf >/dev/null 2>&1; then
libc_output=$(getconf GNU_LIBC_VERSION 2>&1 || true)
case "$libc_output" in *glibc*|*GLIBC*) libc=glibc ;; esac
fi
if [ -z "$libc" ] && command -v ldd >/dev/null 2>&1; then
libc_output=$(ldd --version 2>&1 || true)
case "$libc_output" in
*musl*|*MUSL*) libc=musl ;;
*glibc*|*GLIBC*|*GNU\ libc*|*GNU\ C\ Library*) libc=glibc ;;
esac
fi
if [ -z "$libc" ]; then
for loader in /lib/ld-musl-*.so.1 /usr/lib/ld-musl-*.so.1; do
if [ -e "$loader" ]; then libc=musl; break; fi
done
fi
printf '%s\n' "${libc:-unknown}"
;;
*) printf '%s\n' none ;;
esac"#
}
fn parse_remote_platform(output: &str) -> Result<RemotePlatform> {
let mut lines = output.lines();
let os = normalize_os(lines.next().unwrap_or_default());
let arch = normalize_arch(lines.next().unwrap_or_default());
let libc = match (os.as_str(), lines.next()) {
("linux", Some("glibc")) => Some(LibcFlavor::Glibc),
("linux", Some("musl")) => Some(LibcFlavor::Musl),
("linux", _) => bail!("remote Linux libc family could not be identified"),
(_, Some("none")) => None,
_ => bail!("remote platform response is incomplete"),
};
if os.is_empty() || arch.is_empty() || lines.next().is_some() {
bail!("remote platform response is invalid");
}
Ok(RemotePlatform { os, arch, libc })
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct AbiVersion(Vec<u32>);
impl fmt::Display for AbiVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(
&self
.0
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join("."),
)
}
}
fn validate_default_binary_compatibility(
session: &SshSession<'_>,
binary: &Path,
remote_os: &str,
) -> Result<()> {
if remote_os != "linux" {
return Ok(());
}
let binary_bytes = std::fs::read(binary)?;
let Some(interpreter) = elf_interpreter(&binary_bytes)? else {
return Ok(());
};
let local_output = Command::new(&interpreter).arg("--version").output()?;
let local_libc = parse_libc_flavor(&combined_output(&local_output)).ok_or_else(|| {
eyre!(
"could not identify the libc used by local mise loader {interpreter}; set mise_bin, remote_mise, or bootstrap_command"
)
})?;
let check = format!(
"if test -x {loader}; then {loader} --version 2>&1 || true; else printf '%s\\n' MISE_LOADER_MISSING; fi",
loader = shell_quote(&interpreter),
);
let remote_output = session.output(&["sh", "-c", &check])?;
if remote_output
.lines()
.any(|line| line == "MISE_LOADER_MISSING")
{
bail!(
"remote host '{}' does not provide the dynamic loader required by local mise: {interpreter}; set mise_bin, remote_mise, or bootstrap_command",
session.host.name
);
}
let remote_libc = parse_libc_flavor(&remote_output).ok_or_else(|| {
eyre!(
"could not identify the libc provided by remote loader {interpreter} on '{}'; set mise_bin, remote_mise, or bootstrap_command",
session.host.name
)
})?;
if local_libc != remote_libc {
bail!(
"remote host '{}' libc {remote_libc:?} is incompatible with local mise libc {local_libc:?}; set mise_bin, remote_mise, or bootstrap_command",
session.host.name
);
}
let required = match local_libc {
LibcFlavor::Glibc => max_required_glibc_version(&binary_bytes).ok_or_else(|| {
eyre!(
"could not determine the glibc ABI required by local mise; set mise_bin, remote_mise, or bootstrap_command"
)
})?,
LibcFlavor::Musl => parse_musl_runtime_version(&combined_output(&local_output))
.ok_or_else(|| {
eyre!(
"could not determine the musl version used by local mise loader {interpreter}; set mise_bin, remote_mise, or bootstrap_command"
)
})?,
};
let available = match remote_libc {
LibcFlavor::Glibc => parse_glibc_runtime_version(&remote_output),
LibcFlavor::Musl => parse_musl_runtime_version(&remote_output),
}
.ok_or_else(|| {
eyre!(
"could not determine the {remote_libc:?} version provided by remote loader {interpreter} on '{}'; set mise_bin, remote_mise, or bootstrap_command",
session.host.name
)
})?;
if available < required {
bail!(
"remote host '{}' provides {remote_libc:?} {available}, but local mise requires {remote_libc:?} {required}; set mise_bin, remote_mise, or bootstrap_command",
session.host.name
);
}
Ok(())
}
fn max_required_glibc_version(bytes: &[u8]) -> Option<AbiVersion> {
const PREFIX: &[u8] = b"GLIBC_";
bytes
.windows(PREFIX.len())
.enumerate()
.filter_map(|(offset, window)| {
(window == PREFIX)
.then(|| parse_abi_version(&bytes[offset + PREFIX.len()..]))
.flatten()
})
.max()
}
fn parse_glibc_runtime_version(output: &str) -> Option<AbiVersion> {
let lower = output.to_ascii_lowercase();
["glibc", "gnu libc"].into_iter().find_map(|marker| {
lower.match_indices(marker).find_map(|(offset, _)| {
let suffix = &output.as_bytes()[offset + marker.len()..];
let digit = suffix.iter().position(u8::is_ascii_digit)?;
parse_abi_version(&suffix[digit..])
})
})
}
fn parse_musl_runtime_version(output: &str) -> Option<AbiVersion> {
output.lines().find_map(|line| {
let line = line.trim();
let suffix = line
.get(..7)
.filter(|prefix| prefix.eq_ignore_ascii_case("version"))
.and_then(|_| line.get(7..))?;
if !suffix.starts_with([' ', '\t', ':']) {
return None;
}
let digit = suffix.as_bytes().iter().position(u8::is_ascii_digit)?;
parse_abi_version(&suffix.as_bytes()[digit..])
})
}
fn parse_abi_version(bytes: &[u8]) -> Option<AbiVersion> {
let mut components = Vec::new();
let mut offset = 0;
loop {
let start = offset;
while bytes.get(offset).is_some_and(u8::is_ascii_digit) {
offset += 1;
}
if start == offset {
break;
}
let component = std::str::from_utf8(&bytes[start..offset])
.ok()?
.parse()
.ok()?;
components.push(component);
if bytes.get(offset) != Some(&b'.') {
break;
}
offset += 1;
}
(!components.is_empty()).then_some(AbiVersion(components))
}
fn elf_interpreter(bytes: &[u8]) -> Result<Option<String>> {
const ELF_MAGIC: &[u8] = b"\x7fELF";
const PT_INTERP: u64 = 3;
if !bytes.starts_with(ELF_MAGIC) {
bail!("local mise is not an ELF executable");
}
let class = *bytes.get(4).ok_or_else(|| eyre!("truncated ELF header"))?;
let little_endian = match bytes.get(5) {
Some(1) => true,
Some(2) => false,
_ => bail!("unsupported ELF byte order"),
};
let (program_offset, entry_size, entry_count, offset_field, size_field) = match class {
1 => (
read_elf_int(bytes, 28, 4, little_endian)?,
read_elf_int(bytes, 42, 2, little_endian)?,
read_elf_int(bytes, 44, 2, little_endian)?,
4,
16,
),
2 => (
read_elf_int(bytes, 32, 8, little_endian)?,
read_elf_int(bytes, 54, 2, little_endian)?,
read_elf_int(bytes, 56, 2, little_endian)?,
8,
32,
),
_ => bail!("unsupported ELF class"),
};
let program_offset = usize::try_from(program_offset)?;
let entry_size = usize::try_from(entry_size)?;
let entry_count = usize::try_from(entry_count)?;
for index in 0..entry_count {
let start = program_offset
.checked_add(
index
.checked_mul(entry_size)
.ok_or_else(|| eyre!("invalid ELF program headers"))?,
)
.ok_or_else(|| eyre!("invalid ELF program headers"))?;
if read_elf_int(bytes, start, 4, little_endian)? != PT_INTERP {
continue;
}
let offset = usize::try_from(read_elf_int(
bytes,
start + offset_field,
if class == 1 { 4 } else { 8 },
little_endian,
)?)?;
let size = usize::try_from(read_elf_int(
bytes,
start + size_field,
if class == 1 { 4 } else { 8 },
little_endian,
)?)?;
let value = bytes
.get(
offset
..offset
.checked_add(size)
.ok_or_else(|| eyre!("invalid ELF interpreter"))?,
)
.ok_or_else(|| eyre!("truncated ELF interpreter"))?;
let value = value.strip_suffix(&[0]).unwrap_or(value);
let interpreter = String::from_utf8(value.to_vec())?;
if !interpreter.starts_with('/') || interpreter.contains('\0') || interpreter.contains('\n')
{
bail!("unsafe ELF interpreter path: {interpreter:?}");
}
return Ok(Some(interpreter));
}
Ok(None)
}
fn read_elf_int(bytes: &[u8], offset: usize, size: usize, little_endian: bool) -> Result<u64> {
let value = bytes
.get(
offset
..offset
.checked_add(size)
.ok_or_else(|| eyre!("invalid ELF field"))?,
)
.ok_or_else(|| eyre!("truncated ELF field"))?;
let mut padded = [0_u8; 8];
if little_endian {
padded[..size].copy_from_slice(value);
Ok(u64::from_le_bytes(padded))
} else {
padded[8 - size..].copy_from_slice(value);
Ok(u64::from_be_bytes(padded))
}
}
fn combined_output(output: &Output) -> String {
format!(
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
}
fn parse_libc_flavor(output: &str) -> Option<LibcFlavor> {
let lower = output.to_ascii_lowercase();
if lower.contains("musl") {
Some(LibcFlavor::Musl)
} else if lower.contains("glibc") || lower.contains("gnu libc") {
Some(LibcFlavor::Glibc)
} else {
None
}
}
struct SshSession<'a> {
ssh: PathBuf,
host: &'a RemoteHost,
connect_timeout: u16,
control_path: Option<PathBuf>,
}
impl SshSession<'_> {
fn args(&self, tty: bool, remote_argv: &[&str]) -> Vec<String> {
let mut args = vec![
"-o".to_string(),
format!("ConnectTimeout={}", self.connect_timeout),
];
if let Some(control_path) = &self.control_path {
args.extend([
"-o".to_string(),
"ControlMaster=auto".to_string(),
"-o".to_string(),
"ControlPersist=60".to_string(),
"-o".to_string(),
format!("ControlPath={}", control_path.display()),
]);
}
if !console::user_attended_stderr() {
args.extend(["-o".to_string(), "BatchMode=yes".to_string()]);
}
if tty && console::user_attended_stderr() {
args.push("-tt".to_string());
}
if let Some(port) = self.host.port {
args.extend(["-p".to_string(), port.to_string()]);
}
if let Some(identity) = &self.host.identity_file {
args.push("-i".to_string());
args.push(identity.to_string_lossy().to_string());
}
for option in &self.host.ssh_options {
args.extend(["-o".to_string(), option.clone()]);
}
args.push(self.host.destination());
args.push(shell_words::join(remote_argv));
args
}
fn output(&self, remote_argv: &[&str]) -> Result<String> {
let args = self.args(false, remote_argv);
info!("$ {} {}", self.ssh.display(), shell_words::join(&args));
let output = Command::new(&self.ssh).args(&args).output()?;
checked_output(output, &self.host.name)
}
fn status(&self, remote_argv: &[&str], tty: bool) -> Result<()> {
let args = self.args(tty, remote_argv);
info!("$ {} {}", self.ssh.display(), shell_words::join(&args));
let status = Command::new(&self.ssh).args(args).status()?;
if !status.success() {
bail!(
"remote command on '{}' failed with {status}",
self.host.name
);
}
Ok(())
}
fn status_with_stdin(&self, remote_argv: &[&str], input: File) -> Result<()> {
let args = self.args(false, remote_argv);
info!("$ {} {}", self.ssh.display(), shell_words::join(&args));
let status = Command::new(&self.ssh)
.args(args)
.stdin(Stdio::from(input))
.status()?;
if !status.success() {
bail!("remote upload to '{}' failed with {status}", self.host.name);
}
Ok(())
}
fn upload_executable(&self, local: &Path, remote: &str) -> Result<()> {
let file = File::open(local)
.wrap_err_with(|| format!("failed to open mise binary {}", local.display()))?;
self.status_with_stdin(
&[
"sh",
"-c",
&format!(
"cat > {} && chmod 700 {}",
shell_quote(remote),
shell_quote(remote)
),
],
file,
)
}
fn close(&self) {
let Some(control_path) = &self.control_path else {
return;
};
let status = Command::new(&self.ssh)
.args(["-S"])
.arg(control_path)
.args(["-O", "exit"])
.arg(self.host.destination())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
if status.is_ok_and(|status| !status.success()) {
debug!(
"SSH control connection for {} was already closed",
self.host.name
);
}
}
}
impl Drop for SshSession<'_> {
fn drop(&mut self) {
self.close();
}
}
fn checked_output(output: Output, name: &str) -> Result<String> {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"remote command on '{name}' failed with {}: {}",
output.status,
stderr.trim()
);
}
Ok(String::from_utf8(output.stdout)?)
}
fn resolve_local_path(base: &Path, path: Option<&Path>) -> Result<Option<PathBuf>> {
path.map(|path| {
let path = crate::file::replace_path(path);
let path = if path.is_absolute() {
path
} else {
base.join(path)
};
absolutize(&path)
})
.transpose()
}
fn absolutize(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(std::env::current_dir()?.join(path))
}
}
fn validate_ssh_atom(kind: &str, value: &str) -> Result<()> {
validate_value(kind, value)?;
if value.starts_with('-') || value.chars().any(char::is_whitespace) {
bail!("invalid remote {kind}: {value}");
}
Ok(())
}
fn validate_value(kind: &str, value: &str) -> Result<()> {
if value.is_empty() || value.contains('\0') {
bail!("remote {kind} cannot be empty or contain NUL");
}
Ok(())
}
fn validate_staging_path(path: &str) -> Result<()> {
if !path.starts_with("/tmp/mise-bootstrap.")
|| path["/tmp/mise-bootstrap.".len()..].is_empty()
|| path.chars().any(char::is_whitespace)
{
bail!("remote mktemp returned an unsafe staging path: {path:?}");
}
Ok(())
}
fn validated_remote_command(command: &str) -> Result<String> {
if !command.starts_with('/') || command.contains(['\0', '\n', '\r']) {
bail!("bootstrap_command returned an unsafe mise path: {command:?}");
}
Ok(command.to_string())
}
fn validated_remote_command_output(output: &str) -> Result<String> {
validated_remote_command(output.strip_suffix('\n').unwrap_or(output))
}
fn validated_absolute_remote_path_output(output: &str, kind: &str) -> Result<String> {
let path = output.strip_suffix('\n').unwrap_or(output);
if !path.starts_with('/') || path.contains(['\0', '\n', '\r']) {
bail!("{kind} returned an unsafe absolute path: {path:?}");
}
Ok(path.to_string())
}
fn validate_remote_executable(command: &str) -> Result<()> {
validate_value("mise command", command)?;
let is_path = command.contains('/');
if command.starts_with('-')
|| command.contains(['\n', '\r'])
|| (!is_path && command.chars().any(char::is_whitespace))
{
bail!("remote mise command must be an executable name or path: {command:?}");
}
Ok(())
}
fn shell_quote(value: &str) -> String {
shell_words::join([value])
}
fn normalize_os(value: &str) -> String {
match value.trim().to_ascii_lowercase().as_str() {
"darwin" | "macos" => "macos".to_string(),
"linux" => "linux".to_string(),
other => other.to_string(),
}
}
fn normalize_arch(value: &str) -> String {
match value.trim().to_ascii_lowercase().as_str() {
"x86_64" | "amd64" => "x86_64".to_string(),
"aarch64" | "arm64" => "aarch64".to_string(),
"armv7l" | "armv7" => "armv7".to_string(),
"armv6l" | "armv6" => "armv6".to_string(),
other => other.to_string(),
}
}
fn default_excludes() -> Vec<String> {
vec![
".git".to_string(),
"target".to_string(),
"node_modules".to_string(),
]
}
fn dedupe(values: Vec<String>) -> Vec<String> {
values
.into_iter()
.collect::<IndexSet<_>>()
.into_iter()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ad_hoc_destinations() {
let source = std::env::current_dir().unwrap();
let host = ad_hoc_host("ubuntu@example.com", source, &[]).unwrap();
assert_eq!(host.user.as_deref(), Some("ubuntu"));
assert_eq!(host.host, "example.com");
assert_eq!(host.destination(), "ubuntu@example.com");
assert!(ad_hoc_host("-oProxyCommand=bad", std::env::current_dir().unwrap(), &[]).is_err());
}
#[test]
fn validates_remote_staging_paths() {
assert!(validate_staging_path("/tmp/mise-bootstrap.abc123").is_ok());
assert!(validate_staging_path("/tmp/mise-bootstrap.").is_err());
assert!(validate_staging_path("/tmp/other").is_err());
assert!(validate_staging_path("/tmp/mise-bootstrap.a b").is_err());
}
#[test]
fn normalizes_platform_names() {
assert_eq!(normalize_os("Darwin"), "macos");
assert_eq!(normalize_arch("amd64"), "x86_64");
assert_eq!(normalize_arch("arm64"), "aarch64");
}
#[test]
fn provisioning_override_replaces_inventory_strategy() {
let mut host = ad_hoc_host("example.com", std::env::current_dir().unwrap(), &[]).unwrap();
host.remote_mise = Some("mise".to_string());
host.apply_overrides(&RemoteOverrides {
mise_bin: Some(std::env::current_exe().unwrap()),
..Default::default()
})
.unwrap();
assert!(host.mise_bin.is_some());
assert!(host.remote_mise.is_none());
assert!(host.bootstrap_command.is_none());
host.bootstrap_command = Some("install-mise".to_string());
assert!(host.validate().is_err());
}
#[test]
fn reads_dynamic_loader_from_elf() {
let interpreter = b"/lib64/ld-linux-x86-64.so.2\0";
let mut elf = vec![0_u8; 256];
elf[..6].copy_from_slice(b"\x7fELF\x02\x01");
elf[32..40].copy_from_slice(&64_u64.to_le_bytes());
elf[54..56].copy_from_slice(&56_u16.to_le_bytes());
elf[56..58].copy_from_slice(&1_u16.to_le_bytes());
elf[64..68].copy_from_slice(&3_u32.to_le_bytes());
elf[72..80].copy_from_slice(&128_u64.to_le_bytes());
elf[96..104].copy_from_slice(&(interpreter.len() as u64).to_le_bytes());
elf[128..128 + interpreter.len()].copy_from_slice(interpreter);
assert_eq!(
elf_interpreter(&elf).unwrap().as_deref(),
Some("/lib64/ld-linux-x86-64.so.2")
);
elf[64..68].copy_from_slice(&1_u32.to_le_bytes());
assert_eq!(elf_interpreter(&elf).unwrap(), None);
}
#[test]
fn identifies_libc_flavors_without_assuming_binary_requirements() {
assert_eq!(
parse_libc_flavor("ld.so (Debian GLIBC 2.36) stable release version 2.36."),
Some(LibcFlavor::Glibc)
);
assert_eq!(
parse_libc_flavor("musl libc\nVersion 1.2.5"),
Some(LibcFlavor::Musl)
);
assert_eq!(parse_libc_flavor("unknown loader"), None);
}
#[test]
fn maps_every_supported_remote_release_target() {
let version = env!("CARGO_PKG_VERSION");
assert_eq!(
release_asset_name("linux", "x86_64", Some(LibcFlavor::Glibc)).unwrap(),
format!("mise-v{version}-linux-x64")
);
assert_eq!(
release_asset_name("linux", "x86_64", Some(LibcFlavor::Musl)).unwrap(),
format!("mise-v{version}-linux-x64-musl")
);
assert_eq!(
release_asset_name("linux", "aarch64", Some(LibcFlavor::Glibc)).unwrap(),
format!("mise-v{version}-linux-arm64")
);
assert_eq!(
release_asset_name("linux", "aarch64", Some(LibcFlavor::Musl)).unwrap(),
format!("mise-v{version}-linux-arm64-musl")
);
assert_eq!(
release_asset_name("linux", "armv7", Some(LibcFlavor::Glibc)).unwrap(),
format!("mise-v{version}-linux-armv7")
);
assert_eq!(
release_asset_name("linux", "armv7", Some(LibcFlavor::Musl)).unwrap(),
format!("mise-v{version}-linux-armv7-musl")
);
assert_eq!(
release_asset_name("macos", "x86_64", None).unwrap(),
format!("mise-v{version}-macos-x64")
);
assert_eq!(
release_asset_name("macos", "aarch64", None).unwrap(),
format!("mise-v{version}-macos-arm64")
);
assert!(release_asset_name("linux", "riscv64", Some(LibcFlavor::Glibc)).is_err());
assert!(release_asset_name("freebsd", "x86_64", None).is_err());
assert!(release_asset_name("linux", "x86_64", None).is_err());
}
#[test]
fn maps_official_local_release_executables_for_provenance() {
let version = env!("CARGO_PKG_VERSION");
assert_eq!(
official_release_assets("windows", "x86_64").unwrap(),
vec![format!("mise-v{version}-windows-x64.exe")]
);
assert_eq!(
official_release_assets("windows", "aarch64").unwrap(),
vec![format!("mise-v{version}-windows-arm64.exe")]
);
assert!(official_release_assets("windows", "x86").is_err());
}
#[test]
fn parses_remote_release_platforms_and_requires_linux_libc() {
assert_eq!(
parse_remote_platform("Linux\nx86_64\nglibc\n").unwrap(),
RemotePlatform {
os: "linux".to_string(),
arch: "x86_64".to_string(),
libc: Some(LibcFlavor::Glibc),
}
);
assert_eq!(
parse_remote_platform("linux\naarch64\nmusl\n").unwrap(),
RemotePlatform {
os: "linux".to_string(),
arch: "aarch64".to_string(),
libc: Some(LibcFlavor::Musl),
}
);
assert_eq!(
parse_remote_platform("Darwin\narm64\nnone\n").unwrap(),
RemotePlatform {
os: "macos".to_string(),
arch: "aarch64".to_string(),
libc: None,
}
);
assert!(parse_remote_platform("Linux\nx86_64\nunknown\n").is_err());
assert!(parse_remote_platform("Linux\nx86_64\n").is_err());
assert!(parse_remote_platform("Darwin\narm64\nnone\nextra\n").is_err());
}
#[test]
fn verifies_real_signed_release_manifest_before_lookup() {
let contents = include_str!("remote_testdata/mise-v2026.8.1-SHASUMS256.txt");
let signature = include_str!("remote_testdata/mise-v2026.8.1-SHASUMS256.txt.minisig");
let manifest = ReleaseManifest::verified(contents, signature).unwrap();
assert_eq!(
manifest.checksum("mise-v2026.8.1-linux-x64-musl").unwrap(),
"522fd15a3b0748d8a240bdf06cd45f679f759a097e2f49b436363e92c48fdbdc"
);
assert!(manifest.checksum("missing-asset").is_err());
let tampered = contents.replacen("522fd15", "022fd15", 1);
assert!(ReleaseManifest::verified(&tampered, signature).is_err());
}
#[test]
fn compares_required_glibc_symbol_versions_with_remote_runtime() {
assert_eq!(
max_required_glibc_version(b"GLIBC_PRIVATE\0GLIBC_2.3.4\0GLIBC_2.17\0GLIBC_2.34\0"),
Some(AbiVersion(vec![2, 34]))
);
assert_eq!(
parse_glibc_runtime_version(
"ld.so (Debian GLIBC 2.36-9+deb12u13) stable release version 2.36"
),
Some(AbiVersion(vec![2, 36]))
);
assert_eq!(
parse_glibc_runtime_version("ldd (GNU libc) 2.17"),
Some(AbiVersion(vec![2, 17]))
);
assert!(AbiVersion(vec![2, 17]) < AbiVersion(vec![2, 34]));
}
#[test]
fn compares_musl_loader_versions() {
assert_eq!(
parse_musl_runtime_version("musl libc (x86_64)\nVersion 1.2.5\nDynamic Program Loader"),
Some(AbiVersion(vec![1, 2, 5]))
);
assert_eq!(
parse_musl_runtime_version("musl libc\nversion: 1.1.24\n"),
Some(AbiVersion(vec![1, 1, 24]))
);
assert!(parse_musl_runtime_version("musl libc (x86_64)").is_none());
assert!(AbiVersion(vec![1, 2, 4]) < AbiVersion(vec![1, 2, 5]));
}
#[test]
fn accepts_quoted_absolute_remote_paths() {
assert_eq!(
validated_remote_command_output("/tmp/mise install/bin/mise\n").unwrap(),
"/tmp/mise install/bin/mise"
);
assert!(validated_remote_command("relative/mise").is_err());
assert!(validated_remote_command("/tmp/mise\nother").is_err());
assert!(validate_remote_executable("/tmp/mise install/bin/mise").is_ok());
assert!(validate_remote_executable("./bin with space/mise").is_ok());
assert!(validate_remote_executable("mise command").is_err());
assert!(validate_remote_executable("/tmp/mise\ncommand").is_err());
assert!(validate_remote_executable("-mise").is_err());
}
#[test]
fn snapshots_the_same_candidate_scopes_before_and_after_installation() {
let union = remote_mise_candidate_union_script();
let lookup = remote_mise_candidates_script();
assert!(union.starts_with("{\n"));
assert!(union.contains(lookup));
assert!(union.contains(&shell_words::join(["sh", "-lc", lookup])));
}
#[test]
fn resolves_relative_remote_mise_inside_staged_project() {
let project = "/tmp/mise-bootstrap.abc/project";
assert_eq!(
resolve_remote_mise_path("./bin/mise", project, None).unwrap(),
"/tmp/mise-bootstrap.abc/project/bin/mise"
);
assert_eq!(
resolve_remote_mise_path("bin/../tools/mise", project, None).unwrap(),
"/tmp/mise-bootstrap.abc/project/tools/mise"
);
assert_eq!(
resolve_remote_mise_path("~/.local/bin/mise", project, Some("/home/test user"))
.unwrap(),
"/home/test user/.local/bin/mise"
);
assert_eq!(
resolve_remote_mise_path("/opt/mise/bin/mise", project, None).unwrap(),
"/opt/mise/bin/mise"
);
assert!(resolve_remote_mise_path("../mise", project, None).is_err());
assert!(resolve_remote_mise_path("bin/../../mise", project, None).is_err());
assert!(resolve_remote_mise_path("~/mise", project, None).is_err());
}
#[test]
fn selects_new_changed_or_matching_bootstrapped_mise() {
let identity = |version: &str, checksum: &str| {
Some(RemoteMiseIdentity {
version: version.to_string(),
fingerprint: checksum.to_string(),
})
};
let old = "/opt/old/mise".to_string();
let installed = "/opt/new/mise".to_string();
let before = vec![old.clone()];
let before_identities = IndexMap::from([(old.clone(), identity("1.0.0", "100 10"))]);
let after = vec![old.clone(), installed.clone()];
let after_identities = IndexMap::from([
(old.clone(), identity("1.0.0", "100 10")),
(installed.clone(), identity("2.0.0", "200 20")),
]);
assert_eq!(
select_bootstrapped_mise(&before, &before_identities, &after, &after_identities)
.unwrap(),
installed
);
let after = vec![old.clone()];
let changed = IndexMap::from([(old.clone(), identity("1.0.0", "300 30"))]);
assert_eq!(
select_bootstrapped_mise(&before, &before_identities, &after, &changed).unwrap(),
old
);
let stale = "/opt/stale/mise".to_string();
let overwritten = "/opt/overwritten/mise".to_string();
let same_paths = vec![stale.clone(), overwritten.clone()];
let before_same_version = IndexMap::from([
(stale.clone(), identity("1.0.0", "100 10")),
(overwritten.clone(), identity("2.0.0", "200 20")),
]);
let after_same_version = IndexMap::from([
(stale, identity("1.0.0", "100 10")),
(overwritten.clone(), identity("2.0.0", "300 30")),
]);
assert_eq!(
select_bootstrapped_mise(
&same_paths,
&before_same_version,
&same_paths,
&after_same_version,
)
.unwrap(),
overwritten
);
let current = "/opt/current/mise".to_string();
let candidates = vec!["/opt/stale/mise".to_string(), current.clone()];
let identities = IndexMap::from([
(candidates[0].clone(), identity("0.0.1", "100 10")),
(
current.clone(),
identity(
&format!("{} linux-x64", env!("CARGO_PKG_VERSION")),
"200 20",
),
),
]);
assert_eq!(
select_bootstrapped_mise(&candidates, &identities, &candidates, &identities).unwrap(),
current
);
}
#[test]
fn rejects_ambiguous_bootstrapped_mise_candidates() {
let candidates = vec!["/opt/one/mise".to_string(), "/opt/two/mise".to_string()];
let identities = IndexMap::from([
(
candidates[0].clone(),
Some(RemoteMiseIdentity {
version: "1.0.0".to_string(),
fingerprint: "100 10".to_string(),
}),
),
(
candidates[1].clone(),
Some(RemoteMiseIdentity {
version: "2.0.0".to_string(),
fingerprint: "200 20".to_string(),
}),
),
]);
assert!(
select_bootstrapped_mise(&candidates, &identities, &candidates, &identities).is_err()
);
let current_version = format!("{} linux-x64", env!("CARGO_PKG_VERSION"));
let matching_identities = IndexMap::from([
(
candidates[0].clone(),
Some(RemoteMiseIdentity {
version: current_version.clone(),
fingerprint: "100 10".to_string(),
}),
),
(
candidates[1].clone(),
Some(RemoteMiseIdentity {
version: current_version,
fingerprint: "200 20".to_string(),
}),
),
]);
assert!(
select_bootstrapped_mise(
&candidates,
&matching_identities,
&candidates,
&matching_identities,
)
.unwrap_err()
.to_string()
.contains("multiple version-matching mise executables")
);
assert!(parse_remote_mise_candidates("/opt/mise").is_err());
assert_eq!(
parse_remote_mise_candidates("/opt/one\0/opt/one\0/opt/two\0").unwrap(),
vec!["/opt/one".to_string(), "/opt/two".to_string()]
);
}
}