use color_eyre::Result;
use color_eyre::eyre::bail;
use console::style;
#[cfg(windows)]
use indoc::formatdoc;
use self_update::backends::github::Update;
use self_update::{Status, cargo_crate_version};
use crate::cli::version::{ARCH, OS};
use crate::config::Settings;
use crate::env;
#[cfg(windows)]
use crate::file::MAX_PATH;
use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::fs;
#[cfg(target_os = "macos")]
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
const AUTO_UPDATE_REEXEC_ENV: &str = "__MISE_AUTO_UPDATE_REEXEC";
#[derive(Debug, Default, serde::Deserialize)]
struct InstructionsToml {
message: Option<String>,
#[serde(flatten)]
commands: BTreeMap<String, String>,
}
fn read_instructions_file(path: &PathBuf) -> Option<String> {
let body = fs::read_to_string(path).ok()?;
let parsed: InstructionsToml = toml::from_str(&body).ok()?;
if let Some(msg) = parsed.message {
return Some(msg);
}
if let Some((_k, v)) = parsed.commands.into_iter().next() {
return Some(v);
}
None
}
pub(crate) fn upgrade_instructions_text() -> Option<String> {
if let Some(path) = &*env::MISE_SELF_UPDATE_INSTRUCTIONS
&& let Some(msg) = read_instructions_file(path)
{
return Some(msg);
}
None
}
pub(crate) const SELF_UPDATE_DISABLED_HINT: &str =
"self-update is disabled for this install, update mise the same way you installed it";
pub(crate) fn upgrade_instructions_or_hint() -> String {
upgrade_instructions_text().unwrap_or_else(|| SELF_UPDATE_DISABLED_HINT.to_string())
}
pub(crate) fn append_self_update_instructions(mut message: String) -> String {
if SelfUpdate::is_available() {
message.push_str("\nRun `mise self-update` to update mise");
}
if let Some(instructions) = upgrade_instructions_text() {
message.push('\n');
message.push_str(&instructions);
} else if !SelfUpdate::is_available() {
message.push('\n');
message.push_str(SELF_UPDATE_DISABLED_HINT);
}
message
}
pub(crate) async fn maybe_auto_update(
args: &[String],
original_cwd: Option<&std::path::Path>,
command_eligible: bool,
) -> Result<()> {
let Ok(settings) = Settings::try_get() else {
return Ok(());
};
if !auto_update_eligible(AutoUpdateContext {
enabled: settings.auto_update,
offline: settings.offline(),
prefer_offline: settings.prefer_offline(),
ci: settings.ci || ci_info::is_ci(),
attended: console::user_attended_stderr(),
already_reexecuted: env::var_os(AUTO_UPDATE_REEXEC_ENV).is_some(),
self_update_available: SelfUpdate::is_available(),
command_eligible,
}) {
return Ok(());
}
let lock_path = crate::dirs::CACHE.join("auto-update");
let update_lock = match crate::lock_file::LockFile::new(&lock_path).try_lock() {
Ok(Some(lock)) => lock,
Ok(None) => {
debug!("skipping auto-update because another mise process is updating");
return Ok(());
}
Err(err) => {
debug!("automatic mise update could not acquire its lock: {err:#}");
return Ok(());
}
};
let last_check_path = crate::dirs::CACHE.join("auto-update-last-check");
let check_duration = match settings.auto_update_check_duration() {
Ok(duration) => duration,
Err(err) => {
debug!("automatic mise update has an invalid check duration: {err:#}");
return Ok(());
}
};
if !auto_update_check_due(&last_check_path, check_duration) {
return Ok(());
}
if let Err(err) = crate::file::write(&last_check_path, "") {
debug!("automatic mise update could not record its check: {err:#}");
return Ok(());
}
let Some(version) = crate::cli::version::check_for_new_version(Duration::ZERO).await else {
return Ok(());
};
let update = SelfUpdate {
version: Some(version),
force: false,
yes: true,
no_plugins: true,
};
if let Err(err) = update.run().await {
debug!("automatic mise update failed: {err:#}");
return Ok(());
}
drop(update_lock);
reexec(args, original_cwd)
}
fn auto_update_check_due(path: &std::path::Path, duration: Duration) -> bool {
crate::file::modified_duration(path).map_or(true, |age| age >= duration)
}
#[derive(Clone, Copy)]
struct AutoUpdateContext {
enabled: bool,
offline: bool,
prefer_offline: bool,
ci: bool,
attended: bool,
already_reexecuted: bool,
self_update_available: bool,
command_eligible: bool,
}
fn auto_update_eligible(context: AutoUpdateContext) -> bool {
context.enabled
&& !context.offline
&& !context.prefer_offline
&& !context.ci
&& context.attended
&& !context.already_reexecuted
&& context.self_update_available
&& context.command_eligible
}
fn build_reexec_command<I, S>(args: I, original_cwd: Option<&std::path::Path>) -> Command
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut command = Command::new(&*env::MISE_BIN);
command.args(args).env(AUTO_UPDATE_REEXEC_ENV, "1");
if let Some(cwd) = original_cwd {
command.current_dir(cwd);
}
command
}
#[cfg(unix)]
fn reexec(_args: &[String], original_cwd: Option<&std::path::Path>) -> Result<()> {
use std::os::unix::process::CommandExt;
let mut command = build_reexec_command(std::env::args_os().skip(1), original_cwd);
let err = command.exec();
warn!("mise was updated but could not re-execute the command: {err}");
Ok(())
}
#[cfg(windows)]
fn reexec(_args: &[String], original_cwd: Option<&std::path::Path>) -> Result<()> {
let mut command = build_reexec_command(std::env::args_os().skip(1), original_cwd);
let status = command.status()?;
Err(crate::request_exit(status.code().unwrap_or(1)))
}
#[cfg(not(any(unix, windows)))]
fn reexec(args: &[String], original_cwd: Option<&std::path::Path>) -> Result<()> {
let mut command = build_reexec_command(&args[1..], original_cwd);
let status = command.status()?;
Err(crate::request_exit(status.code().unwrap_or(1)))
}
#[derive(Debug, Default, usage_rs::Args)]
#[usage(verbatim_doc_comment)]
pub(crate) struct SelfUpdate {
version: Option<String>,
#[usage(long, short)]
force: bool,
#[usage(long, short)]
yes: bool,
#[usage(long)]
no_plugins: bool,
}
#[cfg(windows)]
fn temp_dir_breaks_self_replace(tmp: &std::path::Path, exe_stem: Option<&str>) -> bool {
helper_path_len(tmp, exe_stem) >= MAX_PATH
}
#[cfg(windows)]
fn helper_path_len(tmp: &std::path::Path, exe_stem: Option<&str>) -> usize {
use std::os::windows::ffi::OsStrExt;
let separator = usize::from(!ends_with_separator(tmp));
tmp.as_os_str().encode_wide().count() + separator + helper_name_len(exe_stem)
}
#[cfg(windows)]
fn helper_name_len(exe_stem: Option<&str>) -> usize {
let suffix_len = env::SELF_REPLACE_SUFFIXES[0].len();
let stem = exe_stem.map_or(0, |s| s.encode_utf16().count() + 1);
1 + stem + env::SELF_REPLACE_RANDOM_LEN + suffix_len
}
#[cfg(windows)]
fn sweep_helper_orphans() {
for (path, _) in helper_orphans() {
match std::fs::remove_file(&path) {
Ok(()) => debug!("removed stale self-update copy: {}", path.display()),
Err(e) => trace!("could not remove {}: {e}", path.display()),
}
}
}
#[cfg(windows)]
pub(crate) fn helper_orphans() -> Vec<(std::path::PathBuf, u64)> {
let Some(stem) = current_exe_stem() else {
return Vec::new();
};
helper_orphans_in(&std::env::temp_dir(), &stem)
}
#[cfg(windows)]
fn helper_orphans_in(dir: &std::path::Path, stem: &str) -> Vec<(std::path::PathBuf, u64)> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.flatten()
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| env::is_self_replace_helper(name, stem))
})
.map(|entry| {
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
(entry.path(), size)
})
.collect()
}
#[cfg(windows)]
fn ends_with_separator(path: &std::path::Path) -> bool {
use std::os::windows::ffi::OsStrExt;
path.as_os_str()
.encode_wide()
.last()
.is_some_and(|c| c == u16::from(b'\\') || c == u16::from(b'/'))
}
#[cfg(windows)]
fn current_exe_stem() -> Option<String> {
let exe = std::env::current_exe().ok()?;
exe.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
}
fn update_plugins(bin: &std::path::Path) {
if let Err(err) = cmd!(bin, "plugins", "update").run() {
warn!("Failed to update plugins: {err}");
}
}
impl SelfUpdate {
pub(crate) async fn run(self) -> Result<()> {
if !Self::is_available() && !self.force {
if let Some(instructions) = upgrade_instructions_text() {
warn!("{}", instructions);
}
bail!("mise is installed via a package manager, cannot update");
}
#[cfg(windows)]
sweep_helper_orphans();
#[cfg(windows)]
Self::ensure_temp_dir_can_replace_binary()?;
let status = self.do_update()?;
if status.updated() {
let version = status.version().to_string();
let styled_version = style(&version).bright().yellow();
miseprintln!("Updated mise to {styled_version}");
#[cfg(windows)]
match Self::update_mise_shim(&version).await {
Ok(()) => {
if let Err(e) = Self::reshim_after_update().await {
warn!("Failed to reshim after self-update: {e}");
}
}
Err(e) => warn!("Failed to update mise-shim.exe: {e}"),
}
} else {
miseprintln!("mise is already up to date");
}
crate::cli::version::show_auto_update_hint();
if !self.no_plugins {
update_plugins(&env::MISE_BIN);
}
Ok(())
}
#[cfg(windows)]
fn ensure_temp_dir_can_replace_binary() -> Result<()> {
use std::os::windows::ffi::OsStrExt;
let tmp = std::env::temp_dir();
let stem = current_exe_stem();
if !temp_dir_breaks_self_replace(&tmp, stem.as_deref()) {
return Ok(());
}
let msg = formatdoc! {r#"
TEMP is too long to replace mise.exe safely ({len} UTF-16 code units)
TEMP = {tmp}
Updating moves the running mise.exe aside and then launches a helper from TEMP to
put the new one in place. That helper's path would be {helper} UTF-16 code units,
and Windows cannot launch an executable whose path reaches {max}. The move happens
first, so going ahead would leave no mise installed at all.
Point TEMP and TMP at a shorter directory and run mise self-update again:
$env:TEMP = 'C:\Temp'; $env:TMP = 'C:\Temp'"#,
len = tmp.as_os_str().encode_wide().count(),
tmp = tmp.display(),
helper = helper_path_len(&tmp, stem.as_deref()),
max = MAX_PATH,
};
bail!("{msg}");
}
fn do_update(&self) -> Result<Status> {
tokio::task::block_in_place(|| self.do_update_blocking())
}
fn do_update_blocking(&self) -> Result<Status> {
let mut update = Update::configure();
if let Some((token, _)) = crate::github::resolve_token("github.com") {
update.auth_token(&token);
}
#[cfg(windows)]
let bin_path_in_archive = "mise/bin/mise.exe";
#[cfg(not(windows))]
let bin_path_in_archive = "mise/bin/mise";
update
.repo_owner("jdx")
.repo_name("mise")
.bin_name("mise")
.current_version(cargo_crate_version!())
.bin_path_in_archive(bin_path_in_archive);
let settings = Settings::try_get();
let v = self
.version
.clone()
.map_or_else(
|| -> Result<String> { Ok(update.build()?.get_latest_release()?.version) },
Ok,
)
.map(|v| format!("v{v}"))?;
let current_version = format!("v{}", cargo_crate_version!());
if !self.force && v == current_version {
return Ok(Status::UpToDate(current_version));
}
let target = format!("{}-{}", *OS, *ARCH);
#[cfg(target_env = "musl")]
let target = format!("{target}-musl");
update.target_version_tag(&v);
#[cfg(windows)]
let target = format!("mise-{v}-{target}.zip");
#[cfg(not(windows))]
let target = format!("mise-{v}-{target}.tar.gz");
let status = update
.verifying_keys([*include_bytes!("../../zipsign.pub")])
.show_download_progress(true)
.target(&target)
.no_confirm(settings.is_ok_and(|s| s.yes) || self.yes)
.build()?
.update()?;
#[cfg(target_os = "macos")]
if status.updated() {
Self::verify_macos_signature(&env::MISE_BIN)?;
}
Ok(status)
}
#[cfg(windows)]
async fn reshim_after_update() -> Result<()> {
use crate::config::Config;
use crate::toolset::ToolsetBuilder;
let config = Config::get().await?;
let ts = ToolsetBuilder::new().build(&config).await?;
crate::shims::reshim_for(&config, &ts, true, crate::shims::ShimScope::User).await?;
let user_shims = crate::dirs::shims();
let system_shims = crate::dirs::system_shims();
if system_shims.is_dir() && !crate::file::storage_paths_eq(&user_shims, &system_shims) {
crate::shims::reshim_for(&config, &ts, true, crate::shims::ShimScope::System).await?;
}
Ok(())
}
#[cfg(windows)]
async fn update_mise_shim(version: &str) -> Result<()> {
use crate::http::HTTP;
use std::io::Read;
let version = version.strip_prefix('v').unwrap_or(version);
let archive_name = format!("mise-v{version}-{}-{}.zip", *OS, *ARCH);
let url =
format!("https://github.com/jdx/mise/releases/download/v{version}/{archive_name}",);
debug!("Downloading mise-shim.exe from {url}");
let temp_dir = tempfile::tempdir()?;
let zip_path = temp_dir.path().join(&archive_name);
HTTP.download_file(&url, &zip_path, None).await?;
Self::verify_zip_signature(&zip_path)?;
let file = fs::File::open(&zip_path)?;
let mut archive = zip::ZipArchive::new(file)?;
let mut shim_entry = match archive.by_name("mise/bin/mise-shim.exe") {
Ok(entry) => entry,
Err(_) => {
warn!("mise-shim.exe not found in release archive, skipping");
return Ok(());
}
};
let dest = env::MISE_BIN
.parent()
.expect("MISE_BIN should have a parent directory")
.join("mise-shim.exe");
let mut buf = Vec::new();
shim_entry.read_to_end(&mut buf)?;
let temp_shim = temp_dir.path().join("mise-shim.exe");
fs::write(&temp_shim, &buf)?;
if fs::rename(&temp_shim, &dest).is_err() {
fs::copy(&temp_shim, &dest)?;
}
debug!("Updated mise-shim.exe at {}", dest.display());
Ok(())
}
#[cfg(windows)]
fn verify_zip_signature(path: &std::path::Path) -> Result<()> {
let context = path
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.as_bytes())
.ok_or_else(|| color_eyre::eyre::eyre!("non-UTF8 archive path"))?;
let keys = zipsign_api::verify::collect_keys(
[*include_bytes!("../../zipsign.pub")].into_iter().map(Ok),
)
.map_err(|e| color_eyre::eyre::eyre!("failed to load verification keys: {e}"))?;
let mut file = fs::File::open(path)?;
zipsign_api::verify::verify_zip(&mut file, &keys, Some(context))
.map_err(|e| color_eyre::eyre::eyre!("zip signature verification failed: {e}"))?;
debug!("Verified zip signature for {}", path.display());
Ok(())
}
pub(crate) fn is_available() -> bool {
if let Some(b) = *env::MISE_SELF_UPDATE_AVAILABLE {
return b;
}
let has_disable = env::MISE_SELF_UPDATE_DISABLED_PATH.is_some();
let has_instructions = env::MISE_SELF_UPDATE_INSTRUCTIONS.is_some();
!(has_disable || has_instructions)
}
#[cfg(target_os = "macos")]
fn verify_macos_signature(binary_path: &Path) -> Result<()> {
use std::process::Command;
debug!(
"Verifying macOS code signature for: {}",
binary_path.display()
);
let codesign_check = Command::new("which").arg("codesign").output();
if codesign_check.is_err() || !codesign_check.unwrap().status.success() {
warn!("codesign command not found in PATH, skipping binary signature verification");
warn!("This is unusual on macOS - consider verifying your system installation");
return Ok(());
}
let output = Command::new("codesign")
.args([
"--verify",
"--deep",
"--strict",
"-R=identifier \"dev.jdx.mise\"",
])
.arg(binary_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"macOS binary signature verification failed (invalid signature or incorrect identifier): {}",
stderr.trim()
);
}
debug!("macOS binary signature verified successfully");
Ok(())
}
}
#[cfg(test)]
mod auto_update_tests {
use super::*;
fn eligible_context() -> AutoUpdateContext {
AutoUpdateContext {
enabled: true,
offline: false,
prefer_offline: false,
ci: false,
attended: true,
already_reexecuted: false,
self_update_available: true,
command_eligible: true,
}
}
#[test]
fn eligible_interactive_command_updates() {
assert!(auto_update_eligible(eligible_context()));
}
#[test]
fn safety_conditions_disable_auto_update() {
let context = eligible_context();
for ineligible in [
AutoUpdateContext {
enabled: false,
..context
},
AutoUpdateContext {
offline: true,
..context
},
AutoUpdateContext {
prefer_offline: true,
..context
},
AutoUpdateContext {
ci: true,
..context
},
AutoUpdateContext {
attended: false,
..context
},
AutoUpdateContext {
already_reexecuted: true,
..context
},
AutoUpdateContext {
self_update_available: false,
..context
},
] {
assert!(!auto_update_eligible(ineligible));
}
}
#[test]
fn ineligible_commands_do_not_update() {
assert!(!auto_update_eligible(AutoUpdateContext {
command_eligible: false,
..eligible_context()
}));
}
#[test]
fn reexec_preserves_arguments_directory_and_guard() {
use std::ffi::OsString;
let cwd = std::path::Path::new("a directory");
let args = [OsString::from("install"), OsString::from("node@22 beta")];
let command = build_reexec_command(&args, Some(cwd));
assert_eq!(command.get_args().collect::<Vec<_>>(), args);
assert_eq!(command.get_current_dir(), Some(cwd));
assert!(command.get_envs().any(|(key, value)| {
key == AUTO_UPDATE_REEXEC_ENV && value == Some(OsStr::new("1"))
}));
}
#[test]
fn automatic_update_attempts_are_throttled() {
let temp = tempfile::tempdir().unwrap();
let marker = temp.path().join("last-check");
assert!(auto_update_check_due(&marker, Duration::from_secs(60)));
std::fs::write(&marker, "").unwrap();
assert!(!auto_update_check_due(&marker, Duration::from_secs(60)));
assert!(auto_update_check_due(&marker, Duration::ZERO));
}
}
#[cfg(test)]
mod post_update_tests {
use super::*;
#[test]
fn a_plugins_update_that_cannot_run_is_not_fatal() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("mise-that-is-not-there");
assert!(cmd!(&missing, "plugins", "update").run().is_err());
update_plugins(&missing);
}
}
#[cfg(all(test, windows))]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
fn temp_dir_of_len(len: usize) -> PathBuf {
let mut s = String::from("C:\\");
while s.len() < len - 1 {
s.push('t');
}
s.push('\\');
assert_eq!(s.len(), len, "test helper built the wrong length");
PathBuf::from(s)
}
fn breaks(len: usize) -> bool {
temp_dir_breaks_self_replace(&temp_dir_of_len(len), Some("mise"))
}
#[test]
fn an_ordinary_temp_dir_is_left_alone() {
let tmp = Path::new("C:\\Users\\u\\AppData\\Local\\Temp\\");
assert!(!temp_dir_breaks_self_replace(tmp, Some("mise")));
}
#[test]
fn the_boundary_matches_what_windows_actually_does() {
assert!(!breaks(202));
assert!(breaks(203));
}
#[test]
fn temp_dirs_measured_as_destructive_are_rejected() {
assert!(breaks(206)); assert!(breaks(244)); }
#[test]
fn a_long_temp_dir_that_still_works_is_not_rejected() {
assert!(!breaks(191));
}
#[test]
fn a_trailing_separator_is_not_counted_twice() {
assert_eq!(
helper_path_len(Path::new("C:\\Temp\\"), Some("mise")),
helper_path_len(Path::new("C:\\Temp"), Some("mise"))
);
}
#[test]
fn the_helper_name_follows_the_running_executable() {
assert_eq!(helper_name_len(Some("mise")), 57);
assert_eq!(helper_name_len(Some("mise-dev")), 61);
assert_eq!(helper_name_len(None), 52);
}
#[test]
fn a_renamed_binary_lowers_the_ceiling() {
let tmp = temp_dir_of_len(202);
assert!(!temp_dir_breaks_self_replace(&tmp, Some("mise")));
assert!(temp_dir_breaks_self_replace(&tmp, Some("mise-dev")));
}
#[test]
fn only_the_generated_copies_are_collected() {
let dir = tempfile::tempdir().unwrap();
let rand = "a".repeat(env::SELF_REPLACE_RANDOM_LEN);
let collected = [
format!(".mise.{rand}.__selfdelete__.exe"),
format!(".mise.{rand}.__relocated__.exe"),
];
let ignored = [
"mise.exe".to_string(),
format!(".other.{rand}.__selfdelete__.exe"),
format!(".mise.{}.__selfdelete__.exe", "a".repeat(31)),
format!(".mise.{}A.__selfdelete__.exe", "a".repeat(31)),
"setup-x64.exe".to_string(),
];
for name in collected.iter().chain(ignored.iter()) {
std::fs::write(dir.path().join(name), b"xyz").unwrap();
}
let found = helper_orphans_in(dir.path(), "mise");
let mut names = found
.iter()
.map(|(p, _)| p.file_name().unwrap().to_str().unwrap().to_string())
.collect::<Vec<_>>();
names.sort();
let mut want = collected.to_vec();
want.sort();
assert_eq!(names, want);
assert_eq!(found.iter().map(|(_, size)| size).sum::<u64>(), 6);
}
#[test]
fn a_missing_directory_is_not_an_error() {
assert!(helper_orphans_in(Path::new("C:\\nope\\nope\\nope"), "mise").is_empty());
}
#[test]
fn the_length_is_counted_in_utf16_code_units() {
let mut s = String::from("C:\\");
while s.chars().count() < 201 {
s.push('あ');
}
s.push('\\');
let tmp = PathBuf::from(s);
assert_eq!(tmp.as_os_str().len(), 598);
assert!(!temp_dir_breaks_self_replace(&tmp, Some("mise")));
}
}