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
26 changes: 26 additions & 0 deletions e2e/cli/test_install_system
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Test that `mise install --system` creates symlinks in the system installs dir,
# not the user installs dir.
# Regression test for https://github.com/jdx/mise/discussions/8596

# Set up a custom system data dir within the test environment
export MISE_SYSTEM_DATA_DIR="$HOME/.local/share/mise-system"
SYSTEM_INSTALLS="$MISE_SYSTEM_DATA_DIR/installs"
USER_INSTALLS="$MISE_DATA_DIR/installs"

# Install a tool to the user dir first, then a different version to system dir.
# This reproduces the scenario where the backend's installs_path points to the
# user dir but versions also exist in the system dir.
mise install tiny@1.0.0
mise install --system tiny@2.1.0

# Both directories should have their respective version installed
assert_directory_exists "$USER_INSTALLS/tiny/1.0.0"
assert_directory_exists "$SYSTEM_INSTALLS/tiny/2.1.0"

# The system dir should have a "latest" symlink pointing to its own version
assert "readlink $SYSTEM_INSTALLS/tiny/latest" "./2.1.0"

# The user dir "latest" symlink should point to the user dir version, NOT a
# version that only exists in the system dir (which would be a broken symlink)
assert "readlink $USER_INSTALLS/tiny/latest" "./1.0.0"
105 changes: 84 additions & 21 deletions src/runtime_symlinks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,92 @@ use crate::config::{Alias, Config};
use crate::file::make_symlink_or_file;
use crate::plugins::VERSION_REGEX;
use crate::semver::split_version_prefix;
use crate::{backend, file};
use crate::{backend, env, file};
use eyre::Result;
use indexmap::IndexMap;
use itertools::Itertools;
use versions::Versioning;

pub async fn rebuild(config: &Config) -> Result<()> {
for backend in backend::list() {
let symlinks = list_symlinks(config, backend.clone());
let installs_dir = &backend.ba().installs_path;
for (from, to) in symlinks {
let from = installs_dir.join(from);
if from.exists() {
if is_runtime_symlink(&from)
&& file::resolve_symlink(&from)?.unwrap_or_default() != to
{
trace!("Removing existing symlink: {}", from.display());
file::remove_file(&from)?;
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 {
continue;
return Err(e);
}
}
make_symlink_or_file(&to, &from)?;
}
remove_missing_symlinks(backend.clone())?;
}
Ok(())
}

fn list_symlinks(config: &Config, backend: Arc<dyn Backend>) -> IndexMap<String, PathBuf> {
// TODO: make this a pure function and add test cases
fn rebuild_symlinks_in_dir(
config: &Config,
backend: &Arc<dyn Backend>,
installs_dir: &Path,
) -> Result<()> {
let symlinks = list_symlinks_for_dir(config, backend, installs_dir);
for (from, to) in symlinks {
let from = installs_dir.join(from);
if from.exists() {
if is_runtime_symlink(&from) && file::resolve_symlink(&from)?.unwrap_or_default() != to
{
trace!("Removing existing symlink: {}", from.display());
file::remove_file(&from)?;
} else {
continue;
}
}
make_symlink_or_file(&to, &from)?;
}
remove_missing_symlinks_in_dir(installs_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,
backend: &Arc<dyn Backend>,
installs_dir: &Path,
) -> IndexMap<String, PathBuf> {
let mut symlinks = IndexMap::new();
let rel_path = |x: &String| PathBuf::from(".").join(x.clone());
for v in installed_versions(&backend) {
for v in installed_versions_in_dir(installs_dir) {
let (prefix, version) = split_version_prefix(&v);
let versions = Versioning::new(version).unwrap_or_default();
let mut partial = vec![];
Expand Down Expand Up @@ -72,16 +124,27 @@ fn list_symlinks(config: &Config, backend: Arc<dyn Backend>) -> IndexMap<String,
symlinks
}

fn installed_versions(backend: &Arc<dyn Backend>) -> Vec<String> {
backend
.list_installed_versions()
/// List real (non-symlink) installed versions in a specific directory.
fn installed_versions_in_dir(installs_dir: &Path) -> Vec<String> {
if !installs_dir.is_dir() {
return vec![];
}
file::dir_subdirs(installs_dir)
.unwrap_or_default()
.into_iter()
.filter(|v| !v.starts_with('.'))
.filter(|v| !is_runtime_symlink(&installs_dir.join(v)))
.filter(|v| !installs_dir.join(v).join("incomplete").exists())
.filter(|v| !VERSION_REGEX.is_match(v))
.sorted_by_cached_key(|v| (Versioning::new(v), v.to_string()))
.collect()
}

pub fn remove_missing_symlinks(backend: Arc<dyn Backend>) -> Result<()> {
let installs_dir = &backend.ba().installs_path;
remove_missing_symlinks_in_dir(&backend.ba().installs_path)
}

fn remove_missing_symlinks_in_dir(installs_dir: &Path) -> Result<()> {
if !installs_dir.exists() {
return Ok(());
}
Expand Down
Loading