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
60 changes: 60 additions & 0 deletions e2e-win/env_cache_venv.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Regression test for the Windows path of the env_cache / uv venv interaction.
# See e2e/env/test_env_cache_venv for the full rationale: the env cache key did
# not account for the uv.lock / .venv that python.uv_venv_auto discovers via
# find_up, so a venv leaked across directories sharing the same config files.

Describe 'env_cache uv venv' {
BeforeAll {
$script:OriginalDir = Get-Location

$env:MISE_EXPERIMENTAL = "1"
$env:MISE_ENV_CACHE = "1"
# Fixed encryption key, as `mise activate` would set.
$env:__MISE_ENV_CACHE_KEY = "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q="

$script:TestRoot = Join-Path $TestDrive ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $script:TestRoot | Out-Null
$env:MISE_STATE_DIR = Join-Path $script:TestRoot "state"

# venv auto-source lives in the parent config so both child dirs resolve
# the same config files (and thus the same cache key); neither child has
# its own config.
@"
[settings]
python.uv_venv_auto = "source"
"@ | Out-File (Join-Path $script:TestRoot ".mise.toml")
Comment on lines +22 to +25
$env:MISE_TRUSTED_CONFIG_PATHS = $script:TestRoot

# "source" mode only needs an existing .venv directory and a uv.lock, so
# no python/uv install is required.
$script:Proj = Join-Path $script:TestRoot "proj"
$script:Other = Join-Path $script:TestRoot "other"
New-Item -ItemType Directory -Force -Path (Join-Path $script:Proj ".venv\Scripts") | Out-Null
New-Item -ItemType Directory -Force -Path $script:Other | Out-Null
New-Item -ItemType File -Force -Path (Join-Path $script:Proj "uv.lock") | Out-Null
}

AfterAll {
Set-Location $script:OriginalDir
Remove-Item Env:MISE_EXPERIMENTAL, Env:MISE_ENV_CACHE, Env:__MISE_ENV_CACHE_KEY, `
Env:MISE_STATE_DIR, Env:MISE_TRUSTED_CONFIG_PATHS -ErrorAction Ignore
}

It 'does not leak the venv into a sibling without a uv.lock' {
Set-Location $script:Proj
(mise env | Out-String) | Should -Match "VIRTUAL_ENV"

Set-Location $script:Other
(mise env | Out-String) | Should -Not -Match "VIRTUAL_ENV"
}

It 'does not let a cached non-venv sibling suppress the venv' {
Remove-Item (Join-Path $env:MISE_STATE_DIR "env-cache") -Recurse -Force -ErrorAction Ignore

Set-Location $script:Other
(mise env | Out-String) | Should -Not -Match "VIRTUAL_ENV"

Set-Location $script:Proj
(mise env | Out-String) | Should -Match "VIRTUAL_ENV"
}
}
60 changes: 60 additions & 0 deletions e2e/env/test_env_cache_venv
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Regression test: env_cache must not leak a uv venv across directories.
#
# The env cache key (compute_env_cache_key in src/toolset/toolset_env.rs) is
# derived from the resolved config files (+ sibling mise.lock), tool versions,
# settings and the base PATH. But NOT from the current directory or the
# uv.lock / .venv location that `python.uv_venv_auto` discovers via find_up.
#
# As a result two directories that resolve the same config files share one
# cache key, even though only one of them contains a uv venv. Whichever
# directory computes its env first wins for both, so:
# * the venv "stays active" after leaving the project dir, and
# * a project venv can fail to activate if a non-venv sibling cached first.
#
# Both are the same root cause and both are exercised below. A correct fix
# (making the cache key aware of cwd / uv.lock) satisfies both; a fix that
# merely marks the venv directive uncacheable would still fail scenario 2.

set -euo pipefail

export MISE_EXPERIMENTAL=1
# Enable env caching with a fixed encryption key, as `mise activate` would set.
export MISE_ENV_CACHE=1
export __MISE_ENV_CACHE_KEY="dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q="

# uv venv auto-source lives in the GLOBAL config so that both directories below
# resolve the exact same set of config files -> the same cache key. Neither
# directory has a local mise.toml.
cat >"$MISE_CONFIG_DIR/config.toml" <<EOF
[settings]
python.uv_venv_auto = "source"
EOF

# "source" mode only needs an existing .venv directory and a uv.lock; it does
# not create anything, so no python/uv install is required for this test.
mkdir -p proj/.venv/bin other
: >proj/uv.lock

# Sanity check: the venv activates inside the project directory.
cd proj
assert_contains "mise env -s bash" "VIRTUAL_ENV=$PWD/.venv"
cd ..

# Scenario 1 -- venv must not leak into a sibling without a uv.lock.
# (Equivalent to "VIRTUAL_ENV stays set after leaving the venv directory".)
# proj computed first and cached its env (with venv) under the shared key;
# `other` then gets a cache hit and wrongly inherits VIRTUAL_ENV.
cd other
assert_not_contains "mise env -s bash" "VIRTUAL_ENV"
cd ..

# Scenario 2 -- a non-venv sibling must not suppress the project's venv.
# Start from a clean cache, visit `other` first (caches a "no venv" env under
# the shared key), then the project dir must STILL activate its venv.
rm -rf "${MISE_STATE_DIR:?}/env-cache"
cd other
assert_not_contains "mise env -s bash" "VIRTUAL_ENV"
cd ../proj
assert_contains "mise env -s bash" "VIRTUAL_ENV=$PWD/.venv"
cd ..
17 changes: 16 additions & 1 deletion src/toolset/toolset_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,23 @@ impl Toolset {
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();

// Include the auto-sourced uv venv (uv.lock + .venv) in the key so a venv
// dir and a sibling sharing the same config files don't collide on one
// cache entry, which would leak the venv across directories.
let mut uv_venv_inputs: Vec<(PathBuf, u64)> = Vec::new();
if Settings::get().python.uv_venv_auto.should_source()
&& let Some(uv_root) = uv::uv_root()
{
let lock = uv_root.join("uv.lock");
let venv = uv_root.join(".venv");
let lock_mtime = get_file_mtime(&lock).unwrap_or(0);
let venv_mtime = get_file_mtime(&venv).unwrap_or(0);
uv_venv_inputs.push((lock, lock_mtime));
uv_venv_inputs.push((venv, venv_mtime));
}

Ok(CachedEnv::compute_cache_key(
&[config_files, config_lockfiles].concat(),
&[config_files, config_lockfiles, uv_venv_inputs].concat(),
&tool_versions,
&settings_hash,
&base_path,
Expand Down
2 changes: 1 addition & 1 deletion src/uv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ pub async fn uv_venv(config: &Arc<Config>, ts: &Toolset) -> &'static Option<Venv
.await
}

fn uv_root() -> Option<PathBuf> {
pub(crate) fn uv_root() -> Option<PathBuf> {
file::find_up(dirs::CWD.as_ref()?, &["uv.lock"]).map(|p| p.parent().unwrap().to_path_buf())
}
Comment on lines +77 to 79

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

find_up always returns a path ending in uv.lock, so parent() is never None


Expand Down
Loading