Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions crates/mise-util/src/dirs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub static SYSTEM_CONFIG: Lazy<&Path> = Lazy::new(|| &env::MISE_SYSTEM_CONFIG_DI
pub static PLUGINS: Lazy<&Path> = Lazy::new(|| &env::MISE_PLUGINS_DIR);
pub static DOWNLOADS: Lazy<&Path> = Lazy::new(|| &env::MISE_DOWNLOADS_DIR);
pub static INSTALLS: Lazy<&Path> = Lazy::new(|| &env::MISE_INSTALLS_DIR);
pub static INSTALL_STORE: Lazy<&Path> = Lazy::new(|| &env::MISE_INSTALL_STORE_DIR);
pub static COMMAND_WRAPPERS: Lazy<PathBuf> =
Lazy::new(|| DATA.join("command-wrappers").join("bin"));

Expand Down
91 changes: 91 additions & 0 deletions crates/mise-util/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,67 @@ pub static MISE_SYSTEM_CONFIG_DIR: Lazy<PathBuf> = Lazy::new(|| {
pub static MISE_INSTALLS_DIR: Lazy<PathBuf> =
Lazy::new(|| var_path("MISE_INSTALLS_DIR").unwrap_or_else(|| MISE_DATA_DIR.join("installs")));

/// Where the identity install layout keeps its installation directories. The
/// installs directory itself, except on Windows with the default installs
/// directory: there it is `<data>\i`, seven characters shorter than `installs`,
/// because the real installation path is what counts against `MAX_PATH`. Version
/// links and the catalog stay in the installs directory either way.
pub static MISE_INSTALL_STORE_DIR: Lazy<PathBuf> = Lazy::new(|| {
let installs = &*MISE_INSTALLS_DIR;
match var_path("MISE_INSTALL_STORE_DIR") {
// Inside the installs directory the store would sit in a tool's
// directory, where listing and prune take it for a version of that tool;
// such a setting is ignored. The installs directory spelled another way
// is the installs directory.
Some(store) if is_within(&store, installs) => installs.clone(),
Some(store) => store,
None if cfg!(windows) && var_path("MISE_INSTALLS_DIR").is_none() => MISE_DATA_DIR.join("i"),
None => installs.clone(),
}
});

/// Whether `path` is `dir` or inside it, however either is spelled: relative,
/// through `..`, through a symlink, or (on Windows) in another case.
fn is_within(path: &Path, dir: &Path) -> bool {
let comparable = |p: &Path| {
let p = resolved_path(p);
if cfg!(windows) {
PathBuf::from(p.to_string_lossy().replace('/', "\\").to_lowercase())
} else {
p
}
};
comparable(path).starts_with(comparable(dir))
}

/// `path` made absolute, with its longest existing ancestor resolved to the real
/// directory it names; what does not exist yet is kept as written.
fn resolved_path(path: &Path) -> PathBuf {
let lexical = || {
use path_absolutize::Absolutize;
path.absolutize()
.map_or_else(|_| path.to_path_buf(), |p| p.into_owned())
};
let Ok(absolute) = std::path::absolute(path) else {
return lexical();
};
let mut existing = absolute.as_path();
let mut missing = vec![];
loop {
if let Ok(real) = dunce::canonicalize(existing) {
return missing.iter().rev().fold(real, |p, name| p.join(name));
}
// A missing `..` cannot be resolved against the real directory.
match (existing.parent(), existing.file_name()) {
(Some(parent), Some(name)) => {
missing.push(name.to_os_string());
existing = parent;
}
_ => return lexical(),
}
}
}

pub static MISE_DOWNLOADS_DIR: Lazy<PathBuf> =
Lazy::new(|| var_path("MISE_DOWNLOADS_DIR").unwrap_or_else(|| MISE_DATA_DIR.join("downloads")));

Expand Down Expand Up @@ -1308,6 +1369,36 @@ mod launcher_args_tests {
mod tests {
use super::*;

#[test]
fn test_is_within_sees_through_spellings() {
let tmp = tempfile::tempdir().unwrap();
let data = dunce::canonicalize(tmp.path()).unwrap();
let installs = data.join("installs");
std::fs::create_dir_all(&installs).unwrap();
// inside, written plainly or through `..`
assert!(is_within(&installs.join("store"), &installs));
assert!(is_within(
&installs.join("x").join("..").join("store"),
&installs
));
assert!(is_within(&installs, &installs));
// a sibling written through the installs directory is not inside it
assert!(!is_within(&installs.join("..").join("store"), &installs));
assert!(!is_within(&data.join("installs-store"), &installs));
if cfg!(windows) {
let upper = PathBuf::from(installs.to_string_lossy().to_uppercase());
assert!(is_within(&upper.join("store"), &installs));
}
#[cfg(unix)]
{
// through a symlink to the installs directory
let link = data.join("link");
std::os::unix::fs::symlink(&installs, &link).unwrap();
assert!(is_within(&link.join("store"), &installs));
assert!(!is_within(&link.join("..").join("store"), &installs));
}
}

#[cfg(unix)]
#[test]
fn test_path_key_from_env_uses_uppercase_path_on_unix() {
Expand Down
3 changes: 3 additions & 0 deletions crates/mise-util/src/path_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ impl FromStr for PathEnv {
/// and passed to [`is_mise_install_path`] so the per-PATH-entry check stays cheap.
pub fn mise_install_dirs() -> Vec<PathBuf> {
let mut install_dirs = vec![dirs::INSTALLS.to_path_buf()];
if *dirs::INSTALL_STORE != *dirs::INSTALLS {
install_dirs.push(dirs::INSTALL_STORE.to_path_buf());
Comment thread
jdx marked this conversation as resolved.
Resolved
}
install_dirs.extend(crate::env::shared_install_dirs());
install_dirs
}
Expand Down
14 changes: 8 additions & 6 deletions crates/mise-util/src/sandbox/landlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,10 @@ pub(super) fn apply_landlock(
},
)?;
ruleset = add_read_rule(ruleset, "/dev", full_access)?;
let installs_dir: &std::path::Path = &crate::dirs::INSTALLS;
if installs_dir.exists() {
ruleset = add_path_rule(ruleset, installs_dir, read_access)?;
for installs_dir in [*crate::dirs::INSTALLS, *crate::dirs::INSTALL_STORE] {
if installs_dir.exists() {
ruleset = add_path_rule(ruleset, installs_dir, read_access)?;
}
}
ruleset = add_path_rule(ruleset, &crate::env::MISE_DATA_DIR, read_access)?;
for path in &config.allow_read {
Expand All @@ -154,9 +155,10 @@ pub(super) fn apply_landlock(
// /tmp and /dev need read access (not in SYSTEM_READ_PATHS, handled separately)
ruleset = add_read_rule(ruleset, "/tmp", read_access)?;
ruleset = add_read_rule(ruleset, "/dev", read_access)?;
let installs_dir: &std::path::Path = &crate::dirs::INSTALLS;
if installs_dir.exists() {
ruleset = add_path_rule(ruleset, installs_dir, read_access)?;
for installs_dir in [*crate::dirs::INSTALLS, *crate::dirs::INSTALL_STORE] {
if installs_dir.exists() {
ruleset = add_path_rule(ruleset, installs_dir, read_access)?;
}
}
ruleset = add_path_rule(ruleset, &crate::env::MISE_DATA_DIR, read_access)?;
for path in &config.allow_read {
Expand Down
37 changes: 29 additions & 8 deletions docs/dev-tools/install-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ installs/
```

**Installation directories.** Each installation lives in
`installs/<label>-<hash>/`. The label comes from the backend, not from the
`installs/<label>-<hash>/`. The directory that holds them is the install store.
It is the installs directory itself, except on Windows, where it is a shorter
sibling directory (see [Windows](#windows)). The label comes from the backend, not from the
registry shorthand, so a registry change never moves an existing install.
`aqua:FiloSottile/age` gives `age`, `aqua:yarnpkg/berry` gives `berry`, and
`go:github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen` gives
Expand Down Expand Up @@ -120,7 +122,8 @@ same bytes.
**The catalog.** `installs/.mise/` records which directory each identity was
assigned and which installation each unlocked request selected. It is durable
metadata, not a cache. Keep it with the installs it describes when you copy or
cache the installs directory.
cache the installs directory, together with the install store when that is a
separate directory (on Windows, `i` beside `installs`; see [Windows](#windows)).

**Version links.** `installs/<tool>/<version>` links to the installation, so
paths you wrote down, such as an IDE SDK entry, keep working. A link can point at
Expand Down Expand Up @@ -277,7 +280,7 @@ Both work on one installation directory at a time.
- `mise uninstall age@1.2.1` removes the installation directory, and the version
links that name it from every tool directory that has one. It does not follow a
link to decide what to delete, and it refuses to remove a path directly under
the installs directory that has neither a receipt nor a reservation in the
the install store that has neither a receipt nor a reservation in the
catalog. Removing one variant leaves the others in place.
- Removing an installation keeps its catalog record, so installing the same
identity again lands in the same directory.
Expand Down Expand Up @@ -312,13 +315,31 @@ The layout works the same way on Windows, with these differences:
location keeps pointing at the old one. mise finds installations through the
catalog, relative to the installs directory it is using, so this affects
programs that follow the link, not mise.
- **A shorter real path.** Installations go into `%LOCALAPPDATA%\mise\i\`
instead of `installs\`, seven characters shorter, because the real path is
where installers extract files and what counts toward the 260-character limit.
The version links, runtime aliases and catalog stay in `installs\`, so
`installs\java\21` keeps working in an IDE:

```text
%LOCALAPPDATA%\mise\
installs\
jq\1.7.1 -> %LOCALAPPDATA%\mise\i\jq-ezjqmxa4 junction
.mise\ catalog
i\
jq-ezjqmxa4\ the installation
```

Setting `MISE_INSTALLS_DIR` keeps installations in that directory, as on other
platforms. `MISE_INSTALL_STORE_DIR` chooses where installations go, on any
platform, without moving the links.

- **Path length.** The installation's directory name has a bounded length: a label
of at most 24 characters, a dash, and eight hash characters, longer only after a
collision. It sits directly under the installs directory, whatever the version
string looks like. The installs directory itself,
`%LOCALAPPDATA%\mise\installs`, and the paths inside tools still count toward
the 260-character limit, so a shorter `MISE_INSTALLS_DIR` remains the main
lever. mise does not shorten the default installs directory in this release.
collision. It sits directly under the install store, whatever the version
string looks like. The data directory and the paths inside tools still count
toward the limit, so a short `MISE_INSTALL_STORE_DIR` (such as `C:\m`) is the
main lever left.

## Downgrading and compatibility

Expand Down
10 changes: 9 additions & 1 deletion docs/directories.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ This is where plugins may write downloaded assets such as tarballs during instal
default after install/uninstall; set `always_keep_download` to keep them for debugging backend/plugin install behavior.
This directory is not a supported download cache. Some backends may skip a download when the expected file already exists,
but that behavior is backend-specific and not guaranteed. Cache `~/.local/share/mise/installs` instead if you want to
avoid reinstalling tools in CI or offline workflows.
avoid reinstalling tools in CI or offline workflows, together with the install store when it is a separate directory
(`MISE_INSTALL_STORE_DIR`, or `%LOCALAPPDATA%\mise\i` on Windows with the identity [install layout](/dev-tools/install-layout.html)).

### `~/.local/share/mise/plugins`

Expand All @@ -96,6 +97,13 @@ remembers each installation's directory and which installation an unlocked reque
selected. It is durable metadata, not a cache: keep it with the installs it
describes. Installations made before you enabled the layout stay where they are.

On Windows the installations themselves go into `i` beside `installs`
(`%LOCALAPPDATA%\mise\i\age-hlencrst`), a shorter path that leaves more room
under the 260-character limit. The links, runtime aliases and catalog stay in
`installs`. Set `MISE_INSTALL_STORE_DIR` to choose where installations go on any
platform; it defaults to the installs directory, and on Windows to `i` unless
`MISE_INSTALLS_DIR` is set. A directory inside the installs directory is ignored.

You can set the `MISE_INSTALLS_DIR` environment variable to override this location.

`MISE_INSTALLS_DIR` is read when mise starts. Set it in the environment before invoking mise and keep
Expand Down
22 changes: 14 additions & 8 deletions e2e-win/install_layout.Tests.ps1
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
Describe 'install layout' {
# The identity install layout (jdx/mise#13678, behind install_layout = "identity"): an installation lives in
# installs\<label>-<hash>, and installs\<short>\<version> plus the runtime aliases (latest, 1.7)
# are links to it. On Windows they have to be real junctions: a text file standing in for a
# i\<label>-<hash> beside the installs directory (a shorter path than installs\, for MAX_PATH),
# and installs\<short>\<version> plus the runtime aliases (latest, 1.7) are links to it. On Windows they have to be real junctions: a text file standing in for a
# link is invisible to the IDE SDK selectors and external tools this layout keeps working
# for, and a junction is what makes Test-Path and Get-ChildItem see through to the install.

BeforeAll {
$script:OriginalLocation = Get-Location
$script:Saved = @{}
foreach ($name in 'MISE_DATA_DIR', 'MISE_CONFIG_FILE', 'MISE_TRUSTED_CONFIG_PATHS', 'MISE_EXPERIMENTAL', 'MISE_INSTALL_LAYOUT', 'MISE_YES') {
foreach ($name in 'MISE_DATA_DIR', 'MISE_INSTALLS_DIR', 'MISE_INSTALL_STORE_DIR', 'MISE_CONFIG_FILE', 'MISE_TRUSTED_CONFIG_PATHS', 'MISE_EXPERIMENTAL', 'MISE_INSTALL_LAYOUT', 'MISE_YES') {
$script:Saved[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
}

$script:Root = Join-Path $TestDrive ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $script:Root | Out-Null
Set-Location $script:Root
$env:MISE_DATA_DIR = Join-Path $script:Root 'data'
# the default installs directory, which is what moves installations to the short store
Remove-Item -Path Env:\MISE_INSTALLS_DIR, Env:\MISE_INSTALL_STORE_DIR -ErrorAction SilentlyContinue
$env:MISE_CONFIG_FILE = Join-Path $script:Root 'mise.toml'
$env:MISE_TRUSTED_CONFIG_PATHS = $script:Root
$env:MISE_EXPERIMENTAL = '1'
Expand All @@ -24,6 +26,7 @@ Describe 'install layout' {
'' | Out-File -FilePath $env:MISE_CONFIG_FILE -Encoding utf8NoBOM

$script:Installs = Join-Path $env:MISE_DATA_DIR 'installs'
$script:Store = Join-Path $env:MISE_DATA_DIR 'i'
$script:ToolDir = Join-Path $script:Installs 'jq'

# Whether an entry is there, asked by enumerating the parent rather than by resolving the
Expand Down Expand Up @@ -61,13 +64,16 @@ Describe 'install layout' {
}
}

It 'installs into a hashed directory next to the tool directory' {
It 'installs into a hashed directory in the short store beside the installs directory' {
$script:InstallExit | Should -Be 0
$script:Name171 | Should -Match '^jq-[a-z2-7]{8}$'
$script:Name182 | Should -Match '^jq-[a-z2-7]{8}$'
$script:Name171 | Should -Not -Be $script:Name182
(Split-Path (Split-Path $script:Dir171 -Parent) -Leaf) | Should -Be 'installs'
(Split-Path (Split-Path $script:Dir182 -Parent) -Leaf) | Should -Be 'installs'
(Split-Path $script:Dir171 -Parent) | Should -Be $script:Store
(Split-Path $script:Dir182 -Parent) | Should -Be $script:Store
# the catalog stays with the links, in the installs directory
Test-Path -LiteralPath (Join-Path $script:Installs ".mise\names\$($script:Name171)") | Should -BeTrue
Test-Path -LiteralPath (Join-Path $script:Store '.mise') | Should -BeFalse
}

It 'reports a real, existing directory from mise where' {
Expand Down Expand Up @@ -173,8 +179,8 @@ Describe 'install layout' {
$LASTEXITCODE | Should -Be 0
Test-Path -LiteralPath $script:Dir171 | Should -BeFalse
Test-Path -LiteralPath $script:Dir182 | Should -BeFalse
# nothing is left behind in the installs directory but the catalog
$left = Get-ChildItem -LiteralPath $script:Installs -Force |
# nothing is left behind in the store
$left = Get-ChildItem -LiteralPath $script:Store -Force |
Where-Object { $_.Name -like 'jq-*' } | ForEach-Object { $_.Name }
$left | Should -BeNullOrEmpty
foreach ($version in '1.7.1', '1.8.2') {
Expand Down
72 changes: 72 additions & 0 deletions e2e/cli/test_install_layout_store
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# A separate install store (the default on Windows, where it is <data>\i): the
# installations live in MISE_INSTALL_STORE_DIR, while the version links, runtime
# aliases and the catalog stay in the installs directory.
export MISE_EXPERIMENTAL=1
export MISE_INSTALL_LAYOUT=identity
export MISE_INSTALL_STORE_DIR="$MISE_DATA_DIR/i"

installs="$MISE_DATA_DIR/installs"
store="$MISE_INSTALL_STORE_DIR"

assert "mise install dummy@1.1.0 dummy@2.0.0"
dir_1="$(mise where dummy@1.1.0)"
dir_2="$(mise where dummy@2.0.0)"
name_1="$(basename "$dir_1")"
name_2="$(basename "$dir_2")"

echo "=== installations go in the store ==="
assert_matches "mise where dummy@1.1.0" "^$store/dummy-[a-z2-7]{8}\$"
assert "test -f '$dir_1/.mise-install.toml'"
assert "test ! -e '$installs/$name_1'"

echo "=== links, aliases and the catalog stay in the installs directory ==="
assert "readlink '$installs/dummy/1.1.0'" "../../i/$name_1"
assert "readlink '$installs/dummy/2.0.0'" "../../i/$name_2"
assert "readlink '$installs/dummy/latest'" "./2.0.0"
assert_contains "$installs/dummy/latest/bin/dummy" "This is Dummy 2.0.0!"
assert "test -f '$installs/.mise/names/$name_1'"
assert "test ! -e '$store/.mise'"

echo "=== listing and PATH see through the store ==="
assert_contains "mise ls dummy" "1.1.0"
assert_contains "mise ls dummy" "2.0.0"
assert_not_contains "mise ls" "dummy-"
echo 'tools.dummy = "1"' >mise.toml
assert "mise which dummy" "$installs/dummy/1/bin/dummy"
assert_contains "mise x -- dummy" "This is Dummy 1.1.0!"

echo "=== a lost catalog is rebuilt from the store's receipts ==="
rm -rf "$installs/.mise"
assert_contains "mise ls dummy" "1.1.0"
assert "test -f '$installs/.mise/names/$name_1'"
assert "mise where dummy@1.1.0" "$dir_1"

echo "=== uninstall and prune remove the installation and its links ==="
assert "mise uninstall dummy@2.0.0"
assert "test ! -e '$dir_2'"
assert "test ! -L '$installs/dummy/2.0.0'"
assert "test -d '$dir_1'"
rm mise.toml
assert "mise prune --yes dummy"
assert "test ! -e '$dir_1'"
assert "test ! -L '$installs/dummy/1.1.0'"

echo "=== a reinstall lands in the same directory ==="
assert "mise install dummy@1.1.0"
assert "mise where dummy@1.1.0" "$dir_1"

echo "=== a store inside the installs directory is ignored ==="
assert "MISE_INSTALL_STORE_DIR='$installs/dummy/store' mise install dummy@2.0.0"
assert_matches "MISE_INSTALL_STORE_DIR='$installs/dummy/store' mise where dummy@2.0.0" "^$installs/dummy-[a-z2-7]{8}\$"
assert "test ! -e '$installs/dummy/store'"

echo "=== installs directories sharing a store share its installations ==="
other="$HOME/other-installs"
assert "MISE_INSTALLS_DIR='$other' mise install dummy@1.1.0"
assert "MISE_INSTALLS_DIR='$other' mise where dummy@1.1.0" "$dir_1"
assert "readlink '$other/dummy/1.1.0'" "$dir_1"

echo "=== the store written through the installs directory is still the store ==="
beside="$(MISE_INSTALL_STORE_DIR="$installs/../i" mise where dummy@1.1.0)"
assert "realpath '$beside'" "$(realpath "$dir_1")"
Loading
Loading