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 de713bd7b..c2bf98f5b 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -712,6 +712,106 @@ async fn pipenv_dotenv_settings_pick_the_scanned_venv() { assert_not_discovered(&batch_bodies(&server).await, "pkg:pypi/pipenv-pkg@1.0.0"); } +/// A Pipenv project at `/proj` (Pipfile + Pipfile.lock locking +/// `urllib3 1.26.18`) with no Pipenv venv yet, under a stubbed HOME whose +/// conda root holds `system_decoy 6.6.6` (a package the global crawler +/// would find in the OS Python). Returns `(tmp, project, home)`. +fn pipenv_project_without_venv() -> (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"), + include_str!("../../socket-patch-core/tests/fixtures/pipenv/2026.8.0/Pipfile"), + ) + .unwrap(); + std::fs::write( + project.join("Pipfile.lock"), + include_str!("../../socket-patch-core/tests/fixtures/pipenv/2026.8.0/Pipfile.lock"), + ) + .unwrap(); + let home = tmp.path().join("home"); + let system = home + .join("anaconda3") + .join("lib") + .join("python3.11") + .join("site-packages"); + std::fs::create_dir_all(&system).unwrap(); + write_dist_info(&system, "system_decoy", "6.6.6"); + std::fs::create_dir_all(tmp.path().join("wh")).unwrap(); + (tmp, project, home) +} + +/// Run `scan` from a Pipenv project with an empty WORKON_HOME and HOME +/// stubbed to `home`, and return `(exit code, batch bodies)`. +async fn scan_pipenv_without_venv( + project: &Path, + home: &Path, + mode: Option, +) -> (i32, Vec) { + let server = MockServer::start().await; + mock_batch_empty(&server).await; + let prev_home = std::env::var_os("HOME"); + let prev_profile = std::env::var_os("USERPROFILE"); + std::env::set_var("HOME", home); + std::env::set_var("USERPROFILE", home); + let workon = project.parent().unwrap().join("wh"); + let mut args = default_args(project, server.uri()); + args.mode = mode; + let code = scan_with_pipenv_env(args, &[("WORKON_HOME", &workon)]).await; + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match prev_profile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + (code, batch_bodies(&server).await) +} + +/// #504: a Pipenv project with no Pipenv venv has nothing installed for +/// it. A project-scoped scan must not fall back to the OS Python's +/// site-packages (which agent mode would then patch in place), whether or +/// not a `venv/` Pipenv never uses sits in the project. +#[tokio::test] +#[serial] +async fn pipenv_without_a_venv_never_scans_the_system_python() { + for with_stray_venv in [false, true] { + let (_tmp, project, home) = pipenv_project_without_venv(); + if with_stray_venv { + let stray = venv_site_packages(&project.join("venv"), "python3.12"); + std::fs::create_dir_all(&stray).unwrap(); + write_dist_info(&stray, "stray_decoy", "6.6.6"); + } + let (code, bodies) = scan_pipenv_without_venv( + &project, + &home, + Some(socket_patch_cli::commands::scan::ScanMode::Agent), + ) + .await; + assert_eq!(code, 0, "stray venv/: {with_stray_venv}"); + assert_not_discovered(&bodies, "pkg:pypi/system-decoy@6.6.6"); + assert_not_discovered(&bodies, "pkg:pypi/stray-decoy@6.6.6"); + } +} + +/// #947: a vendored (or hosted) scan of a fresh Pipenv checkout takes its +/// candidates from Pipfile.lock alone; a package that exists only in the +/// OS Python is not the project's and must never reach the patch query. +#[tokio::test] +#[serial] +async fn pipenv_fresh_checkout_candidates_come_from_the_lock_only() { + use socket_patch_cli::commands::scan::ScanMode; + for mode in [ScanMode::Vendored, ScanMode::Hosted] { + let (_tmp, project, home) = pipenv_project_without_venv(); + let (code, bodies) = scan_pipenv_without_venv(&project, &home, Some(mode)).await; + assert_eq!(code, 0, "{mode:?}"); + assert_discovered(&bodies, "pkg:pypi/urllib3@1.26.18"); + assert_not_discovered(&bodies, "pkg:pypi/system-decoy@6.6.6"); + } +} + // --------------------------------------------------------------------------- // Package-manager-recorded envs: PDM's saved interpreter / PEP 582, and uv's // UV_PROJECT_ENVIRONMENT, ahead of a stray `./.venv` the manager never uses diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 7019e8945..062dcdc9d 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -3022,7 +3022,8 @@ impl PythonCrawler { /// `.venv`, and `venv` directories, then Poetry's and Pipenv's /// out-of-tree virtualenvs. /// 2. If no venv was found AND the cwd looks like a Python - /// project (see `is_python_project`), fall through + /// project (see `is_python_project`) that is not a Pipenv + /// project (whose env is only ever Pipenv's own), fall through /// to `get_global_python_site_packages`. This mirrors the /// cargo / ruby / go pattern where a project marker /// indicates "scan this ecosystem globally for this project". @@ -3044,6 +3045,13 @@ impl PythonCrawler { if !venv_paths.is_empty() { return Ok(venv_paths); } + // A Pipenv project's env is only ever the one Pipenv resolves for it + // (see `pipenv_project_site_packages`). With none yet, nothing is + // installed for the project, and its lock-only packages come from + // `Pipfile.lock`; the OS Python is never its env (#504, #947). + if is_pipenv_project(&options.cwd) { + return Ok(Vec::new()); + } if is_python_project(&options.cwd).await { return Ok(get_global_python_site_packages().await); } diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } diff --git a/crates/socket-patch-core/tests/crawler_python_e2e.rs b/crates/socket-patch-core/tests/crawler_python_e2e.rs index f61a14cd3..9bfe6b524 100644 --- a/crates/socket-patch-core/tests/crawler_python_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_python_e2e.rs @@ -1188,59 +1188,81 @@ async fn get_site_packages_paths_falls_back_via_uv_lock_marker() { let _ = (result, staged); } -/// A pipenv-managed project ships `Pipfile`/`Pipfile.lock` and commonly has -/// NO pyproject.toml / setup.py / requirements.txt — the marker list must -/// include it or a fresh clone (pipenv keeps its venvs out-of-tree under -/// `~/.local/share/virtualenvs`) returns zero packages via the no-marker -/// early-out. The vendor layer already treats `Pipfile.lock` as a -/// first-class pypi flavor; discovery must agree. +/// #504 / #947: a Pipenv project's env is the one Pipenv resolves for it +/// (#388). With no Pipenv venv yet nothing is installed for the project, and +/// its lock-only packages come from `Pipfile.lock`, so a project-scoped crawl +/// must return nothing rather than fall back to the global interpreters +/// (which agent mode would patch in place, and vendored mode would try to +/// vendor). Holds for a `Pipfile`, a lone `Pipfile.lock`, and a `venv/` +/// Pipenv never uses. #[tokio::test] #[serial] -async fn get_site_packages_paths_falls_back_via_pipfile_marker() { - let project = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - tokio::fs::write( - project.path().join("Pipfile"), - b"[packages]\nrequests = \"*\"\n", - ) - .await - .unwrap(); +async fn get_site_packages_paths_pipenv_without_venv_never_falls_back_to_global() { + for (marker, body, stray_venv) in [ + ("Pipfile", "[packages]\nsix = \"*\"\n", false), + ( + "Pipfile.lock", + "{\"default\": {}, \"develop\": {}}\n", + false, + ), + ("Pipfile", "[packages]\nsix = \"*\"\n", true), + ] { + let project = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let workon = tempfile::tempdir().unwrap(); + tokio::fs::write(project.path().join(marker), body) + .await + .unwrap(); + if stray_venv { + let stray = project + .path() + .join("venv") + .join("lib") + .join("python3.11") + .join("site-packages"); + tokio::fs::create_dir_all(&stray).await.unwrap(); + } - // Stage an anaconda3 layout under the stubbed HOME — scanned by global - // discovery on every platform, so this test needs no per-OS forks. - let staged = home - .path() - .join("anaconda3") - .join("lib") - .join("python3.11") - .join("site-packages"); - tokio::fs::create_dir_all(&staged).await.unwrap(); + // Stage an anaconda3 layout under the stubbed HOME: global discovery + // scans it on every platform, so seeing it means the fallback ran. + let staged = home + .path() + .join("anaconda3") + .join("lib") + .join("python3.11") + .join("site-packages"); + tokio::fs::create_dir_all(&staged).await.unwrap(); + + let prev_virtual_env = std::env::var("VIRTUAL_ENV").ok(); + std::env::remove_var("VIRTUAL_ENV"); + let prev_workon = std::env::var("WORKON_HOME").ok(); + std::env::set_var("WORKON_HOME", workon.path()); + let prev_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", home.path()); + let crawler = PythonCrawler; + let opts = CrawlerOptions { + cwd: project.path().to_path_buf(), + global: false, + global_prefix: None, + }; + let result = crawler.get_site_packages_paths(&opts).await.unwrap(); + if let Some(v) = prev_home { + std::env::set_var("HOME", v); + } + match prev_workon { + Some(v) => std::env::set_var("WORKON_HOME", v), + None => std::env::remove_var("WORKON_HOME"), + } + if let Some(v) = prev_virtual_env { + std::env::set_var("VIRTUAL_ENV", v); + } - let prev_virtual_env = std::env::var("VIRTUAL_ENV").ok(); - std::env::remove_var("VIRTUAL_ENV"); - let prev_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", home.path()); - let crawler = PythonCrawler; - let opts = CrawlerOptions { - cwd: project.path().to_path_buf(), - global: false, - global_prefix: None, - }; - let result = crawler.get_site_packages_paths(&opts).await.unwrap(); - if let Some(v) = prev_home { - std::env::set_var("HOME", v); - } - if let Some(v) = prev_virtual_env { - std::env::set_var("VIRTUAL_ENV", v); + assert!( + result.is_empty(), + "{marker} (stray venv/: {stray_venv}) must not fall back to the \ + global site-packages; got {result:?}" + ); } - - #[cfg(not(windows))] - assert!( - result.iter().any(|p| p == &staged), - "Pipfile marker must trigger global fallback; got {result:?}" - ); - #[cfg(windows)] - let _ = (result, staged); } /// Without any Python-project marker AND without a venv, local-mode