diff --git a/e2e-win/env_cache_venv.Tests.ps1 b/e2e-win/env_cache_venv.Tests.ps1 new file mode 100644 index 0000000000..6311ec5749 --- /dev/null +++ b/e2e-win/env_cache_venv.Tests.ps1 @@ -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") + $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" + } +} diff --git a/e2e/env/test_env_cache_venv b/e2e/env/test_env_cache_venv new file mode 100644 index 0000000000..f876d877f5 --- /dev/null +++ b/e2e/env/test_env_cache_venv @@ -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" <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 .. diff --git a/src/toolset/toolset_env.rs b/src/toolset/toolset_env.rs index 9bc2fb93e9..5d0f581a8b 100644 --- a/src/toolset/toolset_env.rs +++ b/src/toolset/toolset_env.rs @@ -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, diff --git a/src/uv.rs b/src/uv.rs index 780edc861a..f9ef573669 100644 --- a/src/uv.rs +++ b/src/uv.rs @@ -74,7 +74,7 @@ pub async fn uv_venv(config: &Arc, ts: &Toolset) -> &'static Option Option { +pub(crate) fn uv_root() -> Option { file::find_up(dirs::CWD.as_ref()?, &["uv.lock"]).map(|p| p.parent().unwrap().to_path_buf()) }