From 571af7dc6159e4be9dd6a4c100af844476663c27 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 22:25:09 +0000 Subject: [PATCH 1/4] Start fix for #334, #384 Assisted-by: Claude Code:claude-opus-5-5 From ee698556f5768e3b291a9a94aa143ea7313e89b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 22:36:24 +0000 Subject: [PATCH 2/4] Find the venv Pipenv actually uses Agent mode picked a Pipenv project's venv with a generic probe order, so it could patch the wrong venv, or none, and still exit 0: - an activated VIRTUAL_ENV won even with PIPENV_ACTIVE or PIPENV_IGNORE_VIRTUALENVS set, which patched another project's or a tool's venv (#384) - a stray venv/ directory, or a ./.venv that PIPENV_VENV_IN_PROJECT=0 or the Pipfile's [pipenv] venv_in_project = false rules out, shadowed Pipenv's WORKON_HOME venv (#334) Discovery now follows Pipenv's own resolution for Pipenv projects. With an auto-detected .venv and an existing WORKON_HOME venv, both are returned, because Pipenv 2026.2+ and older releases disagree. Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 6 +- .../tests/in_process_python_envs.rs | 109 ++++++ .../src/crawlers/python_crawler.rs | 368 +++++++++++++++++- docs/testing/pipenv-compatibility.md | 2 +- 4 files changed, 462 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4b77c0322..48d9377ea 100644 --- a/README.md +++ b/README.md @@ -333,7 +333,11 @@ Fresh checkouts work in every mode: a clone with only `Pipfile` + `Pipfile.lock` is discovered from the lock (hosted redirects it, vendored fetches the pristine wheel by one of the lock's recorded digests), and agent mode finds Pipenv's default out-of-tree virtualenv under `WORKON_HOME` -without `pipenv run`. +without `pipenv run`. Agent mode picks the venv the way Pipenv does: an +activated `VIRTUAL_ENV` only without `PIPENV_ACTIVE` / +`PIPENV_IGNORE_VIRTUALENVS`, `./.venv` subject to `PIPENV_VENV_IN_PROJECT` +and the Pipfile's `[pipenv] venv_in_project`, and never a `venv/` +directory. Pipenv never reinstalls a release that is already present: `pipenv install`, `pipenv install --deploy` and `pipenv sync` all exit 0 and keep the installed diff --git a/crates/socket-patch-cli/tests/in_process_python_envs.rs b/crates/socket-patch-cli/tests/in_process_python_envs.rs index 29e1c4ecb..7ebf08e01 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -524,3 +524,112 @@ async fn pypi_ambient_virtual_env_does_not_hijack_scan() { assert_discovered(&bodies, "pkg:pypi/local-pkg@1.0.0"); assert_not_discovered(&bodies, "pkg:pypi/ambient-decoy@6.6.6"); } + +// --------------------------------------------------------------------------- +// Pipenv projects: the venv Pipenv resolves, not the generic probe order +// --------------------------------------------------------------------------- + +/// Pipenv's environment knobs, cleared before each Pipenv test and after it +/// so ambient values (a `pipenv shell`, CI images) cannot leak in or out. +const PIPENV_VARS: &[&str] = &[ + "VIRTUAL_ENV", + "WORKON_HOME", + "PIPENV_ACTIVE", + "PIPENV_IGNORE_VIRTUALENVS", + "PIPENV_NO_IGNORE_VIRTUALENVS", + "PIPENV_VENV_IN_PROJECT", + "PIPENV_NO_VENV_IN_PROJECT", + "PIPENV_CUSTOM_VENV_NAME", + "PIPENV_PIPFILE", +]; + +/// Run `scan` with exactly `env` set among [`PIPENV_VARS`]. +async fn scan_with_pipenv_env(args: ScanArgs, env: &[(&str, &Path)]) -> i32 { + for name in PIPENV_VARS { + std::env::remove_var(name); + } + for (name, value) in env { + std::env::set_var(name, value); + } + let code = scan_run(args).await; + for name in PIPENV_VARS { + std::env::remove_var(name); + } + code +} + +/// A Pipenv project (`/proj` with a Pipfile) whose Pipenv venv is +/// `/wh/proj-env` (named through `PIPENV_CUSTOM_VENV_NAME`) holding +/// `pipenv_pkg 1.0.0`. Returns `(tmp, project, workon_home)`. +fn pipenv_project() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("Pipfile"), "[packages]\npipenv-pkg = \"*\"\n").unwrap(); + let workon = tmp.path().join("wh"); + let site = venv_site_packages(&workon.join("proj-env"), "python3.12"); + std::fs::create_dir_all(&site).unwrap(); + write_dist_info(&site, "pipenv_pkg", "1.0.0"); + (tmp, project, workon) +} + +/// #384: with `PIPENV_IGNORE_VIRTUALENVS` or `PIPENV_ACTIVE` set, Pipenv +/// ignores the activated `VIRTUAL_ENV`, so scan must look at Pipenv's own +/// venv and leave the activated one (another project's, a tool venv) alone. +#[tokio::test] +#[serial] +async fn pipenv_opt_outs_keep_activated_virtual_env_from_hijacking_scan() { + let other = tempfile::tempdir().unwrap(); + let decoy = other.path().join("tool-venv"); + let decoy_site = venv_site_packages(&decoy, "python3.12"); + std::fs::create_dir_all(&decoy_site).unwrap(); + write_dist_info(&decoy_site, "activated_decoy", "6.6.6"); + + for opt_out in ["PIPENV_IGNORE_VIRTUALENVS", "PIPENV_ACTIVE"] { + let (_tmp, project, workon) = pipenv_project(); + let server = MockServer::start().await; + mock_batch_empty(&server).await; + let one = Path::new("1"); + let code = scan_with_pipenv_env( + default_args(&project, server.uri()), + &[ + ("VIRTUAL_ENV", &decoy), + ("WORKON_HOME", &workon), + ("PIPENV_CUSTOM_VENV_NAME", Path::new("proj-env")), + (opt_out, one), + ], + ) + .await; + assert_eq!(code, 0, "{opt_out}"); + let bodies = batch_bodies(&server).await; + assert_discovered(&bodies, "pkg:pypi/pipenv-pkg@1.0.0"); + assert_not_discovered(&bodies, "pkg:pypi/activated-decoy@6.6.6"); + } +} + +/// #334: Pipenv never uses `venv/`, and `PIPENV_VENV_IN_PROJECT=0` makes it +/// ignore a `./.venv` directory, so neither may shadow Pipenv's venv. +#[tokio::test] +#[serial] +async fn pipenv_stray_venv_dirs_do_not_shadow_the_pipenv_venv() { + for (stray, opt_out) in [("venv", None), (".venv", Some("0"))] { + let (_tmp, project, workon) = pipenv_project(); + let stray_site = venv_site_packages(&project.join(stray), "python3.12"); + std::fs::create_dir_all(&stray_site).unwrap(); + write_dist_info(&stray_site, "stray_decoy", "6.6.6"); + let server = MockServer::start().await; + mock_batch_empty(&server).await; + let mut env: Vec<(&str, &Path)> = vec![ + ("WORKON_HOME", &workon), + ("PIPENV_CUSTOM_VENV_NAME", Path::new("proj-env")), + ]; + if let Some(value) = opt_out { + env.push(("PIPENV_VENV_IN_PROJECT", Path::new(value))); + } + let code = scan_with_pipenv_env(default_args(&project, server.uri()), &env).await; + assert_eq!(code, 0, "{stray}"); + let bodies = batch_bodies(&server).await; + assert_discovered(&bodies, "pkg:pypi/pipenv-pkg@1.0.0"); + assert_not_discovered(&bodies, "pkg:pypi/stray-decoy@6.6.6"); + } +} diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 001ed5c17..ca6a2b25e 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -265,21 +265,51 @@ async fn find_site_packages_under( /// Find local virtual environment `site-packages` directories. /// /// Checks (in order): -/// 1. `VIRTUAL_ENV` environment variable -/// 2. `.venv` directory in `cwd` -/// 3. `venv` directory in `cwd` -/// 4. Poetry's out-of-tree virtualenv(s) for a Poetry project (see +/// 1. `VIRTUAL_ENV` environment variable (for a Pipenv project, only when +/// Pipenv itself would use it) +/// 2. For a Pipenv project, the venv(s) Pipenv resolves for it (see +/// [`pipenv_project_site_packages`]) +/// 3. `.venv` directory in `cwd` +/// 4. `venv` directory in `cwd` +/// 5. Poetry's out-of-tree virtualenv(s) for a Poetry project (see /// [`find_poetry_virtualenv_site_packages`]) pub async fn find_local_venv_site_packages(cwd: &Path) -> Vec { + let var = |name: &str| std::env::var(name).ok(); + find_local_venv_site_packages_with(cwd, &var).await +} + +/// [`find_local_venv_site_packages`] over an explicit environment (tests pass +/// a closure instead of mutating the process environment). +async fn find_local_venv_site_packages_with( + cwd: &Path, + var: &impl Fn(&str) -> Option, +) -> Vec { let mut results = Vec::new(); + let pipenv = is_pipenv_project(cwd); + + // 1. Check VIRTUAL_ENV env var. Pipenv ignores it under `PIPENV_ACTIVE` + // (a `pipenv shell` started in another project) and + // `PIPENV_IGNORE_VIRTUALENVS`, so for a Pipenv project the activated venv + // then belongs to something else and must not be patched. + if !pipenv || pipenv_uses_virtual_env(var) { + if let Some(virtual_env) = var("VIRTUAL_ENV") { + let venv_path = PathBuf::from(&virtual_env); + let matches = find_site_packages_under(&venv_path, "site-packages").await; + results.extend(matches); + if !results.is_empty() { + return results; + } + } + } - // 1. Check VIRTUAL_ENV env var - if let Ok(virtual_env) = std::env::var("VIRTUAL_ENV") { - let venv_path = PathBuf::from(&virtual_env); - let matches = find_site_packages_under(&venv_path, "site-packages").await; - results.extend(matches); - if !results.is_empty() { - return results; + // 2. A Pipenv project's venv is whatever Pipenv resolves, which is not + // the generic probe order below: Pipenv never uses `venv/`, and its + // in-project settings can rule out an existing `./.venv`. Only when + // Pipenv's venv does not exist yet do the generic probes run. + if pipenv { + let found = pipenv_project_site_packages(cwd, var).await; + if !found.is_empty() { + return found; } } @@ -298,16 +328,106 @@ pub async fn find_local_venv_site_packages(cwd: &Path) -> Vec { results.extend(find_poetry_virtualenv_site_packages(cwd).await); } - // 4. Pipenv keeps its virtualenv OUTSIDE the project by default - // (`$WORKON_HOME/-`), so a plain `pipenv install` leaves - // nothing above to find and the crawl used to fall through to the global - // interpreter's site-packages — patching the wrong Python (or nothing) - // and reporting success. Measured on real Pipenv 11.10.4, 2018.11.26 and - // 2026.8.0. - if results.is_empty() { - results.extend(find_pipenv_virtualenv_site_packages(cwd).await); + results +} + +/// Whether `cwd` is a Pipenv project: a `Pipfile` or a `Pipfile.lock`. +fn is_pipenv_project(cwd: &Path) -> bool { + cwd.join("Pipfile").is_file() || cwd.join("Pipfile.lock").is_file() +} + +/// Pipenv's `get_from_env(arg)` for a boolean setting: `PIPENV_`, else +/// the negated `PIPENV_NO_`. `Ok` for a value Pipenv's `env_to_bool` +/// understands (`1/true/yes/on`, `0/false/no/off`, any case), `Err` with the +/// raw text otherwise (Pipenv then keeps the string), `None` when unset. +fn pipenv_env_setting( + var: &impl Fn(&str) -> Option, + arg: &str, +) -> Option> { + let parse = |value: String| match value.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + _ => Err(value), + }; + if let Some(value) = var(&format!("PIPENV_{arg}")) { + return Some(parse(value)); } + var(&format!("PIPENV_NO_{arg}")).map(|value| parse(value).map(|flag| !flag)) +} +/// Whether Pipenv would take `VIRTUAL_ENV` as the project's venv: only when +/// `PIPENV_ACTIVE` is absent (any value counts) and +/// `bool(PIPENV_IGNORE_VIRTUALENVS)` is false. The same test in every Pipenv +/// from 2018.11 through 2026.8 (`Project.virtualenv_location`, later +/// `VenvLocator.location`). +fn pipenv_uses_virtual_env(var: &impl Fn(&str) -> Option) -> bool { + let ignore = match pipenv_env_setting(var, "IGNORE_VIRTUALENVS") { + Some(Ok(flag)) => flag, + Some(Err(text)) => !text.is_empty(), + None => false, + }; + var("PIPENV_ACTIVE").is_none() && !ignore +} + +/// An explicit in-project choice for the Pipenv project at `cwd`, if any: +/// `PIPENV_VENV_IN_PROJECT` (or `PIPENV_NO_VENV_IN_PROJECT`) first, then +/// Pipenv 2026.2+'s Pipfile `[pipenv] venv_in_project`. A non-boolean, +/// non-empty variable counts as "yes", as `setting or ` did +/// through Pipenv 2026.1. +fn pipenv_venv_in_project(cwd: &Path, var: &impl Fn(&str) -> Option) -> Option { + match pipenv_env_setting(var, "VENV_IN_PROJECT") { + Some(Ok(flag)) => return Some(flag), + Some(Err(text)) if !text.is_empty() => return Some(true), + _ => {} + } + let text = std::fs::read_to_string(cwd.join("Pipfile")).ok()?; + let doc = text.parse::().ok()?; + let value = doc.get("pipenv")?.get("venv_in_project")?.as_value()?; + // Python's `bool(value)` for the scalar shapes a Pipfile can hold. + match value { + toml_edit::Value::Boolean(flag) => Some(*flag.value()), + toml_edit::Value::Integer(number) => Some(*number.value() != 0), + toml_edit::Value::String(text) => Some(!text.value().is_empty()), + _ => None, + } +} + +/// `site-packages` of the venv(s) Pipenv uses for the project at `cwd` +/// (`VenvLocator.get_location`, `VIRTUAL_ENV` aside), most likely first: +/// +/// - No `./.venv` directory: Pipenv's own placement (a `.venv` file pointer +/// or `$WORKON_HOME/-`, see +/// [`find_pipenv_virtualenv_site_packages`]). An explicit "in project" +/// with no `./.venv` means Pipenv has no venv yet, so nothing. +/// - A `./.venv` directory and an explicit "in project": `./.venv` only. +/// - A `./.venv` directory and an explicit "not in project": the +/// WORKON_HOME venv only (Pipenv 2023+ ignores `./.venv` then). +/// - A `./.venv` directory and nothing explicit: Pipenv up to 2026.1 uses +/// it, 2026.2+ prefers an existing WORKON_HOME venv. Without running +/// Pipenv the version is unknown, so both are returned, WORKON_HOME +/// first, and whichever one the installed Pipenv uses gets patched. +/// +/// Never `./venv`: no Pipenv release uses it. +async fn pipenv_project_site_packages( + cwd: &Path, + var: &impl Fn(&str) -> Option, +) -> Vec { + let in_project = pipenv_venv_in_project(cwd, var); + let dot_venv = cwd.join(".venv"); + if !dot_venv.is_dir() { + if in_project == Some(true) && !dot_venv.exists() { + return Vec::new(); + } + return find_pipenv_virtualenv_site_packages_with(cwd, var).await; + } + let in_tree = find_site_packages_under(&dot_venv, "site-packages").await; + if in_project == Some(true) { + return in_tree; + } + let mut results = find_pipenv_virtualenv_site_packages_with(cwd, var).await; + if in_project.is_none() { + results.extend(in_tree); + } results } @@ -651,8 +771,7 @@ async fn find_pipenv_virtualenv_site_packages_with( cwd: &Path, var: &impl Fn(&str) -> Option, ) -> Vec { - let is_file = |leaf: &str| cwd.join(leaf).is_file(); - if !is_file("Pipfile") && !is_file("Pipfile.lock") { + if !is_pipenv_project(cwd) { return Vec::new(); } let mut venvs: Vec = Vec::new(); @@ -1878,6 +1997,213 @@ mod tests { ); } + /// A Pipenv project `proj` with Pipenv's default WORKON_HOME venv laid + /// out, plus an environment closure over `extra` (and WORKON_HOME). + /// Returns `(tmp, project, workon site-packages, var)`. + fn pipenv_project_with_workon_venv( + extra: &[(&'static str, String)], + ) -> ( + tempfile::TempDir, + PathBuf, + PathBuf, + impl Fn(&str) -> Option, + ) { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("Pipfile"), "[packages]\nsix = \"==1.16.0\"\n").unwrap(); + let workon = tmp.path().join("wh"); + let real = std::fs::canonicalize(&project).unwrap(); + let hash = pipenv_venv_hash(&pipenv_path_string(&real.join("Pipfile"))); + let site = fake_venv(&workon, &format!("proj-{hash}")); + let mut env: Vec<(&'static str, String)> = + vec![("WORKON_HOME", workon.to_string_lossy().into_owned())]; + env.extend(extra.iter().cloned()); + let var = move |name: &str| { + env.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| value.clone()) + }; + (tmp, project, site, var) + } + + /// #384: Pipenv uses `VIRTUAL_ENV` only when neither `PIPENV_ACTIVE` nor + /// `PIPENV_IGNORE_VIRTUALENVS` is set (`VenvLocator.location` / + /// `Project.virtualenv_location`, 2022.12 through 2026.8). With either + /// opt-out, the activated venv belongs to something else (another + /// project's `pipenv shell`, a tool venv) and must not be patched. + #[tokio::test] + async fn pipenv_opt_outs_keep_virtual_env_from_hijacking_the_project() { + let other = tempfile::tempdir().unwrap(); + let other_site = fake_venv(other.path(), "tool-venv"); + let other_env = other + .path() + .join("tool-venv") + .to_string_lossy() + .into_owned(); + + for opt_out in [ + ("PIPENV_IGNORE_VIRTUALENVS", "1"), + ("PIPENV_IGNORE_VIRTUALENVS", "true"), + ("PIPENV_IGNORE_VIRTUALENVS", "anything"), + ("PIPENV_NO_IGNORE_VIRTUALENVS", "0"), + ("PIPENV_ACTIVE", "1"), + ("PIPENV_ACTIVE", ""), + ] { + let (_tmp, project, site, var) = pipenv_project_with_workon_venv(&[ + ("VIRTUAL_ENV", other_env.clone()), + (opt_out.0, opt_out.1.to_string()), + ]); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![site], + "{}={:?} must send discovery to Pipenv's own venv", + opt_out.0, + opt_out.1 + ); + } + + // Opt-out set but Pipenv has no venv yet: still never the activated + // venv (Pipenv would create a new one, not reuse it). + let (_tmp, project, site, var) = pipenv_project_with_workon_venv(&[ + ("VIRTUAL_ENV", other_env.clone()), + ("PIPENV_IGNORE_VIRTUALENVS", "1".to_string()), + ]); + std::fs::remove_dir_all(site.ancestors().nth(3).unwrap()).unwrap(); + assert!(!find_local_venv_site_packages_with(&project, &var) + .await + .contains(&other_site)); + + // Controls: without an opt-out (or with a falsy one) Pipenv does use + // VIRTUAL_ENV, and a non-Pipenv project always does. + for extra in [ + vec![], + vec![("PIPENV_IGNORE_VIRTUALENVS", "0".to_string())], + vec![("PIPENV_NO_IGNORE_VIRTUALENVS", "1".to_string())], + ] { + let mut env = vec![("VIRTUAL_ENV", other_env.clone())]; + env.extend(extra); + let (_tmp, project, _site, var) = pipenv_project_with_workon_venv(&env); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![other_site.clone()] + ); + } + let (tmp, _project, _site, var) = pipenv_project_with_workon_venv(&[ + ("VIRTUAL_ENV", other_env.clone()), + ("PIPENV_IGNORE_VIRTUALENVS", "1".to_string()), + ]); + let plain = tmp.path().join("plain"); + std::fs::create_dir_all(&plain).unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&plain, &var).await, + vec![other_site.clone()] + ); + } + + /// #334: Pipenv never uses a `venv/` directory, so a stray one must not + /// shadow the WORKON_HOME venv Pipenv installed into. + #[tokio::test] + async fn pipenv_stray_venv_dir_does_not_shadow_the_workon_home_venv() { + let (_tmp, project, site, var) = pipenv_project_with_workon_venv(&[]); + let _stray = fake_venv(&project, "venv"); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![site] + ); + } + + /// #334: an explicit "not in project" (`PIPENV_VENV_IN_PROJECT` falsy, + /// `PIPENV_NO_VENV_IN_PROJECT` truthy, or Pipenv 2026.2+'s Pipfile + /// `[pipenv] venv_in_project = false`) makes Pipenv ignore a `./.venv` + /// directory. An explicit "in project" makes it use `./.venv` only, and + /// the environment variable beats the Pipfile. + #[tokio::test] + async fn pipenv_venv_in_project_settings_decide_about_dot_venv() { + for env in [ + ("PIPENV_VENV_IN_PROJECT", "0"), + ("PIPENV_VENV_IN_PROJECT", "false"), + ("PIPENV_VENV_IN_PROJECT", "Off"), + ("PIPENV_NO_VENV_IN_PROJECT", "1"), + ] { + let (_tmp, project, site, var) = + pipenv_project_with_workon_venv(&[(env.0, env.1.to_string())]); + let _dot = fake_venv(&project, ".venv"); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![site], + "{}={:?} must skip ./.venv", + env.0, + env.1 + ); + } + + let (_tmp, project, site, var) = pipenv_project_with_workon_venv(&[]); + let dot = fake_venv(&project, ".venv"); + std::fs::write( + project.join("Pipfile"), + "[packages]\nsix = \"==1.16.0\"\n\n[pipenv]\nvenv_in_project = false\n", + ) + .unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![site.clone()], + "Pipfile venv_in_project = false must skip ./.venv" + ); + std::fs::write( + project.join("Pipfile"), + "[packages]\nsix = \"==1.16.0\"\n\n[pipenv]\nvenv_in_project = true\n", + ) + .unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![dot.clone()], + "Pipfile venv_in_project = true must use ./.venv only" + ); + + for env in [ + ("PIPENV_VENV_IN_PROJECT", "1"), + ("PIPENV_NO_VENV_IN_PROJECT", "0"), + ] { + let (_tmp, project, _site, var) = + pipenv_project_with_workon_venv(&[(env.0, env.1.to_string())]); + let dot = fake_venv(&project, ".venv"); + std::fs::write( + project.join("Pipfile"), + "[packages]\n\n[pipenv]\nvenv_in_project = false\n", + ) + .unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![dot], + "{}={:?} beats the Pipfile and uses ./.venv only", + env.0, + env.1 + ); + } + } + + /// #334: with nothing explicit, Pipenv up to 2026.1 uses an existing + /// `./.venv` directory, while 2026.2+ prefers a WORKON_HOME venv that + /// already exists. The crawler cannot tell the versions apart without + /// running Pipenv, so it returns both (Pipenv 2026.2+'s choice first), + /// and either version's venv is patched. With only one of them present, + /// that one is the answer. + #[tokio::test] + async fn pipenv_auto_detected_dot_venv_and_workon_home_venv_are_both_returned() { + let (_tmp, project, site, var) = pipenv_project_with_workon_venv(&[]); + let dot = fake_venv(&project, ".venv"); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![site.clone(), dot.clone()] + ); + std::fs::remove_dir_all(site.ancestors().nth(3).unwrap()).unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![dot] + ); + } + #[tokio::test] async fn pipenv_case_insensitive_fallback_matches_recased_directory() { // Pipenv on a case-insensitive filesystem reuses `-` diff --git a/docs/testing/pipenv-compatibility.md b/docs/testing/pipenv-compatibility.md index d758b72a0..0a3b2a165 100644 --- a/docs/testing/pipenv-compatibility.md +++ b/docs/testing/pipenv-compatibility.md @@ -17,7 +17,7 @@ requirements.txt lanes of the same ecosystem. | Input | Hosted | Vendored | Agent | |-------|--------|----------|-------| -| `Pipfile.lock`, `pipfile-spec: 6` (Pipenv 7 and later) | Every category (`default`, `develop`, Pipenv 2022+ named categories) that pins the patched release becomes `{"file" \| "path": "#sha256=", "hashes": ["sha256:"]}` with `markers`/`extras` preserved and `version`/`index` dropped. `path` for Pipenv 7–11, `file` from 2018. `_meta` (the Pipfile content hash) and the Pipfile are untouched. | Every matching category refers to the committed wheel under `.socket/vendor/pypi//`; wheels with extras use `path` (Pipenv 2022's file-URL bug). Requires Pipenv 2018 or later (`pypi_pipenv_installer_unsupported`). | Independent of the lock: patches the installed distribution in the project's venv — in-project `.venv`, `VIRTUAL_ENV`, or Pipenv's default `$WORKON_HOME/-[-]` (discovered without running Pipenv). | +| `Pipfile.lock`, `pipfile-spec: 6` (Pipenv 7 and later) | Every category (`default`, `develop`, Pipenv 2022+ named categories) that pins the patched release becomes `{"file" \| "path": "#sha256=", "hashes": ["sha256:"]}` with `markers`/`extras` preserved and `version`/`index` dropped. `path` for Pipenv 7–11, `file` from 2018. `_meta` (the Pipfile content hash) and the Pipfile are untouched. | Every matching category refers to the committed wheel under `.socket/vendor/pypi//`; wheels with extras use `path` (Pipenv 2022's file-URL bug). Requires Pipenv 2018 or later (`pypi_pipenv_installer_unsupported`). | Independent of the lock: patches the installed distribution in the venv Pipenv resolves for the project — `VIRTUAL_ENV` unless `PIPENV_ACTIVE` / `PIPENV_IGNORE_VIRTUALENVS` is set, in-project `.venv` subject to `PIPENV_VENV_IN_PROJECT` and the Pipfile's `[pipenv] venv_in_project`, or Pipenv's default `$WORKON_HOME/-[-]`; never `venv/` (discovered without running Pipenv). With an auto-detected `.venv` and an existing WORKON_HOME venv, both are patched, since Pipenv 2026.2+ uses the WORKON_HOME venv and older releases use `.venv`. | | `Pipfile.lock`, `pipfile-spec` < 6 (Pipenv 0–6) | Refused (`redirect_pipenv_skipped`), lock untouched. | Refused (`pypi_pipenv_spec_unsupported`). | Works. | | Lock-only checkout (nothing installed) | Discovered from the lock and redirected. | Discovered from the lock; the pristine wheel is fetched by one of the lock's recorded digests (Pipenv records every release file's sha256 without filenames) through PyPI's JSON API, verified against the same digest, and the patched wheel comes from the service. | Nothing to patch (no installed distribution); the lock's pins are listed as lockfile-only packages. | From 310ea826723911c5429fb3cd231c5f9c15c6a54c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:22:58 +0000 Subject: [PATCH 3/4] Don't block on a FIFO Pipfile in venv discovery The Pipenv venv lookup reads the Pipfile's [pipenv] venv_in_project key. A Pipfile.lock alone marks the project, so a FIFO or device at Pipfile could be opened and wedge scan and apply. Read it with the module's non-blocking, regular-files-only helper instead. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/python_crawler.rs | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index ca6a2b25e..93a407243 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -380,7 +380,9 @@ fn pipenv_venv_in_project(cwd: &Path, var: &impl Fn(&str) -> Option) -> Some(Err(text)) if !text.is_empty() => return Some(true), _ => {} } - let text = std::fs::read_to_string(cwd.join("Pipfile")).ok()?; + // Non-blocking, regular-files-only read: a FIFO `Pipfile` (a lock alone + // marks the project) must not wedge discovery. + let text = read_regular_to_string_sync(&cwd.join("Pipfile")).ok()?; let doc = text.parse::().ok()?; let value = doc.get("pipenv")?.get("venv_in_project")?.as_value()?; // Python's `bool(value)` for the scalar shapes a Pipfile can hold. @@ -2278,6 +2280,40 @@ mod tests { } } + /// A `Pipfile.lock` alone marks a Pipenv project, so a FIFO `Pipfile` + /// beside it reaches the `[pipenv] venv_in_project` lookup, which must + /// not block on it. + #[cfg(unix)] + #[tokio::test] + async fn pipenv_discovery_does_not_block_on_fifo_pipfile() { + let (_tmp, project, site, var) = pipenv_project_with_workon_venv(&[]); + let _dot = fake_venv(&project, ".venv"); + std::fs::remove_file(project.join("Pipfile")).unwrap(); + std::fs::write(project.join("Pipfile.lock"), "{}").unwrap(); + let fifo = project.join("Pipfile"); + assert!(tokio::process::Command::new("mkfifo") + .arg(&fifo) + .status() + .await + .unwrap() + .success()); + let result = tokio::time::timeout( + std::time::Duration::from_secs(2), + find_local_venv_site_packages_with(&project, &var), + ) + .await; + if result.is_err() { + let release = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&fifo) + .unwrap(); + drop(release); + } + let found = result.expect("discovery blocked on a FIFO Pipfile"); + assert_eq!(found.first(), Some(&site)); + } + // ── Poetry out-of-tree virtualenv discovery ───────────────────────────── /// Known-answer vectors computed with Poetry's own algorithm From 38d67b0ba270560e72f819743326f7023c5d001c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 05:23:35 +0000 Subject: [PATCH 4/4] Stop Pipenv projects falling back to venvs Pipenv never uses When Pipenv had no venv yet, discovery fell through to the generic ./.venv and ./venv probes. That picked a stray venv/ (which no Pipenv release uses) or a ./.venv an explicit PIPENV_VENV_IN_PROJECT=0 rules out, so scan/apply patched a leftover tree and exited 0. A Pipenv project now returns only what Pipenv resolves. Co-Authored-By: Claude --- .../src/crawlers/python_crawler.rs | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index e306cbebf..fa760f45a 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -268,7 +268,7 @@ async fn find_site_packages_under( /// 1. `VIRTUAL_ENV` environment variable (for a Pipenv project, only when /// Pipenv itself would use it) /// 2. For a Pipenv project, the venv(s) Pipenv resolves for it (see -/// [`pipenv_project_site_packages`]) +/// [`pipenv_project_site_packages`]), and nothing else /// 3. `.venv` directory in `cwd` /// 4. `venv` directory in `cwd` /// 5. Poetry's out-of-tree virtualenv(s) for a Poetry project (see @@ -304,13 +304,11 @@ async fn find_local_venv_site_packages_with( // 2. A Pipenv project's venv is whatever Pipenv resolves, which is not // the generic probe order below: Pipenv never uses `venv/`, and its - // in-project settings can rule out an existing `./.venv`. Only when - // Pipenv's venv does not exist yet do the generic probes run. + // in-project settings can rule out an existing `./.venv`. When Pipenv + // has no venv yet there is nothing to patch, so the generic probes must + // not fall back to a tree Pipenv will never use. if pipenv { - let found = pipenv_project_site_packages(cwd, var).await; - if !found.is_empty() { - return found; - } + return pipenv_project_site_packages(cwd, var).await; } // 3. Check .venv and venv in cwd @@ -2107,6 +2105,27 @@ mod tests { ); } + /// #334: when Pipenv has no venv yet, discovery must not fall back to a + /// tree Pipenv will never use: a stray `venv/`, or a `./.venv` that an + /// explicit "not in project" rules out. + #[tokio::test] + async fn pipenv_without_its_venv_does_not_fall_back_to_stray_trees() { + let (_tmp, project, site, var) = pipenv_project_with_workon_venv(&[]); + std::fs::remove_dir_all(site.ancestors().nth(3).unwrap()).unwrap(); + let _stray = fake_venv(&project, "venv"); + assert!(find_local_venv_site_packages_with(&project, &var) + .await + .is_empty()); + + let (_tmp, project, site, var) = + pipenv_project_with_workon_venv(&[("PIPENV_VENV_IN_PROJECT", "0".to_string())]); + std::fs::remove_dir_all(site.ancestors().nth(3).unwrap()).unwrap(); + let _dot = fake_venv(&project, ".venv"); + assert!(find_local_venv_site_packages_with(&project, &var) + .await + .is_empty()); + } + /// #334: an explicit "not in project" (`PIPENV_VENV_IN_PROJECT` falsy, /// `PIPENV_NO_VENV_IN_PROJECT` truthy, or Pipenv 2026.2+'s Pipfile /// `[pipenv] venv_in_project = false`) makes Pipenv ignore a `./.venv`