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::fs;
#[cfg(target_os = "macos")]
use std::path::Path;
use std::path::PathBuf;
#[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 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 const SELF_UPDATE_DISABLED_HINT: &str =
"self-update is disabled for this install, update mise the same way you installed it";
pub fn upgrade_instructions_or_hint() -> String {
upgrade_instructions_text().unwrap_or_else(|| SELF_UPDATE_DISABLED_HINT.to_string())
}
pub 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
}
#[derive(Debug, Default, clap::Args)]
#[clap(verbatim_doc_comment)]
pub struct SelfUpdate {
version: Option<String>,
#[clap(long, short)]
force: bool,
#[clap(long, short)]
yes: bool,
#[clap(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)
}
impl SelfUpdate {
pub 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");
}
if !self.no_plugins {
cmd!(&*env::MISE_BIN, "plugins", "update").run()?;
}
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(&config, &ts, true).await
}
#[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 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(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")));
}
}