Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions e2e/cli/test_install_system_readonly
Original file line number Diff line number Diff line change
@@ -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"
97 changes: 29 additions & 68 deletions src/runtime_symlinks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,69 +14,38 @@ 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(())
}

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<dyn Backend>) -> Vec<PathBuf> {
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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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::<std::io::Error>()
.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,
Expand Down
Loading