diff --git a/e2e/cli/test_install_system_readonly b/e2e/cli/test_install_system_readonly new file mode 100644 index 0000000000..97d2364bfb --- /dev/null +++ b/e2e/cli/test_install_system_readonly @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Regression test for the read-only system installs dir scenario: +# When tools are installed in a system dir that the current user cannot +# write to (e.g. a Docker image where root installed at build time and a +# non-root user runs `mise install` at runtime), `mise install` of an +# unrelated tool must NOT try to rewrite the system dir's runtime symlinks. +# https://github.com/jdx/mise/discussions/8596 + +export MISE_SYSTEM_DATA_DIR="$HOME/.local/share/mise-system" +SYSTEM_INSTALLS="$MISE_SYSTEM_DATA_DIR/installs" +USER_INSTALLS="$MISE_DATA_DIR/installs" + +# Install one version to user dir and another to "system" dir. +mise install tiny@1.0.0 +mise install --system tiny@2.1.0 + +# Sanity: both got their `latest` symlinks. +assert "readlink $USER_INSTALLS/tiny/latest" "./1.0.0" +assert "readlink $SYSTEM_INSTALLS/tiny/latest" "./2.1.0" + +# Drop write access to the system dir to simulate the "root-owned, non-root +# runtime" Docker pattern. Trap restores write access so the harness can +# clean up the test dir. +chmod -R a-w "$SYSTEM_INSTALLS" +trap 'chmod -R u+w "$SYSTEM_INSTALLS" 2>/dev/null || true' EXIT + +assert_silent_install() { + local out + if ! out="$($1 2>&1)"; then + echo "$out" + fail "[$1] command failed" + fi + echo "$out" + if [[ $out == *"skipping symlink update"* || $out == *"Permission denied"* || $out == *"failed rm"* ]]; then + fail "[$1] unexpected system-dir warning during install" + fi +} + +# Re-installing a tool that lives only in the user dir must succeed without +# any warning about the system dir — the system-side runtime symlink already +# matches the desired state, so nothing should be written or attempted there. +assert_silent_install "mise install --force tiny@1.0.0" +assert "readlink $USER_INSTALLS/tiny/latest" "./1.0.0" +assert "readlink $SYSTEM_INSTALLS/tiny/latest" "./2.1.0" + +# Installing a different tool that doesn't touch the system dir at all must +# also succeed silently. +assert_silent_install "mise install dummy@1.0.0" +assert_directory_exists "$USER_INSTALLS/dummy/1.0.0" +assert "readlink $SYSTEM_INSTALLS/tiny/latest" "./2.1.0" diff --git a/src/runtime_symlinks.rs b/src/runtime_symlinks.rs index 6f5585afb7..077eec95ff 100644 --- a/src/runtime_symlinks.rs +++ b/src/runtime_symlinks.rs @@ -14,34 +14,8 @@ use versions::Versioning; pub async fn rebuild(config: &Config) -> Result<()> { for backend in backend::list() { - let ba = backend.ba(); - // Collect all install directories for this backend: user dir + shared/system dirs - let mut installs_dirs = vec![ba.installs_path.clone()]; - let tool_dir_name = ba.tool_dir_name(); - for shared_dir in env::shared_install_dirs() { - let dir = shared_dir.join(&tool_dir_name); - if dir.is_dir() && !installs_dirs.contains(&dir) { - installs_dirs.push(dir); - } - } - - // Process user dir (first entry) with normal error propagation - if let Some(installs_dir) = installs_dirs.first() { - rebuild_symlinks_in_dir(config, &backend, installs_dir)?; - } - // Process shared/system dirs with permission error tolerance - for installs_dir in installs_dirs.iter().skip(1) { - if let Err(e) = rebuild_symlinks_in_dir(config, &backend, installs_dir) { - if is_permission_error(&e) { - warn!( - "skipping symlink update for {}: {}", - installs_dir.display(), - e - ); - } else { - return Err(e); - } - } + for installs_dir in install_dirs_for(&backend) { + rebuild_symlinks_in_dir(config, &backend, &installs_dir)?; } } Ok(()) @@ -49,34 +23,29 @@ pub async fn rebuild(config: &Config) -> Result<()> { pub async fn migrate_real_dirs(config: &Config) -> Result<()> { for backend in backend::list() { - let ba = backend.ba(); - let mut installs_dirs = vec![ba.installs_path.clone()]; - let tool_dir_name = ba.tool_dir_name(); - for shared_dir in env::shared_install_dirs() { - let dir = shared_dir.join(&tool_dir_name); - if dir.is_dir() && !installs_dirs.contains(&dir) { - installs_dirs.push(dir); - } + for installs_dir in install_dirs_for(&backend) { + migrate_real_dirs_in_dir(config, &backend, &installs_dir)?; } + } + Ok(()) +} - if let Some(installs_dir) = installs_dirs.first() { - migrate_real_dirs_in_dir(config, &backend, installs_dir)?; - } - for installs_dir in installs_dirs.iter().skip(1) { - if let Err(e) = migrate_real_dirs_in_dir(config, &backend, installs_dir) { - if is_permission_error(&e) { - warn!( - "skipping runtime symlink migration for {}: {}", - installs_dir.display(), - e - ); - } else { - return Err(e); - } - } +/// All install directories to consider for a backend: the backend's primary +/// installs_path plus any shared/system dirs that contain the tool. Per-dir +/// rebuilds are no-ops when desired state already matches actual state, so +/// dirs we have no write access to (read-only system installs) only error +/// out when we actually need to change something there. +fn install_dirs_for(backend: &Arc) -> Vec { + let ba = backend.ba(); + let mut dirs = vec![ba.installs_path.clone()]; + let tool_dir_name = ba.tool_dir_name(); + for shared_dir in env::shared_install_dirs() { + let dir = shared_dir.join(&tool_dir_name); + if dir.is_dir() && !dirs.contains(&dir) { + dirs.push(dir); } } - Ok(()) + dirs } fn rebuild_symlinks_in_dir( @@ -93,16 +62,21 @@ fn rebuild_symlinks_in_dir( let from_name = from.clone(); let from = installs_dir.join(from); if from.exists() { - if is_runtime_symlink(&from) && file::resolve_symlink(&from)?.unwrap_or_default() != to - { + if is_runtime_symlink(&from) { + // Existing runtime symlink: only rewrite if the target changed. + if file::resolve_symlink(&from)?.unwrap_or_default() == to { + continue; + } trace!("Removing existing symlink: {}", from.display()); file::remove_file(&from)?; } else if from .file_name() .zip(to.file_name()) - .is_some_and(|(from_name, to_name)| from_name != to_name) + .is_some_and(|(f, t)| f != t) && !concrete_installs.contains(&from_name) { + // Real (non-symlink) directory at a runtime-symlink slot — + // legacy stale state from the 2026.4 regression. Replace it. trace!("Replacing stale runtime dir: {}", from.display()); file::remove_all(&from)?; } else { @@ -138,19 +112,6 @@ fn migrate_real_dirs_in_dir( Ok(()) } -fn is_permission_error(e: &eyre::Report) -> bool { - e.chain().any(|cause| { - cause - .downcast_ref::() - .is_some_and(|io_err| { - matches!( - io_err.kind(), - std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem - ) - }) - }) -} - /// Build symlinks for versions found in a specific install directory. fn list_symlinks_for_dir( config: &Config,