Skip to content
Open
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
100 changes: 100 additions & 0 deletions crates/socket-patch-cli/tests/in_process_python_envs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tmp>/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<socket_patch_cli::commands::scan::ScanMode>,
) -> (i32, Vec<String>) {
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
Expand Down
9 changes: 4 additions & 5 deletions crates/socket-patch-core/src/crawlers/gradle_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()),
Expand Down
10 changes: 9 additions & 1 deletion crates/socket-patch-core/src/crawlers/python_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand All @@ -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);
}
Expand Down
7 changes: 2 additions & 5 deletions crates/socket-patch-core/src/patch/jvm_jar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
}

/// `<socket_dir>/jvm-originals/<sha256>.jar`.
Expand Down
4 changes: 1 addition & 3 deletions crates/socket-patch-core/src/patch/sidecars/maven.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@

use std::path::{Path, PathBuf};

use sha1::Digest as _;

use super::{
SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction,
SidecarPayload, SidecarSeverity,
Expand All @@ -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)),
}
}
Expand Down
118 changes: 70 additions & 48 deletions crates/socket-patch-core/tests/crawler_python_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading